@opengsd/gsd-core 1.8.0 → 1.9.1

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 (177) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +107 -34
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +236 -107
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +882 -4
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +36 -9
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +61 -6
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/project-root.cjs +48 -0
  49. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  50. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  51. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  52. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  54. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  55. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  58. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  59. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  60. package/gsd-core/bin/lib/state-document.cjs +164 -20
  61. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  62. package/gsd-core/bin/lib/state.cjs +141 -21
  63. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  64. package/gsd-core/bin/lib/uat.cjs +9 -7
  65. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  66. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  67. package/gsd-core/bin/lib/validate.cjs +32 -0
  68. package/gsd-core/bin/lib/verification.cjs +51 -14
  69. package/gsd-core/bin/lib/verify.cjs +146 -22
  70. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  71. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  72. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  73. package/gsd-core/bin/shared/model-catalog.json +5 -0
  74. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  75. package/gsd-core/references/context-budget.md +40 -0
  76. package/gsd-core/references/gate-prompts.md +6 -3
  77. package/gsd-core/references/model-profile-resolution.md +64 -13
  78. package/gsd-core/references/offer-next.md +88 -0
  79. package/gsd-core/references/planning-config.md +2 -1
  80. package/gsd-core/references/reviewer-instances.md +28 -21
  81. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  82. package/gsd-core/references/ui-consideration-probe.md +2 -2
  83. package/gsd-core/references/worktree-branch-check.md +4 -4
  84. package/gsd-core/templates/summary-minimal.md +4 -0
  85. package/gsd-core/templates/summary-standard.md +4 -0
  86. package/gsd-core/templates/summary.md +7 -0
  87. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  88. package/gsd-core/workflows/audit-fix.md +4 -0
  89. package/gsd-core/workflows/audit-milestone.md +8 -0
  90. package/gsd-core/workflows/autonomous.md +19 -15
  91. package/gsd-core/workflows/check-todos.md +2 -2
  92. package/gsd-core/workflows/code-review-fix.md +14 -6
  93. package/gsd-core/workflows/code-review.md +93 -21
  94. package/gsd-core/workflows/debug.md +10 -2
  95. package/gsd-core/workflows/diagnose-issues.md +4 -0
  96. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  97. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  98. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  99. package/gsd-core/workflows/discuss-phase.md +2 -2
  100. package/gsd-core/workflows/docs-update.md +8 -0
  101. package/gsd-core/workflows/eval-review.md +1 -1
  102. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  103. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  104. package/gsd-core/workflows/execute-phase.md +85 -115
  105. package/gsd-core/workflows/execute-plan.md +5 -4
  106. package/gsd-core/workflows/explore.md +4 -0
  107. package/gsd-core/workflows/extract-learnings.md +21 -0
  108. package/gsd-core/workflows/help/modes/full.md +3 -3
  109. package/gsd-core/workflows/import.md +4 -1
  110. package/gsd-core/workflows/ingest-docs.md +4 -0
  111. package/gsd-core/workflows/map-codebase.md +13 -6
  112. package/gsd-core/workflows/new-milestone.md +10 -2
  113. package/gsd-core/workflows/new-project.md +11 -4
  114. package/gsd-core/workflows/next.md +5 -2
  115. package/gsd-core/workflows/plan-phase.md +42 -46
  116. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  117. package/gsd-core/workflows/progress.md +1 -1
  118. package/gsd-core/workflows/quick.md +14 -3
  119. package/gsd-core/workflows/review.md +146 -575
  120. package/gsd-core/workflows/scan.md +9 -1
  121. package/gsd-core/workflows/secure-phase.md +10 -2
  122. package/gsd-core/workflows/ship.md +41 -11
  123. package/gsd-core/workflows/smart-entry.md +1 -1
  124. package/gsd-core/workflows/ui-phase.md +8 -1
  125. package/gsd-core/workflows/ui-review.md +8 -1
  126. package/gsd-core/workflows/update.md +104 -5
  127. package/gsd-core/workflows/validate-phase.md +10 -2
  128. package/gsd-core/workflows/verify-work.md +8 -1
  129. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  130. package/hooks/dist/gsd-cursor-stop.js +6 -2
  131. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  132. package/hooks/dist/gsd-graphify-update.sh +9 -0
  133. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  134. package/hooks/dist/gsd-prompt-guard.js +101 -2
  135. package/hooks/dist/gsd-read-guard.js +100 -2
  136. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  137. package/hooks/dist/gsd-statusline.js +9 -6
  138. package/hooks/dist/gsd-workflow-guard.js +110 -6
  139. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  140. package/hooks/dist/lib/cursor-workspace.js +74 -0
  141. package/hooks/gsd-cursor-session-start.js +6 -2
  142. package/hooks/gsd-cursor-stop.js +6 -2
  143. package/hooks/gsd-cursor-subagent-start.js +6 -2
  144. package/hooks/gsd-graphify-update.sh +9 -0
  145. package/hooks/gsd-phase-boundary.sh +14 -2
  146. package/hooks/gsd-prompt-guard.js +101 -2
  147. package/hooks/gsd-read-guard.js +100 -2
  148. package/hooks/gsd-read-injection-scanner.js +109 -2
  149. package/hooks/gsd-statusline.js +9 -6
  150. package/hooks/gsd-workflow-guard.js +110 -6
  151. package/hooks/gsd-worktree-path-guard.js +132 -8
  152. package/hooks/lib/cursor-workspace.js +74 -0
  153. package/package.json +7 -7
  154. package/pi/gsd.cjs +26 -1
  155. package/scripts/check-coverage-gate.cjs +51 -0
  156. package/scripts/check-glossary-refs.cjs +24 -0
  157. package/scripts/ci-test-scope.cjs +67 -17
  158. package/scripts/gen-adr-index.cjs +6 -4
  159. package/scripts/gen-capability-matrix.cjs +26 -2
  160. package/scripts/gen-capability-registry.cjs +132 -34
  161. package/scripts/gen-emitted-baseline.cjs +145 -0
  162. package/scripts/gen-registry.cjs +39 -15
  163. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  164. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  165. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  166. package/scripts/lint-resolution-provenance.cjs +9 -0
  167. package/scripts/mutation-matrix.cjs +4 -0
  168. package/scripts/prompt-injection-scan.sh +6 -0
  169. package/scripts/registry-schema.cjs +372 -94
  170. package/scripts/release-notes/conventional-title.cjs +19 -1
  171. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  172. package/scripts/validate-registry.cjs +10 -6
  173. package/scripts/workflow-size.cjs +16 -8
  174. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  175. package/vscode/package.json +1 -1
  176. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  177. package/scripts/update-size-baseline.cjs +0 -68
@@ -66,6 +66,70 @@ const PHASE_CONTINUATION_SEGMENT_PREFIX_RE = new RegExp(`^${PHASE_CONTINUATION_S
66
66
  function isPhaseContinuationSegment(seg) {
67
67
  return PHASE_CONTINUATION_SEGMENT_PREFIX_RE.test(seg);
68
68
  }
69
+ // #612 (PR-1): bracket-convention token/heading sources, kept next to the M-NN
70
+ // PHASE_NUMBER_TOKEN_SOURCE so this owner file stays the single origin of every
71
+ // phase-token grammar. `src/phase-id.cts` is exempt from the #2128 drift guard
72
+ // (scripts/lint-phase-id-drift.cjs) by construction, and that guard fails any
73
+ // literal re-derivation of the token grammar elsewhere — so the downstream
74
+ // bracket readers (PR-2: roadmap/validate/verify) must build their regexes by
75
+ // interpolating these exports, never by copying the literal.
76
+ //
77
+ // The canonical numeric WIDTH of a bracket identity field, mirroring pad2()'s
78
+ // output: exactly 2 digits, or 3+ with no leading zero. Owned here as a SOURCE
79
+ // so the read side (BRACKET_PHASE_TOKEN_SOURCE, below) and the emit-side
80
+ // validator (CANONICAL_NUMERIC_RE, which toDir enforces) are one rule rather
81
+ // than two literals that agree today and drift tomorrow.
82
+ const BRACKET_CANONICAL_NUMERIC_SOURCE = '(?:[1-9]\\d{2,}|\\d{2})';
83
+ // BRACKET_PHASE_TOKEN_SOURCE differs from PHASE_NUMBER_TOKEN_SOURCE by a
84
+ // dot-OR-dash sub-separator: a bracket dir/heading numeric run is `MM-PP[.SS]`
85
+ // (a hyphen joins milestone↔phase, a dot joins phase↔sub-phase), whereas M-NN
86
+ // sub-phases are dot-only.
87
+ //
88
+ // The run is POSITIONAL, not a free repetition — `MM-PP[.SS][-LL]` — and each
89
+ // position gets the width its DELIMITER can actually afford:
90
+ //
91
+ // MM leading unbounded — delimited by the `{CODE}.` prefix
92
+ // -PP dash-1 canonical — the grammar REQUIRES this dash, so it is a field
93
+ // separator, not a continuation heuristic
94
+ // .SS dot canonical — a slug carries no dot (toDir sanitizes them
95
+ // away), so this position cannot collide
96
+ // -LL dash-2 #2232 cap — the ONLY slug-adjacent position, and therefore
97
+ // the only one a slug word can collide with
98
+ //
99
+ // #2232 reconciliation: the slug-adjacent position interpolates the single-owner
100
+ // PHASE_CONTINUATION_SEGMENT_SOURCE, so the #2232 bug class cannot reopen on the
101
+ // bracket path — dir `PROJ.01-14-2026-photos-…` (a slug leading with a year)
102
+ // yields `01-14`, never `01-14-2026`.
103
+ //
104
+ // DELIBERATE DIVERGENCE from the M-NN dir-token path (pinned by the parity gate
105
+ // in tests/continuation-grammar-parity.test.cjs, which fails if these two rules
106
+ // drift for a reason nobody intended): the non-slug-adjacent positions stay
107
+ // WIDER than #2232's cap. Bracket admits 3+-digit milestone/phase/sub-phase
108
+ // (CANONICAL_NUMERIC_RE — `[GSD.100] 05` is a pinned regression), and unlike the
109
+ // M-NN continuations those positions are delimiter-disambiguated rather than
110
+ // heuristically recognized, so there is no year collision to defend against.
111
+ // Interpolating the cap verbatim at every position would only under-collect ids
112
+ // that toDir itself emits: `PROJ.02-105-slug` (3-digit phase) would read as
113
+ // `02`, and `[GSD.02] 05.100` (3-digit sub-phase) as `05`. Upstream draws this
114
+ // same line for the same reason — core-utils/phase cap the paired PLAN component
115
+ // while the leading phase component stays unbounded (phase numbers ≥100 are
116
+ // legitimate). The trade-off this accepts is #2232's policy verbatim: a PLAN
117
+ // ≥100 is out of the token grammar.
118
+ //
119
+ // Still deliberately MORE PERMISSIVE than parsePhaseId's strict grammar (it
120
+ // admits a letter-suffixed and unpadded leading token that the parser rejects):
121
+ // this is a READ-TOLERANCE source for the PR-2 readers, which must recognize a
122
+ // bracket-shaped token before deciding what to do with it — it is not the
123
+ // emit/identity grammar. parsePhaseId stays the arbiter of well-formedness.
124
+ const BRACKET_PHASE_TOKEN_SOURCE = `\\d+[A-Z]?` +
125
+ `(?:-${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
126
+ `(?:\\.${BRACKET_CANONICAL_NUMERIC_SOURCE}(?!\\d))?` +
127
+ `(?:-${PHASE_CONTINUATION_SEGMENT_SOURCE})?`;
128
+ // A phase HEADING intro under bracket is either a `[...]` bracket (optionally
129
+ // followed by a `Phase ` label) or a bare `Phase ` label; a bare number is NOT
130
+ // a phase-heading intro. The `[^\]]{1,200}` bound mirrors the existing
131
+ // roadmap-parser heading regexes (ReDoS-safe: a header is one short line).
132
+ const PHASE_HEADING_PREFIX_SRC = '(?:\\[[^\\]]{1,200}\\]\\s*(?:Phase\\s+)?|Phase\\s+)';
69
133
  function stripProjectCodePrefix(value, caseInsensitive = true) {
70
134
  const input = String(value);
71
135
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -98,7 +162,23 @@ function normalizePhaseName(phase) {
98
162
  // Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
99
163
  return str;
100
164
  }
101
- function getMilestoneFromPhaseId(phaseId) {
165
+ function getMilestoneFromPhaseId(phaseId, convention) {
166
+ // READING-B (#612): under the bracket convention the milestone comes from the
167
+ // `[PROJECT.MM]` / `{CODE}.{MM}-` prefix, never the phase-token leading
168
+ // integer (ADR-612 Decision 6). Gated on 'bracket' so the `null` and
169
+ // 'milestone-prefixed' (M-NN) paths keep the legacy leading-int rule
170
+ // (READING-A) below, byte-untouched. The optional parameter keeps this helper
171
+ // pure (no config read) and backward-compatible: every existing single-arg
172
+ // caller resolves to the unchanged READING-A body.
173
+ if (convention === 'bracket') {
174
+ const b = String(phaseId).match(/^([A-Z][A-Z0-9_]*)\.(\d+)/);
175
+ if (!b)
176
+ return null;
177
+ const mm = parseInt(b[2], 10);
178
+ if (SENTINEL_RANGES.includes(mm))
179
+ return null; // sentinel milestones have no real milestone
180
+ return `v${mm}.0`;
181
+ }
102
182
  const stripped = stripProjectCodePrefix(phaseId);
103
183
  const m = stripped.match(/^0*(\d+)-\d/);
104
184
  if (!m)
@@ -123,6 +203,145 @@ function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
123
203
  const base = parts.join('-');
124
204
  return projectCode ? `${projectCode}-${base}` : base;
125
205
  }
206
+ const pad2 = (n) => String(parseInt(n, 10)).padStart(2, '0');
207
+ function parsePhaseId(input) {
208
+ // No .trim(): the match anchors (`^`...`$`) then reject leading/trailing
209
+ // whitespace outright, folding that case into the same "not a bracket
210
+ // phase id" rejection below rather than needing its own check.
211
+ const str = String(input);
212
+ // Display form: [PROJECT.MM] PP[.SS][-LL]. The match itself stays
213
+ // permissive on purpose (it will happily match an unpadded number or a
214
+ // multi-space run) — canonicality is enforced UNIFORMLY below via the
215
+ // render round-trip (ADR-612 Decision 4) rather than by hand-tuning every
216
+ // numeric / whitespace sub-pattern, so a field added later inherits the
217
+ // check for free instead of needing its own regex micro-surgery.
218
+ const disp = str.match(/^\[([A-Z][A-Z0-9_]*)\.(\d+)\]\s+(\d+)(?:\.(\d+))?(?:-(\d+))?$/);
219
+ if (disp) {
220
+ const id = { project: disp[1], milestone: pad2(disp[2]), phase: pad2(disp[3]) };
221
+ if (disp[4] !== undefined)
222
+ id.subphase = pad2(disp[4]);
223
+ if (disp[5] !== undefined)
224
+ id.plan = pad2(disp[5]);
225
+ // Canonicality by construction: re-render the parsed id and require
226
+ // byte-equality with the input. This rejects unpadded ('[GSD.5] 5'),
227
+ // over-padded ('[GSD.005] 05'), and multi-space-separated ('[GSD.02] 05')
228
+ // variants uniformly, without special-casing any one of them — the emit
229
+ // path (renderPhaseId) is the single source of truth for "canonical".
230
+ if (renderPhaseId(id) !== str) {
231
+ throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`);
232
+ }
233
+ return id;
234
+ }
235
+ // Dir / token form: {PROJECT}.{MM}-{PP}[.{SS}][-{plan|slug}]
236
+ const dir = str.match(/^([A-Z][A-Z0-9_]*)\.(\d+)-(\d+)(?:\.(\d+))?(?:-(.+))?$/);
237
+ if (dir) {
238
+ const id = { project: dir[1], milestone: pad2(dir[2]), phase: pad2(dir[3]) };
239
+ if (dir[4] !== undefined)
240
+ id.subphase = pad2(dir[4]);
241
+ // Trailing segment: a pure-integer tail is the plan; anything else is a
242
+ // slug (dropped from the tuple — it is not an identity dimension). The
243
+ // plan tail participates in the canonicality check below; the slug tail
244
+ // is read-tolerant pass-through (a slug is not an identity dimension) and
245
+ // is exempt from it.
246
+ const tail = dir[5];
247
+ const tailIsPlan = tail !== undefined && /^\d+$/.test(tail);
248
+ if (tailIsPlan)
249
+ id.plan = pad2(tail);
250
+ // Canonicality by construction, mirroring the display branch: rebuild the
251
+ // exact dir/token string this id would emit and require it match the
252
+ // input verbatim. Rejects unpadded milestone/phase ('GSD.2-5') and
253
+ // unpadded plan tails ('GSD.02-05-1') without special-casing either.
254
+ const sub = id.subphase ? `.${id.subphase}` : '';
255
+ const tailOut = tail === undefined ? '' : tailIsPlan ? `-${pad2(tail)}` : `-${tail}`;
256
+ const canonical = `${id.project}.${id.milestone}-${id.phase}${sub}${tailOut}`;
257
+ if (canonical !== str) {
258
+ throw new Error(`parsePhaseId: not canonical: ${JSON.stringify(input)}`);
259
+ }
260
+ return id;
261
+ }
262
+ // Ambiguous / bare tokens (e.g. `02-04`, `05`, `2-01`) match neither branch,
263
+ // as does a display/dir form carrying leading/trailing whitespace (the
264
+ // anchors never match it): reject rather than guess a tuple (ADR-612
265
+ // conservative default). The rejection lives ONLY in this new parser —
266
+ // normalizePhaseName and every other legacy reader keep accepting those
267
+ // tokens unchanged.
268
+ throw new Error(`parsePhaseId: not a bracket phase id: ${JSON.stringify(input)}`);
269
+ }
270
+ function renderPhaseId(id) {
271
+ const sub = id.subphase ? `.${id.subphase}` : '';
272
+ const plan = id.plan ? `-${id.plan}` : '';
273
+ return `[${id.project}.${id.milestone}] ${id.phase}${sub}${plan}`;
274
+ }
275
+ // PhaseId is a structural type: nothing forces a caller through parsePhaseId,
276
+ // so toDir cannot trust project/milestone/phase/subphase are already
277
+ // canonical — each is validated below against the exact shape parsePhaseId
278
+ // itself would ever produce, closing off a hand-built id as a path-traversal
279
+ // vector. PROJECT_ID_RE mirrors the parser's `[A-Z][A-Z0-9_]*` grammar;
280
+ // CANONICAL_NUMERIC_RE mirrors pad2()'s output shape — exactly 2 digits, or
281
+ // 3+ digits with no leading zero. It is BUILT from
282
+ // BRACKET_CANONICAL_NUMERIC_SOURCE rather than re-spelled as a literal, so this
283
+ // emit-side gate and the read-side token source cannot disagree about what
284
+ // "canonical width" means (the anchors here make the source's trailing `(?!\d)`
285
+ // guard, which the unanchored read side needs, redundant).
286
+ const PROJECT_ID_RE = /^[A-Z][A-Z0-9_]*$/;
287
+ const CANONICAL_NUMERIC_RE = new RegExp(`^${BRACKET_CANONICAL_NUMERIC_SOURCE}$`);
288
+ function toDir(id, slug) {
289
+ if (!PROJECT_ID_RE.test(id.project)) {
290
+ throw new Error(`toDir: invalid project: ${JSON.stringify(id.project)}`);
291
+ }
292
+ if (!CANONICAL_NUMERIC_RE.test(id.milestone)) {
293
+ throw new Error(`toDir: invalid milestone: ${JSON.stringify(id.milestone)}`);
294
+ }
295
+ if (!CANONICAL_NUMERIC_RE.test(id.phase)) {
296
+ throw new Error(`toDir: invalid phase: ${JSON.stringify(id.phase)}`);
297
+ }
298
+ if (id.subphase !== undefined && !CANONICAL_NUMERIC_RE.test(id.subphase)) {
299
+ throw new Error(`toDir: invalid subphase: ${JSON.stringify(id.subphase)}`);
300
+ }
301
+ // A non-string slug (e.g. an omitted second argument) must not be silently
302
+ // coerced by String(...) into the literal token 'undefined'/'null' on disk.
303
+ if (typeof slug !== 'string') {
304
+ throw new Error(`toDir: slug must be a string: ${JSON.stringify(slug)}`);
305
+ }
306
+ const sub = id.subphase ? `.${id.subphase}` : '';
307
+ // Slug guard: the slug becomes an on-disk path segment, so collapse it to a
308
+ // safe lowercase token — never a path separator or `..` traversal.
309
+ const safeSlug = slug.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
310
+ // A slug that sanitizes to nothing (e.g. '!!!') would otherwise emit a
311
+ // dangling trailing hyphen.
312
+ if (!safeSlug) {
313
+ throw new Error(`toDir: slug sanitizes to empty: ${JSON.stringify(slug)}`);
314
+ }
315
+ // An all-digit slug (e.g. '2026') is string-indistinguishable from the
316
+ // parsePhaseId dir branch's plan tail, so it would re-parse as a plan, not
317
+ // a slug — silently breaking the disk↔identity bijection on read-back.
318
+ if (/^\d+$/.test(safeSlug)) {
319
+ throw new Error(`toDir: slug must not be all-digit: ${JSON.stringify(slug)}`);
320
+ }
321
+ return `${id.project}.${id.milestone}-${id.phase}${sub}-${safeSlug}`;
322
+ }
323
+ // Milestone integers reserved as non-milestone sentinels (0.x backlog / 999.x
324
+ // icebox); a phase id in these ranges has no real milestone.
325
+ const SENTINEL_RANGES = Object.freeze([0, 999]);
326
+ function isSentinelPhaseId(phaseId, convention) {
327
+ const s = String(phaseId);
328
+ // Bracket milestone lives in the `{CODE}.{MM}` prefix. GATED on
329
+ // convention === 'bracket' for the same reason as extractPhaseToken below and
330
+ // getMilestoneFromPhaseId above: that prefix is string-indistinguishable from
331
+ // the legacy #1324 letter-prefixed-decimal family (`P0.0-foundation` is a real
332
+ // phase, NOT sentinel milestone 0) whenever the code ends in a digit. A
333
+ // convention-less caller uses the legacy/bare leading-int rule below, so no
334
+ // existing reader gains a false positive; the bracket reading is opt-in.
335
+ if (convention === 'bracket') {
336
+ const bracket = s.match(/^[A-Z][A-Z0-9_]*\.(\d+)/); // bracket: milestone in the prefix
337
+ if (bracket)
338
+ return SENTINEL_RANGES.includes(parseInt(bracket[1], 10));
339
+ }
340
+ const legacy = stripProjectCodePrefix(s).match(/^0*(\d+)/); // legacy/bare: leading int
341
+ if (!legacy)
342
+ return false;
343
+ return SENTINEL_RANGES.includes(parseInt(legacy[1], 10));
344
+ }
126
345
  /**
127
346
  * Render a regex source fragment matching a phase number against ROADMAP/STATE
128
347
  * prose regardless of zero-padding on either side.
@@ -219,7 +438,25 @@ function comparePhaseNum(a, b) {
219
438
  /**
220
439
  * Extract the phase token from a directory name.
221
440
  */
222
- function extractPhaseToken(dirName) {
441
+ function extractPhaseToken(dirName, convention) {
442
+ // #612 bracket dir form `{CODE}.{MM}-{PP}[.{SS}]-slug` → phase token `PP[.SS]`.
443
+ // GATED on convention === 'bracket' (mirrors getMilestoneFromPhaseId's READING-B
444
+ // decision above). A bracket dir `{CODE}.{MM}-{PP}` is string-INDISTINGUISHABLE
445
+ // from the legacy #2043/#1324 letter-prefixed-decimal family (`P0.3-2`,
446
+ // `P0.12-34`) whenever the project code ends in a digit, so NO string-only
447
+ // discriminator can separate the two conventions — auto-detecting here silently
448
+ // reinterpreted `P0.3-2` → `2` (was `P0.3-2`), a byte-identical-read regression
449
+ // on this CRITICAL 6-caller helper (ADR-2121). Requiring an explicit convention
450
+ // signal keeps every existing (convention-less) call site byte-identical to
451
+ // prior behaviour — see the #2043 numeric-tail characterization in
452
+ // tests/phase-id.test.cjs — while keeping the helper pure (optional param, no
453
+ // config read). The captured token is dot-only (`PP[.SS]`); the milestone↔phase
454
+ // hyphen and any trailing plan/slug are excluded.
455
+ if (convention === 'bracket') {
456
+ const bracketDir = dirName.match(/^[A-Z][A-Z0-9_]*\.\d+-(\d+(?:\.\d+)?)/);
457
+ if (bracketDir)
458
+ return bracketDir[1];
459
+ }
223
460
  const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
224
461
  let prefix = '';
225
462
  let rest = dirName;
@@ -310,9 +547,38 @@ function parsePhaseFromProse(value) {
310
547
  // cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
311
548
  // name is far shorter than the cap.
312
549
  const parenName = str.match(/\(([^)]{1,200})\)/);
313
- const dashName = str.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
314
- const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
315
- const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
550
+ // #2736 (the #1695 AC #3 residual): status-keyword-aware precedence. The
551
+ // first-party writer shapes are `N — Name (aside)` (completePhaseCore),
552
+ // `N (Name) — EXECUTING` (beginPhaseCore), `N — COMPLETE`, and the
553
+ // gsd2-import `N (slug) — Milestone: Title`. A blind paren-first read
554
+ // harvests the aside as the name on the first shape; a blind dash-first
555
+ // read harvests the status keyword on the others. Prefer the em-dash name
556
+ // when it is a genuine name, else fall back to the parenthetical. Still
557
+ // lossy for names that themselves contain a parenthetical — transitions
558
+ // that hold the exact name bypass this parser entirely via the
559
+ // syncStateFrontmatter authoritative override.
560
+ //
561
+ // The em-dash separator is searched on a paren-stripped copy, so an em-dash
562
+ // INSIDE a parenthetical name (`16 (Native — Global Hotkey) — EXECUTING`)
563
+ // can never be mistaken for the name separator.
564
+ const strNoParens = str.replace(/\([^)\n]{0,200}\)/g, ' ');
565
+ const dashName = strNoParens.match(/—\s*([^(\n]{1,200}?)\s*$/);
566
+ // The precedence-decision vocabulary is deliberately broader than the final
567
+ // name-nulling filter below: a dash tail that merely LOOKS like a status
568
+ // annotation should lose to a parenthetical name, without changing which
569
+ // extracted names are nulled (that set stays the long-standing three).
570
+ const STATUS_WORD_RE = /^(?:complete|executing|not started)$/i;
571
+ const STATUSY_TAIL_RE = /^(?:completed?|executing|not started|planning|planned|ready(?:\s+to\s+\S.{0,50})?|done|in progress|blocked|paused|verifying)$/i;
572
+ const dashRaw = dashName?.[1]?.trim() ?? null;
573
+ const dashIsName = dashRaw !== null && dashRaw.length > 0
574
+ && !STATUSY_TAIL_RE.test(dashRaw)
575
+ && !/^milestone\s*:/i.test(dashRaw)
576
+ // A lone ALL-CAPS token after the dash reads as a status marker whenever a
577
+ // parenthetical name exists to prefer (the beginPhase writer's systematic
578
+ // `(Name) — STATUS` shape); with no parenthetical it stays the best guess.
579
+ && !(parenName && /^[A-Z][A-Z0-9_-]*$/.test(dashRaw));
580
+ const rawName = dashIsName ? dashRaw : (parenName?.[1] ?? dashRaw ?? null);
581
+ const name = rawName && !STATUS_WORD_RE.test(rawName.trim())
316
582
  ? rawName.trim()
317
583
  : null;
318
584
  return {
@@ -380,10 +646,17 @@ module.exports = {
380
646
  PHASE_NUMBER_TOKEN_SOURCE,
381
647
  PHASE_CONTINUATION_SEGMENT_SOURCE,
382
648
  isPhaseContinuationSegment,
649
+ BRACKET_PHASE_TOKEN_SOURCE,
650
+ PHASE_HEADING_PREFIX_SRC,
383
651
  stripProjectCodePrefix,
384
652
  normalizePhaseName,
385
653
  getMilestoneFromPhaseId,
386
654
  getPhaseDirFromPhaseId,
655
+ parsePhaseId,
656
+ renderPhaseId,
657
+ toDir,
658
+ SENTINEL_RANGES,
659
+ isSentinelPhaseId,
387
660
  phaseMarkdownRegexSource,
388
661
  phaseMarkdownRegexSourceExact,
389
662
  comparePhaseNum,
@@ -56,6 +56,11 @@ const uatPredicate = require("./uat-predicate.cjs");
56
56
  const { evaluateUatPassed } = uatPredicate;
57
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
58
58
  const verificationMod = require("./verification.cjs");
59
+ // #2572: the artifact↔disk core behind the `verify-summary` verb. `verify.cts`
60
+ // has no transitive import path back to `phase.cts`, so this edge introduces no
61
+ // cycle (the reverse edge, `state.cts → verify.cjs`, would).
62
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- verify.cjs is an export= CommonJS module
63
+ const verifyMod = require("./verify.cjs");
59
64
  const { readVerificationStatus } = verificationMod;
60
65
  const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } = planningWorkspace;
61
66
  const { extractFrontmatter } = frontmatterMod;
@@ -512,7 +517,8 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
512
517
  const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
513
518
  const planPath = node_path_1.default.join(phaseDir, planFile);
514
519
  const content = node_fs_1.default.readFileSync(planPath, 'utf-8');
515
- const fm = extractFrontmatter(content);
520
+ // Pass planPath so a truncated PLAN.md names the file in the #1882 diagnostic.
521
+ const fm = extractFrontmatter(content, planPath);
516
522
  const xmlTasks = content.match(/<task[\s>]/gi) || [];
517
523
  const mdTasks = content.match(/##\s*Task\s*\d+/gi) || [];
518
524
  const taskCount = xmlTasks.length || mdTasks.length;
@@ -1402,13 +1408,14 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1402
1408
  warnings.push(`${file}: has diagnosed gaps`);
1403
1409
  }
1404
1410
  for (const file of phaseFiles.filter((f) => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
1405
- const content = node_fs_1.default.readFileSync(node_path_1.default.join(phaseFullDir, file), 'utf-8');
1411
+ const verificationFilePath = node_path_1.default.join(phaseFullDir, file);
1412
+ const content = node_fs_1.default.readFileSync(verificationFilePath, 'utf-8');
1406
1413
  // #1159 (Defect A): read ONLY the frontmatter `status` key to avoid false positives
1407
1414
  // from historical metadata in the file body (e.g. `previous_status: gaps_found`).
1408
1415
  // A full-text regex like /status: gaps_found/ matches the substring inside
1409
1416
  // `previous_status: gaps_found`, producing spurious warnings even when the
1410
1417
  // current frontmatter status is `passed`.
1411
- const verFm = extractFrontmatter(content);
1418
+ const verFm = extractFrontmatter(content, verificationFilePath);
1412
1419
  // Normalise to lower-case so `status: Passed` (title-case) is not missed.
1413
1420
  const verStatus = typeof verFm['status'] === 'string' ? verFm['status'].trim().toLowerCase() : '';
1414
1421
  if (verStatus === 'human_needed')
@@ -1424,11 +1431,51 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1424
1431
  * mechanism). A readdirSync/readFileSync failure here just means fewer
1425
1432
  * warnings are surfaced this run, not a blocked or corrupted completion. */
1426
1433
  }
1434
+ // #2572: artifact↔disk advisory for the SUMMARYs of the phase being completed.
1435
+ //
1436
+ // A SUMMARY asserts "I created these files". Nothing checked that claim for
1437
+ // phase summaries — the `verify-summary` verb has existed since the beginning
1438
+ // but was only ever pointed at `.planning/research/SUMMARY.md`. An interrupted
1439
+ // or over-reported phase therefore counted toward 100% silently.
1440
+ //
1441
+ // Joins the same ADVISORY channel as the pre-scan above: findings land in
1442
+ // `warnings[]` (rendered by execute-phase.md's "If has_warnings is true"
1443
+ // step), never in the completion GATE (readVerificationStatus below).
1444
+ // Completion is never blocked.
1445
+ //
1446
+ // `checkCommits: false` — only the file-existence half is surfaced here, so
1447
+ // the `git cat-file` probes would be spawned and their result discarded. The
1448
+ // hash pattern is a loose `\b[0-9a-f]{7,40}\b` that matches any hex-shaped
1449
+ // token in prose, too noisy to put in front of a user even as a warning.
1450
+ //
1451
+ // `Infinity` — report every referenced file, not the CLI verb's default first
1452
+ // two, so a phase that lists twelve files and landed three says so. The verb
1453
+ // keeps its 2-file default; only this caller opts out of the cap.
1454
+ try {
1455
+ const phaseDirRel = phaseInfo['directory'];
1456
+ // `summaries` arrives pre-sorted from the phase locator, so warning order is
1457
+ // deterministic across platforms rather than readdir-dependent.
1458
+ const summaryNames = phaseInfo['summaries'] || [];
1459
+ for (const summaryName of summaryNames) {
1460
+ const v = verifyMod.verifySummaryCore(cwd, `${phaseDirRel}/${summaryName}`, Infinity, { checkCommits: false });
1461
+ const missing = v.checks.files_created.missing;
1462
+ if (missing.length > 0) {
1463
+ warnings.push(`${summaryName}: references ${missing.length} file(s) not on disk: ${missing.join(', ')}`);
1464
+ }
1465
+ }
1466
+ }
1467
+ catch {
1468
+ /* best-effort, same posture as the #2245 pre-scan above: an unreadable
1469
+ * SUMMARY means one fewer advisory this run, never a blocked completion. */
1470
+ }
1427
1471
  let nextPhaseNum = null;
1428
1472
  let nextPhaseName = null;
1429
1473
  let isLastPhase = true;
1430
1474
  const verificationBlocked = withPlanningLock(cwd, () => {
1431
- const verificationStatus = readVerificationStatus(phaseFullDir);
1475
+ // #2617: pass the project's runtime so the blocked-completion error below
1476
+ // suggests the command surface this runtime actually installs
1477
+ // ($gsd-… on Codex) rather than a hard-coded Claude-style string.
1478
+ const verificationStatus = readVerificationStatus(phaseFullDir, { runtime: (0, runtime_slash_cjs_1.resolveRuntime)(cwd) });
1432
1479
  if (verificationStatus.status !== 'passed') {
1433
1480
  return verificationStatus;
1434
1481
  }
@@ -1651,7 +1698,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1651
1698
  // requirement's write. The "only flip Pending/In Progress ->
1652
1699
  // Complete" gate is folded into the newValue callback so one
1653
1700
  // updateTableCell call both probes and writes.
1654
- const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) => /^(?:pending|in progress)$/i.test(current.trim()) ? ' Complete ' : current);
1701
+ const reqUpdate = updateTraceabilityCell(reqContent, reqRowMatch, 'Status', (current) =>
1702
+ // #2788: accept `Gaps Found` too so a phase stranded by revert-phase (the
1703
+ // gaps_found response) can complete without hand-editing the table.
1704
+ /^(?:pending|in progress|gaps found)$/i.test(current.trim()) ? ' Complete ' : current);
1655
1705
  if (reqUpdate.ok) {
1656
1706
  reqContent = reqUpdate.value;
1657
1707
  }
@@ -1991,10 +2041,15 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1991
2041
  clock: clock_cjs_1.realClock,
1992
2042
  progressProvider: () => null, // completePhase derives progress from the roadmap, not disk
1993
2043
  roadmapProvider: () => roadmapContent,
2044
+ sourcePath: statePath,
1994
2045
  });
1995
2046
  stateContent = completeResult.content;
1996
2047
  stateContent = updatePerformanceMetricsSection(stateContent, cwd, phaseNum, planCount, summaryCount);
1997
- stateContent = syncStateFrontmatter(stateContent, cwd);
2048
+ // #2736: the transition holds the next phase's exact display name in
2049
+ // the intent; pass it as authoritative so the sync's prose
2050
+ // re-derivation cannot rewrite current_phase_name to the name's own
2051
+ // parenthetical (`Closer-ruling measurement (D1a)` → `D1a`).
2052
+ stateContent = syncStateFrontmatter(stateContent, cwd, nextPhaseDisplayName ? { current_phase_name: nextPhaseDisplayName } : undefined);
1998
2053
  writes.push({ filePath: statePath, before: originalStateContent, after: stateContent });
1999
2054
  }
2000
2055
  writePlanningFileSet(writes);
@@ -3,7 +3,7 @@
3
3
  * ADR-22 Drift-Guard Decision Module
4
4
  *
5
5
  * Implements the authority ladder and severity classification table from
6
- * ADR-22 (docs/adr/0022-source-grounding-drift-guard.md).
6
+ * ADR-22 (docs/adr/22-plan-drift-guard.md).
7
7
  *
8
8
  * Design constraints:
9
9
  * - Pure module: no I/O, no require() calls, no side effects.
@@ -65,7 +65,7 @@ function isPlanSuperseded(planFullPath) {
65
65
  catch {
66
66
  return false;
67
67
  }
68
- const status = extractFrontmatter(content)['status'];
68
+ const status = extractFrontmatter(content, planFullPath)['status'];
69
69
  return typeof status === 'string' && status.trim().toLowerCase() === 'superseded';
70
70
  }
71
71
  function isRootPlanFile(fileName) {
@@ -369,8 +369,15 @@ function findContextMdIn(absDirOrFiles) {
369
369
  return 'CONTEXT.md';
370
370
  return files.find((f) => f.endsWith('-CONTEXT.md')) ?? null;
371
371
  }
372
- catch {
373
- return null;
372
+ catch (err) {
373
+ // #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
374
+ // ("nothing there") keeps the long-standing null contract the callers rely
375
+ // on; every other error (EACCES, EIO, …) is a real read failure that must
376
+ // propagate — otherwise an unreadable phase dir is silently reported as
377
+ // "no CONTEXT.md" and the discuss/plan gates wrongly skip context.
378
+ if (err.code === 'ENOENT')
379
+ return null;
380
+ throw err;
374
381
  }
375
382
  }
376
383
  module.exports = {
@@ -920,24 +920,50 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
920
920
  '<!-- GSD:profile-end -->',
921
921
  ];
922
922
  const sectionContent = sectionLines.join('\n');
923
+ // #2565: resolve target through effective runtime policy instead of
924
+ // hardcoded .claude/CLAUDE.md. Mirrors the #3163 fix applied to
925
+ // cmdGenerateClaudeMd above; that fix diverged when it didn't propagate
926
+ // here, leaving /gsd-profile-user writing Claude files on Codex installs.
927
+ // - Project scope: getProjectInstructionFile(runtime) is the single source
928
+ // of truth (AGENTS.md for codex/opencode/kilo/kimi/unknown; GEMINI.md for
929
+ // antigravity; .github/copilot-instructions.md for copilot).
930
+ // - Global scope: ~/.<config-home>/<instruction-basename>, derived from
931
+ // getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands
932
+ // at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no
933
+ // env-var drift beyond the prior hardcoded path).
934
+ // - GSD_RUNTIME env var takes precedence over config.runtime (mirrors the
935
+ // #3163 env-precedence contract). Non-claude always wins over a stale
936
+ // claude_md_path (#3163 rationale: an AGENTS-native project must never
937
+ // write to CLAUDE.md even if a prior Claude setup left claude_md_path).
938
+ let config = {};
939
+ try {
940
+ config = loadConfig(cwd);
941
+ }
942
+ catch { /* use defaults */ }
943
+ const effectiveRuntime = (0, runtime_name_policy_cjs_1.resolveRuntimeNameFromCandidates)(process.env['GSD_RUNTIME'], config['runtime']);
944
+ const isClaudeRuntime = !effectiveRuntime || effectiveRuntime === 'claude';
923
945
  let targetPath;
924
946
  if (options.global) {
925
- targetPath = node_path_1.default.join(node_os_1.default.homedir(), '.claude', 'CLAUDE.md');
947
+ if (isClaudeRuntime) {
948
+ targetPath = node_path_1.default.join(node_os_1.default.homedir(), '.claude', 'CLAUDE.md');
949
+ }
950
+ else {
951
+ targetPath = node_path_1.default.join((0, runtime_homes_cjs_1.getGlobalConfigDir)(effectiveRuntime), node_path_1.default.basename((0, runtime_name_policy_cjs_1.getProjectInstructionFile)(effectiveRuntime)));
952
+ }
926
953
  }
927
954
  else if (options.output) {
928
955
  targetPath = node_path_1.default.isAbsolute(options.output) ? options.output : node_path_1.default.join(cwd, options.output);
929
956
  }
930
957
  else {
931
- // Read claude_md_path from config; #1098 default is ./.claude/CLAUDE.md
958
+ // Read claude_md_path from config; #1098 default is .claude/CLAUDE.md
932
959
  // (kept consistent with cmdGenerateClaudeMd so the profile section and the
933
960
  // managed sections land in the same file on a config-less project).
934
- let configClaudeMdPath = './.claude/CLAUDE.md';
935
- try {
936
- const config = loadConfig(cwd);
937
- if (config['claude_md_path'])
938
- configClaudeMdPath = config['claude_md_path'];
961
+ let configClaudeMdPath = '.claude/CLAUDE.md';
962
+ if (config['claude_md_path'])
963
+ configClaudeMdPath = config['claude_md_path'];
964
+ if (!isClaudeRuntime) {
965
+ configClaudeMdPath = (0, runtime_name_policy_cjs_1.getProjectInstructionFile)(effectiveRuntime);
939
966
  }
940
- catch { /* use default */ }
941
967
  targetPath = node_path_1.default.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : node_path_1.default.join(cwd, configClaudeMdPath);
942
968
  }
943
969
  let action;
@@ -63,6 +63,33 @@ function findProjectRoot(startDir) {
63
63
  }
64
64
  return false;
65
65
  }
66
+ // #2843: nearest ancestor (including `from` itself) that contains a `.git`,
67
+ // bounded by `upTo` (exclusive). Returns the git-repo root, or null if none
68
+ // exists before `upTo` / the filesystem root. Used to detect a NESTED child
69
+ // repo whose root is strictly below a candidate ancestor `.planning/` — in
70
+ // that case the caller's repo boundary sits between start and the ancestor,
71
+ // so trusting the ancestor's `.planning/` would silently cross into a
72
+ // different project. (No `git` subprocess — fs walk only, matching
73
+ // isInsideGitRepo's deliberate no-spawn contract.)
74
+ function nearestGitRoot(from, upTo) {
75
+ let d = from;
76
+ while (d !== fsRoot) {
77
+ if (d === upTo)
78
+ break;
79
+ try {
80
+ if (node_fs_1.default.existsSync(d + node_path_1.default.sep + '.git'))
81
+ return d;
82
+ }
83
+ catch {
84
+ // ignore
85
+ }
86
+ const next = node_path_1.default.dirname(d);
87
+ if (next === d)
88
+ break;
89
+ d = next;
90
+ }
91
+ return null;
92
+ }
66
93
  let dir = resolvedStart;
67
94
  let depth = 0;
68
95
  while (dir !== fsRoot && depth < FIND_PROJECT_ROOT_MAX_DEPTH) {
@@ -114,6 +141,18 @@ function findProjectRoot(startDir) {
114
141
  // claims our startDir — explicit sub_repos config takes precedence over the
115
142
  // implicit .git signal. (#1422)
116
143
  if (isInsideGitRepo(parent)) {
144
+ // #2843: do NOT cross a git-repo boundary. If the caller is inside its
145
+ // OWN nested repo whose root is strictly below `parent`, trusting
146
+ // `parent`'s .planning/ would silently resolve to a DIFFERENT project.
147
+ // isInsideGitRepo only proved SOME .git exists between start and parent;
148
+ // verify that .git is parent's own (or absent between), not a nested
149
+ // child repo. If nearestGitRoot finds a .git strictly below parent,
150
+ // the boundary is crossed — fall through (do not return parent).
151
+ if (nearestGitRoot(resolvedStart, parent) !== null) {
152
+ dir = parent;
153
+ depth += 1;
154
+ continue;
155
+ }
117
156
  // Lookahead: walk ancestors above `parent` to find a sub_repos claim.
118
157
  let ancestor = node_path_1.default.dirname(parent);
119
158
  let ancestorDepth = 0;
@@ -169,6 +208,15 @@ function findProjectRoot(startDir) {
169
208
  try {
170
209
  const candidatePlanning = parent2 + node_path_1.default.sep + '.planning';
171
210
  if (node_fs_1.default.existsSync(candidatePlanning) && node_fs_1.default.statSync(candidatePlanning).isDirectory()) {
211
+ // #2843: do not cross a git-repo boundary. If the caller is inside its
212
+ // own nested repo (a .git strictly below parent2), parent2's .planning/
213
+ // belongs to a DIFFERENT project — keep walking is wrong; stop and fall
214
+ // through to the startDir fallback instead of silently resolving to the
215
+ // ancestor project. (Reached only when no .git exists anywhere in the
216
+ // chain per the triage, but guard defensively.)
217
+ if (nearestGitRoot(resolvedStart, parent2) !== null) {
218
+ break;
219
+ }
172
220
  return parent2;
173
221
  }
174
222
  }