@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +107 -34
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +236 -107
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +882 -4
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +36 -9
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +61 -6
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +146 -22
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +93 -21
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +372 -94
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/validate-registry.cjs +10 -6
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- 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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = '
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
}
|