Files
msd-core/gsd-core/references/loop-hook-dispatch.md
Tom Boucher dd4f179672 feat(#3970): per-task external-tracker content-resolution seam (#4000)
* feat(#3970): per-task external-tracker content-resolution seam

Implements ADR-3646 (Phase 1, #3970): a `<task tracker-id="...">` attribute
plus a new optional `taskContentResolver` capability-manifest field let a
capability resolve a task's action/verify/acceptance-criteria/read_first/done
content from an external issue tracker instead of PLAN.md's inline body.

- src/plan-document.cts: parses the `tracker-id` attribute into `PlanTask.trackerId`
- src/task-content-resolution.cts: new leaf module — split/find/build/resolve,
  with a hard-halt (throw) contract on ambiguous/failed/timeout/malformed
  resolution, never a silent fallback to possibly-stale inline text
- src/task-command-router.cts: new `task resolve-content --plan --task-id --raw`
  CLI verb wiring the module into a real process exit code
- gsd-core/bin/lib/capability-validator.cjs: validates the new
  `taskContentResolver` manifest field (feature-role only, cross-capability
  trackerPrefix uniqueness)
- gsd-core/workflows/execute-plan.md, gsd-core/references/loop-hook-dispatch.md,
  docs/reference/capability-manifest.md: wire the seam into the per-task loop
  and document it as a new `execute:task` point outside the existing
  contribution/step/gate vocabulary (unconditional in autonomous mode)

Closes #3970

* fix(#3970): gate checkpoint tasks out of content resolution, close trackerPrefix grammar parity gap, cover path-traversal guard

Standards/Spec code-review pass on the task-content-resolution seam (ADR-3646
Phase 1) found three defects:

1. execute-plan.md's task-content-resolution bullet fired on any
   tracker-id-bearing task with no check that it wasn't type="checkpoint:*",
   contradicting ADR-3646 Decision 1 (a checkpoint task must never enter
   resolve-content). plan-document.cts already parses trackerId: null
   unconditionally for checkpoint tasks; only the workflow prose needed the
   fix, so the bullet now explicitly excludes checkpoint tasks.

2. task-content-resolution.cts's parseResolverDeclaration accepted any
   non-empty trackerPrefix with no grammar check, while capability-
   validator.cjs's KEBAB_RE enforces kebab-case at install time — a
   Generative Fix Divergence gap. Added the same grammar (as a literal
   regex, documented as intentionally not shared across the .cts/.cjs build
   boundary) plus a parity test asserting the two surfaces agree across a
   valid/invalid trackerPrefix table.

3. task-command-router.cts's routeResolveContent path-traversal guard on
   --plan had zero test coverage. Added a test exercising a
   ../../../etc/passwit-shaped path and asserting the USAGE rejection names
   the offending path.

* fix(#3970): sanitize resolver diagnostics and cap resolver timeoutMs

Two findings caught by an isolated security-review pass on the task
content resolution seam:

- ResolverFailedError/ResolverMalformedOutputError embedded raw,
  unsanitized subprocess stderr/stdout (attacker/model-influenced via
  the tracker-id argv token) into .message. A hostile or buggy resolver
  could smuggle a newline plus a forged "Error: " line, or terminal
  escape sequences, into a diagnostic io.cjs's error() writes verbatim
  to stderr. Fixed at the constructor (task-content-resolution.cts) via
  io.cjs's existing formatDiagnosticToken(), so every caller of
  resolveTaskContent gets a safe .message by construction.

- capability-validator.cjs's validateTaskContentResolverFields had no
  upper bound on taskContentResolver.invoke.timeoutMs, letting a
  manifest declare an effectively unbounded value and defeat the
  "bounded subprocess" design intent. Added a 120000ms ceiling specific
  to this field, without touching the shared isPositiveIntegerMs()
  helper (still used unbounded by the reviewer lane's timeoutFloorMs
  and probe timeoutMs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#3970): fix gsd-test failures — stale prose allowlist line and stderr-vs-message assertion

gsd-test (remote dockerized matrix) came back red with 5 failures on this
PR; all five are real defects, fixed here.

- tests/no-bare-gsd-tools-command-position.test.cjs: PROSE_ALLOWLIST's
  execute-plan.md entry pointed at line 415, which ffc190df4's
  checkpoint-exclusion caveat (added near line 221) shifted down by one
  line. The actual "validated downstream by gsd-tools uat
  classify-coverage" descriptive mention now sits at line 416. Updated
  the allowlist entry's line number to match.

- tests/task-command-router-resolve-content.test.cjs: the path-traversal
  test asserted the outside-project-scope diagnostic against the thrown
  ExitError's own .message. io.cts's error() (ADR-3889) writes its
  human-readable message to fd 2 via writeAllSync and then throws a bare
  `new ExitError(1)` with no message argument — by design, so the
  exception carries no duplicate text and the thrown ExitError's message
  defaults to "process exit 1" (cli-exit.cts's ExitError constructor).
  Root cause was the test, not the source: task-command-router.cjs's
  outside-project-scope rejection already calls error() correctly and the
  diagnostic text is genuinely emitted, just on fd 2, not on the
  exception. Fixed the test to capture fd-2 writes (mirroring
  tests/estimate-calibrate.test.cjs's runCalibrateExpectError and this
  same file's own captureStdout for fd 1) and assert against the captured
  stderr text instead of err.message. This was masked locally because a
  manual `node -e` sanity check that only inspects the caught exception's
  .message cannot see what the real node:test run actually failed on.

Emitted-Drift-Ack-Growth: execute-plan.md — adds the ADR-3646 task-content-resolution bullet and checkpoint-exclusion caveat to the per-task execute loop; a real behavioral prose addition, not incidental bloat.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#3970): backfill changeset PR number (pr:0 -> pr:4000)

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:17:04 -04:00

6.2 KiB

Loop Hook Dispatch Contract

Generic reference for consuming the --raw JSON output of gsd_run loop render-hooks <point> in any host-loop workflow. This document is point-agnostic — it applies to every loop extension point (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).

Envelope shape

{
  "point": "discuss:pre",
  "activeHooks": [
    { "kind": "contribution", "into": "orchestrator", "fragment": { "inline": "..." } },
    { "kind": "step", "ref": { "skill": "my-skill" } },
    { "kind": "gate", "check": { "query": "..." }, "blocking": true, "onError": "skip" }
  ],
  "rendered": "..."
}

activeHooks is an array of enabled hook entries for the named point. It is empty (or absent) when no capability has registered an active hook at this point — treat that as a no-op.

Dispatch rules by kind

contribution

Inject fragment.inline verbatim into the context for the role named in into (e.g. orchestrator, planner). Do not paraphrase — the text is the product.

step

Dispatch the referenced unit. Exactly one of ref.skill, ref.agent, or ref.command is set.

  • ref.skill present → dispatch via the Skill tool with skill id gsd-<ref.skill>.

  • ref.agent present → dispatch via the Agent tool with subagent_type = ref.agent. Before dispatching an agent, print the canonical liveness banner so users know silence is expected and do not kill a healthy agent:

    ◆ Spawning <agent>... (runs in a subagent — no output until it returns; expected, not a freeze)
    
  • ref.command present → validate it IN-CONTEXT first, before any shell use. It comes from a capability manifest, which may be third-party. Check the value you read from activeHooks against ^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*)*$ yourself — never by pasting it into a shell command to be tested there, because a value carrying a quote, ;, `, $(, or a newline would terminate the assignment and run as its own statement before any shell-side check could execute. A value that fails is a malformed manifest: record a warning, skip that hook, continue to the next entry. Only a value that has passed is run, with the phase number appended:

    gsd_run ${ref.command} --phase "${PHASE_NUMBER}" --raw
    

Wait for the result before continuing to the next hook or the next step.

A step is advisory by construction: it never blocks or redirects the host workflow — that is what a gate is for. Each dispatch is best-effort; on error record a warning and continue, honoring onError.

A point whose workflow hand-rolls one kind does not implement this contract. Several host workflows historically matched a single hook (e.g. execute:post matched only ref.skill == "code-review"), so any other step registered there was declared and silently never run. When a workflow defers to this file, it dispatches every active step entry, not one shape of one.

gate

Validate check before any shell use. check.query and check.predicate come from a capability manifest, which may be third-party — and gates[].check is not one of the executable surfaces the install consent prompt discloses (hooks, command modules, mcpServers, reviewer lanes), so a capability can be consented to as declarative-only and still reach a shell through a gate. Check the query value you read from activeHooks against ^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*)*$ yourself, IN-CONTEXT — never by pasting it into a shell command to be tested there, because a value carrying a quote, ;, a `, $(, or a newline would terminate the assignment and run as its own statement before any shell-side check could execute. A value that fails is a malformed manifest: record a warning, route it per the gate's onError, and do not run it. Pass check.predicate as a single argv element for the same reason — never re-quote it into a shell string, where an apostrophe would close the literal. This is the identical requirement step → ref.command carries above; it was stated there and omitted here, which is the gap #3559 closed.

Evaluate check (one of query, predicate, or agentVerdict). Then honor blocking:

  • blocking: true → if the check returns block: true, surface check.message to the user and stop the current step. Do not continue.
  • blocking: false → advisory only; surface the message but continue regardless of outcome.

Honor onError if the check itself errors: skip means treat as non-blocking and continue; halt means surface the error and stop.

Empty / absent activeHooks

If activeHooks is absent, null, or an empty array, skip silently and continue to the next step in the workflow. No output to the user is needed.

The execute:task point (a different shape)

execute:task exists below wave granularity — it is evaluated once per task, inside the execute:wave:pre / execute:wave:post bracket, immediately before that task's read_first gate. It is not one of the 12 points documented above, does not appear in steps / contributions / gates, and is never dispatched through gsd_run loop render-hooks <point> or this file's activeHooks envelope.

Instead, a capability declares task-content resolution directly in its manifest body via taskContentResolver (trackerPrefix + a bounded invoke) — see Capability manifest reference. execute-plan.md's per-task loop calls gsd_run task resolve-content --plan <path> --task-id <tracker-id> --raw directly, an unconditional, required subprocess invocation with a real, binding exit code — never a prose-dispatched step/gate entry chosen from an activeHooks array.

This point always runs — there is no when config gate and no autonomous-mode elision. That is deliberate, not an oversight: the twelve points above are best-effort prose dispatch, which execute:task's hard-halt safety property cannot be built on top of (a missed dispatch is indistinguishable from a legitimate resolver-empty fallback). See ADR-3646 for the full rationale, including why a kind: "gate" shape was rejected outright.