@opengsd/gsd-core 1.7.0-rc.4 → 1.7.0-rc.6

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 (113) 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 +20 -0
  4. package/agents/gsd-doc-classifier.md +105 -0
  5. package/agents/gsd-doc-synthesizer.md +61 -0
  6. package/agents/gsd-ui-checker.md +30 -0
  7. package/agents/gsd-ui-researcher.md +1 -0
  8. package/bin/install.js +1568 -622
  9. package/gsd-core/bin/gsd-tools.cjs +40 -1
  10. package/gsd-core/bin/lib/api-coverage.cjs +466 -0
  11. package/gsd-core/bin/lib/audit.cjs +6 -3
  12. package/gsd-core/bin/lib/capability-loader.cjs +11 -9
  13. package/gsd-core/bin/lib/capability-registry.cjs +761 -84
  14. package/gsd-core/bin/lib/capability-validator.cjs +56 -18
  15. package/gsd-core/bin/lib/capability-writer.cjs +10 -1
  16. package/gsd-core/bin/lib/check-command-router.cjs +242 -3
  17. package/gsd-core/bin/lib/commands.cjs +7 -5
  18. package/gsd-core/bin/lib/config-loader.cjs +1 -0
  19. package/gsd-core/bin/lib/config.cjs +96 -0
  20. package/gsd-core/bin/lib/core-utils.cjs +4 -1
  21. package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
  22. package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +45 -4
  24. package/gsd-core/bin/lib/init.cjs +76 -39
  25. package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
  26. package/gsd-core/bin/lib/install-engine.cjs +228 -18
  27. package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
  28. package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
  29. package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
  30. package/gsd-core/bin/lib/mcp-server.cjs +18 -7
  31. package/gsd-core/bin/lib/milestone.cjs +3 -3
  32. package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +132 -3
  34. package/gsd-core/bin/lib/phase.cjs +78 -16
  35. package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
  36. package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
  37. package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
  39. package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
  40. package/gsd-core/bin/lib/roadmap.cjs +42 -56
  41. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
  42. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
  43. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
  44. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
  45. package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
  46. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
  47. package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
  48. package/gsd-core/bin/lib/security.cjs +6 -36
  49. package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
  50. package/gsd-core/bin/lib/spec-section.cjs +111 -0
  51. package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
  52. package/gsd-core/bin/lib/state-transition.cjs +1 -1
  53. package/gsd-core/bin/lib/state.cjs +24 -24
  54. package/gsd-core/bin/lib/surface.cjs +40 -6
  55. package/gsd-core/bin/lib/uat.cjs +4 -1
  56. package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
  57. package/gsd-core/bin/lib/validate.cjs +15 -6
  58. package/gsd-core/bin/lib/verify.cjs +33 -37
  59. package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
  60. package/gsd-core/bin/shared/model-catalog.json +14 -9
  61. package/gsd-core/references/api-coverage.md +104 -0
  62. package/gsd-core/references/model-profiles.md +2 -2
  63. package/gsd-core/references/planning-config.md +2 -0
  64. package/gsd-core/references/specless-probe-fallback.md +172 -0
  65. package/gsd-core/references/ui-consideration-probe.md +73 -0
  66. package/gsd-core/templates/UI-SPEC.md +25 -0
  67. package/gsd-core/templates/VALIDATION.md +2 -0
  68. package/gsd-core/templates/config.json +2 -1
  69. package/gsd-core/workflows/audit-fix.md +9 -1
  70. package/gsd-core/workflows/audit-milestone.md +7 -4
  71. package/gsd-core/workflows/code-review-fix.md +7 -3
  72. package/gsd-core/workflows/code-review.md +4 -1
  73. package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
  74. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
  75. package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
  76. package/gsd-core/workflows/execute-phase.md +1 -25
  77. package/gsd-core/workflows/plan-phase.md +37 -2
  78. package/gsd-core/workflows/quick.md +2 -2
  79. package/gsd-core/workflows/review.md +59 -13
  80. package/gsd-core/workflows/settings-advanced.md +12 -9
  81. package/gsd-core/workflows/settings.md +2 -2
  82. package/gsd-core/workflows/ui-phase.md +146 -1
  83. package/gsd-core/workflows/validate-phase.md +2 -2
  84. package/gsd-core/workflows/verify-phase.md +3 -2
  85. package/gsd-core/workflows/verify-work.md +38 -0
  86. package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
  87. package/hooks/dist/gsd-cursor-stop.js +48 -0
  88. package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
  89. package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
  90. package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
  91. package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
  92. package/hooks/dist/managed-hooks-registry.cjs +6 -0
  93. package/hooks/gsd-cursor-pre-tool.js +76 -0
  94. package/hooks/gsd-cursor-stop.js +48 -0
  95. package/hooks/gsd-cursor-subagent-start.js +50 -0
  96. package/hooks/gsd-cursor-subagent-stop.js +40 -0
  97. package/hooks/gsd-windsurf-pre-command.js +275 -0
  98. package/hooks/gsd-windsurf-pre-write.js +132 -0
  99. package/hooks/managed-hooks-registry.cjs +6 -0
  100. package/package.json +9 -4
  101. package/pi/gsd.cjs +354 -0
  102. package/scripts/build-hooks.js +8 -1
  103. package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
  104. package/scripts/gen-registry.cjs +128 -0
  105. package/scripts/lint-phase-id-drift.cjs +150 -0
  106. package/scripts/lint-test-file-count.allowlist.json +2 -1
  107. package/scripts/registry-schema.cjs +565 -0
  108. package/scripts/run-tests.cjs +21 -1
  109. package/scripts/validate-registry.cjs +117 -0
  110. package/vscode/browser.js +197 -0
  111. package/vscode/extension.js +383 -0
  112. package/vscode/host-binding.js +113 -0
  113. package/vscode/package.json +96 -0
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ /**
3
+ * Test-command normalizer (#1857).
4
+ *
5
+ * A GSD verification gate resolves a project's test command and runs it. When
6
+ * that command is a watch/dev-mode runner (vitest defaults to WATCH in an
7
+ * interactive TTY — which is exactly a user running `gsd-execute-phase` in their
8
+ * terminal — and jest with `--watch`/`--watchAll`), the process never exits and
9
+ * the orchestrator waits forever.
10
+ *
11
+ * `normalizeTestCommand` rewrites a resolved command to a best-effort one-shot
12
+ * form so a gate cannot hang on watch mode. It is intentionally conservative:
13
+ * - it NEVER double-adds flags (already-one-shot commands are returned verbatim),
14
+ * - it only touches commands it positively recognises as a watch runner,
15
+ * - anything it cannot classify is returned unchanged.
16
+ * The gate's wall-clock `timeout` is the ultimate guarantee for anything this
17
+ * best-effort pass cannot defeat (e.g. an explicit `--watch` baked into a
18
+ * project's `test` script, which even `CI=1` cannot override per vitest docs).
19
+ *
20
+ * Bounded by design (#1857 security review): the input is length-capped, all
21
+ * scanning is linear-time (no super-linear regex backtracking), and package.json
22
+ * is only read when it is a regular file — so normalization itself can never
23
+ * hang or take super-linear time on an adversarial `workflow.test_command`.
24
+ *
25
+ * Single source of truth: all four test-command gates (regression, post-merge,
26
+ * audit-fix, verify-phase) route their resolved command through this helper so
27
+ * the paths cannot drift.
28
+ *
29
+ * Leaf module — depends only on node:fs / node:path.
30
+ */
31
+ var __importDefault = (this && this.__importDefault) || function (mod) {
32
+ return (mod && mod.__esModule) ? mod : { "default": mod };
33
+ };
34
+ const node_fs_1 = __importDefault(require("node:fs"));
35
+ const node_path_1 = __importDefault(require("node:path"));
36
+ // A resolved test command is never realistically this long; anything larger is
37
+ // not a real runner invocation. We skip normalization above this bound (the
38
+ // gate's own timeout still bounds the actual run) so no regex ever scans an
39
+ // adversarial multi-KB string.
40
+ const MAX_COMMAND_LENGTH = 4096;
41
+ // Markers proving a command is already one-shot / non-watch. If any is present
42
+ // we return the command unchanged so we never double-add flags or fight an
43
+ // explicit user choice.
44
+ const ONE_SHOT_MARKERS = [
45
+ /(?:^|\s)vitest\s+run\b/, // vitest run …
46
+ /(?:^|\s)--run\b/, // vitest --run
47
+ /(?:^|\s)--no-watch\b/, // vitest --no-watch
48
+ /(?:^|\s)--watchAll=false\b/, // jest --watchAll=false
49
+ /(?:^|\s)--watch=false\b/,
50
+ /(?:^|\s)--ci\b/, // jest --ci
51
+ /^\s*CI=/, // already forced into CI/run mode via env
52
+ ];
53
+ /** True if the command already runs one-shot (so normalization is a no-op). */
54
+ function isAlreadyOneShot(cmd) {
55
+ return ONE_SHOT_MARKERS.some((re) => re.test(cmd));
56
+ }
57
+ // Match a runner as a standalone command TOKEN (whitespace-delimited), NOT as a
58
+ // substring — so "vitest.config.js" / "jest-environment" / "node vitest-x.js"
59
+ // are not treated as the runner and are never mangled.
60
+ const VITEST_TOKEN = /(?:^|\s)vitest(?=\s|$)/;
61
+ const JEST_TOKEN = /(?:^|\s)jest(?=\s|$)/;
62
+ /**
63
+ * Linear-time detection of a package-manager `test` script invocation
64
+ * (`npm test`, `pnpm run test`, `pnpm --dir app test`, `yarn test`, …). We split
65
+ * on shell separators FIRST (linear), then test each bounded segment with simple
66
+ * anchored regexes — no tempered-greedy scan, so no super-linear backtracking on
67
+ * adversarial input.
68
+ */
69
+ function isScriptInvocation(cmd) {
70
+ return cmd.split(/&&|\|\||;/).some((seg) => {
71
+ const s = seg.trim();
72
+ return /^(?:npm|pnpm|yarn|bun)\b/.test(s) && /\btest\b/.test(s);
73
+ });
74
+ }
75
+ /**
76
+ * Resolve the package.json directory for a command that may target a different
77
+ * working dir via `--dir <p>` (pnpm), `-C <p>` (pnpm), or `--prefix <p>` (npm).
78
+ */
79
+ function resolvePackageDir(cmd, cwd) {
80
+ const m = cmd.match(/(?:--dir|--prefix|-C)[=\s]+(\S+)/);
81
+ if (m && m[1]) {
82
+ const p = m[1].replace(/^['"]|['"]$/g, '');
83
+ return node_path_1.default.isAbsolute(p) ? p : node_path_1.default.join(cwd, p);
84
+ }
85
+ return cwd;
86
+ }
87
+ /**
88
+ * Inspect package.json `scripts.test` for the given command's target dir and
89
+ * report whether it resolves to a watch-by-default / explicitly-watching runner.
90
+ * Only ever reads `scripts.test` as a STRING for classification — it is never
91
+ * executed or spliced into the output.
92
+ */
93
+ function scriptTestRunner(cmd, cwd) {
94
+ try {
95
+ const pkgPath = node_path_1.default.join(resolvePackageDir(cmd, cwd), 'package.json');
96
+ // Only read a REGULAR file — never block on a FIFO/socket/dir named
97
+ // "package.json" reachable via --dir (#1857 security review).
98
+ let stat;
99
+ try {
100
+ stat = node_fs_1.default.statSync(pkgPath);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ if (!stat.isFile())
106
+ return null;
107
+ const pkg = JSON.parse(node_fs_1.default.readFileSync(pkgPath, 'utf-8'));
108
+ const testScript = pkg.scripts?.test ?? '';
109
+ if (!testScript)
110
+ return null;
111
+ // vitest watches by default unless the script itself is already one-shot.
112
+ if (VITEST_TOKEN.test(testScript) && !isAlreadyOneShot(testScript))
113
+ return 'vitest';
114
+ // jest only watches when explicitly asked to.
115
+ if (JEST_TOKEN.test(testScript) &&
116
+ /(?:^|\s)--watch(?:All)?\b/.test(testScript) &&
117
+ !/--watch(?:All)?=false\b/.test(testScript)) {
118
+ return 'jest';
119
+ }
120
+ return null;
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /** Strip a bare/explicit-true `--watch` / `--watchAll` (never `=false`). */
127
+ function stripWatchFlags(cmd) {
128
+ return cmd
129
+ .replace(/(?:^|\s)--watch(?:All)?(?:=true)?(?=\s|$)/g, ' ')
130
+ .replace(/\s{2,}/g, ' ')
131
+ .trim();
132
+ }
133
+ /**
134
+ * Normalize a resolved test command to a best-effort one-shot form.
135
+ *
136
+ * @param rawCmd The resolved test command (e.g. from `config-get
137
+ * workflow.test_command` or the gate's runner sniff).
138
+ * @param cwd Directory used to locate package.json for script invocations.
139
+ * @returns A one-shot command, or `rawCmd` unchanged when it is already
140
+ * one-shot / not a recognised watch runner / too long to be a real runner.
141
+ */
142
+ function normalizeTestCommand(rawCmd, cwd) {
143
+ const cmd = (rawCmd ?? '').trim();
144
+ if (!cmd || cmd === 'true')
145
+ return rawCmd;
146
+ if (cmd.length > MAX_COMMAND_LENGTH)
147
+ return rawCmd; // bound all downstream scanning
148
+ if (isAlreadyOneShot(cmd))
149
+ return rawCmd;
150
+ const isScript = isScriptInvocation(cmd);
151
+ // 1. Direct vitest invocation (`vitest`, `npx vitest`, `pnpm exec vitest`, …):
152
+ // force the explicit one-shot `run` command and drop any bare --watch.
153
+ if (VITEST_TOKEN.test(cmd) && !isScript) {
154
+ return stripWatchFlags(cmd).replace(/(^|\s)vitest(?=\s|$)/, '$1vitest run');
155
+ }
156
+ // 2. Direct jest invocation with an explicit watch flag: make it one-shot.
157
+ if (JEST_TOKEN.test(cmd) && !isScript) {
158
+ if (/(?:^|\s)--watch(?:All)?\b/.test(cmd)) {
159
+ return `${stripWatchFlags(cmd)} --watchAll=false`;
160
+ }
161
+ return rawCmd; // jest without --watch already runs once
162
+ }
163
+ // 3. Package-manager `test` script invocation: the runner is inside
164
+ // package.json. If it resolves to a watch runner, force CI/run mode via
165
+ // the CI env — robust across --dir and pnpm/yarn `--` propagation quirks
166
+ // (vitest & jest both switch to run/non-interactive mode when CI is set).
167
+ if (isScript) {
168
+ const runner = scriptTestRunner(cmd, cwd);
169
+ if (runner === 'vitest' || runner === 'jest') {
170
+ return `CI=true ${cmd}`;
171
+ }
172
+ return rawCmd;
173
+ }
174
+ return rawCmd;
175
+ }
176
+ /**
177
+ * CLI handler for `gsd-tools query normalize-test-command <raw-cmd>`: prints the
178
+ * normalized one-shot command to stdout (the gates capture it via `$(…)`).
179
+ */
180
+ function cmdNormalizeTestCommand(cwd, rawCmd) {
181
+ process.stdout.write(normalizeTestCommand(rawCmd ?? '', cwd));
182
+ }
183
+ module.exports = {
184
+ normalizeTestCommand,
185
+ cmdNormalizeTestCommand,
186
+ isAlreadyOneShot,
187
+ };
@@ -34,9 +34,20 @@ const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
34
34
  // Enumeration/parse call sites that read phase headers from a regex *literal*
35
35
  // (rather than a `new RegExp` built from an interpolated phase number) cannot
36
36
  // reference this constant; they inline its literal-regex mirror instead —
37
- // `(?:\s*\([^)\n]*\))?` — kept character-for-character equivalent to this
37
+ // `(?:\s*\([^)\n]{0,200}\))?` — kept character-for-character equivalent to this
38
38
  // source. Both forms must change together; see the #1729 regression test.
39
- const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]*\\))?';
39
+ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
40
+ // #2128: the canonical phase-NUMBER-TOKEN grammar — a phase number with an
41
+ // optional single-letter variant suffix and optional dotted sub-phases
42
+ // (1, 01, 12A, 12.1, 3.2.1). This is the ENUMERATION/scan counterpart to
43
+ // phaseMarkdownRegexSource: use phaseMarkdownRegexSource(n) to build a source
44
+ // for ONE KNOWN number; reference this constant when a call site must match ANY
45
+ // phase and capture its token. Enumeration/parse sites inline this into a
46
+ // `new RegExp(...)` instead of re-deriving the grammar as a literal, so every
47
+ // phase-token producer shares one owner. The anti-divergence guard
48
+ // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
49
+ // introduced outside this module without a `// phase-id-owner:` justification.
50
+ const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
40
51
  function stripProjectCodePrefix(value, caseInsensitive = true) {
41
52
  const input = String(value);
42
53
  const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
@@ -200,9 +211,32 @@ function extractPhaseToken(dirName) {
200
211
  }
201
212
  const segments = rest.split('-');
202
213
  const tokenSegments = [];
214
+ // #2043: distinguish a real (zero-padded, ≥2-digit) phase/sub-phase segment
215
+ // from a single-digit slug word. A pure-numeric leading segment ("46") only
216
+ // continues with ≥2-digit segments, so "46-6-rs-…" yields "46" (the "6" is the
217
+ // slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here
218
+ // with "M1-" already stripped as a project-code prefix (see
219
+ // PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same
220
+ // pure-numeric rule applies (M1-46-6-rs → "M1-46"). The firstLetterPrefixed
221
+ // carve-out covers letter+digit leading segments that survive prefix stripping
222
+ // because of punctuation (e.g. "P0.3-2"), whose single-digit continuation is
223
+ // intentionally preserved (unchanged from prior behaviour).
224
+ let firstLetterPrefixed = false;
203
225
  for (let i = 0; i < segments.length; i++) {
204
226
  const seg = segments[i];
205
- if (/^\d/.test(seg) || (i === 0 && /^[A-Za-z]{1,3}\d/.test(seg))) {
227
+ if (i === 0) {
228
+ if (/^\d/.test(seg)) {
229
+ tokenSegments.push(seg);
230
+ }
231
+ else if (/^[A-Za-z]{1,3}\d/.test(seg)) {
232
+ tokenSegments.push(seg);
233
+ firstLetterPrefixed = true;
234
+ }
235
+ else {
236
+ break;
237
+ }
238
+ }
239
+ else if (/^\d{2,}/.test(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
206
240
  tokenSegments.push(seg);
207
241
  }
208
242
  else {
@@ -229,10 +263,101 @@ function phaseTokenMatches(dirName, normalized) {
229
263
  }
230
264
  return false;
231
265
  }
266
+ // ─── #2121 canonical surface (ADR-2121) ──────────────────────────────────────
267
+ /**
268
+ * Parse a phase identifier from a STATE.md `Phase:` prose field VALUE — the text
269
+ * after the `Phase:` label (e.g. `"3 of 4 (Delta)"`, `"3A — Delta (executing)"`,
270
+ * or `"Milestone v0.5 complete"`).
271
+ *
272
+ * The token is anchored to the START of the value (after an optional literal
273
+ * `Phase ` label and an optional project-code prefix) so a phase is only
274
+ * returned when the value actually begins with one. This is the #2111 fix: the
275
+ * prior unanchored `/\b(\d+[A-Z]?(?:\.\d+)*)\b/i` mined the first numeral
276
+ * anywhere, so `"Milestone v0.5 complete"` collapsed to `"5"` (the minor-version
277
+ * digit) and `"v1.0"` to `"0"` (a reserved sentinel). Here both yield
278
+ * `{ phase: null }` because they do not begin with a phase token. The name
279
+ * extraction (parenthetical or em-dash tail, minus status words) is unchanged.
280
+ */
281
+ function parsePhaseFromProse(value) {
282
+ if (!value)
283
+ return { phase: null, name: null };
284
+ // Coerce defensively so a non-string caller cannot throw on this canonical
285
+ // surface (mirrors the sibling #2121 functions' String(...) handling).
286
+ const str = String(value);
287
+ const phaseMatch = str.match(/^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i);
288
+ // The name-extraction quantifiers are length-bounded so a crafted long
289
+ // unterminated run (many `(` or `—`) in an untrusted STATE.md field value
290
+ // cannot drive O(n^2) regex backtracking (CPU-exhaustion DoS). A real phase
291
+ // name is far shorter than the cap.
292
+ const parenName = str.match(/\(([^)]{1,200})\)/);
293
+ const dashName = str.match(/—\s*([^(\n]{1,200}?)(?:\s*\(|$)/);
294
+ const rawName = parenName?.[1] ?? dashName?.[1] ?? null;
295
+ const name = rawName && !/^(?:complete|executing|not started)$/i.test(rawName.trim())
296
+ ? rawName.trim()
297
+ : null;
298
+ return {
299
+ phase: phaseMatch ? phaseMatch[1] : null,
300
+ name,
301
+ };
302
+ }
303
+ /**
304
+ * Config-AWARE project-code prefix strip. Unlike the config-blind
305
+ * `stripProjectCodePrefix` (which strips ANY `<CODE>-` shape), this strips the
306
+ * leading `<CODE>-` ONLY when `<CODE>` case-insensitively equals the configured
307
+ * `projectCode`. A foreign prefix (`MEM-01` when the configured code is `LKML`)
308
+ * or an absent/empty `projectCode` is preserved verbatim — this is the #2104
309
+ * fix: a foreign-prefixed id must not collapse to a bare numeric phase and
310
+ * collide with a real one.
311
+ */
312
+ function stripConfiguredProjectCodePrefix(value, projectCode) {
313
+ const input = String(value);
314
+ const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
315
+ if (!configured)
316
+ return input;
317
+ const m = input.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
318
+ if (!m)
319
+ return input;
320
+ if (m[1].toUpperCase() !== configured.toUpperCase())
321
+ return input;
322
+ return m[2];
323
+ }
324
+ /**
325
+ * True when `phase` carries a project-code prefix that is NOT the configured
326
+ * `projectCode` (or when no `projectCode` is configured). The canonical
327
+ * predicate the init-command foreign-prefix guard (#2056 / PR #2105) delegates
328
+ * to, so every call site shares one foreign-prefix rule.
329
+ */
330
+ function isForeignPrefixedPhaseQuery(phase, projectCode) {
331
+ const m = String(phase).match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
332
+ if (!m)
333
+ return false;
334
+ const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
335
+ return !configured || m[1].toUpperCase() !== configured.toUpperCase();
336
+ }
337
+ /**
338
+ * Canonical ROADMAP heading lookup-source list (moved here from
339
+ * roadmap-parser.cts so phase-id.cts is the single owner of the ordering).
340
+ * Sources are tried in a fixed, deduplicated order: exact (only when the query
341
+ * itself is project-code-prefixed) → bare numeric / padding-tolerant →
342
+ * prefix-tolerant fallback. The bare numeric source precedes the prefix-tolerant
343
+ * form so a canonical heading (`### Phase 117:`) is preferred over a drifted
344
+ * prefixed one (`### Phase MANIFOLD-117:`) when both exist in one ROADMAP.
345
+ */
346
+ function roadmapPhaseLookupSources(phaseNum) {
347
+ const sources = [];
348
+ const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
349
+ if (exactSource)
350
+ sources.push(exactSource);
351
+ const numericSource = phaseMarkdownRegexSource(phaseNum);
352
+ sources.push(numericSource);
353
+ sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
354
+ return [...new Set(sources)];
355
+ }
232
356
  module.exports = {
233
357
  escapeRegex,
234
358
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
235
359
  OPTIONAL_PHASE_TAG_SOURCE,
360
+ PHASE_NUMBER_TOKEN_SOURCE,
236
361
  stripProjectCodePrefix,
237
362
  normalizePhaseName,
238
363
  getMilestoneFromPhaseId,
@@ -242,4 +367,8 @@ module.exports = {
242
367
  comparePhaseNum,
243
368
  extractPhaseToken,
244
369
  phaseTokenMatches,
370
+ parsePhaseFromProse,
371
+ stripConfiguredProjectCodePrefix,
372
+ isForeignPrefixedPhaseQuery,
373
+ roadmapPhaseLookupSources,
245
374
  };
@@ -32,7 +32,7 @@ const coreUtilsMod = require("./core-utils.cjs");
32
32
  const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
33
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
34
34
  const phaseIdMod = require("./phase-id.cjs");
35
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdMod;
35
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, } = phaseIdMod;
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
37
37
  const phaseLocatorMod = require("./phase-locator.cjs");
38
38
  const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
@@ -55,7 +55,7 @@ const { evaluateUatPassed } = uatPredicate;
55
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
56
56
  const verificationMod = require("./verification.cjs");
57
57
  const { readVerificationStatus } = verificationMod;
58
- const { planningDir, withPlanningLock } = planningWorkspace;
58
+ const { planningDir, withPlanningLock, listAvailableWorkstreams, getActiveWorkstream } = planningWorkspace;
59
59
  const { extractFrontmatter } = frontmatterMod;
60
60
  const { readModifyWriteStateMd, stateExtractField, stateReplaceField, syncStateFrontmatter, withStateLock, updatePerformanceMetricsSection, } = stateMod;
61
61
  // #2893 — strict canonical filter: `{padded_phase}-{NN}-PLAN.md` or `PLAN.md`.
@@ -84,7 +84,10 @@ function extractCanonicalPlanId(filename) {
84
84
  .replace(/-SUMMARY\.md$/i, '')
85
85
  .replace(/\.md$/i, '');
86
86
  const parts = base.split('-').filter(Boolean);
87
- const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
87
+ // #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
88
+ // or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
89
+ // so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
90
+ const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
88
91
  const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
89
92
  if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
90
93
  return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
@@ -313,8 +316,7 @@ function cmdFindPhase(cwd, phase, raw) {
313
316
  const match = dirs.find((d) => phaseTokenMatches(d, normalized));
314
317
  if (!match)
315
318
  continue;
316
- const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) ||
317
- match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
319
+ const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i')) || match.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
318
320
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
319
321
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
320
322
  const phaseDir = node_path_1.default.join(searchDir, match);
@@ -578,13 +580,13 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
578
580
  // Three sources are scanned so that a phase in ANY representation
579
581
  // (section header, roadmap bullet, or on-disk directory) is counted:
580
582
  // 1) Section headers: ### Phase N: / ## Phase N: / #### Phase N:
581
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
582
- const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
583
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
584
+ const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
583
585
  // 2) Roadmap bullet entries: - [ ] **Phase N: ...** (all checkbox variants)
584
586
  // The lookahead accepts colon, decimal-dot, whitespace, bold-close asterisk,
585
587
  // or end-of-line so titleless forms ("- [ ] **Phase 11**", "- [ ] Phase 11")
586
588
  // are counted and cannot collide with a freshly-added phase. (#1229)
587
- const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]*\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
589
+ const bulletPattern = /^[ \t]*-[ \t]*\[[^\]]{0,200}\][ \t]*\*{0,2}Phase[ \t]+(\d+)(?=[:.\s*]|$)/gim;
588
590
  const usedPhaseNums = new Set();
589
591
  let m;
590
592
  while ((m = headerPattern.exec(content)) !== null) {
@@ -663,8 +665,8 @@ function cmdPhaseAddBatch(cwd, descriptions, raw) {
663
665
  const content = extractCurrentMilestone(rawContent, cwd);
664
666
  let maxPhase = 0;
665
667
  if (config.phase_naming !== 'custom') {
666
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
667
- const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]*\))?:/gi;
668
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
669
+ const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]{0,200}\))?:/gi;
668
670
  let m;
669
671
  while ((m = phasePattern.exec(content)) !== null) {
670
672
  const num = parseInt(m[1], 10);
@@ -948,7 +950,7 @@ function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, rem
948
950
  if (!isDecimal) {
949
951
  // #1729: fold an optional pre-colon ( ) tag into the suffix capture so it
950
952
  // is re-emitted verbatim — a tagged later phase still gets renumbered.
951
- content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]*\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
953
+ content = content.replace(/(#{2,4}\s*Phase\s+)(\d+(?:\.\d+)?)((?:\s*\([^)\n]{0,200}\))?\s*:)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseToken(num, removedInt)}${suffix}`);
952
954
  content = content.replace(/(-\s*\[[ x]\]\s*.*?Phase\s+)(\d+)(\s*:|\s+)/gi, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
953
955
  content = content.replace(/(\|\s*)(\d+)(\.\s)/g, (_match, prefix, num, suffix) => `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`);
954
956
  content = content.replace(/(?<![0-9-])(\d{2})-(\d{2})(?=(?:(?:-[A-Za-z][A-Za-z0-9-]*)?-(?:PLAN|SUMMARY)\.md)|(?![0-9-]))/g, (_match, phaseNum, planNum) => `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`);
@@ -1067,6 +1069,19 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1067
1069
  if (!phaseNum) {
1068
1070
  error('phase number required for phase complete');
1069
1071
  }
1072
+ // #2028: fail safe in workstream mode with no active workstream. With no active
1073
+ // workstream and no --ws, planningDir(cwd) resolves to root .planning, so
1074
+ // phase.complete would write STATE.md/ROADMAP.md (and mislabel milestone status)
1075
+ // into the shared root that other workstreams read. Mirror the #1912 guard that
1076
+ // init.progress got (resolution: GSD_WORKSTREAM env > stored active pointer; an
1077
+ // explicit --ws sets GSD_WORKSTREAM upstream and satisfies the check).
1078
+ const availableWorkstreams = listAvailableWorkstreams(cwd);
1079
+ const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || getActiveWorkstream(cwd);
1080
+ if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
1081
+ error(`phase.complete requires a workstream in workstream mode — no active workstream is set, so root STATE.md/ROADMAP.md (likely stale) would be written. ` +
1082
+ `Pass --ws <name> or run ${(0, runtime_slash_cjs_1.formatGsdSlash)('workstream set', (0, runtime_slash_cjs_1.resolveRuntime)(cwd))} first. ` +
1083
+ `Available workstreams: ${availableWorkstreams.join(', ')}`);
1084
+ }
1070
1085
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
1071
1086
  const statePath = node_path_1.default.join(planningDir(cwd), 'STATE.md');
1072
1087
  const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
@@ -1132,7 +1147,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1132
1147
  const originalRoadmapContent = node_fs_1.default.readFileSync(roadmapPath, 'utf-8');
1133
1148
  roadmapContent = originalRoadmapContent;
1134
1149
  const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
1135
- const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1150
+ // #2067: the gap between `]` and `Phase N` must allow only whitespace /
1151
+ // markdown bold emphasis — NOT greedy `.*`. A greedy gap matched a later
1152
+ // phase whose description merely mentioned the completed phase number,
1153
+ // so completing an already-checked phase (idempotent re-run) checked the
1154
+ // wrong phase's box. Mirrors the tight pattern used by phase-insert
1155
+ // (`]\\s*(?:\\*\\*)?Phase`).
1156
+ const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*(?:\\*\\*)?\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
1136
1157
  roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
1137
1158
  const tableRowPattern = new RegExp(`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`, 'im');
1138
1159
  // Scope the Progress-row search to the ## Progress section so the regex
@@ -1166,7 +1187,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1166
1187
  else {
1167
1188
  roadmapContent = roadmapContent.replace(tableRowPattern, updateProgressRow);
1168
1189
  }
1169
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i');
1190
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}(?:(?!\\n#{1,4}\\s)[\\s\\S])*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`, 'i');
1170
1191
  roadmapContent = roadmapContent.replace(planCountPattern, `$1${summaryCount}/${planCount} plans complete`);
1171
1192
  const phaseInfoSummaries = phaseInfo['summaries'];
1172
1193
  for (const summaryFile of phaseInfoSummaries) {
@@ -1284,7 +1305,7 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1284
1305
  .filter(isDirInMilestone)
1285
1306
  .sort((a, b) => comparePhaseNum(a, b));
1286
1307
  for (const dir of dirs) {
1287
- const dm = dir.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
1308
+ const dm = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})-?(.*)`, 'i'));
1288
1309
  if (dm) {
1289
1310
  if (/^999(?:\.|$)/.test(dm[1]))
1290
1311
  continue;
@@ -1315,10 +1336,10 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1315
1336
  // phase. Allow optional `**`/`__` emphasis after the marker and stop
1316
1337
  // the name capture at emphasis so bold names slug cleanly; the number
1317
1338
  // capture is unchanged.
1318
- // #1729: `(?:\s*\([^)\n]*\))?` after the number tolerates a pre-colon
1339
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` after the number tolerates a pre-colon
1319
1340
  // ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE) so
1320
1341
  // `### Phase N (Cluster B): X` resolves. Captures are unchanged.
1321
- const phasePattern = /(?:#{2,4}|-\s*\[[ xX]\])\s*(?:\*\*|__)?\s*Phase\s+(\d+[A-Z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n*]+)/gi;
1342
+ const phasePattern = new RegExp(`(?:#{2,4}|-\\s*\\[[ xX]\\])\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
1322
1343
  let pm;
1323
1344
  while ((pm = phasePattern.exec(roadmapForPhases)) !== null) {
1324
1345
  if (comparePhaseNum(pm[1], phaseNum) > 0) {
@@ -1337,6 +1358,47 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
1337
1358
  /* intentionally empty */
1338
1359
  }
1339
1360
  }
1361
+ // #2028: don't stamp "Milestone complete" when a LOWER-numbered phase is
1362
+ // still outstanding. The two blocks above only clear isLastPhase when a
1363
+ // HIGHER-numbered phase exists, so completing the numerically-highest phase
1364
+ // out of order (e.g. Phase 10 before Phase 9) wrongly read as milestone-end.
1365
+ // A phase is complete iff its roadmap checkbox is `[x]` (phase.complete sets
1366
+ // this on completion — including the one just marked above); any earlier
1367
+ // phase in this milestone whose checkbox is still `[ ]` means the milestone
1368
+ // is not done, and the LOWEST such phase is the real next actionable item —
1369
+ // point next_phase at it so STATE.md advances to the gap rather than parking
1370
+ // on the just-completed phase. Roadmaps without phase checkboxes (heading-
1371
+ // only) retain the prior behavior — there is nothing to scan. The checkbox
1372
+ // pattern mirrors the sibling phasePattern's anchoring (only whitespace/bold
1373
+ // between the box and "Phase", a required `:`) so unrelated checklist lines
1374
+ // that merely mention "Phase N" don't match.
1375
+ if (isLastPhase && roadmapContent !== null) {
1376
+ try {
1377
+ const milestoneScope = extractCurrentMilestone(roadmapContent, cwd);
1378
+ const cbPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*(?:\\*\\*|__)?\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n*]+)`, 'gi');
1379
+ let cbm;
1380
+ let lowestOutstanding = null;
1381
+ while ((cbm = cbPattern.exec(milestoneScope)) !== null) {
1382
+ const isChecked = cbm[1].toLowerCase() === 'x';
1383
+ if (!isChecked && comparePhaseNum(cbm[2], phaseNum) < 0) {
1384
+ if (lowestOutstanding === null || comparePhaseNum(cbm[2], lowestOutstanding.num) < 0) {
1385
+ lowestOutstanding = {
1386
+ num: cbm[2],
1387
+ name: cbm[3].replace(/\(INSERTED\)/i, '').trim().toLowerCase().replace(/\s+/g, '-'),
1388
+ };
1389
+ }
1390
+ }
1391
+ }
1392
+ if (lowestOutstanding !== null) {
1393
+ isLastPhase = false;
1394
+ nextPhaseNum = lowestOutstanding.num;
1395
+ nextPhaseName = lowestOutstanding.name;
1396
+ }
1397
+ }
1398
+ catch {
1399
+ /* intentionally empty */
1400
+ }
1401
+ }
1340
1402
  if (node_fs_1.default.existsSync(statePath)) {
1341
1403
  const originalStateContent = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
1342
1404
  let stateContent = originalStateContent;
@@ -120,6 +120,22 @@ function planningDir(cwd, ws, project) {
120
120
  function planningRoot(cwd) {
121
121
  return node_path_1.default.join(cwd, '.planning');
122
122
  }
123
+ // Sorted list of workstream directory names under `<root>/.planning/workstreams`,
124
+ // or `[]` when the project is flat (no workstreams dir). Single source of truth
125
+ // for the "workstream mode" detection shared by the #1912/#2028 fail-safe guards
126
+ // (init.progress, phase.complete) so the two paths cannot drift.
127
+ function listAvailableWorkstreams(cwd) {
128
+ try {
129
+ return node_fs_1.default
130
+ .readdirSync(node_path_1.default.join(planningRoot(cwd), 'workstreams'), { withFileTypes: true })
131
+ .filter((e) => e.isDirectory())
132
+ .map((e) => e.name)
133
+ .sort();
134
+ }
135
+ catch {
136
+ return [];
137
+ }
138
+ }
123
139
  function planningPaths(cwd, ws) {
124
140
  const base = planningDir(cwd, ws);
125
141
  return {
@@ -364,6 +380,7 @@ module.exports = {
364
380
  createMemoryPointerAdapter,
365
381
  planningDir,
366
382
  planningRoot,
383
+ listAvailableWorkstreams,
367
384
  planningPaths,
368
385
  withPlanningLock,
369
386
  getActiveWorkstream,
@@ -15,25 +15,42 @@
15
15
  * per-instance CLI flags). An instance is available iff its base `cli` is
16
16
  * detected. The instance→cli mapping lives HERE (single source; see the parity
17
17
  * test in tests/review-reviewer-instances.test.cjs — DEFECT.GENERATIVE-FIX).
18
+ *
19
+ * KNOWN_REVIEWER_SLUGS (post-review #2092): registry-derived, not a flat
20
+ * hand-maintained array. Each capability-runtime descriptor that is a valid
21
+ * reviewer CLI declares `runtime.hostBehaviors.reviewerCli: true`
22
+ * (capabilities/<id>/capability.json); this module reads that flag off the
23
+ * generated capability-registry.cjs at require-time. A handful of reviewer
24
+ * CLIs are NOT install-time runtimes at all (no capabilities/<id>/ descriptor
25
+ * exists) — those stay a small hardcoded tail:
26
+ * - `gemini` — hook-event dialect name only (see runtime-hooks-surface.cts);
27
+ * the Gemini CLI reviewer is not an installable runtime (#1928 folded
28
+ * gemini into antigravity's descriptor).
29
+ * - `coderabbit` / `ollama` / `lm_studio` / `llama_cpp` — third-party
30
+ * review/model CLIs with no GSD install surface at all.
18
31
  */
19
32
  Object.defineProperty(exports, "__esModule", { value: true });
20
33
  exports.INSTANCE_NAME_PATTERN = exports.KNOWN_REVIEWER_SLUGS = void 0;
21
34
  exports.normalizeConfiguredDefaultReviewers = normalizeConfiguredDefaultReviewers;
22
35
  exports.normalizeReviewerInstances = normalizeReviewerInstances;
23
36
  exports.resolveReviewerSelection = resolveReviewerSelection;
24
- exports.KNOWN_REVIEWER_SLUGS = [
37
+ const NON_RUNTIME_REVIEWER_SLUGS = [
25
38
  'gemini',
26
- 'claude',
27
- 'codex',
28
39
  'coderabbit',
29
- 'opencode',
30
- 'qwen',
31
- 'cursor',
32
- 'antigravity',
33
40
  'ollama',
34
41
  'lm_studio',
35
42
  'llama_cpp',
36
43
  ];
44
+ function deriveRuntimeReviewerSlugs() {
45
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
46
+ const registry = require('./capability-registry.cjs');
47
+ const runtimes = registry.runtimes || {};
48
+ return Object.keys(runtimes).filter((id) => runtimes[id]?.runtime?.hostBehaviors?.reviewerCli === true);
49
+ }
50
+ exports.KNOWN_REVIEWER_SLUGS = [
51
+ ...deriveRuntimeReviewerSlugs(),
52
+ ...NON_RUNTIME_REVIEWER_SLUGS,
53
+ ];
37
54
  /** Instance names are lowercase slugs that must not shadow a built-in slug. */
38
55
  exports.INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
39
56
  function normalizeConfiguredDefaultReviewers(rawValue) {
@@ -44,12 +44,13 @@ function checkW021(content) {
44
44
  // Milestone section heading: ## [GSD] v2.0 — Label OR ## v2.0: Label OR ## Roadmap v2.0
45
45
  // OR ## ✅ v2.0 OR ## 🚧 v2.0 (emoji-prefixed variants used by roadmap templates)
46
46
  // Capture the major integer.
47
- const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
47
+ const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
48
48
  // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
49
- // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
50
- const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i;
49
+ // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
50
+ const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]{0,200}\))?\s*:/i;
51
51
  // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
52
- const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i;
52
+ // phase-id-owner: UNPREFIXED_PHASE_RE token uses the [A-Za-z] case-variant (identical to the canonical [A-Z] token under /i); kept literal, not source-byte-equal to PHASE_NUMBER_TOKEN_SOURCE.
53
+ const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:/i;
53
54
  let currentMilestoneMajor = null;
54
55
  const lines = content.split('\n');
55
56
  for (const line of lines) {