@opengsd/gsd-core 1.5.0-rc.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/LICENSE +1 -1
  3. package/agents/gsd-executor.md +22 -0
  4. package/agents/gsd-phase-researcher.md +1 -1
  5. package/agents/gsd-planner.md +4 -0
  6. package/agents/gsd-project-researcher.md +1 -1
  7. package/agents/gsd-verifier.md +45 -13
  8. package/bin/install.js +56 -100
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/execute-phase.md +1 -1
  11. package/commands/gsd/plan-phase.md +1 -1
  12. package/gemini-extension.json +1 -1
  13. package/gsd-core/bin/gsd-tools.cjs +85 -53
  14. package/gsd-core/bin/lib/agent-command-router.cjs +2 -2
  15. package/gsd-core/bin/lib/agent-install-check.cjs +143 -0
  16. package/gsd-core/bin/lib/audit-command-router.cjs +4 -4
  17. package/gsd-core/bin/lib/capability-activation.cjs +37 -10
  18. package/gsd-core/bin/lib/capability-registry.cjs +2 -0
  19. package/gsd-core/bin/lib/capability-state.cjs +80 -30
  20. package/gsd-core/bin/lib/capability-writer.cjs +5 -4
  21. package/gsd-core/bin/lib/check-command-router.cjs +50 -51
  22. package/gsd-core/bin/lib/commands.cjs +20 -2
  23. package/gsd-core/bin/lib/config-loader.cjs +3 -4
  24. package/gsd-core/bin/lib/config-schema.cjs +1 -1
  25. package/gsd-core/bin/lib/config-types.cjs +2 -1
  26. package/gsd-core/bin/lib/config.cjs +5 -2
  27. package/gsd-core/bin/lib/decisions.cjs +19 -1
  28. package/gsd-core/bin/lib/docs.cjs +14 -2
  29. package/gsd-core/bin/lib/frontmatter.cjs +2 -2
  30. package/gsd-core/bin/lib/gap-checker.cjs +5 -2
  31. package/gsd-core/bin/lib/git-base-branch.cjs +27 -1
  32. package/gsd-core/bin/lib/graphify-command-router.cjs +6 -8
  33. package/gsd-core/bin/lib/graphify.cjs +7 -31
  34. package/gsd-core/bin/lib/gsd2-import.cjs +2 -2
  35. package/gsd-core/bin/lib/init.cjs +30 -4
  36. package/gsd-core/bin/lib/intel-command-router.cjs +6 -3
  37. package/gsd-core/bin/lib/intel.cjs +28 -34
  38. package/gsd-core/bin/lib/io.cjs +2 -4
  39. package/gsd-core/bin/lib/learnings.cjs +2 -2
  40. package/gsd-core/bin/lib/loop-resolver.cjs +45 -167
  41. package/gsd-core/bin/lib/milestone.cjs +13 -5
  42. package/gsd-core/bin/lib/model-resolver.cjs +3 -4
  43. package/gsd-core/bin/lib/phase-id.cjs +3 -5
  44. package/gsd-core/bin/lib/phase-locator.cjs +3 -6
  45. package/gsd-core/bin/lib/phase.cjs +59 -13
  46. package/gsd-core/bin/lib/probe-core.cjs +40 -11
  47. package/gsd-core/bin/lib/profile-output.cjs +5 -2
  48. package/gsd-core/bin/lib/prohibition-enforcement.cjs +660 -0
  49. package/gsd-core/bin/lib/roadmap-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/roadmap-parser.cjs +28 -25
  51. package/gsd-core/bin/lib/roadmap.cjs +9 -4
  52. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +4 -1
  53. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +24 -3
  54. package/gsd-core/bin/lib/state.cjs +75 -17
  55. package/gsd-core/bin/lib/task-command-router.cjs +2 -2
  56. package/gsd-core/bin/lib/teams-status.cjs +74 -0
  57. package/gsd-core/bin/lib/template.cjs +11 -2
  58. package/gsd-core/bin/lib/uat.cjs +64 -2
  59. package/gsd-core/bin/lib/verification.cjs +8 -5
  60. package/gsd-core/bin/lib/verify.cjs +311 -4
  61. package/gsd-core/bin/lib/workstream-inventory.cjs +2 -2
  62. package/gsd-core/bin/lib/workstream.cjs +8 -2
  63. package/gsd-core/bin/lib/worktree-safety.cjs +44 -3
  64. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  65. package/gsd-core/references/planner-antipatterns.md +46 -0
  66. package/gsd-core/references/planning-config.md +5 -1
  67. package/gsd-core/references/prohibition-probe.md +80 -2
  68. package/gsd-core/references/worktree-branch-check.md +11 -5
  69. package/gsd-core/templates/verification-report.md +16 -3
  70. package/gsd-core/workflows/docs-update.md +23 -31
  71. package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +9 -0
  72. package/gsd-core/workflows/execute-phase.md +7 -2
  73. package/gsd-core/workflows/map-codebase.md +8 -10
  74. package/gsd-core/workflows/plan-phase.md +6 -0
  75. package/gsd-core/workflows/quick.md +26 -2
  76. package/gsd-core/workflows/settings-advanced.md +5 -5
  77. package/gsd-core/workflows/settings-integrations.md +5 -5
  78. package/gsd-core/workflows/spec-phase.md +30 -2
  79. package/gsd-core/workflows/verify-phase.md +22 -7
  80. package/hooks/dist/gsd-worktree-path-guard.js +34 -18
  81. package/hooks/gsd-worktree-path-guard.js +34 -18
  82. package/package.json +3 -3
  83. package/scripts/ci-prepare-test-scope.cjs +56 -14
  84. package/scripts/diff-touches-shipped-paths.cjs +5 -11
  85. package/scripts/gen-capability-registry.cjs +27 -1
  86. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
  87. package/scripts/research-profiles.cjs +2 -2
  88. package/scripts/run-tests.cjs +1 -0
  89. package/gsd-core/bin/lib/core.cjs +0 -345
@@ -70,7 +70,17 @@ Aggregate all must_haves across plans for phase-level verification.
70
70
  **Prohibitions (`must_haves.prohibitions`, ADR-550 D3 — the must-NOT sibling block):** When a plan carries `must_haves.prohibitions`, extract each `{ statement, status, verification }` item and route it by `verification` tier in verdict assembly (ADR-550 D4, "B-with-guard", 2026-06-12 maintainer decision). These are NEGATIVE checks (the must-NOT must NOT have happened), distinct from positive `truths`:
71
71
 
72
72
  - **judgment-tier → mode-dependent soft-gate.** Interactive verify defers each item to the end-of-phase human checkpoint (`human_verify_mode: end-of-phase`). Autonomous verify records a NON-AUTHORITATIVE LLM-judge verdict + a prominent `unverified-prohibition — human review recommended` flag (autonomous completion reads "complete with N flagged prohibitions"). NEVER a silent pass; NEVER a hard halt of an AFK run.
73
- - **test-tier → FAIL CLOSED (accept-and-flag).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later), but a well-formed test-tier item reaching verify with NO wired enforcement disposes as UNVERIFIED, flagged like an unresolved judgment item, NEVER green. The deterministic fail-closed default is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`). The real fail-first negative-test enforcement MECHANISM defers to a follow-up PR (#644's corpus is entirely judgment-tier; a contrived test-tier fixture here would be the gold-plating failure mode).
73
+ - **test-tier → ENFORCED via `check prohibition-enforcement` (green on pass, hard-gate on miss/fail).** Accept the `verification: test` value (the SPEC↔must_haves.prohibitions projection contract holds — no forced schema change later). For each test-tier item, the verifier builds `request.check` **DETERMINISTICALLY from the projected descriptor** — it does NOT invent `{ kind, target, rule }`. Read the flat scalar keys `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` off the `must_haves.prohibitions` item and reconstruct the `CheckDescriptor` via the `descriptorFromProjection` adapter in `prohibition-enforcement` (`descriptorFromProjection(projectedItem)` → `{ kind: check_kind, target: check_target, rule?: check_rule, violationFixture?: check_violation_fixture }`). The `violationFixture` (a path to a KNOWN-BAD subject) is the field that gates **green** and it is **now projected** (`check_violation_fixture`, #1346) — so a prohibition authored with all four scalars greens through the projection alone, **zero hand-authoring at verify time**. Do NOT rely on `failFirst`: it is DEMOTED (#1279) and greens nothing on its own; an item with no projected fixture hard-gates fail-closed. Invoke the producer (CLI surface unchanged):
74
+
75
+ ```bash
76
+ gsd_run check prohibition-enforcement <request.json>
77
+ ```
78
+
79
+ where `<request.json>` carries `{ prohibition, check, mode }` — `check` being the wired mechanical-check descriptor `{ kind: 'node-test' | 'lint-rule', target, rule?, violationFixture, failFirst? }`, with `kind`/`target`/`rule`/`violationFixture` now sourced from the projected `check_*` scalars (not author/verifier invention — #1278 + #1346). For `node-test`, `target` (from `check_target`) is the negative-test file path; for `lint-rule`, `target` is the PATH to lint and `rule` (from `check_rule`) is the eslint rule id (e.g. `local/no-source-grep`) — both required (a lint-rule without `rule` is not a valid wired check). `violationFixture` (from `check_violation_fixture`) is the path to a KNOWN-BAD subject the producer runs the check against to **machine-prove fail-first** (for `node-test`, injected via the `GSD_PROHIB_SUBJECT` env convention — #1279); `failFirst` is a DEMOTED, non-authoritative hint kept only for backward route-JSON shape (no path greens on it alone — FF-08). The producer LOCATES the wired check from the projection, **machine-proves it is fail-first** by running it against the violation and confirming it goes RED, RUNS it for a genuine non-vacuous pass, builds `enforcementEvidence`, and emits the `dispositionForProhibition()` verdict (#1259 + #1278 + #1279, ADR-550 D5d). Fail-first is **machine-proven, not caller-attested** — absent a provable violation the producer fails closed, never falling back to attestation. Route the result by its typed fields:
80
+ - **`status: 'green'`, `flagged: false`** (a genuinely-passing wired negative test / lint rule, `located: true`, non-empty `evidence`) → the item is satisfiable → it can reach **passed**.
81
+ - **missing, non-attested, or genuinely-non-passing check** (`located: false` OR `status: 'unverified'`, `flagged: true`) → **hard-gate**: disposes flagged-unverified, NEVER green, routing to `gaps_found` in BOTH interactive and autonomous modes (a failing mechanical check blocks even AFK; ADR-550 D4 / D3). The deterministic fail-closed default backing every miss/fail is `dispositionForProhibition()` in probe-core (`status: 'unverified'`, `flagged: true` on empty `enforcementEvidence`).
82
+
83
+ > **Descriptor source — deterministic locate + machine-proof compose (#1278 + #1346, DELIVERED).** The `check` descriptor's `{ kind, target, rule, violationFixture }` is now sourced **deterministically from the projected `check_kind` / `check_target` / `check_rule` / `check_violation_fixture` scalars** on the `must_haves.prohibitions` item (authored at `/gsd:spec-phase`, projected by `projectProhibitions`, read back via the `descriptorFromProjection` adapter). So both halves close with **zero manual descriptor authoring** — the verifier neither invents the locate (#1278) nor hand-supplies the violation fixture (#1346): a prohibition authored with all four scalars machine-proves fail-first and greens end-to-end through the projection alone (removing the spoofable invent-at-verify-time surface; ADR-857 §147 exogenous grading). **Fail-closed is preserved:** an item with NO projected descriptor, a PARTIAL one (e.g. a `lint-rule` missing `check_rule`), OR a descriptor with **no `check_violation_fixture`** makes `descriptorFromProjection` return `null` / an under-specified or fixture-less descriptor, which falls through to the producer's fail-closed paths (`located: false`, or located-but-unprovable) → flagged-unverified, NEVER green, in BOTH modes. `failFirst` is demoted and greens nothing on its own (#1279, FF-08). Residual (tracked **#1346**): the node-test proof confirms the fixture exists and the check goes RED, but cannot generically prove the red was *caused by* the subject's content vs the env merely being set.
74
84
 
75
85
  **Option B: Use Success Criteria from ROADMAP.md**
76
86
 
@@ -101,10 +111,12 @@ If no must_haves in frontmatter AND no Success Criteria in ROADMAP:
101
111
  <step name="verify_truths">
102
112
  For each observable truth, determine if the codebase enables it.
103
113
 
104
- **Status:** ✓ VERIFIED (all supporting artifacts pass) | ✗ FAILED (artifact missing/stub/unwired) | ? UNCERTAIN (needs human)
114
+ **Status:** ✓ VERIFIED (all supporting artifacts pass — and, for a behavior-dependent truth, a behavioral test exercises the asserted behavior) | ⚠️ PRESENT_BEHAVIOR_UNVERIFIED (present + wired, but a state transition or cancellation/cleanup/ordering invariant is exercised by no test — routes to human verification, excluded from the score) | ✗ FAILED (artifact missing/stub/unwired) | ? UNCERTAIN (needs human)
105
115
 
106
116
  For each truth: identify supporting artifacts → check artifact status → check wiring → determine truth status.
107
117
 
118
+ **Behavior-dependent truths:** when a truth asserts a state transition or a cancellation/cleanup/ordering invariant, symbol presence + wiring is necessary but not sufficient — the code can be present and wired yet still leak state on the path the invariant covers. Mark such a truth ✓ VERIFIED only when a pre-existing test exercises the transition/invariant and passes (one named test, never the full suite); otherwise mark it ⚠️ PRESENT_BEHAVIOR_UNVERIFIED, emit a human-verification item, and exclude it from the verified score.
119
+
108
120
  **Example:** Truth "User can see existing messages" depends on Chat.tsx (renders), /api/chat GET (provides), Message model (schema). If Chat.tsx is a stub or API returns hardcoded [] → FAILED. If all exist, are substantive, and connected → VERIFIED.
109
121
  </step>
110
122
 
@@ -440,13 +452,14 @@ Infrastructure and foundation phases — code foundations, database schema, inte
440
452
  - Mark human verification as **N/A** with rationale: "Infrastructure/foundation phase — no user-facing elements to test manually."
441
453
  - Set `human_verification: []` and do **not** produce a `human_needed` status solely due to lack of user-facing features.
442
454
  - Only add human verification items if the phase goal or success criteria explicitly describe something a user would interact with (UI, CLI command output visible to end users, external service UX).
455
+ - **Exception — behavior-unverified truths still count.** A truth marked ⚠️ PRESENT_BEHAVIOR_UNVERIFIED (a state transition or a cancellation/cleanup/ordering invariant with no test exercising it) is a behavioral-evidence gap, not an artificial user-facing step. Record it in `behavior_unverified_items` and emit a human-verification item for it **even on an infrastructure/foundation phase** — these invariants are exactly where infra phases hide runtime state leaks. Such a truth drives `human_needed`; the auto-pass-UAT shortcut applies only to the absence of user-facing UX, never to a behavior-unverified invariant.
443
456
 
444
457
  **How to determine if a phase is infrastructure/foundation:**
445
458
  - Phase goal or name contains: "foundation", "infrastructure", "schema", "database", "internal API", "data model", "scaffolding", "pipeline", "tooling", "CI", "migrations", "service layer", "backend", "core library"
446
459
  - Phase success criteria describe only technical artifacts (files exist, tests pass, schema is valid) with no user interaction required
447
460
  - There is no UI, CLI output visible to end users, or real-time behavior to observe
448
461
 
449
- **If the phase IS infrastructure/foundation:** auto-pass UAT — skip the human verification items list entirely. Log:
462
+ **If the phase IS infrastructure/foundation:** auto-pass UAT — skip the human verification items list entirely, **except any ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truth (see exception above), which still emits a human-verification item and drives `human_needed`.** Log:
450
463
 
451
464
  ```markdown
452
465
  ## Human Verification
@@ -471,19 +484,21 @@ Classify status using this decision tree IN ORDER (most restrictive first):
471
484
  → **gaps_found**
472
485
 
473
486
  2. IF any `must_haves.prohibitions` item disposes as flagged-unverified (ADR-550 D4):
474
- - **test-tier, fail-closed** (no wired enforcement — `dispositionForProhibition()` returns `status: 'unverified'`, `flagged: true`): → **gaps_found** (never green; the unwired test-tier item is an unverified gap).
487
+ - **test-tier, fail-closed when the wired check is MISSING OR FAILS** (now run via `check prohibition-enforcement` — `located: false`, or `dispositionForProhibition()` returns `status: 'unverified'`, `flagged: true`): → **gaps_found** in both interactive and autonomous modes (never green; a missing/failing mechanical check is an unverified gap). A test-tier item whose wired check PASSES disposes `status: 'green'`, `flagged: false` and is NOT a gap — it can reach **passed**.
475
488
  - **judgment-tier, autonomous run** (non-authoritative LLM-judge verdict): emit the `unverified-prohibition — human review recommended` flag and classify → **human_needed** (autonomous completion reads "complete with N flagged prohibitions"; never a silent pass, never a hard halt).
476
489
  - **judgment-tier, interactive run**: route to the end-of-phase human checkpoint → **human_needed**.
477
490
 
478
- 3. IF the previous step produced ANY human verification items:
479
- → **human_needed** (even if all truths VERIFIED and score is N/N)
491
+ 3. IF the previous step produced ANY human verification items — this includes every ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truth:
492
+ → **human_needed** (even if all other truths VERIFIED)
480
493
 
481
494
  4. IF all checks pass AND no human verification items AND no flagged prohibitions:
482
495
  → **passed**
483
496
 
484
497
  **passed is ONLY valid when no human verification items AND no flagged prohibitions exist.** A prohibition (must-NOT) can never be silently absorbed into a `passed` verdict — that is the core failure mode ADR-550 D4 forbids.
485
498
 
486
- **Score:** `verified_truths / total_truths`
499
+ A ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truth is never FAILED and never VERIFIED: it does not trigger gaps_found (the code is present and wired) and is not counted as verified (its runtime behavior was not exercised). It routes through the existing human_needed sink — no new overall status.
500
+
501
+ **Score:** `verified_truths / total_truths` — `verified_truths` counts ✓ VERIFIED truths plus PASSED (override) truths; ⚠️ PRESENT_BEHAVIOR_UNVERIFIED truths are the only ones excluded, reported separately as the `behavior_unverified` count. A headline N/N therefore certifies behavioral evidence for every behavior-dependent truth, not merely symbol presence.
487
502
  </step>
488
503
 
489
504
  <step name="filter_deferred_items">
@@ -71,6 +71,17 @@ process.stdin.on('end', () => {
71
71
  process.exit(0); // main repo, submodule, or separate-git-dir — no-op
72
72
  }
73
73
 
74
+ // #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
75
+ // are always on a `worktree-agent-*` branch (the positive allow-list enforced
76
+ // by worktree-branch-check.md, #2924). A manually-created linked worktree (plain
77
+ // non-GSD work, e.g. Claude Code plan-mode) is on the user's own branch, so the
78
+ // guard must be a no-op there. Detached HEAD / error → not GSD-managed → no-op.
79
+ const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
80
+ const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
81
+ if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
82
+ process.exit(0); // not a GSD-managed executor worktree — no-op
83
+ }
84
+
74
85
  // Get the raw --show-toplevel output for the worktree (cwd).
75
86
  // We keep it raw (not path.resolve'd) to compare directly with the
76
87
  // file's toplevel — same git binary, same format, no normalization needed.
@@ -110,15 +121,9 @@ process.stdin.on('end', () => {
110
121
 
111
122
  if (!checkDir) {
112
123
  // Walked to root without finding any directory — path is synthetic.
113
- // Block conservatively.
114
- const output = {
115
- decision: 'block',
116
- reason:
117
- `Worktree path guard: '${filePath}' has no existing ancestor directory — ` +
118
- `cannot verify it is inside the worktree '${wtTopRaw}'. Use a relative path instead.`,
119
- };
120
- process.stdout.write(JSON.stringify(output));
121
- process.exit(2);
124
+ // A path with no existing ancestor is not the #260 main-repo vector;
125
+ // #260 is caught by the different-git-root branch below. Fail open. (#1342)
126
+ process.exit(0);
122
127
  }
123
128
 
124
129
  // Ask git for the toplevel of the file's location.
@@ -130,15 +135,26 @@ process.stdin.on('end', () => {
130
135
  const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
131
136
 
132
137
  if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
133
- // checkDir is not inside any git repo → cannot be inside the worktree.
134
- const output = {
135
- decision: 'block',
136
- reason:
137
- `Worktree path guard: '${filePath}' is not inside any git repository — ` +
138
- `it cannot be inside the worktree at '${wtTopRaw}'. Use a relative path instead.`,
139
- };
140
- process.stdout.write(JSON.stringify(output));
141
- process.exit(2);
138
+ // The target's location is not a git work tree. Two sub-cases:
139
+ // - Inside a .git directory (e.g. /main-repo/.git/config or .git/hooks/*)
140
+ // → an absolute write into a repository's internals; still a #260-class
141
+ // escape (and dangerous) → BLOCK.
142
+ // - Truly outside all git repositories (e.g. ~/.claude/plans/) → not the
143
+ // main-repo vector → fail open. (#1342)
144
+ const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
145
+ if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
146
+ const output = {
147
+ decision: 'block',
148
+ reason:
149
+ `Worktree path guard: '${filePath}' is inside a git internal (.git) directory, ` +
150
+ `not the active worktree at '${wtTopRaw}'. Writing to repository internals via an ` +
151
+ `absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
152
+ };
153
+ process.stdout.write(JSON.stringify(output));
154
+ process.exit(2);
155
+ }
156
+ // Outside all git repositories — fail open (#1342).
157
+ process.exit(0);
142
158
  }
143
159
 
144
160
  const fileTopRaw = fileTopResult.stdout.trim();
@@ -71,6 +71,17 @@ process.stdin.on('end', () => {
71
71
  process.exit(0); // main repo, submodule, or separate-git-dir — no-op
72
72
  }
73
73
 
74
+ // #1342: Only enforce inside a GSD-managed isolated executor worktree. Those
75
+ // are always on a `worktree-agent-*` branch (the positive allow-list enforced
76
+ // by worktree-branch-check.md, #2924). A manually-created linked worktree (plain
77
+ // non-GSD work, e.g. Claude Code plan-mode) is on the user's own branch, so the
78
+ // guard must be a no-op there. Detached HEAD / error → not GSD-managed → no-op.
79
+ const branchResult = git(['symbolic-ref', '--short', 'HEAD'], cwd);
80
+ const branch = branchResult.status === 0 && branchResult.stdout ? branchResult.stdout.trim() : '';
81
+ if (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch)) {
82
+ process.exit(0); // not a GSD-managed executor worktree — no-op
83
+ }
84
+
74
85
  // Get the raw --show-toplevel output for the worktree (cwd).
75
86
  // We keep it raw (not path.resolve'd) to compare directly with the
76
87
  // file's toplevel — same git binary, same format, no normalization needed.
@@ -110,15 +121,9 @@ process.stdin.on('end', () => {
110
121
 
111
122
  if (!checkDir) {
112
123
  // Walked to root without finding any directory — path is synthetic.
113
- // Block conservatively.
114
- const output = {
115
- decision: 'block',
116
- reason:
117
- `Worktree path guard: '${filePath}' has no existing ancestor directory — ` +
118
- `cannot verify it is inside the worktree '${wtTopRaw}'. Use a relative path instead.`,
119
- };
120
- process.stdout.write(JSON.stringify(output));
121
- process.exit(2);
124
+ // A path with no existing ancestor is not the #260 main-repo vector;
125
+ // #260 is caught by the different-git-root branch below. Fail open. (#1342)
126
+ process.exit(0);
122
127
  }
123
128
 
124
129
  // Ask git for the toplevel of the file's location.
@@ -130,15 +135,26 @@ process.stdin.on('end', () => {
130
135
  const fileTopResult = git(['rev-parse', '--show-toplevel'], checkDir);
131
136
 
132
137
  if (fileTopResult.status !== 0 || !fileTopResult.stdout) {
133
- // checkDir is not inside any git repo → cannot be inside the worktree.
134
- const output = {
135
- decision: 'block',
136
- reason:
137
- `Worktree path guard: '${filePath}' is not inside any git repository — ` +
138
- `it cannot be inside the worktree at '${wtTopRaw}'. Use a relative path instead.`,
139
- };
140
- process.stdout.write(JSON.stringify(output));
141
- process.exit(2);
138
+ // The target's location is not a git work tree. Two sub-cases:
139
+ // - Inside a .git directory (e.g. /main-repo/.git/config or .git/hooks/*)
140
+ // → an absolute write into a repository's internals; still a #260-class
141
+ // escape (and dangerous) → BLOCK.
142
+ // - Truly outside all git repositories (e.g. ~/.claude/plans/) → not the
143
+ // main-repo vector → fail open. (#1342)
144
+ const insideGitDir = git(['rev-parse', '--is-inside-git-dir'], checkDir);
145
+ if (insideGitDir.status === 0 && insideGitDir.stdout && insideGitDir.stdout.trim() === 'true') {
146
+ const output = {
147
+ decision: 'block',
148
+ reason:
149
+ `Worktree path guard: '${filePath}' is inside a git internal (.git) directory, ` +
150
+ `not the active worktree at '${wtTopRaw}'. Writing to repository internals via an ` +
151
+ `absolute path is not permitted from an isolated executor worktree. Use a relative path.`,
152
+ };
153
+ process.stdout.write(JSON.stringify(output));
154
+ process.exit(2);
155
+ }
156
+ // Outside all git repositories — fail open (#1342).
157
+ process.exit(0);
142
158
  }
143
159
 
144
160
  const fileTopRaw = fileTopResult.stdout.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.5.0-rc.4",
3
+ "version": "1.5.0",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "bin": {
6
6
  "gsd-core": "bin/install.js",
@@ -50,7 +50,7 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@anthropic-ai/claude-agent-sdk": "^0.2.84",
53
- "ws": "8.20.1"
53
+ "ws": "^8.21.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@eslint/js": "^9.39.4",
@@ -62,7 +62,7 @@
62
62
  "eslint-plugin-no-only-tests": "^3.4.0",
63
63
  "fast-check": "^4.8.0",
64
64
  "globals": "^16.5.0",
65
- "js-yaml": "^4.1.1",
65
+ "js-yaml": "^4.2.0",
66
66
  "typescript": "^6.0.3",
67
67
  "typescript-eslint": "^8.60.0"
68
68
  },
@@ -16,13 +16,37 @@ const path = require('path');
16
16
 
17
17
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
18
18
 
19
- const scope = process.env.TEST_SCOPE || '';
20
- const targeted = process.env.TARGETED_TESTS || '';
21
- const windows = process.env.WINDOWS_TESTS || '';
19
+ // Suite sentinels understood by run-tests.cjs (scripts/run-tests.cjs SUITES).
20
+ // A sentinel resolves to its live file set at run time, so — unlike an explicit
21
+ // filename — it can never reference a since-deleted test file.
22
+ const SUITE_SENTINELS = ['all', 'unit', 'integration', 'install', 'security', 'slow'];
22
23
 
23
- const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs';
24
+ // Fast smoke set used when scope detection yields no targeted tests. Each entry
25
+ // MUST resolve via run-tests.cjs: an existing repo-relative test file or a
26
+ // SUITE_SENTINELS token. A stale filename here (a test deleted by a refactor)
27
+ // is exactly what broke CI in #1329 — `tests/core.test.cjs`, deleted in #1291,
28
+ // was still listed and crashed every scoped lane that hit this fallback. The
29
+ // parity guard in tests/bug-641-files-from-suite-token.test.cjs fails the
30
+ // moment an entry stops resolving; resolveSelection() filters at write time so
31
+ // a stale entry degrades instead of crashing the lane.
32
+ const FALLBACK = [
33
+ 'tests/command-contract.test.cjs',
34
+ 'tests/commands.test.cjs',
35
+ 'tests/package-manifest.test.cjs',
36
+ ];
24
37
 
25
- function main() {
38
+ // Last-resort selector when every FALLBACK entry has been deleted: the whole
39
+ // unit suite, resolved live by run-tests.cjs (the #408/#641 sentinel path).
40
+ const FALLBACK_SENTINEL = 'unit';
41
+
42
+ // An entry is runnable if it is a suite sentinel or an existing file under root.
43
+ function isResolvable(entry, root) {
44
+ return SUITE_SENTINELS.includes(entry) || fs.existsSync(path.join(root, entry));
45
+ }
46
+
47
+ // Resolve the scoped test selection for the lane. Pure (no I/O beyond the
48
+ // existence probe under `root`) so it can be unit-tested directly.
49
+ function resolveSelection({ scope, targeted, windows, root }) {
26
50
  let selected;
27
51
  if (scope === 'windows') {
28
52
  selected = windows;
@@ -32,20 +56,38 @@ function main() {
32
56
  throw new ExitError(1, `::error::Unknown test scope: ${scope}`);
33
57
  }
34
58
 
35
- // Trim and fall back to default set if empty.
36
- if (!selected.trim()) {
37
- selected = FALLBACK;
59
+ // Detected list passes through verbatim: affected-tests-lib.cjs already filters
60
+ // deleted files, and the list may legitimately carry a suite sentinel.
61
+ const detected = (selected || '').split(/\s+/).filter(Boolean);
62
+ if (detected.length > 0) {
63
+ return detected;
38
64
  }
39
65
 
40
- // Split on whitespace, filter blanks, join with newlines.
41
- const lines = selected.split(/\s+/).filter(Boolean);
42
- const content = lines.join('\n') + '\n';
66
+ // Empty detection → smoke fallback, existence-filtered so a stale entry can
67
+ // never crash the scoped lane (#1329). If nothing survives, use the unit
68
+ // sentinel, which run-tests.cjs always resolves.
69
+ const survivors = FALLBACK.filter((f) => isResolvable(f, root));
70
+ return survivors.length > 0 ? survivors : [FALLBACK_SENTINEL];
71
+ }
43
72
 
44
- const outPath = path.join(process.cwd(), '.ci-selected-tests.txt');
45
- fs.writeFileSync(outPath, content, 'utf-8');
73
+ function main() {
74
+ const root = process.cwd();
75
+ const lines = resolveSelection({
76
+ scope: process.env.TEST_SCOPE || '',
77
+ targeted: process.env.TARGETED_TESTS || '',
78
+ windows: process.env.WINDOWS_TESTS || '',
79
+ root,
80
+ });
81
+
82
+ const content = lines.join('\n') + '\n';
83
+ fs.writeFileSync(path.join(root, '.ci-selected-tests.txt'), content, 'utf-8');
46
84
 
47
85
  process.stdout.write('Scoped tests:\n');
48
86
  process.stdout.write(content);
49
87
  }
50
88
 
51
- runMain(main);
89
+ if (require.main === module) {
90
+ runMain(main);
91
+ }
92
+
93
+ module.exports = { FALLBACK, FALLBACK_SENTINEL, SUITE_SENTINELS, resolveSelection, main };
@@ -12,11 +12,10 @@
12
12
  * - package.json (always included by `npm pack`, regardless of `files`)
13
13
  * - every entry in package.json `files`, treated as either an exact
14
14
  * file match or a directory prefix (matching `npm pack` semantics).
15
- * - CI-gating test paths: `tests/<anything>` plus
16
- * `sdk/src/<anything>/<name>.test.<ts|cjs|mjs|js>` and `.spec.` variants
17
- * — these don't ship in the tarball, but they gate the hotfix-branch
18
- * test job. A test fixture update that aligns with a cherry-picked
19
- * production fix MUST be pickable or CI fails on the hotfix run.
15
+ * - CI-gating test paths: `tests/<anything>` — these don't ship in the
16
+ * tarball, but they gate the hotfix-branch test job. A test fixture
17
+ * update that aligns with a cherry-picked production fix MUST be
18
+ * pickable or CI fails on the hotfix run.
20
19
  * #3621 — root cause of the v1.42.3 hotfix red CI.
21
20
  *
22
21
  * `package-lock.json` is intentionally NOT considered shipped — `npm pack`
@@ -65,12 +64,7 @@ function loadShipPrefixes(pkgPath) {
65
64
  // in hotfix.yml, this lets `test(####):` fixture-alignment commits be
66
65
  // cherry-picked alongside their production counterparts.
67
66
  function isCiGating(diffPath) {
68
- if (diffPath.startsWith('tests/')) return true;
69
- // SDK vitest specs live next to source. Production source ships via
70
- // sdk/dist/ (already in package.json `files`); the test files are what's
71
- // missing from that surface.
72
- if (diffPath.startsWith('sdk/src/') && /\.(test|spec)\.(ts|cjs|mjs|js)$/.test(diffPath)) return true;
73
- return false;
67
+ return diffPath.startsWith('tests/');
74
68
  }
75
69
 
76
70
  function isShipped(diffPath, shipPrefixes) {
@@ -542,6 +542,32 @@ function validateFeatureBody(cap) {
542
542
  }
543
543
  }
544
544
 
545
+ // activationKey: optional string naming the dotted config key that gates this capability.
546
+ // If present: must be a non-empty string that is declared in this capability's own config slice.
547
+ if (cap.activationKey !== undefined) {
548
+ if (typeof cap.activationKey !== 'string' || cap.activationKey.length === 0) {
549
+ errors.push(
550
+ 'capability "' + (cap.id || '(unknown)') + '" activationKey must be a non-empty string (got: ' +
551
+ JSON.stringify(cap.activationKey) + ')',
552
+ );
553
+ } else if (cap.activationKey === '__proto__' || cap.activationKey === 'constructor' || cap.activationKey === 'prototype') {
554
+ // Prototype-pollution guard (inline literal, CodeQL barrier)
555
+ errors.push(
556
+ 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey +
557
+ '" is a reserved JavaScript property name and cannot be used as an activationKey',
558
+ );
559
+ } else if (
560
+ typeof cap.config !== 'object' ||
561
+ cap.config === null ||
562
+ !Object.prototype.hasOwnProperty.call(cap.config, cap.activationKey)
563
+ ) {
564
+ errors.push(
565
+ 'capability "' + (cap.id || '(unknown)') + '" activationKey "' + cap.activationKey +
566
+ '" is not declared in this capability\'s config slice — add it to the "config" object or use a key that is declared there',
567
+ );
568
+ }
569
+ }
570
+
545
571
  return errors;
546
572
  }
547
573
 
@@ -586,7 +612,7 @@ const VALID_HOOK_EVENTS = new Set(['claude', 'gemini', 'opencode-subset']);
586
612
  const VALID_SANDBOX_TIERS = new Set(['none', 'codex-agent-sandbox']);
587
613
  const VALID_ARTIFACT_KIND_NAMES = new Set(['commands', 'agents', 'skills', 'kimi-agents']);
588
614
  const VALID_ARTIFACT_NESTINGS = new Set(['flat', 'nested']);
589
- const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks'];
615
+ const FEATURE_FIELDS_FORBIDDEN_ON_RUNTIME = ['skills', 'agents', 'steps', 'contributions', 'gates', 'hooks', 'activationKey'];
590
616
  const VALID_INSTALL_SURFACES = new Set(['settings-json', 'codex-toml', 'copilot-instructions', 'cline-rules', 'cursor-hooks-json', 'profile-marker-only']);
591
617
  const VALID_PERMISSION_WRITERS = new Set(['opencode', 'kilo']);
592
618
  const VALID_EXTENDED_HOOK_EVENTS = new Set(['SubagentStop', 'Stop', 'PreCompact', 'FileChanged', 'BeforeAgent', 'AfterAgent', 'BeforeModel']);
@@ -152,8 +152,6 @@
152
152
  "tests/context-enrichment.test.cjs :: source-text-is-the-product",
153
153
  "tests/contributor-standards.test.cjs :: source-text-is-the-product",
154
154
  "tests/copilot-install.test.cjs :: integration-test-input",
155
- "tests/core.test.cjs :: architectural-invariant",
156
- "tests/core.test.cjs :: structural-regression-guard",
157
155
  "tests/cursor-hooks.test.cjs :: source-text-is-the-product",
158
156
  "tests/cursor-reviewer.test.cjs :: source-text-is-the-product",
159
157
  "tests/debug-session-management.test.cjs :: source-text-is-the-product",
@@ -24,7 +24,7 @@ const PROFILES = [
24
24
  'Researches domain ecosystem before roadmap creation. Produces files in .planning/research/ consumed during roadmap creation. Spawned by /gsd:new-project or /gsd:new-milestone orchestrators.',
25
25
  color: 'cyan',
26
26
  tools:
27
- 'Read, Write, Bash, Grep, Glob, Skill, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*',
27
+ 'Read, Write, Bash, Grep, Glob, Skill, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*, mcp__perplexity__*',
28
28
  requiredIncludes: [
29
29
  '@~/.claude/gsd-core/references/research-documentation-lookup.md',
30
30
  '@~/.claude/gsd-core/references/research-philosophy.md',
@@ -46,7 +46,7 @@ const PROFILES = [
46
46
  'Researches how to implement a phase before planning. Produces RESEARCH.md consumed by gsd-planner. Spawned by /gsd:plan-phase orchestrator.',
47
47
  color: 'cyan',
48
48
  tools:
49
- 'Read, Write, Edit, Bash, Grep, Glob, Skill, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*',
49
+ 'Read, Write, Edit, Bash, Grep, Glob, Skill, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*, mcp__perplexity__*',
50
50
  requiredIncludes: [
51
51
  '@~/.claude/gsd-core/references/research-documentation-lookup.md',
52
52
  '@~/.claude/gsd-core/references/research-philosophy.md',
@@ -461,6 +461,7 @@ function main() {
461
461
  // them so the local runner matches CI; tests that need them set them explicitly.
462
462
  delete process.env.GSD_PROJECT;
463
463
  delete process.env.GSD_WORKSTREAM;
464
+ delete process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
464
465
 
465
466
  // Log selected files to stderr for CI / harness-test visibility.
466
467
  // node:test default reporter doesn't echo filenames, so this gives