Commit Graph

8 Commits

Author SHA1 Message Date
Tom Boucher
9dd240f634 fix(#3302): return per-agent worktree metadata from workflow scripts (#3450)
* fix(#3302): return per-agent worktree metadata from workflow scripts

* fix(#3302): link changeset fragment to pr 3450

---------

Co-authored-by: sim <sim@local>
2026-08-14 09:28:22 -04:00
Tom Boucher
9341d8b8d3 test(#3334): fold the workflow-dispatch & review-lane fix-* cluster — Wave 2 (#3342)
Folds 15 tests/fix-*.test.cjs regression files (191 test() blocks) into
their module's main suite, per the wave decomposition of #3315 (H3 of
epic #3053). 187 blocks land in 8 existing suites (4 exact-duplicate
cases dropped, documented inline); 4 blocks move via git mv into 2 new
suite files with no prior coverage to merge into. Zero production
behavior change.

Also tightens two H1 (#3313) ratchets that the fold's own file-count
reduction moved past their grace window, per the ratchets' documented
dual failure mode (a stale/too-loose baseline fails exactly like a
novel violation):
- lint-test-file-count.allowlist.json: removes the stale "audit" entry
  (folding fix-2766 into tests/uat.test.cjs drops that module back to
  its 2-file cap).
- lint-allow-test-rule-refs.ceiling.json: lowers maxFiles 314 -> 309,
  the real post-fold high-water mark (gsd-test's own repo-baseline
  test caught this — CI, not a human, found it).

Two orthogonal review passes (Standards+Spec code-review, isolated
security-review) found and this commit fixes two issues before push:
a genuinely-distinct #2287 test case (file-absent vs. file-present-
resolved) that a prior fold pass had wrongly dropped as a duplicate —
restored verbatim into tests/uat.test.cjs; and a missing same-line
allow-test-rule citation on the #2196 block in
tests/debug-session-management.test.cjs, added for consistency with
its sibling #2257 block.

lint-removed-but-needed also caught two stale doc references to the
now-folded-away fix-2285-claude-orchestration-wiring.test.cjs filename
(docs/adr/1143-claude-orchestration-capability.md,
gsd-core/references/execute-phase-response-language.md) — updated both
to point at tests/claude-orchestration.test.cjs, its new home.

Co-authored-by: sim <sim@local>
2026-08-10 20:51:40 -04:00
Tom Boucher
09477f925e fix(#2686): thread the resolved executor model into the Workflow backend (#2715)
* test(#2686): failing-first parity guard for Workflow-backend model threading

The Workflow backend emitted every agent() call with no model, so
model_overrides / model_policy / model_profile were silently inert on that path
while the inline path honored them (ADR-1411). Neither existing suite contained
the string 'model' at all.

The centrepiece derives BOTH sides from resolveModelInternal(cwd,'gsd-executor')
rather than hardcoding either, so it asserts backend parity rather than a fixed
string. Also covers: omit-on-inherit/empty (#2517), byte-identical output when
nothing resolves, the #2772/#2285 per-plan worktree gate, adversarial model ids
reaching the code generator, the #2285 composed seam, CLI config-defaulting, and
a fast-check round-trip property.

RED expected: no model key is emitted anywhere, and --executor-model does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* fix(#2686): thread the resolved executor model into the Workflow backend

The Workflow backend emitted every agent() call with no model at all, so
model_overrides / model_policy / model_profile_overrides / model_profile were
silently inert on that path while the inline path honored all of them. The model
was not dropped at the last step — it was absent from the whole seam:
agentOptions() took no model, EmitInput had no field to carry one, and
ResolveWaveDispatchInput (the #2285 seam the orchestrator actually calls) could
not forward one. The generated script asserted the parity it broke.

VERIFY-FIRST, which #2686 flags as the question that decides the fix: the
Workflow tool's agent() DOES accept a per-call model. Its documented signature is
  agent(prompt, opts?: { label?, phase?, schema?, model?, effort?, isolation?, agentType? })
so fix branch 1 applies and branch 2 (declare model routing unavailable) is ruled
out. ADR-1143:24's option enumeration omitting `model` is an incomplete
enumeration, not a decision to exclude it.

- agentOptions(p, executorModel) emits `model` only when it is a non-empty string
  that is not "inherit" (#2517: an empty model 404s on runtimes without native
  tier aliases). A non-string is a malformed config: omit, never throw.
- executorModel threaded through EmitInput and ResolveWaveDispatchInput.
- The CLI resolves gsd-executor from project config by DEFAULT rather than
  requiring a flag, reading the same source the inline path reads. An
  orchestrator that never learns about a new flag would otherwise silently keep
  the old bug. --executor-model exists only to pin/override.
- ADR-1411 provenance: the generated header now states which model was applied,
  or that none resolved and why. A fallback must be a visible value.

Compatibility: when nothing resolves, the emitted options object is byte-identical
to before, so every existing caller and assertion is unaffected.

Behavior change (Hyrum's Law): opted-in users move from session inheritance to the
catalog-resolved executor model. Adding a `model` key also changes agent() opts,
which invalidates the cached prefix of any in-flight resumeFromRunId run — a
one-time re-execution. Both disclosed in the changeset.

Fixes #2686

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* fix(#2686): reject script-breaking model ids and share the emit predicate

The isolated adversarial review found a BLOCKER in my own provenance comment,
proven by execution (the emitted script exited 42 from an injected statement).

U+2028/U+2029 are ECMAScript LineTerminators that END a `//` single-line comment
in EVERY engine — the ES2019 change legalized them inside string LITERALS only.
So quoteString (JSON.stringify) is sufficient for the `model: "..."` object
literal but NOT for the `// model: ...` provenance line I added: a raw U+2028 in
a model id closed the comment and made the rest of the line live top-level code.
The value is reachable from `.planning/config.json` (model_overrides /
model_policy), which `mapClaudeOverrideForRuntime` passes through verbatim on any
non-claude runtime — attacker-influenceable in a cloned repo.

`emitWorkflowScript` now rejects a string executorModel carrying any character in
UNSCRIPTABLE_CHAR_RE — the same class `isScriptableIdentifier` already applied to
phaseDir/runId, which is proof the codebase knew this hazard. Rejection is
ok:false with a reason rather than a silent drop, and resolveWaveDispatch maps an
emit failure to the inline backend WITH that reason, so the degradation is
visible. A non-string stays on the existing defensive path (omit, never throw) —
that is malformed config, not an injection attempt.

Also from the reviews:

- The predicate deciding "is this model emittable" was duplicated between the
  emission and the comment asserting it. Extracted to emittableModel() so a
  generated comment can never claim something the generator did not do — the
  exact failure class #2686 was filed for.
- That predicate now trims and lower-cases before comparing, closing a real
  #2517-class gap: " " and "INHERIT" were previously emitted verbatim.
- The adversarial test was pass-always against this very vulnerability — it
  asserted only that JSON.stringify appeared. Replaced with the real contract
  (rejection) plus an execution-level check that no LineTerminator survives into
  the comment. A raw U+2028 had also been committed into that test's fixture
  array where a tab was intended; both are now explicit \u escapes.
- optionsOf in the test was /\{[^}]*\}/, which truncated at any brace a generated
  model contained — silently not testing what it claimed. Now brace- and
  string-aware.

Stale-test corrections in tests/fix-2285-*: three assertions froze the exact
options literal `{ agentType: "gsd-executor" }`. The object legitimately gained
an optional additive `model` key, so they now assert the invariant they exist to
protect (agentType present, isolation absent) rather than a frozen literal. The
CLI-vs-pure equality test pins --executor-model on both sides; otherwise it
compared a config-resolved CLI run against a pure call given no model.

CONTEXT.md glossary updated for the changed emitWorkflowScript signature and the
new rejection rule (CLAUDE.md: the glossary is a PR gate for core-module changes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* test(#2686): fix the options extractor and model the rejection path

Two defects in my own test helper, caught by the full matrix:

- optionsOf anchored on /\(\s*\{/ — a '(' immediately followed by '{'. The
  emitted shape is agent("brief", { ... }), so that never matched and the helper
  returned an empty array, making every assertion over it vacuously true. It now
  anchors on agent( and takes the first balanced, string-aware {...} after it.

- The fast-check property predated the security fix and asserted ok:true for any
  generated string. Strings carrying an unscriptable character are now rejected,
  so the property models the real three-way contract: unscriptable -> ok:false;
  trims to empty or 'inherit' (any case) -> omitted; otherwise -> emitted as the
  trimmed value.

Verified locally against the built module: omit values clean, both plans carry
the model on the parity path, property passes 500 runs at seed 42. Test file
re-scanned for raw hazardous codepoints — zero; the U+2028/U+2029 cases are
explicit \u escapes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* test(#2686): scope no-control-regex on the mirrored unscriptable-char class

The class is the point of the assertion — those bytes are exactly what must be
rejected — so the rule is disabled at that line rather than the class weakened.
UNSCRIPTABLE_CHAR_RE is not exported from src/claude-orchestration.cts, hence
the mirror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

* chore(#2686): backfill changeset PR number

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DPq9ovaovP2UvSVLjD4Lso

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 16:24:21 -04:00
Tom Boucher
0d08c32048 fix(#2590): emit Workflow scripts the Workflow tool accepts; make the backend reachable (#2681)
* fix(#2590): emit Workflow scripts the Workflow tool accepts; make the backend reachable

Every emitted script was rejected. Four invalid constructs, the first fatal on
its own, so the Workflow backend could never dispatch a wave:

  1. no `export const meta = {…}` first statement -> whole script rejected
  2. resumeFromRunId("<id>")  -> "resumeFromRunId is not defined". It is a
     Workflow TOOL INPUT parameter, not a script function. The run id still
     reaches the caller via summary.resumeRunId, to pass as that input.
  3. budget(<n>)              -> "budget is not a function". `budget` is a
     read-only object { total, spent(), remaining() } fed by the caller's token
     directive; a script cannot set it. Recorded as intent in a comment.
  4. parallel(agent(…), agent(…)) -> "parallel() expects an array of functions".
     Now parallel([() => agent(…), …]) — passing agent() results directly also
     started every agent eagerly, before parallel() could bound concurrency.

The single-plan stage had its own branch with the same parallel() defect; both
branches are now one array-emitting path. Waves also emit phase() calls whose
titles match meta.phases exactly, so progress groups correctly.

Two secondary defects kept the script from ever being REACHED — which is why
this shipped undetected:

  5. NOTHING resolved the Agent SDK version. The fragment claimed there was "no
     scriptable way" to introspect it and told callers to omit the flag, so
     gate 5 returned agent_sdk_version_unknown on every automated run while
     `capability state` still reported active:true. True for bash, false for
     Node: the router now reads the installed @anthropic-ai/claude-agent-sdk
     version, walking node_modules up the tree and reading package.json
     directly — require.resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED because the
     SDK's exports map does not expose ./package.json. Precedence: explicit flag
     > GSD_AGENT_SDK_VERSION > installed. Fail-closed is preserved; an
     unresolvable version still declines to inline. A too-old SDK now reports
     the truthful agent_sdk_version_below_floor instead of unknown.
  6. The runtime fallback was `--runtime > GSD_RUNTIME > 'unknown'`, diverging
     from the canonical `GSD_RUNTIME > config.runtime > 'claude'`, so any
     invocation without --runtime reported runtime_not_claude on an ordinary
     Claude project. Now delegates to runtime-slash.resolveRuntime.

The fragment's `${AGENT_SDK_VERSION:+--agent-sdk-version "$AGENT_SDK_VERSION"}`
snippet is removed rather than repaired: it was also shell-dependent — zsh does
not word-split unquoted parameter expansions, so it collapsed to a single argv
element, argValue() never matched, and the run failed into the same
agent_sdk_version_unknown, indistinguishable from genuinely unknown. Auto-
resolution removes the need for the construct entirely.

Verified with the issue's own repro: no flags now reaches the version gate; an
SDK above the floor yields backend:"workflow" with a script that parses as a
real ES module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ

* fix(#2590): sync generated registry, repair sibling tests, reject duplicate wave ids

Findings from the isolated review, all fixed.

HIGH — gsd-core/bin/lib/capability-registry.cjs was stale, and `lint:ci` was
already RED because of it. The registry embeds the fragment text INLINE, so the
shipped/installed copy still taught the exact broken contract this PR fixes:
the old `${AGENT_SDK_VERSION:+…}` bash line and the "OMIT the flag when unknown"
guidance. Regenerated. (I had read `lint:ci` by grepping its output instead of
checking its exit code, so I recorded a red chain as green — checking $? now.)

HIGH — three existing tests asserted the OLD broken shape and would have failed
CI; none was touched by the first commit:
  tests/fix-2285-claude-orchestration-wiring.test.cjs — matched resumeFromRunId("…")
  tests/claude-orchestration.test.cjs                 — .includes('budget(')
  tests/claude-orchestration-command-router.test.cjs  — .includes('budget(')
Each now asserts the corrected contract: the id/pool reaches the caller via
summary, and neither construct is ever CALLED. Two sibling assertions had also
gone vacuous — `.includes('resumeFromRunId')` still passed, but only because the
new explanatory COMMENT contains that substring, not because anything is wired.
Rewritten to assert the real property.

MEDIUM — duplicate wave ids were never rejected. Plan-id uniqueness was checked
within a wave, but nothing checked wave ids across waves. That was harmless
before; it is not now, because each wave emits a `phase("Wave <id>")` call plus a
matching meta.phases entry and the tool matches titles by exact string — two
waves sharing an id would collapse into one progress group and misattribute the
second wave's agents to the first. Rejected at validation, with tests either
side of the boundary.

MEDIUM — the fragment contradicted itself (its "Manifest construction" header
still listed $AGENT_SDK_VERSION as orchestrator-built) and, more seriously, never
told the orchestrator to pass summary.resumeRunId as the Workflow tool's
resumeFromRunId INPUT. Since this PR moves resume from a broken in-script call to
a tool-invocation input, an implementer following only the fragment would have
silently regressed phase-resume to a no-op. Both fixed.

MEDIUM — docs/how-to/enable-claude-orchestration-workflow-backend.md and
docs/explanation/claude-orchestration-capability.md documented
`resumeFromRunId("<id>")` and `budget(<tokens>)` as current correct output —
teaching the bug as the feature. Updated to the real contract, including the
required meta block and the thunk-array parallel() form. (The changeset is
`Fixed`, so the docs gate exempts this; it is corrected because it is wrong,
not because a gate demanded it.)

LOW — the router's top-of-file comment still described the divergent
`--runtime > GSD_RUNTIME > 'unknown'` chain as current, ninety lines above the
fix; and inserting resolveInstalledAgentSdkVersion had orphaned
resolveDetectionArgs' JSDoc above the wrong function. Both repaired.

lint:ci now exits 0 (verified by exit code, not by reading output).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TCwhbMuY37DzRMCfzTABJ

* chore(#2590): backfill changeset pr number (#2681)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:49:38 -04:00
Tom Boucher
5ea3401d4f fix(#2289): context-monitor emits injection envelope only for supported events (#2324)
* fix(#2289): context-monitor emits injection envelope only for supported events

gsd-context-monitor is wired to Codex Stop/SubagentStart/SubagentStop/PreCompact
(#772), but it emitted a hookSpecificOutput.additionalContext envelope for every
event. Codex's Stop schema rejects that shape ("hook returned invalid stop hook
JSON output") exactly when context is low.

Use a positive allowlist: emit only for context-injection events (PostToolUse,
AfterTool, and the pre-existing Gemini missing-name fallback); exit 0 silently
for Stop and every other event. Debounce and critical-session bookkeeping still
run on silenced events. Behavioral regression tests drive the real hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(#2289): backfill PR number 2324 into changeset

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(#2285): de-flake per-plan use_worktree property test on duplicate briefs

The fast-check property in claude-orchestration.test.cjs located each plan's
agent() call via indexOf on the brief, but only plan IDs were unique — briefs
could collide (fast-check shrinks toward short strings). On a colliding seed the
lookup found the first duplicate's line and misattributed its isolation, failing
"use_worktree:true must carry isolation" intermittently (surfaced on macOS CI).
emitWorkflowScript is correct for duplicate briefs (verified); suffix the unique
id onto each agent() label so the test probe is unambiguous.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 00:56:07 -04:00
Tom Boucher
ff9cb6069f fix(#2285): wire claude-orchestration Workflow backend into execute-phase (#2314)
The claude-orchestration capability (#1143) shipped registered 'active'
but fully inert: detectWorkflowBackend/emitWorkflowScript had no caller
outside their own CLI router, and execute-phase.md declared an
execute:wave:pre hook point that the workflow body never rendered — so
claude_orchestration.enabled:true had zero effect on real runs.

Approach B (maintainer-chosen):
- execute-phase.md now renders the execute:wave:pre hook
  (gsd_run loop render-hooks execute:wave:pre) at a new step 2.75,
  immediately before each wave's Agent() dispatch — fixing the latent
  dead-hook gap for any pre-wave capability.
- Move the claude-orchestration contribution execute:wave:post ->
  execute:wave:pre (a pre-wave backend selector belongs before dispatch,
  not after); rename fragments/execute-wave-post.md -> execute-wave-pre.md
  with prose instructing the orchestrator to call resolve-wave-dispatch
  before step 3. Unrelated wave:post contributions (ui.safety-gate, drift,
  external-job, mempalace) untouched.
- New .cts seam resolveWaveDispatch(input) composes detectWorkflowBackend
  + emitWorkflowScript into one {backend:'inline'|'workflow', ...} result;
  exposed as gsd-tools claude-orchestration resolve-wave-dispatch. This is
  a real non-CLI-router, non-test caller of both functions.

Fail-closed: any gate miss (disabled, non-Claude runtime, Workflow tool
absent, SDK below floor, execution_backend:inline, malformed input) or an
emit failure resolves to inline with a byte-identical result shape — no
regression to the default-off execute-phase path.

Regression tests (tests/fix-2285-*) cover happy-path activation + SDK-floor
BVA, the fail-closed gate-miss table with detectWorkflowBackend parity, a
fast-check composition property, capability.json contribution assertions,
and a source-contract guard that execute:wave:pre is now actually rendered.
Dependent registry-shape assertions updated in-scope.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:18:28 -04:00
Tom Boucher
f433db8b88 fix(#1143): address adversarial review — full semver precedence, docs/reality alignment
- compareSemver: implement full SemVer 2.0.0 §11 pre-release identifier
  comparison (two pre-releases of the same triple now order correctly; was 0).
- capability description + fragment: scope the plan-checker/verifier claim
  (this capability delivers the parallel-execution backend; those gates remain
  inline until separately wired). Correct the 'each wave is one barrier' prose
  (a wave splits into multiple sequential parallel() barriers on files_modified
  overlap). Frame detect-backend CLI as a simulation harness; the pure function
  with the live host descriptor is the real detection seam.
- partitionStages docstring: 'near-minimal via greedy first-fit' (not 'fewest');
  document empty-files_modified behavior.
2026-07-06 15:41:19 -04:00
Tom Boucher
e3262d94d3 feat(capabilities): add claude-orchestration capability (Workflow backend) (#1143)
Default-off, BETA, claude-only capability adopting Claude Code's Workflow tool
(/effort ultracode, Agent SDK >= v0.3.149) as an optional parallel-execution
backend for the GSD loop. Restores the wave parallelism + plan-checker + verifier
that #853 forces inline on Claude Code, and folds gsd-ultraplan-phase under one
runtime gate.

- Pure fail-closed core (src/claude-orchestration.cts): detectWorkflowBackend
  (gate ladder: enabled -> Claude -> backend != inline -> nested+background host
  -> valid Agent SDK -> SDK >= floor; every miss degrades to inline) and
  emitWorkflowScript (waves -> parallel() barriers, plans -> gsd-executor +
  worktree, files_modified overlap -> separate stages, resumeFromRunId, budget).
  All interpolated identifiers validated script-safe; briefs JSON-quoted.
- claude-orchestration command family (gsd-tools claude-orchestration
  detect-backend|emit-workflow) for orchestrator invocation.
- Two gated loop contributions at wired points (execute:wave:post, plan:post);
  federated config keys (enabled/execution_backend/min_agent_sdk_version).
- ADR-1143 implementation amendment; CONTEXT.md glossary entry; explanation doc.

On any runtime lacking the Workflow tool, behaviour is byte-identical to today.

closes #1143
2026-07-06 15:18:23 -04:00