Resolves the four conditions on the approved-feature verdict for #3646: new execute:task granularity tier below wave, hard-halt enforced via a code-side resolver seam (Lens B) rather than prose dispatch (since #3647's dispatch-reliability defect is still open), registration/validation requirements for loop-hook-dispatch.md + capability-validator.cjs, and autonomous-mode behavior via a new non-gate kind. Closes #3969 Co-authored-by: sim <sim@local>
14 KiB
ADR-3646: Per-task external-tracker content-resolution seam
| Status | Proposed |
| Date | 2026-08-27 |
| Issue | #3646 |
| Phase-0 sub-issue | #3969 |
| Implementation phase | #3970 |
| Related | #3554 (generic ship:pre gate/step dispatch — same class of gap, independent) · #3647 (lifecycle-dispatch steps intermittently skipped) · #3606 (validator checks hook existence, not dispatch — CLOSED) |
| Reference | loop-hook-dispatch.md, autonomous-ui-design-contract.md |
Context
gsd-core/workflows/execute-plan.md's <step name="execute"> reads every task's instructions
(<read_first>, <action>, <verify>, <acceptance_criteria>, <done>, etc.) directly out of
the PLAN.md task block. There is no seam for a capability to resolve that content from an
external issue tracker (beads, Linear, Jira, GitHub Issues, …) by an id carried in the task block
instead. A project that wants the tracker to own task content — not just task status — has to
patch the workflow file locally. #3646 asks for a native, opt-in seam.
The Feature Review Report on #3646 approved this go-with-conditions, with four conditions this
ADR exists to resolve before any implementation lands.
The existing extension-point system
gsd-core/references/loop-hook-dispatch.md defines the dispatch contract consumed by
gsd_run loop render-hooks <point>. Twelve points exist today, all phase- or wave-scoped:
discuss:pre, discuss:post, plan:pre, plan:post, execute:pre, execute:wave:pre,
execute:wave:post, execute:post, verify:pre, verify:post, ship:pre, ship:post. A
search for task:pre / task:post / execute:task across the tree returns nothing — the finest
granularity in the system is the wave, one level coarser than what #3646 asks for.
Each point's activeHooks array carries entries of kind: "contribution" | "step" | "gate",
dispatched by prose instructions in the consuming workflow file: step invokes a Skill/Agent/
command; gate evaluates a check and blocks or advises depending on blocking. Both kinds are
natural-language instructions inside a markdown workflow document, executed by whichever LLM
is driving the loop.
Why that mechanism cannot deliver this feature's safety property
The seam's own stated purpose is a hard halt on an unresolvable id — deliberately, because a
silent fall-back to inline PLAN.md text would require the tracker and PLAN.md to stay in sync
forever, defeating the point of moving content out of PLAN.md.
But #3647 (filed the same day as #3646, still open) reports that lifecycle-dispatch steps are intermittently skipped in real usage — 1 of 4 wave-close dispatches observed firing. #3606 (closed) documented a related but distinct failure: the capability validator checked that a hook call site existed, not that it dispatched, so a wired-and-enabled hook could be declared and silently never run; that specific validator gap is now closed, but #3647's broader dispatch- reliability problem is not.
A per-task point built as a step/gate entry in activeHooks, dispatched the same way the
twelve existing points are, inherits that reliability profile. Per-task dispatch multiplies the
number of dispatch opportunities by the task count — a plan with 20 tasks creates 20 chances for
the dispatch to silently not fire, instead of the 1-per-wave exposure the existing points carry.
When a per-task dispatch does not fire, nothing errors: the executor simply proceeds to read the
task's inline PLAN.md body. That is exactly branch 3 of the proposal (the legitimate
pre-migration fall-back for tasks authored before a tracker migration) — a missed dispatch and a
genuine pre-migration task are indistinguishable at the point of failure, so the executor edits
stale content while believing it authoritative. This reproduces the exact hazard the proposal's
own hard-halt requirement exists to prevent.
Decision
1. Granularity tier
This ADR adds a thirteenth extension point, at a new tier below wave: execute:task. It sits
inside the existing execute:wave:pre / execute:wave:post bracket, evaluated once per task,
immediately before that task's read_first gate (execute-plan.md:221) — the read_first list
is itself a field this point can resolve, so a point evaluated after read_first would gate on
content that has not been fetched yet.
execute:task is not registered as a contribution / step / gate entry in
loop-hook-dispatch.md's existing kind vocabulary. Decision 2 explains why a new kind is
required rather than reusing one of the three.
Ordering within one task: execute:task → read_first gate → task body (type="auto" /
"tracer" / "checkpoint:*") → acceptance_criteria hard gate. A checkpoint:* task never
enters execute:task — its interactive structure (options, pros/cons, resume-signal) is
irreducibly a human-facing prose step and stays sourced from PLAN.md. Plan-level sections
(<objective>, <context>, <verification>, <success_criteria>) are out of scope for this
point; load_prompt's whole-file read is unchanged.
2. Hard-halt enforcement: Lens B — a code-side resolution seam
Chosen over Lens A (a prose step/gate dispatched the same way as the twelve existing
points), for the reason argued in Context: #3647 is open, so any step/gate-shaped
implementation inherits a live, unresolved reliability defect and cannot deliver the feature's own
safety property. The Feature Review Report's Lens B recommendation is adopted as-is; Lens A is
rejected outright rather than sequenced behind #3647, because sequencing behind an open reliability
issue with no committed fix date blocks #3646 indefinitely on someone else's timeline for no
architectural gain — Lens B is buildable today and is strictly the safer shape regardless of
whether #3647 is ever fixed.
execute:task resolution is a real subprocess invocation with a real exit code, not an LLM
instruction:
gsd_run task resolve-content --plan "<PLAN.md path>" --task-id "<task id>" --raw
Contract:
- No capability registers a task-content resolver for this plan → the command exits
0with{"resolved": false}. The workflow proceeds to read the task's inlinePLAN.mdbody exactly as it does today. This is the unconditional default for every existing project — the seam is additive and opt-in. - A capability registers a resolver and the resolution fails (tracker unreachable, id not
found, malformed response) → the command exits non-zero.
execute-plan.md'sexecutestep treats any non-zero exit fromtask resolve-contentas a hard halt for that task: surface the id, the tracker, and the raw error, and stop. This is enforced by the same mechanism every othergsd_runsubcommand's failure is enforced by (src/cli-exit.cts'srunMain/ExitErrorseam — see ADR-3889) — a real process exit code the calling loop cannot fail to observe the way it can fail to execute a prose instruction. - A capability registers a resolver and resolution succeeds with non-empty content → exits
0with{"resolved": true, "content": {...}}; that payload's fields stand in for the task's<read_first>/<action>/<verify>/<acceptance_criteria>/<done>PLAN.md elements for every downstream gate in theexecutestep (theread_firstMANDATORY gate, theacceptance_criteriaHARD GATE,<verify>/<done>checks). - A capability registers a resolver and resolution succeeds with empty/absent content → exits
0with{"resolved": false, "reason": "empty"}— the documented pre-migration boundary case; the workflow falls back to the inlinePLAN.mdbody. This is the only legitimate fall-back path, and it is now distinguishable from a missed dispatch: a missed dispatch cannot happen because there is no dispatch to miss — the resolver either runs (and reports which of the three outcomes above occurred) or is absent by construction (resolved: false, no resolver registered).
Resolver registration itself (how a capability declares "I own task-content resolution for this project") is implementation detail for #3970, constrained by Decision 3.
3. Registration and validation
execute:task is declared in gsd-core/references/loop-hook-dispatch.md in the same change that
implements it (#3970), as a new section documenting the kind: "resolver" shape (Decision 4) and
the gsd_run task resolve-content contract above — not as a new entry in the existing
contribution/step/gate dispatch-rules list, since it is dispatched differently (a required
subprocess call with a binding exit code, not a best-effort prose step). gsd-core/bin/lib/ capability-validator.cjs is extended in the same change to recognize a manifest declaring a
task-content resolver, so a capability that declares one but never wires the corresponding command
fails validation at install time rather than failing silently at first use — closing, for this new
point, the exact class of gap #3606 closed for the existing twelve.
4. Autonomous-mode behavior
gsd-core/references/autonomous-ui-design-contract.md:34 records that activeHooks entries with
kind == "gate" are silently ignored on the autonomous path, because autonomous mode is always
pipeline mode and has no blocking-gate UI to route through. execute:task is not a gate —
it is the new kind: "resolver" — so that silent-ignore rule does not apply to it and does not
need an exception carved out. execute:task runs identically in autonomous and interactive modes:
a required subprocess call whose exit code is either observed (halt) or falls through by design
(resolved: false). Autonomous mode has no discretion to skip it, because there is no discretion
built into a kind that autonomous mode's dispatcher does not special-case at all — the contract
is honored by omission, not by an added branch.
Rejected alternatives
- Lens A, unconditionally — matches the established
step/gatepattern, cheapest to build, reviewable in one PR. Rejected: cannot deliver the hard-halt guarantee while #3647 is open (see Context); multiplies dispatch-reliability exposure by task count. - Lens A, sequenced behind a fix to #3647 — would deliver the guarantee once #3647 lands.
Rejected: blocks #3646 on an unscoped, undated fix to a different issue, for no benefit over
Lens B, which needs no such dependency and is architecturally the more precise fit (a resolution
failure is a data-fetch failure, which is what process exit codes exist to signal — not a
workflow-routing decision, which is what the
step/gatevocabulary exists for). - Reusing
gatewithkind: "gate"andblocking: true— would at least be checked by the existing dispatch contract. Rejected:blocking: truegates still route through the autonomous silent-ignore rule atautonomous-ui-design-contract.md:34for other points sharing thatkind, and reusing the tag would require carving out a per-point exception to that rule rather than making the safety property structural. It also still depends on the LLM executing the dispatch instruction at all, which is the reliability problem this ADR exists to route around. - A per-task point that falls back to inline
PLAN.mdon ANY resolver error (matching branch-3 semantics for both failure and pre-migration) — rejected explicitly by the issue itself: it requires the tracker andPLAN.mdto stay in sync forever, defeating the purpose of the feature.
Consequences
execute-plan.md's per-task loop gains one new required call before theread_firstgate, for every task, on every plan — including plans with no resolver registered, where it is a fast, always-{"resolved":false}no-op. This is a small, unconditional per-task cost in exchange for the hard-halt guarantee being real.- A new
kind: "resolver"is added to the dispatch vocabulary alongsidecontribution/step/gate. Any future per-task or finer-grained point that needs a binding (not best-effort) outcome has a precedent to follow instead of inventing its own shape. capability-validator.cjsgrows a new manifest shape to validate, and every runtime's emitted copy ofexecute-plan.mdcarries the new step through the installer — this puts the change under the workflow-content gates (docs/INVENTORY.md, manifest regen,size:baseline) for #3970, not the doc-only exemption this ADR itself qualifies for.- The
beads/bdlocal patch referenced in #3646 becomes portable: its three branches (halt on fetch failure, non-empty description supersedes PLAN.md, empty description falls back) map directly onto the threegsd_run task resolve-contentoutcomes above, so #3970 can validate the new seam against that existing, already-proven-in-production patch.
Open questions for #3970
- Exact resolver-registration surface in the capability manifest schema (a new
contributionsentry shape, most likely — concrete design left to the implementation phase). - Whether
gsd_run task resolve-contentneeds a--dry-run/preview mode forgsd-plan-checkerto validate resolver reachability before execution starts, rather than only at first task.