Commit Graph

4328 Commits

Author SHA1 Message Date
Tom Boucher
102ffa0f9e fix(#2086): #338 fail-safe floor for reference-host behaviors on registry-load failure
Reviewer (PR #2106, elevated): if capability-registry.cjs fails to load,
_hostBehaviors('claude') returned {} — silently routing a claude LOCAL install to
the repo-shared settings.json instead of the gitignored settings.local.json (#338),
skipping mergeClaudePermissions + the .gsd-source marker. The migration is what
introduced that registry dependency (pre-PR the path had none).

Add FALLBACK_HOST_BEHAVIORS (keyed by runtime id — a data lookup, not a
runtime==='claude' branch) mirroring the reference host's #338-privacy-critical keys
(settingsFileByScope, permissionsSchema, sourceMarkerFile), consulted only when the
registry (or the descriptor) is unavailable. Behavior degrades CLOSED, never open;
the live descriptor stays the source of truth. Normal (registry-present) output is
unchanged (golden parity preserved). Pinned by tests via a registry-injected
_resolveHostBehaviors helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 15:19:12 -04:00
Tom Boucher
ff6e928530 fix(#2086): normalize realpath temp root in golden parity manifest (macOS /private)
The claude LOCAL install resolves its config dir via realpath, which on macOS
prepends /private to the temp root and embeds it in projected agents/commands/
workflows (@ references). buildParityManifest normalized only `root` (/var/folders/…),
leaving the /private prefix on macOS while Linux has none — so the mac-generated
claude-local fixture failed the Linux CI leg (198 files). Normalize the realpath
form too; no-op for the global fixtures (literal --config-dir, never realpath-resolved).
Regenerated claude-local.json now matches the Linux hashes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:49:58 -04:00
Tom Boucher
cf159f7929 docs(changeset): backfill pr 2106 for #2086 changeset
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:38:32 -04:00
Tom Boucher
fdd5e401eb feat(architecture): [EoS/claude] drive claude through the imperative adapter + descriptor-driven hostBehaviors (#2086)
Fold Claude Code's install/uninstall onto the Embeddable Orchestration System
(ADR-1239 Phase D). claude is GSD's tier-1 reference host, but its install path
was still driven by 13 hardcoded `runtime === 'claude'` string-equality branches
scattered across bin/install.js rather than the public Host-Integration Interface.

- Route install()/uninstall() through `createImperativeAdapter({runtime})` — the
  adapter delegates to the SAME installRuntimeArtifacts/uninstallRuntimeArtifacts
  engine calls, so output is byte-identical (proven pre/post, both scopes).
- Replace all 13 `runtime === 'claude'` / `runtime !== 'claude'` branches with
  descriptor-driven `runtime.hostBehaviors` lookups on capabilities/claude/
  capability.json (attributionSource, authorsCanonicalWorkflow, localInstallStyle,
  permissionsSchema, settingsFileByScope, sourceMarkerFile, agentFrontmatterExtensions,
  ownsClaudePaths, nativeModelAliases, skillsGlobalOnboarding). Behavior is
  identical; the brittle string-equality coupling (the add-a-host tax) is gone.
- Single-source the scattered literal 'claude' defaults/rosters behind DEFAULT_RUNTIME.
- Extend golden-install-parity to assert the claude LOCAL legacy layout is
  byte-identical too (AC1 "both scopes"); exclude the platform-varying
  settings.local.json (same reason settings.json is excluded).
- New tests/claude-imperative-reference.test.cjs: adapter kind, programmatic-cli
  profile, fail-closed negotiation on a corrupted/partial descriptor, and an AC2
  source guard that no `runtime === 'claude'` branch remains.

No user-visible install-output change (internal architecture only).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:31:53 -04:00
Tom Boucher
3852c318c4 Merge pull request #2079 from open-gsd/fix/2067-phase-complete-checkbox-regex
fix(#2067): restrict phase-complete checkbox regex gap
2026-07-08 12:11:44 -04:00
Tom Boucher
fc6a61a11f Merge branch 'next' into fix/2067-phase-complete-checkbox-regex 2026-07-08 11:59:17 -04:00
Tom Boucher
53a22f76a3 Merge pull request #2078 from open-gsd/fix/perf-316-lock-holder-holdms
test(#2081): stabilize perf-316 lock-holder race under load
2026-07-08 11:58:28 -04:00
Tom Boucher
af115100d8 docs(changeset): backfill pr: 2079 for #2067 changeset 2026-07-08 11:46:26 -04:00
Tom Boucher
ceee82dcc2 docs: add changeset for #2067 phase-complete checkbox fix 2026-07-08 10:52:55 -04:00
Tom Boucher
8c18d032a5 fix: restrict phase-complete checkbox regex gap (#2067)
The checkbox regex in cmdPhaseComplete used a greedy .* between ] and
'Phase N', so completing an already-checked phase (idempotent re-run)
matched a LATER phase whose description merely mentioned the target phase
number — checking the wrong phase's box. Restrict the gap to whitespace /
optional markdown bold emphasis, mirroring the tight pattern already used
by phase-insert.
2026-07-08 10:52:55 -04:00
Tom Boucher
597ee3248a test: add failing regression for #2067 phase-complete checkbox regex
The checkbox regex in cmdPhaseComplete uses a greedy .* between ] and
'Phase N'. Completing an already-checked phase (idempotent re-run) wrongly
matches a later phase whose description mentions the target phase. These
tests encode the exact repro from #2067; they fail on next @ origin/next.
2026-07-08 10:52:55 -04:00
Tom Boucher
49d320a972 test: stabilize perf-316 lock-holder holdMs race under load
Worker A (lock holder) used holdMs=1000 as a safety cap on its
Atomics.wait for the writer's contention signal. Under container load
(full suite, node24) Worker B's spawn + require('state.cjs') + stub
installation can exceed 1s, so A's wait timed out and removed the lock
before B ever contended. B's first atomic-create then succeeded
(lockAttempts:1), failing the retry-path witness and red-flagging the
gsd-test gate on otherwise-green branches.

The handshake is the real release trigger; holdMs is only a safety cap
for a dead/hung writer, so it must be large enough to never elapse
during B's spawn+init. Bump to 30s (bounded; afterEach terminate()s A
on the normal path, so no added latency) and widen the per-test timeout
to 15s for spawn headroom under heavy parallel load.
2026-07-08 10:25:28 -04:00
Tom Boucher
20297a8ff9 Merge pull request #1584 from Retengart/enh/1578-critic-self-check
enhance(#1578): ui-checker adversarial stance + extractor discipline
2026-07-08 09:13:13 -04:00
Tom Boucher
0e039e6dfb Merge branch 'next' into enh/1578-critic-self-check 2026-07-08 08:58:57 -04:00
Tom Boucher
5e7e0c18cd Merge pull request #2066 from open-gsd/fix/2028-phase-complete-milestone-end-and-workstream-guard
fix(#2028): phase.complete milestone-end out-of-order + workstream root-fallback guard
2026-07-08 08:49:17 -04:00
Alex V.
f15c25867d docs(#1578): rebase B2+B3 agent prompt changes 2026-07-08 12:57:49 +03:00
Tom Boucher
8ffb1261d1 Merge branch 'next' into fix/2028-phase-complete-milestone-end-and-workstream-guard 2026-07-07 23:26:39 -04:00
Tom Boucher
a467dad065 Merge pull request #2075 from open-gsd/fix/2009-load-failed-capability-injects-blocking-
fix(#2009): load-failed capability gates fail open with a loud warning
2026-07-07 23:26:12 -04:00
Tom Boucher
5aa7771dd3 Merge branch 'next' into fix/2009-load-failed-capability-injects-blocking- 2026-07-07 23:12:09 -04:00
Tom Boucher
3f82929cc2 Merge pull request #2074 from open-gsd/fix/2072-thread-model-into-assumptions-and-review-spawns
fix(#2072): thread resolved model into routed-agent spawns (assumptions-analyzer, code-reviewer, code-fixer)
2026-07-07 23:11:53 -04:00
Tom Boucher
809f07b2f6 Merge branch 'next' into fix/2028-phase-complete-milestone-end-and-workstream-guard 2026-07-07 23:05:24 -04:00
Tom Boucher
df352187ae Merge pull request #2076 from open-gsd/fix/2071-extract-effort-install-resolvers
fix(#2071): extract install-time effort resolvers so effort sync stops requiring the un-shipped bin/install.js
2026-07-07 23:05:06 -04:00
Tom Boucher
015c3a7fda fix(#2071): extract install-time effort resolvers so effort sync stops requiring the un-shipped bin/install.js
`gsd-tools effort sync` crashed in every installed runtime (e.g. ~/.claude/gsd-core/)
with `Cannot find module '../../../bin/install.js'`: cmdEffortSync (src/commands.cts)
required the package-root bin/install.js for its install-time effort resolvers, but the
installer only copies the gsd-core/ subtree into a runtime home — bin/install.js is never
present there. So `effort` config changes silently never reached installed agents without
a full reinstall (exactly the gap #488 was meant to close). 4th instance of the recurring
"runtime code under gsd-core/ requires a file outside the shipped subtree via ../../../"
anti-pattern (#1223/#1920/#1383 were the prior three, all already mitigated).

Fix (ADR-457 direction — extract, single source): move readGsdEffectiveEffortConfig +
resolveInstallTimeEffort (with their _getGsdEffortCatalog + _readGsdConfigFile helpers)
out of the hand-authored bin/install.js into a new src/install-effort-resolver.cts that
compiles into the shipped gsd-core/bin/lib/install-effort-resolver.cjs. commands.cts now
requires it as a sibling (`./install-effort-resolver.cjs`) — always present in the
installed tree — instead of `../../../bin/install.js`. bin/install.js imports the same four
symbols back from the new module (it still calls them + re-exports them), so there is one
source of truth and no duplication/drift. The lazy manifest read is repointed from the
package-root layout (`.., gsd-core, bin, shared`) to the bin/lib layout (`.., shared`).

Scope note: this is one of four instances of the anti-pattern; the other three are already
shipped/guarded. A build-time guard rejecting new cross-boundary requires whose target isn't
in the installer copy manifest (to prevent instance #5) is recommended on the issue but kept
out of this fix.

Tests: tests/effort-sync-installed-runtime.test.cjs does a real minimal install into a temp
home (the golden-parity helper) and runs the issue's exact repro
(`gsd-tools effort sync --config-dir <temp>`), asserting no MODULE_NOT_FOUND for
bin/install.js. Fail-first verified: against pristine next the same test throws
`Cannot find module '../../../bin/install.js'` at cmdEffortSync; post-fix it syncs cleanly.

New module registered in .gitignore (ADR-457), eslint ignores, docs/INVENTORY.md +
INVENTORY-MANIFEST.json. bin/install.js is not shipped and the new module is under bin/lib
(excluded from golden parity), so no golden fixtures change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:21:45 -04:00
Tom Boucher
487ba4ddd1 docs(#2009): add changeset fragment (PR #2075) 2026-07-07 22:09:26 -04:00
Tom Boucher
1ee00320c7 Merge branch 'next' into fix/2072-thread-model-into-assumptions-and-review-spawns 2026-07-07 22:03:47 -04:00
Tom Boucher
fad205e961 Merge branch 'next' into fix/2028-phase-complete-milestone-end-and-workstream-guard 2026-07-07 21:38:12 -04:00
Tom Boucher
4483300253 fix(#2072): thread resolved model into routed-agent spawns (assumptions-analyzer, code-reviewer, code-fixer)
model_overrides / models.<phaseType> were silently inert for gsd-assumptions-analyzer,
gsd-code-reviewer, and gsd-code-fixer on Claude Code: resolveModelInternal honors them,
but the workflows spawned these agents with no model= param, so the resolved value
never reached the Agent tool and the agents inherited the session model — no warning.

Fix — thread each agent's resolved model at every spawn site (the established
plan-phase pattern; the architecture-consistent Claude mechanism, since 13 other
agents already thread their model):
- discuss-phase-assumptions.md: `resolve-model gsd-assumptions-analyzer --raw`
  → ANALYZER_MODEL, threaded.
- code-review.md + code-review-fix.md (re-review): `resolve-model gsd-code-reviewer --raw`
  → REVIEWER_MODEL, threaded.
- code-review-fix.md (both fixer spawns): `resolve-model gsd-code-fixer --raw`
  → FIXER_MODEL, threaded (same silently-inert bug, same file — folded in per review).
- quick.md review step: was reusing `{executor_model}` for gsd-code-reviewer (so the
  reviewer's own override was ignored); init.quick now resolves `reviewer_model`
  (gsd-code-reviewer) and the spawn threads it.

resolve-model --raw returns the bare model string (resolve-execution --raw would
return effort — wrong). The resolver maps these agents to phaseType discuss /
verification / execution, so models.<phaseType> apply too.

Scope: the three agents reachable from the two issue-named workflows + quick.md. The
wider systemic class (other agents in UNTOUCHED workflows with the same pattern) stays
documented on the issue for a maintainer-scoped structural decision (thread-at-source
vs embed-at-install like #2256), not widened here.

Docs: the stale "discuss — reserved, no subagent today" model-profile tables now list
gsd-assumptions-analyzer and the verification row includes gsd-code-reviewer, across
the English docs, the shipped gsd-core/references/model-profiles.md reference, and the
ja-JP / zh-CN / ko-KR / pt-BR locale mirrors.

Tests:
- tests/model-resolver.test.cjs: #2072 acceptance — model_overrides and
  models.discuss/verification/execution resolve for all three agents.
- tests/model-routing-spawn-threading.test.cjs: every spawn of the three agents threads
  a resolved model (fails pre-fix); a header-precise parity guard fails the suite if a
  new un-threaded spawn of any of them regresses.
All 16 golden-install-parity fixtures + the workflow size baseline regenerated for the
changed shipped files (4 workflows + the reference doc); bin/lib is excluded from parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:22:48 -04:00
Tom Boucher
e162c31c93 Merge pull request #2065 from open-gsd/feat/1562-api-coverage-gate
feat(#1562): API-coverage verify:pre gate
2026-07-07 21:22:25 -04:00
Tom Boucher
f51ccceaf7 fix(test): make perf-316 lock-contention deterministic via release handshake
The perf-316 state-lock test released the held lock on a fixed 1000ms timer, then
asserted the SUT had contended (lockAttempts >= 2). On a slow/loaded runner the
SUT worker's spawn+init exceeded the timer, so it acquired the lock on the first
try (lockAttempts:1) and the test failed intermittently (observed on linux-node22
while linux-node24 passed). The holder now releases only when the writer signals
its first FAILED lock attempt (via a shared SharedArrayBuffer + Atomics), so a
retry is guaranteed regardless of worker-spawn latency; holdMs becomes a safety
cap. Surfaced by the #2009 gsd-test run.
2026-07-07 20:25:06 -04:00
Tom Boucher
1092a71554 Merge branch 'next' into fix/2028-phase-complete-milestone-end-and-workstream-guard 2026-07-07 19:42:01 -04:00
Tom Boucher
46f8d3814c fix(#2009): load-failed capability gates fail open with a loud warning
Previously a capability that failed to LOAD (e.g. incompatible engines.gsd) but
declared a gate-kind loop hook caused the loop resolver to inject a BLOCKING
synthetic gate (blocking:true, onError:halt) at every declared point, halting
every ship:pre / verify:post project-wide over an unrelated load error, with no
remediation surfaced.

Per maintainer decision (#2009) it now fails OPEN: no gate is injected (the loop
proceeds; --active-cap correctly reports the failed cap inactive) and a loud
warning is emitted — to stderr (the channel host workflows/agents actually see)
and in the envelope 'warnings' array — naming the load reason and the exact
'gsd capability remove <id>' remediation. The loader still records blockedGates;
only the consequence changes from block to warn.

Security (review): capId and reason originate from a third-party manifest /
directory name. capId is validated against the canonical kebab-case id shape
before it is placed in the runnable remediation command (withheld otherwise);
reason is stripped of control chars and backticks. This closes an argument/
prompt-injection vector in the surfaced message.

Docs updated to the fail-open-warning posture (ARCHITECTURE, INVENTORY,
CONFIGURATION, README, capability-overlay-model). Also removes a dead 'before'
import surfaced by lint in the issue-2045 test.
2026-07-07 19:38:01 -04:00
Tom Boucher
c82355972d test(#2009): fail-first — load-failed capability gate must fail open loudly
Asserts the loop resolver injects NO gate for a load-failed overlay capability
(fail open) and instead emits a loud warning — to stderr and the envelope
'warnings' array — carrying the 'gsd capability remove <id>' remediation, and
that an invalid (non-kebab) capability id is withheld from the runnable command.
Fails against origin/next (which injects a blocking fail-closed gate).
2026-07-07 19:38:01 -04:00
Tom Boucher
6addeccd19 feat(ai-integration): API-coverage verify:pre gate (#1562)
Full API Coverage by Default — Opt Out, Never Opt In. A phase that integrates
an external API/SDK/service can no longer seal without a decided coverage matrix.

- src/api-coverage.cts: deterministic detector (compound verb+noun signal +
  <Service> API/SDK surface; stopword-guarded; strips fenced code) + matrix
  parse/validate/render with field-length caps.
- check api-coverage.verify-pre: blocking seal-time gate; phase arg resolved as
  a token under .planning/phases/ only (traversal-neutralized); validates
  COVERAGE.md or blocks iff a strong integration signal is detected and no
  matrix exists; fail-closed when phases tree exists but phase unresolvable.
- capabilities/ai-integration: workflow.api_coverage_gate config key (default
  true), plan:pre contribution, blocking verify:pre gate. Data-driven.
- gsd-core/workflows/verify-work.md: generic verify:pre gate dispatch.
- Tests: detector FP/FN + matrix validation + fast-check bijection; gate e2e.
  Code+security review findings fixed (stopword FP, scope containment, pipe/cap
  rejection, prompt-injection message hygiene).
- Regenerated registry/matrix/loop-host-contract/goldens/baseline + docs.

Closes #1562
2026-07-07 15:11:12 -04:00
Tom Boucher
51dfa683d4 fix(#2028): phase.complete milestone-end out-of-order + workstream root-fallback guard
Two code-confirmed defects in `gsd-tools phase complete` (re-verified against
next; the three severe corruption paths the issue filed are superseded by the
ADR-1769 Transition Module migration + #2012, so this is the confirmed remainder).

1. Milestone-end mislabel (isLastPhase). The milestone-end determination only
   cleared isLastPhase when a HIGHER-numbered phase existed, so completing the
   numerically-highest phase out of order (e.g. Phase 10 before Phase 9) stamped
   STATE.md `Status: Milestone complete` while a lower phase was still outstanding.
   Added a lower-phase check: after the existing higher-phase scans, if any earlier
   phase in the current milestone has an unchecked roadmap checkbox (`[ ]`),
   isLastPhase becomes false AND next_phase/next_phase_name point at the LOWEST
   outstanding lower phase — so STATE.md advances to the real gap instead of
   parking on the just-completed phase. A completed phase always has `[x]`
   (phase.complete sets it), so all-lower-complete still reports milestone-end;
   heading-only roadmaps (no checkboxes) retain prior behavior. The checkbox regex
   mirrors the sibling phasePattern's anchoring (whitespace/bold + required `:`) so
   unrelated checklist lines mentioning "Phase N" don't match.

2. Workstream root-fallback (no guard). cmdPhaseComplete resolves every path via
   planningDir(cwd); with a `workstreams/` dir present but no active workstream and
   no --ws, that returns root `.planning`, so phase.complete wrote STATE.md/
   ROADMAP.md (and the mislabel) into the shared root other workstreams read.
   Added the same #1912 fail-safe guard init.progress got: refuse (asking for
   `--ws`/active workstream) instead of silently writing root. Resolution itself
   was already wired globally (resolveActiveWorkstream: --ws > GSD_WORKSTREAM >
   pointer, set in bin/gsd-tools.cjs), so only the refusal guard was missing.

The workstream-mode detection (`listAvailableWorkstreams`) is extracted into
planning-workspace.cts as the single source of truth and consumed by BOTH
init.progress and phase.complete, so the two fail-safe paths cannot drift.

Tests (tests/phase.test.cjs, new #2028 describe): out-of-order completion becomes
`Ready to plan` with is_last_phase=false, next_phase pointing at the outstanding
phase and Current Phase advancing to it (not the completed phase); all-lower-
complete still reports milestone-end; workstream-mode-no-active refuses with an
`--ws` hint; `--ws` completes in the workstream leaving root untouched; flat mode
unaffected. Fail-first verified locally via direct gsd-tools invocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 14:40:44 -04:00
Tom Boucher
cce405c9b7 Merge pull request #2058 from open-gsd/fix/2046-config-unset-null
fix(#2046): config-set <key> null unsets (clears) the key instead of persisting "null"
2026-07-07 13:47:59 -04:00
Tom Boucher
3742270c87 Merge branch 'next' into fix/2046-config-unset-null 2026-07-07 13:26:16 -04:00
Tom Boucher
bc56e26ca3 Merge pull request #2059 from open-gsd/fix/2043-phase-token-single-digit-slug
fix(#2043): reject single-digit slug word in phase-token extraction (all sites)
2026-07-07 13:25:54 -04:00
Tom Boucher
9accce2b72 Merge branch 'next' into fix/2043-phase-token-single-digit-slug 2026-07-07 13:13:31 -04:00
Tom Boucher
9a9f304690 Merge pull request #2060 from open-gsd/fix/1857-test-gate-watch-mode-timeout
fix(#1857): test gates normalize to one-shot + bounded timeout (no watch-mode hang)
2026-07-07 13:13:12 -04:00
Tom Boucher
12cc1955b2 Merge branch 'next' into fix/1857-test-gate-watch-mode-timeout 2026-07-07 12:58:57 -04:00
Tom Boucher
a6922c6d52 Merge pull request #2057 from open-gsd/fix/1821-kilo-dead-hook-copy
fix(#1821): stop copying dead hook scripts for Kilo and ZCode
2026-07-07 12:58:37 -04:00
Tom Boucher
603593d41d fix(#1857): test gates normalize to one-shot + bounded timeout (no watch-mode hang)
A GSD verification gate resolves a project's test command and runs it. vitest
defaults to WATCH mode in an interactive TTY — exactly where a user runs
`gsd-execute-phase` — so a resolved `npm test`/`pnpm test` backed by vitest never
exited and the orchestrator waited indefinitely. Recovery needed the user to
manually prompt "something blocking?".

Fix — one shared helper + a bounded, surfacing timeout on the test-command gates:
- New pure module src/normalize-test-command.cts + `gsd-tools query
  normalize-test-command` verb: rewrites a resolved command to a best-effort
  one-shot form (direct vitest → `vitest run`; jest `--watch` → `--watchAll=false`;
  a package-manager `test` script whose package.json runner is watch-vitest →
  `CI=true` prefix; handles `--dir`; already-one-shot commands unchanged —
  never double-flagged). Named `normalize-test-command` (not `test-*`) so the
  file does not match node --test's default `test-*` discovery glob.
- The three gates that HUNG or silently-continued route through that ONE helper
  and bound execution with `timeout $(config-get workflow.test_gate_timeout)`
  (new config key, default 600s): the regression gate (extracted to
  execute-phase/steps/regression-gate.md since execute-phase.md is size-frozen —
  it shrank 93528→93132; ABORTS on exit 124), the post-merge gate, and the
  audit-fix gate (previously an UNBOUNDED `eval`). All name watch/dev mode on 124.
- verify-phase's gate was ALREADY bounded (a fixed `timeout 300`, not a hang), so
  it only gains the normalizer (so a watch runner exits fast) + a watch-mode hint
  on 124, staying under its frozen 40960-byte tier cap.

Security hardening (review): the normalizer only rewrites a runner named as a
standalone command TOKEN (so `run-vitest.js`/`make test-vitest`/paths are never
mangled), is length-capped and uses only linear-time split-based scanning (no
super-linear backtracking on an adversarial `workflow.test_command`), and reads
package.json only when it is a regular file (never blocks on a FIFO via `--dir`).

Config key `workflow.test_gate_timeout` (seconds, default 600) registered in the
schema manifest + templates/config.json + docs/CONFIGURATION.md (mirrors
workflow.cross_ai_timeout). New module registered in .gitignore, eslint ignores,
inventory manifest/index. All 16 golden-install-parity fixtures + workflow size
baseline regenerated for the changed shipped files; bin/lib is excluded from the
parity manifest.

Tests: tests/normalize-test-command.test.cjs (normalizer units incl. security
hardening) and tests/test-gate-watch-mode.test.cjs (the three core gates route
through the shared helper + configured timeout + exit-124 watch-mode hint;
verify-phase asserted as normalize-only/already-bounded).
tests/execute-phase-active-flags.test.cjs repointed at the extracted step;
tests/planner-language-regression.test.cjs allowlist comment updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:14:43 -04:00
Tom Boucher
6efd2b4016 fix(#2043): reject single-digit slug word in phase-token extraction (all sites)
A phase whose slug's first word is a bare single digit (dir
`46-6-rs-pipeline-orchestrator`, roadmap phase "6 Rs Pipeline Orchestrator" →
slug `6-rs-…`) had its phase token over-collected as `46-6` instead of `46`, so
`gsd-tools` phase-by-number lookups (init.plan-phase, init.phase-op, and
downstream execute/verify/ship) resolved phase_dir=null / has_context=false.

Root cause: an over-broad "looks numeric" test (`/^\d/`, `\d+(?:-\d+)*`)
classified token segments and could not distinguish a legitimate zero-padded
sub-phase segment (`01`, `02`) from a single-digit slug word (`6`). Zero-padded
phase/sub-phase segments are always ≥2 digits, so requiring ≥2 digits is the
structural distinguisher. Applied consistently across every same-class
implementation the triage identified (fixing extractPhaseToken alone leaves the
health-check and milestone-filter subsystems exposed):

- src/phase-id.cts extractPhaseToken — a pure-numeric leading segment continues
  only with ≥2-digit segments; a letter-prefixed milestone id (`M1`) still
  admits its single-digit sub-phase (`M1-2`).
- src/validate.cts PHASE_TOKEN_FROM_DIR_RE (W005/W006/W007 health checks) and
  canonicalPlanStem (I001 plan/summary pairing).
- src/roadmap-parser.cts isDirInMilestone numericRe (getMilestonePhaseFilter —
  highest blast radius: a false negative silently excludes a phase from the
  milestone).
- src/core-utils.cts + its verbatim duplicate src/phase.cts extractCanonicalPlanId.

Regression tests added across tests/{phase-id,health-validation,core-utils,
roadmap-parser}.test.cjs using the shared `46-6-rs-…` fixture, asserting the
token resolves to `46` (not `46-6`) while legit multi-segment tokens
(`01-02`, `02-03-04`, `M1-2`, `68-01`, `02-01`) are unchanged. The
roadmap-parser test exercises the real getMilestonePhaseFilter end-to-end.

The residual ≥2-digit-leading-slug case (e.g. `NN-2024-roadmap`) stays out of
scope — subsumed by the #612 / #565 phase-ID convention work, per the issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 10:15:37 -04:00
Tom Boucher
dd21bb7451 fix(#2046): config-set <key> null unsets (removes) the key instead of writing "null"
`gsd-tools config-set <key> null` — the "Clear" action documented in
settings-integrations.md / settings-advanced.md — previously fell through the
value parser as the literal STRING "null" and persisted it. Consequences:
"cleared" keys stayed set (config-get returned truthy "null"), and for secret
keys (brave_search/firecrawl/exa_search) a masked success line hid a truthy
4-char value on disk that integrations could pass along as a real credential.
There was also no unset/delete verb at all.

Fix: parse a bare `null` to JS null and short-circuit to a real UNSET that
DELETES the key from config.json — the semantic the docs already describe
("Remove the stored key" / "remove the key by setting it to null"). Deleting
(not persisting JSON null) is the correct clear: a persisted null is still a
present value consumers must special-case.

- src/config.cts:
  - parse block: `else if (val === 'null') parsedValue = null;`
  - cmdConfigSet: when parsedValue === null, short-circuit BEFORE the typed
    per-key validator gauntlet (so clearing an enum/boolean/number key removes
    it rather than being rejected) and before the project_code special-case;
    mask the previous value for secret keys in the output.
  - new `unsetConfigValue()` + `_unsetNestedValue()` mirroring setConfigValue/
    _setNestedValue: same prototype-pollution guard, but never creates missing
    intermediates and never prunes empty parents; returns { previousValue,
    existed }. Unsetting a never-set key is an idempotent no-op success.
- tests/config.test.cjs: new suite covering non-secret routing key, secret key,
  typed-enum-key bypass (context), idempotent unset, literal-"null"-on-disk
  guard, the unset-path prototype-pollution guard (alert #26 parity), and a
  4-segment deep-nested unset.
- tests/review-model-config.test.cjs: update the stale round-trip test that
  codified the bug (asserted config-set null → config-get returns "null") to the
  fixed contract — the model key is removed; the review workflow's
  `[ -n "$VAR" ] && [ "$VAR" != "null" ]` guard handles the empty read as
  "no override → reviewer default", same as the old "null" sentinel.

The 4 documented "Clear" flows (settings-integrations.md, settings-advanced.md)
were verified — their prose already describes removal, so the fix makes them
accurate rather than aspirational; no doc wording change required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:41:14 -04:00
Tom Boucher
2b806904e2 fix(#1821): stop copying dead hook scripts for Kilo and ZCode (hooksSurface:none)
Kilo and ZCode both declare `hooksSurface: 'none'` and have no plugin surface,
so the GSD installer staged lifecycle hook scripts (hooks/*.js, hooks/*.sh,
hooks/lib/) plus a `{"type":"commonjs"}` package.json marker into their config
dirs where nothing ever invokes them — dead weight (#1821).

The installer's two hook-copy guards at bin/install.js were still on the legacy
hardcoded runtime-name list and never excluded Kilo or ZCode. Add
`&& !isKilo && !isZcode` to both (isZcode added to the install() runtimeFlags
destructure).

OpenCode — which #1821 also named — is deliberately NOT excluded: since the
issue was filed, #1914 shipped a native OpenCode plugin (plugins/gsd-core.js)
that spawns those exact staged hooks via OpenCode's event bus and requires both
the hook scripts and the CommonJS package.json marker. Excluding OpenCode would
regress #1914, so its hooks stay live. The genuinely-dead cases are Kilo & ZCode.

- bin/install.js: add `&& !isKilo && !isZcode` to the hooks/dist copy guard and
  the hooks/lib copy guard; document the OpenCode-vs-Kilo/ZCode split.
- tests/install-minimal-hooks.test.cjs: regression test asserting Kilo and ZCode
  receive no gsd-*.js/.sh hooks or hooks/lib, while OpenCode keeps its hooks +
  #1914 plugin and Claude keeps its hooks (over-exclusion guard).
- tests/fixtures/golden-install-parity/{kilo,zcode}.json: drop the 21 hooks/*
  entries and the package.json marker they no longer receive (opencode unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 09:40:01 -04:00
Tom Boucher
a3b9cbaeb8 Merge pull request #2054 from open-gsd/fix/2045-third-party-skills-surface
fix(#2045): third-party capability skills surface correctly
2026-07-07 08:38:23 -04:00
Tom Boucher
847de596b8 fix: third-party capability skills surface correctly (#2045)
A skills-only role:feature third-party capability installed 'active' but its skills never reached the runtime surface, capability enable/set rejected it as 'unknown capability', and capability list disagreed with capability state. Three defects, fix shape 1b (teach resolveSurface, no on-disk linking):

D1 (materialization): resolveSurface built the surfaced skills Set only from the on-disk manifest; third-party cap skills live at ~/.gsd/capabilities/<id>/skills/ and never entered the Set -> surfaced:false. Fix: union registry.capabilityClusters values into the Set in the full-profile branch (idempotent for first-party, additive for third-party; prototype-pollution guarded).

D2 (enable/set unknown): setCapabilityState validated the capId against the static first-party registry instead of the composed overlay-aware registry. Fix: validate against loadRegistry({includeInstalled}) like capability-state.cts does.

D3 (list vs state): capability list derived status purely from ledger existence, never surface composition. Fix: add a 'surfaced' field to list rows sourced from the same resolver capability state uses, so list and state agree.

Regression coverage: tests/issue-2045-third-party-skills-surface.test.cjs asserts all five acceptance criteria (resolveSurface union, state surfaced:true, enable/set not-unknown, list/state agreement, first-party + unknown-id regression). gsd-test: 23916/23916 green (linux-node22 + linux-node24).
2026-07-07 08:25:33 -04:00
Tom Boucher
2372a221eb Merge pull request #1835 from open-gsd/feat/1820-specless-predicate-rail
feat(#1820): spec-optional predicate rail — author probe predicates into must_haves when SPEC omits them
2026-07-07 08:05:30 -04:00
Tom Boucher
ea4378063f Merge branch 'next' into feat/1820-specless-predicate-rail 2026-07-07 07:49:07 -04:00
Tom Boucher
d2ae7fe391 Merge pull request #2053 from open-gsd/chore/sync-next-version-1.7.0-rc.4
chore: sync next package version to 1.7.0-rc.4
2026-07-07 02:08:34 -04:00