feat(#1169): complete ADR-857 phase 6 — migrate features to Capabilities, revive dead gates, harden conformance gate (#1183)

* test(#1168): make phase-6 gate un-gameable — reject empty stubs + require loop shrink

The migration assertion previously checked only role==feature, so a registration-only stub (empty hooks, logic left inline) would turn the gate green while phase 6 stayed incomplete — the exact false-completion pattern this gate exists to prevent. Strengthen it: each ADR-named feature must OWN its behavior (>=1 hook, or a command family); and plan-phase.md/execute-phase.md must shrink strictly below their frozen pre-phase-6 sizes (94519/93166 LF bytes), which also defeats double-run gaming (declare a hook but keep the inline block -> file does not shrink -> red).

Gate now 5 pass / 4 fail (orphaned execute:wave:post, empty/unregistered features, config-key leaks, no shrink). Green is now reachable only by REAL migration. Refs #1168, #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1169): migrate gap-analysis to a Capability (plan:post gate)

First real ADR-857 phase-6 migration (pattern-defining tracer). gap-analysis moves from an inline post_planning_gaps branch in plan-phase.md to a real plan:post gate Capability:

- capabilities/gap-analysis/capability.json: role:feature, plan:post gate (when=workflow.post_planning_gaps, blocking:false advisory), OWNS workflow.post_planning_gaps (federated out of central schema). - plan-phase.md: inline config-get + gsd_run gap-analysis block replaced with a plan:post render-hooks call site dispatching the gate; file shrinks 94519->93279. - src/check-command-router.cts: cmdGapAnalysisPlanPost runs the real gap analysis via gap-checker. - post_planning_gaps removed from central manifest; resolves via federated config (default true preserved). - tests/post-planning-gaps-2493: re-pointed to assert capability ownership.

Verified: gate 5 pass / 4 fail (gap-analysis cleared from migration, plan:post-orphan, config-leak, and plan-phase shrink checks); loadConfig still returns post_planning_gaps=true; check command runs real analysis; 392/392 in the config/registry/federation/router net. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1169): migrate profile-pipeline to a command-family Capability

ADR-857 Decision 7: profile-pipeline becomes a command-family Capability (like audit/intel/graphify). capabilities/profile-pipeline/capability.json declares an 8-command family (scan-sessions, extract-messages, profile-sample, write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md) backed by a new gsd-core/bin/lib/profile-pipeline-command-router.cjs; the inline case arms are removed from gsd-tools.cjs. Owns profile-pipeline.enabled (federated).

Verified: registry shows role:feature with commands.length=8; scan-sessions/profile-sample run live via the family; gate cleared profile-pipeline from the empty-stub failure (only tdd/schema-gate/drift remain); 296/296 registry+inventory+gsd-tools tests; lint 0 errors. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1167): wire execute:wave:post + implement ui.safety-gate check

Revives the second dead gate from #1167: ui.gates@execute:wave:post was declared but never dispatched AND its check.query (ui.safety-gate) was unimplemented. Adds the per-wave execute:wave:post render-hooks call site in execute-phase.md (fires after each wave's merge/cleanup, before the next forks) and implements cmdUiSafetyGate (frontend + UI-SPEC aware, mirrors cmdUiPlanGate) in check-command-router. +17 regression tests.

Verified: phase-6 orphaned-points conformance test now PASSES (gate 6 pass / 3 fail); ui-safety-gate routable in dot+hyphen forms; check-ui-safety-gate 17/17, check-ui-plan-gate 18/18; lint 0 errors. Refs #1167, #1168.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1169): migrate drift (schema + codebase) to execute:wave:post gates

Removes the inline schema_drift_gate + codebase_drift_gate steps (77 lines) from execute-phase.md; drift becomes a Capability with two execute:wave:post gates (verify.schema-drift blocking, verify.codebase-drift advisory) dispatched via the per-wave render-hooks call site. check-command-router routes verify.schema-drift / verify.codebase-drift to the real detectors. Federates workflow.drift_threshold / drift_action / schema_drift_gate out of central.

Also fixes the execute:wave:post dispatch prose to run NON-blocking (advisory) gates too — the prior version only ran blocking gates, which would have silently dropped the codebase-drift advisory after its inline step was removed. Behavior preserved.

Verified: gate 7 pass / 2 fail (drift cleared from stub + config-leak; execute-phase.md 92297 < 93166 frozen -> shrink passes); both drift checks run real detection; loadConfig defaults preserved (threshold=3, action=warn, gate=true); drift-detection 56/56 + schema-drift 34/34; lint 0 errors. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1169): migrate tdd to a Capability (plan:pre contribution + execute:post gate)

tdd becomes a real Capability: a plan:pre contribution injects the <tdd_mode_active> planner guidance (rendered from PLAN_PRE_HOOKS_JSON like security's contribution), and an execute:post gate (tdd.review-checkpoint, advisory) runs the real end-of-phase RED/GREEN review via a new check-command handler. Inline tdd_mode reads + the inline planner block + the tdd_review_checkpoint step are removed; workflow.tdd_mode is federated out of central. The MVP+TDD per-task RED-commit gate is preserved — TDD_MODE is now derived from the execute:post hooks (capId==tdd active), not an inline config-get.

BEHAVIOR CHANGE (documented, not silent): the --tdd CLI flag now persists workflow.tdd_mode=true via config-set instead of being per-invocation. Rationale: tdd is now a config-toggled Capability, and env vars do not persist across the workflow's separate bash blocks (config does), so an ephemeral override isn't cleanly achievable; --tdd therefore enables the tdd capability, consistent with how all capabilities are toggled.

Verified: gate 7 pass / 2 fail (tdd cleared from stub + config-leak; plan-phase + execute-phase both < frozen sizes); contribution injection + execute:post gate dispatch wired; MVP+TDD gate preserved; tdd.review-checkpoint runs real review; full unit suite 556/0; lint 0 errors. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1169): migrate schema-gate to a plan:pre contribution Capability

The plan-time schema-push detection (former plan-phase.md §5.7) becomes a schema-gate Capability: a plan:pre contribution (into:planner, when:workflow.schema_push_detection) whose fragment carries the full ORM-detection + [BLOCKING] schema-push-task injection logic, rendered into the planner via the existing plan:pre render-hooks dispatch. The inline §5.7 block is removed (plan-phase.md 94519->90445). workflow.schema_push_detection is a new capability-owned (federated) key, default true. (The execute-side schema-drift gate was migrated separately into the drift capability.)

Verified: registry inlines the fragment (len 2704) so it is actually delivered at plan:pre; gate 8 pass / 1 fail — all 5 ADR-named features now real Capabilities, only the config-leak test remains (intel/security, next unit). Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(#1169): close the 3 capability config-key leaks — phase-6 gate now GREEN

Removes the last inline config-get reads of capability-owned keys from plan-phase.md. security_asvs_level/security_block_on now flow through the security plan:pre contribution via a new loop-resolver configValues mechanism (resolves declared config keys with the same 4-level precedence as activation and attaches them to the rendered hook); the §5.55 banner reads them from PLAN_PRE_HOOKS_JSON. intel.enabled becomes a real intel plan:pre step (ref.command: intel api-surface) dispatched via render-hooks; the inline intel branch is gone. gen-capability-registry now validates ref.command as a third dispatch shape.

Verified: phase-6 capstone conformance gate is FULLY GREEN (9/0); 3 leaks gone (grep=0); security configValues resolve to {2,medium}/default {1,high}; intel step present only when enabled; loop-render-hooks 62/0, capability-registry 287/0, capability-state/federated-config 113/0; lint 0 errors. Closes the migration half of #1169. Refs #1139, #1167, #1168.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1169): address adversarial review — restore schema-drift block, generic planner injection, uniform gate contract

Adversarial review caught 2 real regressions the green gate missed: (1) schema-drift no longer blocked — the execute:wave:post dispatch read GATE_RESULT.block but verify.schema-drift emitted drift_detected/blocking, and onError:skip wrongly bypassed positive blocks; (2) only tdd's plan:pre contribution was injected into the planner, dropping schema-gate's schema-push detection and security's threat-model guidance.

Fixes: (A) every gate check returns a uniform boolean 'block' under --raw (the dispatch form), with advisory gates (tdd/gap) carrying their report in 'message'; (B) gate-dispatch contract corrected at all sites — onError governs command errors only, a blocking gate's positive block always halts; (C) generic planner injection of all plan:pre contributions where into=='planner' (tdd + schema-gate + security incl configValues); (D) two new conformance assertions: planner contributions injected generically + every gate check.query returns boolean block under --raw.

Verified: gate 11/11; all 6 gate checks return boolean block under --raw; full suite 595/0; lint 0 errors. Refs #1167, #1168, #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1169): restore MVP+TDD end-of-phase blocking escalation (2nd adversarial pass)

The migrated tdd execute:post gate is statically blocking:false, but the contract (references/execute-mvp-tdd.md + CONTEXT.md) requires the end-of-phase TDD review to ESCALATE from advisory to blocking when MVP_MODE && TDD_MODE && a TDD plan misses a RED/GREEN commit. The migration prose had downgraded this to a 'strong advisory recommendation' — silent loss of the blocking escalation. Restore it: the tdd-gate dispatch now refuses to mark the phase complete (Phase blocked message) under MVP+TDD when GATE_RESULT.block is true; advisory otherwise.

Also strengthen tests/execute-mvp-tdd-gate.test.cjs: hasBlockingEscalation previously matched any line with 'blocking'+'mvp+tdd' (so 'advisory (blocking: false) ... under MVP+TDD' was a false green); now it requires the real refusal semantics ('refuse to mark the phase complete' / 'phase blocked'). Caught by 2nd adversarial review pass.

Verified: execute-phase.md 92702 < 93166 frozen; mvp-tdd-gate + phase-6 gate 19/0; full suite green; lint 0 errors. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1169): restore MVP+TDD proceed-block, codebase auto-remap, schema skip-flag (3rd adversarial pass)

3rd adversarial pass found 4 more silent regressions: (1) the tdd MVP+TDD 'refuse to mark complete' was nullified by a downstream 'ALWAYS proceed regardless of gate results' line — proceed is now conditional (stops on an active MVP+TDD block); (2) the test now asserts the proceed is NOT an unconditional override; (3) codebase-drift auto-remap (spawn gsd-codebase-mapper when drift_action=auto-remap) was dropped — the execute:wave:post advisory dispatch now consumes spawn_mapper/directive; (4) GSD_SKIP_SCHEMA_CHECK bypass was lost from the gate path — cmdVerifySchemaDrift now honors the env var (block:false when set).

Verified: no unconditional proceed; GSD_SKIP_SCHEMA_CHECK=true -> block:false; gate 11/11 + mvp-tdd 9/9; full suite 569/0; lint 0; execute-phase.md 93109 < 93166. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1169): init.cts reads federated config keys from nested path (4th adversarial pass)

Config federation moved tdd_mode/research/nyquist_validation from flat config.<key> to nested config.workflow.<key>, but src/init.cts still read them flat — so init.plan-phase/init.execute-phase emitted tdd_mode:false / research_enabled:undefined / nyquist:undefined regardless of config (a public command-contract regression; the migrated loops use render-hooks so enforcement was unaffected). Read via config.workflow (type-safe Record cast). Now init reflects the same resolved values + federated defaults (research/nyquist default true) as the render-hooks path.

Verified: build clean; init.plan-phase emits tdd_mode:true/research:false/nyquist:false for set config, defaults true for empty; full suite 591/0; lint 0. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(#1169): add changeset for ADR-857 phase-6 completion (PR #1183)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1169): complete phase-6 migration fallout — restore TEXT_MODE, fix registry .claude leak, re-point stale workflow-contract tests

The capability migration left real regressions and stale consumer tests that
the per-module unit suite missed but the full cross-platform suite caught (27
failing tests):

Real source regressions (fixed):
- execute-phase.md lost its AskUserQuestion TEXT_MODE plain-text fallback when
  the inline schema_drift_gate step was removed — non-Claude runtimes would
  stall. Restored, and the execute:post gate-dispatch prose de-duplicated to
  cite the execute:wave:post contract (loop body shrinks below the frozen
  pre-phase-6 ceiling while keeping every onError/blocking nuance).
- capabilities/tdd inline fragment hardcoded `@~/.claude/gsd-core/references/tdd.md`,
  baked verbatim into the committed capability-registry.cjs and leaked the
  install path on 11 non-Claude runtimes (registry .cjs is copied, not
  path-converted). Made the fragment path-free; regenerated the registry. The
  phase-6 conformance gate now guards this (no ~/.claude install path in any
  capability source or the generated registry).
- plan-phase.md: removed a §5.7 stub re-added in error and routed Branch 2 to
  step 6 (schema-gate is a plan:pre capability, §5.7 is gone).

Stale workflow-contract tests re-pointed to the capability dispatch they now
must assert (behavior verified preserved in source first, assertions kept
equal-or-stronger): bug-621 + bug-2851 (gap-analysis via gsd_run render-hooks
plan:post + registry binding), feat-2527 (tdd_mode federated out of central),
phase6-planning + plan-phase-ui-redirect (§5.6 bounded by ## 6.),
plan-phase-drift-guard (intel when:intel.enabled skip branch).

profile-pipeline-command-router.cjs un-ignored from eslint (hand-written, no
TS source) + stale disable comments removed. Size baseline regenerated.

Verified: full suite 15140 tests / 0 fail; lint 0 errors; conformance gate green
legitimately. Refs #1139, #1167, #1168, #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1169): add ADR-857 E2E content-test coverage for the 12 loop points + capability deliverables

Grounds the capability engine in behavioral E2E tests (drive the real
render-hooks/check CLI + the real registry, assert typed result content — no
source-grep), structured around what ADR-857 says to deliver. 207 tests; each
genuineness-checked (flip the expectation, confirm it fails).

Per-loop-point dispatch (7 files): empty-point negative-space across the 6
no-hook points; verify:post 3-step resolution+ordering+onError; plan:pre
contribution/configValues + ui.plan-gate + intel; plan:post gap-analysis;
execute:wave:post drift+ui gates via the check route (schema-drift block/skip,
codebase-drift threshold BVA, auto-remap); execute:post tdd.review-checkpoint
RED/GREEN; ship:pre security gate resolution + frontmatter-get predicate pieces.

ADR-deliverable coverage (4 files): predicate boundary held (edge/prohibition
probes stay core, not off-by-default Feature Capabilities — phase-6 exception);
core loop runs with zero capabilities (all 12 points empty, init bundles
resolve); contribution merge (multiple ordered <contribution from=> blocks);
federated-config key removal on uninstall.

federated-config allowlisted for its 3-file split (unit + integration +
lifecycle). Refs #1139, #1167, #1168, #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1169): remove dead drifted converter dups + address adversarial review

Lint cleanup (root-caused, not waved off): src/runtime-artifact-conversion.cts
carried 11 agent-converter functions (+5 orphaned consts/helpers) that were
never exported, never called, and had silently DRIFTED from the live
hand-authored copies in bin/install.js (one even referenced an undefined
`claudeToCopilotTools`). Deleted the dead duplicates; install.js's live copies
are untouched (it never imported these). Lint now 0 errors / 0 warnings.

Adversarial-review (Codex) findings fixed:
- HIGH: execute-phase.md TDD_MODE used `jq ... || echo false`, silently
  disabling the MVP+TDD blocking gate on jq-less runtimes. Reverted to the
  `node -e` form (node is guaranteed; matches the file's other node-e usages) so
  a missing optional tool can no longer fail-open a blocking safety path.
- MEDIUM: federated-config-key-removal orphan-key test was vacuous (it skipped
  the orphan assertion). Now asserts the removed capability's key is genuinely
  not surfaced/validated after uninstall.
- LOW: phase-6 conformance leak regex broadened to catch absolute-home and
  Windows-backslash `.claude/(gsd-core|commands|agents|hooks)` paths, not only
  `~`/`$HOME` forward-slash forms.
- LOW: bug-2851 plan:post dispatch assertion now requires `--raw` (matched its
  stated contract).
- nit: plan-pre intel-step test duplicate assertion replaced with a distinct
  structured-output check.

Size baseline regenerated (execute-phase.md 93089 < 93166 frozen). Refs #1167, #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(#1169): make runtime-homes-descriptor-drive titles environment-independent

The descriptor-equivalence test embedded the absolute golden config path
(`os.homedir()`-derived) directly in each `test(...)` title, so titles differed
between macOS (`/Users/x/.claude`) and Docker (`/home/gsdtest/.claude`). Every
test PASSES on both platforms (15885/0 leaf tests each), but gsd-test-summary
compares results by title and reported 29+29 false "only in Mac / only in
Docker" discrepancies for tests that actually pass everywhere.

Move the golden path out of the title and into the assertion message (still
shown on failure); titles are now byte-identical across platforms so the
cross-platform comparator matches them. No assertion logic or golden values
changed. Refs #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(#1169): derive TDD_MODE via gsd_run --active-cap, not node -e (fix prompt-injection CI gate)

The prior fix reverted execute-phase.md:181 from jq to `node -e` to close a
Codex HIGH (jq||echo-false silently disabling the MVP+TDD blocking gate on
jq-less runtimes) — but the CI prompt-injection scanner BLOCKS new `node -e` in
workflow markdown (inline code-exec = injection vector), turning the security
gate red. Both forms were wrong: node -e fails the scanner; jq fail-opens a
blocking safety gate; `config-get workflow.tdd_mode` is forbidden by the
conformance leak gate (tdd_mode is capability-owned).

Correct fix (what Codex recommended): a gsd_run-native boolean. Add an
`--active-cap <capId>` flag to `loop render-hooks <point>` that resolves hooks
the normal way and prints exactly `true`/`false` for whether a capId is active
— scanner-safe (canonical launcher, no inline code), node-reliable (no optional
jq to fail-open), and leak-free (render-hooks resolution, not config-get).
execute-phase.md:181 now `TDD_MODE=$(gsd_run loop render-hooks execute:post
--active-cap tdd)`. +5 behavioral tests for the flag.

Verified: prompt-injection-scan --diff origin/next → 0 findings; conformance
gate 13/13 (execute-phase.md 92934 < 93166); execute-mvp-tdd + tdd-mode +
loop-render-hooks 87/0; lint 0/0. Refs #1167, #1169.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-13 21:07:55 -04:00
committed by GitHub
parent 83e87e3ec0
commit b10e56818b
53 changed files with 7759 additions and 794 deletions

View File

@@ -0,0 +1,5 @@
---
type: Changed
pr: 1183
---
**ADR-857 phase 6 complete: optional features are now Capabilities, not inline loop branches.** `tdd`, `schema-gate`, `drift`, `gap-analysis`, and `profile-pipeline` are migrated out of the five-step host loop into declarative Capabilities (loop hooks + a command family); their config keys are federated to capability ownership; and the `plan-phase`/`execute-phase` workflow bodies shrink accordingly. Two previously-declared-but-dead capability gates now actually fire — the security ship-time gate (`ship:pre`) and the UI safety gate (`execute:wave:post`) — and the phase-6 conformance gate is hardened to be un-gameable (rejects empty stubs, requires loop-body shrink, verifies hook dispatch and gate-result contracts). Behavior is preserved, verified across five adversarial review passes. (#1139, #1167, #1168, #1169)

View File

@@ -0,0 +1,48 @@
{
"id": "drift",
"role": "feature",
"title": "Drift detection gates",
"description": "Post-execution drift detection gates that run after each wave completes. Provides two gates at execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md).",
"tier": "full",
"requires": [],
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.drift_threshold": {
"type": "number",
"default": 3,
"description": "Minimum number of new structural elements (directories, barrel exports, migrations, routes) before the codebase drift gate triggers a warn or auto-remap action."
},
"workflow.drift_action": {
"type": "enum",
"values": ["warn", "auto-remap"],
"default": "warn",
"description": "Action taken by the codebase drift gate when the threshold is exceeded: warn (advisory message) or auto-remap (spawn gsd-codebase-mapper agent to refresh STRUCTURE.md)."
},
"workflow.schema_drift_gate": {
"type": "boolean",
"default": true,
"description": "Enable the drift gates at execute:wave:post. When enabled, the schema drift gate blocks verification if schema-relevant files changed during execution but no database push command was executed; the codebase drift gate (non-blocking) warns when structural additions exceed the drift_threshold."
}
},
"steps": [],
"contributions": [],
"gates": [
{
"point": "execute:wave:post",
"check": { "query": "verify.schema-drift" },
"when": "workflow.schema_drift_gate",
"blocking": true,
"onError": "skip"
},
{
"point": "execute:wave:post",
"check": { "query": "verify.codebase-drift" },
"when": "workflow.schema_drift_gate",
"blocking": false,
"onError": "skip"
}
]
}

View File

@@ -0,0 +1,32 @@
{
"id": "gap-analysis",
"role": "feature",
"title": "Post-planning gap analysis",
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
"tier": "standard",
"requires": [],
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.post_planning_gaps": {
"type": "boolean",
"default": true,
"description": "Run the post-planning gap analysis report after plans are generated."
}
},
"steps": [],
"contributions": [],
"gates": [
{
"point": "plan:post",
"check": {
"query": "gap-analysis.plan-post"
},
"when": "workflow.post_planning_gaps",
"blocking": false,
"onError": "skip"
}
]
}

View File

@@ -23,7 +23,16 @@
} }
], ],
"hooks": [], "hooks": [],
"steps": [], "steps": [
{
"point": "plan:pre",
"ref": { "command": "intel api-surface" },
"produces": [".planning/intel/API-SURFACE.md"],
"consumes": [],
"when": "intel.enabled",
"onError": "skip"
}
],
"contributions": [], "contributions": [],
"gates": [] "gates": []
} }

View File

@@ -0,0 +1,64 @@
{
"id": "profile-pipeline",
"role": "feature",
"title": "Developer profiling pipeline",
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
"tier": "full",
"requires": [],
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
"skills": ["profile-user"],
"agents": ["gsd-user-profiler"],
"config": {
"profile-pipeline.enabled": {
"type": "boolean",
"default": false,
"description": "Enable the developer profiling pipeline commands (scan-sessions, extract-messages, profile-sample, write-profile, etc.)."
}
},
"commands": [
{
"family": "scan-sessions",
"module": "profile-pipeline-command-router.cjs",
"router": "routeScanSessions"
},
{
"family": "extract-messages",
"module": "profile-pipeline-command-router.cjs",
"router": "routeExtractMessages"
},
{
"family": "profile-sample",
"module": "profile-pipeline-command-router.cjs",
"router": "routeProfileSample"
},
{
"family": "write-profile",
"module": "profile-pipeline-command-router.cjs",
"router": "routeWriteProfile"
},
{
"family": "profile-questionnaire",
"module": "profile-pipeline-command-router.cjs",
"router": "routeProfileQuestionnaire"
},
{
"family": "generate-dev-preferences",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateDevPreferences"
},
{
"family": "generate-claude-profile",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateClaudeProfile"
},
{
"family": "generate-claude-md",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateClaudeMd"
}
],
"hooks": [],
"steps": [],
"contributions": [],
"gates": []
}

View File

@@ -0,0 +1,32 @@
{
"id": "schema-gate",
"role": "feature",
"title": "Schema push detection gate",
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
"tier": "full",
"requires": [],
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.schema_push_detection": {
"type": "boolean",
"default": true,
"description": "Enable ORM schema push detection during planning. When schema-relevant files are detected in the phase scope, a [BLOCKING] push task is injected into the plan."
}
},
"steps": [],
"contributions": [
{
"point": "plan:pre",
"into": "planner",
"fragment": { "path": "fragments/plan-pre.md" },
"produces": [],
"consumes": ["CONTEXT.md"],
"when": "workflow.schema_push_detection",
"onError": "skip"
}
],
"gates": []
}

View File

@@ -0,0 +1,61 @@
# Schema Push Detection Gate
> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.
Check if any files in the phase scope match schema patterns:
```bash
PHASE_SECTION=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section 2>/dev/null)
```
Scan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:
| ORM | File Patterns |
|-----|--------------|
| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |
| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |
| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |
| Supabase | `supabase/migrations/*.sql` |
| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |
Also check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.
**If schema-relevant files detected:**
Set `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.
Determine the push command for the detected ORM:
| ORM | Push Command | Non-TTY Workaround |
|-----|-------------|-------------------|
| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |
| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |
| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |
| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |
| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |
Inject the following into the planner prompt (step 8) as an additional constraint:
```markdown
<schema_push_requirement>
**[BLOCKING] Schema Push Required**
This phase modifies schema-relevant files ({detected_files}). The planner MUST include
a `[BLOCKING]` task that runs the database schema push command AFTER all schema file
modifications are complete but BEFORE verification.
- ORM detected: {SCHEMA_ORM}
- Push command: {push_command}
- Non-TTY workaround: {env_hint}
- If push requires interactive prompts that cannot be suppressed, flag the task for
manual intervention with `autonomous: false`
This task is mandatory — the phase CANNOT pass verification without it. Build and
type checks will pass without the push (types come from config, not the live database),
creating a false-positive verification state.
</schema_push_requirement>
```
Display: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`
**If no schema-relevant files detected:** Skip silently.

View File

@@ -44,6 +44,10 @@
"fragment": { "fragment": {
"inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on." "inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on."
}, },
"configValues": {
"security_asvs_level": "workflow.security_asvs_level",
"security_block_on": "workflow.security_block_on"
},
"produces": [], "produces": [],
"consumes": ["CONTEXT.md"], "consumes": ["CONTEXT.md"],
"when": "workflow.security_enforcement" "when": "workflow.security_enforcement"

View File

@@ -0,0 +1,44 @@
{
"id": "tdd",
"role": "feature",
"title": "Test-driven development",
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
"tier": "full",
"requires": [],
"runtimeCompat": { "supported": ["*"], "unsupported": [] },
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.tdd_mode": {
"type": "boolean",
"default": false,
"description": "Enable TDD mode: planner annotates eligible tasks type:tdd and executor enforces RED/GREEN/REFACTOR gate sequence."
}
},
"steps": [],
"contributions": [
{
"point": "plan:pre",
"into": "planner",
"fragment": {
"inline": "<tdd_mode_active>\n**TDD Mode is ENABLED.** Apply TDD heuristics to all eligible tasks:\n- Business logic with defined I/O → type: tdd\n- API endpoints with request/response contracts → type: tdd\n- Data transformations, validation, algorithms → type: tdd\n- UI, config, glue code, CRUD → standard plan (type: execute)\nEach TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.\n</tdd_mode_active>"
},
"produces": [],
"consumes": [],
"when": "workflow.tdd_mode",
"onError": "skip"
}
],
"gates": [
{
"point": "execute:post",
"check": {
"query": "tdd.review-checkpoint"
},
"when": "workflow.tdd_mode",
"blocking": false,
"onError": "skip"
}
]
}

View File

@@ -335,6 +335,7 @@
"planning-workspace.cjs", "planning-workspace.cjs",
"probe-core.cjs", "probe-core.cjs",
"profile-output.cjs", "profile-output.cjs",
"profile-pipeline-command-router.cjs",
"profile-pipeline.cjs", "profile-pipeline.cjs",
"project-root.cjs", "project-root.cjs",
"prompt-budget.cjs", "prompt-budget.cjs",

View File

@@ -372,7 +372,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
--- ---
## CLI Modules (112 shipped) ## CLI Modules (113 shipped)
Full listing: `gsd-core/bin/lib/*.cjs`. Full listing: `gsd-core/bin/lib/*.cjs`.
@@ -446,6 +446,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) | | `planning-workspace.cjs` | Planning path/workstream seam (`planningDir`, `planningPaths`, active-workstream routing, `.planning/.lock` orchestration) |
| `project-root.cjs` | Resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic) | | `project-root.cjs` | Resolves a project root from a starting directory using four heuristics (own `.planning/` guard, `sub_repos` config, `multiRepo` flag, `.git` heuristic) |
| `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation |
| `profile-pipeline-command-router.cjs` | ADR-959 capability command router for the profile-pipeline command family — dispatches scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase); phase 6 cutover |
| `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning |
| `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) | | `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) |
| `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) | | `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) |

View File

@@ -201,8 +201,6 @@ const milestone = require('./lib/milestone.cjs');
const commands = require('./lib/commands.cjs'); const commands = require('./lib/commands.cjs');
const init = require('./lib/init.cjs'); const init = require('./lib/init.cjs');
const frontmatter = require('./lib/frontmatter.cjs'); const frontmatter = require('./lib/frontmatter.cjs');
const profilePipeline = require('./lib/profile-pipeline.cjs');
const profileOutput = require('./lib/profile-output.cjs');
const workstream = require('./lib/workstream.cjs'); const workstream = require('./lib/workstream.cjs');
const docs = require('./lib/docs.cjs'); const docs = require('./lib/docs.cjs');
const learnings = require('./lib/learnings.cjs'); const learnings = require('./lib/learnings.cjs');
@@ -1250,8 +1248,24 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
} }
loopConfigDir = value; loopConfigDir = value;
} }
// --active-cap <capId>: parse and validate before delegating
let loopActiveCap = undefined;
const activeCapEqArg = args.find(arg => arg.startsWith('--active-cap='));
const activeCapIdx = args.indexOf('--active-cap');
if (activeCapEqArg) {
const value = activeCapEqArg.slice('--active-cap='.length).trim();
if (!value) error('Missing value for --active-cap (e.g. --active-cap tdd)', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
loopActiveCap = value;
} else if (activeCapIdx !== -1) {
const value = args[activeCapIdx + 1];
if (!value || value.startsWith('--')) {
error('Missing value for --active-cap (e.g. --active-cap tdd)', core.ERROR_REASON ? core.ERROR_REASON.USAGE : undefined);
}
loopActiveCap = value;
}
loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, { loopResolver.cmdLoopRenderHooks(cwd, args[2], raw, {
configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined, configDir: loopConfigDir ? path.resolve(loopConfigDir) : undefined,
activeCap: loopActiveCap,
}); });
} else { } else {
error( error(
@@ -1321,94 +1335,6 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
break; break;
} }
// ─── Profiling Pipeline ────────────────────────────────────────────────
case 'scan-sessions': {
const pathIdx = args.indexOf('--path');
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const verboseFlag = args.includes('--verbose');
const jsonFlag = args.includes('--json');
await profilePipeline.cmdScanSessions(sessionsPath, { verbose: verboseFlag, json: jsonFlag }, raw);
break;
}
case 'extract-messages': {
const sessionIdx = args.indexOf('--session');
const sessionId = sessionIdx !== -1 ? args[sessionIdx + 1] : null;
const limitIdx = args.indexOf('--limit');
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : null;
const pathIdx = args.indexOf('--path');
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const projectArg = args[1];
if (!projectArg || projectArg.startsWith('--')) {
error('Usage: gsd-tools extract-messages <project> [--session <id>] [--limit N] [--path <dir>]\nRun scan-sessions first to see available projects.', ERROR_REASON.USAGE);
}
await profilePipeline.cmdExtractMessages(projectArg, { sessionId, limit }, raw, sessionsPath);
break;
}
case 'profile-sample': {
const pathIdx = args.indexOf('--path');
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const limitIdx = args.indexOf('--limit');
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 150;
const maxPerIdx = args.indexOf('--max-per-project');
const maxPerProject = maxPerIdx !== -1 ? parseInt(args[maxPerIdx + 1], 10) : null;
const maxCharsIdx = args.indexOf('--max-chars');
const maxChars = maxCharsIdx !== -1 ? parseInt(args[maxCharsIdx + 1], 10) : 500;
await profilePipeline.cmdProfileSample(sessionsPath, { limit, maxPerProject, maxChars }, raw);
break;
}
// ─── Profile Output ──────────────────────────────────────────────────
case 'write-profile': {
const inputIdx = args.indexOf('--input');
const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
if (!inputPath) error('--input <analysis-json-path> is required', ERROR_REASON.USAGE);
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
profileOutput.cmdWriteProfile(cwd, { input: inputPath, output: outputPath }, raw);
break;
}
case 'profile-questionnaire': {
const answersIdx = args.indexOf('--answers');
const answers = answersIdx !== -1 ? args[answersIdx + 1] : null;
profileOutput.cmdProfileQuestionnaire({ answers }, raw);
break;
}
case 'generate-dev-preferences': {
const analysisIdx = args.indexOf('--analysis');
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
const stackIdx = args.indexOf('--stack');
const stack = stackIdx !== -1 ? args[stackIdx + 1] : null;
profileOutput.cmdGenerateDevPreferences(cwd, { analysis: analysisPath, output: outputPath, stack }, raw);
break;
}
case 'generate-claude-profile': {
const analysisIdx = args.indexOf('--analysis');
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
const globalFlag = args.includes('--global');
profileOutput.cmdGenerateClaudeProfile(cwd, { analysis: analysisPath, output: outputPath, global: globalFlag }, raw);
break;
}
case 'generate-claude-md': {
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
const autoFlag = args.includes('--auto');
const forceFlag = args.includes('--force');
profileOutput.cmdGenerateClaudeMd(cwd, { output: outputPath, auto: autoFlag, force: forceFlag }, raw);
break;
}
case 'workstream': { case 'workstream': {
const subcommand = args[1]; const subcommand = args[1];
if (subcommand === 'create') { if (subcommand === 'create') {

View File

@@ -595,6 +595,103 @@ const capabilities = {
"extendedHookEvents": [] "extendedHookEvents": []
} }
}, },
"drift": {
"id": "drift",
"role": "feature",
"title": "Drift detection gates",
"description": "Post-execution drift detection gates that run after each wave completes. Provides two gates at execute:wave:post: a blocking schema drift gate (detects schema files changed without a database push) and a non-blocking codebase drift gate (detects structural additions not reflected in STRUCTURE.md).",
"tier": "full",
"requires": [],
"runtimeCompat": {
"supported": [
"*"
],
"unsupported": []
},
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.drift_threshold": {
"type": "number",
"default": 3,
"description": "Minimum number of new structural elements (directories, barrel exports, migrations, routes) before the codebase drift gate triggers a warn or auto-remap action."
},
"workflow.drift_action": {
"type": "enum",
"values": [
"warn",
"auto-remap"
],
"default": "warn",
"description": "Action taken by the codebase drift gate when the threshold is exceeded: warn (advisory message) or auto-remap (spawn gsd-codebase-mapper agent to refresh STRUCTURE.md)."
},
"workflow.schema_drift_gate": {
"type": "boolean",
"default": true,
"description": "Enable the drift gates at execute:wave:post. When enabled, the schema drift gate blocks verification if schema-relevant files changed during execution but no database push command was executed; the codebase drift gate (non-blocking) warns when structural additions exceed the drift_threshold."
}
},
"steps": [],
"contributions": [],
"gates": [
{
"point": "execute:wave:post",
"check": {
"query": "verify.schema-drift"
},
"when": "workflow.schema_drift_gate",
"blocking": true,
"onError": "skip"
},
{
"point": "execute:wave:post",
"check": {
"query": "verify.codebase-drift"
},
"when": "workflow.schema_drift_gate",
"blocking": false,
"onError": "skip"
}
]
},
"gap-analysis": {
"id": "gap-analysis",
"role": "feature",
"title": "Post-planning gap analysis",
"description": "Proactive, non-blocking post-planning coverage report. After all PLAN.md files are generated, cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md against plan bodies. Emits a Source | Item | Status table. Does not block phase advancement.",
"tier": "standard",
"requires": [],
"runtimeCompat": {
"supported": [
"*"
],
"unsupported": []
},
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.post_planning_gaps": {
"type": "boolean",
"default": true,
"description": "Run the post-planning gap analysis report after plans are generated."
}
},
"steps": [],
"contributions": [],
"gates": [
{
"point": "plan:post",
"check": {
"query": "gap-analysis.plan-post"
},
"when": "workflow.post_planning_gaps",
"blocking": false,
"onError": "skip"
}
]
},
"gemini": { "gemini": {
"id": "gemini", "id": "gemini",
"role": "runtime", "role": "runtime",
@@ -763,7 +860,20 @@ const capabilities = {
} }
], ],
"hooks": [], "hooks": [],
"steps": [], "steps": [
{
"point": "plan:pre",
"ref": {
"command": "intel api-surface"
},
"produces": [
".planning/intel/API-SURFACE.md"
],
"consumes": [],
"when": "intel.enabled",
"onError": "skip"
}
],
"contributions": [], "contributions": [],
"gates": [] "gates": []
}, },
@@ -1052,6 +1162,79 @@ const capabilities = {
"contributions": [], "contributions": [],
"gates": [] "gates": []
}, },
"profile-pipeline": {
"id": "profile-pipeline",
"role": "feature",
"title": "Developer profiling pipeline",
"description": "Developer behavioral profiling from Claude Code session history; scans session JSONL files, extracts and samples user messages, and generates profile artifacts (USER-PROFILE.md, dev-preferences.md, CLAUDE.md sections). Exposes eight `gsd-tools` commands: scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase). Backs the /gsd-profile-user skill and gsd-user-profiler agent.",
"tier": "full",
"requires": [],
"runtimeCompat": {
"supported": [
"*"
],
"unsupported": []
},
"skills": [
"profile-user"
],
"agents": [
"gsd-user-profiler"
],
"config": {
"profile-pipeline.enabled": {
"type": "boolean",
"default": false,
"description": "Enable the developer profiling pipeline commands (scan-sessions, extract-messages, profile-sample, write-profile, etc.)."
}
},
"commands": [
{
"family": "scan-sessions",
"module": "profile-pipeline-command-router.cjs",
"router": "routeScanSessions"
},
{
"family": "extract-messages",
"module": "profile-pipeline-command-router.cjs",
"router": "routeExtractMessages"
},
{
"family": "profile-sample",
"module": "profile-pipeline-command-router.cjs",
"router": "routeProfileSample"
},
{
"family": "write-profile",
"module": "profile-pipeline-command-router.cjs",
"router": "routeWriteProfile"
},
{
"family": "profile-questionnaire",
"module": "profile-pipeline-command-router.cjs",
"router": "routeProfileQuestionnaire"
},
{
"family": "generate-dev-preferences",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateDevPreferences"
},
{
"family": "generate-claude-profile",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateClaudeProfile"
},
{
"family": "generate-claude-md",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateClaudeMd"
}
],
"hooks": [],
"steps": [],
"contributions": [],
"gates": []
},
"qwen": { "qwen": {
"id": "qwen", "id": "qwen",
"role": "runtime", "role": "runtime",
@@ -1153,6 +1336,48 @@ const capabilities = {
"contributions": [], "contributions": [],
"gates": [] "gates": []
}, },
"schema-gate": {
"id": "schema-gate",
"role": "feature",
"title": "Schema push detection gate",
"description": "Detects ORM schema-relevant files in the phase scope during planning and injects a mandatory [BLOCKING] schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.",
"tier": "full",
"requires": [],
"runtimeCompat": {
"supported": [
"*"
],
"unsupported": []
},
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.schema_push_detection": {
"type": "boolean",
"default": true,
"description": "Enable ORM schema push detection during planning. When schema-relevant files are detected in the phase scope, a [BLOCKING] push task is injected into the plan."
}
},
"steps": [],
"contributions": [
{
"point": "plan:pre",
"into": "planner",
"fragment": {
"path": "fragments/plan-pre.md",
"inline": "# Schema Push Detection Gate\n\n> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.\n\nCheck if any files in the phase scope match schema patterns:\n\n```bash\nPHASE_SECTION=$(gsd_run query roadmap.get-phase \"${PHASE}\" --pick section 2>/dev/null)\n```\n\nScan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:\n\n| ORM | File Patterns |\n|-----|--------------|\n| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |\n| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |\n| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |\n| Supabase | `supabase/migrations/*.sql` |\n| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |\n\nAlso check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.\n\n**If schema-relevant files detected:**\n\nSet `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.\n\nDetermine the push command for the detected ORM:\n\n| ORM | Push Command | Non-TTY Workaround |\n|-----|-------------|-------------------|\n| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |\n| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |\n| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |\n| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |\n| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |\n\nInject the following into the planner prompt (step 8) as an additional constraint:\n\n```markdown\n<schema_push_requirement>\n**[BLOCKING] Schema Push Required**\n\nThis phase modifies schema-relevant files ({detected_files}). The planner MUST include\na `[BLOCKING]` task that runs the database schema push command AFTER all schema file\nmodifications are complete but BEFORE verification.\n\n- ORM detected: {SCHEMA_ORM}\n- Push command: {push_command}\n- Non-TTY workaround: {env_hint}\n- If push requires interactive prompts that cannot be suppressed, flag the task for\n manual intervention with `autonomous: false`\n\nThis task is mandatory — the phase CANNOT pass verification without it. Build and\ntype checks will pass without the push (types come from config, not the live database),\ncreating a false-positive verification state.\n</schema_push_requirement>\n```\n\nDisplay: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`\n\n**If no schema-relevant files detected:** Skip silently.\n"
},
"produces": [],
"consumes": [
"CONTEXT.md"
],
"when": "workflow.schema_push_detection",
"onError": "skip"
}
],
"gates": []
},
"security": { "security": {
"id": "security", "id": "security",
"role": "feature", "role": "feature",
@@ -1220,6 +1445,10 @@ const capabilities = {
"fragment": { "fragment": {
"inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on." "inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on."
}, },
"configValues": {
"security_asvs_level": "workflow.security_asvs_level",
"security_block_on": "workflow.security_block_on"
},
"produces": [], "produces": [],
"consumes": [ "consumes": [
"CONTEXT.md" "CONTEXT.md"
@@ -1244,6 +1473,55 @@ const capabilities = {
} }
] ]
}, },
"tdd": {
"id": "tdd",
"role": "feature",
"title": "Test-driven development",
"description": "Injects TDD heuristics into the planner and enforces RED/GREEN gate compliance on type:tdd plans after execution. Owns workflow.tdd_mode; the --tdd CLI flag is the ephemeral override.",
"tier": "full",
"requires": [],
"runtimeCompat": {
"supported": [
"*"
],
"unsupported": []
},
"skills": [],
"agents": [],
"hooks": [],
"config": {
"workflow.tdd_mode": {
"type": "boolean",
"default": false,
"description": "Enable TDD mode: planner annotates eligible tasks type:tdd and executor enforces RED/GREEN/REFACTOR gate sequence."
}
},
"steps": [],
"contributions": [
{
"point": "plan:pre",
"into": "planner",
"fragment": {
"inline": "<tdd_mode_active>\n**TDD Mode is ENABLED.** Apply TDD heuristics to all eligible tasks:\n- Business logic with defined I/O → type: tdd\n- API endpoints with request/response contracts → type: tdd\n- Data transformations, validation, algorithms → type: tdd\n- UI, config, glue code, CRUD → standard plan (type: execute)\nEach TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.\n</tdd_mode_active>"
},
"produces": [],
"consumes": [],
"when": "workflow.tdd_mode",
"onError": "skip"
}
],
"gates": [
{
"point": "execute:post",
"check": {
"query": "tdd.review-checkpoint"
},
"when": "workflow.tdd_mode",
"blocking": false,
"onError": "skip"
}
]
},
"trae": { "trae": {
"id": "trae", "id": "trae",
"role": "runtime", "role": "runtime",
@@ -1439,6 +1717,7 @@ const bySkill = {
"code-review": "code-review", "code-review": "code-review",
"graphify": "graphify", "graphify": "graphify",
"validate-phase": "nyquist", "validate-phase": "nyquist",
"profile-user": "profile-pipeline",
"secure-phase": "security", "secure-phase": "security",
"ui-phase": "ui", "ui-phase": "ui",
"ui-review": "ui" "ui-review": "ui"
@@ -1453,6 +1732,7 @@ const byAgent = {
"gsd-code-fixer": "code-review", "gsd-code-fixer": "code-review",
"gsd-nyquist-auditor": "nyquist", "gsd-nyquist-auditor": "nyquist",
"gsd-pattern-mapper": "pattern-mapper", "gsd-pattern-mapper": "pattern-mapper",
"gsd-user-profiler": "profile-pipeline",
"gsd-phase-researcher": "research", "gsd-phase-researcher": "research",
"gsd-security-auditor": "security", "gsd-security-auditor": "security",
"gsd-ui-checker": "ui", "gsd-ui-checker": "ui",
@@ -1487,6 +1767,19 @@ const byLoopPoint = {
"when": "workflow.ai_integration_phase", "when": "workflow.ai_integration_phase",
"onError": "skip" "onError": "skip"
}, },
{
"capId": "intel",
"point": "plan:pre",
"ref": {
"command": "intel api-surface"
},
"produces": [
".planning/intel/API-SURFACE.md"
],
"consumes": [],
"when": "intel.enabled",
"onError": "skip"
},
{ {
"capId": "research", "capId": "research",
"point": "plan:pre", "point": "plan:pre",
@@ -1542,6 +1835,21 @@ const byLoopPoint = {
} }
], ],
"contributions": [ "contributions": [
{
"capId": "schema-gate",
"point": "plan:pre",
"into": "planner",
"fragment": {
"path": "fragments/plan-pre.md",
"inline": "# Schema Push Detection Gate\n\n> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.\n\nCheck if any files in the phase scope match schema patterns:\n\n```bash\nPHASE_SECTION=$(gsd_run query roadmap.get-phase \"${PHASE}\" --pick section 2>/dev/null)\n```\n\nScan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:\n\n| ORM | File Patterns |\n|-----|--------------|\n| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |\n| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |\n| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |\n| Supabase | `supabase/migrations/*.sql` |\n| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |\n\nAlso check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.\n\n**If schema-relevant files detected:**\n\nSet `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.\n\nDetermine the push command for the detected ORM:\n\n| ORM | Push Command | Non-TTY Workaround |\n|-----|-------------|-------------------|\n| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |\n| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |\n| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |\n| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |\n| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |\n\nInject the following into the planner prompt (step 8) as an additional constraint:\n\n```markdown\n<schema_push_requirement>\n**[BLOCKING] Schema Push Required**\n\nThis phase modifies schema-relevant files ({detected_files}). The planner MUST include\na `[BLOCKING]` task that runs the database schema push command AFTER all schema file\nmodifications are complete but BEFORE verification.\n\n- ORM detected: {SCHEMA_ORM}\n- Push command: {push_command}\n- Non-TTY workaround: {env_hint}\n- If push requires interactive prompts that cannot be suppressed, flag the task for\n manual intervention with `autonomous: false`\n\nThis task is mandatory — the phase CANNOT pass verification without it. Build and\ntype checks will pass without the push (types come from config, not the live database),\ncreating a false-positive verification state.\n</schema_push_requirement>\n```\n\nDisplay: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`\n\n**If no schema-relevant files detected:** Skip silently.\n"
},
"produces": [],
"consumes": [
"CONTEXT.md"
],
"when": "workflow.schema_push_detection",
"onError": "skip"
},
{ {
"capId": "security", "capId": "security",
"point": "plan:pre", "point": "plan:pre",
@@ -1549,11 +1857,27 @@ const byLoopPoint = {
"fragment": { "fragment": {
"inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on." "inline": "Each PLAN.md must include a <threat_model> block when security enforcement is active. Use the configured ASVS level and blocking threshold from workflow.security_asvs_level and workflow.security_block_on."
}, },
"configValues": {
"security_asvs_level": "workflow.security_asvs_level",
"security_block_on": "workflow.security_block_on"
},
"produces": [], "produces": [],
"consumes": [ "consumes": [
"CONTEXT.md" "CONTEXT.md"
], ],
"when": "workflow.security_enforcement" "when": "workflow.security_enforcement"
},
{
"capId": "tdd",
"point": "plan:pre",
"into": "planner",
"fragment": {
"inline": "<tdd_mode_active>\n**TDD Mode is ENABLED.** Apply TDD heuristics to all eligible tasks:\n- Business logic with defined I/O → type: tdd\n- API endpoints with request/response contracts → type: tdd\n- Data transformations, validation, algorithms → type: tdd\n- UI, config, glue code, CRUD → standard plan (type: execute)\nEach TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.\n</tdd_mode_active>"
},
"produces": [],
"consumes": [],
"when": "workflow.tdd_mode",
"onError": "skip"
} }
], ],
"gates": [ "gates": [
@@ -1572,7 +1896,18 @@ const byLoopPoint = {
"plan:post": { "plan:post": {
"steps": [], "steps": [],
"contributions": [], "contributions": [],
"gates": [] "gates": [
{
"capId": "gap-analysis",
"point": "plan:post",
"check": {
"query": "gap-analysis.plan-post"
},
"when": "workflow.post_planning_gaps",
"blocking": false,
"onError": "skip"
}
]
}, },
"execute:pre": { "execute:pre": {
"steps": [], "steps": [],
@@ -1588,6 +1923,26 @@ const byLoopPoint = {
"steps": [], "steps": [],
"contributions": [], "contributions": [],
"gates": [ "gates": [
{
"capId": "drift",
"point": "execute:wave:post",
"check": {
"query": "verify.schema-drift"
},
"when": "workflow.schema_drift_gate",
"blocking": true,
"onError": "skip"
},
{
"capId": "drift",
"point": "execute:wave:post",
"check": {
"query": "verify.codebase-drift"
},
"when": "workflow.schema_drift_gate",
"blocking": false,
"onError": "skip"
},
{ {
"capId": "ui", "capId": "ui",
"point": "execute:wave:post", "point": "execute:wave:post",
@@ -1619,7 +1974,18 @@ const byLoopPoint = {
} }
], ],
"contributions": [], "contributions": [],
"gates": [] "gates": [
{
"capId": "tdd",
"point": "execute:post",
"check": {
"query": "tdd.review-checkpoint"
},
"when": "workflow.tdd_mode",
"blocking": false,
"onError": "skip"
}
]
}, },
"verify:pre": { "verify:pre": {
"steps": [], "steps": [],
@@ -1709,14 +2075,21 @@ const configKeys = {
"workflow.ai_integration_phase": "ai-integration", "workflow.ai_integration_phase": "ai-integration",
"workflow.code_review": "code-review", "workflow.code_review": "code-review",
"workflow.code_review_depth": "code-review", "workflow.code_review_depth": "code-review",
"workflow.drift_threshold": "drift",
"workflow.drift_action": "drift",
"workflow.schema_drift_gate": "drift",
"workflow.post_planning_gaps": "gap-analysis",
"graphify.enabled": "graphify", "graphify.enabled": "graphify",
"intel.enabled": "intel", "intel.enabled": "intel",
"workflow.nyquist_validation": "nyquist", "workflow.nyquist_validation": "nyquist",
"workflow.pattern_mapper": "pattern-mapper", "workflow.pattern_mapper": "pattern-mapper",
"profile-pipeline.enabled": "profile-pipeline",
"workflow.research": "research", "workflow.research": "research",
"workflow.schema_push_detection": "schema-gate",
"workflow.security_enforcement": "security", "workflow.security_enforcement": "security",
"workflow.security_asvs_level": "security", "workflow.security_asvs_level": "security",
"workflow.security_block_on": "security", "workflow.security_block_on": "security",
"workflow.tdd_mode": "tdd",
"workflow.ui_phase": "ui", "workflow.ui_phase": "ui",
"workflow.ui_review": "ui", "workflow.ui_review": "ui",
"workflow.ui_safety_gate": "ui" "workflow.ui_safety_gate": "ui"
@@ -1746,6 +2119,34 @@ const configSchema = {
"deep" "deep"
] ]
}, },
"workflow.drift_threshold": {
"owner": "drift",
"type": "number",
"default": 3,
"description": "Minimum number of new structural elements (directories, barrel exports, migrations, routes) before the codebase drift gate triggers a warn or auto-remap action."
},
"workflow.drift_action": {
"owner": "drift",
"type": "enum",
"default": "warn",
"description": "Action taken by the codebase drift gate when the threshold is exceeded: warn (advisory message) or auto-remap (spawn gsd-codebase-mapper agent to refresh STRUCTURE.md).",
"values": [
"warn",
"auto-remap"
]
},
"workflow.schema_drift_gate": {
"owner": "drift",
"type": "boolean",
"default": true,
"description": "Enable the drift gates at execute:wave:post. When enabled, the schema drift gate blocks verification if schema-relevant files changed during execution but no database push command was executed; the codebase drift gate (non-blocking) warns when structural additions exceed the drift_threshold."
},
"workflow.post_planning_gaps": {
"owner": "gap-analysis",
"type": "boolean",
"default": true,
"description": "Run the post-planning gap analysis report after plans are generated."
},
"graphify.enabled": { "graphify.enabled": {
"owner": "graphify", "owner": "graphify",
"type": "boolean", "type": "boolean",
@@ -1770,12 +2171,24 @@ const configSchema = {
"default": true, "default": true,
"description": "Run the pattern mapper before planning when context or research is available." "description": "Run the pattern mapper before planning when context or research is available."
}, },
"profile-pipeline.enabled": {
"owner": "profile-pipeline",
"type": "boolean",
"default": false,
"description": "Enable the developer profiling pipeline commands (scan-sessions, extract-messages, profile-sample, write-profile, etc.)."
},
"workflow.research": { "workflow.research": {
"owner": "research", "owner": "research",
"type": "boolean", "type": "boolean",
"default": true, "default": true,
"description": "Run phase research before planning when research artifacts are missing or explicitly refreshed." "description": "Run phase research before planning when research artifacts are missing or explicitly refreshed."
}, },
"workflow.schema_push_detection": {
"owner": "schema-gate",
"type": "boolean",
"default": true,
"description": "Enable ORM schema push detection during planning. When schema-relevant files are detected in the phase scope, a [BLOCKING] push task is injected into the plan."
},
"workflow.security_enforcement": { "workflow.security_enforcement": {
"owner": "security", "owner": "security",
"type": "boolean", "type": "boolean",
@@ -1801,6 +2214,12 @@ const configSchema = {
"none" "none"
] ]
}, },
"workflow.tdd_mode": {
"owner": "tdd",
"type": "boolean",
"default": false,
"description": "Enable TDD mode: planner annotates eligible tasks type:tdd and executor enforces RED/GREEN/REFACTOR gate sequence."
},
"workflow.ui_phase": { "workflow.ui_phase": {
"owner": "ui", "owner": "ui",
"type": "boolean", "type": "boolean",
@@ -2725,6 +3144,26 @@ const commandFamilies = {
"module": "audit-command-router.cjs", "module": "audit-command-router.cjs",
"router": "routeAuditUat" "router": "routeAuditUat"
}, },
"extract-messages": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeExtractMessages"
},
"generate-claude-md": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateClaudeMd"
},
"generate-claude-profile": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateClaudeProfile"
},
"generate-dev-preferences": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeGenerateDevPreferences"
},
"graphify": { "graphify": {
"capId": "graphify", "capId": "graphify",
"module": "graphify-command-router.cjs", "module": "graphify-command-router.cjs",
@@ -2734,6 +3173,26 @@ const commandFamilies = {
"capId": "intel", "capId": "intel",
"module": "intel-command-router.cjs", "module": "intel-command-router.cjs",
"router": "routeIntelCommand" "router": "routeIntelCommand"
},
"profile-questionnaire": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeProfileQuestionnaire"
},
"profile-sample": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeProfileSample"
},
"scan-sessions": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeScanSessions"
},
"write-profile": {
"capId": "profile-pipeline",
"module": "profile-pipeline-command-router.cjs",
"router": "routeWriteProfile"
} }
}; };
@@ -2750,6 +3209,9 @@ const capabilityClusters = {
"nyquist": [ "nyquist": [
"validate-phase" "validate-phase"
], ],
"profile-pipeline": [
"profile-user"
],
"security": [ "security": [
"secure-phase" "secure-phase"
], ],
@@ -2784,6 +3246,12 @@ const profileMembership = {
"full" "full"
] ]
}, },
"profile-pipeline": {
"tier": "full",
"profiles": [
"full"
]
},
"security": { "security": {
"tier": "full", "tier": "full",
"profiles": [ "profiles": [
@@ -2810,6 +3278,8 @@ const _requiresGraph = {
"codex": [], "codex": [],
"copilot": [], "copilot": [],
"cursor": [], "cursor": [],
"drift": [],
"gap-analysis": [],
"gemini": [], "gemini": [],
"graphify": [], "graphify": [],
"hermes": [], "hermes": [],
@@ -2821,9 +3291,12 @@ const _requiresGraph = {
"pattern-mapper": [ "pattern-mapper": [
"research" "research"
], ],
"profile-pipeline": [],
"qwen": [], "qwen": [],
"research": [], "research": [],
"schema-gate": [],
"security": [], "security": [],
"tdd": [],
"trae": [], "trae": [],
"ui": [], "ui": [],
"windsurf": [] "windsurf": []

View File

@@ -0,0 +1,138 @@
'use strict';
/**
* Profile-pipeline command router — CLI dispatcher for gsd-tools profiling commands.
*
* ADR-857 phase 6 / ADR-959: profile-pipeline capability command cutover.
* Extracted from hardcoded case arms in gsd-tools.cjs (lines 1324-1410).
* Dispatch path: default → dispatchCapabilityCommand →
* require(profile-pipeline-command-router.cjs) → route<X>.
*
* Router signature: { args, cwd, raw, error } — identical to existing routers.
* Test seams: _pipeline / _output inject mock modules; _core injects mock core.
*
* Async note: cmdExtractMessages and cmdProfileSample are async functions.
* dispatchCapabilityCommand (gsd-tools.cjs:366-371) explicitly errors if a
* router returns a Promise. Therefore these router functions call the async
* function WITHOUT await and WITHOUT returning the Promise. The async functions
* end with output() or process.exit() so the process terminates correctly once
* the event loop drains. Unhandled rejections are caught by the .catch() wrapper
* to surface errors via the error() callback.
*/
const { ERROR_REASON } = require('./io.cjs');
// ─── Pipeline phase commands ───────────────────────────────────────────────────
function routeScanSessions({ args, cwd, raw, error, _pipeline }) {
void cwd; void error;
const p = _pipeline ?? require('./profile-pipeline.cjs');
const pathIdx = args.indexOf('--path');
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const verboseFlag = args.includes('--verbose');
const jsonFlag = args.includes('--json');
// cmdScanSessions is synchronous — call directly.
p.cmdScanSessions(sessionsPath, { verbose: verboseFlag, json: jsonFlag }, raw);
}
function routeExtractMessages({ args, cwd, raw, error, _pipeline }) {
const p = _pipeline ?? require('./profile-pipeline.cjs');
const sessionIdx = args.indexOf('--session');
const sessionId = sessionIdx !== -1 ? args[sessionIdx + 1] : null;
const limitIdx = args.indexOf('--limit');
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : null;
const pathIdx = args.indexOf('--path');
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
// args[0] = 'extract-messages' (family name), args[1] = project positional
const projectArg = args[1];
if (!projectArg || projectArg.startsWith('--')) {
error('Usage: gsd-tools extract-messages <project> [--session <id>] [--limit N] [--path <dir>]\nRun scan-sessions first to see available projects.', ERROR_REASON.USAGE);
return;
}
// cmdExtractMessages is async — do NOT return the Promise.
// The function ends with output() or process.exit(); the event loop will drain.
void cwd;
p.cmdExtractMessages(projectArg, { sessionId, limit }, raw, sessionsPath)
.catch(e => { error(e && e.message ? e.message : String(e)); });
}
function routeProfileSample({ args, cwd, raw, error, _pipeline }) {
void cwd; void error;
const p = _pipeline ?? require('./profile-pipeline.cjs');
const pathIdx = args.indexOf('--path');
const sessionsPath = pathIdx !== -1 ? args[pathIdx + 1] : null;
const limitIdx = args.indexOf('--limit');
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 150;
const maxPerIdx = args.indexOf('--max-per-project');
const maxPerProject = maxPerIdx !== -1 ? parseInt(args[maxPerIdx + 1], 10) : null;
const maxCharsIdx = args.indexOf('--max-chars');
const maxChars = maxCharsIdx !== -1 ? parseInt(args[maxCharsIdx + 1], 10) : 500;
// cmdProfileSample is async — do NOT return the Promise.
p.cmdProfileSample(sessionsPath, { limit, maxPerProject, maxChars }, raw)
.catch(e => { error(e && e.message ? e.message : String(e)); });
}
// ─── Output phase commands ─────────────────────────────────────────────────────
function routeWriteProfile({ args, cwd, raw, error, _output }) {
const o = _output ?? require('./profile-output.cjs');
const inputIdx = args.indexOf('--input');
const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null;
if (!inputPath) {
error('--input <analysis-json-path> is required', ERROR_REASON.USAGE);
return;
}
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
o.cmdWriteProfile(cwd, { input: inputPath, output: outputPath }, raw);
}
function routeProfileQuestionnaire({ args, cwd, raw, error, _output }) {
void cwd; void error;
const o = _output ?? require('./profile-output.cjs');
const answersIdx = args.indexOf('--answers');
const answers = answersIdx !== -1 ? args[answersIdx + 1] : null;
o.cmdProfileQuestionnaire({ answers }, raw);
}
function routeGenerateDevPreferences({ args, cwd, raw, error, _output }) {
void error;
const o = _output ?? require('./profile-output.cjs');
const analysisIdx = args.indexOf('--analysis');
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
const stackIdx = args.indexOf('--stack');
const stack = stackIdx !== -1 ? args[stackIdx + 1] : null;
o.cmdGenerateDevPreferences(cwd, { analysis: analysisPath, output: outputPath, stack }, raw);
}
function routeGenerateClaudeProfile({ args, cwd, raw, error, _output }) {
void error;
const o = _output ?? require('./profile-output.cjs');
const analysisIdx = args.indexOf('--analysis');
const analysisPath = analysisIdx !== -1 ? args[analysisIdx + 1] : null;
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
const globalFlag = args.includes('--global');
o.cmdGenerateClaudeProfile(cwd, { analysis: analysisPath, output: outputPath, global: globalFlag }, raw);
}
function routeGenerateClaudeMd({ args, cwd, raw, error, _output }) {
void error;
const o = _output ?? require('./profile-output.cjs');
const outputIdx = args.indexOf('--output');
const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null;
const autoFlag = args.includes('--auto');
const forceFlag = args.includes('--force');
o.cmdGenerateClaudeMd(cwd, { output: outputPath, auto: autoFlag, force: forceFlag }, raw);
}
module.exports = {
routeScanSessions,
routeExtractMessages,
routeProfileSample,
routeWriteProfile,
routeProfileQuestionnaire,
routeGenerateDevPreferences,
routeGenerateClaudeProfile,
routeGenerateClaudeMd,
};

View File

@@ -28,7 +28,6 @@
"verifier": true, "verifier": true,
"nyquist_validation": true, "nyquist_validation": true,
"ai_integration_phase": true, "ai_integration_phase": true,
"tdd_mode": false,
"human_verify_mode": "end-of-phase", "human_verify_mode": "end-of-phase",
"auto_advance": false, "auto_advance": false,
"_auto_chain_active": false, "_auto_chain_active": false,

View File

@@ -15,7 +15,6 @@
"workflow.auto_advance", "workflow.auto_advance",
"workflow.node_repair", "workflow.node_repair",
"workflow.node_repair_budget", "workflow.node_repair_budget",
"workflow.tdd_mode",
"workflow.human_verify_mode", "workflow.human_verify_mode",
"workflow.text_mode", "workflow.text_mode",
"workflow.research_before_questions", "workflow.research_before_questions",
@@ -30,9 +29,6 @@
"workflow.plan_bounce_passes", "workflow.plan_bounce_passes",
"workflow.plan_chunked", "workflow.plan_chunked",
"workflow.plan_review_convergence", "workflow.plan_review_convergence",
"workflow.post_planning_gaps",
"workflow.drift_threshold",
"workflow.drift_action",
"code_quality.fallow.enabled", "code_quality.fallow.enabled",
"code_quality.fallow.scope", "code_quality.fallow.scope",
"code_quality.fallow.profile", "code_quality.fallow.profile",

View File

@@ -177,7 +177,8 @@ Resolve `MVP_MODE` once via the centralized `phase.mvp-mode` query verb (precede
MVP_FLAG_ARG="" MVP_FLAG_ARG=""
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi
MVP_MODE=$(gsd_run query phase.mvp-mode "${PHASE_NUMBER}" $MVP_FLAG_ARG --pick active) MVP_MODE=$(gsd_run query phase.mvp-mode "${PHASE_NUMBER}" $MVP_FLAG_ARG --pick active)
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false") EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw)
TDD_MODE=$(gsd_run loop render-hooks execute:post --active-cap tdd)
``` ```
<step name="safe_resume_gate"> <step name="safe_resume_gate">
@@ -898,6 +899,51 @@ increases monotonically across waves. `{status}` is `complete` (success),
**If no plan in this wave used worktrees** (project-level `USE_WORKTREES=false` OR `WAVE_WORKTREE_PLANS` is empty): sequential agents already updated STATE.md and ROADMAP.md themselves — skip this step. **If no plan in this wave used worktrees** (project-level `USE_WORKTREES=false` OR `WAVE_WORKTREE_PLANS` is empty): sequential agents already updated STATE.md and ROADMAP.md themselves — skip this step.
5.75. **Execute:wave:post capability dispatch:**
After worktree merge, post-merge tests, and tracking updates, dispatch capability hooks registered at `execute:wave:post`. The primary hook is the `ui.safety-gate` gate from the UI capability — it verifies that any frontend files changed in this wave conform to the UI-SPEC contract.
```bash
WAVE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:wave:post --raw)
```
Read the `activeHooks` array from `WAVE_POST_HOOKS_JSON` in-context (do NOT pipe through a shell parser).
**If `activeHooks` is empty or absent:** Skip silently to step 5.8.
**For each active entry where `kind == "gate"`** (process in array order), run the gate check:
```bash
GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_NUMBER}" --raw)
CHECK_EXIT=$?
```
**Step 1 — did the CHECK COMMAND itself succeed?**
If the check command failed (non-zero `CHECK_EXIT`, empty output, or unparseable JSON):
- `onError == "halt"` → treat as a fatal error: stop wave completion, do NOT proceed to step 5.8, and surface: `⚠ Gate check command failed ({hook.capId}): command error. Resolve before continuing.`
- `onError == "skip"` → log a warning and continue to the next hook. Do NOT read `GATE_RESULT.block`.
**Step 2 — read `GATE_RESULT.block` (boolean).** This step is only reached when the command succeeded.
- **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == true`:** HALT — stop wave completion, do NOT proceed to step 5.8, and present:
```
⚠ Wave {N} blocked by capability gate ({hook.capId}): {GATE_RESULT.message}
Resolve before continuing to next wave.
```
This halt is **not** bypassed by `onError` — `onError` only covers command errors (step 1 above), not the gate's block decision.
- **Non-blocking gate (`hook.blocking == false`):** never halts. If `GATE_RESULT.block` is `true` (or non-empty `message`), print `⚠ {hook.capId} advisory (wave {N}): {GATE_RESULT.message}`, then:
- If `GATE_RESULT.spawn_mapper == true` OR `GATE_RESULT.directive == "auto-remap"`: spawn `gsd-codebase-mapper` per `execute-phase/steps/codebase-drift-gate.md`; pass `--paths {GATE_RESULT.affected_paths}`. Continue regardless (wave NOT failed by remap failure).
- Otherwise: continue after advisory.
- If block `false` and no `message`: continue silently.
- **Blocking gate (`hook.blocking == true`) AND `GATE_RESULT.block == false`:** continue silently.
**When all active gates are processed without a blocking halt:** continue to step 5.8.
5.8. **Handle test gate failures (when `WAVE_FAILURE_COUNT > 0`):** 5.8. **Handle test gate failures (when `WAVE_FAILURE_COUNT > 0`):**
``` ```
@@ -1083,59 +1129,6 @@ If an active secure-phase step hook exists AND SECURITY.md exists: check frontma
``` ```
</step> </step>
<step name="tdd_review_checkpoint">
**Optional step — TDD collaborative review.**
```bash
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false")
```
**Skip if `TDD_MODE` is `false`.**
When `TDD_MODE` is `true`, check whether any completed plans in this phase have `type: tdd` in their frontmatter:
```bash
TDD_PLANS=$(grep -rl "^type: tdd" "${PHASE_DIR}"/*-PLAN.md 2>/dev/null | wc -l | tr -d ' ')
```
**If `TDD_PLANS` > 0:** Insert end-of-phase collaborative review checkpoint.
1. Collect all SUMMARY.md files for TDD plans
2. For each TDD plan summary, verify the RED/GREEN/REFACTOR gate sequence:
- RED gate: A failing test commit exists (`test(...)` commit with MUST-fail evidence)
- GREEN gate: An implementation commit exists (`feat(...)` commit making tests pass)
- REFACTOR gate: Optional cleanup commit (`refactor(...)` commit, tests still pass)
3. If any TDD plan is missing the RED or GREEN gate commits, flag it:
```
⚠ TDD gate violation: Plan {plan_id} missing {RED|GREEN} phase commit.
Expected commit pattern: test({phase}-{plan}): ... → feat({phase}-{plan}): ...
```
4. Present collaborative review summary:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TDD REVIEW — Phase {X}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TDD Plans: {TDD_PLANS} | Gate violations: {count}
| Plan | RED | GREEN | REFACTOR | Status |
|------|-----|-------|----------|--------|
| {id} | ✓ | ✓ | ✓ | Pass |
| {id} | ✓ | ✗ | — | FAIL |
```
**Escalation under MVP+TDD.** When `MVP_MODE=true` AND `TDD_MODE=true`, the review verdict escalates from advisory to **blocking**: missing RED or GREEN gate commits prevent marking the phase complete.
```text
Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under MVP+TDD.
Resolve and re-run /gsd execute-phase, or override with
/gsd execute-phase {phase} --force-mvp-gate to ship anyway.
```
`--force-mvp-gate` is the escape hatch (documented, not yet implemented). Policy is:
- `MVP_MODE=true` AND `TDD_MODE=true`: violations are **blocking** unless explicitly overridden.
- otherwise: violations are advisory/non-blocking and are surfaced for review.
The verifier agent (step `verify_phase_goal`) still checks TDD discipline in both cases.
</step>
<step name="handle_partial_wave_execution"> <step name="handle_partial_wave_execution">
If `WAVE_FILTER` was used, re-run plan discovery after execution: If `WAVE_FILTER` was used, re-run plan discovery after execution:
@@ -1168,16 +1161,16 @@ Selected wave finished successfully. This phase still has incomplete plans, so p
</step> </step>
<step name="code_review_gate" required="true"> <step name="code_review_gate" required="true">
**This step is REQUIRED to evaluate the capability hook.** When the code-review capability is active, auto-invoke code review on the phase's source changes. Advisory only — never blocks execution flow. **This step is REQUIRED to evaluate the capability hook.** When the code-review capability is active, auto-invoke code review on the phase's source changes. Advisory only — never blocks execution flow. Also dispatches advisory execute:post gate hooks (e.g. tdd.review-checkpoint).
**Capability gate:** **Capability gate:**
```bash ```bash
EXECUTE_POST_HOOKS_JSON=$(gsd_run loop render-hooks execute:post --raw) EXECUTE_POST_HOOKS_JSON=${EXECUTE_POST_HOOKS_JSON:-$(gsd_run loop render-hooks execute:post --raw)}
``` ```
Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`. Resolve active step hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "step"` and `ref.skill == "code-review"`.
If no active code-review step hook exists: display "Code review skipped (code-review capability inactive)" and proceed to next step. If no active code-review step hook exists: display "Code review skipped (code-review capability inactive)" and proceed to gate dispatch.
**Invoke review:** **Invoke review:**
``` ```
@@ -1197,9 +1190,28 @@ Code review found issues. Consider running:
/gsd:code-review ${PHASE_NUMBER} --fix /gsd:code-review ${PHASE_NUMBER} --fix
``` ```
**Error handling:** If the Skill invocation fails or throws, catch the error, display "Code review encountered an error (non-blocking): {error}" and proceed to next step. Review failures must never block execution. **Error handling:** If the Skill invocation fails or throws, catch the error, display "Code review encountered an error (non-blocking): {error}" and proceed to gate dispatch. Review failures must never block execution.
Regardless of review result, ALWAYS proceed to close_parent_artifacts → regression_gate → verify_phase_goal. **Execute:post gate hook dispatch.** After code review, dispatch all active gate hooks from `EXECUTE_POST_HOOKS_JSON` where `kind == "gate"`:
For each active gate hook:
```bash
GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_NUMBER}" --raw)
CHECK_EXIT=$?
```
**Gate evaluation** uses the same two-step contract as `execute:wave:post` above: **Step 1** — if the check command failed (non-zero `CHECK_EXIT`, empty/unparseable output), `onError == "halt"` stops and surfaces the error, `onError == "skip"` warns and continues to the next hook (do not read `block`). **Step 2** (command succeeded) — a blocking gate (`hook.blocking == true`) halts on `GATE_RESULT.block == true` with its message/table (never bypassed by `onError`); an advisory gate (`hook.blocking == false`) shows its `table`/summary when `block == true` or `message` is non-empty, then continues; a blocking gate with `block == false` continues silently.
**TDD review escalation (overrides the advisory default for the `tdd.review-checkpoint` gate only).** The tdd `execute:post` gate is declared `blocking: false`, so by the generic contract above it displays its `message`/table and continues. There is ONE documented exception (see `~/.claude/gsd-core/references/execute-mvp-tdd.md`): when `MVP_MODE=true` AND `TDD_MODE=true` AND `GATE_RESULT.block == true` (one or more TDD plans miss a RED or GREEN gate commit), the end-of-phase TDD review escalates from advisory to **blocking under MVP+TDD** — refuse to mark the phase complete and present:
```
Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under MVP+TDD.
Resolve and re-run /gsd execute-phase, or override with /gsd execute-phase {phase} --force-mvp-gate to ship anyway.
```
(`--force-mvp-gate` is the documented, not-yet-implemented escape hatch.) Outside MVP+TDD, TDD-review violations remain advisory (table shown, execution continues).
**Proceed rule:** If `MVP_MODE && TDD_MODE && GATE_RESULT.block == true` for `tdd.review-checkpoint`: STOP — do NOT proceed to `close_parent_artifacts`, `regression_gate`, `verify_phase_goal`, or `phase.complete`. Otherwise proceed normally.
</step> </step>
<step name="close_parent_artifacts"> <step name="close_parent_artifacts">
@@ -1322,84 +1334,7 @@ Options:
3. Abort phase — roll back and re-plan 3. Abort phase — roll back and re-plan
``` ```
Use AskUserQuestion to present the options. If `TEXT_MODE` is true, present as a plain-text numbered list and ask the user to type their choice number. Otherwise, use AskUserQuestion to present the options.
</step>
<step name="schema_drift_gate">
Post-execution schema drift detection. Catches false-positive verification where
build/types pass because TypeScript types come from config, not the live database.
**Run after execution completes but BEFORE verification marks success.**
```bash
SCHEMA_DRIFT=$(gsd_run query verify.schema-drift "${PHASE_NUMBER}" 2>/dev/null)
```
Parse JSON result for: `drift_detected`, `blocking`, `schema_files`, `orms`, `unpushed_orms`, `message`.
**If `drift_detected` is false:** Skip to verify_phase_goal.
**If `drift_detected` is true AND `blocking` is true:**
Check for override:
```bash
SKIP_SCHEMA=$(echo "${GSD_SKIP_SCHEMA_CHECK:-false}")
```
**If `SKIP_SCHEMA` is `true`:**
Display:
```
⚠ Schema drift detected but GSD_SKIP_SCHEMA_CHECK=true — bypassing gate.
Schema files changed: {schema_files}
ORMs requiring push: {unpushed_orms}
Proceeding to verification (database may be out of sync).
```
→ Continue to verify_phase_goal.
**If `SKIP_SCHEMA` is not `true`:**
BLOCK verification. Display:
```
## BLOCKED: Schema Drift Detected
Schema-relevant files changed during this phase but no database push command
was executed. Build and type checks pass because TypeScript types come from
config, not the live database — verification would produce a false positive.
Schema files changed: {schema_files}
ORMs requiring push: {unpushed_orms}
Required push commands:
{For each unpushed ORM, show the push command from the message}
Options:
1. Run push command now (recommended) — execute the push, then re-verify
2. Skip schema check (GSD_SKIP_SCHEMA_CHECK=true) — bypass this gate
3. Abort — stop execution and investigate
```
If `TEXT_MODE` is true, present as a plain-text numbered list. Otherwise use AskUserQuestion.
**If user selects option 1:** Present the specific push command(s) to run. After user confirms execution, re-run the schema drift check. If it passes, continue to verify_phase_goal.
**If user selects option 2:** Set override and continue to verify_phase_goal.
**If user selects option 3:** Stop execution. Report partial completion.
</step>
<step name="codebase_drift_gate">
Post-execution structural drift detection (#2003). Non-blocking by contract:
any internal error here MUST fall through to `verify_phase_goal`. The phase
is never failed by this gate.
Load and follow the full step spec from
`gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md` —
covers the SDK call, JSON contract, `warn` vs `auto-remap` branches, mapper
spawn template, and the two `workflow.drift_*` config keys.
</step> </step>
<step name="verify_phase_goal"> <step name="verify_phase_goal">

View File

@@ -74,11 +74,10 @@ AGENT_SKILLS_RESEARCHER=$(gsd_run query agent-skills gsd-phase-researcher)
AGENT_SKILLS_PLANNER=$(gsd_run query agent-skills gsd-planner) AGENT_SKILLS_PLANNER=$(gsd_run query agent-skills gsd-planner)
AGENT_SKILLS_CHECKER=$(gsd_run query agent-skills gsd-plan-checker) AGENT_SKILLS_CHECKER=$(gsd_run query agent-skills gsd-plan-checker)
CONTEXT_WINDOW=$(gsd_run query config-get context_window 2>/dev/null || echo "200000") CONTEXT_WINDOW=$(gsd_run query config-get context_window 2>/dev/null || echo "200000")
TDD_MODE=$(gsd_run query config-get workflow.tdd_mode 2>/dev/null || echo "false")
MVP_MODE_CFG=$(gsd_run query config-get workflow.mvp_mode 2>/dev/null || echo "false") MVP_MODE_CFG=$(gsd_run query config-get workflow.mvp_mode 2>/dev/null || echo "false")
``` ```
When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd` to eligible tasks using heuristics from `references/tdd.md`. The planner's `<required_reading>` is extended to include `@~/.claude/gsd-core/references/tdd.md` so gate enforcement rules are available during planning. When the tdd capability's `workflow.tdd_mode` is active (resolved via the plan:pre render-hooks), the planner agent is instructed to apply `type: tdd` to eligible tasks using heuristics from `references/tdd.md`. The TDD guidance is injected via the tdd capability's contribution hook at §5.6; no inline config-get is needed.
When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work. When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work.
@@ -165,7 +164,9 @@ Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from in
```bash ```bash
MVP_FLAG_ARG="" MVP_FLAG_ARG=""
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi if [[ "$ARGUMENTS" =~ (^|[[:space:]])--mvp([[:space:]]|$) ]]; then MVP_FLAG_ARG="--cli-flag"; fi
if [[ "$ARGUMENTS" =~ (^|[[:space:]])--tdd([[:space:]]|$) ]]; then TDD_MODE=true; fi if [[ "$ARGUMENTS" =~ (^|[[:space:]])--tdd([[:space:]]|$) ]]; then
gsd_run query config-set workflow.tdd_mode true 2>/dev/null || true
fi
``` ```
Defer the `phase.mvp-mode` query until `PHASE` is finalized (after explicit argument parsing/fallback phase detection + validation). The verb returns `true|false`; full result also exposes `source` (`cli_flag` | `roadmap` | `config` | `none`) for diagnostics. Mode is **all-or-nothing per phase** (PRD decision Q1). Defer the `phase.mvp-mode` query until `PHASE` is finalized (after explicit argument parsing/fallback phase detection + validation). The verb returns `true|false`; full result also exposes `source` (`cli_flag` | `roadmap` | `config` | `none`) for diagnostics. Mode is **all-or-nothing per phase** (PRD decision Q1).
@@ -581,15 +582,15 @@ test -f "${PHASE_DIR}/${PADDED_PHASE}-VALIDATION.md" && echo "VALIDATION_CREATED
```bash ```bash
PLAN_PRE_HOOKS_JSON=$(gsd_run loop render-hooks plan:pre --raw) PLAN_PRE_HOOKS_JSON=$(gsd_run loop render-hooks plan:pre --raw)
SECURITY_ASVS=$(gsd_run query config-get workflow.security_asvs_level --raw 2>/dev/null || echo "1")
SECURITY_BLOCK=$(gsd_run query config-get workflow.security_block_on --raw 2>/dev/null || echo "high")
``` ```
Resolve active contribution hooks from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "security"`. Resolve active contribution hooks from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "security"`.
**If no active security contribution hook exists:** Skip to step 5.6. **If no active security contribution hook exists:** Skip to step 5.6.
**If an active security contribution hook exists:** Display banner: **If an active security contribution hook exists:** Read `SECURITY_ASVS` from the active hook's `configValues.security_asvs_level` (default: `1`) and `SECURITY_BLOCK` from `configValues.security_block_on` (default: `"high"`). These values are resolved by the capability registry from user config using the same four-level precedence as hook activation — no inline `config-get` is needed.
Display banner:
``` ```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -635,7 +636,7 @@ GATE=$(gsd_run check ui-plan-gate "${PHASE}" --raw)
Read `frontend`, `hasUiSpec`, and `block` from `GATE`. Read `frontend`, `hasUiSpec`, and `block` from `GATE`.
**Branch 2 — no frontend indicators (`frontend` is `false`):** Skip silently to step 5.7. **Branch 2 — no frontend indicators (`frontend` is `false`):** Skip silently to step 6.
**Branch 3 — UI-SPEC already exists (`hasUiSpec` is `true`):** **Branch 3 — UI-SPEC already exists (`hasUiSpec` is `true`):**
@@ -686,68 +687,6 @@ Also available:
**Exit the plan-phase workflow. Do not continue.** **Exit the plan-phase workflow. Do not continue.**
## 5.7. Schema Push Detection Gate
> Detects schema-relevant files in the phase scope and injects a mandatory `[BLOCKING]` schema push task into the plan. Prevents false-positive verification where build/types pass because TypeScript types come from config, not the live database.
Check if any files in the phase scope match schema patterns:
```bash
PHASE_SECTION=$(gsd_run query roadmap.get-phase "${PHASE}" --pick section 2>/dev/null)
```
Scan `PHASE_SECTION`, `CONTEXT.md` (if loaded), and `RESEARCH.md` (if exists) for file paths matching these ORM patterns:
| ORM | File Patterns |
|-----|--------------|
| Payload CMS | `src/collections/**/*.ts`, `src/globals/**/*.ts` |
| Prisma | `prisma/schema.prisma`, `prisma/schema/*.prisma` |
| Drizzle | `drizzle/schema.ts`, `src/db/schema.ts`, `drizzle/*.ts` |
| Supabase | `supabase/migrations/*.sql` |
| TypeORM | `src/entities/**/*.ts`, `src/migrations/**/*.ts` |
Also check if any existing PLAN.md files for this phase already reference these file patterns in `files_modified`.
**If schema-relevant files detected:**
Set `SCHEMA_PUSH_REQUIRED=true` and `SCHEMA_ORM={detected_orm}`.
Determine the push command for the detected ORM:
| ORM | Push Command | Non-TTY Workaround |
|-----|-------------|-------------------|
| Payload CMS | `npx payload migrate` | `CI=true PAYLOAD_MIGRATING=true npx payload migrate` |
| Prisma | `npx prisma db push` | `npx prisma db push --accept-data-loss` (if destructive) |
| Drizzle | `npx drizzle-kit push` | `npx drizzle-kit push` |
| Supabase | `supabase db push` | Set `SUPABASE_ACCESS_TOKEN` env var |
| TypeORM | `npx typeorm migration:run` | `npx typeorm migration:run -d src/data-source.ts` |
Inject the following into the planner prompt (step 8) as an additional constraint:
```markdown
<schema_push_requirement>
**[BLOCKING] Schema Push Required**
This phase modifies schema-relevant files ({detected_files}). The planner MUST include
a `[BLOCKING]` task that runs the database schema push command AFTER all schema file
modifications are complete but BEFORE verification.
- ORM detected: {SCHEMA_ORM}
- Push command: {push_command}
- Non-TTY workaround: {env_hint}
- If push requires interactive prompts that cannot be suppressed, flag the task for
manual intervention with `autonomous: false`
This task is mandatory — the phase CANNOT pass verification without it. Build and
type checks will pass without the push (types come from config, not the live database),
creating a false-positive verification state.
</schema_push_requirement>
```
Display: `Schema files detected ({SCHEMA_ORM}) — [BLOCKING] push task will be injected into plans`
**If no schema-relevant files detected:** Skip silently to step 6.
## 6. Check Existing Plans ## 6. Check Existing Plans
```bash ```bash
@@ -853,16 +792,21 @@ PATTERNS_PATH="${PHASE_DIR}/${PADDED_PHASE}-PATTERNS.md"
## 7.9. Regenerate API-SURFACE.md (intel gate) ## 7.9. Regenerate API-SURFACE.md (intel gate)
> Capability-driven dispatch. Resolves active `plan:pre` step hooks via the capability registry; the intel hook's `when: intel.enabled` condition is evaluated by the registry — no inline config-get needed.
Read the active intel step hook from `PLAN_PRE_HOOKS_JSON` where `kind == "step"` and `capId == "intel"`.
**If no active intel step hook exists:** `API_SURFACE_PATH` stays empty; skip to step 8. The step-8 planner entry for API Surface is omitted when `API_SURFACE_PATH` is empty.
**If an active intel step hook exists:**
```bash ```bash
INTEL_CFG=$(gsd_run query config-get intel.enabled 2>/dev/null || echo "false") gsd_run intel api-surface
# false (absent = false) → API_SURFACE_PATH stays empty; step-8 planner entry omitted API_SURFACE_PATH=".planning/intel/API-SURFACE.md"
if [ "$INTEL_CFG" = "true" ]; then echo "✓ API surface regenerated: ${API_SURFACE_PATH}" # injected into step 8 as HINT
gsd_run intel api-surface
API_SURFACE_PATH=".planning/intel/API-SURFACE.md"
echo "✓ API surface regenerated: ${API_SURFACE_PATH}" # injected into step 8 as HINT
fi
``` ```
Continue to step 8.
## 8. Spawn gsd-planner Agent ## 8. Spawn gsd-planner Agent
Display banner: Display banner:
@@ -896,7 +840,7 @@ Planner prompt:
- {SPEC_PATH} (Phase SPEC — carries the ## Edge Coverage section to lift covered/backstop edges from, if exists) - {SPEC_PATH} (Phase SPEC — carries the ## Edge Coverage section to lift covered/backstop edges from, if exists)
- {SPIKE_FINDINGS_PATH} (Spike Findings — validated patterns, constraints, landmines from experiments, if exists) - {SPIKE_FINDINGS_PATH} (Spike Findings — validated patterns, constraints, landmines from experiments, if exists)
- {SKETCH_FINDINGS_PATH} (Sketch Findings — validated design decisions, CSS patterns, visual direction, if exists) - {SKETCH_FINDINGS_PATH} (Sketch Findings — validated design decisions, CSS patterns, visual direction, if exists)
- {API_SURFACE_PATH} (API Surface — HINT ONLY, if intel.enabled; see <intel_surface_hint> below) - {API_SURFACE_PATH} (API Surface — HINT ONLY, when intel capability is active; see <intel_surface_hint> below)
${CONTEXT_WINDOW >= 500000 ? ` ${CONTEXT_WINDOW >= 500000 ? `
**Cross-phase context (1M model enrichment):** **Cross-phase context (1M model enrichment):**
- CONTEXT.md files from the 3 most recent completed phases (locked decisions — maintain consistency) - CONTEXT.md files from the 3 most recent completed phases (locked decisions — maintain consistency)
@@ -928,16 +872,7 @@ Historical findings already incorporated, explicitly deferred/rejected in PLAN.m
**Project instructions:** Read ./CLAUDE.md or ./.claude/CLAUDE.md if either exists — follow project-specific guidelines **Project instructions:** Read ./CLAUDE.md or ./.claude/CLAUDE.md if either exists — follow project-specific guidelines
**Project skills:** Check .claude/skills/ or .agents/skills/ directory (if either exists) — read SKILL.md files, plans should account for project skill rules **Project skills:** Check .claude/skills/ or .agents/skills/ directory (if either exists) — read SKILL.md files, plans should account for project skill rules
${TDD_MODE === 'true' ? ` {For each active entry in `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `into == "planner"` (in array order): inject the entry's `fragment.inline` verbatim here. This delivers all planner-targeted contributions — including tdd's `<tdd_mode_active>` block (type:tdd heuristics), schema-gate's schema-push detection guidance (if active at plan:pre), and security's threat-model guidance. For the security contribution, also surface the resolved `configValues`: `security_asvs_level` (ASVS enforcement level) and `security_block_on` (severity threshold) so the planner uses the configured values when generating `<threat_model>` blocks. If no active planner contributions exist, omit this block entirely.}
<tdd_mode_active>
**TDD Mode is ENABLED.** Apply TDD heuristics from @~/.claude/gsd-core/references/tdd.md to all eligible tasks:
- Business logic with defined I/O → type: tdd
- API endpoints with request/response contracts → type: tdd
- Data transformations, validation, algorithms → type: tdd
- UI, config, glue code, CRUD → standard plan (type: execute)
Each TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence.
</tdd_mode_active>
` : ''}
**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.) **MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.)
**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.) **WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.)
@@ -1630,53 +1565,38 @@ gsd_run query commit "docs(${PADDED_PHASE}): create phase plan" --files "${PHASE
This commits all PLAN.md files for the phase plus the updated STATE.md and ROADMAP.md to version-control the planning artifacts. Skip this step if `commit_docs` is false. This commits all PLAN.md files for the phase plus the updated STATE.md and ROADMAP.md to version-control the planning artifacts. Skip this step if `commit_docs` is false.
## 13e. Post-Planning Gap Analysis ## 13e. Post-Planning Gap Analysis (plan:post capability gate dispatch)
After all plans are generated, committed, and the Requirements Coverage Gate (§13) Proactive, non-blocking coverage report gated on `workflow.post_planning_gaps`
has run, emit a single unified gap report covering both REQUIREMENTS.md and the (default `true`). Dispatched via the `plan:post` capability gate owned by the
CONTEXT.md `<decisions>` section. This is a **proactive, post-hoc report** — it `gap-analysis` capability (ADR-857 §53). Reads REQUIREMENTS.md and CONTEXT.md
does not block phase advancement and does not re-plan. It exists so that any `<decisions>` and cross-references each REQ-ID / D-ID against `${PHASE_DIR}/*-PLAN.md`.
requirement or decision that slipped through the per-plan checks is surfaced in
one place before execution begins.
**Skip if:** `workflow.post_planning_gaps` is `false`. Default is `true`.
```bash ```bash
POST_PLANNING_GAPS=$(gsd_run query config-get workflow.post_planning_gaps --default true 2>/dev/null || echo true) PLAN_POST_HOOKS_JSON=$(gsd_run loop render-hooks plan:post --raw)
if [ "$POST_PLANNING_GAPS" = "true" ]; then PHASE_REQ_IDS=$(gsd_run query init.plan-phase "$PHASE" --pick phase_req_ids 2>/dev/null || echo TBD)
# Scope to this phase's mapped REQ-IDs (#447); null/TBD skips the requirements comparison (CONTEXT.md decisions still reported), mirroring §13.
gsd_run gap-analysis --phase-dir "${PHASE_DIR}" --phase-req-ids "$(gsd_run query init.plan-phase "$PHASE" --pick phase_req_ids 2>/dev/null || echo TBD)"
fi
``` ```
(`gsd-tools.cjs gap-analysis` reads `.planning/REQUIREMENTS.md`, `${PHASE_DIR}/CONTEXT.md`, Read the `activeHooks` array from `PLAN_POST_HOOKS_JSON` in-context. If the
and `${PHASE_DIR}/*-PLAN.md`, then prints a markdown table with one row per `gap-analysis` gate hook is absent (capability inactive), skip this step.
REQ-ID and D-ID. Word-boundary matching prevents `REQ-1` from being mistaken for
`REQ-10`.)
**Output format (deterministic; sorted REQUIREMENTS.md → CONTEXT.md, then natural **For each active entry where `kind == "gate"`** (process in array order):
sort within source):**
``` ```bash
## Post-Planning Gap Analysis GATE_RESULT=$(gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}" --raw)
CHECK_EXIT=$?
| Source | Item | Status |
|--------|------|--------|
| REQUIREMENTS.md | REQ-01 | ✓ Covered |
| REQUIREMENTS.md | REQ-02 | ✗ Not covered |
| CONTEXT.md | D-01 | ✓ Covered |
| CONTEXT.md | D-02 | ✗ Not covered |
⚠ N items not covered by any plan
``` ```
**Skip-gracefully behavior:** **Step 1 — did the CHECK COMMAND itself succeed?**
- REQUIREMENTS.md missing → CONTEXT-only report. If the check command failed (non-zero `CHECK_EXIT`, empty output, or unparseable JSON):
- CONTEXT.md missing → REQUIREMENTS-only report. - `onError == "halt"` → halt and surface command error.
- Both missing or `<decisions>` block missing → "No requirements or decisions to check" line, no error. - `onError == "skip"` → log a warning and continue to the next hook.
This step is non-blocking. If items are reported as not covered, the user may **Step 2 — read `GATE_RESULT.block` (boolean).** Only reached when command succeeded.
re-run `/gsd:plan-phase --gaps` to add plans, or proceed to execute-phase as-is.
- If `hook.blocking == true` and `GATE_RESULT.block == true`: halt. (gap-analysis is always `blocking: false` so this branch is informational only.)
- If `hook.blocking == false` (advisory): if `GATE_RESULT.block == true` or non-empty `table`/`summary`, output the gap table and continue. Advisory gates never block phase completion.
- If `hook.blocking == true` and `GATE_RESULT.block == false`: continue silently.
## 14. Present Final Status ## 14. Present Final Status

View File

@@ -1030,15 +1030,17 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
} }
if (typeof step.ref !== 'object' || step.ref === null) { if (typeof step.ref !== 'object' || step.ref === null) {
errors.push(prefix + '.ref must be an object with skill or agent key'); errors.push(prefix + '.ref must be an object with skill, agent, or command key');
} else { } else {
const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill'); const hasSkill = Object.prototype.hasOwnProperty.call(step.ref, 'skill');
const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent'); const hasAgent = Object.prototype.hasOwnProperty.call(step.ref, 'agent');
if (!hasSkill && !hasAgent) { const hasCommand = Object.prototype.hasOwnProperty.call(step.ref, 'command');
errors.push(prefix + '.ref must have a "skill" or "agent" key'); const dispatchCount = [hasSkill, hasAgent, hasCommand].filter(Boolean).length;
} else if (hasSkill && hasAgent) { if (dispatchCount === 0) {
// Fix #4: ref must be exclusive {skill} XOR {agent} errors.push(prefix + '.ref must have a "skill", "agent", or "command" key');
errors.push(prefix + '.ref must have exactly one of "skill" or "agent", not both'); } else if (dispatchCount > 1) {
// ref must be exclusive: skill XOR agent XOR command
errors.push(prefix + '.ref must have exactly one of "skill", "agent", or "command", not multiple');
} }
if (hasSkill && typeof step.ref.skill !== 'string') { if (hasSkill && typeof step.ref.skill !== 'string') {
errors.push(prefix + '.ref.skill must be a string'); errors.push(prefix + '.ref.skill must be a string');
@@ -1068,6 +1070,9 @@ function validateStep(step, prefix, declaredSkills, declaredAgents) {
[...declaredAgents].join(', ') + ']', [...declaredAgents].join(', ') + ']',
); );
} }
if (hasCommand && typeof step.ref.command !== 'string') {
errors.push(prefix + '.ref.command must be a string');
}
} }
if (!Array.isArray(step.produces)) { if (!Array.isArray(step.produces)) {

View File

@@ -160,6 +160,14 @@
"edge-probe.test.cjs" "edge-probe.test.cjs"
], ],
"issue": "550" "issue": "550"
},
"federated-config": {
"files": [
"federated-config.test.cjs",
"federated-config-loadconfig.test.cjs",
"federated-config-key-removal.test.cjs"
],
"issue": "TBD"
} }
} }
} }

View File

@@ -16,8 +16,14 @@ import { parseDecisions } from './decisions.cjs';
import type { Decision } from './decisions.cjs'; import type { Decision } from './decisions.cjs';
import { checkUiPresence } from './ui-safety-gate.cjs'; import { checkUiPresence } from './ui-safety-gate.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
import verifyModule = require('./verify.cjs');
const { cmdVerifySchemaDrift, cmdVerifyCodebaseDrift } = verifyModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapModule = require('./roadmap.cjs'); import roadmapModule = require('./roadmap.cjs');
const { getRoadmapPhaseWithFallback } = roadmapModule; const { getRoadmapPhaseWithFallback } = roadmapModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import gapCheckerModule = require('./gap-checker.cjs');
const { runGapAnalysis } = gapCheckerModule;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -462,6 +468,367 @@ function cmdUiPlanGate(projectDir: string, args: string[], raw: boolean): void {
output(computeUiPlanGate(projectDir, phase), raw, undefined); output(computeUiPlanGate(projectDir, phase), raw, undefined);
} }
// ─── ui-safety-gate ───────────────────────────────────────────────────────────
/**
* ui-safety-gate: post-wave check that verifies UI-changed files conform to
* the active UI-SPEC for the phase. Called after each wave by execute:wave:post.
*
* Returns JSON: { frontend: boolean, hasUiFiles: boolean, hasUiSpec: boolean, block: boolean, message?: string }
* block = frontend && hasUiFiles && !hasUiSpec
*
* Args: check ui-safety-gate <phase>
* Invocable as: gsd_run check ui-safety-gate <phase>
* or gsd_run check ui.safety-gate <phase> (dots normalized to hyphens)
*
* Uses checkUiPresence from ui-safety-gate.cjs — does NOT reimplement frontend detection.
* Checks whether any files changed in recent git history match frontend file patterns.
* Also checks whether a *-UI-SPEC.md exists in the phase directory (same as ui-plan-gate).
*
* Limitation: uses git diff HEAD~1..HEAD which covers only the last commit; in a
* multi-plan wave the wave-start commit would be more accurate but is not yet stored
* in the wave manifest. This is tracked as a known limitation.
*/
const UI_FILE_EXTENSIONS_RE = /\.(tsx|jsx|css|scss|sass|less|vue|svelte|html)$/i;
const UI_PATH_PATTERNS_RE = /\/(components|pages|views|screens|layouts|ui|frontend)\//i;
/**
* Pure logic for ui-safety-gate — exposed for direct behavioral testing.
*
* Given a projectDir and phase number:
* (a) Reads the phase section from ROADMAP.md via getRoadmapPhaseWithFallback —
* same lookup as computeUiPlanGate — to determine if this is a frontend phase.
* (b) Runs checkUiPresence (frontend detection) — no reimplementation.
* (c) Checks git diff HEAD~1..HEAD for UI file changes in the current worktree.
* (d) Resolves the phase directory via core.findPhaseInternal; checks for *-UI-SPEC.md.
*
* Returns: { frontend, hasUiFiles, hasUiSpec, block, message?, phaseLookupFailed? }
* block = frontend && hasUiFiles && !hasUiSpec
* phaseLookupFailed = ROADMAP.md present but phase header not found
*/
function computeUiSafetyGate(projectDir: string, phase: string): {
frontend: boolean;
hasUiFiles: boolean;
hasUiSpec: boolean;
block: boolean;
message?: string;
phaseLookupFailed?: boolean;
} {
// (a) Read the phase section text (same two-pass lookup as computeUiPlanGate)
let phaseSection = '';
let phaseLookupFailed: boolean | undefined;
try {
const section = getRoadmapPhaseWithFallback(projectDir, phase);
if (section === null) {
const planDir: string = typeof (core as unknown as Record<string, unknown>)['planningDir'] === 'function'
? (core as unknown as Record<string, (cwd: string) => string>)['planningDir'](projectDir)
: path.join(projectDir, '.planning');
const roadmapPath = path.join(planDir, 'ROADMAP.md');
if (fs.existsSync(roadmapPath)) {
phaseLookupFailed = true;
}
} else {
phaseSection = section;
}
} catch { /* roadmap read failure → treat as empty (non-frontend) */ }
// (b) Run checkUiPresence (frontend detection) — reuse existing helper; no reimplementation
const presenceResult = checkUiPresence(phaseSection);
const frontend = presenceResult.hasUI;
// (c) Check whether any UI files were changed in recent git commits
// Uses git diff HEAD~1..HEAD to detect frontend file changes since last commit.
// Known limitation: multi-plan waves may need the wave-start commit for full coverage.
let hasUiFiles = false;
try {
const changed = execFileSync('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], {
cwd: projectDir,
encoding: 'utf-8',
maxBuffer: 2 * 1024 * 1024,
windowsHide: true,
});
hasUiFiles = changed.split('\n').some((f) =>
f.trim() && (UI_FILE_EXTENSIONS_RE.test(f) || UI_PATH_PATTERNS_RE.test(f)),
);
} catch { /* git unavailable or no prior commit — treat as no UI files changed */ }
// (d) Resolve phase directory and check for *-UI-SPEC.md (same as computeUiPlanGate)
const coreModule = core as unknown as Record<string, unknown>;
let phaseDir = '';
try {
const findPhase = coreModule['findPhaseInternal'] as ((cwd: string, phase: string) => Record<string, unknown> | string | null) | undefined;
if (typeof findPhase === 'function') {
const result = findPhase(projectDir, phase);
if (result && typeof result === 'object') {
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
if (relDir) {
phaseDir = path.resolve(projectDir, relDir);
}
} else if (typeof result === 'string') {
phaseDir = result;
}
}
} catch { /* phase dir lookup failure → hasUiSpec=false */ }
const uiSpecPath = findUiSpecInDir(phaseDir);
const hasUiSpec = uiSpecPath !== '';
// block only when: this is a frontend phase AND UI files were changed AND no UI-SPEC exists
const block = frontend && hasUiFiles && !hasUiSpec;
const result: {
frontend: boolean;
hasUiFiles: boolean;
hasUiSpec: boolean;
block: boolean;
message?: string;
phaseLookupFailed?: boolean;
} = { frontend, hasUiFiles, hasUiSpec, block };
if (block) {
result.message = `UI files changed in this wave but no UI-SPEC.md exists for Phase ${phase}. ` +
`Run /gsd:ui-phase ${phase} to generate the design contract before continuing.`;
}
if (phaseLookupFailed) result.phaseLookupFailed = true;
return result;
}
function cmdUiSafetyGate(projectDir: string, args: string[], raw: boolean): void {
// args[0] = 'check', args[1] = 'ui-safety-gate', args[2] = phase
const phase = args[2] || '';
if (!phase) {
error('ui-safety-gate requires a phase argument: check ui-safety-gate <phase>', ERROR_REASON.SDK_MISSING_ARG);
return;
}
output(computeUiSafetyGate(projectDir, phase), raw, undefined);
}
// ─── tdd-review-checkpoint ────────────────────────────────────────────────────
/**
* tdd-review-checkpoint: end-of-phase advisory check that scans type:tdd plans
* for RED/GREEN/REFACTOR gate-sequence compliance and surfaces a review table.
*
* Logic from gsd-core/references/tdd.md <end_of_phase_review> and
* execute-phase.md <step name="tdd_review_checkpoint"> (now removed).
*
* Returns JSON:
* { passed: true, tddPlans: N, violations: N, table: string, rows: PlanRow[] }
* where passed is always true (advisory gate — never blocks).
*
* Args: check tdd.review-checkpoint <phase>
* Phase can be a number or phase-dir path; if not resolvable the check
* returns passed:true with tddPlans:0 (no plans to review).
*/
interface TddPlanRow {
planId: string;
red: boolean;
green: boolean;
refactor: boolean;
status: 'Pass' | 'FAIL';
missing: string[];
}
function cmdTddReviewCheckpoint(projectDir: string, args: string[], raw: boolean): void {
// args[0] = 'check', args[1] = 'tdd-review-checkpoint' (normalized), args[2] = phase
const phase = args[2] || '';
if (!phase) {
error('tdd.review-checkpoint requires a phase argument: check tdd.review-checkpoint <phase>', ERROR_REASON.SDK_MISSING_ARG);
return;
}
// Resolve phase directory
const coreModule = core as unknown as Record<string, unknown>;
let phaseDir = '';
try {
const findPhase = coreModule['findPhaseInternal'] as ((cwd: string, phase: string) => Record<string, unknown> | string | null) | undefined;
if (typeof findPhase === 'function') {
const result = findPhase(projectDir, phase);
if (result && typeof result === 'object') {
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
if (relDir) phaseDir = path.resolve(projectDir, relDir);
} else if (typeof result === 'string') {
phaseDir = result;
}
}
} catch { /* phase dir lookup failure */ }
// Find all PLAN.md files with type: tdd in frontmatter
const tddPlanFiles: string[] = [];
if (phaseDir) {
try {
const files = fs.readdirSync(phaseDir).filter(f => f.endsWith('-PLAN.md'));
for (const file of files) {
const planPath = path.join(phaseDir, file);
const content = readIfExists(planPath);
// Check frontmatter for type: tdd
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const fm = frontmatterMatch[1];
if (/^type:\s*tdd\s*$/m.test(fm)) {
tddPlanFiles.push(planPath);
}
}
}
} catch { /* directory read failure */ }
}
if (tddPlanFiles.length === 0) {
const result = {
// Uniform gate contract: block = violations > 0 (advisory; never truly blocks).
block: false,
passed: true,
tddPlans: 0,
violations: 0,
table: '',
rows: [] as TddPlanRow[],
message: `No type:tdd plans found in phase ${phase}. TDD review skipped.`,
};
// Pass undefined as rawValue so --raw emits JSON (not plain text).
// The human-readable report is carried in `result.message` for the
// dispatch's advisory branch to surface.
output(result, raw, undefined);
return;
}
// For each TDD plan, extract the plan ID (padded plan number) and check git log
const rows: TddPlanRow[] = [];
for (const planPath of tddPlanFiles) {
// Extract plan ID from filename (e.g. "01-02-PLAN.md" → "01-02", or "03-PLAN.md" → "03")
const basename = path.basename(planPath, '-PLAN.md');
// planId for commit grep: phase-plan format, e.g. "01-02"
const planId = basename;
// Check for RED gate commit: test({planId}):
let red = false;
let green = false;
let refactor = false;
try {
const redCommit = execFileSync(
'git', ['log', '--oneline', `--grep=^test(${planId}):`, '--', '.'],
{ cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true },
);
red = redCommit.trim().length > 0;
} catch { /* git unavailable or no match */ }
try {
const greenCommit = execFileSync(
'git', ['log', '--oneline', `--grep=^feat(${planId}):`, '--', '.'],
{ cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true },
);
green = greenCommit.trim().length > 0;
} catch { /* git unavailable or no match */ }
try {
const refactorCommit = execFileSync(
'git', ['log', '--oneline', `--grep=^refactor(${planId}):`, '--', '.'],
{ cwd: projectDir, encoding: 'utf-8', maxBuffer: 1024 * 1024, windowsHide: true },
);
refactor = refactorCommit.trim().length > 0;
} catch { /* git unavailable or no match */ }
const missing: string[] = [];
if (!red) missing.push('RED');
if (!green) missing.push('GREEN');
const status: 'Pass' | 'FAIL' = missing.length === 0 ? 'Pass' : 'FAIL';
rows.push({ planId, red, green, refactor, status, missing });
}
const violations = rows.filter(r => r.status === 'FAIL').length;
// Build review table
const sep = '━'.repeat(53);
const tableHeader = '| Plan | RED | GREEN | REFACTOR | Status |';
const tableDivider = '|------|-----|-------|----------|--------|';
const tableRows = rows.map(r =>
`| ${r.planId.padEnd(4)} | ${r.red ? ' ✓ ' : ' ✗ '} | ${r.green ? ' ✓ ' : ' ✗ '} | ${r.refactor ? ' ✓ ' : ' — '} | ${r.status.padEnd(6)} |`,
);
let table = [
sep,
` TDD REVIEW — Phase ${phase}`,
sep,
'',
`TDD Plans: ${tddPlanFiles.length} | Gate violations: ${violations}`,
'',
tableHeader,
tableDivider,
...tableRows,
].join('\n');
if (violations > 0) {
table += '\n\n⚠ Gate violations are advisory — review before advancing.';
for (const r of rows.filter(row => row.status === 'FAIL')) {
table += `\n Plan ${r.planId} missing: ${r.missing.join(', ')} gate commit(s).`;
table += `\n Expected commit pattern: test(${r.planId}): ... → feat(${r.planId}): ...`;
}
}
const result = {
// Uniform gate contract: block = violations > 0.
// This gate is advisory (blocking: false in capability.json) so block:true
// only surfaces as a warning, never halts. Kept here so the host-loop
// dispatch can read a single consistent `block` field.
block: violations > 0,
passed: true,
tddPlans: tddPlanFiles.length,
violations,
table,
rows,
// Human-readable report in `message` so the dispatch's advisory branch
// can surface it. --raw emits JSON (rawValue=undefined), not plain text.
message: table,
};
// Pass undefined as rawValue so --raw emits JSON (not the raw table text).
// The review table is carried in `result.message` and `result.table` so
// the host-loop dispatch's advisory branch can surface it.
output(result, raw, undefined);
}
// ─── gap-analysis-plan-post ───────────────────────────────────────────────────
/**
* gap-analysis-plan-post: non-blocking advisory check that runs the post-planning
* gap analysis after all PLAN.md files are generated for a phase.
*
* Cross-references every REQ-ID and D-ID from REQUIREMENTS.md and CONTEXT.md
* against the concatenated text of all *-PLAN.md files, emitting a coverage table.
*
* This gate is always advisory (passed: true) — it never blocks phase advancement.
*
* Args: check gap-analysis.plan-post <phase-dir> [phase-req-ids]
* Invocable as: gsd_run check gap-analysis.plan-post <phase-dir> [phase-req-ids]
*/
function cmdGapAnalysisPlanPost(projectDir: string, args: string[], raw: boolean): void {
// args[0] = 'check', args[1] = 'gap-analysis-plan-post' (normalized), args[2] = phaseDir, args[3] = phaseReqIds
const phaseDir = args[2] || '';
if (!phaseDir) {
error('gap-analysis.plan-post requires a phase-dir argument: check gap-analysis.plan-post <phase-dir> [phase-req-ids]', ERROR_REASON.SDK_MISSING_ARG);
return;
}
const phaseReqIds = args[3] ?? undefined;
const result = runGapAnalysis(projectDir, phaseDir, { phaseReqIds });
// Uniform gate contract: block = false (gap-analysis is always advisory, never blocks).
// `message` carries the human-readable gap analysis report so the dispatch's
// advisory branch can surface it. --raw emits JSON (rawValue=undefined), not
// plain markdown text.
output(
{
block: false,
passed: true,
enabled: result.enabled,
table: result.table,
summary: result.summary,
counts: result.counts,
// Human-readable report in `message` for the host-loop advisory branch.
message: result.table || result.summary || '',
},
raw,
undefined,
);
}
interface RouteCheckCommandOptions { interface RouteCheckCommandOptions {
args: string[]; args: string[];
cwd: string; cwd: string;
@@ -493,7 +860,34 @@ function routeCheckCommand({ args, cwd, raw }: RouteCheckCommandOptions): void {
cmdUiPlanGate(cwd, args, raw); cmdUiPlanGate(cwd, args, raw);
return; return;
} }
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, ui-plan-gate', ERROR_REASON.SDK_UNKNOWN_COMMAND); if (subcommand === 'gap-analysis-plan-post') {
cmdGapAnalysisPlanPost(cwd, args, raw);
return;
}
if (subcommand === 'tdd-review-checkpoint') {
cmdTddReviewCheckpoint(cwd, args, raw);
return;
}
if (subcommand === 'ui-safety-gate') {
cmdUiSafetyGate(cwd, args, raw);
return;
}
if (subcommand === 'verify-schema-drift') {
// Delegates to verify.schema-drift — drift capability gate at execute:wave:post (blocking).
// Dot-to-hyphen normalization means query "verify.schema-drift" routes here.
// Honor GSD_SKIP_SCHEMA_CHECK=true to bypass the gate (preserves the original inline gate behavior).
const phaseArg = typeof args[2] === 'string' ? args[2] : '';
const skipSchemaCheck = process.env['GSD_SKIP_SCHEMA_CHECK'] === 'true';
cmdVerifySchemaDrift(cwd, phaseArg, skipSchemaCheck, raw);
return;
}
if (subcommand === 'verify-codebase-drift') {
// Delegates to verify.codebase-drift — drift capability gate at execute:wave:post (non-blocking).
// Dot-to-hyphen normalization means query "verify.codebase-drift" routes here.
cmdVerifyCodebaseDrift(cwd, raw);
return;
}
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, tdd-review-checkpoint, ui-plan-gate, ui-safety-gate, verify-schema-drift, verify-codebase-drift', ERROR_REASON.SDK_UNKNOWN_COMMAND);
} }
export = { export = {
@@ -501,4 +895,7 @@ export = {
decisionMentioned, decisionMentioned,
extractPlanDesignatedSections, extractPlanDesignatedSections,
computeUiPlanGate, computeUiPlanGate,
computeUiSafetyGate,
cmdGapAnalysisPlanPost,
cmdTddReviewCheckpoint,
}; };

View File

@@ -576,7 +576,6 @@ function loadConfig(cwd: string, options: Record<string, unknown> = {}): Record<
brave_search: get('brave_search') ?? defaults.brave_search, brave_search: get('brave_search') ?? defaults.brave_search,
firecrawl: get('firecrawl') ?? defaults.firecrawl, firecrawl: get('firecrawl') ?? defaults.firecrawl,
exa_search: get('exa_search') ?? defaults.exa_search, exa_search: get('exa_search') ?? defaults.exa_search,
tdd_mode: get('tdd_mode', { section: 'workflow', field: 'tdd_mode' }) ?? false,
mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false, mvp_mode: get('mvp_mode', { section: 'workflow', field: 'mvp_mode' }) ?? false,
text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode, text_mode: get('text_mode', { section: 'workflow', field: 'text_mode' }) ?? defaults.text_mode,
auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false, auto_advance: get('auto_advance', { section: 'workflow', field: 'auto_advance' }) ?? false,

View File

@@ -235,7 +235,6 @@ function buildNewProjectConfig(userChoices: Record<string, unknown>): Record<str
ui_phase: true, ui_phase: true,
ui_safety_gate: true, ui_safety_gate: true,
ai_integration_phase: true, ai_integration_phase: true,
tdd_mode: false,
human_verify_mode: 'end-of-phase', human_verify_mode: 'end-of-phase',
text_mode: false, text_mode: false,
research_before_questions: false, research_before_questions: false,

View File

@@ -229,11 +229,13 @@ function cmdInitExecutePhase(
: null; : null;
const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null; const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
const wf = (config.workflow ?? {}) as Record<string, unknown>;
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
executor_model: resolveModelInternal(cwd, 'gsd-executor'), executor_model: resolveModelInternal(cwd, 'gsd-executor'),
verifier_model: resolveModelInternal(cwd, 'gsd-verifier'), verifier_model: resolveModelInternal(cwd, 'gsd-verifier'),
tdd_mode: options['tdd'] || config.tdd_mode || false, tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
commit_docs: config.commit_docs, commit_docs: config.commit_docs,
sub_repos: config.sub_repos, sub_repos: config.sub_repos,
parallelization: config.parallelization, parallelization: config.parallelization,
@@ -381,16 +383,18 @@ function cmdInitPlanPhase(
assertValidGranularityOverride(granularityOverride, error); assertValidGranularityOverride(granularityOverride, error);
const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined); const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined);
const wf = (config.workflow ?? {}) as Record<string, unknown>;
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'), researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'),
planner_model: resolveModelInternal(cwd, 'gsd-planner'), planner_model: resolveModelInternal(cwd, 'gsd-planner'),
checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'),
tdd_mode: options['tdd'] || config.tdd_mode || false, tdd_mode: options['tdd'] || Boolean(wf['tdd_mode']) || false,
granularity, granularity,
research_enabled: config.research, research_enabled: wf['research'],
plan_checker_enabled: config.plan_checker, plan_checker_enabled: config.plan_checker,
nyquist_validation_enabled: config.nyquist_validation, nyquist_validation_enabled: wf['nyquist_validation'],
commit_docs: config.commit_docs, commit_docs: config.commit_docs,
text_mode: config.text_mode, text_mode: config.text_mode,
auto_advance: !!(config.auto_advance), auto_advance: !!(config.auto_advance),
@@ -641,13 +645,15 @@ function cmdInitNewMilestone(cwd: string, raw: boolean): void {
/* intentionally empty */ /* intentionally empty */
} }
const wf = (config.workflow ?? {}) as Record<string, unknown>;
const result: Record<string, unknown> = { const result: Record<string, unknown> = {
researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'), researcher_model: resolveModelInternal(cwd, 'gsd-project-researcher'),
synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'), synthesizer_model: resolveModelInternal(cwd, 'gsd-research-synthesizer'),
roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'), roadmapper_model: resolveModelInternal(cwd, 'gsd-roadmapper'),
commit_docs: config.commit_docs, commit_docs: config.commit_docs,
research_enabled: config.research, research_enabled: wf['research'],
current_milestone: milestone['version'], current_milestone: milestone['version'],
current_milestone_name: milestone['name'], current_milestone_name: milestone['name'],

View File

@@ -259,6 +259,8 @@ interface ActiveHook {
blocking?: boolean; blocking?: boolean;
check?: unknown; check?: unknown;
onError?: string; onError?: string;
/** Resolved capability-owned config values declared in the contribution's configValues map. */
configValues?: Record<string, unknown>;
} }
interface ResolveLoopHooksInput { interface ResolveLoopHooksInput {
@@ -352,6 +354,47 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
return Object.keys(fragment).length > 0 ? fragment : undefined; return Object.keys(fragment).length > 0 ? fragment : undefined;
} }
/**
* Resolve declared configValues for a contribution hook.
* The hook may carry `configValues: { alias: "dotted.key", ... }`.
* Each key is resolved using the same four-level precedence as activation resolution,
* but returning the raw value (not coerced to boolean) so numeric/string config values
* are preserved (e.g. security_asvs_level: 2, security_block_on: "medium").
*/
function resolveConfigValues(hook: RawHook): Record<string, unknown> | undefined {
const raw = (hook as Record<string, unknown>)['configValues'];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
const rawMap = raw as Record<string, unknown>;
const resolved: Record<string, unknown> = {};
for (const [alias, dotKey] of Object.entries(rawMap)) {
// Prototype-pollution guard (inline literal, CodeQL barrier)
if (alias === '__proto__' || alias === 'constructor' || alias === 'prototype') continue;
if (typeof dotKey !== 'string') continue;
// Level 1: loadConfig result
const fromConfig = _getNestedConfigValue(config, dotKey);
if (fromConfig.found) { resolved[alias] = fromConfig.value; continue; }
// Level 2 + 3: raw config.json files
if (cwd) {
const wsConfigPath = path.join(planningDir(cwd), 'config.json');
const rootConfigPath = path.join(planningRoot(cwd), 'config.json');
const fromWs = _readRawConfigKey(wsConfigPath, dotKey);
if (fromWs.found) { resolved[alias] = fromWs.value; continue; }
if (wsConfigPath !== rootConfigPath) {
const fromRoot = _readRawConfigKey(rootConfigPath, dotKey);
if (fromRoot.found) { resolved[alias] = fromRoot.value; continue; }
}
}
// Level 4: registry configSchema default
const schemaEntry = (registry['configSchema'] as Record<string, unknown> | undefined)?.[dotKey];
if (schemaEntry && typeof schemaEntry === 'object' && schemaEntry !== null) {
const def = (schemaEntry as Record<string, unknown>)['default'];
if (def !== undefined) { resolved[alias] = def; continue; }
}
// Level 5: absent → undefined (omit from resolved map)
}
return Object.keys(resolved).length > 0 ? resolved : undefined;
}
// Process steps // Process steps
const stepsRaw = entryMap['steps']; const stepsRaw = entryMap['steps'];
const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : []; const steps: RawHook[] = Array.isArray(stepsRaw) ? (stepsRaw as RawHook[]) : [];
@@ -392,6 +435,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
const produces = toStringArray(hook['produces']); const produces = toStringArray(hook['produces']);
const consumes = toStringArray(hook['consumes']); const consumes = toStringArray(hook['consumes']);
const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined; const onError = typeof hook['onError'] === 'string' ? hook['onError'] : undefined;
const configValuesResolved = resolveConfigValues(hook);
const active: ActiveHook = { capId, kind: 'contribution' }; const active: ActiveHook = { capId, kind: 'contribution' };
if (into !== undefined) active.into = into; if (into !== undefined) active.into = into;
if (fragment !== undefined) active.fragment = fragment; if (fragment !== undefined) active.fragment = fragment;
@@ -399,6 +443,7 @@ function resolveLoopHooks(input: ResolveLoopHooksInput): ResolveLoopHooksResult
if (produces.length > 0) active.produces = produces; if (produces.length > 0) active.produces = produces;
if (consumes.length > 0) active.consumes = consumes; if (consumes.length > 0) active.consumes = consumes;
if (onError !== undefined) active.onError = onError; if (onError !== undefined) active.onError = onError;
if (configValuesResolved !== undefined) active.configValues = configValuesResolved;
activeHooks.push(active); activeHooks.push(active);
} }
@@ -535,6 +580,13 @@ function renderLoopHooks(resolved: ResolveLoopHooksResult): string {
* merged-object-from-untrusted-keys security concern and correctly handles * merged-object-from-untrusted-keys security concern and correctly handles
* pre-cutover keys like `workflow.ui_phase` that live in config.json but are not * pre-cutover keys like `workflow.ui_phase` that live in config.json but are not
* yet exposed through loadConfig's whitelist. * yet exposed through loadConfig's whitelist.
*
* --active-cap <capId>: when present, resolves hooks for <point> exactly as the
* normal path does, then prints exactly `true` (if any resolved activeHook has
* capId === <capId>) or `false` followed by a single newline, and exits 0.
* No JSON envelope is emitted — output is clean for shell $(…) capture.
* Missing <capId> value → coreError + non-zero exit.
* Unknown/inactive capId → `false` (not an error).
*/ */
function cmdLoopRenderHooks( function cmdLoopRenderHooks(
cwd: string, cwd: string,
@@ -547,6 +599,13 @@ function cmdLoopRenderHooks(
return; return;
} }
// --active-cap <capId> mode: emit 'true' or 'false' only (scanner-safe, no JSON envelope)
const activeCapId = typeof options['activeCap'] === 'string' ? options['activeCap'] : undefined;
if (activeCapId !== undefined && activeCapId === '') {
coreError('--active-cap requires a <capId> value (e.g. --active-cap tdd)');
return;
}
const runtimeConfigDir = typeof options['configDir'] === 'string' const runtimeConfigDir = typeof options['configDir'] === 'string'
? options['configDir'] ? options['configDir']
: undefined; : undefined;
@@ -572,6 +631,13 @@ function cmdLoopRenderHooks(
return; return;
} }
// --active-cap mode: print exactly 'true' or 'false' with no envelope
if (activeCapId !== undefined) {
const isActive = resolved.activeHooks.some((h) => h.capId === activeCapId);
process.stdout.write(isActive ? 'true\n' : 'false\n');
return;
}
const rendered = renderLoopHooks(resolved); const rendered = renderLoopHooks(resolved);
const envelope: { const envelope: {
point: string; point: string;

View File

@@ -48,20 +48,6 @@ const claudeToOpencodeTools = {
WebSearch: 'websearch', // Plugin/MCP - keep for compatibility WebSearch: 'websearch', // Plugin/MCP - keep for compatibility
}; };
// Tool name mapping from Claude Code to Gemini CLI
// Gemini CLI uses snake_case built-in tool names
const claudeToGeminiTools = {
Read: 'read_file',
Write: 'write_file',
Edit: 'replace',
Bash: 'run_shell_command',
Glob: 'glob',
Grep: 'search_file_content',
WebSearch: 'google_web_search',
WebFetch: 'web_fetch',
TodoWrite: 'write_todos',
};
// Tool name mapping from Claude/GSD agents to Kimi CLI module paths. // Tool name mapping from Claude/GSD agents to Kimi CLI module paths.
// Kimi custom agent YAML requires fully-qualified module paths. // Kimi custom agent YAML requires fully-qualified module paths.
const claudeToKimiTools = { const claudeToKimiTools = {
@@ -109,37 +95,6 @@ function convertToolName(claudeTool) {
return claudeTool.toLowerCase(); return claudeTool.toLowerCase();
} }
/**
* Convert a Claude Code tool name to Gemini CLI format
* - Applies Claude→Gemini mapping (Read→read_file, Bash→run_shell_command, etc.)
* - Filters out MCP tools (mcp__*) — they are auto-discovered at runtime in Gemini
* - Filters out Task/Agent — agents are auto-registered as tools in Gemini
* @returns {string|null} Gemini tool name, or null if tool should be excluded
*/
function convertGeminiToolName(claudeTool) {
// MCP tools: exclude — auto-discovered from mcpServers config at runtime
if (claudeTool.startsWith('mcp__')) {
return null;
}
// Task/Agent: exclude — agents are auto-registered as callable tools.
// AskUserQuestion: exclude — Gemini CLI does not expose an ask_user tool;
// emitting it causes frontmatter validation errors (#3362).
if (
claudeTool === 'Task' ||
claudeTool === 'Agent' ||
claudeTool === 'AskUserQuestion' ||
claudeTool === 'ask_user'
) {
return null;
}
// Check for explicit mapping
if (claudeToGeminiTools[claudeTool]) {
return claudeToGeminiTools[claudeTool];
}
// Default: lowercase
return claudeTool.toLowerCase();
}
function createKimiToolDiagnostic(reason, tool, source = null) { function createKimiToolDiagnostic(reason, tool, source = null) {
const isMcp = reason === 'mcp_managed'; const isMcp = reason === 'mcp_managed';
return { return {
@@ -263,27 +218,6 @@ function replaceRelativePathReference(content, fromPath, toPath) {
); );
} }
/**
* Convert a Claude Code tool name to GitHub Copilot format.
* - Applies explicit mapping from claudeToCopilotTools
* - Handles mcp__context7__* prefix → io.github.upstash/context7/*
* - Falls back to lowercase for unknown tools
*/
function convertCopilotToolName(claudeTool) {
// mcp__context7__* wildcard → io.github.upstash/context7/*
if (claudeTool.startsWith('mcp__context7__')) {
return 'io.github.upstash/context7/' + claudeTool.slice('mcp__context7__'.length);
}
// Check explicit mapping
if (claudeToCopilotTools[claudeTool]) {
return claudeToCopilotTools[claudeTool];
}
// mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl;
// add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up)
// Default: lowercase
return claudeTool.toLowerCase();
}
/** /**
* Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names). * Apply Copilot-specific content conversion — CONV-06 (paths) + CONV-07 (command names).
* Path mappings depend on install mode: * Path mappings depend on install mode:
@@ -755,39 +689,6 @@ function buildKimiAgentArtifacts({
}; };
} }
/**
* Convert a Claude agent (.md) to a Copilot agent (.agent.md).
* Applies tool mapping + deduplication, formats tools as JSON array.
* CONV-04: JSON array format. CONV-05: Tool name mapping.
*/
function convertClaudeAgentToCopilotAgent(content, isGlobal = false) {
const converted = convertClaudeToCopilotContent(content, isGlobal);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const color = extractFrontmatterField(frontmatter, 'color');
const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
// CONV-04 + CONV-05: Map tools, deduplicate, format as JSON array
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
const mappedTools = claudeTools.map(t => convertCopilotToolName(t));
const uniqueTools = [...new Set(mappedTools)];
const toolsArray = uniqueTools.length > 0
? "['" + uniqueTools.join("', '") + "']"
: '[]';
// Reconstruct frontmatter in Copilot format. Quote description (#2876)
// so a leading YAML flow indicator (`[BETA] …`, `{ … }`, etc.) doesn't
// crash the Copilot frontmatter loader.
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${toolsArray}\n`;
if (color) fm += `color: ${color}\n`;
fm += '---';
return `${fm}\n${body}`;
}
/** /**
* Apply Antigravity-specific content conversion — path replacement + command name conversion. * Apply Antigravity-specific content conversion — path replacement + command name conversion.
* Path mappings depend on install mode: * Path mappings depend on install mode:
@@ -841,32 +742,6 @@ function convertClaudeCommandToAntigravitySkill(content, skillName, _runtime = n
return `${fm}\n${body}`; return `${fm}\n${body}`;
} }
/**
* Convert a Claude agent (.md) to an Antigravity agent.
* Uses Gemini tool names since Antigravity runs on Gemini 3 backend.
*/
function convertClaudeAgentToAntigravityAgent(content, isGlobal = false) {
const converted = convertClaudeToAntigravityContent(content, isGlobal);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const color = extractFrontmatterField(frontmatter, 'color');
const toolsRaw = extractFrontmatterField(frontmatter, 'tools') || '';
// Map tools to Gemini equivalents (reuse existing convertGeminiToolName)
const claudeTools = toolsRaw.split(',').map(t => t.trim()).filter(Boolean);
const mappedTools = claudeTools.map(t => convertGeminiToolName(t)).filter(Boolean);
// #2876: quote description for the same reason as the skill variant.
let fm = `---\nname: ${name}\ndescription: ${yamlQuote(description)}\ntools: ${mappedTools.join(', ')}\n`;
if (color) fm += `color: ${color}\n`;
fm += '---';
return `${fm}\n${body}`;
}
function toSingleLine(value) { function toSingleLine(value) {
return value.replace(/\s+/g, ' ').trim(); return value.replace(/\s+/g, ' ').trim();
} }
@@ -906,30 +781,6 @@ function extractFrontmatterField(frontmatter, fieldName) {
return match[1].trim().replace(/^['"]|['"]$/g, ''); return match[1].trim().replace(/^['"]|['"]$/g, '');
} }
// Tool name mapping from Claude Code to Cursor CLI
const claudeToCursorTools = {
Bash: 'Shell',
Edit: 'StrReplace',
AskUserQuestion: null, // No direct equivalent — use conversational prompting
SlashCommand: null, // No equivalent — skills are auto-discovered
};
/**
* Convert a Claude Code tool name to Cursor CLI format
* @returns {string|null} Cursor tool name, or null if tool should be excluded
*/
function convertCursorToolName(claudeTool) {
if (claudeTool in claudeToCursorTools) {
return claudeToCursorTools[claudeTool];
}
// MCP tools keep their format (Cursor supports MCP)
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
// Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite)
return claudeTool;
}
function convertSlashCommandsToCursorSkillMentions(content) { function convertSlashCommandsToCursorSkillMentions(content) {
// Keep leading "/" for slash commands; only normalize gsd: -> gsd-. // Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
// This preserves rendered "next step" commands like "/gsd-execute-phase 17". // This preserves rendered "next step" commands like "/gsd-execute-phase 17".
@@ -1025,53 +876,10 @@ function convertClaudeCommandToCursorCommand(content, _commandName) {
return body.trimStart(); return body.trimStart();
} }
/**
* Convert Claude Code agent markdown to Cursor agent format.
* Strips frontmatter fields Cursor doesn't support (color, skills),
* converts tool references, and adds a role context header.
*/
function convertClaudeAgentToCursorAgent(content) {
const converted = convertClaudeToCursorMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
// --- Windsurf converters --- // --- Windsurf converters ---
// Windsurf uses a tool set similar to Cursor. // Windsurf uses a tool set similar to Cursor.
// Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global). // Config lives in .windsurf/ (local) and ~/.codeium/windsurf/ (global).
// Tool name mapping from Claude Code to Windsurf Cascade
const claudeToWindsurfTools = {
Bash: 'Shell',
Edit: 'StrReplace',
AskUserQuestion: null, // No direct equivalent — use conversational prompting
SlashCommand: null, // No equivalent — skills are auto-discovered
};
/**
* Convert a Claude Code tool name to Windsurf Cascade format
* @returns {string|null} Windsurf tool name, or null if tool should be excluded
*/
function convertWindsurfToolName(claudeTool) {
if (claudeTool in claudeToWindsurfTools) {
return claudeToWindsurfTools[claudeTool];
}
// MCP tools keep their format (Windsurf supports MCP)
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
// Most tools share the same name (Read, Write, Glob, Grep, Task, WebSearch, WebFetch, TodoWrite)
return claudeTool;
}
function convertSlashCommandsToWindsurfSkillMentions(content) { function convertSlashCommandsToWindsurfSkillMentions(content) {
// Keep leading "/" for slash commands; only normalize gsd: -> gsd-. // Keep leading "/" for slash commands; only normalize gsd: -> gsd-.
return content.replace(/gsd:/gi, 'gsd-'); return content.replace(/gsd:/gi, 'gsd-');
@@ -1153,56 +961,10 @@ function convertClaudeCommandToWindsurfSkill(content, skillName) {
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
} }
/**
* Convert Claude Code agent markdown to Windsurf agent format.
* Strips frontmatter fields Windsurf doesn't support (color, skills),
* converts tool references, and adds a role context header.
*/
function convertClaudeAgentToWindsurfAgent(content) {
const converted = convertClaudeToWindsurfMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
// --- Augment converters --- // --- Augment converters ---
// Augment uses a tool set similar to Cursor/Windsurf. // Augment uses a tool set similar to Cursor/Windsurf.
// Config lives in .augment/ (local) and ~/.augment/ (global). // Config lives in .augment/ (local) and ~/.augment/ (global).
const claudeToAugmentTools = {
Bash: 'launch-process',
Edit: 'str-replace-editor',
AskUserQuestion: null,
SlashCommand: null,
TodoWrite: 'add_tasks',
};
function convertAugmentToolName(claudeTool) {
if (claudeTool in claudeToAugmentTools) {
return claudeToAugmentTools[claudeTool];
}
if (claudeTool.startsWith('mcp__')) {
return claudeTool;
}
const toolMapping = {
Read: 'view',
Write: 'save-file',
Glob: 'view',
Grep: 'grep',
Task: null,
WebSearch: 'web-search',
WebFetch: 'web-fetch',
};
return toolMapping[claudeTool] || claudeTool;
}
function convertSlashCommandsToAugmentSkillMentions(content) { function convertSlashCommandsToAugmentSkillMentions(content) {
return content.replace(/gsd:/gi, 'gsd-'); return content.replace(/gsd:/gi, 'gsd-');
} }
@@ -1279,30 +1041,6 @@ function convertClaudeCommandToAugmentSkill(content, skillName) {
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`;
} }
/**
* Convert Claude Code agent markdown to Augment agent format.
* Strips frontmatter fields Augment doesn't support (color, skills),
* converts tool references, and cleans up for Augment agents.
*/
function convertClaudeAgentToAugmentAgent(content) {
const converted = convertClaudeToAugmentMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
/**
* Copy Claude commands as Augment skills — one folder per skill with SKILL.md.
* Mirrors copyCommandsAsCursorSkills but uses Augment converters.
*/
function convertSlashCommandsToTraeSkillMentions(content) { function convertSlashCommandsToTraeSkillMentions(content) {
return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
return `/gsd-${commandName}`; return `/gsd-${commandName}`;
@@ -1352,20 +1090,6 @@ function convertClaudeCommandToTraeSkill(content, skillName) {
return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`; return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`;
} }
function convertClaudeAgentToTraeAgent(content) {
const converted = convertClaudeToTraeMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
function convertSlashCommandsToCodebuddySkillMentions(content) { function convertSlashCommandsToCodebuddySkillMentions(content) {
return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => {
return `/gsd-${commandName}`; return `/gsd-${commandName}`;
@@ -1447,20 +1171,6 @@ function convertClaudeCommandToCodebuddyCommand(content, commandName) {
return lines.join('\n'); return lines.join('\n');
} }
function convertClaudeAgentToCodebuddyAgent(content) {
const converted = convertClaudeToCodebuddyMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
// ── Cline converters ──────────────────────────────────────────────────────── // ── Cline converters ────────────────────────────────────────────────────────
function convertClaudeToCliineMarkdown(content) { function convertClaudeToCliineMarkdown(content) {
@@ -1485,16 +1195,6 @@ function convertClaudeToCliineMarkdown(content) {
return converted; return converted;
} }
function convertClaudeAgentToClineAgent(content) {
const converted = convertClaudeToCliineMarkdown(content);
const { frontmatter, body } = extractFrontmatterAndBody(converted);
if (!frontmatter) return converted;
const name = extractFrontmatterField(frontmatter, 'name') || 'unknown';
const description = extractFrontmatterField(frontmatter, 'description') || '';
const cleanFrontmatter = `---\nname: ${yamlIdentifier(name)}\ndescription: ${yamlQuote(toSingleLine(description))}\n---`;
return `${cleanFrontmatter}\n${body}`;
}
/** /**
* Convert a Claude command (.md) to a Cline skill (SKILL.md). * Convert a Claude command (.md) to a Cline skill (SKILL.md).
* Emits ONLY name + description frontmatter per the Cline skills spec * Emits ONLY name + description frontmatter per the Cline skills spec
@@ -1505,7 +1205,7 @@ function convertClaudeAgentToClineAgent(content) {
* Cline uses Claude-Code-compatible tool names, so no adapter header is needed. * Cline uses Claude-Code-compatible tool names, so no adapter header is needed.
* Targets ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.0. * Targets ~/.cline/skills/<name>/SKILL.md for Cline >= v3.48.0.
*/ */
function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cmdNames = null) { function convertClaudeCommandToClineSkill(content, skillName, _runtime = null, cmdNames = null) {
const { frontmatter, body } = extractFrontmatterAndBody(content); const { frontmatter, body } = extractFrontmatterAndBody(content);
if (!frontmatter) return content; if (!frontmatter) return content;

View File

@@ -1659,7 +1659,7 @@ function cmdVerifySchemaDrift(
const pDir = planningDir(cwd); const pDir = planningDir(cwd);
const phasesDir = path.join(pDir, 'phases'); const phasesDir = path.join(pDir, 'phases');
if (!fs.existsSync(phasesDir)) { if (!fs.existsSync(phasesDir)) {
output({ drift_detected: false, blocking: false, message: 'No phases directory' }, raw); output({ block: false, drift_detected: false, blocking: false, message: 'No phases directory' }, raw);
return; return;
} }
@@ -1679,7 +1679,7 @@ function cmdVerifySchemaDrift(
if (!phaseDir) { if (!phaseDir) {
output( output(
{ drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` }, { block: false, drift_detected: false, blocking: false, message: `Phase directory not found: ${phaseArg}` },
raw, raw,
); );
return; return;
@@ -1709,15 +1709,21 @@ function cmdVerifySchemaDrift(
const result = checkSchemaDrift(allFiles, executionLog, { skipCheck: !!skipFlag }) as unknown as Record<string, unknown>; const result = checkSchemaDrift(allFiles, executionLog, { skipCheck: !!skipFlag }) as unknown as Record<string, unknown>;
const isSkipped = !!result['skipped'];
output( output(
{ {
// Uniform gate contract: `block` = true means "this gate's bad condition is met".
// When skipCheck is true (GSD_SKIP_SCHEMA_CHECK=true), the gate is bypassed —
// block must be false regardless of whether drift was detected.
// drift_detected and blocking are kept for compatibility.
block: isSkipped ? false : !!result['driftDetected'],
drift_detected: result['driftDetected'], drift_detected: result['driftDetected'],
blocking: result['blocking'], blocking: result['blocking'],
schema_files: result['schemaFiles'], schema_files: result['schemaFiles'],
orms: result['orms'], orms: result['orms'],
unpushed_orms: result['unpushedOrms'], unpushed_orms: result['unpushedOrms'],
message: result['message'], message: result['message'],
skipped: result['skipped'] || false, skipped: isSkipped,
}, },
raw, raw,
); );
@@ -1735,6 +1741,8 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
const structurePath = path.join(codebaseDir, 'STRUCTURE.md'); const structurePath = path.join(codebaseDir, 'STRUCTURE.md');
if (!fs.existsSync(structurePath)) { if (!fs.existsSync(structurePath)) {
emit({ emit({
// Uniform gate contract: block = action_required (false when skipped).
block: false,
skipped: true, skipped: true,
reason: 'no-structure-md', reason: 'no-structure-md',
action_required: false, action_required: false,
@@ -1749,6 +1757,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
structureMd = fs.readFileSync(structurePath, 'utf-8'); structureMd = fs.readFileSync(structurePath, 'utf-8');
} catch (err) { } catch (err) {
emit({ emit({
block: false,
skipped: true, skipped: true,
reason: 'cannot-read-structure-md: ' + (err instanceof Error ? err.message : String(err)), reason: 'cannot-read-structure-md: ' + (err instanceof Error ? err.message : String(err)),
action_required: false, action_required: false,
@@ -1763,6 +1772,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
const revProbe = execGit(['rev-parse', 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string }; const revProbe = execGit(['rev-parse', 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string };
if (revProbe.exitCode !== 0) { if (revProbe.exitCode !== 0) {
emit({ emit({
block: false,
skipped: true, skipped: true,
reason: 'not-a-git-repo', reason: 'not-a-git-repo',
action_required: false, action_required: false,
@@ -1784,6 +1794,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
const diff = execGit(['diff', '--name-status', base, 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string }; const diff = execGit(['diff', '--name-status', base, 'HEAD'], { cwd }) as unknown as { exitCode: number; stdout: string };
if (diff.exitCode !== 0) { if (diff.exitCode !== 0) {
emit({ emit({
block: false,
skipped: true, skipped: true,
reason: 'git-diff-failed', reason: 'git-diff-failed',
action_required: false, action_required: false,
@@ -1825,10 +1836,13 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
runtime: resolveRuntime(cwd), runtime: resolveRuntime(cwd),
}); });
const actionRequired = !!driftResult['actionRequired'];
emit({ emit({
// Uniform gate contract: block = action_required.
block: actionRequired,
skipped: !!driftResult['skipped'], skipped: !!driftResult['skipped'],
reason: driftResult['reason'] || null, reason: driftResult['reason'] || null,
action_required: !!driftResult['actionRequired'], action_required: actionRequired,
directive: driftResult['directive'], directive: driftResult['directive'],
spawn_mapper: !!driftResult['spawnMapper'], spawn_mapper: !!driftResult['spawnMapper'],
affected_paths: driftResult['affectedPaths'] || [], affected_paths: driftResult['affectedPaths'] || [],
@@ -1840,6 +1854,7 @@ function cmdVerifyCodebaseDrift(cwd: string, raw: boolean): void {
}); });
} catch (err) { } catch (err) {
emit({ emit({
block: false,
skipped: true, skipped: true,
reason: 'exception: ' + (err && err instanceof Error ? err.message : String(err)), reason: 'exception: ' + (err && err instanceof Error ? err.message : String(err)),
action_required: false, action_required: false,

View File

@@ -0,0 +1,261 @@
'use strict';
/**
* adr857-contribution-merge.test.cjs — behavioral tests for ADR-857 deliverable J:
* "Contribution merge" — multiple contributions at one point compose by ordered
* concatenation in produces/consumes topological order (capId tiebreak), each
* wrapped in <contribution from="<capability-id>">...</contribution>.
*
* Tests use synthetic registries built in-test plus the real
* resolveLoopHooks/renderLoopHooks pure functions.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const {
resolveLoopHooks,
renderLoopHooks,
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
const {
buildRegistry,
} = require('../scripts/gen-capability-registry.cjs');
// ─── Synthetic registry builder helpers ──────────────────────────────────────
/**
* Build a capability map entry for use with buildRegistry().
* Minimal valid shape mirroring the cycle-test fixtures in capability-registry.test.cjs.
*/
function makeCapEntry(id, contributions) {
return {
id,
role: 'feature',
title: id,
tier: 'full',
requires: [],
skills: [],
agents: [],
hooks: [],
config: {},
steps: [],
contributions,
gates: [],
};
}
/**
* Build a compiled registry via buildRegistry() from a list of simple contribution
* descriptors. Each descriptor: { capId, fragment, produces, consumes, into }.
* buildRegistry applies the produces/consumes topo sort + capId tiebreak so the
* resulting registry.byLoopPoint['plan:pre'].contributions are already sorted.
*/
function makeContribRegistry(contribs) {
const capMap = new Map(contribs.map(c => [
c.capId,
makeCapEntry(c.capId, [{
point: 'plan:pre',
into: c.into ?? 'planner',
fragment: c.fragment ?? { inline: `Content from ${c.capId}.` },
produces: c.produces ?? [],
consumes: c.consumes ?? [],
onError: 'skip',
}]),
]));
return buildRegistry(capMap);
}
// ─── 1. Happy path: topological ordering (produces → consumes dependency) ─────
describe('ADR-857 deliverable J: contribution merge ordering', () => {
test(
'[happy] two contributions at plan:pre where cap-a produces and cap-b consumes: ' +
'resolveLoopHooks orders cap-a before cap-b',
() => {
// cap-b consumes what cap-a produces → cap-a must come first
const registry = makeContribRegistry([
// Deliberately listed in reverse dependency order to prove sorting happens
{ capId: 'cap-b', produces: [], consumes: ['A.md'], fragment: { inline: 'Consume A.md here.' } },
{ capId: 'cap-a', produces: ['A.md'], consumes: [], fragment: { inline: 'Produce A.md here.' } },
]);
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
assert.strictEqual(resolved.activeHooks.length, 2, 'Both contributions must be active');
const capIds = resolved.activeHooks.map(h => h.capId);
assert.strictEqual(capIds[0], 'cap-a', 'cap-a (producer) must appear first');
assert.strictEqual(capIds[1], 'cap-b', 'cap-b (consumer) must appear second');
// Both must be kind=contribution
for (const hook of resolved.activeHooks) {
assert.strictEqual(hook.kind, 'contribution', 'Every active hook must have kind=contribution');
}
},
);
test(
'[happy] renderLoopHooks produces TWO separate <contribution from=...> blocks, ' +
'cap-a block before cap-b block, each independently opened and closed',
() => {
const registry = makeContribRegistry([
{ capId: 'cap-b', produces: [], consumes: ['A.md'], fragment: { inline: 'Consumer block body.' } },
{ capId: 'cap-a', produces: ['A.md'], consumes: [], fragment: { inline: 'Producer block body.' } },
]);
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
const rendered = renderLoopHooks(resolved);
// Two separate opening tags must be present
const openA = `<contribution from="cap-a"`;
const openB = `<contribution from="cap-b"`;
assert.ok(rendered.includes(openA), `rendered must contain opening tag for cap-a. Got:\n${rendered}`);
assert.ok(rendered.includes(openB), `rendered must contain opening tag for cap-b. Got:\n${rendered}`);
// Two separate closing tags
const closes = rendered.split('</contribution>');
// splitting by </contribution> gives N+1 parts for N occurrences
assert.ok(closes.length >= 3, `rendered must contain at least two </contribution> close tags. Got:\n${rendered}`);
// cap-a's block must appear before cap-b's block in document order
const posA = rendered.indexOf(openA);
const posB = rendered.indexOf(openB);
assert.ok(posA < posB, `cap-a opening tag must appear before cap-b opening tag. posA=${posA}, posB=${posB}`);
// No merged or nested blocks — each cap's open tag must be closed before the other cap's open tag
// i.e. the first close tag must appear after posA and before posB
const firstClose = rendered.indexOf('</contribution>');
assert.ok(
firstClose > posA && firstClose < posB,
`First </contribution> must close cap-a before cap-b opens. firstClose=${firstClose}, posA=${posA}, posB=${posB}`,
);
// Fragment body content appears inside respective blocks
assert.ok(rendered.includes('Producer block body.'), 'cap-a fragment body must appear in rendered output');
assert.ok(rendered.includes('Consumer block body.'), 'cap-b fragment body must appear in rendered output');
},
);
});
// ─── 2. BVA: no produces/consumes dependency → capId tiebreak ────────────────
describe('ADR-857 deliverable J: capId tiebreak ordering', () => {
test(
'[BVA] two contributions with NO produces/consumes dependency: ' +
'capId alphabetical tiebreak ensures a-contrib renders before z-contrib',
() => {
// Neither cap produces/consumes anything → tiebreak by capId
const registry = makeContribRegistry([
// Reverse alpha order in input to prove sort is applied
{ capId: 'z-contrib', produces: [], consumes: [], fragment: { inline: 'Z content.' } },
{ capId: 'a-contrib', produces: [], consumes: [], fragment: { inline: 'A content.' } },
]);
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
assert.strictEqual(resolved.activeHooks.length, 2, 'Both contributions must be active');
const capIds = resolved.activeHooks.map(h => h.capId);
assert.strictEqual(capIds[0], 'a-contrib', 'a-contrib must appear first (alphabetical tiebreak)');
assert.strictEqual(capIds[1], 'z-contrib', 'z-contrib must appear second (alphabetical tiebreak)');
// Render and confirm document order matches sort order
const rendered = renderLoopHooks(resolved);
const posA = rendered.indexOf('<contribution from="a-contrib"');
const posZ = rendered.indexOf('<contribution from="z-contrib"');
assert.ok(posA >= 0, 'a-contrib opening tag must be present in rendered output');
assert.ok(posZ >= 0, 'z-contrib opening tag must be present in rendered output');
assert.ok(posA < posZ, `a-contrib must render before z-contrib. posA=${posA}, posZ=${posZ}`);
},
);
});
// ─── 3. Happy path: provenance — each block's from= matches its capId ─────────
describe('ADR-857 deliverable J: contribution provenance', () => {
test(
'[happy] each block from= attribute matches its capId; the two from= values differ',
() => {
const registry = makeContribRegistry([
{ capId: 'feature-alpha', produces: ['ALPHA.md'], consumes: [], fragment: { inline: 'Alpha content.' } },
{ capId: 'feature-beta', produces: [], consumes: ['ALPHA.md'], fragment: { inline: 'Beta content.' } },
]);
const resolved = resolveLoopHooks({ point: 'plan:pre', registry, config: {} });
const rendered = renderLoopHooks(resolved);
// Both capIds appear as from= values
assert.ok(
rendered.includes('from="feature-alpha"'),
'rendered must contain from="feature-alpha"',
);
assert.ok(
rendered.includes('from="feature-beta"'),
'rendered must contain from="feature-beta"',
);
// The two from= values are distinct (they differ from each other)
const fromAlpha = 'from="feature-alpha"';
const fromBeta = 'from="feature-beta"';
assert.notEqual(fromAlpha, fromBeta, 'the two from= attribute strings must differ');
// activeHooks provenance: each hook's capId matches what it will render as
const [first, second] = resolved.activeHooks;
assert.strictEqual(first.capId, 'feature-alpha', 'first hook capId must be feature-alpha');
assert.strictEqual(second.capId, 'feature-beta', 'second hook capId must be feature-beta');
assert.notEqual(first.capId, second.capId, 'the two capIds must differ from each other');
},
);
});
// ─── 4. Negative: produces/consumes cycle → buildRegistry throws ──────────────
describe('ADR-857 deliverable J: contribution cycle detection', () => {
test(
'[negative] a produces/consumes cycle among contributions at one point ' +
'causes buildRegistry to throw an error mentioning "cycle"',
() => {
// cap-a produces A.md and consumes B.md
// cap-b produces B.md and consumes A.md
// → mutual dependency cycle
const capMap = new Map([
['cap-a', makeCapEntry('cap-a', [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'A fragment.' },
produces: ['A.md'],
consumes: ['B.md'],
onError: 'skip',
}])],
['cap-b', makeCapEntry('cap-b', [{
point: 'plan:pre',
into: 'planner',
fragment: { inline: 'B fragment.' },
produces: ['B.md'],
consumes: ['A.md'],
onError: 'skip',
}])],
]);
assert.throws(
() => buildRegistry(capMap),
(err) => {
assert.ok(err instanceof Error, `Expected Error, got: ${Object.prototype.toString.call(err)}`);
assert.match(
err.message,
/cycle/i,
`Error message must mention "cycle". Got: "${err.message}"`,
);
// Must also reference contributions (not just steps)
assert.match(
err.message,
/contribution/i,
`Error message must mention "contribution". Got: "${err.message}"`,
);
return true;
},
);
},
);
});

View File

@@ -0,0 +1,554 @@
'use strict';
/**
* adr857-core-without-capabilities.test.cjs
*
* ADR-857 deliverable B — "the core loop ships and runs without any plug-in"
* (Consequences, §"Positive": "The core loop ships and runs without any plug-in;
* plan-phase.md/execute-phase.md shrink to the irreducible five steps.")
*
* Verified contracts:
* B1. All 12 canonical loop points return activeHooks:[] when every
* capability when-key is explicitly false (real registry, all-caps-off config).
* B2. The CLI `loop render-hooks <point>` exits 0 and emits activeHooks:[],
* placeholder rendered for representative points with all-caps-off config.
* B3. Init bundles for the 5-step loop's entry seam (plan-phase, execute-phase,
* verify-work) resolve with exit 0 and valid JSON when capabilities are off.
* B4. An EMPTY registry (byLoopPoint:{}) at all 12 points → activeHooks:[]
* (loop tolerates a capability-less install).
* B5. [BVA] Exactly one capability ON (tdd_mode) → that capability's points
* non-empty, all OTHER points still empty (caps are additive; core is baseline).
*
* RULESET: no readFileSync + .includes() on source files (source-grep ban).
* All assertions drive real exported functions / subprocess and inspect typed results.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('child_process');
// ── Module under test ─────────────────────────────────────────────────────────
const {
resolveLoopHooks,
renderLoopHooks,
CANONICAL_POINTS,
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
// Real registry (compiled from capabilities/ at build time)
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
// ── Helpers from test harness ─────────────────────────────────────────────────
const { cleanup } = require('./helpers.cjs');
// ── Paths ─────────────────────────────────────────────────────────────────────
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ── Config fixtures ───────────────────────────────────────────────────────────
/**
* All 12 canonical loop points. Derived from the exported constant so the
* assertion set cannot drift from the resolver's own authoritative list.
*/
const ALL_12_POINTS = [...CANONICAL_POINTS];
/**
* All when-keys discovered from the real registry, set to false.
* Built by scanning every hook in every loop point's steps/contributions/gates arrays.
* This gives us a "caps-off" config that passes through the activation resolver as
* explicitly false rather than relying on missing-key default behaviour.
*
* Structure: nested (workflow.* → workflow:{...}, intel.enabled → intel:{enabled:false})
* because _getNestedConfigValue expects a nested object, not a flat dotted key.
*/
function buildAllFalseConfig() {
const workflow = {};
const intel = {};
for (const point of ALL_12_POINTS) {
const entry = realRegistry.byLoopPoint[point];
if (!entry) continue;
for (const kind of ['steps', 'contributions', 'gates']) {
for (const hook of entry[kind] || []) {
const when = hook.when;
if (typeof when !== 'string' || !when) continue;
if (when.startsWith('workflow.')) {
const key = when.slice('workflow.'.length);
workflow[key] = false;
} else if (when === 'intel.enabled') {
intel.enabled = false;
}
// Any future top-level keys would need extending here.
}
}
}
return { workflow, intel };
}
const ALL_FALSE_CONFIG = buildAllFalseConfig();
/**
* All-false config with tdd_mode: true.
* Only workflow.tdd_mode differs from ALL_FALSE_CONFIG.
*/
function buildTddOnlyConfig() {
return {
...ALL_FALSE_CONFIG,
workflow: { ...ALL_FALSE_CONFIG.workflow, tdd_mode: true },
};
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Run gsd-tools subprocess and return { exitCode, output }.
* Does NOT throw on non-zero exit — let the test assert.
*/
function runCli(args, cwd) {
try {
const stdout = execFileSync(process.execPath, [GSD_TOOLS, ...args], {
cwd,
encoding: 'utf-8',
timeout: 30000,
});
return { exitCode: 0, output: stdout.trim() };
} catch (err) {
return {
exitCode: err.status ?? 1,
output: err.stdout?.toString().trim() ?? '',
error: err.stderr?.toString().trim() ?? '',
};
}
}
/** Create a temp project dir with a .planning/ sub-dir. */
function makeProject(configJson = null) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adr857-b-'));
const planning = path.join(dir, '.planning');
fs.mkdirSync(planning, { recursive: true });
if (configJson !== null) {
fs.writeFileSync(path.join(planning, 'config.json'), JSON.stringify(configJson), 'utf8');
}
return dir;
}
/** Remove a temp dir safely. */
function removeTmp(dir) {
if (dir) cleanup(dir);
}
// ─────────────────────────────────────────────────────────────────────────────
// B1. [happy/aggregate] All 12 points → activeHooks:[] with all-caps-off config
// ─────────────────────────────────────────────────────────────────────────────
describe('B1 — real registry, all-caps-off config: every canonical point resolves to activeHooks:[]', () => {
test('all 12 CANONICAL_POINTS return activeHooks:[] simultaneously when every capability when-key is false', () => {
const failures = [];
for (const point of ALL_12_POINTS) {
const result = resolveLoopHooks({
point,
registry: realRegistry,
config: ALL_FALSE_CONFIG,
});
// Shape guard — result must be an object with an array
assert.ok(result && typeof result === 'object', `${point}: result must be an object`);
assert.ok(Array.isArray(result.activeHooks), `${point}: activeHooks must be an array`);
if (result.activeHooks.length !== 0) {
failures.push({
point,
count: result.activeHooks.length,
capIds: result.activeHooks.map(h => h.capId),
});
}
}
// Genuine assertion: if any point has activeHooks, report them concretely.
// This fails on regression to the specific wrong value, not just "not empty".
assert.deepStrictEqual(
failures,
[],
`Expected zero active hooks at all 12 points with all-caps-off config but got: ${JSON.stringify(failures)}`,
);
});
test('CANONICAL_POINTS exports exactly 12 points', () => {
assert.strictEqual(
ALL_12_POINTS.length,
12,
`CANONICAL_POINTS must have 12 entries (ADR-857 §"Loop Extension Points (the 12)"), got ${ALL_12_POINTS.length}`,
);
});
test('each of the 12 known point names is present in CANONICAL_POINTS', () => {
const expected = [
'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',
];
for (const p of expected) {
assert.ok(
ALL_12_POINTS.includes(p),
`Expected canonical point "${p}" to be in CANONICAL_POINTS`,
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// B2. [happy] CLI render-hooks E2E: exit 0, activeHooks:[], placeholder rendered
// ─────────────────────────────────────────────────────────────────────────────
describe('B2 — CLI loop render-hooks: exit 0, activeHooks:[], placeholder for all-caps-off project', () => {
let tmpDir;
before(() => {
tmpDir = makeProject(ALL_FALSE_CONFIG);
});
after(() => {
removeTmp(tmpDir);
tmpDir = null;
});
for (const point of ['plan:pre', 'execute:wave:post', 'ship:post']) {
test(`loop render-hooks ${point} → exit 0, activeHooks:[], non-empty rendered placeholder`, () => {
const { exitCode, output, error } = runCli(['loop', 'render-hooks', point], tmpDir);
assert.strictEqual(
exitCode,
0,
`Expected exit 0 for "loop render-hooks ${point}" with all-caps-off config; got ${exitCode}. stderr: ${error ?? ''}`,
);
// Must parse as JSON
let parsed;
try {
parsed = JSON.parse(output);
} catch (e) {
assert.fail(`CLI output for ${point} is not valid JSON: ${output.slice(0, 200)}`);
}
// activeHooks must be present and empty
assert.ok(
Array.isArray(parsed.activeHooks),
`${point}: activeHooks must be an array`,
);
assert.strictEqual(
parsed.activeHooks.length,
0,
`${point}: expected activeHooks:[] with all-caps-off config, got ${JSON.stringify(parsed.activeHooks)}`,
);
// rendered field must be a non-empty placeholder string (loop still renders output)
assert.ok(
typeof parsed.rendered === 'string' && parsed.rendered.length > 0,
`${point}: rendered must be a non-empty string, got ${JSON.stringify(parsed.rendered)}`,
);
// Genuine assertion: the placeholder contains the point name so it doesn't silently
// return a generic empty string detached from the requested point.
assert.ok(
parsed.rendered.includes(point),
`${point}: rendered placeholder must reference the point name "${point}", got: "${parsed.rendered}"`,
);
});
}
});
// ─────────────────────────────────────────────────────────────────────────────
// B3. [happy] Init bundles resolve with exit 0 and valid JSON with caps off
// ─────────────────────────────────────────────────────────────────────────────
describe('B3 — init bundles for 5-step loop entry seam: exit 0 and valid JSON with capabilities off', () => {
// Cases: the 5-step loop's main init entry points (those available without git)
const INIT_CASES = [
{
label: 'init plan-phase',
args: ['init', 'plan-phase', '--phase', '01-stub'],
// Required fields that prove the bundle is a real JSON object used by the loop
requiredFields: ['tdd_mode', 'phase_found', 'planning_exists'],
},
{
label: 'init execute-phase',
args: ['init', 'execute-phase', '--phase', '01-stub'],
requiredFields: ['tdd_mode', 'phase_found', 'config_exists'],
},
{
label: 'init verify-work',
args: ['init', 'verify-work', '--phase', '01-stub'],
requiredFields: ['phase_found', 'commit_docs'],
},
];
for (const { label, args, requiredFields } of INIT_CASES) {
describe(label, () => {
let tmpDir;
before(() => {
// Bare project with .planning/ but all caps off in config
tmpDir = makeProject(ALL_FALSE_CONFIG);
});
after(() => {
removeTmp(tmpDir);
tmpDir = null;
});
test(`${label} exits 0 with capabilities off`, () => {
const { exitCode, error } = runCli(args, tmpDir);
assert.strictEqual(
exitCode,
0,
`${label}: expected exit 0 with all-caps-off project, got ${exitCode}. stderr: ${error ?? ''}`,
);
});
test(`${label} returns parseable JSON with expected fields`, () => {
const { output } = runCli(args, tmpDir);
let parsed;
try {
parsed = JSON.parse(output);
} catch (e) {
assert.fail(`${label}: output is not valid JSON: ${output.slice(0, 200)}`);
}
assert.ok(
parsed && typeof parsed === 'object' && !Array.isArray(parsed),
`${label}: JSON must be a plain object`,
);
for (const field of requiredFields) {
assert.ok(
Object.prototype.hasOwnProperty.call(parsed, field),
`${label}: bundle must contain field "${field}", got keys: ${Object.keys(parsed).join(', ')}`,
);
}
});
test(`${label} returns parseable JSON with bare project (no config at all)`, () => {
const bareDir = makeProject(null); // no config.json
try {
const { exitCode, output, error } = runCli(args, bareDir);
assert.strictEqual(
exitCode,
0,
`${label}: expected exit 0 with bare project (no config), got ${exitCode}. stderr: ${error ?? ''}`,
);
let parsed;
try {
parsed = JSON.parse(output);
} catch (e) {
assert.fail(`${label}: bare project output is not valid JSON: ${output.slice(0, 200)}`);
}
assert.ok(
parsed && typeof parsed === 'object' && !Array.isArray(parsed),
`${label}: bare project JSON must be a plain object`,
);
} finally {
removeTmp(bareDir);
}
});
});
}
});
// ─────────────────────────────────────────────────────────────────────────────
// B4. [negative] Empty registry → activeHooks:[] at all 12 points
// ─────────────────────────────────────────────────────────────────────────────
describe('B4 — empty registry (byLoopPoint:{}) at all 12 points → activeHooks:[]', () => {
const EMPTY_REGISTRY = {
byLoopPoint: {},
capabilities: {},
configKeys: {},
configSchema: {},
commandFamilies: {},
};
test('loop tolerates a capability-less install: all 12 points return activeHooks:[]', () => {
const failures = [];
for (const point of ALL_12_POINTS) {
const result = resolveLoopHooks({
point,
registry: EMPTY_REGISTRY,
config: {},
});
assert.ok(
result && typeof result === 'object',
`${point}: result must be an object`,
);
assert.ok(
Array.isArray(result.activeHooks),
`${point}: activeHooks must be an array`,
);
if (result.activeHooks.length !== 0) {
failures.push({
point,
count: result.activeHooks.length,
capIds: result.activeHooks.map(h => h.capId),
});
}
}
assert.deepStrictEqual(
failures,
[],
`Empty registry: expected zero active hooks at all 12 points, got non-empty at: ${JSON.stringify(failures)}`,
);
});
test('empty registry does not throw for any of the 12 canonical points', () => {
for (const point of ALL_12_POINTS) {
assert.doesNotThrow(
() => resolveLoopHooks({ point, registry: EMPTY_REGISTRY, config: {} }),
`resolveLoopHooks must not throw for empty registry at point "${point}"`,
);
}
});
test('renderLoopHooks with empty activeHooks returns a non-empty placeholder string', () => {
const placeholder = renderLoopHooks({ point: 'plan:pre', activeHooks: [] });
assert.ok(
typeof placeholder === 'string' && placeholder.length > 0,
`renderLoopHooks must return a non-empty string for empty activeHooks, got: ${JSON.stringify(placeholder)}`,
);
// Specific value check — genuineness: this must change if the placeholder format changes
assert.strictEqual(
placeholder,
'_No active hooks at plan:pre._',
`renderLoopHooks placeholder must be "_No active hooks at plan:pre._", got: "${placeholder}"`,
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// B5. [BVA] One capability ON (tdd_mode) → its 2 points non-empty, 10 others empty
// ─────────────────────────────────────────────────────────────────────────────
describe('B5 — BVA: tdd_mode ON, all other caps OFF → additive: only tdd points active', () => {
// tdd contributes at plan:pre and execute:post (verified from capability registry)
const TDD_ACTIVE_POINTS = ['plan:pre', 'execute:post'];
const TDD_INACTIVE_POINTS = ALL_12_POINTS.filter(p => !TDD_ACTIVE_POINTS.includes(p));
const TDD_ON_CONFIG = buildTddOnlyConfig();
test('plan:pre has exactly 1 active hook and it belongs to tdd', () => {
const result = resolveLoopHooks({
point: 'plan:pre',
registry: realRegistry,
config: TDD_ON_CONFIG,
});
assert.ok(Array.isArray(result.activeHooks), 'activeHooks must be an array');
assert.strictEqual(
result.activeHooks.length,
1,
`plan:pre: expected 1 active hook (tdd), got ${result.activeHooks.length}: ${JSON.stringify(result.activeHooks.map(h => h.capId))}`,
);
assert.strictEqual(
result.activeHooks[0].capId,
'tdd',
`plan:pre: expected activeHooks[0].capId to be "tdd", got "${result.activeHooks[0].capId}"`,
);
});
test('execute:post has exactly 1 active hook and it belongs to tdd', () => {
const result = resolveLoopHooks({
point: 'execute:post',
registry: realRegistry,
config: TDD_ON_CONFIG,
});
assert.ok(Array.isArray(result.activeHooks), 'activeHooks must be an array');
assert.strictEqual(
result.activeHooks.length,
1,
`execute:post: expected 1 active hook (tdd gate), got ${result.activeHooks.length}: ${JSON.stringify(result.activeHooks.map(h => h.capId))}`,
);
assert.strictEqual(
result.activeHooks[0].capId,
'tdd',
`execute:post: expected activeHooks[0].capId to be "tdd", got "${result.activeHooks[0].capId}"`,
);
});
test('all 10 non-tdd points return activeHooks:[] even with tdd_mode ON', () => {
const failures = [];
for (const point of TDD_INACTIVE_POINTS) {
const result = resolveLoopHooks({
point,
registry: realRegistry,
config: TDD_ON_CONFIG,
});
assert.ok(Array.isArray(result.activeHooks), `${point}: activeHooks must be an array`);
if (result.activeHooks.length !== 0) {
failures.push({
point,
count: result.activeHooks.length,
capIds: result.activeHooks.map(h => h.capId),
});
}
}
assert.deepStrictEqual(
failures,
[],
`Expected 10 non-tdd points to be empty with tdd_mode ON, got non-zero at: ${JSON.stringify(failures)}`,
);
});
test('tdd hook at plan:pre is a contribution kind (not a gate or step)', () => {
const result = resolveLoopHooks({
point: 'plan:pre',
registry: realRegistry,
config: TDD_ON_CONFIG,
});
assert.strictEqual(
result.activeHooks.length,
1,
'Expected exactly 1 active hook at plan:pre with tdd ON',
);
assert.strictEqual(
result.activeHooks[0].kind,
'contribution',
`plan:pre tdd hook must be kind "contribution", got "${result.activeHooks[0].kind}"`,
);
});
test('tdd hook at execute:post is a gate kind (not a contribution or step)', () => {
const result = resolveLoopHooks({
point: 'execute:post',
registry: realRegistry,
config: TDD_ON_CONFIG,
});
assert.strictEqual(
result.activeHooks.length,
1,
'Expected exactly 1 active hook at execute:post with tdd ON',
);
assert.strictEqual(
result.activeHooks[0].kind,
'gate',
`execute:post tdd hook must be kind "gate", got "${result.activeHooks[0].kind}"`,
);
});
test('turning tdd_mode OFF restores both tdd points to activeHooks:[]', () => {
// Regression check: tdd_mode OFF → both previously-active points go back to empty
const tddOffConfig = buildAllFalseConfig(); // tdd_mode: false
for (const point of TDD_ACTIVE_POINTS) {
const result = resolveLoopHooks({
point,
registry: realRegistry,
config: tddOffConfig,
});
assert.strictEqual(
result.activeHooks.length,
0,
`${point}: expected activeHooks:[] with tdd_mode OFF, got ${JSON.stringify(result.activeHooks.map(h => h.capId))}`,
);
}
});
});

View File

@@ -0,0 +1,450 @@
/**
* ADR-857 deliverable A — predicate-boundary conformance gate.
*
* Amended 2026-06-12: "Verification substrate vs. plug-in tier (the predicate boundary)"
* + Rollout §6 exception: predicate-generation is CORE substrate, NOT an off-by-default
* Feature Capability.
*
* Key ADR assertions tested here:
* - "The probe family that generates must-NOT-have and edge predicates is core
* verification substrate, not an off-by-default Feature Capability."
* - "no capabilities/edge-probe/ Feature Capability may remove it."
* - "phase 6 does not migrate predicate-generation to an off-by-default Capability."
* - "The substrate must be available even when all Feature Capabilities are off."
*
* Tests do NOT read source files (.md/.cjs) and .includes() on them.
* All assertions drive the real exported functions and inspect typed return values.
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
// ── Paths ─────────────────────────────────────────────────────────────────────
const REPO_ROOT = path.join(__dirname, '..');
const LIB = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib');
const PROBE_CORE_PATH = path.join(LIB, 'probe-core.cjs');
const EDGE_PROBE_PATH = path.join(LIB, 'edge-probe.cjs');
const CAPABILITIES_DIR = path.join(REPO_ROOT, 'capabilities');
// ── Helpers ───────────────────────────────────────────────────────────────────
/** Collect ids of all capability.json files under capabilities/. */
function collectCapabilityIds() {
const dirs = fs.readdirSync(CAPABILITIES_DIR, { withFileTypes: true });
const ids = [];
for (const d of dirs) {
if (!d.isDirectory()) continue;
const capFile = path.join(CAPABILITIES_DIR, d.name, 'capability.json');
if (fs.existsSync(capFile)) {
const parsed = JSON.parse(fs.readFileSync(capFile, 'utf8'));
ids.push({ id: parsed.id, role: parsed.role });
}
}
return ids;
}
/** Minimal valid item for probe-core analyzeCoverage. */
function makeItem(category, overrides = {}) {
return {
requirement_id: 'REQ-1',
category,
status: 'unresolved',
verification: null,
resolution: null,
reason: null,
probe: `probe-${category}`,
...overrides,
};
}
/** Minimal validators bundle (mirrors edge adapter shape). */
const REPRESENTATIVE_VALIDATORS = {
categories: ['boundary', 'adjacency', 'empty'],
verification: ['explicit', 'backstop'],
requiredFieldsByVerification: {
explicit: ['resolution'],
backstop: ['resolution'],
},
};
// ── [happy] ADR-857 §"Verification substrate vs. plug-in tier": substrate loads and
// functions as CORE regardless of capability config with NO capabilities active ───
describe('ADR-857 predicate boundary: substrate loads as core (no capabilities active)', () => {
test('probe-core.cjs resolves from gsd-core/bin/lib (core path)', () => {
// Confirm the module is loadable from the core lib path, not capabilities/
assert.ok(
fs.existsSync(PROBE_CORE_PATH),
`probe-core.cjs must exist at core path ${PROBE_CORE_PATH}`
);
const pc = require(PROBE_CORE_PATH);
assert.ok(pc != null, 'probe-core.cjs must export a non-null module');
});
test('edge-probe.cjs resolves from gsd-core/bin/lib (core path)', () => {
assert.ok(
fs.existsSync(EDGE_PROBE_PATH),
`edge-probe.cjs must exist at core path ${EDGE_PROBE_PATH}`
);
const ep = require(EDGE_PROBE_PATH);
assert.ok(ep != null, 'edge-probe.cjs must export a non-null module');
});
test('probe-core exports the locked VALID_STATUS set — substrate contract is present', () => {
const pc = require(PROBE_CORE_PATH);
// ADR: the contract is a stability contract — its shape must be consistent
assert.ok(Array.isArray(pc.VALID_STATUS), 'VALID_STATUS must be an array');
assert.deepEqual(
[...pc.VALID_STATUS].sort(),
['dismissed', 'resolved', 'unresolved'],
'VALID_STATUS must contain exactly resolved|dismissed|unresolved (the locked re-cut)'
);
});
test('probe-core exports all four required contract functions', () => {
const pc = require(PROBE_CORE_PATH);
// The four deterministic substrate functions defined in probe-core
assert.strictEqual(typeof pc.validateRequirement, 'function', 'validateRequirement must be a function');
assert.strictEqual(typeof pc.validateResolution, 'function', 'validateResolution must be a function');
assert.strictEqual(typeof pc.analyzeCoverage, 'function', 'analyzeCoverage must be a function');
assert.strictEqual(typeof pc.runProbeCli, 'function', 'runProbeCli must be a function');
});
test('probe-core.validateRequirement accepts a valid requirement with NO capability config', () => {
const pc = require(PROBE_CORE_PATH);
// No capability config passed — function must work unconditionally (non-toggleable substrate)
assert.doesNotThrow(
() => pc.validateRequirement({ id: 'REQ-42', text: 'the system rounds values to two decimal places' }),
'validateRequirement must not throw for a valid requirement when no capabilities are active'
);
});
test('probe-core.analyzeCoverage returns a contract-shaped coverage report with NO capability config', () => {
const pc = require(PROBE_CORE_PATH);
// Drive the core merge/rollup engine with a minimal item set and NO capability config
const items = [makeItem('boundary'), makeItem('adjacency')];
const report = pc.analyzeCoverage(items, [], REPRESENTATIVE_VALIDATORS);
// Contract shape: { items[], coverage: { applicable, resolved, unresolved, byVerification } }
assert.ok(Array.isArray(report.items), 'report.items must be an array');
assert.strictEqual(report.items.length, 2, 'report.items must contain both proposed items');
assert.ok(report.coverage != null && typeof report.coverage === 'object', 'report.coverage must be an object');
assert.strictEqual(typeof report.coverage.applicable, 'number', 'coverage.applicable must be a number');
assert.strictEqual(typeof report.coverage.resolved, 'number', 'coverage.resolved must be a number');
assert.strictEqual(typeof report.coverage.unresolved, 'number', 'coverage.unresolved must be a number');
assert.ok(report.coverage.byVerification != null, 'coverage.byVerification must be present');
// Exact values for genuineness
assert.strictEqual(report.coverage.applicable, 2, 'applicable must equal item count (2)');
assert.strictEqual(report.coverage.unresolved, 2, 'unresolved must be 2 (no resolutions provided)');
assert.strictEqual(report.coverage.resolved, 0, 'resolved must be 0 (no resolutions provided)');
assert.strictEqual(report.coverage.byVerification.explicit, 0, 'explicit count must be 0');
assert.strictEqual(report.coverage.byVerification.backstop, 0, 'backstop count must be 0');
});
test('edge-probe exports the locked shape vocabulary (VALID_SHAPES, TAXONOMY, EDGE_VALIDATORS)', () => {
const ep = require(EDGE_PROBE_PATH);
// VALID_SHAPES: exactly 5 shape names
assert.ok(ep.VALID_SHAPES instanceof Set, 'VALID_SHAPES must be a Set');
assert.strictEqual(ep.VALID_SHAPES.size, 5, 'VALID_SHAPES must have exactly 5 entries');
for (const s of ['numeric-range', 'collection', 'text', 'stateful', 'io']) {
assert.ok(ep.VALID_SHAPES.has(s), `VALID_SHAPES must contain "${s}"`);
}
// TAXONOMY: exactly 8 edge categories
assert.ok(Array.isArray(ep.TAXONOMY), 'TAXONOMY must be an array');
assert.strictEqual(ep.TAXONOMY.length, 8, 'TAXONOMY must have exactly 8 categories');
// EDGE_VALIDATORS: verification tiers must be exactly explicit|backstop
assert.deepEqual(
[...ep.EDGE_VALIDATORS.verification].sort(),
['backstop', 'explicit'],
'EDGE_VALIDATORS.verification must be ["explicit","backstop"]'
);
});
test('edge-probe.classifyShape returns a typed array result with NO capability config', () => {
const ep = require(EDGE_PROBE_PATH);
// ADR: substrate available without any capability toggling.
// Text chosen to trigger multiple concrete shapes:
// "save" (word-boundary match in SHAPE_CUES.stateful) → stateful
// "file" (SHAPE_CUES.io) → io
// "maximum count limit" (SHAPE_CUES['numeric-range']) → numeric-range
const shapes = ep.classifyShape('the system must save a file with a maximum count limit');
assert.ok(Array.isArray(shapes), 'classifyShape must return an array');
assert.ok(shapes.includes('numeric-range'), 'classifyShape must detect numeric-range from "maximum count limit"');
assert.ok(shapes.includes('stateful'), 'classifyShape must detect stateful from "save" (word-boundary cue)');
assert.ok(shapes.includes('io'), 'classifyShape must detect io from "file"');
});
test('edge-probe.proposeEdges returns unresolved items with contract shape with NO capability config', () => {
const ep = require(EDGE_PROBE_PATH);
const edges = ep.proposeEdges({ id: 'R-num', text: 'the score must stay within a numeric range between 0 and 100' });
assert.ok(Array.isArray(edges), 'proposeEdges must return an array');
assert.ok(edges.length > 0, 'proposeEdges must propose at least one edge for a numeric-range requirement');
// Every proposed edge must be unresolved with null verification
for (const edge of edges) {
assert.strictEqual(edge.requirement_id, 'R-num', 'edge.requirement_id must match input id');
assert.strictEqual(edge.status, 'unresolved', 'proposed edge status must be unresolved');
assert.strictEqual(edge.verification, null, 'proposed edge verification must be null');
assert.strictEqual(typeof edge.category, 'string', 'edge.category must be a string');
assert.strictEqual(typeof edge.probe, 'string', 'edge.probe must be a string');
}
// Specific: "numeric range between 0 and 100" => boundary category expected
const cats = edges.map(e => e.category);
assert.ok(cats.includes('boundary'), 'proposeEdges must include boundary category for numeric-range text');
});
});
// ── [negative] No off-by-default Feature Capability owns predicate-generation ──
describe('ADR-857 predicate boundary: no capabilities/edge-probe or prohibition-probe Feature Capability exists', () => {
test('capabilities/edge-probe directory does NOT exist (ADR-857: not an off-by-default plug-in)', () => {
const edgeProbeCap = path.join(CAPABILITIES_DIR, 'edge-probe');
assert.strictEqual(
fs.existsSync(edgeProbeCap),
false,
'capabilities/edge-probe must not exist — ADR-857 forbids predicate-generation as an off-by-default Capability'
);
});
test('capabilities/prohibition-probe directory does NOT exist (ADR-857: not an off-by-default plug-in)', () => {
const prohibitionProbeCap = path.join(CAPABILITIES_DIR, 'prohibition-probe');
assert.strictEqual(
fs.existsSync(prohibitionProbeCap),
false,
'capabilities/prohibition-probe must not exist — ADR-857 forbids predicate-generation as an off-by-default Capability'
);
});
test('real capability registry has NO entry with id "edge-probe" or "prohibition-probe" with role "feature"', () => {
const { capabilities } = require(path.join(LIB, 'capability-registry.cjs'));
const ids = Object.keys(capabilities);
// Assert no edge-probe feature capability
const hasEdgeProbeFeature = ids.some(
id => id === 'edge-probe' && capabilities[id].role === 'feature'
);
assert.strictEqual(
hasEdgeProbeFeature,
false,
'registry must NOT contain a feature capability with id "edge-probe"'
);
// Assert no prohibition-probe feature capability
const hasProhibitionProbeFeature = ids.some(
id => id === 'prohibition-probe' && capabilities[id].role === 'feature'
);
assert.strictEqual(
hasProhibitionProbeFeature,
false,
'registry must NOT contain a feature capability with id "prohibition-probe"'
);
// Also verify neither id exists at all (not even as a different role)
assert.ok(
!ids.includes('edge-probe'),
'registry must not contain any capability with id "edge-probe"'
);
assert.ok(
!ids.includes('prohibition-probe'),
'registry must not contain any capability with id "prohibition-probe"'
);
});
test('capability.json files on disk contain no id matching edge-probe or prohibition-probe with role feature', () => {
const allCaps = collectCapabilityIds();
const probeFeatures = allCaps.filter(
c => (c.id === 'edge-probe' || c.id === 'prohibition-probe') && c.role === 'feature'
);
assert.deepEqual(
probeFeatures,
[],
`No capability.json on disk may declare id "edge-probe" or "prohibition-probe" with role "feature"; found: ${JSON.stringify(probeFeatures)}`
);
});
});
// ── [happy] Predicate substrate lives in core (bin/lib), not in capabilities/ ──
describe('ADR-857 predicate boundary: substrate lives in core, not in capabilities/', () => {
test('probe-core.cjs is resolvable from gsd-core/bin/lib — the core module tier', () => {
// Must resolve from core lib, not from any capability folder
const resolved = require.resolve(PROBE_CORE_PATH);
assert.ok(
resolved.includes(path.join('gsd-core', 'bin', 'lib')),
`probe-core.cjs must resolve from gsd-core/bin/lib (got: ${resolved})`
);
assert.ok(
!resolved.includes('capabilities'),
`probe-core.cjs must NOT resolve from any capabilities/ folder (got: ${resolved})`
);
});
test('edge-probe.cjs is resolvable from gsd-core/bin/lib — the core module tier', () => {
const resolved = require.resolve(EDGE_PROBE_PATH);
assert.ok(
resolved.includes(path.join('gsd-core', 'bin', 'lib')),
`edge-probe.cjs must resolve from gsd-core/bin/lib (got: ${resolved})`
);
assert.ok(
!resolved.includes('capabilities'),
`edge-probe.cjs must NOT resolve from any capabilities/ folder (got: ${resolved})`
);
});
test('no capabilities/*/capability.json declares id "edge-probe" or "prohibition-probe" as a feature', () => {
// Scan all capability.json files on disk and confirm none are probe features
const allCaps = collectCapabilityIds();
const featureIds = allCaps.filter(c => c.role === 'feature').map(c => c.id);
assert.ok(
!featureIds.includes('edge-probe'),
`Feature capability ids must not include "edge-probe"; found: ${JSON.stringify(featureIds)}`
);
assert.ok(
!featureIds.includes('prohibition-probe'),
`Feature capability ids must not include "prohibition-probe"; found: ${JSON.stringify(featureIds)}`
);
});
});
// ── [negative/BVA] Toggling ALL capability config keys off does NOT change substrate
// availability or output — substrate is non-toggleable ─────────────────────────
describe('ADR-857 predicate boundary: substrate is non-toggleable (all-off config does not affect it)', () => {
test('probe-core functions return identical output before and after constructing an all-off config', () => {
const ep = require(EDGE_PROBE_PATH);
const { configKeys } = require(path.join(LIB, 'capability-registry.cjs'));
// Build a config object with every known workflow.* and intel/profile key set to false
const allOffConfig = {};
for (const key of Object.keys(configKeys)) {
allOffConfig[key] = false;
}
// "Before": call analyzeCoverage with a representative set
const reqText = 'the API endpoint accepts a list of items with a maximum count threshold and stores each one';
const BEFORE_shapes = ep.classifyShape(reqText);
const BEFORE_edges = ep.proposeEdges({ id: 'R-bva', text: reqText });
const BEFORE_report = ep.analyzeCoverage([{ id: 'R-bva', text: reqText }], []);
// Simulate "all capabilities off" by confirming the config object is fully false
// (The substrate does not accept a config parameter — this BVA tests that the
// probe functions are unconditionally available regardless of config state)
const allOff = Object.values(allOffConfig).every(v => v === false);
assert.strictEqual(allOff, true, 'all config keys must be set to false in the all-off config');
// "After all-off config": call the same functions again — results must be identical
const AFTER_shapes = ep.classifyShape(reqText);
const AFTER_edges = ep.proposeEdges({ id: 'R-bva', text: reqText });
const AFTER_report = ep.analyzeCoverage([{ id: 'R-bva', text: reqText }], []);
// ADR-857: the substrate is non-toggleable — output must not change
assert.deepEqual(
AFTER_shapes,
BEFORE_shapes,
'classifyShape must return identical output regardless of capability config state'
);
assert.deepEqual(
AFTER_edges,
BEFORE_edges,
'proposeEdges must return identical output regardless of capability config state'
);
assert.deepEqual(
AFTER_report,
BEFORE_report,
'analyzeCoverage must return identical output regardless of capability config state'
);
// Specific value assertion to prevent vacuous-truth: shapes must include at least two types
assert.ok(AFTER_shapes.length >= 2, `classifyShape must detect at least 2 shapes for complex text (got ${AFTER_shapes.length})`);
assert.ok(AFTER_edges.length >= 2, `proposeEdges must propose at least 2 edges for this requirement (got ${AFTER_edges.length})`);
});
test('probe-core.validateResolution rejects an invalid status regardless of all-off config (BVA: status boundary)', () => {
const pc = require(PROBE_CORE_PATH);
// BVA: exact boundary — 'unresolved' (valid, limit case) vs 'covered' (was valid pre-re-cut, now invalid)
// Valid status (limit): must NOT throw
assert.doesNotThrow(
() => pc.validateResolution(
{ requirement_id: 'R1', category: 'boundary', status: 'unresolved', verification: null, resolution: null, reason: null },
REPRESENTATIVE_VALIDATORS
),
'validateResolution must accept status="unresolved" (the valid boundary case)'
);
// Invalid status (just outside the locked set): must throw with a message naming the bad status
assert.throws(
() => pc.validateResolution(
{ requirement_id: 'R1', category: 'boundary', status: 'covered', verification: null, resolution: null, reason: null },
REPRESENTATIVE_VALIDATORS
),
(err) => {
assert.ok(err instanceof Error, 'must throw an Error');
assert.ok(
err.message.includes('covered'),
`error message must name the invalid status "covered"; got: "${err.message}"`
);
return true;
},
'validateResolution must reject status="covered" (the pre-re-cut status that is now outside the locked set)'
);
});
test('probe-core.analyzeCoverage rejects a resolved item missing verification tier (BVA: verification null boundary)', () => {
const pc = require(PROBE_CORE_PATH);
// BVA: resolved + null verification is INVALID (one step below the minimum)
const badItems = [
makeItem('boundary', { status: 'resolved', verification: null, resolution: 'AC text' }),
];
assert.throws(
() => pc.analyzeCoverage(badItems, [], REPRESENTATIVE_VALIDATORS),
(err) => {
assert.ok(err instanceof Error, 'must throw an Error');
assert.ok(
err.message.toLowerCase().includes('verification'),
`error message must mention "verification"; got: "${err.message}"`
);
return true;
},
'analyzeCoverage must reject a resolved item with verification=null (verification required at this boundary)'
);
// BVA: resolved + valid verification tier is VALID (at the minimum)
const goodItems = [
makeItem('boundary', { status: 'resolved', verification: 'explicit', resolution: 'acceptance criterion text' }),
];
const report = pc.analyzeCoverage(goodItems, [], REPRESENTATIVE_VALIDATORS);
assert.strictEqual(report.coverage.resolved, 1, 'resolved count must be 1 for a valid resolved item');
assert.strictEqual(report.coverage.byVerification.explicit, 1, 'byVerification.explicit must be 1');
});
test('all capability config keys being false does not prevent probe-core from loading or exporting VALID_STATUS', () => {
// This test confirms the substrate is non-conditionally loaded (not behind any
// capability gate) — if probe-core depended on a capability config, VALID_STATUS
// would differ or throw when the underlying capability was off.
const pc = require(PROBE_CORE_PATH);
const { configKeys } = require(path.join(LIB, 'capability-registry.cjs'));
// With every key false, VALID_STATUS must remain the locked set
const allOffConfig = {};
for (const key of Object.keys(configKeys)) {
allOffConfig[key] = false;
}
// probe-core does not accept a config — it must be unconditional
// Exact value check (genuineness: flipping one would fail)
assert.deepEqual(
[...pc.VALID_STATUS].sort(),
['dismissed', 'resolved', 'unresolved'],
'VALID_STATUS must be identical regardless of all-off config (substrate is non-toggleable)'
);
// Also verify configKeys has at least some keys (ensures the all-off scenario is meaningful)
assert.ok(
Object.keys(configKeys).length > 0,
'configKeys must be non-empty (all-off scenario must be meaningful)'
);
});
});

View File

@@ -178,22 +178,28 @@ describe('bug-2851: workflow files must not call bare `gsd-tools` (#2245 sweep r
); );
}); });
test('plan-phase.md §13e gap-analysis uses the gsd_run launcher (resolvable invocation, #621)', () => { test('plan-phase.md §13e gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate, #621)', () => {
const planPhase = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); const planPhase = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8');
const blocks = extractShellBlocks(planPhase); const blocks = extractShellBlocks(planPhase);
let foundGapAnalysisCall = false; let foundPlanPostDispatch = false;
for (const blk of blocks) { for (const blk of blocks) {
for (const line of blk.lines) { for (const line of blk.lines) {
if (/gap-analysis/.test(line) && !/^\s*#/.test(line)) { if (/gsd_run\s+loop\s+render-hooks\s+plan:post\s+--raw/.test(line) && !/^\s*#/.test(line)) {
foundGapAnalysisCall = true; foundPlanPostDispatch = true;
assert.match(
line,
/\bgsd_run\s+gap-analysis\b/,
`gap-analysis must use the gsd_run launcher (not a hardcoded $HOME path), got: ${line.trim()}`,
);
} }
} }
} }
assert.ok(foundGapAnalysisCall, 'expected at least one gap-analysis invocation in plan-phase.md'); assert.ok(
foundPlanPostDispatch,
'expected plan-phase.md §13e to dispatch gsd_run loop render-hooks plan:post --raw (gap-analysis moved to capability gate plan:post in ADR-857 migration)',
);
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const planPostPoint = (registry.byLoopPoint || {})['plan:post'] || {};
const gates = planPostPoint.gates || [];
const gapAnalysisGate = gates.find((g) => g.capId === 'gap-analysis');
assert.ok(
gapAnalysisGate,
'gap-analysis capability must be registered as a plan:post gate in capability-registry.cjs',
);
}); });
}); });

View File

@@ -35,10 +35,10 @@ const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf8');
// ─── #621 regression: gap-analysis routes through gsd_run ──────────────────── // ─── #621 regression: gap-analysis routes through gsd_run ────────────────────
describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run launcher (#621)', () => { describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run launcher (#621)', () => {
test('gap-analysis call routes through gsd_run, not hardcoded node path', () => { test('gap-analysis dispatches via gsd_run loop render-hooks plan:post (ADR-857 capability gate)', () => {
assert.ok( assert.ok(
workflow.includes('gsd_run gap-analysis'), workflow.includes('gsd_run loop render-hooks plan:post'),
'workflow must invoke gap-analysis via gsd_run, not a hardcoded node path' 'workflow must dispatch gap-analysis via gsd_run loop render-hooks plan:post, not a hardcoded node path or direct gsd_run gap-analysis call'
); );
}); });
@@ -66,7 +66,7 @@ describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run laun
test('post-planning-gaps block still gates on workflow.post_planning_gaps and preserves required args', () => { test('post-planning-gaps block still gates on workflow.post_planning_gaps and preserves required args', () => {
const hasGate = workflow.includes('workflow.post_planning_gaps'); const hasGate = workflow.includes('workflow.post_planning_gaps');
const hasPhaseDir = workflow.includes('--phase-dir "${PHASE_DIR}"'); const hasPhaseDir = workflow.includes('gsd_run check ${hook.check.query} "${PHASE_DIR}" "${PHASE_REQ_IDS}"');
const hasPickArg = workflow.includes('--pick phase_req_ids'); const hasPickArg = workflow.includes('--pick phase_req_ids');
assert.ok( assert.ok(
hasGate, hasGate,
@@ -74,7 +74,7 @@ describe('plan-phase workflow: post-planning-gaps gap-analysis uses gsd_run laun
); );
assert.ok( assert.ok(
hasPhaseDir, hasPhaseDir,
'gap-analysis invocation must still pass --phase-dir "${PHASE_DIR}"' 'gap-analysis check dispatch must pass "${PHASE_DIR}" (and "${PHASE_REQ_IDS}") positionally to gsd_run check'
); );
assert.ok( assert.ok(
hasPickArg, hasPickArg,

View File

@@ -0,0 +1,507 @@
'use strict';
/**
* E2E content tests for plan:post hook — gap-analysis gate.
*
* ADR-857 phase 6 backlog: check-gap-analysis-plan-post-e2e.test.cjs
*
* Tests exercise:
* - loop render-hooks plan:post (gate discovery)
* - check gap-analysis.plan-post (advisory gate check)
*
* HARD RULES enforced here:
* - Every test runs a real CLI subprocess or the real resolver + real registry.
* - No readFileSync + .includes() source-grep on workflow files.
* - Asserts TYPED CONTENT (JSON fields, counts, booleans, strings).
* - Each test fully isolated (own fixture), cleanup in afterEach.
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ─── Shared helpers ───────────────────────────────────────────────────────────
/**
* Write REQUIREMENTS.md with REQ-IDs in checkbox format.
* @param {string} planningDir
* @param {string[]} ids
*/
function writeRequirements(planningDir, ids) {
const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1} description`);
fs.writeFileSync(
path.join(planningDir, 'REQUIREMENTS.md'),
`# Requirements\n\n${lines.join('\n')}\n`
);
}
/**
* Write CONTEXT.md with a <decisions> block containing decisions.
* @param {string} phaseDir
* @param {{id: string, text: string}[]} decisions
*/
function writeContext(phaseDir, decisions) {
const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n');
fs.writeFileSync(
path.join(phaseDir, 'CONTEXT.md'),
`# Phase Context\n\n<decisions>\n## Implementation Decisions\n\n${dLines}\n</decisions>\n`
);
}
/**
* Write a PLAN.md with the given body.
* @param {string} phaseDir
* @param {string} name e.g. '01'
* @param {string} body
*/
function writePlan(phaseDir, name, body) {
fs.writeFileSync(path.join(phaseDir, `${name}-PLAN.md`), body);
}
/**
* Run loop render-hooks via spawnSync for low-level exit-code control.
* @param {string} point
* @param {string} cwd
* @returns {{ status: number, stdout: string, stderr: string }}
*/
function spawnRenderHooks(point, cwd) {
const result = spawnSync(process.execPath, [GSD_TOOLS, 'loop', 'render-hooks', point, '--raw'], {
cwd,
encoding: 'utf8',
timeout: 60000,
env: { ...process.env, GSD_SESSION_KEY: '', CODEX_THREAD_ID: '', CLAUDE_SESSION_ID: '' },
});
return {
status: result.status,
stdout: (result.stdout || '').trim(),
stderr: (result.stderr || '').trim(),
};
}
/**
* Run check gap-analysis.plan-post via CLI with controlled args.
* @param {string[]} extraArgs args after 'gap-analysis.plan-post'
* @param {string} cwd
* @returns {{ success: boolean, output: string, error: string, exitCode: number }}
*/
function runGapCheck(extraArgs, cwd) {
return runGsdTools(['check', 'gap-analysis.plan-post', ...extraArgs, '--raw'], cwd);
}
// ─── Section 1: render-hooks plan:post ───────────────────────────────────────
describe('render-hooks plan:post — gate discovery', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
// Initialize a valid config so schema defaults apply
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
});
afterEach(() => cleanup(tmpDir));
test('[happy] render-hooks plan:post returns gap-analysis gate hook with correct typed shape when workflow.post_planning_gaps=true (default)', () => {
// Default config → post_planning_gaps=true (schema default)
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
assert.strictEqual(envelope.point, 'plan:post');
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be array');
assert.strictEqual(envelope.activeHooks.length, 1, 'exactly one active hook expected');
const hook = envelope.activeHooks[0];
assert.strictEqual(hook.capId, 'gap-analysis', 'capId must be gap-analysis');
assert.strictEqual(hook.kind, 'gate', 'kind must be gate');
assert.strictEqual(hook.blocking, false, 'blocking must be false (advisory)');
assert.strictEqual(hook.onError, 'skip', 'onError must be skip');
assert.strictEqual(hook.when, 'workflow.post_planning_gaps', 'when must be workflow.post_planning_gaps');
assert.deepStrictEqual(hook.check, { query: 'gap-analysis.plan-post' }, 'check.query must be gap-analysis.plan-post');
// rendered must mention the gate
assert.ok(typeof envelope.rendered === 'string', 'rendered must be string');
assert.ok(envelope.rendered.includes('gap-analysis'), 'rendered must mention gap-analysis');
assert.ok(envelope.rendered.includes('gap-analysis.plan-post'), 'rendered must include check query');
});
test('[negative] render-hooks plan:post returns empty activeHooks when workflow.post_planning_gaps=false (gate deactivated)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: false } })
);
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
assert.strictEqual(envelope.point, 'plan:post');
// GENUINE check: must be EMPTY, not length 1
assert.deepStrictEqual(envelope.activeHooks, [], 'activeHooks must be empty when gate disabled');
assert.strictEqual(envelope.rendered, '_No active hooks at plan:post._',
'rendered placeholder must match exactly when no hooks active');
});
test('[happy] render-hooks plan:post with explicit post_planning_gaps=true in config returns same hook as default', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: true } })
);
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
assert.strictEqual(envelope.activeHooks.length, 1, 'exactly one hook with explicit true');
assert.strictEqual(envelope.activeHooks[0].capId, 'gap-analysis');
assert.strictEqual(envelope.activeHooks[0].blocking, false);
});
test('[bva] render-hooks plan:post envelope has exactly 3 keys (point, activeHooks, rendered) — Hyrum\'s law shape pin', () => {
const r = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
const envelope = JSON.parse(r.stdout);
const keys = Object.keys(envelope).sort();
assert.deepStrictEqual(keys, ['activeHooks', 'point', 'rendered'],
`envelope must have exactly 3 keys, got: ${keys.join(',')}`);
});
});
// ─── Section 2: check gap-analysis.plan-post — content tests ─────────────────
describe('check gap-analysis.plan-post — gate content E2E', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
});
afterEach(() => cleanup(tmpDir));
// ── Coverage table tests ────────────────────────────────────────────────────
test('[happy] check gap-analysis.plan-post returns block:false with coverage table when phaseDir has plans covering some REQ-IDs', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02']);
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 only.\n');
const r = runGapCheck([phaseDir, 'REQ-01,REQ-02'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
// GENUINE typed field assertions
assert.strictEqual(out.block, false, 'block must be false (gap-analysis is always advisory)');
assert.strictEqual(out.passed, true);
assert.strictEqual(out.enabled, true);
assert.strictEqual(out.counts.total, 2, 'total must be 2');
assert.strictEqual(out.counts.covered, 1, 'covered must be 1 (REQ-01 only)');
assert.strictEqual(out.counts.uncovered, 1, 'uncovered must be 1 (REQ-02 not in plan)');
// Table content — assert specific coverage rows
assert.ok(out.table.includes('REQ-01'), 'table must include REQ-01');
assert.ok(out.table.includes('REQ-02'), 'table must include REQ-02');
assert.ok(out.table.includes('✓ Covered'), 'table must show covered row');
assert.ok(out.table.includes('✗ Not covered'), 'table must show not-covered row');
});
test('[happy] check gap-analysis.plan-post returns block:false with all-covered summary when all REQ-IDs and D-IDs are in plans', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writeContext(phaseDir, [{ id: 'D-01', text: 'Use pattern X for consistency' }]);
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 and D-01.\n');
const r = runGapCheck([phaseDir], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.block, false);
assert.strictEqual(out.enabled, true);
assert.strictEqual(out.counts.total, 2, 'total must be 2 (1 req + 1 decision)');
assert.strictEqual(out.counts.covered, 2, 'both items must be covered');
// GENUINE: uncovered must be 0, not 1
assert.strictEqual(out.counts.uncovered, 0, 'uncovered must be 0 when all covered');
assert.ok(/all 2 items covered/i.test(out.summary), `summary must say "all 2 items covered", got: ${out.summary}`);
});
test('[empty-resolution] check gap-analysis.plan-post returns block:false with empty rows when no REQUIREMENTS.md and no CONTEXT.md exist', () => {
// No REQUIREMENTS.md, no CONTEXT.md — only a PLAN.md
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
const r = runGapCheck([phaseDir], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.block, false);
assert.strictEqual(out.enabled, true);
// GENUINE: total must be 0 (nothing to check)
assert.strictEqual(out.counts.total, 0, 'total must be 0 with no requirements/decisions');
assert.strictEqual(out.counts.uncovered, 0);
assert.ok(/no requirements or decisions/i.test(out.summary),
`summary must mention "no requirements or decisions", got: ${out.summary}`);
});
// ── Disabled gate tests ─────────────────────────────────────────────────────
test('[negative] check gap-analysis.plan-post returns enabled:false with block:false when workflow.post_planning_gaps=false', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: false } })
);
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
const r = runGapCheck([phaseDir], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.block, false, 'block must be false when disabled');
assert.strictEqual(out.passed, true);
// GENUINE: enabled must be FALSE when gate is disabled
assert.strictEqual(out.enabled, false, 'enabled must be false when post_planning_gaps=false');
assert.strictEqual(out.table, '', 'table must be empty string when disabled');
assert.ok(/disabled/i.test(out.summary), `summary must mention disabled, got: ${out.summary}`);
assert.strictEqual(out.counts.total, 0);
});
// ── Missing arg tests ───────────────────────────────────────────────────────
test('[negative] check gap-analysis.plan-post exits non-zero with error string when phaseDir argument is omitted', () => {
// Pass only --raw, no phaseDir
const r = runGsdTools(['check', 'gap-analysis.plan-post', '--raw'], tmpDir);
// GENUINE: must fail, not succeed
assert.strictEqual(r.success, false, 'must fail when phaseDir omitted');
assert.strictEqual(r.exitCode, 1, 'exit code must be 1');
assert.ok(r.error.includes('requires a phase-dir argument'),
`stderr must say "requires a phase-dir argument", got: ${r.error}`);
// Output should NOT be valid JSON (it's an error message, not JSON)
let parsed;
try { parsed = JSON.parse(r.output); } catch (_) { parsed = null; }
assert.strictEqual(parsed, null, 'output must not be valid JSON when phase-dir is missing');
});
// ── BVA: phaseReqIds=TBD ────────────────────────────────────────────────────
test('[bva] check gap-analysis.plan-post with phaseReqIds=TBD returns zero requirement rows but still reports CONTEXT.md decisions', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['OTHER-01', 'OTHER-02']);
writeContext(phaseDir, [{ id: 'D-01', text: 'Use canonical pattern for this module' }]);
writePlan(phaseDir, '01', '# Plan\n\nNo decisions addressed here.\n');
// TBD means: skip requirements, but still report CONTEXT.md decisions
const r = runGapCheck([phaseDir, 'TBD'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.enabled, true);
// GENUINE: only D-01 (from CONTEXT.md) — OTHER-01/OTHER-02 must be excluded
assert.strictEqual(out.counts.total, 1, 'total must be 1 (only D-01 from CONTEXT.md)');
// REQUIREMENTS.md rows must not appear
assert.ok(!out.table.includes('OTHER-01'), 'OTHER-01 must not appear in table when phaseReqIds=TBD');
assert.ok(!out.table.includes('OTHER-02'), 'OTHER-02 must not appear in table when phaseReqIds=TBD');
// D-01 must appear
assert.ok(out.table.includes('D-01'), 'D-01 from CONTEXT.md must still appear');
});
// ── BVA: mapped REQ-ID absent from REQUIREMENTS.md ─────────────────────────
test('[bva] check gap-analysis.plan-post with mapped REQ-ID absent from REQUIREMENTS.md emits Missing-from-REQUIREMENTS.md status in table', () => {
// REQUIREMENTS.md has only REQ-01, but phaseReqIds includes REQ-99 (absent)
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
const r = runGapCheck([phaseDir, 'REQ-01,REQ-99'], tmpDir);
assert.ok(r.success, `check failed: ${r.error}`);
const out = JSON.parse(r.output);
assert.strictEqual(out.enabled, true);
// GENUINE: uncovered must be 1 (REQ-99 is "missing" which counts as uncovered)
assert.strictEqual(out.counts.uncovered, 1, 'uncovered must be 1 for missing REQ-99');
assert.strictEqual(out.counts.total, 2, 'total must be 2 (REQ-01 + REQ-99)');
assert.ok(out.table.includes('REQ-99'), 'table must include REQ-99');
// GENUINE: the status row for REQ-99 must say "Missing from REQUIREMENTS.md"
assert.ok(out.table.includes('Missing from REQUIREMENTS.md'),
`table must contain "Missing from REQUIREMENTS.md" for REQ-99, got table: ${out.table}`);
// REQ-01 must still be covered
assert.ok(out.table.includes('✓ Covered'), 'REQ-01 must show as covered');
});
});
// ─── Section 3: Full pipeline — render-hooks → check dispatch ─────────────────
describe('Full pipeline: render-hooks plan:post discovers gate, then check dispatched', () => {
let tmpDir;
let phaseDir;
beforeEach(() => {
tmpDir = createTempProject();
phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
fs.mkdirSync(phaseDir, { recursive: true });
const init = runGsdTools('config-ensure-section', tmpDir);
assert.ok(init.success, `config-ensure-section failed: ${init.error}`);
});
afterEach(() => cleanup(tmpDir));
test('[happy] Full pipeline: render-hooks plan:post discovers gate hook, then check dispatched with hook.check.query returns advisory table — gate never blocking', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01', 'REQ-02']);
writePlan(phaseDir, '01', '# Plan 1\n\nImplements REQ-01 only.\n');
// Step 1: discover the gate hook via render-hooks
const hookResult = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(hookResult.status, 0, `render-hooks exited non-zero: ${hookResult.stderr}`);
const envelope = JSON.parse(hookResult.stdout);
assert.strictEqual(envelope.activeHooks.length, 1, 'must discover exactly 1 gate hook');
const hook = envelope.activeHooks[0];
// GENUINE: gate must be advisory (blocking=false)
assert.strictEqual(hook.blocking, false, 'gap-analysis gate must be non-blocking');
assert.strictEqual(hook.check.query, 'gap-analysis.plan-post', 'check.query must be gap-analysis.plan-post');
// Step 2: dispatch the check using the discovered query
const checkResult = runGapCheck([phaseDir], tmpDir);
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
const out = JSON.parse(checkResult.output);
// GENUINE: the check result must also say block:false
assert.strictEqual(out.block, false, 'check must return block:false (advisory gate)');
assert.strictEqual(out.counts.uncovered, 1, 'one uncovered item: REQ-02');
assert.ok(out.table.length > 0, 'table must be non-empty');
assert.ok(out.table.includes('REQ-01'), 'table must show REQ-01');
assert.ok(out.table.includes('REQ-02'), 'table must show REQ-02');
});
test('[happy] Full pipeline: when post_planning_gaps=true and all items covered, check returns zero uncovered', () => {
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nImplements REQ-01.\n');
// Confirm hook exists via render-hooks
const hookResult = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(hookResult.status, 0);
const envelope = JSON.parse(hookResult.stdout);
assert.strictEqual(envelope.activeHooks.length, 1);
// Run the check
const checkResult = runGapCheck([phaseDir], tmpDir);
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
const out = JSON.parse(checkResult.output);
assert.strictEqual(out.block, false);
assert.strictEqual(out.enabled, true);
assert.strictEqual(out.counts.total, 1);
assert.strictEqual(out.counts.covered, 1);
assert.strictEqual(out.counts.uncovered, 0);
});
test('[negative] Full pipeline: when post_planning_gaps=false, render-hooks returns empty and check returns enabled:false — dual contract agreement', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { post_planning_gaps: false } })
);
writeRequirements(path.join(tmpDir, '.planning'), ['REQ-01']);
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
// Step 1: render-hooks must return empty (gate suppressed)
const hookResult = spawnRenderHooks('plan:post', tmpDir);
assert.strictEqual(hookResult.status, 0);
const envelope = JSON.parse(hookResult.stdout);
// GENUINE: both render-hooks and check must agree on suppression
assert.deepStrictEqual(envelope.activeHooks, [],
'render-hooks must return empty activeHooks when gate disabled');
assert.strictEqual(envelope.rendered, '_No active hooks at plan:post._');
// Step 2: check must return enabled:false, confirming dual-contract agreement
writePlan(phaseDir, '01', '# Plan\n\nSome content.\n');
const checkResult = runGapCheck([phaseDir], tmpDir);
assert.ok(checkResult.success, `check failed: ${checkResult.error}`);
const out = JSON.parse(checkResult.output);
// GENUINE: enabled must be false (both surfaces agree gate is suppressed)
assert.strictEqual(out.enabled, false,
'check must return enabled:false when render-hooks also shows empty — dual contract parity');
});
});
// ─── Section 4: Pure resolver tests against real registry ────────────────────
describe('resolveLoopHooks plan:post — pure function against real registry', () => {
const { resolveLoopHooks, renderLoopHooks } = require('../gsd-core/bin/lib/loop-resolver.cjs');
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('[happy] resolveLoopHooks plan:post with post_planning_gaps=true returns one gap-analysis gate', () => {
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: { workflow: { post_planning_gaps: true } },
});
assert.strictEqual(result.point, 'plan:post');
assert.ok(Array.isArray(result.activeHooks));
assert.strictEqual(result.activeHooks.length, 1, 'must be exactly 1 hook with post_planning_gaps=true');
const hook = result.activeHooks[0];
assert.strictEqual(hook.capId, 'gap-analysis');
assert.strictEqual(hook.kind, 'gate');
assert.strictEqual(hook.blocking, false);
assert.strictEqual(hook.onError, 'skip');
});
test('[negative] resolveLoopHooks plan:post with post_planning_gaps=false returns empty activeHooks', () => {
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: { workflow: { post_planning_gaps: false } },
});
assert.strictEqual(result.point, 'plan:post');
// GENUINE: must be empty array (not length-1)
assert.deepStrictEqual(result.activeHooks, [],
'activeHooks must be empty when post_planning_gaps=false');
});
test('[happy] renderLoopHooks plan:post with empty activeHooks returns exact placeholder string', () => {
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: { workflow: { post_planning_gaps: false } },
});
const rendered = renderLoopHooks(result);
// GENUINE: must be exact placeholder, not a hook string
assert.strictEqual(rendered, '_No active hooks at plan:post._',
'rendered must be exact placeholder when no active hooks');
});
test('[bva] resolveLoopHooks plan:post with absent config uses schema default (post_planning_gaps=true)', () => {
// No workflow key in config → schema default should be true → hook active
const result = resolveLoopHooks({
point: 'plan:post',
registry: realRegistry,
config: {},
});
// GENUINE: schema default=true means the hook should activate even with empty config
assert.strictEqual(result.activeHooks.length, 1,
'schema default for post_planning_gaps is true — hook must activate with empty config');
assert.strictEqual(result.activeHooks[0].capId, 'gap-analysis');
});
test('[happy] real registry byLoopPoint plan:post has exactly one gate and no steps or contributions', () => {
const entry = realRegistry.byLoopPoint['plan:post'];
assert.ok(entry, 'plan:post must exist in byLoopPoint');
assert.ok(Array.isArray(entry.steps), 'steps must be an array');
assert.ok(Array.isArray(entry.contributions), 'contributions must be an array');
assert.ok(Array.isArray(entry.gates), 'gates must be an array');
assert.strictEqual(entry.steps.length, 0, 'plan:post must have zero steps');
assert.strictEqual(entry.contributions.length, 0, 'plan:post must have zero contributions');
assert.strictEqual(entry.gates.length, 1, 'plan:post must have exactly one gate');
assert.strictEqual(entry.gates[0].capId, 'gap-analysis');
});
});

View File

@@ -0,0 +1,463 @@
'use strict';
/**
* E2E content tests for execute:post hook resolution and tdd.review-checkpoint gate.
*
* Hook point: execute:post
* Focus:
* - loop render-hooks execute:post typed envelope (step + gate ordering, both-on / tdd-off / both-off)
* - check tdd.review-checkpoint via CLI subprocess with real git fixtures:
* RED+GREEN → block:false,violations:0,Pass
* no commits → block:true,missing:[RED,GREEN]
* RED only → block:true,missing:[GREEN]
* no type:tdd plans → block:false,tddPlans:0
* violations=1 boundary → block:true with advisory table
* missing phase arg → exitCode:1
* - rendered text format: Step 1 code-review before Gate tdd
*
* HARD RULES followed:
* - CONTENT/E2E only: every test drives a real CLI subprocess or real resolver
* - No readFileSync source-grep (scripts/lint-no-source-grep.cjs would reject it)
* - Genuine assertions: negative/BVA cases assert the SPECIFIC differing value
* - Fully isolated: each test has its own createTempProject / createTempGitProject
* - Git fixtures use real file commits (not --allow-empty) so git log --grep -- . matches
*/
const { describe, test, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync, spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const TOOLS_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ─── Git fixture helper (inlined — do NOT modify helpers.cjs) ──────────────────
/**
* Create a temp dir with a git repo and initial commit containing a .planning/
* phases directory structure. Commits real files (not --allow-empty) so that
* git log --grep -- . works correctly (the -- path filter skips empty-tree commits).
*
* Returns { tmpDir } — cleanup() in afterEach.
*/
function createTddGitFixture({ planFiles = [] } = {}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tdd-e2e-'));
function git(...args) {
const result = spawnSync('git', args, {
cwd: tmpDir,
encoding: 'utf-8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@test.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@test.com',
},
});
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`);
}
return result.stdout.trim();
}
git('init', '--initial-branch=main');
git('config', 'user.email', 'test@test.com');
git('config', 'user.name', 'Test');
// Create planning directory
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(planningDir, { recursive: true });
// Write plan files
for (const { dir, filename, content } of planFiles) {
const phaseDir = path.join(phasesDir, dir);
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, filename), content, 'utf8');
}
// Write a config.json with git tracking so initial commit has a real file
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
git('add', '.');
git('commit', '-m', 'init: project scaffold');
return { tmpDir, git };
}
/**
* Build a type:tdd PLAN.md frontmatter block content.
*/
function tddPlan(phaseNum, planId) {
return `---\ntype: tdd\nphase: ${phaseNum}\nslug: ${planId}\n---\n# Task: ${planId}\n`;
}
/**
* Build a type:execute PLAN.md (non-TDD) content.
*/
function executePlan(phaseNum, planId) {
return `---\ntype: execute\nphase: ${phaseNum}\nslug: ${planId}\n---\n# Task: ${planId}\n`;
}
/**
* Commit a real file in the git fixture with the given commit message.
* Needed because git log --grep with -- path filter only matches commits
* that changed at least one tracked file.
*/
function commitFile(git, tmpDir, filename, commitMessage) {
const filepath = path.join(tmpDir, filename);
// Append timestamp to make each file unique
fs.writeFileSync(filepath, `${commitMessage}\n${Date.now()}\n`, 'utf8');
git('add', filepath);
git('commit', '-m', commitMessage);
}
// ─── Helpers for subprocess invocation ────────────────────────────────────────
const TEST_ENV_BASE = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
};
function runTools(args, cwd) {
const argv = Array.isArray(args)
? args
: (args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [])
.map((t) => t.replace(/"([^"]*)"/g, '$1').replace(/'([^']*)'/g, '$1'));
try {
const stdout = execFileSync(process.execPath, [TOOLS_PATH, ...argv], {
cwd,
encoding: 'utf-8',
env: { ...process.env, ...TEST_ENV_BASE },
timeout: 60000,
});
return { success: true, output: stdout.trim(), exitCode: 0, error: '' };
} catch (err) {
return {
success: false,
output: err.stdout?.toString().trim() || '',
error: err.stderr?.toString().trim() || err.message,
exitCode: err.status ?? 1,
};
}
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe('execute:post render-hooks — typed envelope resolution', () => {
let tmpDir;
afterEach(() => { if (tmpDir) { cleanup(tmpDir); tmpDir = null; } });
test('[happy] tdd_mode=true and code_review=true: both hooks in typed shape with step before gate', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { code_review: true, tdd_mode: true } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
assert.strictEqual(envelope.point, 'execute:post', 'point field must be execute:post');
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
assert.strictEqual(envelope.activeHooks.length, 2, 'both hooks (step + gate) must be active');
// Step 1: code-review step (must come before gate)
const step = envelope.activeHooks[0];
assert.strictEqual(step.kind, 'step', 'first hook must be a step');
assert.strictEqual(step.capId, 'code-review', 'step capId must be code-review');
assert.deepStrictEqual(step.ref, { skill: 'code-review' }, 'step ref must point to code-review skill');
assert.ok(Array.isArray(step.produces), 'produces must be array');
assert.ok(step.produces.includes('REVIEW.md'), 'step must produce REVIEW.md');
assert.strictEqual(step.onError, 'skip', 'code-review step onError must be skip');
// Gate: tdd advisory gate (must come after step)
const gate = envelope.activeHooks[1];
assert.strictEqual(gate.kind, 'gate', 'second hook must be a gate');
assert.strictEqual(gate.capId, 'tdd', 'gate capId must be tdd');
assert.deepStrictEqual(gate.check, { query: 'tdd.review-checkpoint' }, 'gate check query must match');
assert.strictEqual(gate.blocking, false, 'tdd gate must be advisory (blocking=false)');
});
test('[negative] code_review=false and tdd_mode=false: empty activeHooks with no-hooks rendered text', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { code_review: false, tdd_mode: false } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed even with both disabled. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
assert.strictEqual(envelope.point, 'execute:post');
// SPECIFIC assertion: 0 hooks, not 1 or 2
assert.strictEqual(envelope.activeHooks.length, 0, 'both disabled: must return ZERO active hooks, not any');
assert.ok(
envelope.rendered.includes('_No active hooks at execute:post._'),
`rendered must contain placeholder text, got: ${envelope.rendered}`
);
});
test('[negative] tdd_mode=false excludes tdd gate but code-review step active by schema default', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { tdd_mode: false } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
// SPECIFIC assertion: exactly 1 hook (code-review only), not 0 or 2
assert.strictEqual(envelope.activeHooks.length, 1, 'tdd_mode=false: exactly 1 hook (step only), not 2');
assert.strictEqual(envelope.activeHooks[0].capId, 'code-review', 'sole hook must be code-review step');
assert.strictEqual(envelope.activeHooks[0].kind, 'step', 'sole hook must be kind=step');
// Confirm tdd gate is absent
const tddHook = envelope.activeHooks.find((h) => h.capId === 'tdd');
assert.strictEqual(tddHook, undefined, 'no tdd gate hook must be present when tdd_mode=false');
});
test('[happy] rendered text format: Step 1 code-review before Gate tdd in correct markdown', () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ep-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ workflow: { code_review: true, tdd_mode: true } }),
'utf8'
);
const result = runTools('loop render-hooks execute:post --raw', tmpDir);
assert.ok(result.success, `render-hooks should succeed. stderr: ${result.error}`);
const envelope = JSON.parse(result.output);
const rendered = envelope.rendered;
// Step 1 code-review heading must appear first
assert.ok(
rendered.includes('### Step 1: skill:code-review (code-review)'),
`rendered must start with Step 1 heading. got: ${rendered.slice(0, 200)}`
);
// produces and consumes in step section
assert.ok(rendered.includes('produces: REVIEW.md'), 'rendered must include produces: REVIEW.md');
assert.ok(rendered.includes('consumes: SUMMARY.md'), 'rendered must include consumes: SUMMARY.md');
// when key for code-review step
assert.ok(rendered.includes('when: `workflow.code_review`'), 'rendered must include when for code-review');
// Gate tdd appears AFTER the step
assert.ok(
rendered.includes('**Gate** (tdd): check={"query":"tdd.review-checkpoint"}, blocking=false, onError=skip'),
`rendered must include Gate tdd section. got: ${rendered}`
);
// Step 1 must come before the gate
const step1Idx = rendered.indexOf('### Step 1');
const gateIdx = rendered.indexOf('**Gate** (tdd)');
assert.ok(step1Idx < gateIdx, 'Step 1 code-review must appear before Gate tdd in rendered text');
});
});
// ─── check tdd.review-checkpoint via CLI — git fixture tests ───────────────────
describe('check tdd.review-checkpoint — CLI subprocess E2E with git fixtures', () => {
test('[happy] RED+GREEN commits present: block:false, violations:0, status Pass', () => {
const { tmpDir, git } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
],
});
try {
// RED: failing test commit (must touch a real file for git log --grep -- . to work)
commitFile(git, tmpDir, 'test-login.js', 'test(01-01): failing test for login');
// GREEN: implementation commit
commitFile(git, tmpDir, 'login.js', 'feat(01-01): implement login');
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should succeed with exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.block, false, 'RED+GREEN present: block must be false, not true');
assert.strictEqual(out.passed, true, 'passed must be true');
assert.strictEqual(out.tddPlans, 1, 'must find 1 tdd plan');
assert.strictEqual(out.violations, 0, 'violations must be 0 when both commits present');
assert.ok(Array.isArray(out.rows), 'rows must be array');
assert.strictEqual(out.rows.length, 1, 'must have 1 row');
assert.strictEqual(out.rows[0].planId, '01-01', 'planId must be 01-01');
assert.strictEqual(out.rows[0].red, true, 'red must be true');
assert.strictEqual(out.rows[0].green, true, 'green must be true');
assert.strictEqual(out.rows[0].status, 'Pass', 'status must be Pass');
assert.strictEqual(out.rows[0].missing.length, 0, 'missing array must be empty');
} finally {
cleanup(tmpDir);
}
});
test('[negative] type:tdd plan with no commits: block:true, violations:1, missing includes RED and GREEN', () => {
const { tmpDir } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
],
});
try {
// No additional commits — only the init commit exists
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should exit 0 (advisory gate). stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC assertion: block must be TRUE (distinguishes from the passing case)
assert.strictEqual(out.block, true, 'no commits: block must be TRUE, not false');
assert.strictEqual(out.tddPlans, 1, 'must find 1 tdd plan');
assert.strictEqual(out.violations, 1, 'violations must be 1');
assert.strictEqual(out.rows[0].red, false, 'red must be false without test() commit');
assert.strictEqual(out.rows[0].green, false, 'green must be false without feat() commit');
assert.strictEqual(out.rows[0].status, 'FAIL', 'status must be FAIL');
// missing must include both RED and GREEN
assert.ok(out.rows[0].missing.includes('RED'), 'missing must include RED');
assert.ok(out.rows[0].missing.includes('GREEN'), 'missing must include GREEN');
} finally {
cleanup(tmpDir);
}
});
test('[negative] RED present but GREEN missing: block:true, violations:1, missing deepEqual [GREEN]', () => {
const { tmpDir, git } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
],
});
try {
// Only RED commit — no feat() commit
commitFile(git, tmpDir, 'test-auth.js', 'test(01-01): failing auth test');
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC assertion: block true, violations 1
assert.strictEqual(out.block, true, 'RED only: block must be true');
assert.strictEqual(out.violations, 1, 'violations must be exactly 1');
assert.strictEqual(out.rows[0].red, true, 'red must be true (commit present)');
assert.strictEqual(out.rows[0].green, false, 'green must be false (no feat commit)');
assert.strictEqual(out.rows[0].status, 'FAIL', 'status must be FAIL');
// missing must be exactly ['GREEN'] — not ['RED', 'GREEN']
assert.deepStrictEqual(out.rows[0].missing, ['GREEN'], 'missing must deepEqual [GREEN] when RED present');
} finally {
cleanup(tmpDir);
}
});
test('[empty-resolution] no type:tdd plans (type:execute only): block:false, tddPlans:0, empty rows', () => {
const { tmpDir } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: executePlan(1, '01-01') },
],
});
try {
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should succeed with exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC assertion: block false AND tddPlans 0 (distinguishes from a plan that passes)
assert.strictEqual(out.block, false, 'no tdd plans: block must be false');
assert.strictEqual(out.tddPlans, 0, 'tddPlans must be 0 when no type:tdd files');
assert.strictEqual(out.violations, 0, 'violations must be 0');
assert.strictEqual(out.rows.length, 0, 'rows must be empty array');
assert.strictEqual(out.table, '', 'table must be empty string when no tdd plans');
} finally {
cleanup(tmpDir);
}
});
test('[bva] violations=1 boundary: exactly 1 violation sets block:true and advisory message present', () => {
// Two tdd plans: 01-01 passes (both commits), 01-02 fails (no commits)
// violations = 1 exactly — boundary test (violations > 0 → block:true)
const { tmpDir, git } = createTddGitFixture({
planFiles: [
{ dir: '01-phase1', filename: '01-01-PLAN.md', content: tddPlan(1, '01-01') },
{ dir: '01-phase1', filename: '01-02-PLAN.md', content: tddPlan(1, '01-02') },
],
});
try {
// 01-01: both RED and GREEN commits (passes)
commitFile(git, tmpDir, 'test1.js', 'test(01-01): failing test');
commitFile(git, tmpDir, 'impl1.js', 'feat(01-01): implementation');
// 01-02: no commits (fails)
const result = runTools('check tdd.review-checkpoint 1 --raw', tmpDir);
assert.ok(result.success, `check should exit 0. stderr: ${result.error}`);
const out = JSON.parse(result.output);
// SPECIFIC: block must be TRUE for violations=1 (not false as it would be for violations=0)
assert.strictEqual(out.block, true, 'violations=1 boundary: block must be true');
assert.strictEqual(out.violations, 1, 'violations must be exactly 1 (not 0, not 2)');
assert.strictEqual(out.tddPlans, 2, 'tddPlans must be 2');
assert.strictEqual(out.passed, true, 'advisory gate: passed stays true');
// Check both rows
const passRow = out.rows.find((r) => r.planId === '01-01');
const failRow = out.rows.find((r) => r.planId === '01-02');
assert.ok(passRow, '01-01 row must exist');
assert.ok(failRow, '01-02 row must exist');
assert.strictEqual(passRow.status, 'Pass', '01-01 must Pass');
assert.strictEqual(failRow.status, 'FAIL', '01-02 must FAIL');
// Advisory table must mention the warning text
assert.ok(
out.table.includes('⚠ Gate violations are advisory'),
'table must include advisory warning when violations > 0'
);
} finally {
cleanup(tmpDir);
}
});
test('[negative] missing phase argument: exitCode 1 and error contains required message', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tdd-noarg-'));
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
try {
const result = runTools('check tdd.review-checkpoint --raw', tmpDir);
// SPECIFIC: exitCode must be 1 (non-zero), not 0
assert.strictEqual(result.success, false, 'missing phase arg must cause failure (success=false)');
assert.strictEqual(result.exitCode, 1, 'exitCode must be 1, not 0');
// Error message must identify the command and what's missing
const errText = result.error + result.output;
assert.ok(
errText.includes('tdd.review-checkpoint') || errText.includes('phase argument'),
`error must reference tdd.review-checkpoint or phase argument. got: ${errText}`
);
} finally {
cleanup(tmpDir);
}
});
});

View File

@@ -0,0 +1,259 @@
'use strict';
/**
* Behavioral tests for the `check ui-safety-gate` subcommand (#1168).
*
* Tests the `computeUiSafetyGate` pure function exported from check-command-router.cjs.
* Uses in-memory tmpdir fixtures — no real CLI subprocess needed.
*
* Return shape: { frontend: bool, hasUiFiles: bool, hasUiSpec: bool, block: bool, message?: string }
* Invariant: block = frontend && hasUiFiles && !hasUiSpec
*
* Per RULESET.TESTS.boundary-coverage: exercises all branches:
* (a) frontend + UI files changed + no spec → block:true
* (b) frontend + UI files changed + spec exists → block:false
* (c) non-frontend → block:false
* (d) frontend + no UI files changed → block:false
*
* Per RULESET.TESTS.coderabbit-fix-prefer: calls the exported function and asserts typed fields.
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { computeUiSafetyGate } = require('../gsd-core/bin/lib/check-command-router.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Create a minimal project dir with:
* .planning/ROADMAP.md — one phase section with `phaseSection` body
* .planning/phases/01-test-phase/ — phase directory
* (optionally) a *-UI-SPEC.md inside the phase dir
*/
function makeProject({ phaseSection = '', hasUiSpec = false } = {}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-safety-gate-test-'));
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
const phaseDir = path.join(phasesDir, '01-test-phase');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({}), 'utf8');
// Minimal ROADMAP.md with one phase section
const roadmapContent = [
'# Project Roadmap',
'',
'## Phase 1: Test Phase',
'',
phaseSection,
'',
].join('\n');
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmapContent, 'utf8');
if (hasUiSpec) {
fs.writeFileSync(path.join(phaseDir, '01-UI-SPEC.md'), '# UI Design Contract\n', 'utf8');
}
return { tmpDir, phaseDir };
}
// ─── Tests ─────────────────────────────────────────────────────────────────────
describe('computeUiSafetyGate — ui.safety-gate check logic (#1168)', () => {
let frontendNoSpec, frontendWithSpec, nonFrontend;
before(() => {
// Branch (a): frontend + no UI-SPEC → tests block behavior
frontendNoSpec = makeProject({
phaseSection: 'Build the user interface and dashboard components for the frontend.',
hasUiSpec: false,
});
// Branch (b): frontend + UI-SPEC exists → block:false
frontendWithSpec = makeProject({
phaseSection: 'Build the frontend dashboard with React components and UI forms.',
hasUiSpec: true,
});
// Branch (c): no frontend indicators → block:false
nonFrontend = makeProject({
phaseSection: 'Add a REST API endpoint and database migration for the user table.',
hasUiSpec: false,
});
});
after(() => {
for (const { tmpDir } of [frontendNoSpec, frontendWithSpec, nonFrontend]) {
try { cleanup(tmpDir); } catch { /* ignore */ }
}
});
describe('return shape', () => {
test('result has required keys: frontend, hasUiFiles, hasUiSpec, block', () => {
const result = computeUiSafetyGate(nonFrontend.tmpDir, '1');
assert.ok(typeof result === 'object' && result !== null, 'result must be an object');
assert.ok(typeof result.frontend === 'boolean', 'frontend must be boolean');
assert.ok(typeof result.hasUiFiles === 'boolean', 'hasUiFiles must be boolean');
assert.ok(typeof result.hasUiSpec === 'boolean', 'hasUiSpec must be boolean');
assert.ok(typeof result.block === 'boolean', 'block must be boolean');
});
test('block invariant: block === frontend && hasUiFiles && !hasUiSpec for all scenarios', () => {
for (const [label, { tmpDir }] of [
['frontendNoSpec', frontendNoSpec],
['frontendWithSpec', frontendWithSpec],
['nonFrontend', nonFrontend],
]) {
const r = computeUiSafetyGate(tmpDir, '1');
assert.strictEqual(
r.block,
r.frontend && r.hasUiFiles && !r.hasUiSpec,
`${label}: block invariant violated — frontend=${r.frontend} hasUiFiles=${r.hasUiFiles} hasUiSpec=${r.hasUiSpec} block=${r.block}`,
);
}
});
});
describe('branch (a) — frontend + no UI-SPEC → gate fires when hasUiFiles', () => {
test('detects frontend indicators in phase section', () => {
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
assert.strictEqual(r.frontend, true, 'should detect frontend indicators');
});
test('hasUiSpec is false when no *-UI-SPEC.md exists', () => {
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
assert.strictEqual(r.hasUiSpec, false, 'hasUiSpec must be false');
});
test('block is true when frontend + hasUiFiles + no UI-SPEC', () => {
// hasUiFiles depends on git state; when false, block must also be false (invariant).
// We verify the invariant holds rather than hardcoding the git state.
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
assert.strictEqual(r.block, r.frontend && r.hasUiFiles && !r.hasUiSpec,
'block invariant: frontend && hasUiFiles && !hasUiSpec');
});
test('message is present when block is true', () => {
const r = computeUiSafetyGate(frontendNoSpec.tmpDir, '1');
if (r.block) {
assert.ok(typeof r.message === 'string' && r.message.length > 0,
'message must be a non-empty string when block is true');
assert.ok(r.message.includes('UI-SPEC'), 'message must reference UI-SPEC');
}
});
});
describe('branch (b) — frontend + UI-SPEC exists → block:false', () => {
test('detects frontend indicators in phase section', () => {
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
assert.strictEqual(r.frontend, true, 'should detect frontend indicators');
});
test('hasUiSpec is true when *-UI-SPEC.md exists', () => {
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
assert.strictEqual(r.hasUiSpec, true, 'hasUiSpec must be true');
});
test('block is false when UI-SPEC exists (regardless of hasUiFiles)', () => {
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
assert.strictEqual(r.block, false, 'block must be false when spec exists');
});
test('message is absent when block is false', () => {
const r = computeUiSafetyGate(frontendWithSpec.tmpDir, '1');
assert.ok(!r.message || r.message === undefined,
'message must be absent when block is false');
});
});
describe('branch (c) — non-frontend phase → block:false', () => {
test('frontend is false for non-UI phase section', () => {
const r = computeUiSafetyGate(nonFrontend.tmpDir, '1');
assert.strictEqual(r.frontend, false, 'should NOT detect frontend indicators');
});
test('block is false for non-frontend phases', () => {
const r = computeUiSafetyGate(nonFrontend.tmpDir, '1');
assert.strictEqual(r.block, false, 'block must be false');
});
});
describe('graceful degradation', () => {
test('non-existent project dir returns frontend:false, block:false (no crash)', () => {
const r = computeUiSafetyGate('/tmp/nonexistent-gsd-test-dir-xyz', '1');
assert.strictEqual(typeof r.frontend, 'boolean', 'frontend must be boolean');
assert.strictEqual(r.frontend, false, 'missing roadmap → no frontend indicators');
assert.strictEqual(r.block, false, 'missing roadmap → block false');
assert.strictEqual(typeof r.hasUiFiles, 'boolean', 'hasUiFiles must be boolean');
assert.strictEqual(typeof r.hasUiSpec, 'boolean', 'hasUiSpec must be boolean');
});
test('missing ROADMAP.md returns frontend:false gracefully (no phaseLookupFailed)', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-safety-nomap-'));
try {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-phase'), { recursive: true });
const r = computeUiSafetyGate(tmpDir, '1');
assert.strictEqual(r.frontend, false, 'no ROADMAP → no frontend indicators');
assert.strictEqual(r.block, false, 'no ROADMAP → no block');
assert.ok(
!r.phaseLookupFailed,
'phaseLookupFailed must NOT be set when ROADMAP.md is absent (no-roadmap project is not a lookup failure)',
);
} finally {
try { cleanup(tmpDir); } catch { /* ignore */ }
}
});
test('ROADMAP.md present but phase not found → phaseLookupFailed:true (not silent false)', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-safety-noPhase-'));
try {
const planningDir = path.join(tmpDir, '.planning');
const phasesDir = path.join(planningDir, 'phases');
fs.mkdirSync(path.join(phasesDir, '01-test-phase'), { recursive: true });
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [
'# Project Roadmap',
'',
'## Phase 1: Test Phase',
'',
'Build the frontend dashboard with React components.',
'',
].join('\n'), 'utf8');
// Phase 99 is not in the roadmap
const r = computeUiSafetyGate(tmpDir, '99');
assert.strictEqual(r.phaseLookupFailed, true,
'phaseLookupFailed must be true when ROADMAP.md exists but phase is not found');
assert.strictEqual(r.frontend, false, 'empty section → no frontend indicators');
} finally {
try { cleanup(tmpDir); } catch { /* ignore */ }
}
});
});
describe('routing — ui-safety-gate is routable via check-command-router', () => {
test('routeCheckCommand routes ui-safety-gate (hyphen form)', () => {
const { routeCheckCommand } = require('../gsd-core/bin/lib/check-command-router.cjs');
// Should not throw; just verify routing works (output goes to stdout)
let threw = false;
try {
routeCheckCommand({ args: ['check', 'ui-safety-gate', '1'], cwd: nonFrontend.tmpDir, raw: true });
} catch (err) {
threw = true;
}
assert.strictEqual(threw, false, 'routeCheckCommand must not throw for ui-safety-gate');
});
test('routeCheckCommand routes ui.safety-gate (dot form — normalized to hyphens)', () => {
const { routeCheckCommand } = require('../gsd-core/bin/lib/check-command-router.cjs');
let threw = false;
try {
routeCheckCommand({ args: ['check', 'ui.safety-gate', '1'], cwd: nonFrontend.tmpDir, raw: true });
} catch (err) {
threw = true;
}
assert.strictEqual(threw, false, 'routeCheckCommand must not throw for ui.safety-gate (dot form)');
});
});
});

View File

@@ -469,17 +469,44 @@ describe('detectDrift — non-blocking guarantee', () => {
}); });
}); });
// ─── Config validation: new keys present and restricted ────────────────────── // ─── Config validation: drift keys owned by the drift capability ──────────────
//
// After ADR-857 phase-6 migration, workflow.drift_threshold and workflow.drift_action
// are no longer in the central config schema manifest (VALID_CONFIG_KEYS). They are
// federated config keys owned exclusively by the `drift` capability in the registry.
// VALID_CONFIG_KEYS covers central-only keys; capability-owned keys resolve through
// the federated config overlay (loadConfig still returns them at their defaults).
const CAPABILITY_REGISTRY_PATH = path.join(
__dirname,
'..',
'gsd-core',
'bin',
'lib',
'capability-registry.cjs',
);
describe('config-schema — drift keys', () => { describe('config-schema — drift keys', () => {
test('workflow.drift_threshold in VALID_CONFIG_KEYS', () => { test('workflow.drift_threshold owned by drift capability (not central)', () => {
const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH); const { isCentralConfigKey } = require(CONFIG_SCHEMA_PATH);
assert.ok(VALID_CONFIG_KEYS.has('workflow.drift_threshold')); const registry = require(CAPABILITY_REGISTRY_PATH);
// Must be owned by the drift capability
assert.strictEqual(registry.configKeys['workflow.drift_threshold'], 'drift',
'workflow.drift_threshold must be owned by the drift capability');
// Must NOT be in central schema (migration complete)
assert.strictEqual(isCentralConfigKey('workflow.drift_threshold'), false,
'workflow.drift_threshold must not be a central config key after capability migration');
}); });
test('workflow.drift_action in VALID_CONFIG_KEYS', () => { test('workflow.drift_action owned by drift capability (not central)', () => {
const { VALID_CONFIG_KEYS } = require(CONFIG_SCHEMA_PATH); const { isCentralConfigKey } = require(CONFIG_SCHEMA_PATH);
assert.ok(VALID_CONFIG_KEYS.has('workflow.drift_action')); const registry = require(CAPABILITY_REGISTRY_PATH);
// Must be owned by the drift capability
assert.strictEqual(registry.configKeys['workflow.drift_action'], 'drift',
'workflow.drift_action must be owned by the drift capability');
// Must NOT be in central schema (migration complete)
assert.strictEqual(isCentralConfigKey('workflow.drift_action'), false,
'workflow.drift_action must not be a central config key after capability migration');
}); });
}); });
@@ -571,9 +598,19 @@ describe('gsd-codebase-mapper --paths flag', () => {
}); });
// ─── Execute-phase workflow integration ────────────────────────────────────── // ─── Execute-phase workflow integration ──────────────────────────────────────
//
// After ADR-857 phase-6 migration, codebase_drift_gate is no longer an inline
// step in execute-phase.md. Instead, it is declared as a gate in the `drift`
// capability at the `execute:wave:post` hook point. The execute-phase.md
// dispatches capability gates via `gsd_run loop render-hooks execute:wave:post`,
// which fires the drift gates automatically.
describe('execute-phase integrates codebase_drift_gate', () => { describe('execute-phase integrates codebase_drift_gate', () => {
test('workflow references a codebase drift step', () => { test('workflow references a codebase drift step', () => {
// After capability migration: the drift gate fires via execute:wave:post
// render-hooks dispatch. Verify two things:
// 1. execute-phase.md has the execute:wave:post render-hooks call site.
// 2. The drift capability declares a codebase-drift gate at execute:wave:post.
const doc = fs.readFileSync( const doc = fs.readFileSync(
path.join( path.join(
__dirname, __dirname,
@@ -584,7 +621,25 @@ describe('execute-phase integrates codebase_drift_gate', () => {
), ),
'utf8', 'utf8',
); );
assert.ok(/codebase_drift_gate|codebase-drift/.test(doc)); // execute-phase.md must dispatch execute:wave:post hooks (the call site that fires drift gates)
assert.ok(
/loop render-hooks execute:wave:post/.test(doc),
'execute-phase.md must dispatch execute:wave:post hooks (drift capability gate call site)',
);
// The drift capability must declare a codebase-drift gate at execute:wave:post
const registry = require(CAPABILITY_REGISTRY_PATH);
const driftCap = registry.capabilities['drift'];
assert.ok(driftCap, 'drift capability must be registered');
const codebaseDriftGate = (driftCap.gates || []).find(
(g) => g.check && /codebase.drift/i.test(g.check.query),
);
assert.ok(
codebaseDriftGate,
'drift capability must declare a codebase-drift gate at execute:wave:post',
);
assert.strictEqual(codebaseDriftGate.point, 'execute:wave:post');
assert.strictEqual(codebaseDriftGate.blocking, false,
'codebase-drift gate must be non-blocking by contract');
}); });
test('workflow documents non-blocking guarantee for drift', () => { test('workflow documents non-blocking guarantee for drift', () => {

View File

@@ -15,14 +15,38 @@ const WORKFLOW = path.join(__dirname, '..', 'gsd-core', 'workflows', 'execute-ph
function parseGateContract(content) { function parseGateContract(content) {
const lines = content.split(/\r?\n/); const lines = content.split(/\r?\n/);
const lowerLines = lines.map(line => line.toLowerCase()); const lowerLines = lines.map(line => line.toLowerCase());
// Detect whether the proceed-past-tdd-escalation is conditional on the MVP+TDD block
// being ABSENT (correct) or unconditional (contradicts the block — regression).
//
// A contradicting unconditional proceed looks like:
// "regardless ... gate results ... always proceed"
// That pattern is illegal once the MVP+TDD block is documented, because it nullifies it.
// The proceed must be guarded ("if ... not blocked ... proceed").
const hasUnconditionalProceed = /regardless[\s\S]{0,60}gate results[\s\S]{0,60}always proceed/i.test(content)
|| /always proceed[\s\S]{0,80}regardless/i.test(content);
// The corrected proceed must co-occur with a conditional guard near the block mention.
// We accept either: explicit conditional keyword ("if ... not ... block" / "otherwise proceed")
// adjacent to the block text, OR absence of the unconditional pattern altogether.
const hasProceedConditional = !hasUnconditionalProceed;
return { return {
hasMvpModeVariable: lowerLines.some(line => line.includes('mvp_mode')), hasMvpModeVariable: lowerLines.some(line => line.includes('mvp_mode')),
hasRoadmapModeResolution: lowerLines.some(line => line.includes('phase.mvp-mode') || line.includes('roadmap') && line.includes('mode')), hasRoadmapModeResolution: lowerLines.some(line => line.includes('phase.mvp-mode') || line.includes('roadmap') && line.includes('mode')),
hasDualGateCondition: lowerLines.some(line => line.includes('mvp_mode') && line.includes('tdd_mode')), hasDualGateCondition: lowerLines.some(line => line.includes('mvp_mode') && line.includes('tdd_mode')),
hasGateLabel: lowerLines.some(line => line.includes('mvp+tdd gate') || line.includes('mvp-tdd gate')), hasGateLabel: lowerLines.some(line => line.includes('mvp+tdd gate') || line.includes('mvp-tdd gate')),
hasRedCommitRule: lowerLines.some(line => line.includes('failing-test commit') || line.includes('missing red commit') || line.includes('test(')), hasRedCommitRule: lowerLines.some(line => line.includes('failing-test commit') || line.includes('missing red commit') || line.includes('test(')),
hasBlockingEscalation: lowerLines.some(line => line.includes('blocking') && line.includes('mvp+tdd')), // Must assert the REAL refusal semantics, not merely the words "blocking" + "mvp+tdd"
// (which "advisory (blocking: false) ... under MVP+TDD" would satisfy as a false green).
hasBlockingEscalation:
content.toLowerCase().includes('mvp+tdd')
&& (content.toLowerCase().includes('refuse to mark the phase complete')
|| content.toLowerCase().includes('phase blocked')),
hasReferenceDoc: lowerLines.some(line => line.includes('execute-mvp-tdd.md')), hasReferenceDoc: lowerLines.some(line => line.includes('execute-mvp-tdd.md')),
// Must NOT have an unconditional "proceed regardless of gate results" that overrides the block.
// hasProceedConditional is true when the proceed is properly gated (or absent entirely).
hasProceedConditional,
}; };
} }
@@ -47,6 +71,13 @@ describe('execute-phase — MVP+TDD gate', () => {
assert.ok(contract.hasBlockingEscalation, 'must escalate end-of-phase review to blocking'); assert.ok(contract.hasBlockingEscalation, 'must escalate end-of-phase review to blocking');
}); });
test('proceed past TDD escalation is conditional — not an unconditional override', () => {
assert.ok(
contract.hasProceedConditional,
'workflow must NOT contain an unconditional "regardless of gate results, ALWAYS proceed" that nullifies the MVP+TDD block; the proceed must be guarded by the absence of an MVP+TDD block',
);
});
test('workflow references execute-mvp-tdd.md', () => { test('workflow references execute-mvp-tdd.md', () => {
assert.ok(contract.hasReferenceDoc, 'must reference the gate semantics file'); assert.ok(contract.hasReferenceDoc, 'must reference the gate semantics file');
}); });

View File

@@ -0,0 +1,640 @@
'use strict';
/**
* execute-wave-post-gate-pipeline-e2e.test.cjs
*
* ADR-857 Phase 6 capstone E2E content tests for the execute:wave:post hook pipeline.
*
* Hook: execute:wave:post
* Three gates registered in the real capability-registry.cjs:
* 1. drift / verify.schema-drift — blocking=true, onError=skip
* 2. drift / verify.codebase-drift — blocking=false, onError=skip
* 3. ui / ui.safety-gate — blocking=true, onError=halt
*
* Focus areas:
* A. loop render-hooks execute:wave:post — resolution full/partial/none
* B. check verify.schema-drift — no-schema/block/GSD_SKIP_SCHEMA_CHECK bypass
* C. check verify.codebase-drift — BVA threshold-1/threshold/auto-remap/no-STRUCTURE.md
* D. check ui.safety-gate — frontend+UI-file/+spec/missing-arg
* E. Full pipeline chain (render-hooks → dispatch each gate)
*
* All tests drive real CLI commands or real resolver functions.
* No readFileSync source-grep.
*/
const { describe, test, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ─── Test-local git helper ───────────────────────────────────────────────────
// Inline — NOT modifying tests/helpers.cjs per task rules.
function gitSync(args, cwd) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@test.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@test.com' } });
if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
return r.stdout.trim();
}
function initGitRepo(dir) {
gitSync(['init'], dir);
gitSync(['config', 'user.email', 'test@test.com'], dir);
gitSync(['config', 'user.name', 'Test'], dir);
gitSync(['config', 'commit.gpgsign', 'false'], dir);
}
function gitAddCommit(dir, message) {
gitSync(['add', '-A'], dir);
gitSync(['commit', '--allow-empty', '-m', message], dir);
}
// ─── GSD CLI runner ──────────────────────────────────────────────────────────
/**
* Run gsd-tools and return { status, stdout, stderr, parsed? }.
* When raw=true the tool emits JSON; parsed is set on success.
*/
function runTool(args, { cwd, env = {} } = {}) {
const childEnv = {
...process.env,
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
...env,
};
const r = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
cwd: cwd || os.tmpdir(),
encoding: 'utf8',
env: childEnv,
timeout: 60000,
});
const result = { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
if (r.stdout && r.stdout.trim().startsWith('{')) {
try { result.parsed = JSON.parse(r.stdout.trim()); } catch { /* non-JSON or partial */ }
}
return result;
}
// ─── Shared fixture teardown ─────────────────────────────────────────────────
const tmpDirs = [];
function makeTmpDir() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wave-post-'));
tmpDirs.push(d);
return d;
}
after(() => { for (const d of tmpDirs) { try { cleanup(d); } catch { /* best-effort */ } } });
// ─── Section A: loop render-hooks execute:wave:post ──────────────────────────
describe('A. loop render-hooks execute:wave:post — resolution', () => {
test('[happy] full resolution: all 3 gates present with default config', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
// default config — schema_drift_gate and ui_safety_gate both default to true
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), '{}');
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const env = r.parsed;
assert.strictEqual(env.point, 'execute:wave:post');
assert.ok(Array.isArray(env.activeHooks), 'activeHooks must be an array');
// Real registry: 3 gates (schema-drift blocking, codebase-drift non-blocking, ui-safety blocking)
assert.strictEqual(env.activeHooks.length, 3,
`expected 3 gates; got ${env.activeHooks.length}: ${JSON.stringify(env.activeHooks.map(h => h.capId || h.check?.query))}`);
// Verify the three expected gate queries
const queries = env.activeHooks.map(h => h.check?.query);
assert.ok(queries.includes('verify.schema-drift'), 'verify.schema-drift gate must be present');
assert.ok(queries.includes('verify.codebase-drift'), 'verify.codebase-drift gate must be present');
assert.ok(queries.includes('ui.safety-gate'), 'ui.safety-gate gate must be present');
});
test('[negative] no gates returned when schema_drift_gate=false AND ui_safety_gate=false — all suppressed', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ workflow: { schema_drift_gate: false, ui_safety_gate: false } }),
);
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const env = r.parsed;
assert.strictEqual(env.point, 'execute:wave:post');
// This is the SPECIFIC differing value — must be 0, not 1, 2, or 3
assert.strictEqual(env.activeHooks.length, 0,
`expected 0 active hooks when both gates suppressed; got ${env.activeHooks.length}`);
assert.strictEqual(env.rendered, '_No active hooks at execute:wave:post._');
});
test('[bva] partial suppression: schema_drift_gate=false → only ui gate present (1 hook)', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
// schema_drift_gate=false suppresses BOTH drift gates (both use this when key)
// ui_safety_gate defaults to true so ui.safety-gate stays active
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ workflow: { schema_drift_gate: false, ui_safety_gate: true } }),
);
const r = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const env = r.parsed;
// Specific differing value: exactly 1 hook, not 3 or 0
assert.strictEqual(env.activeHooks.length, 1,
`expected 1 hook (only ui); got ${env.activeHooks.length}: ${JSON.stringify(env.activeHooks.map(h => h.check?.query))}`);
assert.strictEqual(env.activeHooks[0].check?.query, 'ui.safety-gate',
`remaining hook must be ui.safety-gate, got ${env.activeHooks[0].check?.query}`);
assert.strictEqual(env.activeHooks[0].capId, 'ui');
});
});
// ─── Section B: check verify.schema-drift ────────────────────────────────────
describe('B. check verify.schema-drift — CLI route', () => {
// Helper: build a minimal git repo with a phase dir containing a PLAN.md
function buildSchemaDriftFixture({ hasSchemaFile = false } = {}) {
const dir = makeTmpDir();
initGitRepo(dir);
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-setup'), { recursive: true });
// Write a PLAN.md with files_modified
const schemaEntry = hasSchemaFile ? 'prisma/schema.prisma' : 'src/index.ts';
const planContent = [
'# 01 Plan',
'',
`files_modified: [${schemaEntry}]`,
'',
].join('\n');
fs.writeFileSync(path.join(dir, '.planning', 'phases', '01-setup', '01-PLAN.md'), planContent);
// Write README so git has something to commit
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
return dir;
}
test('[happy] block:false when no schema files in PLAN.md — happy path', () => {
const dir = buildSchemaDriftFixture({ hasSchemaFile: false });
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific typed fields
assert.strictEqual(result.block, false, `block must be false for non-schema file; got ${result.block}`);
assert.strictEqual(result.drift_detected, false,
`drift_detected must be false; got ${result.drift_detected}`);
assert.strictEqual(result.skipped, false,
`skipped must be false; got ${result.skipped}`);
});
test('[negative] block:true when schema file in PLAN.md and no push executed — fail-closed', () => {
const dir = buildSchemaDriftFixture({ hasSchemaFile: true });
// No SUMMARY.md with push evidence is written — so schema drift detected
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be TRUE here (not false) — FAIL if block is still false
assert.strictEqual(result.block, true, `block must be true when schema file has no push; got ${result.block}`);
assert.strictEqual(result.drift_detected, true,
`drift_detected must be true; got ${result.drift_detected}`);
// unpushed_orms must contain 'prisma'
assert.ok(Array.isArray(result.unpushed_orms), 'unpushed_orms must be an array');
assert.ok(result.unpushed_orms.includes('prisma'),
`unpushed_orms must include 'prisma'; got ${JSON.stringify(result.unpushed_orms)}`);
});
test('[negative] GSD_SKIP_SCHEMA_CHECK=true → block:false, skipped:true even with schema drift', () => {
const dir = buildSchemaDriftFixture({ hasSchemaFile: true });
const r = runTool(['check', 'verify.schema-drift', '1', '--raw'], {
cwd: dir,
env: { GSD_SKIP_SCHEMA_CHECK: 'true' },
});
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be FALSE (bypassed) even though drift was detected
assert.strictEqual(result.block, false,
`block must be false with GSD_SKIP_SCHEMA_CHECK=true; got ${result.block}`);
assert.strictEqual(result.skipped, true,
`skipped must be true; got ${result.skipped}`);
// drift_detected should still be true (bypass doesn't mask detection)
assert.strictEqual(result.drift_detected, true,
`drift_detected must be true even when bypassed; got ${result.drift_detected}`);
});
});
// ─── Section C: check verify.codebase-drift — BVA ────────────────────────────
describe('C. check verify.codebase-drift — BVA at threshold', () => {
/**
* Build a git repo with STRUCTURE.md stamped at an initial commit,
* then add N new barrel exports in a second commit to trigger drift detection.
*/
function buildCodebaseDriftFixture({ barrelCount = 0, driftAction = 'warn', threshold = 3 } = {}) {
const dir = makeTmpDir();
initGitRepo(dir);
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
// Write config.json
const config = {
workflow: {
drift_threshold: threshold,
drift_action: driftAction,
},
};
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(config));
// Initial commit with STRUCTURE.md + config
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
// Write STRUCTURE.md stub — will be stamped after initial commit
fs.writeFileSync(
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
'# Structure\n\nInitial layout.\n',
);
gitAddCommit(dir, 'initial commit');
// Stamp STRUCTURE.md with last_mapped_commit = HEAD of initial commit
const headSha = gitSync(['rev-parse', 'HEAD'], dir);
const stampedContent = `---\nlast_mapped_commit: ${headSha}\n---\n# Structure\n\nInitial layout.\n`;
fs.writeFileSync(path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'), stampedContent);
gitAddCommit(dir, 'stamp STRUCTURE.md with last_mapped_commit');
// Add the new barrel exports in a third commit (these are "new" since last map)
if (barrelCount > 0) {
for (let i = 0; i < barrelCount; i++) {
const pkgName = `pkg-${i}`;
fs.mkdirSync(path.join(dir, 'packages', pkgName, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'packages', pkgName, 'src', 'index.ts'),
`export const val${i} = ${i};\n`,
);
}
gitAddCommit(dir, `add ${barrelCount} new barrel exports`);
// Re-read head sha and update STRUCTURE.md stamp to the pre-barrel commit
// (so all the barrel files are "new" relative to last_mapped_commit)
// Actually: we want the stamp to be at the commit BEFORE the barrels were added,
// so we need to get the second commit's SHA.
// We already have stamped at the second commit. The third commit added barrels.
// The stamp still points to the initial commit, so diff = all new barrel files.
}
return dir;
}
test('[bva] threshold-1 (2 elements) → block:false, action_required:false — just-under boundary', () => {
// threshold=3, barrelCount=2 → 2 < 3 → no block
const dir = buildCodebaseDriftFixture({ barrelCount: 2, threshold: 3 });
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be FALSE at threshold-1
assert.strictEqual(result.block, false,
`block must be false at threshold-1 (2 elements); got ${result.block}`);
assert.strictEqual(result.action_required, false,
`action_required must be false; got ${result.action_required}`);
assert.ok(Array.isArray(result.elements),
`elements must be an array; got ${typeof result.elements}`);
assert.strictEqual(result.elements.length, 2,
`elements.length must be exactly 2; got ${result.elements.length}`);
assert.strictEqual(result.directive, 'none',
`directive must be 'none'; got ${result.directive}`);
assert.strictEqual(result.skipped, false,
`skipped must be false; got ${result.skipped}`);
});
test('[bva] threshold exactly (3 elements) → block:true, action_required:true — at boundary', () => {
// threshold=3, barrelCount=3 → 3 >= 3 → block
const dir = buildCodebaseDriftFixture({ barrelCount: 3, threshold: 3 });
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be TRUE at exactly threshold — FAIL if still false
assert.strictEqual(result.block, true,
`block must be true at threshold (3 elements); got ${result.block}`);
assert.strictEqual(result.action_required, true,
`action_required must be true; got ${result.action_required}`);
assert.strictEqual(result.elements.length, 3,
`elements.length must be exactly 3; got ${result.elements.length}`);
assert.strictEqual(result.directive, 'warn',
`directive must be 'warn'; got ${result.directive}`);
assert.strictEqual(result.spawn_mapper, false,
`spawn_mapper must be false for warn action; got ${result.spawn_mapper}`);
});
test('[happy] drift_action=auto-remap + threshold exceeded → block:true, spawn_mapper:true', () => {
const dir = buildCodebaseDriftFixture({ barrelCount: 3, driftAction: 'auto-remap', threshold: 3 });
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
assert.strictEqual(result.block, true,
`block must be true; got ${result.block}`);
assert.strictEqual(result.action_required, true,
`action_required must be true; got ${result.action_required}`);
// Specific: spawn_mapper must be TRUE for auto-remap action
assert.strictEqual(result.spawn_mapper, true,
`spawn_mapper must be true for auto-remap; got ${result.spawn_mapper}`);
assert.strictEqual(result.directive, 'auto-remap',
`directive must be 'auto-remap'; got ${result.directive}`);
});
test('[empty-resolution] STRUCTURE.md absent → block:false, skipped:true, reason:no-structure-md', () => {
const dir = makeTmpDir();
initGitRepo(dir);
// Create .planning/codebase/ dir but NO STRUCTURE.md
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
const r = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
assert.strictEqual(result.block, false,
`block must be false when STRUCTURE.md absent; got ${result.block}`);
assert.strictEqual(result.skipped, true,
`skipped must be true; got ${result.skipped}`);
assert.strictEqual(result.reason, 'no-structure-md',
`reason must be 'no-structure-md'; got ${result.reason}`);
assert.strictEqual(result.action_required, false,
`action_required must be false; got ${result.action_required}`);
});
});
// ─── Section D: check ui.safety-gate ─────────────────────────────────────────
describe('D. check ui.safety-gate — CLI subprocess route', () => {
/**
* Build a git repo fixture for ui.safety-gate tests.
*
* Sequence:
* commit 1: initial commit with README
* commit 2: add src/components/Button.tsx (UI file)
* Optional: create .planning/phases/01-phase/01-UI-SPEC.md
*/
function buildUiSafetyGateFixture({ hasUiSpec = false, frontend = true } = {}) {
const dir = makeTmpDir();
initGitRepo(dir);
// Create planning dirs
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-phase'), { recursive: true });
// Write ROADMAP.md with a frontend Phase 1 section.
// getRoadmapPhaseWithFallback requires ## or ### heading (not #) for phase lookup.
const phaseText = frontend
? '## Phase 1: dashboard frontend\n\nBuild the user-facing dashboard UI component.\n'
: '## Phase 1: backend api\n\nBuild the backend API endpoints only.\n';
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), phaseText);
// Initial commit
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
// Optionally add UI-SPEC before the UI file commit
if (hasUiSpec) {
fs.writeFileSync(
path.join(dir, '.planning', 'phases', '01-phase', '01-UI-SPEC.md'),
'# UI Spec\n\nDesign contract for Phase 1.\n',
);
gitAddCommit(dir, 'add UI-SPEC');
}
// Second commit: add a UI file (matches UI_FILE_EXTENSIONS_RE: .tsx)
fs.mkdirSync(path.join(dir, 'src', 'components'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'components', 'Button.tsx'),
'export const Button = () => null;\n',
);
gitAddCommit(dir, 'add Button.tsx component');
return dir;
}
test('[negative] block:true when frontend phase + UI file changed + no UI-SPEC — live block path', () => {
const dir = buildUiSafetyGateFixture({ hasUiSpec: false, frontend: true });
const r = runTool(['check', 'ui.safety-gate', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be TRUE — fails if block is false
assert.strictEqual(result.block, true,
`block must be true: frontend=${result.frontend} hasUiFiles=${result.hasUiFiles} hasUiSpec=${result.hasUiSpec}`);
assert.strictEqual(result.frontend, true,
`frontend must be true; got ${result.frontend}`);
assert.strictEqual(result.hasUiFiles, true,
`hasUiFiles must be true; got ${result.hasUiFiles}`);
assert.strictEqual(result.hasUiSpec, false,
`hasUiSpec must be false; got ${result.hasUiSpec}`);
});
test('[happy] block:false when frontend phase + UI file changed + UI-SPEC present — gate passes', () => {
const dir = buildUiSafetyGateFixture({ hasUiSpec: true, frontend: true });
const r = runTool(['check', 'ui.safety-gate', '1', '--raw'], { cwd: dir });
assert.strictEqual(r.status, 0, `exit non-zero: ${r.stderr}`);
assert.ok(r.parsed, `stdout not JSON: ${r.stdout}`);
const result = r.parsed;
// Specific: block must be FALSE when spec is present
assert.strictEqual(result.block, false,
`block must be false when UI-SPEC exists; got block=${result.block}`);
assert.strictEqual(result.frontend, true,
`frontend must be true; got ${result.frontend}`);
assert.strictEqual(result.hasUiFiles, true,
`hasUiFiles must be true; got ${result.hasUiFiles}`);
assert.strictEqual(result.hasUiSpec, true,
`hasUiSpec must be true; got ${result.hasUiSpec}`);
});
test('[negative] exits non-zero with error message when phase argument is missing', () => {
const dir = makeTmpDir();
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
const r = runTool(['check', 'ui.safety-gate', '--raw'], { cwd: dir });
// Specific: exit must be NON-ZERO — FAIL if 0
assert.notStrictEqual(r.status, 0,
`expected non-zero exit for missing phase arg; got ${r.status}`);
const combined = r.stdout + r.stderr;
assert.ok(
combined.includes('ui-safety-gate requires a phase argument'),
`error message must mention 'ui-safety-gate requires a phase argument'; got: ${combined}`,
);
});
});
// ─── Section E: Full execute:wave:post pipeline chain ────────────────────────
describe('E. Full execute:wave:post pipeline — render-hooks then dispatch gates', () => {
test('[happy] Full chain: render-hooks discovers 3 gates → schema-drift block:false → codebase-drift block:false', () => {
// Build fixture: git repo, non-frontend ROADMAP, fresh STRUCTURE.md stamped at current HEAD
const dir = makeTmpDir();
initGitRepo(dir);
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
fs.mkdirSync(path.join(dir, '.planning', 'phases', '01-setup'), { recursive: true });
// Non-frontend ROADMAP so ui.safety-gate doesn't block (no UI files changed).
// Use ## heading — getRoadmapPhaseWithFallback requires ## or ### (not #).
fs.writeFileSync(
path.join(dir, '.planning', 'ROADMAP.md'),
'## Phase 1: backend setup\n\nConfigure server-side services.\n',
);
// PLAN.md with only non-schema files
fs.writeFileSync(
path.join(dir, '.planning', 'phases', '01-setup', '01-PLAN.md'),
'files_modified: [src/server.ts, package.json]\n',
);
// Write STRUCTURE.md stub (no frontmatter stamp initially)
fs.writeFileSync(
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
'# Structure\n\nInitial codebase layout.\n',
);
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
gitAddCommit(dir, 'initial commit');
// Stamp STRUCTURE.md with current HEAD so there is no drift since last map
const headSha = gitSync(['rev-parse', 'HEAD'], dir);
fs.writeFileSync(
path.join(dir, '.planning', 'codebase', 'STRUCTURE.md'),
`---\nlast_mapped_commit: ${headSha}\n---\n# Structure\n\nInitial codebase layout.\n`,
);
gitAddCommit(dir, 'stamp STRUCTURE.md');
// --- Step 1: render-hooks → discover gates ---
const step1 = runTool(['loop', 'render-hooks', 'execute:wave:post', '--raw'], { cwd: dir });
assert.strictEqual(step1.status, 0, `step1 exit non-zero: ${step1.stderr}`);
assert.ok(step1.parsed, `step1 not JSON: ${step1.stdout}`);
const envelope = step1.parsed;
assert.strictEqual(envelope.point, 'execute:wave:post');
assert.strictEqual(envelope.activeHooks.length, 3,
`step1: expected 3 gates, got ${envelope.activeHooks.length}`);
// Confirm schema-drift gate is present and has correct metadata
const schemaDriftHook = envelope.activeHooks.find(h => h.check?.query === 'verify.schema-drift');
assert.ok(schemaDriftHook, 'verify.schema-drift gate must be in activeHooks');
assert.strictEqual(schemaDriftHook.blocking, true, 'schema-drift gate must be blocking');
assert.strictEqual(schemaDriftHook.onError, 'skip', 'schema-drift onError must be skip');
// --- Step 2: dispatch schema-drift gate ---
const step2 = runTool(['check', 'verify.schema-drift', '1', '--raw'], { cwd: dir });
assert.strictEqual(step2.status, 0, `step2 exit non-zero: ${step2.stderr}`);
assert.ok(step2.parsed, `step2 not JSON: ${step2.stdout}`);
assert.strictEqual(step2.parsed.block, false,
`step2 schema-drift block must be false; got ${step2.parsed.block}`);
// --- Step 3: dispatch codebase-drift gate ---
const step3 = runTool(['check', 'verify.codebase-drift', '--raw'], { cwd: dir });
assert.strictEqual(step3.status, 0, `step3 exit non-zero: ${step3.stderr}`);
assert.ok(step3.parsed, `step3 not JSON: ${step3.stdout}`);
// After stamping and committing with no new barrel/migration files, no drift
// (the stamp commit itself is just config changes — not drift categories)
assert.strictEqual(step3.parsed.block, false,
`step3 codebase-drift block must be false; got ${step3.parsed.block}`);
});
});
// ─── Section F: real registry shape assertions (pure-function) ────────────────
describe('F. Real registry execute:wave:post shape — guard against accidental changes', () => {
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
test('[happy] real registry execute:wave:post has exactly 3 gates with correct queries', () => {
const point = realRegistry.byLoopPoint['execute:wave:post'];
assert.ok(point, 'byLoopPoint must have execute:wave:post key');
assert.ok(Array.isArray(point.gates), 'gates must be an array');
// Specific: exactly 3 gates — fails if someone adds or removes one
assert.strictEqual(point.gates.length, 3,
`execute:wave:post must have exactly 3 gates; got ${point.gates.length}`);
const queries = point.gates.map(g => g.check?.query);
assert.ok(queries.includes('verify.schema-drift'), 'verify.schema-drift gate must exist');
assert.ok(queries.includes('verify.codebase-drift'), 'verify.codebase-drift gate must exist');
assert.ok(queries.includes('ui.safety-gate'), 'ui.safety-gate gate must exist');
});
test('[happy] real registry: schema-drift gate is blocking=true, codebase-drift is blocking=false', () => {
const gates = realRegistry.byLoopPoint['execute:wave:post'].gates;
const schemaDrift = gates.find(g => g.check?.query === 'verify.schema-drift');
const codebaseDrift = gates.find(g => g.check?.query === 'verify.codebase-drift');
assert.strictEqual(schemaDrift.blocking, true,
`schema-drift gate must be blocking=true; got ${schemaDrift.blocking}`);
assert.strictEqual(codebaseDrift.blocking, false,
`codebase-drift gate must be blocking=false; got ${codebaseDrift.blocking}`);
});
test('[happy] real registry: ui.safety-gate is blocking=true, onError=halt', () => {
const gates = realRegistry.byLoopPoint['execute:wave:post'].gates;
const uiGate = gates.find(g => g.check?.query === 'ui.safety-gate');
assert.ok(uiGate, 'ui.safety-gate gate must exist');
assert.strictEqual(uiGate.blocking, true,
`ui.safety-gate must be blocking=true; got ${uiGate.blocking}`);
assert.strictEqual(uiGate.onError, 'halt',
`ui.safety-gate onError must be 'halt'; got ${uiGate.onError}`);
});
test('[happy] real registry: execute:wave:post has no steps and no contributions — pure gate point', () => {
const point = realRegistry.byLoopPoint['execute:wave:post'];
assert.strictEqual(point.steps.length, 0,
`execute:wave:post steps must be empty; got ${point.steps.length}`);
assert.strictEqual(point.contributions.length, 0,
`execute:wave:post contributions must be empty; got ${point.contributions.length}`);
});
});

View File

@@ -36,7 +36,6 @@ const NEW_FIELDS = [
]; ];
const CENTRAL_NEW_FIELDS = [ const CENTRAL_NEW_FIELDS = [
'workflow.tdd_mode',
'commit_docs', 'commit_docs',
]; ];

View File

@@ -0,0 +1,418 @@
'use strict';
/**
* federated-config-key-removal.test.cjs
*
* ADR-857 deliverable F — Decision 3:
* "Uninstalling a Capability removes its config keys cleanly."
*
* Tests:
* [happy] Capability X present → its key surfaces; X removed → key gone, others intact.
* [happy] loadConfig after removing a capability no longer surfaces the key, but central
* base-config keys remain.
* [BVA] Orphaned user value for the removed key is dropped — not leaked as a phantom key.
* [negative] Removing capability 'x' does NOT drop a differently-prefixed capability's key
* (e.g. 'xy.enabled' survives when only 'x.enabled' is removed).
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const { mergeFederatedConfig } = require('../gsd-core/bin/lib/federated-config.cjs');
const configLoader = require('../gsd-core/bin/lib/config-loader.cjs');
const {
loadConfig,
_setFederatedRegistryForTests,
_resetFederatedRegistryForTests,
} = configLoader;
// ─── Fixtures ─────────────────────────────────────────────────────────────────
/** Never treated as a central key — all keys federated freely. */
const neverCentral = (_key) => false;
/** Minimal well-formed boolean slice. */
function boolSlice(owner, defaultValue = true) {
return { owner, type: 'boolean', default: defaultValue, description: `Boolean key for ${owner}.` };
}
// ─── Temp project helpers (mirrors federated-config-loadconfig.test.cjs) ──────
let tmpDirs = [];
beforeEach(() => {
tmpDirs = [];
_resetFederatedRegistryForTests();
});
afterEach(() => {
_resetFederatedRegistryForTests();
for (const d of tmpDirs) {
cleanup(d);
}
});
function mkTempProject() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cap-removal-test-'));
tmpDirs.push(d);
fs.mkdirSync(path.join(d, '.planning', 'phases'), { recursive: true });
return d;
}
function writeConfig(dir, obj) {
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify(obj, null, 2),
'utf-8',
);
}
// ─── 1. [happy] key present → surfaces; capability removed → key gone ─────────
describe('[happy] capability X present then removed — key lifecycle', () => {
test('registry WITH capability X: x.enabled surfaces with default true', () => {
const withX = {
'x.enabled': boolSlice('cap-x', true),
};
const result = mergeFederatedConfig({
configSchema: withX,
isCentralKey: neverCentral,
userConfig: {},
});
// Positive assertion: the key is in validKeys AND values
assert.ok(
result.validKeys.includes('x.enabled'),
'x.enabled must be in validKeys when cap-x is installed',
);
assert.strictEqual(
result.values['x.enabled'],
true,
'x.enabled default must be true',
);
assert.deepEqual(result.warnings, [], 'no warnings for valid federated key');
});
test('registry WITHOUT capability X: x.enabled is absent from result', () => {
// Registry after cap-x is uninstalled — its key is no longer in configSchema
const withoutX = {};
const result = mergeFederatedConfig({
configSchema: withoutX,
isCentralKey: neverCentral,
userConfig: {},
});
assert.ok(
!result.validKeys.includes('x.enabled'),
'x.enabled must NOT be in validKeys after cap-x is removed',
);
assert.ok(
!Object.prototype.hasOwnProperty.call(result.values, 'x.enabled'),
'x.enabled must NOT appear in values after cap-x is removed',
);
assert.strictEqual(Object.keys(result.values).length, 0, 'values must be empty');
});
test('[no contamination] removing cap-x leaves cap-y.flag intact', () => {
// Before removal: both capabilities present
const withBoth = {
'x.enabled': boolSlice('cap-x', true),
'y.flag': boolSlice('cap-y', false),
};
const before = mergeFederatedConfig({
configSchema: withBoth,
isCentralKey: neverCentral,
userConfig: {},
});
assert.ok(before.validKeys.includes('x.enabled'), 'x.enabled present before removal');
assert.ok(before.validKeys.includes('y.flag'), 'y.flag present before removal');
// After removal: only cap-y remains in the registry
const withoutX = {
'y.flag': boolSlice('cap-y', false),
};
const after = mergeFederatedConfig({
configSchema: withoutX,
isCentralKey: neverCentral,
userConfig: {},
});
// x.enabled must be gone
assert.ok(
!after.validKeys.includes('x.enabled'),
'x.enabled must be absent after cap-x removal',
);
assert.ok(
!Object.prototype.hasOwnProperty.call(after.values, 'x.enabled'),
'x.enabled must not appear in values after removal',
);
// y.flag must still be present AND have the correct value
assert.ok(
after.validKeys.includes('y.flag'),
'y.flag must still be in validKeys after cap-x removal',
);
assert.strictEqual(
after.values['y.flag'],
false,
'y.flag value must remain false (its default) after cap-x removal',
);
});
});
// ─── 2. [happy] loadConfig: removed capability key absent, base keys intact ───
describe('[happy] loadConfig after capability removal — base keys survive', () => {
test('cap-x present → loadConfig surfaces mytool.enabled; cap-x absent → key gone', () => {
const tmpDir = mkTempProject();
writeConfig(tmpDir, {});
// Phase A: cap-x installed
_setFederatedRegistryForTests({
configSchema: {
'mytool.enabled': boolSlice('cap-x', true),
},
});
const resultWith = loadConfig(tmpDir);
assert.ok(
typeof resultWith['mytool'] === 'object' && resultWith['mytool'] !== null,
'mytool section must exist when cap-x is installed',
);
assert.strictEqual(
resultWith['mytool']['enabled'],
true,
'mytool.enabled must be true (cap-x default)',
);
// Phase B: cap-x uninstalled — registry now empty
_resetFederatedRegistryForTests();
_setFederatedRegistryForTests({ configSchema: {} });
const resultWithout = loadConfig(tmpDir);
// Central base-config key must still be present
assert.ok(
Object.prototype.hasOwnProperty.call(resultWithout, 'model_profile'),
'model_profile (central key) must still exist after cap-x removal',
);
// Federated key must be absent — either undefined or not surfaced under 'mytool'
const myToolSection = resultWithout['mytool'];
const enabledValue = (myToolSection && typeof myToolSection === 'object')
? myToolSection['enabled']
: undefined;
assert.strictEqual(
enabledValue,
undefined,
'mytool.enabled must NOT be present after cap-x removal; got: ' + JSON.stringify(enabledValue),
);
});
test('base config keys (model_profile, research) survive capability removal', () => {
const tmpDir = mkTempProject();
writeConfig(tmpDir, { model_profile: 'fast', research: false });
// Install and then remove a synthetic capability
_setFederatedRegistryForTests({
configSchema: {
'extra.flag': boolSlice('cap-extra', true),
},
});
const before = loadConfig(tmpDir);
assert.strictEqual(before['model_profile'], 'fast', 'model_profile from user config before removal');
assert.strictEqual(before['research'], false, 'research from user config before removal');
_setFederatedRegistryForTests({ configSchema: {} });
const after = loadConfig(tmpDir);
// Central keys from user's config.json must be unchanged
assert.strictEqual(after['model_profile'], 'fast', 'model_profile must survive capability removal');
assert.strictEqual(after['research'], false, 'research must survive capability removal');
});
});
// ─── 3. [BVA] orphaned user value not surfaced after removal ──────────────────
describe('[BVA] orphaned user value is silently dropped after capability removal', () => {
test('user config sets removed key → orphaned value not surfaced as phantom', () => {
// User has 'mytool.enabled': false in their config.json
// BUT the capability is now uninstalled (not in registry configSchema)
const result = mergeFederatedConfig({
configSchema: {}, // cap-x removed — configSchema is empty
isCentralKey: neverCentral,
userConfig: { mytool: { enabled: false } }, // user value remains in file
});
// The orphaned user value must NOT leak into validKeys or values
assert.ok(
!result.validKeys.includes('mytool.enabled'),
'orphaned user key must not appear in validKeys',
);
assert.ok(
!Object.prototype.hasOwnProperty.call(result.values, 'mytool.enabled'),
'orphaned user key must not appear in values',
);
// The entire values map must be empty (no phantom keys)
assert.strictEqual(
Object.keys(result.values).length,
0,
'values must be empty when registry has no keys — got: ' + JSON.stringify(Object.keys(result.values)),
);
assert.deepEqual(result.validKeys, [], 'validKeys must be empty when registry has no keys');
});
test('user config sets removed key — top-level orphan also not surfaced', () => {
// Top-level orphan: user set 'orphan_flag' but the cap is gone
const result = mergeFederatedConfig({
configSchema: {},
isCentralKey: neverCentral,
userConfig: { orphan_flag: true },
});
assert.ok(
!Object.prototype.hasOwnProperty.call(result.values, 'orphan_flag'),
'top-level orphaned key must not appear in values',
);
assert.deepEqual(result.validKeys, []);
assert.strictEqual(Object.keys(result.values).length, 0);
});
test('loadConfig: orphaned user value in config.json not surfaced after removal', () => {
const tmpDir = mkTempProject();
// User config contains a value for a key whose capability will be removed
writeConfig(tmpDir, { orphancap: { flag: true } });
// Capability removed: inject empty registry
_setFederatedRegistryForTests({ configSchema: {} });
const result = loadConfig(tmpDir);
// The orphaned capability key must NOT be surfaced in the resolved config object.
// loadConfig extracts only known/central/federated keys into _baseConfig — any key
// whose capability has been uninstalled (configSchema: {}) must not appear in the result.
assert.ok(
!Object.prototype.hasOwnProperty.call(result, 'orphancap'),
'orphaned top-level key must not appear in resolved config when capability is removed',
);
// Central keys must remain unaffected by capability removal.
assert.ok(
Object.prototype.hasOwnProperty.call(result, 'model_profile'),
'model_profile must still be present (central key unaffected by federated removal)',
);
});
});
// ─── 4. [negative] removing 'x' does NOT drop 'xy.enabled' ──────────────────
describe('[negative] prefix-adjacent key not dropped when shorter-prefix cap removed', () => {
test("removing cap 'x' (key x.enabled) does NOT remove cap 'xy' (key xy.enabled)", () => {
// Registry after 'x' is uninstalled but 'xy' remains
const registryAfterXRemoved = {
'xy.enabled': boolSlice('cap-xy', false),
};
const result = mergeFederatedConfig({
configSchema: registryAfterXRemoved,
isCentralKey: neverCentral,
userConfig: {},
});
// x.enabled must not appear (was removed)
assert.ok(
!result.validKeys.includes('x.enabled'),
'x.enabled must not appear (cap-x was uninstalled)',
);
assert.ok(
!Object.prototype.hasOwnProperty.call(result.values, 'x.enabled'),
'x.enabled must not be in values',
);
// xy.enabled MUST still appear (different capability)
assert.ok(
result.validKeys.includes('xy.enabled'),
'xy.enabled must still be present after cap-x removal',
);
assert.strictEqual(
result.values['xy.enabled'],
false,
'xy.enabled value must be false (cap-xy default), not contaminated by cap-x removal',
);
});
test("removing 'x' does not drop 'x2.enabled' (numeric suffix, distinct cap)", () => {
const registryAfterXRemoved = {
'x2.enabled': boolSlice('cap-x2', true),
};
const result = mergeFederatedConfig({
configSchema: registryAfterXRemoved,
isCentralKey: neverCentral,
userConfig: {},
});
assert.ok(
!result.validKeys.includes('x.enabled'),
'x.enabled must not appear (not in registry)',
);
assert.ok(
result.validKeys.includes('x2.enabled'),
'x2.enabled must survive — it belongs to cap-x2, not cap-x',
);
assert.strictEqual(
result.values['x2.enabled'],
true,
'x2.enabled must have its own default (true)',
);
});
test("removing 'alpha' cap does not affect 'alphabeta.flag' cap", () => {
const registryAfterAlphaRemoved = {
'alphabeta.flag': boolSlice('cap-alphabeta', false),
'gamma.flag': boolSlice('cap-gamma', true),
};
const result = mergeFederatedConfig({
configSchema: registryAfterAlphaRemoved,
isCentralKey: neverCentral,
userConfig: {},
});
// alpha.flag not present (removed)
assert.ok(
!result.validKeys.includes('alpha.flag'),
'alpha.flag must not appear after cap-alpha removal',
);
// alphabeta.flag MUST be present (distinct capability)
assert.ok(
result.validKeys.includes('alphabeta.flag'),
'alphabeta.flag must survive removal of alpha capability',
);
assert.strictEqual(
result.values['alphabeta.flag'],
false,
'alphabeta.flag value must be false (its own default)',
);
// gamma.flag also unaffected
assert.ok(
result.validKeys.includes('gamma.flag'),
'gamma.flag must be unaffected by alpha removal',
);
assert.strictEqual(
result.values['gamma.flag'],
true,
'gamma.flag value must be true (its own default)',
);
});
});

View File

@@ -0,0 +1,750 @@
'use strict';
/**
* E2E content tests for the GSD capability engine — ADR-857 phase 6
*
* Hook points tested: discuss:pre, discuss:post, execute:pre, execute:wave:pre,
* verify:pre, ship:post
*
* All 6 points have zero hooks in the real registry by design.
* Tests pin: exact envelope shape, placeholder string contract (Hyrum's Law),
* resolver-filter mechanics (schema default / config-override / capabilityStatesById),
* CLI contract (missing-arg, invalid-point), and Postel-leniency (malformed config).
*
* Rules:
* - Every test drives a real command (CLI subprocess or real resolver + real registry).
* - No readFileSync(...).includes() source-grep.
* - Negative/BVA cases assert the SPECIFIC differing value so regression is caught.
* - Each test is independently isolated with its own temp dir.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
const { resolveLoopHooks, renderLoopHooks } = require('../gsd-core/bin/lib/loop-resolver.cjs');
// ─── Fixture helpers ──────────────────────────────────────────────────────────
/** Create a bare temp dir with .planning/ layout (no config.json) */
function makeTempProject() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-loop-e2e-'));
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
return dir;
}
/** Create a temp dir with .planning/config.json set to the given object */
function makeTempProjectWithConfig(configObj) {
const dir = makeTempProject();
fs.writeFileSync(path.join(dir, '.planning', 'config.json'), JSON.stringify(configObj));
return dir;
}
/** Create a bare temp dir with no .planning directory at all */
function makeBareDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-loop-bare-'));
}
/** Spawn gsd-tools via raw spawnSync; returns { status, stdout, stderr } */
function spawnGsd(args, cwd) {
return spawnSync(process.execPath, [GSD_TOOLS, ...args], {
cwd: cwd || os.tmpdir(),
encoding: 'utf8',
timeout: 60000,
});
}
/**
* Build a synthetic registry that has ALL 12 canonical byLoopPoint keys
* (required so resolveLoopHooks does not reject valid canonical points),
* with a single step at `targetPoint` that activates on `when` config key.
*/
function buildSyntheticRegistry({ targetPoint, when, schemaDefault }) {
const allPoints = [
'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',
];
const byLoopPoint = {};
for (const p of allPoints) {
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
}
// Add the step (or gate) at the target point
byLoopPoint[targetPoint] = {
steps: [{
capId: 'future-cap',
when,
ref: { skill: 'future-skill' },
}],
contributions: [],
gates: [],
};
const configSchema = {};
if (when !== undefined && schemaDefault !== undefined) {
configSchema[when] = { default: schemaDefault };
}
return { byLoopPoint, configSchema };
}
// ─── Shared all-caps-on config (used by multiple tests) ──────────────────────
const ALL_CAPS_ON_CONFIG = {
workflow: {
ui_phase: true,
ui_review: true,
ui_safety_gate: true,
security_enforcement: true,
tdd_mode: true,
code_review: true,
nyquist_validation: true,
schema_drift_gate: true,
post_planning_gaps: true,
intel: { enabled: true },
},
};
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 1: discuss:pre
// ─────────────────────────────────────────────────────────────────────────────
describe('discuss:pre — real registry empty-resolution', () => {
let tmpDir;
before(() => { tmpDir = makeTempProject(); });
after(() => { cleanup(tmpDir); });
it('[happy] discuss:pre with real registry returns exact 3-key envelope with empty activeHooks (Gall\'s Law E2E pin)', () => {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:pre', '--cwd', tmpDir, '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'discuss:pre');
assert.deepEqual(envelope.activeHooks, []);
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:pre._');
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered'], 'envelope must have exactly 3 keys');
});
it('[bva] discuss:pre with all capability config keys enabled still returns activeHooks:[] — config does not activate phantom hooks', () => {
const configDir = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
try {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:pre', '--cwd', configDir, '--raw'], configDir);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
assert.deepEqual(envelope.activeHooks, [], 'No capability config should activate hooks at discuss:pre');
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:pre._');
} finally {
cleanup(configDir);
}
});
it('[happy] discuss:pre with real registry pure-function resolveLoopHooks returns empty activeHooks', () => {
const resolved = resolveLoopHooks({ point: 'discuss:pre', registry: realRegistry, config: {} });
assert.strictEqual(resolved.point, 'discuss:pre');
assert.deepEqual(resolved.activeHooks, []);
});
it('[happy] renderLoopHooks for discuss:pre empty state pins the exact Hyrum\'s-Law contract string', () => {
const rendered = renderLoopHooks({ point: 'discuss:pre', activeHooks: [] });
assert.strictEqual(rendered, '_No active hooks at discuss:pre._');
});
it('[negative] discuss:pre missing-point argument to CLI exits non-zero with clear message', () => {
const result = spawnGsd(['loop', 'render-hooks', '--raw'], tmpDir);
assert.notStrictEqual(result.status, 0, 'must exit non-zero when point arg is missing');
const combined = (result.stdout + result.stderr);
assert.match(combined, /render-hooks requires a .point. argument/i);
});
it('[bva] discuss:pre close-typo "discuss:pre " (trailing space) exits non-zero — boundary for point name validation', () => {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:pre ', '--raw'], tmpDir);
assert.notStrictEqual(result.status, 0, 'must exit non-zero for invalid point');
const combined = (result.stdout + result.stderr);
assert.match(combined, /Invalid loop point/i);
// Must list valid points so callers know what to use
assert.match(combined, /discuss:pre[,\s]/);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 2: discuss:post — resolution + synthetic mechanics
// ─────────────────────────────────────────────────────────────────────────────
describe('discuss:post — E2E empty envelope + synthetic resolver mechanics', () => {
let tmpDir;
before(() => { tmpDir = makeTempProjectWithConfig({}); });
after(() => { cleanup(tmpDir); });
it('[empty-resolution] discuss:post E2E subprocess returns exact empty envelope — activeHooks:[], 3-key shape, placeholder string pinned', () => {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post', '--raw', '--cwd', tmpDir], tmpDir);
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'discuss:post');
assert.deepEqual(envelope.activeHooks, []);
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:post._');
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']);
assert.ok(!Object.prototype.hasOwnProperty.call(envelope, 'warnings'), 'must not have spurious warnings field');
});
it('[happy] discuss:post E2E with no .planning directory returns empty hooks (Postel leniency path)', () => {
const bareDir = makeBareDir();
try {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post', '--raw', '--cwd', bareDir], bareDir);
assert.strictEqual(result.status, 0, `expected exit 0 even with no .planning dir. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.activeHooks.length, 0);
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:post._');
} finally {
cleanup(bareDir);
}
});
it('[happy] discuss:post with real registry resolveLoopHooks returns activeHooks:[] (real registry, not synthetic)', () => {
const resolved = resolveLoopHooks({ point: 'discuss:post', registry: realRegistry, config: {} });
assert.strictEqual(resolved.point, 'discuss:post');
assert.strictEqual(resolved.activeHooks.length, 0, 'Real registry must have 0 hooks at discuss:post');
});
it('[bva] discuss:post with synthetic capability, schema default=false + config absent → hook absent (schema default=false suppresses hook)', () => {
const reg = buildSyntheticRegistry({
targetPoint: 'discuss:post',
when: 'workflow.testcap_on',
schemaDefault: false,
});
const resolved = resolveLoopHooks({ point: 'discuss:post', registry: reg, config: {} });
assert.strictEqual(resolved.activeHooks.length, 0, 'schema default=false must suppress hook when config absent');
});
it('[bva] discuss:post with synthetic capability, schema default=true + config absent → hook active; explicit config=false overrides → hook absent (BVA at config-wins threshold)', () => {
const reg = buildSyntheticRegistry({
targetPoint: 'discuss:post',
when: 'workflow.testcap_on',
schemaDefault: true,
});
// Sub-case a: schema default=true, no config → active
const resolvedA = resolveLoopHooks({ point: 'discuss:post', registry: reg, config: {} });
assert.strictEqual(resolvedA.activeHooks.length, 1, 'schema default=true must activate hook when config absent');
// Sub-case b: explicit config=false → overrides schema default → inactive
const resolvedB = resolveLoopHooks({
point: 'discuss:post',
registry: reg,
config: { workflow: { testcap_on: false } },
});
assert.strictEqual(resolvedB.activeHooks.length, 0, 'explicit config=false must override schema default=true');
});
it('[bva] discuss:post with synthetic capability, capabilityStatesById enabled=false → hook absent even when config=true', () => {
const reg = buildSyntheticRegistry({
targetPoint: 'discuss:post',
when: 'workflow.testcap_on',
schemaDefault: true,
});
const resolved = resolveLoopHooks({
point: 'discuss:post',
registry: reg,
config: { workflow: { testcap_on: true } },
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
});
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById enabled=false must suppress hook even when config=true');
});
it('[negative] discuss:post with invalid point name "discuss:past" exits non-zero and lists valid points', () => {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:past', '--raw', '--cwd', tmpDir], tmpDir);
assert.notStrictEqual(result.status, 0, 'must exit non-zero for typo point name');
const combined = (result.stdout + result.stderr);
assert.match(combined, /discuss:past|Invalid loop point/i);
assert.match(combined, /discuss:pre/);
});
it('[negative] discuss:post E2E with malformed config.json → still exits 0 with empty hooks (Postel)', () => {
const malformedDir = makeTempProject();
fs.writeFileSync(path.join(malformedDir, '.planning', 'config.json'), '{invalid json');
try {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post', '--raw', '--cwd', malformedDir], malformedDir);
assert.strictEqual(result.status, 0, `must not crash on malformed config. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.activeHooks.length, 0);
assert.strictEqual(envelope.rendered, '_No active hooks at discuss:post._');
} finally {
cleanup(malformedDir);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 3: execute:pre
// ─────────────────────────────────────────────────────────────────────────────
describe('execute:pre — real registry empty-resolution + synthetic resolver mechanics', () => {
it('[happy] execute:pre with real registry + all capability flags enabled returns 0 active hooks and exact placeholder', () => {
const allOnDir = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
try {
const result = spawnGsd(['loop', 'render-hooks', 'execute:pre', '--raw', '--cwd', allOnDir], allOnDir);
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
const parsed = JSON.parse(result.stdout.trim());
assert.strictEqual(parsed.point, 'execute:pre');
assert.strictEqual(parsed.activeHooks.length, 0);
assert.strictEqual(parsed.rendered, '_No active hooks at execute:pre._');
} finally {
cleanup(allOnDir);
}
});
it('[empty-resolution] execute:pre with no config.json returns 0 active hooks (empty-project negative space)', () => {
const emptyDir = makeTempProject();
try {
const result = spawnGsd(['loop', 'render-hooks', 'execute:pre', '--raw', '--cwd', emptyDir], emptyDir);
assert.strictEqual(result.status, 0);
const parsed = JSON.parse(result.stdout.trim());
assert.strictEqual(parsed.activeHooks.length, 0);
assert.strictEqual(parsed.rendered, '_No active hooks at execute:pre._');
} finally {
cleanup(emptyDir);
}
});
it('[happy] execute:pre with synthetic step+contribution+gate registered and when-flag=true → all 3 hooks activated', () => {
const allPoints = [
'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',
];
const byLoopPoint = {};
for (const p of allPoints) {
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
}
byLoopPoint['execute:pre'] = {
steps: [{ capId: 'future-cap', when: 'workflow.future_enabled', ref: { skill: 'step-skill' } }],
contributions: [{ capId: 'future-cap', when: 'workflow.future_enabled', into: 'context' }],
gates: [{ capId: 'future-cap', when: 'workflow.future_enabled', check: { query: 'future.gate' }, blocking: true, onError: 'halt' }],
};
const syntheticReg = {
byLoopPoint,
configSchema: { 'workflow.future_enabled': { default: false } },
};
const resolved = resolveLoopHooks({
point: 'execute:pre',
registry: syntheticReg,
config: { workflow: { future_enabled: true } },
});
assert.strictEqual(resolved.activeHooks.length, 3, 'All 3 hooks (step, contribution, gate) must be active when when-flag=true');
assert.strictEqual(resolved.activeHooks[0].kind, 'step');
assert.strictEqual(resolved.activeHooks[1].kind, 'contribution');
assert.strictEqual(resolved.activeHooks[2].kind, 'gate');
});
it('[negative] execute:pre with synthetic hook registered but when-flag=false → hook filtered, activeHooks=[], rendered is placeholder', () => {
const reg = buildSyntheticRegistry({ targetPoint: 'execute:pre', when: 'workflow.future_enabled', schemaDefault: false });
const resolved = resolveLoopHooks({
point: 'execute:pre',
registry: reg,
config: { workflow: { future_enabled: false } },
});
assert.strictEqual(resolved.activeHooks.length, 0, 'when-flag=false must filter hook');
const rendered = renderLoopHooks(resolved);
assert.strictEqual(rendered, '_No active hooks at execute:pre._');
});
it('[negative] execute:pre with synthetic unconditional hook but capability disabled via capabilityStatesById → hook filtered', () => {
const allPoints = [
'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',
];
const byLoopPoint = {};
for (const p of allPoints) {
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
}
// No `when` = unconditional hook
byLoopPoint['execute:pre'] = {
steps: [{ capId: 'future-cap', ref: { skill: 'future-skill' } }],
contributions: [],
gates: [],
};
const syntheticReg = { byLoopPoint, configSchema: {} };
const resolved = resolveLoopHooks({
point: 'execute:pre',
registry: syntheticReg,
config: {},
capabilityStatesById: new Map([['future-cap', { enabled: false }]]),
});
assert.strictEqual(resolved.activeHooks.length, 0, 'capabilityStatesById disabled must filter unconditional hook');
});
it('[bva] execute:pre with schema default=true synthetic hook and NO config → hook activates (schema default boundary)', () => {
const reg = buildSyntheticRegistry({
targetPoint: 'execute:pre',
when: 'workflow.future_enabled',
schemaDefault: true,
});
const resolved = resolveLoopHooks({ point: 'execute:pre', registry: reg, config: {} });
assert.strictEqual(resolved.activeHooks.length, 1, 'schema default=true must activate hook even with absent config');
});
it('[negative] real registry has exactly 0 hooks at execute:pre — guard against accidental registration', () => {
const entry = realRegistry.byLoopPoint['execute:pre'];
assert.ok(entry, 'execute:pre must be present in real registry byLoopPoint');
assert.strictEqual(entry.steps.length, 0, 'execute:pre must have 0 steps in real registry');
assert.strictEqual(entry.contributions.length, 0, 'execute:pre must have 0 contributions in real registry');
assert.strictEqual(entry.gates.length, 0, 'execute:pre must have 0 gates in real registry');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 4: execute:wave:pre
// ─────────────────────────────────────────────────────────────────────────────
describe('execute:wave:pre — real registry empty-resolution + synthetic mechanics', () => {
it('[empty-resolution] execute:wave:pre with real registry returns empty activeHooks and exact placeholder text', () => {
const result = spawnGsd(['loop', 'render-hooks', 'execute:wave:pre', '--raw'], os.tmpdir());
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'execute:wave:pre');
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be an array');
assert.strictEqual(envelope.activeHooks.length, 0);
assert.strictEqual(envelope.rendered, '_No active hooks at execute:wave:pre._');
});
it('[happy] execute:wave:pre with synthetic registry containing a gate hook resolves it correctly', () => {
const allPoints = [
'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',
];
const byLoopPoint = {};
for (const p of allPoints) {
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
}
byLoopPoint['execute:wave:pre'] = {
steps: [],
contributions: [],
gates: [{
capId: 'future-cap',
// no `when` = unconditional
check: { query: 'future.gate' },
blocking: true,
onError: 'halt',
}],
};
const syntheticReg = { byLoopPoint, configSchema: {} };
const resolved = resolveLoopHooks({ point: 'execute:wave:pre', registry: syntheticReg, config: {} });
assert.strictEqual(resolved.activeHooks.length, 1);
const gate = resolved.activeHooks[0];
assert.strictEqual(gate.kind, 'gate');
assert.strictEqual(gate.capId, 'future-cap');
assert.deepEqual(gate.check, { query: 'future.gate' });
assert.strictEqual(gate.blocking, true);
assert.strictEqual(gate.onError, 'halt');
});
it('[negative] execute:wave:pre with synthetic step hook and when=false config → hook filtered → empty', () => {
const reg = buildSyntheticRegistry({
targetPoint: 'execute:wave:pre',
when: 'workflow.wave_pre_enabled',
schemaDefault: true,
});
// BVA: schema default=true without override → active
const resolvedDefault = resolveLoopHooks({ point: 'execute:wave:pre', registry: reg, config: {} });
assert.strictEqual(resolvedDefault.activeHooks.length, 1, 'schema default=true with no config override must activate');
// BVA: config override false → inactive
const resolvedOff = resolveLoopHooks({
point: 'execute:wave:pre',
registry: reg,
config: { workflow: { wave_pre_enabled: false } },
});
assert.strictEqual(resolvedOff.activeHooks.length, 0, 'explicit config=false must override schema default=true');
});
it('[bva] execute:wave:pre BVA: schema default=true → active (threshold=on), schema default=false → inactive (threshold=off)', () => {
const regA = buildSyntheticRegistry({ targetPoint: 'execute:wave:pre', when: 'workflow.flag', schemaDefault: true });
const regB = buildSyntheticRegistry({ targetPoint: 'execute:wave:pre', when: 'workflow.flag', schemaDefault: false });
const resolvedA = resolveLoopHooks({ point: 'execute:wave:pre', registry: regA, config: {} });
assert.strictEqual(resolvedA.activeHooks.length, 1, 'registry A (default=true) must activate hook');
const resolvedB = resolveLoopHooks({ point: 'execute:wave:pre', registry: regB, config: {} });
assert.strictEqual(resolvedB.activeHooks.length, 0, 'registry B (default=false) must NOT activate hook');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 5: verify:pre
// ─────────────────────────────────────────────────────────────────────────────
describe('verify:pre — real registry empty-resolution + synthetic extension-point readiness', () => {
let tmpEmptyProjectDir;
let tmpProjectDirAllOn;
before(() => {
tmpEmptyProjectDir = makeTempProject();
tmpProjectDirAllOn = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
});
after(() => {
cleanup(tmpEmptyProjectDir);
cleanup(tmpProjectDirAllOn);
});
it('[empty-resolution] verify:pre with real registry and no config yields empty activeHooks and exact placeholder (Gall\'s Law)', () => {
const resolved = resolveLoopHooks({ point: 'verify:pre', registry: realRegistry, config: {} });
assert.strictEqual(resolved.activeHooks.length, 0);
assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:pre._');
});
it('[happy] verify:pre E2E subprocess returns well-formed 3-key JSON envelope with empty activeHooks (Hyrum\'s Law contract pin)', () => {
const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', tmpEmptyProjectDir, '--raw'], tmpEmptyProjectDir);
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'verify:pre');
assert.deepEqual(envelope.activeHooks, []);
assert.strictEqual(envelope.rendered, '_No active hooks at verify:pre._');
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']);
});
it('[negative] verify:pre with all capability config keys set to true still yields empty activeHooks — no leakage from other points', () => {
const resolved = resolveLoopHooks({
point: 'verify:pre',
registry: realRegistry,
config: { workflow: { ui_phase: true, ui_review: true, ui_safety_gate: true } },
});
assert.strictEqual(resolved.activeHooks.length, 0, 'UI and other capabilities must not bleed through to verify:pre');
});
it('[bva] Synthetic step at verify:pre with configSchema default=true fires correctly — extension point readiness', () => {
const reg = buildSyntheticRegistry({ targetPoint: 'verify:pre', when: 'workflow.future_enabled', schemaDefault: true });
const resolved = resolveLoopHooks({ point: 'verify:pre', registry: reg, config: {} });
assert.strictEqual(resolved.activeHooks.length, 1, 'synthetic step at verify:pre with default=true must activate');
assert.strictEqual(resolved.activeHooks[0].capId, 'future-cap');
assert.strictEqual(resolved.activeHooks[0].kind, 'step');
const rendered = renderLoopHooks(resolved);
assert.match(rendered, /future-skill/);
assert.match(rendered, /future-cap/);
});
it('[bva] Synthetic step at verify:pre with when=false (config override) filters correctly — activation logic applies at this point', () => {
const reg = buildSyntheticRegistry({ targetPoint: 'verify:pre', when: 'workflow.future_enabled', schemaDefault: true });
const resolved = resolveLoopHooks({
point: 'verify:pre',
registry: reg,
config: { workflow: { future_enabled: false } },
});
assert.strictEqual(resolved.activeHooks.length, 0, 'config explicit false must beat schema default=true');
});
it('[negative] verify:pre with malformed config.json in .planning/ degrades leniently (Postel\'s Law at this point)', () => {
const malformedDir = makeTempProject();
fs.writeFileSync(path.join(malformedDir, '.planning', 'config.json'), '{invalid json');
try {
const result = spawnGsd(['loop', 'render-hooks', 'verify:pre', '--cwd', malformedDir, '--raw'], malformedDir);
assert.strictEqual(result.status, 0, `must not crash on malformed config. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.deepEqual(envelope.activeHooks, []);
} finally {
cleanup(malformedDir);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 6: ship:post
// ─────────────────────────────────────────────────────────────────────────────
describe('ship:post — real registry empty-resolution + resilience to registry edge-cases', () => {
it('[happy] ship:post E2E subprocess returns typed envelope: point=\'ship:post\', activeHooks=[], rendered=placeholder, no extra keys', () => {
const tmpDir = makeTempProject();
try {
const result = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', tmpDir], tmpDir);
assert.strictEqual(result.status, 0, `expected exit 0. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'ship:post');
assert.deepEqual(envelope.activeHooks, []);
assert.strictEqual(envelope.rendered, '_No active hooks at ship:post._');
assert.deepEqual(Object.keys(envelope).sort(), ['activeHooks', 'point', 'rendered']);
assert.ok(!Object.prototype.hasOwnProperty.call(envelope, 'warnings'), 'must not have warnings key');
} finally {
cleanup(tmpDir);
}
});
it('[bva] ship:post returns empty activeHooks regardless of any capability config being enabled — no config leaks hooks into this point', () => {
const allOnDir = makeTempProjectWithConfig(ALL_CAPS_ON_CONFIG);
try {
const result = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', allOnDir], allOnDir);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
assert.deepEqual(envelope.activeHooks, [], 'Maximum capability activation must still produce zero hooks at ship:post');
} finally {
cleanup(allOnDir);
}
});
it('[happy] resolveLoopHooks with real capability-registry at ship:post returns empty activeHooks and well-formed byLoopPoint entry', () => {
const resolved = resolveLoopHooks({ point: 'ship:post', registry: realRegistry, config: {} });
assert.strictEqual(resolved.point, 'ship:post');
assert.ok(Array.isArray(resolved.activeHooks));
assert.strictEqual(resolved.activeHooks.length, 0);
const entry = realRegistry.byLoopPoint['ship:post'];
assert.ok(entry, 'ship:post must be present in real registry byLoopPoint');
assert.strictEqual(entry.steps.length, 0, 'ship:post must have 0 steps');
assert.strictEqual(entry.contributions.length, 0, 'ship:post must have 0 contributions');
assert.strictEqual(entry.gates.length, 0, 'ship:post must have 0 gates');
});
it('[negative] ship:post E2E exits 0 and returns empty envelope when project has no .planning directory at all', () => {
const bareDir = makeBareDir();
try {
const result = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', bareDir], bareDir);
assert.strictEqual(result.status, 0, `expected exit 0 even with no .planning dir. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.activeHooks.length, 0);
assert.strictEqual(envelope.rendered, '_No active hooks at ship:post._');
} finally {
cleanup(bareDir);
}
});
it('[negative] resolveLoopHooks does not throw and returns empty hooks when byLoopPoint[\'ship:post\'] is null', () => {
const allPoints = [
'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',
];
const byLoopPoint = {};
for (const p of allPoints) {
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
}
byLoopPoint['ship:post'] = null;
const syntheticReg = { byLoopPoint, configSchema: {} };
const resolved = resolveLoopHooks({ point: 'ship:post', registry: syntheticReg, config: {} });
assert.strictEqual(resolved.activeHooks.length, 0, 'null byLoopPoint entry must not throw and must return 0 hooks');
});
it('[negative] resolveLoopHooks does not throw when byLoopPoint[\'ship:post\'] exists but has no steps/contributions/gates keys', () => {
const allPoints = [
'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',
];
const byLoopPoint = {};
for (const p of allPoints) {
byLoopPoint[p] = { steps: [], contributions: [], gates: [] };
}
byLoopPoint['ship:post'] = {}; // No arrays at all
const syntheticReg = { byLoopPoint, configSchema: {} };
const resolved = resolveLoopHooks({ point: 'ship:post', registry: syntheticReg, config: {} });
assert.strictEqual(resolved.activeHooks.length, 0, 'missing arrays in byLoopPoint entry must not throw');
});
it('[bva] ship:post returns empty activeHooks even when security_enforcement=true — security gate lives at ship:pre not ship:post', () => {
const securityOnDir = makeTempProjectWithConfig({ workflow: { security_enforcement: true } });
try {
// ship:post must be empty
const postResult = spawnGsd(['loop', 'render-hooks', 'ship:post', '--raw', '--cwd', securityOnDir], securityOnDir);
assert.strictEqual(postResult.status, 0);
const postEnvelope = JSON.parse(postResult.stdout.trim());
assert.deepEqual(postEnvelope.activeHooks, [], 'ship:post must be empty even with security_enforcement=true');
// ship:pre must have the security gate (proves the config actually works and the difference is real)
const preResult = spawnGsd(['loop', 'render-hooks', 'ship:pre', '--raw', '--cwd', securityOnDir], securityOnDir);
assert.strictEqual(preResult.status, 0);
const preEnvelope = JSON.parse(preResult.stdout.trim());
const secGate = preEnvelope.activeHooks.find(h => h.capId === 'security');
assert.ok(secGate, 'ship:pre must have a security gate when security_enforcement=true');
} finally {
cleanup(securityOnDir);
}
});
it('[empty-resolution] ship:post byLoopPoint entry exists in the real registry with all three arrays empty — no capability has silently self-registered here', () => {
const entry = realRegistry.byLoopPoint['ship:post'];
assert.ok(entry, 'ship:post must be present in real registry byLoopPoint');
assert.strictEqual(entry.steps.length, 0, 'ship:post must have 0 steps — accidental registration guard');
assert.strictEqual(entry.contributions.length, 0, 'ship:post must have 0 contributions — accidental registration guard');
assert.strictEqual(entry.gates.length, 0, 'ship:post must have 0 gates — accidental registration guard');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 7: CLI contract (shared across all 6 points)
// ─────────────────────────────────────────────────────────────────────────────
describe('CLI contract — missing/invalid point argument (shared across all 6 empty points)', () => {
it('[negative] missing point argument exits non-zero and includes render-hooks syntax in error message', () => {
const result = spawnGsd(['loop', 'render-hooks', '--raw'], os.tmpdir());
assert.notStrictEqual(result.status, 0, 'must exit non-zero when point arg is missing');
const combined = (result.stdout + result.stderr);
assert.match(combined, /render-hooks requires a .point. argument/i);
});
it('[bva] "discuss:post " with trailing space exits non-zero — boundary: trailing whitespace makes point invalid', () => {
const result = spawnGsd(['loop', 'render-hooks', 'discuss:post ', '--raw'], os.tmpdir());
assert.notStrictEqual(result.status, 0, 'must reject point with trailing space');
const combined = (result.stdout + result.stderr);
assert.match(combined, /Invalid loop point/i);
});
it('[bva] "execute:pre." with trailing period exits non-zero — boundary: period suffix makes point invalid', () => {
const result = spawnGsd(['loop', 'render-hooks', 'execute:pre.', '--raw'], os.tmpdir());
assert.notStrictEqual(result.status, 0, 'must reject point with trailing period');
const combined = (result.stdout + result.stderr);
assert.match(combined, /Invalid loop point/i);
});
it('[negative] error message for invalid point includes list of valid points so callers can self-correct', () => {
const result = spawnGsd(['loop', 'render-hooks', 'verify:future', '--raw'], os.tmpdir());
assert.notStrictEqual(result.status, 0);
const combined = (result.stdout + result.stderr);
// Must include at least several valid points in the error message
assert.match(combined, /discuss:pre/);
assert.match(combined, /ship:post/);
assert.match(combined, /verify:pre/);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// SECTION 8: Parametric empty-point sweep across all 6 points (E2E regression guard)
// ─────────────────────────────────────────────────────────────────────────────
describe('Parametric E2E sweep — all 6 empty points return correct envelope shape via real registry', () => {
const EMPTY_POINTS = [
'discuss:pre',
'discuss:post',
'execute:pre',
'execute:wave:pre',
'verify:pre',
'ship:post',
];
for (const point of EMPTY_POINTS) {
it(`[parametric] ${point} — E2E subprocess exits 0 with {point, activeHooks:[], rendered:placeholder}`, () => {
const tmpDir = makeTempProject();
try {
const result = spawnGsd(['loop', 'render-hooks', point, '--raw', '--cwd', tmpDir], tmpDir);
assert.strictEqual(result.status, 0, `${point}: expected exit 0. stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, point, `${point}: envelope.point mismatch`);
assert.ok(Array.isArray(envelope.activeHooks), `${point}: activeHooks must be an array`);
assert.strictEqual(envelope.activeHooks.length, 0, `${point}: activeHooks must be empty`);
assert.strictEqual(envelope.rendered, `_No active hooks at ${point}._`, `${point}: rendered placeholder mismatch`);
} finally {
cleanup(tmpDir);
}
});
it(`[parametric] ${point} — pure-function resolveLoopHooks with real registry returns 0 activeHooks`, () => {
const resolved = resolveLoopHooks({ point, registry: realRegistry, config: {} });
assert.strictEqual(resolved.point, point);
assert.strictEqual(resolved.activeHooks.length, 0, `${point}: real registry must have 0 hooks at this point`);
});
}
});

View File

@@ -0,0 +1,309 @@
'use strict';
/**
* loop-hooks-ship-pre-e2e.test.cjs — E2E content tests for the ship:pre hook point.
*
* ADR-857 phase 6 gap coverage. Tests cover:
* - loop render-hooks ship:pre CLI subprocess (envelope shape, predicate typing)
* - frontmatter get CLI subprocess (threats_open field contract)
* - resolveLoopHooks pure-function with realRegistry (predicate.equals integer contract)
*
* NOTE: ship:pre has NO runnable predicate evaluator — enforcement is ship.md prose only.
* The check.predicate shape is asserted here to pin the Hyrum's-law contract for downstream
* consumers (workflow prose, manual ship gate). This is a known robustness gap (kerckhoffs).
*
* Follows RULESET.TESTS (no source-grep, BVA at thresholds, genuine assertions).
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
const {
resolveLoopHooks,
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
// ─── Helpers ──────────────────────────────────────────────────────────────────
/**
* Run gsd-tools synchronously via spawnSync. Returns { status, stdout, stderr }.
* Does NOT throw on non-zero exit — callers must assert status themselves.
*/
function runTools(args, opts = {}) {
const result = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
encoding: 'utf8',
timeout: 60000,
cwd: opts.cwd || process.cwd(),
env: {
...process.env,
// Clear ambient session vars that can redirect config paths
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
...opts.env,
},
});
return result;
}
/**
* Create a minimal temp project directory with a .planning/ dir.
* Optionally write config.json if configObj is provided.
*/
function makeTmpProject(prefix, configObj) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
if (configObj !== undefined) {
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(configObj), 'utf8');
}
return tmpDir;
}
/**
* Write a SECURITY.md file with the given frontmatter content to the given dir.
*/
function writeSecurityMd(dir, frontmatter) {
const content = `---\n${frontmatter}\n---\n# Security Review\n`;
const filePath = path.join(dir, 'SECURITY.md');
fs.writeFileSync(filePath, content, 'utf8');
return filePath;
}
// ─── Fixture state ─────────────────────────────────────────────────────────────
let tmpEnforcementOn; // .planning/config.json with security_enforcement:true
let tmpEnforcementOff; // .planning/config.json with security_enforcement:false
let tmpNoConfig; // .planning/ dir with NO config.json (schema default applies)
let tmpWithSecurityMd; // project + SECURITY.md variants in sub-temp dir
before(() => {
tmpEnforcementOn = makeTmpProject('ship-pre-on-', { workflow: { security_enforcement: true } });
tmpEnforcementOff = makeTmpProject('ship-pre-off-', { workflow: { security_enforcement: false } });
tmpNoConfig = makeTmpProject('ship-pre-noconf-'); // no config.json
tmpWithSecurityMd = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-pre-secmd-'));
});
after(() => {
if (tmpEnforcementOn) cleanup(tmpEnforcementOn);
if (tmpEnforcementOff) cleanup(tmpEnforcementOff);
if (tmpNoConfig) cleanup(tmpNoConfig);
if (tmpWithSecurityMd) cleanup(tmpWithSecurityMd);
});
// ─── 1. render-hooks ship:pre envelope tests ──────────────────────────────────
describe('loop render-hooks ship:pre — envelope resolution', () => {
test('[happy] security_enforcement=true returns gate hook with correct predicate shape', () => {
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpEnforcementOn });
assert.strictEqual(result.status, 0, `expected exit 0, got ${result.status}; stderr: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'ship:pre');
assert.strictEqual(envelope.activeHooks.length, 1, 'expected exactly 1 active hook');
const gate = envelope.activeHooks[0];
assert.strictEqual(gate.capId, 'security');
assert.strictEqual(gate.kind, 'gate');
assert.strictEqual(gate.blocking, true);
assert.strictEqual(gate.onError, 'halt');
assert.strictEqual(gate.when, 'workflow.security_enforcement');
// Predicate shape — the critical contract for downstream workflow prose
const pred = gate.check.predicate;
assert.strictEqual(pred.kind, 'artifact-frontmatter-equals');
assert.strictEqual(pred.artifact, 'SECURITY.md');
assert.strictEqual(pred.field, 'threats_open');
assert.strictEqual(pred.equals, 0);
// TYPE contract: equals must be integer (not string '0')
assert.strictEqual(typeof pred.equals, 'number', 'predicate.equals must be a number, not a string');
});
test('[negative] security_enforcement=false returns empty activeHooks (gate suppressed)', () => {
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpEnforcementOff });
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'ship:pre');
assert.deepEqual(envelope.activeHooks, [], 'expected empty activeHooks when enforcement disabled');
assert.strictEqual(envelope.rendered, '_No active hooks at ship:pre._');
// Confirm no security hook leaked through
const secHook = envelope.activeHooks.find(h => h.capId === 'security');
assert.strictEqual(secHook, undefined, 'security gate must be absent when enforcement=false');
});
test('[happy] no config.json uses schema default (security_enforcement=true) and activates gate', () => {
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpNoConfig });
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// Schema default for security_enforcement is true — gate must fire
assert.strictEqual(envelope.activeHooks.length, 1, 'schema default must activate the security gate');
assert.strictEqual(envelope.activeHooks[0].capId, 'security');
assert.strictEqual(envelope.activeHooks[0].blocking, true);
});
test('[empty-resolution] gate active when no SECURITY.md exists: envelope confirms gate live (fail-closed)', () => {
// The phase dir has NO SECURITY.md — the gate is still ACTIVE in the envelope
// (activation is config-driven; file absence is a predicate evaluation concern
// handled by ship.md prose, not the CLI resolver).
const tmpPhaseNoSec = makeTmpProject('ship-pre-nosec-', { workflow: { security_enforcement: true } });
try {
const phaseDir = path.join(tmpPhaseNoSec, '.planning', 'phases', '01-feature');
fs.mkdirSync(phaseDir, { recursive: true });
// No SECURITY.md written anywhere
const result = runTools(['loop', 'render-hooks', 'ship:pre', '--raw'], { cwd: tmpPhaseNoSec });
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// Gate must still be active — no-file does not suppress the gate
assert.strictEqual(envelope.activeHooks.length, 1, 'gate must remain active even without SECURITY.md on disk');
assert.strictEqual(envelope.activeHooks[0].capId, 'security');
assert.strictEqual(envelope.activeHooks[0].blocking, true);
// Confirm no SECURITY.md in the phase dir (this is the "no-file" scenario)
const hasSec = fs.readdirSync(phaseDir).some(f => f.endsWith('-SECURITY.md') || f === 'SECURITY.md');
assert.strictEqual(hasSec, false, 'fixture must have no SECURITY.md for this test to be meaningful');
} finally {
cleanup(tmpPhaseNoSec);
}
});
});
// ─── 2. predicate.equals integer contract via resolveLoopHooks (pure function) ─
describe('resolveLoopHooks ship:pre — predicate.equals integer type contract', () => {
test('[bva] predicate.equals is integer 0 in resolved output (Hyrum\'s-law type pin)', () => {
const resolved = resolveLoopHooks({
point: 'ship:pre',
registry: realRegistry,
config: { workflow: { security_enforcement: true } },
});
assert.strictEqual(resolved.activeHooks.length, 1);
const gate = resolved.activeHooks[0];
assert.strictEqual(gate.capId, 'security');
const equals = gate.check.predicate.equals;
assert.strictEqual(equals, 0, 'predicate.equals must be integer 0');
assert.strictEqual(typeof equals, 'number', 'predicate.equals typeof must be number, not string');
});
test('[negative] security_enforcement=false via resolveLoopHooks returns 0 active hooks', () => {
const resolved = resolveLoopHooks({
point: 'ship:pre',
registry: realRegistry,
config: { workflow: { security_enforcement: false } },
});
assert.strictEqual(resolved.activeHooks.length, 0, 'enforcement=false must yield 0 hooks');
assert.strictEqual(resolved.point, 'ship:pre');
});
});
// ─── 3. frontmatter get contract for threats_open field ───────────────────────
describe('frontmatter get SECURITY.md threats_open — type contract', () => {
test('[happy] threats_open:0 returns string "0" (type contract: YAML→string via frontmatter CLI)', () => {
const secFile = writeSecurityMd(tmpWithSecurityMd, 'threats_open: 0\nasvs_level: 1');
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
assert.strictEqual(result.status, 0);
// Raw output is JSON-encoded string "0", not integer 0
const parsed = JSON.parse(result.stdout.trim());
assert.strictEqual(parsed, '0', 'frontmatter returns string "0", not integer 0');
assert.strictEqual(typeof parsed, 'string', 'frontmatter CLI must return a string for YAML integer fields');
});
test('[bva] threats_open:1 returns string "1" — above threshold, predicate(equals:0) fails', () => {
// BVA: equals:0 passes, equals:1 blocks — this is the just-above threshold value
const secFile = path.join(tmpWithSecurityMd, 'SECURITY-1.md');
fs.writeFileSync(secFile, '---\nthreats_open: 1\nasvs_level: 1\n---\n# Security\n', 'utf8');
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
assert.strictEqual(result.status, 0);
const parsed = JSON.parse(result.stdout.trim());
assert.strictEqual(parsed, '1', 'threats_open:1 must return string "1"');
// Verify this differs from the passing case (string "0" !== string "1")
assert.notStrictEqual(parsed, '0', 'string "1" must not equal passing value "0"');
});
test('[negative] missing threats_open field returns Field-not-found error (fail-closed path)', () => {
const secFile = path.join(tmpWithSecurityMd, 'SECURITY-missing-field.md');
// No threats_open key in frontmatter — only unrelated fields
fs.writeFileSync(secFile, '---\nphase: 01\nstatus: active\n---\n# Security\n', 'utf8');
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
// Exit 0 — the CLI returns a JSON error object, not a crash
assert.strictEqual(result.status, 0);
const parsed = JSON.parse(result.stdout.trim());
// Must return an error object, not a string value
assert.strictEqual(typeof parsed, 'object', 'missing field must return an object, not a string');
assert.strictEqual(parsed.error, 'Field not found');
assert.strictEqual(parsed.field, 'threats_open');
});
test('[negative] threats_open: unknown returns string "unknown" (ambiguous value must fail closed)', () => {
// Non-numeric string value — predicate (equals:0 integer) cannot match
const secFile = path.join(tmpWithSecurityMd, 'SECURITY-unknown.md');
fs.writeFileSync(secFile, '---\nthreats_open: unknown\nasvs_level: 1\n---\n# Security\n', 'utf8');
const result = runTools(['frontmatter', 'get', secFile, '--field', 'threats_open', '--raw']);
assert.strictEqual(result.status, 0);
const parsed = JSON.parse(result.stdout.trim());
assert.strictEqual(parsed, 'unknown', 'non-numeric value must be returned as-is');
// Confirm this is NOT a match for predicate.equals===0 (integer)
assert.notStrictEqual(parsed, 0, 'string "unknown" must not match integer 0');
assert.strictEqual(typeof parsed, 'string');
});
});
// ─── 4. Real registry structure sanity ────────────────────────────────────────
describe('real registry ship:pre — structural guards', () => {
test('ship:pre byLoopPoint entry has exactly 1 gate and 0 steps/contributions', () => {
const entry = realRegistry.byLoopPoint['ship:pre'];
assert.ok(entry, 'ship:pre must exist in byLoopPoint');
assert.strictEqual(entry.steps.length, 0, 'ship:pre must have 0 steps');
assert.strictEqual(entry.contributions.length, 0, 'ship:pre must have 0 contributions');
assert.strictEqual(entry.gates.length, 1, 'ship:pre must have exactly 1 gate (security)');
});
test('ship:pre gate capId is "security" and check has predicate not query', () => {
const gate = realRegistry.byLoopPoint['ship:pre'].gates[0];
assert.strictEqual(gate.capId, 'security');
assert.ok(gate.check.predicate, 'ship:pre gate must use predicate, not query');
assert.strictEqual(gate.check.query, undefined, 'ship:pre must NOT have a check.query (predicate-only gate)');
});
});

View File

@@ -0,0 +1,543 @@
'use strict';
/**
* loop-hooks-verify-post-e2e.test.cjs
*
* E2E content tests for the verify:post hook point — ADR-857 phase 6.
*
* Coverage focus (backlog: hook-e2e-gaps.md § verify:post):
* - All-on: 3 hooks in registry order (nyquist → security → ui) with
* correct kind/ref.skill/onError (halt for nyquist+security, skip for ui)
* - No-config: schema defaults activate all 3
* - Per-key false: each of the 3 BVA cases excludes only that one step
* - All-false: empty activeHooks + valid envelope shape
* - Surface-disable (via capabilityStatesById on pure resolver): ui/security
* cluster excluded; remaining steps correct
* - Malformed config.json: falls back to schema defaults (3 active)
* - Deterministic ordering: two calls produce identical activeHooks arrays
*
* Hard rules enforced here:
* - Every test drives real resolver or CLI subprocess — no readFileSync source-grep
* - Genuine assertions: negative/BVA cases assert the SPECIFIC differing value
* - Each test owns its own fixture (isolated tmpDir); cleanup in afterEach
*/
const { describe, test, before, after, afterEach } = require('node:test');
const { cleanup } = require('./helpers.cjs');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
// ── Real modules under test ────────────────────────────────────────────────────
const {
resolveLoopHooks,
renderLoopHooks,
} = require('../gsd-core/bin/lib/loop-resolver.cjs');
const realRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
// ── CLI path ───────────────────────────────────────────────────────────────────
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ── Env hermeticity (strip ambient GSD_ vars that skew planning dir lookups) ──
const CLEAN_ENV = Object.fromEntries(
Object.entries(process.env).filter(([k]) => !k.startsWith('GSD_')),
);
/**
* Invoke gsd-tools CLI with spawnSync and return the parsed result.
* Always use CLEAN_ENV to avoid ambient GSD_ env vars redirecting planning paths.
*/
function runCli(args, cwd) {
const result = spawnSync(process.execPath, [GSD_TOOLS, ...args], {
cwd,
encoding: 'utf8',
env: CLEAN_ENV,
timeout: 60000,
});
return result;
}
/** Create a temp dir with a .planning/ subdirectory (no config.json). */
function makeTmpProject() {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'vpost-e2e-'));
fs.mkdirSync(path.join(d, '.planning'), { recursive: true });
return d;
}
/** Write .planning/config.json with the given object. */
function writeConfig(tmpDir, cfg) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(cfg),
'utf8',
);
}
// ── Fixtures shared across all-on and ordering tests ─────────────────────────
let allOnDir; // .planning/config.json with all three verify:post flags = true
let noConfigDir; // .planning/ but NO config.json
let allOffDir; // all three flags explicitly false
before(() => {
allOnDir = makeTmpProject();
writeConfig(allOnDir, {
workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true },
});
noConfigDir = makeTmpProject();
// No config.json — schema defaults (all true) should activate all three
allOffDir = makeTmpProject();
writeConfig(allOffDir, {
workflow: { nyquist_validation: false, security_enforcement: false, ui_review: false },
});
});
after(() => {
for (const d of [allOnDir, noConfigDir, allOffDir]) {
if (d) cleanup(d);
}
});
// Per-test isolation: each test creates its own dir; afterEach cleans it up.
let perTestDir = null;
afterEach(() => {
if (perTestDir) {
cleanup(perTestDir);
perTestDir = null;
}
});
// ─── 1. All-on: three hooks in correct order with full typed shape ─────────────
describe('verify:post — all-on config activates all three steps in registry order', () => {
test('[happy] CLI returns 3 active hooks: nyquist→security→ui with correct capId, kind, ref.skill', () => {
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
allOnDir,
);
assert.strictEqual(result.status, 0, `CLI exited non-zero: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'verify:post');
assert.strictEqual(envelope.activeHooks.length, 3,
`Expected 3 active hooks, got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
// Step 1: nyquist
const [nyquist, security, ui] = envelope.activeHooks;
assert.strictEqual(nyquist.capId, 'nyquist');
assert.strictEqual(nyquist.kind, 'step');
assert.strictEqual(nyquist.ref.skill, 'validate-phase');
assert.strictEqual(nyquist.onError, 'halt');
// Step 2: security
assert.strictEqual(security.capId, 'security');
assert.strictEqual(security.kind, 'step');
assert.strictEqual(security.ref.skill, 'secure-phase');
assert.strictEqual(security.onError, 'halt');
// Step 3: ui
assert.strictEqual(ui.capId, 'ui');
assert.strictEqual(ui.kind, 'step');
assert.strictEqual(ui.ref.skill, 'ui-review');
assert.strictEqual(ui.onError, 'skip');
});
test('[happy] CLI returns rendered markdown with Step 1/2/3 in correct order', () => {
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
allOnDir,
);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// Rendered text must contain all three steps in correct order
const { rendered } = envelope;
assert.ok(typeof rendered === 'string' && rendered.length > 0, 'rendered must be non-empty string');
const step1Pos = rendered.indexOf('validate-phase');
const step2Pos = rendered.indexOf('secure-phase');
const step3Pos = rendered.indexOf('ui-review');
assert.ok(step1Pos < step2Pos, `nyquist (pos ${step1Pos}) must come before security (pos ${step2Pos}) in rendered`);
assert.ok(step2Pos < step3Pos, `security (pos ${step2Pos}) must come before ui (pos ${step3Pos}) in rendered`);
// Rendered must NOT be the placeholder (all hooks active)
assert.ok(
!rendered.includes('_No active hooks at verify:post._'),
'rendered must not be the empty-hooks placeholder when all are active',
);
});
});
// ─── 2. No-config: schema defaults activate all 3 ─────────────────────────────
describe('verify:post — no config.json falls back to schema defaults (all three active)', () => {
test('[happy] CLI with no config.json returns 3 active hooks via schema default=true', () => {
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', noConfigDir],
noConfigDir,
);
assert.strictEqual(result.status, 0, `CLI exited non-zero: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'verify:post');
assert.strictEqual(envelope.activeHooks.length, 3,
`Schema defaults should activate 3 hooks, got ${envelope.activeHooks.length}`);
// Verify capIds — schema default=true for all three
const capIds = envelope.activeHooks.map(h => h.capId);
assert.deepEqual(capIds, ['nyquist', 'security', 'ui'],
`Expected ['nyquist','security','ui'], got ${JSON.stringify(capIds)}`);
});
test('[happy] pure resolveLoopHooks with realRegistry and empty config activates all 3 (schema default path)', () => {
const resolved = resolveLoopHooks({
point: 'verify:post',
registry: realRegistry,
config: {},
});
assert.strictEqual(resolved.point, 'verify:post');
assert.strictEqual(resolved.activeHooks.length, 3,
`Expected 3 active hooks via schema default, got ${resolved.activeHooks.length}`);
assert.deepEqual(
resolved.activeHooks.map(h => h.capId),
['nyquist', 'security', 'ui'],
);
});
});
// ─── 3. All-false: empty hooks + valid 3-key envelope ─────────────────────────
describe('verify:post — all three flags explicitly false returns empty activeHooks', () => {
test('[negative] CLI with all-false config returns activeHooks:[] and placeholder rendered', () => {
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOffDir],
allOffDir,
);
assert.strictEqual(result.status, 0, `CLI exited non-zero: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
// Genuine assertion: MUST be 0 (not 1 or 3) — verifies filtering actually works
assert.strictEqual(envelope.activeHooks.length, 0,
`Expected 0 hooks when all flags=false, got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
assert.deepEqual(envelope.activeHooks, []);
assert.strictEqual(envelope.rendered, '_No active hooks at verify:post._');
assert.strictEqual(envelope.point, 'verify:post');
});
test('[negative] pure resolveLoopHooks with all-false config returns empty activeHooks', () => {
const resolved = resolveLoopHooks({
point: 'verify:post',
registry: realRegistry,
config: { workflow: { nyquist_validation: false, security_enforcement: false, ui_review: false } },
});
// Must be exactly 0, not 1 or 3
assert.strictEqual(resolved.activeHooks.length, 0);
assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:post._');
});
});
// ─── 4. BVA: per-key false excludes only that one step ────────────────────────
describe('verify:post — per-key BVA: each false excludes only that single step', () => {
test('[bva] nyquist_validation=false excludes ONLY nyquist; security+ui remain (length=2)', () => {
perTestDir = makeTmpProject();
writeConfig(perTestDir, {
workflow: { nyquist_validation: false, security_enforcement: true, ui_review: true },
});
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
perTestDir,
);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// Genuine BVA: must be exactly 2, not 3 or 0
assert.strictEqual(envelope.activeHooks.length, 2,
`Expected 2 hooks (security+ui), got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
const capIds = envelope.activeHooks.map(h => h.capId);
assert.ok(!capIds.includes('nyquist'), `nyquist must be absent when nyquist_validation=false, got ${JSON.stringify(capIds)}`);
assert.strictEqual(capIds[0], 'security', `First remaining hook must be security`);
assert.strictEqual(capIds[1], 'ui', `Second remaining hook must be ui`);
});
test('[bva] security_enforcement=false excludes ONLY security; nyquist+ui remain (length=2)', () => {
perTestDir = makeTmpProject();
writeConfig(perTestDir, {
workflow: { nyquist_validation: true, security_enforcement: false, ui_review: true },
});
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
perTestDir,
);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// Genuine BVA: must be exactly 2, not 3 or 0
assert.strictEqual(envelope.activeHooks.length, 2,
`Expected 2 hooks (nyquist+ui), got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
const capIds = envelope.activeHooks.map(h => h.capId);
assert.ok(!capIds.includes('security'), `security must be absent when security_enforcement=false, got ${JSON.stringify(capIds)}`);
assert.strictEqual(capIds[0], 'nyquist', `First remaining hook must be nyquist`);
assert.strictEqual(capIds[1], 'ui', `Second remaining hook must be ui`);
});
test('[bva] ui_review=false excludes ONLY ui; nyquist+security remain (length=2)', () => {
perTestDir = makeTmpProject();
writeConfig(perTestDir, {
workflow: { nyquist_validation: true, security_enforcement: true, ui_review: false },
});
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
perTestDir,
);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// Genuine BVA: must be exactly 2, not 3 or 0
assert.strictEqual(envelope.activeHooks.length, 2,
`Expected 2 hooks (nyquist+security), got ${envelope.activeHooks.length}: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
const capIds = envelope.activeHooks.map(h => h.capId);
assert.ok(!capIds.includes('ui'), `ui must be absent when ui_review=false, got ${JSON.stringify(capIds)}`);
assert.strictEqual(capIds[0], 'nyquist', `First remaining hook must be nyquist`);
assert.strictEqual(capIds[1], 'security', `Second remaining hook must be security`);
});
});
// ─── 5. Surface-disable via capabilityStatesById (pure resolver) ──────────────
describe('verify:post — surface-disable: capabilityStatesById filters hooks', () => {
test('[negative] ui disabled via capabilityStatesById→enabled:false excludes ui step; nyquist+security remain', () => {
const capabilityStatesById = new Map([
['nyquist', { enabled: true }],
['security', { enabled: true }],
['ui', { enabled: false }],
]);
const resolved = resolveLoopHooks({
point: 'verify:post',
registry: realRegistry,
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
capabilityStatesById,
});
// Genuine assertion: must be 2 (not 3) — proves surface filter excludes ui
assert.strictEqual(resolved.activeHooks.length, 2,
`Expected 2 hooks with ui disabled, got ${resolved.activeHooks.length}: ${JSON.stringify(resolved.activeHooks.map(h => h.capId))}`);
const capIds = resolved.activeHooks.map(h => h.capId);
assert.ok(!capIds.includes('ui'), `ui must be filtered out when capability disabled`);
assert.strictEqual(capIds[0], 'nyquist');
assert.strictEqual(capIds[1], 'security');
});
test('[negative] security disabled via capabilityStatesById excludes security step; nyquist+ui remain', () => {
const capabilityStatesById = new Map([
['nyquist', { enabled: true }],
['security', { enabled: false }],
['ui', { enabled: true }],
]);
const resolved = resolveLoopHooks({
point: 'verify:post',
registry: realRegistry,
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
capabilityStatesById,
});
// Genuine: must be 2 (not 3) — proves security cluster exclusion
assert.strictEqual(resolved.activeHooks.length, 2,
`Expected 2 hooks with security disabled, got ${resolved.activeHooks.length}`);
const capIds = resolved.activeHooks.map(h => h.capId);
assert.ok(!capIds.includes('security'), `security must be filtered out when capability disabled`);
assert.strictEqual(capIds[0], 'nyquist');
assert.strictEqual(capIds[1], 'ui');
});
test('[empty-resolution] all three disabled via capabilityStatesById returns empty activeHooks with valid envelope', () => {
const capabilityStatesById = new Map([
['nyquist', { enabled: false }],
['security', { enabled: false }],
['ui', { enabled: false }],
]);
const resolved = resolveLoopHooks({
point: 'verify:post',
registry: realRegistry,
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
capabilityStatesById,
});
assert.strictEqual(resolved.point, 'verify:post');
assert.deepEqual(resolved.activeHooks, []);
assert.strictEqual(renderLoopHooks(resolved), '_No active hooks at verify:post._');
});
});
// ─── 6. Malformed config.json: falls back to schema defaults ──────────────────
describe('verify:post — malformed config.json: schema defaults fire (3 active, no crash)', () => {
test('[negative] CLI with malformed config.json exits 0 and returns all 3 hooks via schema defaults', () => {
perTestDir = makeTmpProject();
fs.writeFileSync(
path.join(perTestDir, '.planning', 'config.json'),
'{ broken json',
'utf8',
);
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', perTestDir],
perTestDir,
);
assert.strictEqual(result.status, 0, `CLI must not crash on malformed config: ${result.stderr}`);
const envelope = JSON.parse(result.stdout.trim());
assert.strictEqual(envelope.point, 'verify:post');
// Schema defaults (all true) must activate all 3 when config.json parse fails
assert.strictEqual(envelope.activeHooks.length, 3,
`Expected 3 hooks via schema defaults on malformed config, got ${envelope.activeHooks.length}`);
const capIds = envelope.activeHooks.map(h => h.capId);
assert.deepEqual(capIds, ['nyquist', 'security', 'ui']);
});
});
// ─── 7. Deterministic ordering: two calls produce identical results ────────────
describe('verify:post — deterministic ordering: repeated calls produce identical activeHooks', () => {
test('[happy] two resolveLoopHooks calls return identical activeHooks arrays (order stability)', () => {
const config = {
workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true },
};
const first = resolveLoopHooks({ point: 'verify:post', registry: realRegistry, config });
const second = resolveLoopHooks({ point: 'verify:post', registry: realRegistry, config });
// Genuine: both must have exactly the same structure
assert.deepEqual(first.activeHooks, second.activeHooks,
'Two resolver calls must produce identical activeHooks (determinism)');
assert.deepEqual(
first.activeHooks.map(h => h.capId),
['nyquist', 'security', 'ui'],
'Order must be nyquist→security→ui',
);
});
test('[happy] two CLI invocations return identical stdout (CLI-level determinism)', () => {
const call1 = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
allOnDir,
);
const call2 = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
allOnDir,
);
assert.strictEqual(call1.status, 0);
assert.strictEqual(call2.status, 0);
const env1 = JSON.parse(call1.stdout.trim());
const env2 = JSON.parse(call2.stdout.trim());
assert.deepEqual(env1.activeHooks, env2.activeHooks,
'Two CLI calls must produce identical activeHooks');
assert.strictEqual(env1.rendered, env2.rendered,
'Two CLI calls must produce identical rendered output');
});
});
// ─── 8. onError fields per-hook (halt for nyquist+security, skip for ui) ──────
describe('verify:post — onError semantics: halt for nyquist+security, skip for ui', () => {
test('[bva] onError is exactly "halt" for nyquist, "halt" for security, "skip" for ui — pure resolver', () => {
const resolved = resolveLoopHooks({
point: 'verify:post',
registry: realRegistry,
config: { workflow: { nyquist_validation: true, security_enforcement: true, ui_review: true } },
});
assert.strictEqual(resolved.activeHooks.length, 3);
// Genuine BVA: each onError must match the exact canonical value
assert.strictEqual(resolved.activeHooks[0].onError, 'halt',
`nyquist onError must be 'halt', got '${resolved.activeHooks[0].onError}'`);
assert.strictEqual(resolved.activeHooks[1].onError, 'halt',
`security onError must be 'halt', got '${resolved.activeHooks[1].onError}'`);
assert.strictEqual(resolved.activeHooks[2].onError, 'skip',
`ui onError must be 'skip', got '${resolved.activeHooks[2].onError}'`);
});
test('[bva] CLI envelope preserves onError values in the correct field position', () => {
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
allOnDir,
);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// Genuine BVA: assert specific onError value at each position, not just presence
assert.strictEqual(envelope.activeHooks[0].onError, 'halt');
assert.strictEqual(envelope.activeHooks[1].onError, 'halt');
assert.strictEqual(envelope.activeHooks[2].onError, 'skip');
});
});
// ─── 9. Envelope shape: exactly 3 keys, no spurious 'warnings' ────────────────
describe('verify:post — envelope shape pins Hyrum\'s Law contract', () => {
test('[happy] all-on CLI response has exactly 3 envelope keys: point, activeHooks, rendered', () => {
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOnDir],
allOnDir,
);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// When state.warnings is empty, the envelope must have exactly 3 keys
const keys = Object.keys(envelope).sort();
assert.deepEqual(keys, ['activeHooks', 'point', 'rendered'],
`Envelope must have exactly 3 keys, got: ${JSON.stringify(keys)}`);
});
test('[negative] all-off CLI response envelope still has exactly 3 keys (no extra warnings key)', () => {
const result = runCli(
['loop', 'render-hooks', 'verify:post', '--raw', '--cwd', allOffDir],
allOffDir,
);
assert.strictEqual(result.status, 0);
const envelope = JSON.parse(result.stdout.trim());
// All-false path: 3 keys, not more
const keys = Object.keys(envelope).sort();
assert.deepEqual(keys, ['activeHooks', 'point', 'rendered'],
`Empty-hooks envelope must have exactly 3 keys, got: ${JSON.stringify(keys)}`);
assert.strictEqual(envelope.point, 'verify:post');
assert.deepEqual(envelope.activeHooks, []);
});
});
// ─── 10. Real registry byLoopPoint shape check (no drift guard) ───────────────
describe('verify:post — real registry has exactly 3 steps and 0 contributions+gates', () => {
test('[happy] realRegistry.byLoopPoint[verify:post] has 3 steps, 0 contributions, 0 gates', () => {
const entry = realRegistry.byLoopPoint['verify:post'];
assert.ok(entry, 'verify:post must exist in registry');
assert.strictEqual(entry.steps.length, 3,
`Expected 3 steps at verify:post, got ${entry.steps.length}`);
assert.strictEqual(entry.contributions.length, 0,
`Expected 0 contributions at verify:post, got ${entry.contributions.length}`);
assert.strictEqual(entry.gates.length, 0,
`Expected 0 gates at verify:post, got ${entry.gates.length}`);
});
test('[happy] registry steps at verify:post have correct capIds in order', () => {
const entry = realRegistry.byLoopPoint['verify:post'];
const capIds = entry.steps.map(s => s.capId);
assert.deepEqual(capIds, ['nyquist', 'security', 'ui'],
`Registry must have steps in nyquist→security→ui order, got ${JSON.stringify(capIds)}`);
});
});

View File

@@ -866,3 +866,100 @@ describe('cmdLoopRenderHooks end-to-end (via gsd-tools)', () => {
assert.match(result.stderr, /plan:mid|Invalid loop point/); assert.match(result.stderr, /plan:mid|Invalid loop point/);
}); });
}); });
// ─── 10. --active-cap flag (scanner-safe boolean derivation) ──────────────────
describe('--active-cap flag (loop render-hooks)', () => {
// Temp project with tdd_mode=true in config
let tddOnDir;
// Temp project with tdd_mode=false in config
let tddOffDir;
before(() => {
tddOnDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-active-cap-tdd-on-'));
const planOn = path.join(tddOnDir, '.planning');
fs.mkdirSync(planOn, { recursive: true });
fs.writeFileSync(
path.join(planOn, 'config.json'),
JSON.stringify({ workflow: { tdd_mode: true } }),
'utf8',
);
tddOffDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loop-active-cap-tdd-off-'));
const planOff = path.join(tddOffDir, '.planning');
fs.mkdirSync(planOff, { recursive: true });
fs.writeFileSync(
path.join(planOff, 'config.json'),
JSON.stringify({ workflow: { tdd_mode: false } }),
'utf8',
);
});
after(() => {
if (tddOnDir) cleanup(tddOnDir);
if (tddOffDir) cleanup(tddOffDir);
});
test('--active-cap tdd with tdd_mode=true → stdout trimmed === "true", exit 0', () => {
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOnDir],
{ cwd: ROOT, encoding: 'utf8' },
);
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
assert.strictEqual(result.stdout.trim(), 'true', 'Expected stdout "true" when tdd_mode=true');
});
test('--active-cap tdd with tdd_mode=false → stdout trimmed === "false", exit 0', () => {
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOffDir],
{ cwd: ROOT, encoding: 'utf8' },
);
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
assert.strictEqual(result.stdout.trim(), 'false', 'Expected stdout "false" when tdd_mode=false');
});
test('--active-cap <nonexistent-cap> → stdout trimmed === "false", exit 0', () => {
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'no-such-capability-xyz', '--cwd', tddOffDir],
{ cwd: ROOT, encoding: 'utf8' },
);
assert.strictEqual(result.status, 0, 'Expected exit 0 for unknown capId. stderr: ' + (result.stderr || ''));
assert.strictEqual(result.stdout.trim(), 'false', 'Expected stdout "false" for unknown capId');
});
test('--active-cap with no value → non-zero exit and error message', () => {
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', '--cwd', tddOffDir],
{ cwd: ROOT, encoding: 'utf8' },
);
assert.notStrictEqual(result.status, 0, 'Expected non-zero exit when --active-cap has no value');
assert.match(result.stderr, /active-cap/i, 'Expected error message referencing --active-cap');
});
test('--active-cap output is exactly "true" or "false" (no JSON envelope, clean for shell capture)', () => {
// The entire stdout must be just "true" or "false" + newline — no envelope object
const result = spawnSync(
process.execPath,
[GSD_TOOLS, 'loop', 'render-hooks', 'execute:post', '--active-cap', 'tdd', '--cwd', tddOnDir],
{ cwd: ROOT, encoding: 'utf8' },
);
assert.strictEqual(result.status, 0, 'Expected exit 0. stderr: ' + (result.stderr || ''));
// Must be exactly "true" or "false" — not a JSON object/envelope
const trimmed = result.stdout.trim();
assert.ok(
trimmed === 'true' || trimmed === 'false',
`stdout must be "true" or "false", got: ${JSON.stringify(result.stdout)}`,
);
// Must not be a JSON object (no envelope with point/activeHooks/rendered keys)
let parsed;
try { parsed = JSON.parse(trimmed); } catch { parsed = null; }
assert.ok(
typeof parsed !== 'object' || parsed === null,
'stdout must not be a JSON object/envelope when --active-cap is used',
);
});
});

View File

@@ -5,6 +5,8 @@ const { describe, test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const ROOT = path.join(__dirname, '..'); const ROOT = path.join(__dirname, '..');
const HOST_LOOP_FILES = [ const HOST_LOOP_FILES = [
@@ -145,17 +147,29 @@ describe('ADR-857 Phase 6 capstone conformance (#1139)', () => {
); );
}); });
test('all ADR-857-named optional features are migrated to Capabilities (#1169)', () => { test('all ADR-857-named optional features are real Capabilities, not empty stubs (#1169)', () => {
// ADR-857 §53 + Decision 7 enumerate these optional, non-loop modules as // ADR-857 §53 + Decision 7 enumerate these optional, non-loop modules as
// Capabilities. Until each is a registered feature capability (or documented // Capabilities. "Migrated" means the feature OWNS its behavior: hook-based
// as core substrate), phase 6 is incomplete and the capstone is a false green. // features (tdd/schema-gate/drift/gap-analysis) must declare >=1 hook;
// command-family features (profile-pipeline) must declare a command family.
// A registration-only stub (role:feature but no hooks/commands) games this
// gate while the logic stays welded into the loop — rejected here.
const REQUIRED = ['tdd', 'schema-gate', 'drift', 'gap-analysis', 'profile-pipeline']; const REQUIRED = ['tdd', 'schema-gate', 'drift', 'gap-analysis', 'profile-pipeline'];
const unmigrated = REQUIRED.filter((id) => registry.capabilities[id]?.role !== 'feature'); const problems = [];
for (const id of REQUIRED) {
const cap = registry.capabilities[id];
if (!cap) { problems.push(`${id}: not registered`); continue; }
if (cap.role !== 'feature') { problems.push(`${id}: role="${cap.role}", must be "feature"`); continue; }
const hookCount = (cap.steps?.length || 0) + (cap.contributions?.length || 0) + (cap.gates?.length || 0);
const isCommandFamily = (cap.commands?.length || 0) > 0;
if (hookCount === 0 && !isCommandFamily) {
problems.push(`${id}: EMPTY STUB (no hooks, no command family) — inline logic was not migrated; declare the real hooks/commands and remove the inline branch`);
}
}
assert.deepEqual( assert.deepEqual(
unmigrated, [], problems, [],
`ADR-857 phase 6 is NOT complete: these ADR-named optional features are not yet ` + `ADR-857 phase 6 is NOT complete:\n ${problems.join('\n ')}\n` +
`feature Capabilities (still inline in plan-phase.md / execute-phase.md): ` + `Each feature must OWN its behavior via hooks or a command family — not exist as a registration-only stub (#1169).`,
`${unmigrated.join(', ')}. Migrate each, or document it as core substrate (#1169).`,
); );
}); });
@@ -180,4 +194,179 @@ describe('ADR-857 Phase 6 capstone conformance (#1139)', () => {
`inline:\n ${leaks.join('\n ')}\nThe owning capability must render/consume these (#1169).`, `inline:\n ${leaks.join('\n ')}\nThe owning capability must render/consume these (#1169).`,
); );
}); });
test('host loop bodies are materially smaller than the pre-phase-6 baseline (#1168)', () => {
// #1139 AC: plan-phase.md / execute-phase.md must shrink as optional features
// extract to capabilities. Frozen pre-phase-6 sizes (LF bytes); the files must
// drop strictly below these. This also defeats double-run gaming — declaring a
// hook while leaving the inline block keeps the file from shrinking -> red.
const { lfByteCount } = require('../scripts/workflow-size.cjs');
const PRE_PHASE6 = { 'plan-phase.md': 94519, 'execute-phase.md': 93166 };
const notShrunk = [];
for (const [file, frozen] of Object.entries(PRE_PHASE6)) {
const now = lfByteCount(path.join(ROOT, 'gsd-core', 'workflows', file));
if (now >= frozen) notShrunk.push(`${file}: ${now} bytes (must be < pre-phase-6 ${frozen})`);
}
assert.deepEqual(
notShrunk, [],
`ADR-857 phase 6 is NOT complete: host loop bodies have not shrunk — the optional ` +
`feature logic has not actually been extracted:\n ${notShrunk.join('\n ')}`,
);
});
describe('ADR-857 phase 6 — capabilities must not bake install paths into the registry', () => {
// Matches GSD install paths that LEAK when copied verbatim to non-Claude runtimes.
// (~/.claude/projects is a legit runtime feature and is intentionally NOT matched.)
const LEAK = /\.claude[/\\](?:gsd-core|commands|agents|hooks)\b/;
test('no capability source (capability.json or fragment) embeds a ~/.claude install path', () => {
const capsDir = path.join(__dirname, '..', 'capabilities');
const offenders = [];
for (const id of fs.readdirSync(capsDir)) {
const dir = path.join(capsDir, id);
if (!fs.statSync(dir).isDirectory()) continue;
const cj = path.join(dir, 'capability.json');
if (fs.existsSync(cj) && LEAK.test(fs.readFileSync(cj, 'utf8'))) {
offenders.push(`capabilities/${id}/capability.json`);
}
const fragDir = path.join(dir, 'fragments');
if (fs.existsSync(fragDir)) {
for (const f of fs.readdirSync(fragDir)) {
if (LEAK.test(fs.readFileSync(path.join(fragDir, f), 'utf8'))) {
offenders.push(`capabilities/${id}/fragments/${f}`);
}
}
}
}
assert.deepEqual(offenders, [],
`capability sources embed ~/.claude install paths — these leak into the verbatim-copied capability-registry.cjs on non-Claude runtimes. Make the fragment path-free. Offenders: ${offenders.join(', ')}`);
});
test('generated capability-registry.cjs contains no ~/.claude install path', () => {
const reg = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), 'utf8');
const leakLines = reg.split('\n').map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n);
assert.deepEqual(leakLines, [],
`capability-registry.cjs leaks ~/.claude install paths at line(s) ${leakLines.join(', ')} — the registry is copied verbatim to non-Claude runtimes (only workflow .md files are path-converted at install). Make the source capability fragment path-free.`);
});
});
test('every plan:pre planner contribution is injected generically (not per-capId hardcode)', () => {
// FIX C regression guard: plan-phase.md must inject planner contributions
// generically (by into == "planner") rather than only injecting a single
// hardcoded capId (e.g. "tdd"). A generic injection ensures any active
// plan:pre contribution with into=="planner" reaches the planner — including
// tdd, schema-gate, and security contributions.
//
// Heuristic: the planner prompt section must reference injecting where
// into == "planner" (or iterate contributions), AND must NOT rely solely
// on a single capId == "tdd" injection as the only planner contribution
// delivery mechanism.
const planPhase = readRepoFile('gsd-core/workflows/plan-phase.md');
// The file must contain a generic reference to into == "planner" contribution injection.
assert.match(
planPhase,
/into\s*==\s*["']planner["']/,
'plan-phase.md must inject planner contributions generically via into == "planner" ' +
'(not just a single hardcoded capId). Fix C regression: all active planner contributions must reach the planner.',
);
// Verify the file does NOT rely SOLELY on a hardcoded capId == "tdd" injection
// for the planner contribution. If only a tdd-specific injection exists (old form),
// the schema-gate and security contributions are silently dropped.
// We check: every occurrence of 'capId == "tdd"' contribution injection must be
// accompanied somewhere by a generic into=="planner" dispatch (already verified above).
// Additionally, the old exact tdd-only injection prose must not be the only delivery.
const onlyTddInjection = /\bRead from `PLAN_PRE_HOOKS_JSON` where `kind == "contribution"` and `capId == "tdd"`\b/;
// If the old tdd-only prose still exists WITHOUT the generic into=="planner" prose,
// that's a regression. Since we already asserted into=="planner" exists, we just
// confirm the tdd-only prose is no longer the sole injection mechanism.
if (onlyTddInjection.test(planPhase)) {
// Old prose still present: acceptable only if generic prose is ALSO present (already asserted).
// Verify the into=="planner" injection appears NEAR the planner prompt (within 5000 chars of it).
const plannerPromptIdx = planPhase.indexOf('into == "planner"');
assert.ok(
plannerPromptIdx >= 0,
'plan-phase.md has tdd-only injection prose but no generic into=="planner" injection. ' +
'Remove the tdd-only injection and replace with generic contribution dispatch.',
);
}
});
test('every declared gate check.query returns a uniform boolean `block` field', () => {
// FIX A regression guard: every gate check command must return a top-level
// boolean `block` field so the host-loop dispatch can read a single consistent
// field regardless of which capability owns the gate.
//
// For each unique check.query declared in the registry's gate hooks, invoke
// the check command against a temp directory and assert the JSON output
// contains `block` as a boolean. Uses a minimal temp dir so the command
// returns quickly without real project state.
const os = require('node:os');
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gate-block-contract-'));
// Collect unique gate check.queries from the registry
const queries = new Set();
for (const cap of Object.values(registry.capabilities)) {
for (const gate of cap.gates || []) {
if (gate.check && gate.check.query) queries.add(gate.check.query);
}
}
assert.ok(queries.size > 0, 'Registry must declare at least one gate check.query');
const gsdTools = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
const failures = [];
for (const query of [...queries].sort()) {
let rawOut = '';
try {
// Invoke with --raw (the real dispatch form used by the host loop).
// Most commands accept a phase number and return valid JSON even when
// no real project state exists.
rawOut = execFileSync(
process.execPath,
[gsdTools, 'check', query, '1', '--raw'],
{ cwd: tmpDir, encoding: 'utf-8', timeout: 10000 },
);
const parsed = JSON.parse(rawOut.trim());
if (typeof parsed.block !== 'boolean') {
failures.push(
`check ${query}: returned JSON without a boolean \`block\` field ` +
`(got: ${JSON.stringify(parsed.block)}, type: ${typeof parsed.block}). ` +
`Add \`block\` to the command's output per the uniform gate contract.`,
);
}
} catch (err) {
// If it threw because the command required a different arg shape, try with a path
try {
rawOut = execFileSync(
process.execPath,
[gsdTools, 'check', query, tmpDir, '--raw'],
{ cwd: tmpDir, encoding: 'utf-8', timeout: 10000 },
);
const parsed = JSON.parse(rawOut.trim());
if (typeof parsed.block !== 'boolean') {
failures.push(
`check ${query}: returned JSON without a boolean \`block\` field ` +
`(got: ${JSON.stringify(parsed.block)}, type: ${typeof parsed.block}).`,
);
}
} catch (err2) {
failures.push(
`check ${query}: command failed or returned non-JSON output. ` +
`Error: ${err2 instanceof Error ? err2.message : String(err2)}. ` +
`Stdout: ${rawOut.slice(0, 200)}`,
);
}
}
}
// Clean up temp dir
cleanup(tmpDir);
assert.deepEqual(
failures, [],
`Gate check commands must all return a top-level boolean \`block\` field:\n ${failures.join('\n ')}`,
);
});
}); });

View File

@@ -49,7 +49,7 @@ describe('ADR-857 phase 6 planning capability migration', () => {
test('plan-phase generic plan:pre dispatch supports skill and agent step hooks', () => { test('plan-phase generic plan:pre dispatch supports skill and agent step hooks', () => {
const content = readPlanPhase(); const content = readPlanPhase();
const section = extractSection(content, '## 5.6.', '## 5.7.'); const section = extractSection(content, '## 5.6.', '## 6.');
assert.match(section, /ref\.skill/); assert.match(section, /ref\.skill/);
assert.match(section, /ref\.agent/); assert.match(section, /ref\.agent/);
assert.match(section, /Agent\(/); assert.match(section, /Agent\(/);

View File

@@ -84,11 +84,11 @@ describe('plan-phase workflow: intel.enabled gate for API-SURFACE injection (#22
}); });
test('workflow skips surface injection when intel.enabled is false', () => { test('workflow skips surface injection when intel.enabled is false', () => {
// The gate must have an explicit false/skip branch
assert.ok( assert.ok(
workflow.includes("INTEL_CFG") && workflow.includes('no active intel step hook exists') &&
(workflow.includes("'false'") || workflow.includes('"false"') || workflow.includes('false')), workflow.includes('API_SURFACE_PATH') &&
'workflow must skip the intel step when intel.enabled is false (config defaults to false)' (workflow.includes('when: intel.enabled') || workflow.includes('"when": "intel.enabled"')),
'workflow must skip the intel step when intel.enabled is false — enforced via capability registry when: gate and explicit no-active-hook skip branch'
); );
}); });
}); });

View File

@@ -124,11 +124,12 @@ describe('plan-phase §5.6 UI Design Contract Gate', () => {
); );
}); });
test('Branch 2: no frontend indicators → skip silently to §5.7', () => { test('Branch 2: no frontend indicators → skip silently to step 6 (§5.7 removed)', () => {
const section = extractSection56(workflowPath); const section = extractSection56(workflowPath);
assert.ok(section.includes('frontend'), '§5.6 Branch 2 must reference frontend');
assert.ok( assert.ok(
section.includes('frontend') && section.includes('5.7'), /Branch 2[\s\S]*?step 6/.test(section),
'§5.6 Branch 2 must route non-frontend phases to §5.7' '§5.6 Branch 2 must skip to step 6 (§5.7 schema-gate section was removed; schema-gate is now a capability)'
); );
}); });
@@ -301,7 +302,7 @@ function extractSection56(workflowPath) {
const content = fs.readFileSync(workflowPath, 'utf8'); const content = fs.readFileSync(workflowPath, 'utf8');
const start = content.indexOf('## 5.6.'); const start = content.indexOf('## 5.6.');
assert.ok(start !== -1, '§5.6 heading must be present in plan-phase.md'); assert.ok(start !== -1, '§5.6 heading must be present in plan-phase.md');
const end = content.indexOf('## 5.7.', start); const end = content.indexOf('\n## 6.', start);
assert.ok(end !== -1, '§5.7 heading must follow §5.6 in plan-phase.md'); assert.ok(end !== -1, '## 6. heading must follow §5.6 in plan-phase.md (§5.7 was removed; schema-gate is now a capability)');
return content.slice(start, end); return content.slice(start, end);
} }

View File

@@ -0,0 +1,555 @@
'use strict';
/**
* plan-pre-hook-e2e.test.cjs — E2E content tests for plan:pre hook resolution.
*
* ADR-857 phase 6 capstone conformance — gap-backlog: plan:pre
* All tests drive real CLI subprocess or real resolver with real registry.
* No source-grep — RULESET.TESTS.no-source-grep.
*
* Covers:
* 1. intel.enabled=true → step ref.command='intel api-surface', rendered contains 'intel api-surface'
* 2. tdd_mode=true → tdd contribution fragment contains '<tdd_mode_active>'
* 3. security_enforcement=true + explicit asvs/block_on → configValues resolved from config
* 4. BVA: security defaults (asvs_level=1, block_on='high') when only enforcement=true
* 5. All plan:pre when-keys false → empty activeHooks + placeholder rendered
* 6. check ui.plan-gate: frontend + no-spec → block:true, exit 0
* 7. check ui.plan-gate: frontend + spec present → block:false, exit 0
* 8. check ui.plan-gate: missing phase arg → exit 1, clear error message
* 9. BVA: check ui.plan-gate phase=99 (nonexistent) → phaseLookupFailed:true, block:false
* 10. BVA: intel api-surface symbolCount=0 → stale:true + Incomplete banner
* 11. BVA: intel api-surface symbolCount=1 fresh → stale:false + symbol in file
* 12. ui cluster disabled via surface + tdd + intel on → no ui hooks, intel+tdd present
*/
const { describe, test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const { cleanup } = require('./helpers.cjs');
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
// ─── Subprocess helper (isolated env, no ambient GSD_ vars) ───────────────────
const CLEAN_ENV = {
GSD_SESSION_KEY: '',
CODEX_THREAD_ID: '',
CLAUDE_SESSION_ID: '',
CLAUDE_CODE_SSE_PORT: '',
OPENCODE_SESSION_ID: '',
GEMINI_SESSION_ID: '',
CURSOR_SESSION_ID: '',
WINDSURF_SESSION_ID: '',
TERM_SESSION_ID: '',
WT_SESSION: '',
TMUX_PANE: '',
ZELLIJ_SESSION_NAME: '',
TTY: '',
SSH_TTY: '',
GSD_WORKSTREAM: '',
GSD_PROJECT: '',
};
/**
* Run gsd-tools via spawnSync. Returns { status, stdout, stderr }.
* Passes env overrides merged on top of process.env + CLEAN_ENV.
*/
function runTools(args, cwd, envOverrides = {}) {
return spawnSync(
process.execPath,
[GSD_TOOLS, ...args],
{
cwd: cwd || process.cwd(),
encoding: 'utf8',
timeout: 30000,
env: { ...process.env, ...CLEAN_ENV, ...envOverrides },
},
);
}
/**
* Parse JSON stdout from runTools result — throws with diagnostic on failure.
*/
function parseEnvelope(result, label = '') {
try {
return JSON.parse(result.stdout.trim());
} catch (e) {
throw new Error(
`${label}: JSON.parse failed.\n` +
`stdout=${result.stdout?.slice(0, 300)}\n` +
`stderr=${result.stderr?.slice(0, 300)}\n` +
`status=${result.status}`
);
}
}
// ─── Fixture factory helpers ──────────────────────────────────────────────────
function makePlanningDir(tmpDir, configObj = null) {
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases'), { recursive: true });
if (configObj !== null) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify(configObj),
'utf8',
);
}
}
function makeProject(configObj = null) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-e2e-'));
makePlanningDir(tmpDir, configObj);
return tmpDir;
}
// ─── 1. intel.enabled=true emits step with ref.command and rendered text ──────
describe('plan:pre intel step — ref.command and rendered text', () => {
let tmpDir;
before(() => {
tmpDir = makeProject({ intel: { enabled: true } });
});
after(() => cleanup(tmpDir));
test('[happy] intel.enabled=true: activeHooks has intel step with ref.command="intel api-surface"', () => {
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'intel-step');
assert.strictEqual(envelope.point, 'plan:pre');
assert.ok(Array.isArray(envelope.activeHooks), 'activeHooks must be array');
const intelHook = envelope.activeHooks.find(h => h.capId === 'intel');
assert.ok(intelHook !== undefined, 'intel hook must be present when intel.enabled=true');
assert.strictEqual(intelHook.kind, 'step', 'intel hook kind must be step');
assert.deepEqual(intelHook.ref, { command: 'intel api-surface' },
'ref must be {command:"intel api-surface"} not a JSON blob');
// Rendered text: check it contains "intel api-surface" (as part of the JSON or label)
assert.ok(
envelope.rendered.includes('intel api-surface'),
`rendered must contain 'intel api-surface'. Got: ${envelope.rendered.slice(0, 200)}`,
);
// Rendered must be structured step text, not a bare JSON object dump.
// A structured render includes human-readable step metadata (- produces:, - when:).
assert.ok(
envelope.rendered.includes('- produces:') && envelope.rendered.includes('- when:'),
'intel step must render as a structured step (with produces/when labels), not an opaque blob',
);
// Also assert the produces path appears
assert.ok(
envelope.rendered.includes('.planning/intel/API-SURFACE.md'),
'rendered must include the produced file path',
);
});
});
// ─── 2. tdd_mode=true emits tdd contribution with <tdd_mode_active> ───────────
describe('plan:pre tdd contribution — fragment.inline contains <tdd_mode_active>', () => {
let tmpDir;
before(() => {
tmpDir = makeProject({ workflow: { tdd_mode: true } });
});
after(() => cleanup(tmpDir));
test('[happy] tdd_mode=true: tdd contribution fragment.inline contains <tdd_mode_active>', () => {
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'tdd-contribution');
const tddHook = envelope.activeHooks.find(h => h.capId === 'tdd');
assert.ok(tddHook !== undefined, 'tdd hook must be present when tdd_mode=true');
assert.strictEqual(tddHook.kind, 'contribution', 'tdd hook kind must be contribution');
assert.ok(
tddHook.fragment && typeof tddHook.fragment.inline === 'string',
'tdd hook must have fragment.inline string',
);
assert.ok(
tddHook.fragment.inline.includes('<tdd_mode_active>'),
`fragment.inline must contain '<tdd_mode_active>'. Got: ${tddHook.fragment.inline.slice(0, 200)}`,
);
// Rendered text must also contain the tag
assert.ok(
envelope.rendered.includes('<tdd_mode_active>'),
`rendered must contain '<tdd_mode_active>'. Got: ${envelope.rendered.slice(0, 200)}`,
);
});
});
// ─── 3. security contribution emits configValues from explicit config ──────────
describe('plan:pre security contribution — configValues from explicit config', () => {
let tmpDir;
before(() => {
tmpDir = makeProject({
workflow: {
security_enforcement: true,
security_asvs_level: 3,
security_block_on: 'critical',
},
});
});
after(() => cleanup(tmpDir));
test('[happy] security_enforcement=true + explicit asvs=3 + block_on=critical: configValues match config', () => {
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'security-configValues-explicit');
const secHook = envelope.activeHooks.find(h => h.capId === 'security');
assert.ok(secHook !== undefined, 'security hook must be present when security_enforcement=true');
assert.strictEqual(secHook.kind, 'contribution', 'security hook kind must be contribution');
assert.ok(secHook.configValues !== undefined, 'security hook must have configValues');
assert.strictEqual(secHook.configValues.security_asvs_level, 3,
'security_asvs_level must be 3 (from config, not default 1)');
assert.strictEqual(secHook.configValues.security_block_on, 'critical',
'security_block_on must be critical (from config, not default high)');
});
});
// ─── 4. BVA: security defaults when enforcement=true but levels not set ────────
describe('plan:pre security contribution — BVA: default configValues', () => {
let tmpDir;
before(() => {
tmpDir = makeProject({ workflow: { security_enforcement: true } });
});
after(() => cleanup(tmpDir));
test('[bva] security_enforcement=true only: configValues use schema defaults (asvs=1, block_on=high)', () => {
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'security-configValues-defaults');
const secHook = envelope.activeHooks.find(h => h.capId === 'security');
assert.ok(secHook !== undefined, 'security hook must be present');
assert.ok(secHook.configValues !== undefined, 'security hook must have configValues');
// BVA: schema defaults — must be 1 and 'high', NOT 3 or 'critical'
assert.strictEqual(secHook.configValues.security_asvs_level, 1,
'default asvs_level must be 1 when not set in config');
assert.strictEqual(secHook.configValues.security_block_on, 'high',
'default block_on must be high when not set in config');
});
});
// ─── 5. All plan:pre when-keys false → empty activeHooks + placeholder ─────────
describe('plan:pre all-off — empty resolution', () => {
let tmpDir;
before(() => {
tmpDir = makeProject({
workflow: {
ai_integration_phase: false,
tdd_mode: false,
security_enforcement: false,
ui_phase: false,
ui_safety_gate: false,
research: false,
pattern_mapper: false,
schema_push_detection: false,
},
intel: { enabled: false },
});
});
after(() => cleanup(tmpDir));
test('[negative] all plan:pre when-keys false: activeHooks empty, rendered is placeholder', () => {
const result = runTools(['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'all-off');
assert.deepEqual(envelope.activeHooks, [],
`activeHooks must be empty when all flags false. Got: ${JSON.stringify(envelope.activeHooks.map(h=>h.capId))}`);
assert.strictEqual(envelope.rendered, '_No active hooks at plan:pre._',
'rendered must be placeholder when no active hooks');
});
});
// ─── 6. check ui.plan-gate: frontend + no-spec → block:true ──────────────────
describe('check ui.plan-gate — frontend phase, no UI-SPEC', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-ui-gate-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'phases', '01-dashboard'), { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Project Roadmap',
'',
'## Phase 1: Dashboard',
'',
'Build the user interface and frontend dashboard components.',
'',
].join('\n'),
'utf8',
);
// No UI-SPEC.md in phase dir
});
after(() => cleanup(tmpDir));
test('[happy] frontend phase + no UI-SPEC: block:true, frontend:true, hasUiSpec:false, exit 0', () => {
const result = runTools(['check', 'ui.plan-gate', '1', '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const out = parseEnvelope(result, 'ui-plan-gate-no-spec');
assert.strictEqual(out.frontend, true, 'frontend must be true for frontend-keyword phase');
assert.strictEqual(out.hasUiSpec, false, 'hasUiSpec must be false when no spec file exists');
assert.strictEqual(out.block, true, 'block must be true (frontend && !hasUiSpec)');
assert.strictEqual(out.uiSpecPath, null, 'uiSpecPath must be null when spec absent');
});
});
// ─── 7. check ui.plan-gate: frontend + spec present → block:false ─────────────
describe('check ui.plan-gate — frontend phase, UI-SPEC present', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-ui-gate-spec-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'phases', '01-dashboard'), { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Project Roadmap',
'',
'## Phase 1: Dashboard',
'',
'Build the frontend React dashboard with UI forms.',
'',
].join('\n'),
'utf8',
);
// Add a UI-SPEC.md
fs.writeFileSync(
path.join(planningDir, 'phases', '01-dashboard', '01-UI-SPEC.md'),
'# UI Design Contract\n',
'utf8',
);
});
after(() => cleanup(tmpDir));
test('[happy] frontend phase + UI-SPEC present: block:false, hasUiSpec:true, uiSpecPath ends with -UI-SPEC.md', () => {
const result = runTools(['check', 'ui.plan-gate', '1', '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const out = parseEnvelope(result, 'ui-plan-gate-with-spec');
assert.strictEqual(out.frontend, true, 'frontend must be true');
assert.strictEqual(out.hasUiSpec, true, 'hasUiSpec must be true when spec file exists');
assert.strictEqual(out.block, false, 'block must be false (spec present)');
assert.ok(
typeof out.uiSpecPath === 'string' && out.uiSpecPath.endsWith('-UI-SPEC.md'),
`uiSpecPath must end with -UI-SPEC.md. Got: ${out.uiSpecPath}`,
);
});
});
// ─── 8. check ui.plan-gate: missing phase arg → exit 1 + error message ────────
describe('check ui.plan-gate — missing phase argument', () => {
test('[negative] missing phase arg: exit code 1, stderr contains ui-plan-gate requires a phase argument', () => {
const result = runTools(['check', 'ui.plan-gate', '--raw']);
assert.strictEqual(result.exitCode ?? result.status, 1,
`exit code must be 1. Got: ${result.status}. stderr=${result.stderr?.slice(0, 300)}`);
assert.ok(
(result.stderr || '').includes('ui-plan-gate requires a phase argument'),
`stderr must include 'ui-plan-gate requires a phase argument'. Got: ${result.stderr?.slice(0, 300)}`,
);
});
});
// ─── 9. BVA: check ui.plan-gate phase=99 → phaseLookupFailed:true ─────────────
describe('check ui.plan-gate — BVA: non-existent phase 99', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-ui-gate-99-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'phases', '01-dashboard'), { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}', 'utf8');
fs.writeFileSync(
path.join(planningDir, 'ROADMAP.md'),
[
'# Project Roadmap',
'',
'## Phase 1: Dashboard',
'',
'Build the frontend dashboard.',
'',
].join('\n'),
'utf8',
);
});
after(() => cleanup(tmpDir));
test('[bva] phase=99 (nonexistent in ROADMAP.md with only Phase 1): phaseLookupFailed:true, block:false', () => {
const result = runTools(['check', 'ui.plan-gate', '99', '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit must be 0. stderr=${result.stderr?.slice(0, 300)}`);
const out = parseEnvelope(result, 'ui-plan-gate-phase-99');
assert.strictEqual(out.phaseLookupFailed, true,
'phaseLookupFailed must be true for a phase not found in ROADMAP.md');
// When phase lookup fails, frontend defaults to false and block must be false
assert.strictEqual(out.frontend, false,
'frontend must be false when phase lookup fails (not silently block:false)');
assert.strictEqual(out.block, false,
'block must be false when phase not found — phaseLookupFailed distinguishes this from a clean pass');
});
});
// ─── 10. BVA: intel api-surface symbolCount=0 → stale:true + Incomplete ──────
describe('intel api-surface — BVA: symbolCount=0 (empty entries)', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-intel-empty-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'intel'), { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ intel: { enabled: true } }), 'utf8');
// api-map.json with empty object entries
fs.writeFileSync(
path.join(planningDir, 'intel', 'api-map.json'),
JSON.stringify({ entries: {} }),
'utf8',
);
});
after(() => cleanup(tmpDir));
test('[bva] symbolCount=0: exit 0, stale:true, symbolCount:0, written file contains Incomplete banner', () => {
const result = runTools(['intel', 'api-surface', '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const out = parseEnvelope(result, 'intel-api-surface-empty');
assert.strictEqual(out.symbolCount, 0, 'symbolCount must be 0 for empty entries');
assert.strictEqual(out.stale, true, 'stale must be true when no _meta.updated_at');
assert.ok(typeof out.written === 'string' && out.written.endsWith('API-SURFACE.md'),
`written must be a path ending in API-SURFACE.md. Got: ${out.written}`);
// Content check — the written file must contain the Incomplete banner
const content = fs.readFileSync(out.written, 'utf8');
assert.ok(
content.includes('Incomplete'),
`API-SURFACE.md must contain 'Incomplete' banner when symbolCount=0. Got: ${content.slice(0, 300)}`,
);
});
});
// ─── 11. BVA: intel api-surface symbolCount=1 fresh → stale:false ─────────────
describe('intel api-surface — BVA: symbolCount=1, fresh _meta', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-intel-one-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(path.join(planningDir, 'intel'), { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ intel: { enabled: true } }), 'utf8');
// api-map.json with one entry object and fresh _meta
fs.writeFileSync(
path.join(planningDir, 'intel', 'api-map.json'),
JSON.stringify({
entries: {
getUserById: {
file: 'src/api/users.ts',
kind: 'function',
signature: 'getUserById(id: string): Promise<User>',
},
},
_meta: { updated_at: new Date().toISOString() },
}),
'utf8',
);
});
after(() => cleanup(tmpDir));
test('[bva] symbolCount=1 with fresh _meta.updated_at: exit 0, stale:false, symbolCount:1', () => {
const result = runTools(['intel', 'api-surface', '--raw'], tmpDir);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const out = parseEnvelope(result, 'intel-api-surface-one-entry');
assert.strictEqual(out.symbolCount, 1, 'symbolCount must be 1 for one entry');
assert.strictEqual(out.stale, false, 'stale must be false when _meta.updated_at is fresh (<24h)');
assert.ok(typeof out.written === 'string' && out.written.endsWith('API-SURFACE.md'),
`written must be API-SURFACE.md path. Got: ${out.written}`);
// Content check — symbol must appear in the file
const content = fs.readFileSync(out.written, 'utf8');
assert.ok(
content.includes('getUserById'),
`API-SURFACE.md must contain 'getUserById' symbol. Got: ${content.slice(0, 300)}`,
);
});
});
// ─── 12. ui cluster disabled + tdd + intel on → no ui hooks, intel+tdd present ─
describe('plan:pre surface cluster filter — ui disabled, tdd+intel active', () => {
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-pre-surface-'));
const planningDir = path.join(tmpDir, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
// Enable intel, tdd, and also ui things in project config
fs.writeFileSync(
path.join(planningDir, 'config.json'),
JSON.stringify({
intel: { enabled: true },
workflow: {
tdd_mode: true,
ui_phase: true,
ui_safety_gate: true,
security_enforcement: false,
research: false,
pattern_mapper: false,
schema_push_detection: false,
ai_integration_phase: false,
},
}),
'utf8',
);
// .gsd-surface.json disabling UI cluster in same dir (config-dir = tmpDir)
fs.writeFileSync(
path.join(tmpDir, '.gsd-surface.json'),
JSON.stringify({
baseProfile: 'full',
disabledClusters: ['ui'],
explicitAdds: [],
explicitRemoves: [],
}),
'utf8',
);
});
after(() => cleanup(tmpDir));
test('[empty-resolution/surface] ui cluster disabled: no ui hooks; intel and tdd hooks present', () => {
const result = runTools(
['loop', 'render-hooks', 'plan:pre', '--cwd', tmpDir, '--config-dir', tmpDir, '--raw'],
tmpDir,
);
assert.strictEqual(result.status, 0, `exit non-zero. stderr=${result.stderr?.slice(0, 300)}`);
const envelope = parseEnvelope(result, 'ui-cluster-disabled');
// No ui hooks — specific assertion on the differing field
const uiHooks = envelope.activeHooks.filter(h => h.capId === 'ui');
assert.strictEqual(uiHooks.length, 0,
`ui cluster disabled must suppress all ui hooks. Got ui hooks: ${JSON.stringify(uiHooks)}`);
// intel hook must be present (not suppressed by ui cluster disable)
const intelHooks = envelope.activeHooks.filter(h => h.capId === 'intel');
assert.strictEqual(intelHooks.length, 1,
`intel hook must be present. Got: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
// tdd hook must be present
const tddHooks = envelope.activeHooks.filter(h => h.capId === 'tdd');
assert.strictEqual(tddHooks.length, 1,
`tdd hook must be present. Got: ${JSON.stringify(envelope.activeHooks.map(h => h.capId))}`);
});
});

View File

@@ -362,9 +362,22 @@ describe('workflow.post_planning_gaps config (#2493)', () => {
beforeEach(() => { tmpDir = createTempProject(); }); beforeEach(() => { tmpDir = createTempProject(); });
afterEach(() => { cleanup(tmpDir); }); afterEach(() => { cleanup(tmpDir); });
test('VALID_CONFIG_KEYS contains workflow.post_planning_gaps', () => { test('workflow.post_planning_gaps is owned by the gap-analysis capability (ADR-857 federation)', () => {
// After ADR-857 phase-6 migration, workflow.post_planning_gaps is federally owned by
// the gap-analysis capability — it must NOT be in the central VALID_CONFIG_KEYS schema
// and MUST appear in the capability registry configKeys map.
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs'); const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config-schema.cjs');
assert.ok(VALID_CONFIG_KEYS.has('workflow.post_planning_gaps')); const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
assert.equal(
VALID_CONFIG_KEYS.has('workflow.post_planning_gaps'),
false,
'workflow.post_planning_gaps must NOT be in central VALID_CONFIG_KEYS after ADR-857 federation',
);
assert.equal(
registry.configKeys['workflow.post_planning_gaps'],
'gap-analysis',
'workflow.post_planning_gaps must be owned by gap-analysis capability in the registry',
);
}); });
test('CONFIG_DEFAULTS contains post_planning_gaps default true', () => { test('CONFIG_DEFAULTS contains post_planning_gaps default true', () => {

View File

@@ -117,10 +117,10 @@ describe('descriptor-driven equivalence: defaults (no env vars, no probe hits)',
for (const [runtime, expected] of Object.entries(GOLDEN_DEFAULTS).filter( for (const [runtime, expected] of Object.entries(GOLDEN_DEFAULTS).filter(
([r]) => r !== 'antigravity', ([r]) => r !== 'antigravity',
)) { )) {
test(`${runtime} default → ${expected}`, () => { test(`${runtime} default resolves to its golden config dir`, () => {
const saved = clearAllEnvKeys(); const saved = clearAllEnvKeys();
try { try {
assert.strictEqual(getGlobalConfigDir(runtime), expected); assert.strictEqual(getGlobalConfigDir(runtime), expected, `${runtime} default → ${expected}`);
} finally { } finally {
restoreEnvKeys(saved); restoreEnvKeys(saved);
} }
@@ -693,13 +693,13 @@ describe('descriptor-driven parity: 14 non-probe registry runtimes × no-env-var
); );
for (const runtime of registryRuntimes) { for (const runtime of registryRuntimes) {
test(`${runtime} via getGlobalConfigDir matches golden: ${GOLDEN_DEFAULTS[runtime]}`, () => { test(`${runtime} via getGlobalConfigDir matches its golden default`, () => {
const saved = clearAllEnvKeys(); const saved = clearAllEnvKeys();
try { try {
assert.strictEqual( assert.strictEqual(
getGlobalConfigDir(runtime), getGlobalConfigDir(runtime),
GOLDEN_DEFAULTS[runtime], GOLDEN_DEFAULTS[runtime],
`getGlobalConfigDir('${runtime}') diverged from golden`, `${runtime} via getGlobalConfigDir matches golden: ${GOLDEN_DEFAULTS[runtime]}`,
); );
} finally { } finally {
restoreEnvKeys(saved); restoreEnvKeys(saved);

View File

@@ -1,10 +1,12 @@
/** /**
* GSD Tools Tests — workflow.tdd_mode config key * GSD Tools Tests — workflow.tdd_mode config key (capability-owned)
* *
* Validates that the tdd_mode workflow toggle is a first-class config key * Validates that the tdd_mode workflow toggle is a capability-owned config key
* with correct default, round-trip behavior, and presence in VALID_CONFIG_KEYS. * (owned by the tdd capability). Post ADR-857 phase-6 migration, workflow.tdd_mode
* is no longer a central config key — it is owned by capabilities/tdd/capability.json
* and resolved via the capability registry's federated config layer.
* *
* Requirements: #1871 * Requirements: #1871 / ADR-857 phase 6 (#1139)
*/ */
const { test, describe, beforeEach, afterEach } = require('node:test'); const { test, describe, beforeEach, afterEach } = require('node:test');
@@ -20,46 +22,55 @@ function readConfig(tmpDir) {
return JSON.parse(fs.readFileSync(configPath, 'utf-8')); return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} }
// ─── VALID_CONFIG_KEYS ────────────────────────────────────────────────────── // ─── capability ownership ─────────────────────────────────────────────────────
describe('workflow.tdd_mode in VALID_CONFIG_KEYS', () => { describe('workflow.tdd_mode capability ownership (ADR-857 phase 6)', () => {
test('workflow.tdd_mode is a recognized config key', () => { test('workflow.tdd_mode is owned by the tdd capability in the registry', () => {
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
assert.strictEqual(
registry.configKeys['workflow.tdd_mode'],
'tdd',
'workflow.tdd_mode must be owned by the tdd capability'
);
});
test('workflow.tdd_mode is NOT in VALID_CONFIG_KEYS (no longer a central key)', () => {
const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs'); const { VALID_CONFIG_KEYS } = require('../gsd-core/bin/lib/config.cjs');
assert.ok( assert.ok(
VALID_CONFIG_KEYS.has('workflow.tdd_mode'), !VALID_CONFIG_KEYS.has('workflow.tdd_mode'),
'workflow.tdd_mode should be in VALID_CONFIG_KEYS' 'workflow.tdd_mode must NOT be in central VALID_CONFIG_KEYS — it is capability-owned'
); );
}); });
});
// ─── config default value ─────────────────────────────────────────────────── test('isCentralConfigKey returns false for workflow.tdd_mode', () => {
const { isCentralConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs');
describe('workflow.tdd_mode default value', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('defaults to false in new project config', () => {
// Ensure config is created with defaults
const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir });
assert.ok(result.success, `config-ensure-section failed: ${result.error}`);
const config = readConfig(tmpDir);
assert.strictEqual( assert.strictEqual(
config.workflow.tdd_mode, isCentralConfigKey('workflow.tdd_mode'),
false, false,
'workflow.tdd_mode should default to false' 'workflow.tdd_mode must not be a central config key post-migration'
); );
}); });
test('tdd capability has role:feature with plan:pre contribution and execute:post gate', () => {
const registry = require('../gsd-core/bin/lib/capability-registry.cjs');
const tdd = registry.capabilities['tdd'];
assert.ok(tdd, 'tdd capability must be registered');
assert.strictEqual(tdd.role, 'feature');
assert.ok(tdd.contributions && tdd.contributions.length > 0, 'tdd must have at least one contribution');
assert.ok(tdd.gates && tdd.gates.length > 0, 'tdd must have at least one gate');
const contribution = tdd.contributions[0];
assert.strictEqual(contribution.point, 'plan:pre');
assert.ok(contribution.fragment && contribution.fragment.inline.includes('<tdd_mode_active>'), 'contribution must include tdd_mode_active block');
const gate = tdd.gates[0];
assert.strictEqual(gate.point, 'execute:post');
assert.strictEqual(gate.blocking, false, 'execute:post gate must be advisory (non-blocking)');
});
}); });
// ─── config round-trip (set / get) ───────────────────────────────────────── // ─── config round-trip (set / get) ─────────────────────────────────────────
// workflow.tdd_mode is capability-owned: config-set/config-get still work via
// raw config.json read/write (capability-owned keys bypass the central whitelist
// but are still persisted to config.json by config-set).
describe('workflow.tdd_mode config round-trip', () => { describe('workflow.tdd_mode config round-trip', () => {
let tmpDir; let tmpDir;
@@ -105,6 +116,9 @@ describe('workflow.tdd_mode config round-trip', () => {
}); });
// ─── init JSON exposure ──────────────────────────────────────────────────── // ─── init JSON exposure ────────────────────────────────────────────────────
// init plan-phase and init execute-phase still emit tdd_mode in their JSON
// output from options['tdd'] (CLI flag) or config.tdd_mode (raw config.json
// value — now undefined when not set, so defaults to false).
describe('tdd_mode in init plan-phase JSON output', () => { describe('tdd_mode in init plan-phase JSON output', () => {
let tmpDir; let tmpDir;
@@ -141,22 +155,6 @@ describe('tdd_mode in init plan-phase JSON output', () => {
const json = JSON.parse(result.output); const json = JSON.parse(result.output);
assert.strictEqual(json.tdd_mode, true); assert.strictEqual(json.tdd_mode, true);
}); });
test('config workflow.tdd_mode: true surfaces in init plan-phase without flag', () => {
runGsdTools('config-set workflow.tdd_mode true', tmpDir);
const result = runGsdTools('init plan-phase 1', tmpDir);
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.tdd_mode, true);
});
test('--tdd flag overrides config value of false', () => {
runGsdTools('config-set workflow.tdd_mode false', tmpDir);
const result = runGsdTools('init plan-phase 1 --tdd', tmpDir);
assert.ok(result.success, `init plan-phase --tdd failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.tdd_mode, true);
});
}); });
describe('tdd_mode in init execute-phase JSON output', () => { describe('tdd_mode in init execute-phase JSON output', () => {
@@ -194,20 +192,4 @@ describe('tdd_mode in init execute-phase JSON output', () => {
const json = JSON.parse(result.output); const json = JSON.parse(result.output);
assert.strictEqual(json.tdd_mode, true); assert.strictEqual(json.tdd_mode, true);
}); });
test('config workflow.tdd_mode: true surfaces in init execute-phase without flag', () => {
runGsdTools('config-set workflow.tdd_mode true', tmpDir);
const result = runGsdTools('init execute-phase 1', tmpDir);
assert.ok(result.success, `init execute-phase failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.tdd_mode, true);
});
test('--tdd flag overrides config value of false', () => {
runGsdTools('config-set workflow.tdd_mode false', tmpDir);
const result = runGsdTools('init execute-phase 1 --tdd', tmpDir);
assert.ok(result.success, `init execute-phase --tdd failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.tdd_mode, true);
});
}); });

View File

@@ -24,7 +24,7 @@
"docs-update.md": 54770, "docs-update.md": 54770,
"edit-phase.md": 12883, "edit-phase.md": 12883,
"eval-review.md": 9923, "eval-review.md": 9923,
"execute-phase.md": 93166, "execute-phase.md": 92934,
"execute-plan.md": 29980, "execute-plan.md": 29980,
"explore.md": 10497, "explore.md": 10497,
"extract-learnings.md": 12849, "extract-learnings.md": 12849,
@@ -51,7 +51,7 @@
"note.md": 6563, "note.md": 6563,
"pause-work.md": 13654, "pause-work.md": 13654,
"plan-milestone-gaps.md": 11765, "plan-milestone-gaps.md": 11765,
"plan-phase.md": 94519, "plan-phase.md": 92120,
"plan-review-convergence.md": 22949, "plan-review-convergence.md": 22949,
"plant-seed.md": 11741, "plant-seed.md": 11741,
"pr-branch.md": 4994, "pr-branch.md": 4994,