@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +20 -0
- package/agents/gsd-doc-classifier.md +105 -0
- package/agents/gsd-doc-synthesizer.md +61 -0
- package/agents/gsd-ui-checker.md +30 -0
- package/agents/gsd-ui-researcher.md +1 -0
- package/bin/install.js +1568 -622
- package/gsd-core/bin/gsd-tools.cjs +40 -1
- package/gsd-core/bin/lib/api-coverage.cjs +466 -0
- package/gsd-core/bin/lib/audit.cjs +6 -3
- package/gsd-core/bin/lib/capability-loader.cjs +11 -9
- package/gsd-core/bin/lib/capability-registry.cjs +761 -84
- package/gsd-core/bin/lib/capability-validator.cjs +56 -18
- package/gsd-core/bin/lib/capability-writer.cjs +10 -1
- package/gsd-core/bin/lib/check-command-router.cjs +242 -3
- package/gsd-core/bin/lib/commands.cjs +7 -5
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +96 -0
- package/gsd-core/bin/lib/core-utils.cjs +4 -1
- package/gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs +234 -0
- package/gsd-core/bin/lib/host-integration-adapters/imperative-hook-bus.cjs +145 -0
- package/gsd-core/bin/lib/host-integration.cjs +45 -4
- package/gsd-core/bin/lib/init.cjs +76 -39
- package/gsd-core/bin/lib/install-effort-resolver.cjs +213 -0
- package/gsd-core/bin/lib/install-engine.cjs +228 -18
- package/gsd-core/bin/lib/installer-migration-report.cjs +7 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +68 -17
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +50 -11
- package/gsd-core/bin/lib/mcp-server.cjs +18 -7
- package/gsd-core/bin/lib/milestone.cjs +3 -3
- package/gsd-core/bin/lib/normalize-test-command.cjs +187 -0
- package/gsd-core/bin/lib/phase-id.cjs +132 -3
- package/gsd-core/bin/lib/phase.cjs +78 -16
- package/gsd-core/bin/lib/planning-workspace.cjs +17 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +24 -7
- package/gsd-core/bin/lib/roadmap-command-router.cjs +5 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +21 -30
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +9 -9
- package/gsd-core/bin/lib/roadmap.cjs +42 -56
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +248 -44
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +39 -23
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +19 -5
- package/gsd-core/bin/lib/runtime-homes.cjs +30 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +534 -37
- package/gsd-core/bin/lib/runtime-name-policy.cjs +63 -5
- package/gsd-core/bin/lib/security.cjs +6 -36
- package/gsd-core/bin/lib/shell-command-projection.cjs +115 -2
- package/gsd-core/bin/lib/spec-section.cjs +111 -0
- package/gsd-core/bin/lib/stale-bake-guard.cjs +30 -10
- package/gsd-core/bin/lib/state-transition.cjs +1 -1
- package/gsd-core/bin/lib/state.cjs +24 -24
- package/gsd-core/bin/lib/surface.cjs +40 -6
- package/gsd-core/bin/lib/uat.cjs +4 -1
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +249 -0
- package/gsd-core/bin/lib/validate.cjs +15 -6
- package/gsd-core/bin/lib/verify.cjs +33 -37
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/bin/shared/model-catalog.json +14 -9
- package/gsd-core/references/api-coverage.md +104 -0
- package/gsd-core/references/model-profiles.md +2 -2
- package/gsd-core/references/planning-config.md +2 -0
- package/gsd-core/references/specless-probe-fallback.md +172 -0
- package/gsd-core/references/ui-consideration-probe.md +73 -0
- package/gsd-core/templates/UI-SPEC.md +25 -0
- package/gsd-core/templates/VALIDATION.md +2 -0
- package/gsd-core/templates/config.json +2 -1
- package/gsd-core/workflows/audit-fix.md +9 -1
- package/gsd-core/workflows/audit-milestone.md +7 -4
- package/gsd-core/workflows/code-review-fix.md +7 -3
- package/gsd-core/workflows/code-review.md +4 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +8 -4
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +42 -0
- package/gsd-core/workflows/execute-phase.md +1 -25
- package/gsd-core/workflows/plan-phase.md +37 -2
- package/gsd-core/workflows/quick.md +2 -2
- package/gsd-core/workflows/review.md +59 -13
- package/gsd-core/workflows/settings-advanced.md +12 -9
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/ui-phase.md +146 -1
- package/gsd-core/workflows/validate-phase.md +2 -2
- package/gsd-core/workflows/verify-phase.md +3 -2
- package/gsd-core/workflows/verify-work.md +38 -0
- package/hooks/dist/gsd-cursor-pre-tool.js +76 -0
- package/hooks/dist/gsd-cursor-stop.js +48 -0
- package/hooks/dist/gsd-cursor-subagent-start.js +50 -0
- package/hooks/dist/gsd-cursor-subagent-stop.js +40 -0
- package/hooks/dist/gsd-windsurf-pre-command.js +275 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +132 -0
- package/hooks/dist/managed-hooks-registry.cjs +6 -0
- package/hooks/gsd-cursor-pre-tool.js +76 -0
- package/hooks/gsd-cursor-stop.js +48 -0
- package/hooks/gsd-cursor-subagent-start.js +50 -0
- package/hooks/gsd-cursor-subagent-stop.js +40 -0
- package/hooks/gsd-windsurf-pre-command.js +275 -0
- package/hooks/gsd-windsurf-pre-write.js +132 -0
- package/hooks/managed-hooks-registry.cjs +6 -0
- package/package.json +9 -4
- package/pi/gsd.cjs +354 -0
- package/scripts/build-hooks.js +8 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +11 -1
- package/scripts/gen-registry.cjs +128 -0
- package/scripts/lint-phase-id-drift.cjs +150 -0
- package/scripts/lint-test-file-count.allowlist.json +2 -1
- package/scripts/registry-schema.cjs +565 -0
- package/scripts/run-tests.cjs +21 -1
- package/scripts/validate-registry.cjs +117 -0
- package/vscode/browser.js +197 -0
- package/vscode/extension.js +383 -0
- package/vscode/host-binding.js +113 -0
- 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]
|
|
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 (
|
|
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
|
-
|
|
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}(
|
|
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]
|
|
582
|
-
const headerPattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]
|
|
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]*\[[^\]]
|
|
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]
|
|
667
|
-
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*(?:\s*\([^)\n]
|
|
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]
|
|
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
|
-
|
|
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(
|
|
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]
|
|
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 =
|
|
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
|
-
|
|
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+(?:\[[^\]]
|
|
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]
|
|
50
|
-
const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]
|
|
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
|
-
|
|
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) {
|