@opengsd/gsd-core 1.6.0-rc.1 → 1.6.0-rc.2

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 (48) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-roadmapper.md +6 -0
  3. package/bin/install.js +92 -332
  4. package/commands/gsd/capture.md +5 -1
  5. package/gemini-extension.json +1 -1
  6. package/gsd-core/bin/gsd-tools.cjs +18 -3
  7. package/gsd-core/bin/lib/adr-parser.cjs +21 -6
  8. package/gsd-core/bin/lib/capability-registry.cjs +48 -48
  9. package/gsd-core/bin/lib/commands.cjs +247 -0
  10. package/gsd-core/bin/lib/config-loader.cjs +6 -0
  11. package/gsd-core/bin/lib/config.cjs +6 -0
  12. package/gsd-core/bin/lib/frontmatter.cjs +7 -3
  13. package/gsd-core/bin/lib/phase-id.cjs +25 -11
  14. package/gsd-core/bin/lib/phase.cjs +4 -4
  15. package/gsd-core/bin/lib/probe-core.cjs +7 -0
  16. package/gsd-core/bin/lib/prohibition-enforcement.cjs +59 -26
  17. package/gsd-core/bin/lib/roadmap-command-router.cjs +16 -3
  18. package/gsd-core/bin/lib/roadmap-parser.cjs +29 -8
  19. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -17
  20. package/gsd-core/bin/lib/roadmap.cjs +5 -2
  21. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +423 -3
  22. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +77 -0
  23. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -28
  24. package/gsd-core/bin/lib/runtime-name-policy.cjs +44 -0
  25. package/gsd-core/bin/lib/shell-command-projection.cjs +55 -1
  26. package/gsd-core/bin/lib/surface.cjs +12 -19
  27. package/gsd-core/bin/lib/validate.cjs +5 -2
  28. package/gsd-core/bin/lib/verify.cjs +11 -2
  29. package/gsd-core/bin/lib/worktree-safety.cjs +202 -0
  30. package/gsd-core/bin/shared/config-defaults.manifest.json +2 -1
  31. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  32. package/gsd-core/references/context-budget.md +8 -8
  33. package/gsd-core/references/execute-phase-context-guard.md +16 -0
  34. package/gsd-core/references/planning-config.md +1 -0
  35. package/gsd-core/references/prohibition-probe.md +15 -9
  36. package/gsd-core/workflows/autonomous.md +33 -33
  37. package/gsd-core/workflows/diagnose-issues.md +6 -1
  38. package/gsd-core/workflows/execute-phase.md +8 -6
  39. package/gsd-core/workflows/help/modes/full.md +10 -0
  40. package/gsd-core/workflows/list-seeds.md +63 -0
  41. package/gsd-core/workflows/manager.md +37 -37
  42. package/gsd-core/workflows/pr-branch.md +156 -0
  43. package/gsd-core/workflows/quick.md +6 -1
  44. package/gsd-core/workflows/review.md +10 -2
  45. package/gsd-core/workflows/spec-phase.md +8 -3
  46. package/gsd-core/workflows/verify-phase.md +2 -2
  47. package/package.json +4 -1
  48. package/scripts/prompt-injection-scan.sh +1 -0
@@ -12,6 +12,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
12
12
  const node_fs_1 = __importDefault(require("node:fs"));
13
13
  const node_path_1 = __importDefault(require("node:path"));
14
14
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
15
+ const security_cjs_1 = require("./security.cjs");
15
16
  // eslint-disable-next-line @typescript-eslint/no-require-imports
16
17
  const ioMod = require("./io.cjs");
17
18
  const { output, error } = ioMod;
@@ -144,6 +145,110 @@ function cmdListTodos(cwd, area, raw) {
144
145
  const result = { count, todos };
145
146
  output(result, raw, count.toString());
146
147
  }
148
+ /**
149
+ * List captured seeds from .planning/seeds/SEED-*.md for browsing/audit (#441).
150
+ *
151
+ * Unlike audit.scanSeeds (which returns only *unimplemented* seeds for the
152
+ * milestone surface), this lists seeds of every status with the richer fields a
153
+ * human audit needs (scope, trigger, planted date). An optional case-insensitive
154
+ * status filter narrows the set. Seed content is user-controlled, so every
155
+ * displayed field is passed through sanitizeForDisplay and each file path is
156
+ * validated with requireSafePath before reading. Read-only — never mutates.
157
+ */
158
+ /**
159
+ * Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
160
+ * frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
161
+ *
162
+ * seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
163
+ * of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
164
+ * remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
165
+ * `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
166
+ */
167
+ function deriveSeedIdentity(stem, rawFmId) {
168
+ const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
169
+ let seedId;
170
+ if (/^SEED-\d+$/i.test(fmId)) {
171
+ seedId = fmId;
172
+ }
173
+ else {
174
+ const numMatch = stem.match(/^(SEED-\d+)/i);
175
+ seedId = numMatch ? numMatch[1] : stem;
176
+ }
177
+ const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
178
+ const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
179
+ return { seed_id: seedId, slug };
180
+ }
181
+ function cmdListSeeds(cwd, statusFilter, raw) {
182
+ const planDir = planningDir(cwd);
183
+ const seedsDir = node_path_1.default.join(planDir, 'seeds');
184
+ const wantStatus = statusFilter ? statusFilter.trim().toLowerCase() : null;
185
+ const seeds = [];
186
+ const summary = {};
187
+ // Frontmatter values are not guaranteed to be scalars: extractFrontmatter
188
+ // yields {} for a bare `key:` line and an array for `key: [a, b]`. Coerce every
189
+ // read to a string so one malformed seed cannot crash the whole audit list
190
+ // (`.toLowerCase()` on a non-string throws) or leak a raw object/array into the
191
+ // JSON contract. Mirrors the existing `typeof fm.id === 'string'` guard below.
192
+ const fmStr = (v) => (typeof v === 'string' ? v : '');
193
+ let files;
194
+ try {
195
+ files = node_fs_1.default.readdirSync(seedsDir, { withFileTypes: true });
196
+ }
197
+ catch {
198
+ // No seeds dir (or unreadable) — an empty, non-error result. The seed dir is
199
+ // created lazily by the first plant-seed, so absence is the normal zero case.
200
+ output({ count: 0, seeds: [], summary: {} }, raw, '0');
201
+ return;
202
+ }
203
+ for (const entry of files) {
204
+ if (!entry.isFile())
205
+ continue;
206
+ if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md'))
207
+ continue;
208
+ let safeFilePath;
209
+ try {
210
+ safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', { allowAbsolute: true });
211
+ }
212
+ catch {
213
+ continue;
214
+ }
215
+ const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
216
+ if (content === null)
217
+ continue;
218
+ const fm = extractFrontmatter(content);
219
+ const status = (fmStr(fm.status) || 'dormant').toLowerCase().trim() || 'dormant';
220
+ // Match on the raw lowercased status (both sides already normalized);
221
+ // sanitizeForDisplay is for output, not comparison.
222
+ if (wantStatus && status !== wantStatus)
223
+ continue;
224
+ // Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
225
+ // back to the numeric prefix of the filename, then to the whole stem. The
226
+ // descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
227
+ const stem = node_path_1.default.basename(entry.name, '.md');
228
+ const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
229
+ let title = (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.title).slice(0, 100));
230
+ if (!title) {
231
+ const headingMatch = content.match(/^#\s*(.+)$/m);
232
+ if (headingMatch)
233
+ title = (0, security_cjs_1.sanitizeForDisplay)(headingMatch[1].trim().slice(0, 100));
234
+ }
235
+ const safeStatus = (0, security_cjs_1.sanitizeForDisplay)(status);
236
+ summary[safeStatus] = (summary[safeStatus] || 0) + 1;
237
+ seeds.push({
238
+ seed_id: (0, security_cjs_1.sanitizeForDisplay)(seedId),
239
+ slug: (0, security_cjs_1.sanitizeForDisplay)(slug),
240
+ status: safeStatus,
241
+ scope: (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.scope) || 'unknown'),
242
+ trigger_when: (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.trigger_when)),
243
+ planted: (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.planted)),
244
+ title,
245
+ path: toPosixPath(node_path_1.default.relative(cwd, safeFilePath)),
246
+ });
247
+ }
248
+ // Stable order: by seed_id so output is deterministic across filesystems.
249
+ seeds.sort((a, b) => a.seed_id.localeCompare(b.seed_id));
250
+ output({ count: seeds.length, seeds, summary }, raw, seeds.length.toString());
251
+ }
147
252
  function cmdVerifyPathExists(cwd, targetPath, raw) {
148
253
  if (!targetPath) {
149
254
  error('path required for verification');
@@ -629,6 +734,145 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
629
734
  };
630
735
  output(result, raw, Object.entries(repos).map(([r, v]) => `${r}:${v.hash || 'skip'}`).join(' '));
631
736
  }
737
+ /**
738
+ * Prepare a sub-repo for a companion PR branch.
739
+ *
740
+ * Detects uncommitted changes, creates a new branch, stages every changed
741
+ * file explicitly (never git add -A per universal-anti-patterns.md:44), commits,
742
+ * and pushes with --set-upstream. Returns a structured result the workflow uses
743
+ * to call `gh pr create`.
744
+ *
745
+ * On a stage/commit failure (nothing committed yet), the branch is deleted and
746
+ * the caller is returned to the original HEAD so the repo is left clean. On a
747
+ * push failure, the commit already exists — the branch is left in place instead
748
+ * so the user's work is not lost; the error includes a retry instruction.
749
+ */
750
+ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
751
+ if (!repo) {
752
+ error('--repo required');
753
+ }
754
+ if (!branch) {
755
+ error('--branch required');
756
+ }
757
+ if (!commitMessage || commitMessage.startsWith('--')) {
758
+ error('commit message required');
759
+ }
760
+ if (branch.startsWith('-')) {
761
+ error(`Branch name must not start with '-': ${branch}`);
762
+ }
763
+ // 0. Security: validate repo path is contained within the workspace root.
764
+ // Uses security.cjs validatePath (symlink-safe realpathSync + startsWith guard)
765
+ // to reject ../escape, absolute paths, and symlink traversal.
766
+ // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
767
+ const { validatePath } = require('./security.cjs');
768
+ const pathCheck = validatePath(repo, cwd);
769
+ if (!pathCheck.safe) {
770
+ error(`Sub-repo path is unsafe: ${pathCheck.error}`);
771
+ }
772
+ const repoCwd = pathCheck.resolved;
773
+ if (!node_fs_1.default.existsSync(repoCwd)) {
774
+ error(`Sub-repo not found: ${repoCwd}`);
775
+ }
776
+ // 1. Collect changed files via porcelain status — explicit, never git add -A.
777
+ // ?? (untracked) lines are excluded — only stage tracked modifications.
778
+ const statusResult = (0, shell_command_projection_cjs_1.execGit)(['-c', 'core.quotePath=false', 'status', '--porcelain'], { cwd: repoCwd });
779
+ if (statusResult.exitCode !== 0) {
780
+ error(`git status failed in ${repo}: ${statusResult.stderr}`);
781
+ }
782
+ // Parse porcelain output into two lists:
783
+ // changedFiles — all affected paths (old + new for renames) → goes into result.files
784
+ // filesToStage — paths to pass to git add (rename old-paths are already staged by
785
+ // the rename op and no longer exist in the worktree; only add new paths)
786
+ const changedFiles = [];
787
+ const filesToStage = [];
788
+ for (const line of statusResult.stdout.split('\n').filter(Boolean).filter(l => !l.startsWith('??'))) {
789
+ // execGit trims the entire stdout string, which may strip the leading X-status
790
+ // space from the first output line. Normalize before slicing.
791
+ const normalized = line.trimStart();
792
+ const file = normalized.slice(2).trim();
793
+ const arrowIdx = file.indexOf(' -> ');
794
+ if (arrowIdx !== -1) {
795
+ const oldPath = file.slice(0, arrowIdx).trim();
796
+ const newPath = file.slice(arrowIdx + 4).trim();
797
+ changedFiles.push(oldPath, newPath);
798
+ filesToStage.push(newPath); // old path already staged; worktree no longer has it
799
+ }
800
+ else {
801
+ changedFiles.push(file);
802
+ filesToStage.push(file);
803
+ }
804
+ }
805
+ if (changedFiles.length === 0) {
806
+ output({ ok: true, repo, branch, committed: false, reason: 'nothing_to_commit', files: [] }, raw, 'nothing_to_commit');
807
+ return;
808
+ }
809
+ // 2. Guard: refuse if branch already exists — checkout -b is non-idempotent
810
+ const branchCheck = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', branch], { cwd: repoCwd });
811
+ if (branchCheck.exitCode === 0) {
812
+ error(`Branch already exists in ${repo}: ${branch}. Delete it first or choose a unique name.`);
813
+ }
814
+ // Capture current HEAD before switching so rollback can return explicitly.
815
+ // git checkout - fails on a fresh single-branch repo with no prior HEAD.
816
+ const prevBranchResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoCwd });
817
+ const prevBranchName = prevBranchResult.exitCode === 0 ? prevBranchResult.stdout.trim() : null;
818
+ // 3. Create branch
819
+ const checkoutResult = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branch], { cwd: repoCwd });
820
+ if (checkoutResult.exitCode !== 0) {
821
+ error(`Failed to create branch ${branch} in ${repo}: ${checkoutResult.stderr}`);
822
+ }
823
+ // Helper: rollback the created branch and return to the previous HEAD.
824
+ const rollback = () => {
825
+ if (prevBranchName) {
826
+ (0, shell_command_projection_cjs_1.execGit)(['checkout', prevBranchName], { cwd: repoCwd });
827
+ }
828
+ (0, shell_command_projection_cjs_1.execGit)(['branch', '-D', branch], { cwd: repoCwd });
829
+ };
830
+ // 4. Stage explicit files (never git add -A per universal-anti-patterns.md:44)
831
+ for (const file of filesToStage) {
832
+ const addResult = (0, shell_command_projection_cjs_1.execGit)(['add', '--', file], { cwd: repoCwd });
833
+ if (addResult.exitCode !== 0) {
834
+ rollback();
835
+ error(`Failed to stage ${file} in ${repo}: ${addResult.stderr}`);
836
+ }
837
+ }
838
+ // 5. Commit
839
+ const commitResult = (0, shell_command_projection_cjs_1.execGit)(['commit', '-m', commitMessage], { cwd: repoCwd });
840
+ if (commitResult.exitCode !== 0) {
841
+ rollback();
842
+ error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
843
+ }
844
+ // 6. Capture commit hash
845
+ const hashResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--short', 'HEAD'], { cwd: repoCwd });
846
+ const commitHash = hashResult.exitCode === 0 ? hashResult.stdout.trim() : null;
847
+ // 7. Capture remote URL and derive GitHub owner/repo slug for gh pr create
848
+ const remoteResult = (0, shell_command_projection_cjs_1.execGit)(['remote', 'get-url', 'origin'], { cwd: repoCwd });
849
+ const remoteUrl = remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : null;
850
+ let remoteSlug = null;
851
+ if (remoteUrl) {
852
+ const m = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
853
+ remoteSlug = m ? m[1] : null;
854
+ }
855
+ // 8. Push with --set-upstream so gh pr create can find the branch.
856
+ // Network operation — use a longer timeout than the default 10 s.
857
+ // Do NOT rollback on push failure — the commit already exists on the local branch.
858
+ // Deleting the branch here would destroy the only ref holding the user's work.
859
+ // Leave the branch in place so the user can retry the push.
860
+ const pushResult = (0, shell_command_projection_cjs_1.execGit)(['push', '--set-upstream', 'origin', branch], { cwd: repoCwd, timeout: 60_000 });
861
+ if (pushResult.exitCode !== 0) {
862
+ error(`Failed to push ${branch} in ${repo}: ${pushResult.stderr}\nBranch ${branch} was created locally — retry with: git -C ${repo} push --set-upstream origin ${branch}`);
863
+ }
864
+ const result = {
865
+ ok: true,
866
+ repo,
867
+ branch,
868
+ committed: true,
869
+ files: changedFiles,
870
+ commit_hash: commitHash,
871
+ remote_url: remoteUrl,
872
+ remote_slug: remoteSlug,
873
+ };
874
+ output(result, raw, `${repo}@${commitHash ?? 'unknown'}`);
875
+ }
632
876
  function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
633
877
  if (!summaryPath) {
634
878
  error('summary-path required for summary-extract');
@@ -1224,6 +1468,8 @@ module.exports = {
1224
1468
  cmdGenerateSlug,
1225
1469
  cmdCurrentTimestamp,
1226
1470
  cmdListTodos,
1471
+ cmdListSeeds,
1472
+ deriveSeedIdentity,
1227
1473
  cmdVerifyPathExists,
1228
1474
  cmdHistoryDigest,
1229
1475
  cmdResolveModel,
@@ -1232,6 +1478,7 @@ module.exports = {
1232
1478
  cmdEffortSync,
1233
1479
  cmdCommit,
1234
1480
  cmdCommitToSubrepo,
1481
+ cmdPrSubrepo,
1235
1482
  cmdSummaryExtract,
1236
1483
  cmdWebsearch,
1237
1484
  cmdProgressRender,
@@ -135,6 +135,12 @@ function _deepMergeConfig(base, overlay) {
135
135
  return overlay;
136
136
  const result = { ...base };
137
137
  for (const key of Object.keys(overlay)) {
138
+ // Prototype-pollution guard — mirrors the four sibling guards in this file
139
+ // (lines ~315/319/331/341/549). Without it a workstream/root config.json with
140
+ // {"__proto__": {...}} pollutes this merged object's prototype chain and can
141
+ // spoof unset config flags. (Per-object pollution, not global Object.prototype.)
142
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype')
143
+ continue;
138
144
  if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) {
139
145
  result[key] = _deepMergeConfig((base[key] ?? {}), overlay[key]);
140
146
  }
@@ -210,6 +210,7 @@ function buildNewProjectConfig(userChoices) {
210
210
  ui_safety_gate: true,
211
211
  ai_integration_phase: true,
212
212
  human_verify_mode: 'end-of-phase',
213
+ context_guard_mode: 'warn',
213
214
  text_mode: false,
214
215
  research_before_questions: false,
215
216
  discuss_mode: 'discuss',
@@ -556,6 +557,11 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
556
557
  if (kp === 'workflow.human_verify_mode' && !VALID_HUMAN_VERIFY_MODES.includes(String(parsedValue))) {
557
558
  error(`Invalid workflow.human_verify_mode '${val}'. Valid values: ${VALID_HUMAN_VERIFY_MODES.join(', ')}`);
558
559
  }
560
+ // Context exhaustion guard mode (#1452)
561
+ const VALID_CONTEXT_GUARD_MODES = ['auto', 'warn', 'off'];
562
+ if (kp === 'workflow.context_guard_mode' && !VALID_CONTEXT_GUARD_MODES.includes(String(parsedValue))) {
563
+ error(`Invalid workflow.context_guard_mode '${val}'. Valid values: ${VALID_CONTEXT_GUARD_MODES.join(', ')}`);
564
+ }
559
565
  // Context position enum validation (#2937)
560
566
  const VALID_CONTEXT_POSITIONS = ['front', 'end'];
561
567
  if (kp === 'statusline.context_position' && !VALID_CONTEXT_POSITIONS.includes(String(parsedValue))) {
@@ -56,10 +56,14 @@ function extractFrontmatter(content) {
56
56
  const frontmatter = {};
57
57
  // Match frontmatter only at byte 0 — a `---` block later in the document
58
58
  // body (YAML examples, horizontal rules) must never be treated as frontmatter.
59
- const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
60
- if (!match)
59
+ const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
60
+ if (headerEnd === -1)
61
61
  return frontmatter;
62
- const yaml = match[1];
62
+ const closingLineStart = content.indexOf('\n---', headerEnd);
63
+ if (closingLineStart === -1)
64
+ return frontmatter;
65
+ const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
66
+ const yaml = content.slice(headerEnd, yamlEnd);
63
67
  const lines = yaml.split(/\r?\n/);
64
68
  const stack = [{ obj: frontmatter, key: null, indent: -1 }];
65
69
  for (const line of lines) {
@@ -14,10 +14,24 @@
14
14
  function escapeRegex(value) {
15
15
  return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16
16
  }
17
+ // project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
18
+ // leading underscores are not valid project codes per .planning/config.json.
19
+ const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
20
+ const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
21
+ const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
22
+ const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
23
+ function stripProjectCodePrefix(value, caseInsensitive = true) {
24
+ const input = String(value);
25
+ const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
26
+ return input.replace(re, '');
27
+ }
28
+ function hasProjectCodePrefix(value) {
29
+ return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value));
30
+ }
17
31
  function normalizePhaseName(phase) {
18
32
  const str = String(phase);
19
33
  // Strip optional project_code prefix (e.g., 'CK-01' → '01')
20
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
34
+ const stripped = stripProjectCodePrefix(str, false);
21
35
  // Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
22
36
  const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
23
37
  if (milestoneMatch) {
@@ -39,8 +53,7 @@ function normalizePhaseName(phase) {
39
53
  return str;
40
54
  }
41
55
  function getMilestoneFromPhaseId(phaseId) {
42
- const str = String(phaseId);
43
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
56
+ const stripped = stripProjectCodePrefix(phaseId);
44
57
  const m = stripped.match(/^0*(\d+)-\d/);
45
58
  if (!m)
46
59
  return null;
@@ -50,8 +63,7 @@ function getMilestoneFromPhaseId(phaseId) {
50
63
  return `v${major}.0`;
51
64
  }
52
65
  function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
53
- const str = String(phaseId);
54
- const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
66
+ const stripped = stripProjectCodePrefix(phaseId);
55
67
  const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
56
68
  if (!m)
57
69
  return null;
@@ -70,7 +82,7 @@ function getPhaseDirFromPhaseId(phaseId, phaseName, projectCode) {
70
82
  * prose regardless of zero-padding on either side.
71
83
  */
72
84
  function phaseMarkdownRegexSource(phaseNum) {
73
- const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
85
+ const stripped = stripProjectCodePrefix(phaseNum);
74
86
  // Milestone-prefixed IDs: M-NN or M-N-N (deep).
75
87
  const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
76
88
  if (milestoneSegments && milestoneSegments[2]) {
@@ -99,14 +111,14 @@ function phaseMarkdownRegexSource(phaseNum) {
99
111
  */
100
112
  function phaseMarkdownRegexSourceExact(phaseNum) {
101
113
  const raw = String(phaseNum);
102
- if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw))
114
+ if (!hasProjectCodePrefix(raw))
103
115
  return null;
104
116
  return escapeRegex(raw);
105
117
  }
106
118
  function comparePhaseNum(a, b) {
107
119
  // Strip optional project_code prefix before comparing
108
- const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
109
- const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
120
+ const sa = stripProjectCodePrefix(a);
121
+ const sb = stripProjectCodePrefix(b);
110
122
  const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
111
123
  const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
112
124
  if (milestoneA && milestoneB) {
@@ -162,7 +174,7 @@ function comparePhaseNum(a, b) {
162
174
  * Extract the phase token from a directory name.
163
175
  */
164
176
  function extractPhaseToken(dirName) {
165
- const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
177
+ const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
166
178
  let prefix = '';
167
179
  let rest = dirName;
168
180
  if (codePrefixMatch) {
@@ -192,7 +204,7 @@ function phaseTokenMatches(dirName, normalized) {
192
204
  const token = extractPhaseToken(dirName);
193
205
  if (token.toUpperCase() === normalized.toUpperCase())
194
206
  return true;
195
- const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
207
+ const stripped = stripProjectCodePrefix(dirName);
196
208
  if (stripped !== dirName) {
197
209
  const strippedToken = extractPhaseToken(stripped);
198
210
  if (strippedToken.toUpperCase() === normalized.toUpperCase())
@@ -202,6 +214,8 @@ function phaseTokenMatches(dirName, normalized) {
202
214
  }
203
215
  module.exports = {
204
216
  escapeRegex,
217
+ OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
218
+ stripProjectCodePrefix,
205
219
  normalizePhaseName,
206
220
  getMilestoneFromPhaseId,
207
221
  getPhaseDirFromPhaseId,
@@ -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 } = phaseIdMod;
35
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches, OPTIONAL_PROJECT_CODE_PREFIX_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;
@@ -167,7 +167,7 @@ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
167
167
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
168
168
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
169
169
  baseExists = dirs.some((d) => phaseTokenMatches(d, normalized));
170
- const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
170
+ const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
171
171
  for (const dir of dirs) {
172
172
  const match = dir.match(dirPattern);
173
173
  if (match)
@@ -310,7 +310,7 @@ function cmdFindPhase(cwd, phase, raw) {
310
310
  const match = dirs.find((d) => phaseTokenMatches(d, normalized));
311
311
  if (!match)
312
312
  continue;
313
- const dirMatch = match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) ||
313
+ const dirMatch = match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) ||
314
314
  match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
315
315
  const phaseNumber = dirMatch ? dirMatch[1] : normalized;
316
316
  const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
@@ -756,7 +756,7 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
756
756
  try {
757
757
  const entries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
758
758
  const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
759
- const decimalPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`);
759
+ const decimalPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`);
760
760
  for (const dir of dirs) {
761
761
  const dm = dir.match(decimalPattern);
762
762
  if (dm)
@@ -288,6 +288,13 @@ function projectProhibitions(items) {
288
288
  if (typeof p.check_violation_fixture === 'string' && p.check_violation_fixture.trim() !== '') {
289
289
  entry.check_violation_fixture = String(p.check_violation_fixture);
290
290
  }
291
+ // `check_clean_fixture` (#1346) rides BOTH kinds — the KNOWN-CLEAN control subject the prover
292
+ // requires to stay GREEN (content-dependence proof). Emit ONLY a non-empty fixture (blank ->
293
+ // absent so no control runs; the documented residual remains). Like the violation fixture it is
294
+ // meaningless without the descriptor, so it lives inside this well-formed-descriptor branch.
295
+ if (typeof p.check_clean_fixture === 'string' && p.check_clean_fixture.trim() !== '') {
296
+ entry.check_clean_fixture = String(p.check_clean_fixture);
297
+ }
291
298
  }
292
299
  out.push(entry);
293
300
  }
@@ -79,8 +79,9 @@ const probe_core_cjs_1 = require("./probe-core.cjs");
79
79
  * - `null`/`undefined`/non-object input -> `null`.
80
80
  * - `check_kind` ABSENT -> `null` (no descriptor -> producer locates nothing -> fail-closed).
81
81
  * - `check_kind` present -> `{ kind: check_kind, target: check_target }`, adding `rule: check_rule`
82
- * ONLY when `check_rule` is a non-empty string, and `violationFixture: check_violation_fixture`
83
- * ONLY when that scalar is a non-empty string (#1346 — composes #1278 locate with #1279 proof).
82
+ * ONLY when `check_rule` is a non-empty string, `violationFixture: check_violation_fixture`
83
+ * ONLY when that scalar is a non-empty string (composes #1278 locate with #1279 proof), and
84
+ * `cleanFixture: check_clean_fixture` ONLY when that scalar is non-empty (#1346 causation control).
84
85
  * - `failFirst` is NEVER sourced from the projection — it stays a verify-time caller attestation
85
86
  * (#1279 machine-proves it; out of scope here). The returned descriptor carries no `failFirst`.
86
87
  * - The adapter does NOT strictly validate kind/target/rule: it faithfully reconstructs whatever
@@ -118,6 +119,13 @@ function descriptorFromProjection(projected) {
118
119
  const fixture = scalar(projected.check_violation_fixture);
119
120
  if (fixture.trim().length > 0)
120
121
  descriptor.violationFixture = fixture;
122
+ // `cleanFixture` (#1346) rides BOTH kinds — reconstruct it from `check_clean_fixture` so the
123
+ // causation control runs end-to-end: when present the prover also requires the check to stay GREEN
124
+ // against this known-clean subject (proving the violation RED is content-dependent). Absent/blank ->
125
+ // no control (the documented residual remains; backward-compatible with the #1314 compose path).
126
+ const clean = scalar(projected.check_clean_fixture);
127
+ if (clean.trim().length > 0)
128
+ descriptor.cleanFixture = clean;
121
129
  return descriptor;
122
130
  }
123
131
  /** node --test argv. Forces the TAP reporter so the summary counts are parseable + version-stable;
@@ -360,6 +368,31 @@ const CHECK_MAX_BUFFER = 16 * 1024 * 1024;
360
368
  function posTimeout(timeoutMs, def) {
361
369
  return typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : def;
362
370
  }
371
+ /**
372
+ * Spawn the negative `node --test` against a single subject (set via the `GSD_PROHIB_SUBJECT`
373
+ * convention, #1279) and return its TAP output. Reuses the bounded-subprocess machinery
374
+ * (`process.execPath`, arg arrays → no shell, `childEnv`, bounded `timeout`/`maxBuffer`) and NEVER
375
+ * throws — a RED run exits non-zero, so the partial TAP (with the `# fail` summary) is recovered from
376
+ * the thrown error's `stdout`. The prover calls this once per subject: the KNOWN-BAD violation fixture
377
+ * (expect RED) and, for the #1346 causation control, the KNOWN-CLEAN control subject (expect GREEN).
378
+ */
379
+ function runNodeTestWithSubject(check, cwd, subject, timeoutMs) {
380
+ try {
381
+ return (0, node_child_process_1.execFileSync)(process.execPath, buildNodeTestArgs(check), {
382
+ cwd,
383
+ encoding: 'utf-8',
384
+ stdio: ['ignore', 'pipe', 'pipe'],
385
+ windowsHide: true,
386
+ env: { ...childEnv(), GSD_PROHIB_SUBJECT: subject },
387
+ timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
388
+ maxBuffer: CHECK_MAX_BUFFER,
389
+ });
390
+ }
391
+ catch (e) {
392
+ const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
393
+ return typeof stdout === 'string' ? stdout : '';
394
+ }
395
+ }
363
396
  function defaultRunCheck(check, cwd, timeoutMs) {
364
397
  try {
365
398
  if (check.kind === 'node-test') {
@@ -482,36 +515,36 @@ function defaultProveFailFirst(check, cwd, timeoutMs) {
482
515
  // a setup crash, not from the prohibition firing. Requiring the fixture to exist before spawning
483
516
  // closes the realistic typo/stale-path case (#1279 review, Major 1).
484
517
  //
485
- // KNOWN RESIDUAL (documented, fail-open direction, tracked follow-up #1346): existence is
486
- // necessary but not sufficient — a deliberately deceptive negative test that reds merely BECAUSE
487
- // `GSD_PROHIB_SUBJECT` is set (rather than because the subject's CONTENT violates the must-NOT)
488
- // is still accepted. Proving "the red was CAUSED BY the violation" cannot be done generically for
489
- // an arbitrary author-supplied test, so it is recorded as a constraint, not silently implied-solved.
518
+ // CAUSATION (#1346): existence + a non-vacuous red is necessary but not sufficient — a deceptive
519
+ // negative test that reds merely BECAUSE `GSD_PROHIB_SUBJECT` is set (rather than because the
520
+ // subject's CONTENT violates the must-NOT) would otherwise be accepted. The OPTIONAL `cleanFixture`
521
+ // control below proves content-dependence when supplied (red on bad AND green on clean). When NO
522
+ // clean fixture is authored the control cannot run, so the residual remains a documented constraint
523
+ // for that case (an author opts into the stronger proof by supplying a known-clean control subject).
490
524
  // Resolve the fixture against `cwd` (NOT the verify process's cwd): the spawned test reads
491
525
  // `GSD_PROHIB_SUBJECT` and resolves a relative subject against `cwd`, so the existence check must
492
526
  // use the SAME base or it could pass here yet ENOENT in the child (re-opening the fail-open hole).
493
527
  if (!fixture || !node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, fixture)))
494
528
  return { provenFailFirst: false };
495
- let out = '';
496
- try {
497
- out = (0, node_child_process_1.execFileSync)(process.execPath, buildNodeTestArgs(check), {
498
- cwd,
499
- encoding: 'utf-8',
500
- stdio: ['ignore', 'pipe', 'pipe'],
501
- windowsHide: true,
502
- // CONVENTION (#1279): the negative test reads its subject-under-test from this env var.
503
- env: { ...childEnv(), GSD_PROHIB_SUBJECT: fixture },
504
- timeout: posTimeout(timeoutMs, NODE_TEST_TIMEOUT_MS),
505
- maxBuffer: CHECK_MAX_BUFFER,
506
- });
507
- }
508
- catch (e) {
509
- // A negative test that goes RED exits non-zero; the partial TAP (with the `# fail` summary)
510
- // is on stdout. Parse what we have: a real failure here is the PROOF the test is fail-first.
511
- const stdout = e && typeof e === 'object' && 'stdout' in e ? e.stdout : '';
512
- out = typeof stdout === 'string' ? stdout : '';
529
+ // Run the negative test against the KNOWN-BAD subject and require a NON-VACUOUS red.
530
+ const redOut = runNodeTestWithSubject(check, cwd, fixture, timeoutMs);
531
+ if (!isNonVacuousNodeTestRed(redOut, check.target))
532
+ return { provenFailFirst: false, method: 'violation-fixture' };
533
+ // #1346 CAUSATION CONTROL (optional): if a clean control subject is supplied, run the SAME test
534
+ // against it and require it to stay GREEN. This proves the red above was caused by the subject's
535
+ // CONTENT — a deceptive test that reds merely because GSD_PROHIB_SUBJECT is SET reds here too →
536
+ // not content-dependent → not proven. Absent → no control (documented residual; backward-compat).
537
+ const clean = check.cleanFixture;
538
+ if (clean) {
539
+ // A supplied-but-missing/typo'd control path can't run the control → fail-closed, symmetric
540
+ // with the violation-fixture existence guard (resolve against the SAME `cwd` as the child).
541
+ if (!node_fs_1.default.existsSync(node_path_1.default.resolve(cwd, clean)))
542
+ return { provenFailFirst: false, method: 'violation-fixture' };
543
+ const cleanOut = runNodeTestWithSubject(check, cwd, clean, timeoutMs);
544
+ if (!isNonVacuousNodeTestPass(cleanOut, check.target))
545
+ return { provenFailFirst: false, method: 'violation-fixture' };
513
546
  }
514
- return { provenFailFirst: isNonVacuousNodeTestRed(out, check.target), method: 'violation-fixture' };
547
+ return { provenFailFirst: true, method: 'violation-fixture' };
515
548
  }
516
549
  // Unknown kind — defensive; the LOCATE guard already rejects it.
517
550
  return { provenFailFirst: false };
@@ -150,10 +150,23 @@ function routeRoadmapCommand({ roadmap, args, cwd, raw, error }) {
150
150
  },
151
151
  'upgrade': () => {
152
152
  const dryRun = !args.includes('--apply');
153
- const convention = args.find((_a, i) => args[i - 1] === '--convention') || 'milestone-prefixed';
153
+ // Parse `--convention <value>` and `--convention=<value>`. When the flag is
154
+ // absent entirely, default to the only supported convention; when present
155
+ // with a missing/unsupported value, fall through to the rejection below
156
+ // (fail-closed — never silently run a migration the user did not request).
157
+ let convention = 'milestone-prefixed';
158
+ const conventionFlagIdx = args.findIndex((a) => a === '--convention' || a.startsWith('--convention='));
159
+ if (conventionFlagIdx !== -1) {
160
+ const token = args[conventionFlagIdx];
161
+ convention = token.includes('=')
162
+ ? token.slice(token.indexOf('=') + 1)
163
+ : (args[conventionFlagIdx + 1] ?? '');
164
+ }
154
165
  if (convention !== 'milestone-prefixed') {
155
- process.stderr.write('Only --convention milestone-prefixed is supported\n');
156
- process.exit(1);
166
+ // No-throw hub contract (ADR-0012): a hub-dispatched handler must not call
167
+ // process.exit. Throw instead — the hub converts this to HandlerFailure and
168
+ // the adapter routes it through the injected error() boundary.
169
+ throw new Error('Only --convention milestone-prefixed is supported');
157
170
  }
158
171
  const plan = roadmapUpgrade.computeMigrationPlan(cwd);
159
172
  roadmapUpgrade.applyMigration(cwd, plan, { dryRun });