@opengsd/gsd-core 1.19.0 → 1.20.0

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 (106) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/commands/gsd/update.md +5 -2
  9. package/gsd-core/bin/gsd-tools.cjs +73 -23
  10. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  11. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  12. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  13. package/gsd-core/bin/lib/commands.cjs +27 -17
  14. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  15. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  16. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  17. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  18. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  19. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  20. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  21. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  22. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  23. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  24. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  25. package/gsd-core/bin/lib/init.cjs +180 -67
  26. package/gsd-core/bin/lib/io.cjs +6 -0
  27. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  28. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  29. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  30. package/gsd-core/bin/lib/milestone.cjs +18 -50
  31. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  32. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  33. package/gsd-core/bin/lib/phase-id.cjs +609 -35
  34. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  35. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  36. package/gsd-core/bin/lib/phase.cjs +2990 -666
  37. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  38. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  39. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  40. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  41. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  42. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  43. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  44. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  45. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  46. package/gsd-core/bin/lib/roadmap-parser.cjs +730 -144
  47. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  48. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  49. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  50. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  51. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  52. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  53. package/gsd-core/bin/lib/state-document.cjs +332 -23
  54. package/gsd-core/bin/lib/state-transition.cjs +39 -10
  55. package/gsd-core/bin/lib/state.cjs +498 -179
  56. package/gsd-core/bin/lib/template.cjs +2 -2
  57. package/gsd-core/bin/lib/validate.cjs +3 -2
  58. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  59. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  60. package/gsd-core/bin/lib/verify.cjs +2 -2
  61. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  62. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  63. package/gsd-core/references/cicd-review-patterns.md +45 -0
  64. package/gsd-core/references/necessity-review.md +48 -0
  65. package/gsd-core/workflows/code-review-fix.md +46 -5
  66. package/gsd-core/workflows/code-review.md +3 -0
  67. package/gsd-core/workflows/execute-phase.md +1 -1
  68. package/gsd-core/workflows/execute-plan.md +1 -1
  69. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  70. package/gsd-core/workflows/plan-phase.md +1 -1
  71. package/gsd-core/workflows/pr-branch.md +2 -0
  72. package/gsd-core/workflows/reapply-patches.md +104 -0
  73. package/gsd-core/workflows/review.md +54 -141
  74. package/gsd-core/workflows/secure-phase.md +1 -1
  75. package/gsd-core/workflows/settings-advanced.md +57 -9
  76. package/gsd-core/workflows/ship.md +12 -0
  77. package/hooks/dist/gsd-graphify-update.sh +6 -0
  78. package/hooks/dist/gsd-statusline.js +3 -2
  79. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  80. package/hooks/dist/gsd-workflow-guard.js +2 -1
  81. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  82. package/hooks/dist/lib/git-probe.js +16 -0
  83. package/hooks/gsd-graphify-update.sh +6 -0
  84. package/hooks/gsd-statusline.js +3 -2
  85. package/hooks/gsd-windsurf-pre-write.js +4 -4
  86. package/hooks/gsd-workflow-guard.js +2 -1
  87. package/hooks/gsd-worktree-path-guard.js +4 -4
  88. package/hooks/lib/git-probe.js +16 -0
  89. package/package.json +3 -3
  90. package/pi/package-agents/gsd-code-fixer.md +9 -4
  91. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  92. package/pi/package-agents/gsd-executor.md +8 -4
  93. package/pi/package-agents/manifest.json +3 -3
  94. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  95. package/scripts/ci-next-health.cjs +246 -23
  96. package/scripts/docs-guard-registry.cjs +2 -0
  97. package/scripts/gen-features.cjs +14 -2
  98. package/scripts/lib/drift-scan.cjs +27 -0
  99. package/scripts/lib/macos-conformance-tier.generated.cjs +6 -0
  100. package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
  101. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  102. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  103. package/scripts/mutation-matrix.cjs +23 -1
  104. package/scripts/sync-next-version.cjs +38 -6
  105. package/skills/gsd-update/SKILL.md +5 -2
  106. package/vscode/package.json +1 -1
@@ -1874,6 +1874,11 @@ const KNOWN_REVIEWER_FIELDS = new Set([
1874
1874
  // effort", which emits no effort argument at all.
1875
1875
  'effortConfigKey',
1876
1876
  'defaultEffort',
1877
+ // `contextWindowTokens` / `contextWindowConfigKey` added by #4559, same optional/backward-compatible
1878
+ // shape as `promptBudgetKey`: a manifest authored before they existed keeps validating, and an
1879
+ // absent or non-positive window resolves to "no derived prompt cap" at the invocation boundary.
1880
+ 'contextWindowTokens',
1881
+ 'contextWindowConfigKey',
1877
1882
  'handler',
1878
1883
  ]);
1879
1884
 
@@ -2505,6 +2510,16 @@ function validateReviewerBodyFields(cap) {
2505
2510
  );
2506
2511
  }
2507
2512
 
2513
+ // #4559: optional. Absent or null is "no declared window"; a present key must be a dotted
2514
+ // config key string. The window's VALUE is narrowed (never rejected) by `resolveLaneCap`.
2515
+ if (r.contextWindowConfigKey !== undefined && r.contextWindowConfigKey !== null
2516
+ && (typeof r.contextWindowConfigKey !== 'string' || r.contextWindowConfigKey.length === 0)) {
2517
+ errors.push(
2518
+ ctx + ' reviewer.contextWindowConfigKey must be a dotted config key or null ' +
2519
+ '(got: ' + describeValue(r.contextWindowConfigKey) + ')',
2520
+ );
2521
+ }
2522
+
2508
2523
  // ── handler (D6) — closed first-party enum; null is the default ────────────
2509
2524
  if (r.handler !== null && !VALID_LANE_HANDLERS.has(r.handler)) {
2510
2525
  errors.push(
@@ -31,13 +31,13 @@ const coreUtilsMod = require("./core-utils.cjs");
31
31
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
32
32
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
33
  const phaseIdMod = require("./phase-id.cjs");
34
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
34
+ const { customPhaseQuery, customDirectoryOwner, isCustomPhaseId, normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
35
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
36
  const phaseIdDisplayMod = require("./phase-id-display.cjs");
37
37
  const { renderBracketMilestoneDisplay } = phaseIdDisplayMod;
38
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
39
  const phaseLocatorMod = require("./phase-locator.cjs");
40
- const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
40
+ const { phaseDirectoryOptions, getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
41
41
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
42
  const roadmapParserMod = require("./roadmap-parser.cjs");
43
43
  const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getRoadmapPhaseInternal } = roadmapParserMod;
@@ -1822,7 +1822,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved) {
1822
1822
  // phase — the phase arm must never branch-mutate for it (isSentinelPhaseId
1823
1823
  // is the invariant's single owner, src/phase-id.cts).
1824
1824
  if (phaseNum && !isSentinelPhaseId(phaseNum)) {
1825
- const phaseInfo = findPhaseInternal(cwd, phaseNum);
1825
+ const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd, config));
1826
1826
  if (phaseInfo) {
1827
1827
  // #4126: shared with init.cts's cmdInitExecutePhase branch_name field
1828
1828
  // via the one canonical renderer (src/phase-id.cts) so an undeliverable
@@ -3053,6 +3053,7 @@ function cmdProgressRender(cwd, format, raw) {
3053
3053
  ? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
3054
3054
  : null;
3055
3055
  const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
3056
+ const directoryContext = phaseDirectoryOptions(cwd);
3056
3057
  const phases = [];
3057
3058
  let totalPlans = 0;
3058
3059
  let totalSummaries = 0;
@@ -3091,9 +3092,11 @@ function cmdProgressRender(cwd, format, raw) {
3091
3092
  }
3092
3093
  }
3093
3094
  else {
3095
+ const custom = customPhaseQuery(dir, directoryContext);
3096
+ const owner = custom && customDirectoryOwner(dir, custom);
3094
3097
  const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
3095
- phaseNum = dm ? dm[1] : dir;
3096
- phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
3098
+ phaseNum = owner ? owner.id : dm ? dm[1] : dir;
3099
+ phaseName = owner ? owner.slug.replace(/-/g, ' ') : dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
3097
3100
  }
3098
3101
  // #3183/#5060: canonical plan/summary counts and lifecycle status
3099
3102
  // (root+nested, superseded-excluded, canonical pairing) from the
@@ -3212,7 +3215,7 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
3212
3215
  .map(w => w.replace(/[^a-z0-9]/g, ''))
3213
3216
  .filter(w => w.length > 2 && !stopWords.has(w)));
3214
3217
  // Find phase directory to get expected file paths
3215
- const phaseInfoDisk = findPhaseInternal(cwd, phase);
3218
+ const phaseInfoDisk = findPhaseInternal(cwd, phase, undefined, phaseDirectoryOptions(cwd));
3216
3219
  const phasePlans = [];
3217
3220
  if (phaseInfoDisk && phaseInfoDisk['found']) {
3218
3221
  try {
@@ -3394,7 +3397,7 @@ function cmdScaffold(cwd, type, options, raw) {
3394
3397
  const padded = phase ? normalizePhaseName(phase) : '00';
3395
3398
  const today = clock_cjs_1.realClock.localToday();
3396
3399
  // Find phase directory
3397
- const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
3400
+ const phaseInfo = phase && type !== 'phase-dir' ? findPhaseInternal(cwd, phase, undefined, phaseDirectoryOptions(cwd)) : null;
3398
3401
  const phaseDir = phaseInfo ? node_path_1.default.join(cwd, phaseInfo['directory']) : null;
3399
3402
  if (phase && !phaseDir && type !== 'phase-dir') {
3400
3403
  error(`Phase ${phase} directory not found`);
@@ -3422,14 +3425,14 @@ function cmdScaffold(cwd, type, options, raw) {
3422
3425
  }
3423
3426
  const slug = generateSlugInternal(name);
3424
3427
  // #3287: apply project_code prefix to stay consistent with phase.add/phase.insert
3425
- const scaffoldConfig = loadConfig(cwd);
3426
- const scaffoldProjectCode = scaffoldConfig['project_code'] || '';
3427
- const scaffoldPrefix = scaffoldProjectCode ? `${scaffoldProjectCode}-` : '';
3428
- const dirName = `${scaffoldPrefix}${padded}-${slug}`;
3429
- const phasesParent = planningPaths(cwd).phases;
3430
- (0, shell_command_projection_cjs_1.platformEnsureDir)(phasesParent);
3431
- const dirPath = node_path_1.default.join(phasesParent, dirName);
3432
- (0, shell_command_projection_cjs_1.platformEnsureDir)(dirPath);
3428
+ const scaffoldConfig = phaseDirectoryOptions(cwd, undefined, '');
3429
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
3430
+ const roadmap = scaffoldConfig.phaseNaming === 'custom' && node_fs_1.default.existsSync(roadmapPath)
3431
+ ? node_fs_1.default.readFileSync(roadmapPath, 'utf8') : '';
3432
+ const [{ dirPath }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
3433
+ id: phase, slug: slug || '',
3434
+ directoryId: scaffoldConfig.phaseNaming === 'custom' && isCustomPhaseId(phase) ? phase : padded,
3435
+ }], { ...scaffoldConfig, roadmap }, false);
3433
3436
  output({ created: true, directory: toPosixPath(node_path_1.default.relative(cwd, dirPath)), path: dirPath }, raw, dirPath);
3434
3437
  return;
3435
3438
  }
@@ -3728,12 +3731,19 @@ function cmdStats(cwd, format, raw) {
3728
3731
  */
3729
3732
  function cmdCheckCommit(cwd, raw) {
3730
3733
  const config = loadConfig(cwd);
3731
- const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd });
3734
+ // #5548: execGit scrubs the repo-location vars, but inside the pre-commit hook git sets them
3735
+ // (GIT_DIR/GIT_WORK_TREE of an explicit --git-dir commit, GIT_INDEX_FILE of a `-a` / `<path>`
3736
+ // commit) to name the repo being committed: hand them back or the guard finds no repo and allows.
3737
+ const hookEnv = {};
3738
+ for (const k of shell_command_projection_cjs_1.GIT_LOCATION_ENV_VARS)
3739
+ if (process.env[k] !== undefined)
3740
+ hookEnv[k] = process.env[k];
3741
+ const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd, env: hookEnv });
3732
3742
  if (stagedResult.exitCode === 0) {
3733
3743
  const files = stagedResult.stdout.split('\0').filter(Boolean);
3734
3744
  const planningFiles = files.filter(f => f.startsWith('.planning/'));
3735
3745
  if (planningFiles.length > 0) {
3736
- const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning'));
3746
+ const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning', hookEnv));
3737
3747
  if (!policy.resolved) {
3738
3748
  error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
3739
3749
  planningFiles.map(f => ` ${f}`).join('\n') +
@@ -213,16 +213,16 @@ function _deepMergeConfig(base, overlay) {
213
213
  const _warnedUnknownConfigKeys = new Set();
214
214
  // ─── Git utilities ────────────────────────────────────────────────────────────
215
215
  const _gitIgnoredCache = new Map();
216
- function isGitIgnored(cwd, targetPath) {
216
+ function isGitIgnored(cwd, targetPath, env) {
217
217
  // #2206: strip trailing slashes — `git check-ignore` has a quirk where a
218
218
  // CRLF .gitignore with blank lines falsely reports a trailing-slash path
219
219
  // (e.g. `.planning/`) as ignored. Normalizing here protects every call site.
220
220
  const normalized = targetPath.replace(/\/+$/, '');
221
- const key = cwd + '::' + normalized;
221
+ const key = cwd + '::' + normalized + (env ? '::env:' + JSON.stringify(Object.entries(env).sort()) : '');
222
222
  if (_gitIgnoredCache.has(key))
223
223
  return _gitIgnoredCache.get(key);
224
224
  // --no-index checks .gitignore rules regardless of whether the file is tracked.
225
- const result = (0, shell_command_projection_cjs_1.execGit)(['check-ignore', '-q', '--no-index', '--', normalized], { cwd });
225
+ const result = (0, shell_command_projection_cjs_1.execGit)(['check-ignore', '-q', '--no-index', '--', normalized], { cwd, env });
226
226
  const ignored = result.exitCode === 0;
227
227
  _gitIgnoredCache.set(key, ignored);
228
228
  return ignored;
@@ -42,6 +42,7 @@ const node_path_1 = __importDefault(require("node:path"));
42
42
  const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
43
43
  // pattern.cjs is a leaf module (escapeRegex only), so this adds no cycle.
44
44
  const pattern_cjs_1 = require("./pattern.cjs");
45
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
45
46
  // eslint-disable-next-line @typescript-eslint/no-require-imports
46
47
  const phaseIdModule = require("./phase-id.cjs");
47
48
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -553,6 +554,15 @@ function planRowPattern(planId, opts = {}) {
553
554
  const lead = opts.anchored ? '^(\\s*-\\s*\\[)' : '(-\\s*\\[)';
554
555
  return new RegExp(`${lead}${box}(\\]\\s*(?:\\*\\*)?${(0, pattern_cjs_1.escapeRegex)(planId)}${PLAN_ID_ROW_BOUNDARY_SOURCE}(?:\\*\\*)?)`, 'i');
555
556
  }
557
+ /**
558
+ * Tick the first line-anchored unchecked row for `planId` in `text` (the
559
+ * `phase complete` / `roadmap update-plan-progress` bullet flip): a bullet
560
+ * mutation through `updateBullet`, so never a fenced line or mid-line match.
561
+ */
562
+ function tickAnchoredPlanRow(text, planId) {
563
+ const row = planRowPattern(planId, { anchored: true });
564
+ return (0, markdown_sectionizer_cjs_1.updateBullet)(text, (_bulletText, rawLine) => row.test(rawLine), (rawLine) => rawLine.replace(row, '$1x$2'));
565
+ }
556
566
  module.exports = {
557
567
  toPosixPath,
558
568
  normalizeLineEndings,
@@ -570,4 +580,5 @@ module.exports = {
570
580
  findOrphanSummaries,
571
581
  summaryPairsWithAnyPlan,
572
582
  planRowPattern,
583
+ tickAnchoredPlanRow,
573
584
  };
@@ -851,6 +851,17 @@ function frontmatterListEntries(content, key) {
851
851
  * no closed, parseable frontmatter mapping or the key is not an own key of it.
852
852
  */
853
853
  function rawFrontmatterField(content, key) {
854
+ const mapping = rawFrontmatterMapping(content);
855
+ if (!mapping || !Object.prototype.hasOwnProperty.call(mapping, key))
856
+ return null;
857
+ return { value: mapping[key] };
858
+ }
859
+ /**
860
+ * The top-level frontmatter mapping VERBATIM, off `rawFrontmatterField`'s guarded parse path.
861
+ * Null when the document has no closed, parseable frontmatter mapping — so a caller can tell
862
+ * "parsed, without that key" (#4998 review) from "no usable frontmatter".
863
+ */
864
+ function rawFrontmatterMapping(content) {
854
865
  const found = frontmatterRegion(content);
855
866
  if (!found || !found.terminated)
856
867
  return null;
@@ -867,9 +878,7 @@ function rawFrontmatterField(content, key) {
867
878
  }
868
879
  if (!raw || typeof raw !== 'object' || Array.isArray(raw))
869
880
  return null;
870
- if (!Object.prototype.hasOwnProperty.call(raw, key))
871
- return null;
872
- return { value: raw[key] };
881
+ return raw;
873
882
  }
874
883
  /**
875
884
  * Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1
@@ -940,6 +949,14 @@ function scalarNeedsDoubleQuoting(s) {
940
949
  // round-trip.
941
950
  if (/[\uD800-\uDFFF]/.test(s))
942
951
  return true;
952
+ // #4998: U+2028/U+2029 are not YAML line breaks, so a bare one round-trips
953
+ // through js-yaml, but every line-anchored reader of a planning document
954
+ // (`^key:\s*(.+)` with the `m` flag) ends the line there and reads only the
955
+ // part before it. (A value holding `:` was already quoted by the callers, so
956
+ // no key could be forged this way.) The quoted path escapes them (`\L`,
957
+ // `\P`), which no such reader splits on.
958
+ if (/[\u2028\u2029]/.test(s))
959
+ return true;
943
960
  return false;
944
961
  }
945
962
  /**
@@ -1806,6 +1823,7 @@ module.exports = {
1806
1823
  // parse path — the decision-coverage gates read `must_haves`/`truths`/`objective` and the
1807
1824
  // SUMMARY `files_modified` list through it instead of a hand-rolled `^key:` scan / regex.
1808
1825
  rawFrontmatterField,
1826
+ rawFrontmatterMapping,
1809
1827
  // #5139: one key's block as unparsed text, for a citation scan that must survive frontmatter
1810
1828
  // the YAML parser refuses.
1811
1829
  frontmatterKeyBlockText,
@@ -39,7 +39,7 @@ const planningWorkspaceMod = require("./planning-workspace.cjs");
39
39
  const { planningDir } = planningWorkspaceMod;
40
40
  // eslint-disable-next-line @typescript-eslint/no-require-imports
41
41
  const phaseLocatorMod = require("./phase-locator.cjs");
42
- const { findPhaseInternal } = phaseLocatorMod;
42
+ const { phaseDirectoryOptions, findPhaseInternal } = phaseLocatorMod;
43
43
  const security_cjs_1 = require("./security.cjs");
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const roadmapModule = require("./roadmap.cjs");
@@ -171,7 +171,7 @@ function evaluateApiCoverageVerifyPre(input) {
171
171
  let resolvedDir = null;
172
172
  let phaseNumber = '';
173
173
  if (token) {
174
- const found = findPhaseInternal(projectDir, token);
174
+ const found = findPhaseInternal(projectDir, token, undefined, phaseDirectoryOptions(projectDir));
175
175
  if (found && found.directory) {
176
176
  // findPhaseInternal's `directory` is relative to the project root; anchor it to `projectDir`
177
177
  // (not the process cwd) so the gate reads the same files in-process as it does from the CLI.
@@ -85,7 +85,7 @@ function runContextDriftGate(cwd, phaseArg) {
85
85
  }
86
86
  // Same phase-directory resolution rule the schema-drift gate uses (#1571, #2528):
87
87
  // matchPhaseDirs, never a naive substring test.
88
- const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(phasesDir, phaseArg);
88
+ const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(cwd, phasesDir, phaseArg);
89
89
  if (!phaseDir) {
90
90
  return unresolvable();
91
91
  }
@@ -34,10 +34,7 @@ const planningWorkspaceMod = require("./planning-workspace.cjs");
34
34
  const { planningDir } = planningWorkspaceMod;
35
35
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
36
  const phaseLocatorMod = require("./phase-locator.cjs");
37
- const { findPhaseInternal } = phaseLocatorMod;
38
- // eslint-disable-next-line @typescript-eslint/no-require-imports
39
- const phaseIdMod = require("./phase-id.cjs");
40
- const { normalizePhaseName, matchPhaseDirs } = phaseIdMod;
37
+ const { phaseDirectoryOptions, findPhaseInternal, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
41
38
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
39
  const roadmapModule = require("./roadmap.cjs");
43
40
  const { getRoadmapPhaseWithFallback } = roadmapModule;
@@ -78,7 +75,7 @@ function unresolvableProbeVerdict(readError) {
78
75
  function resolvePhaseDir(projectDir, phase) {
79
76
  let result;
80
77
  try {
81
- result = findPhaseInternal(projectDir, phase);
78
+ result = findPhaseInternal(projectDir, phase, undefined, phaseDirectoryOptions(projectDir));
82
79
  }
83
80
  catch (err) {
84
81
  return (0, gate_evidence_cjs_1.evidenceFromError)(err, `phase ${phase}`);
@@ -109,11 +106,13 @@ function resolvePhaseDir(projectDir, phase) {
109
106
  * `resolvePhaseDir` would widen what those gates inspect, so the two stay apart and each states its
110
107
  * scope. Throws when `phasesDir` cannot be listed; both callers sit inside their non-blocking catch.
111
108
  */
112
- function resolvePhaseDirByToken(phasesDir, phaseArg) {
113
- const normalizedPhase = normalizePhaseName(phaseArg);
109
+ function resolvePhaseDirByToken(cwd, phasesDir, phaseArg, directoryContext = phaseDirectoryOptions(cwd)) {
110
+ const lookup = resolvePhaseDirectoryLookup(cwd, phaseArg, undefined, directoryContext);
114
111
  const dirEntries = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true });
115
112
  const dirNames = dirEntries.filter((e) => e.isDirectory()).map((e) => e.name);
116
- const matched = matchPhaseDirs(dirNames, normalizedPhase).matches[0];
113
+ // #4304: both drift gates resolve a directory in the current checkout.
114
+ // Bracket is the only opt-in grammar; all other conventions remain legacy.
115
+ const matched = matchPhaseDirsForLookup(dirNames, lookup, undefined, directoryContext).matches[0];
117
116
  if (matched)
118
117
  return node_path_1.default.join(phasesDir, matched);
119
118
  const contained = (0, security_cjs_1.tryWithinRoot)(phaseArg, phasesDir);
@@ -68,7 +68,7 @@ function runSchemaDriftGate(cwd, phaseArg, skipFlag) {
68
68
  // matching "11-expansion"), making the drift gate inspect the wrong phase.
69
69
  // This shares the one selection rule with find-phase / verify
70
70
  // phase-completeness rather than restating it. (#1571, #2528)
71
- const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(phasesDir, phaseArg);
71
+ const phaseDir = (0, gate_phase_context_cjs_1.resolvePhaseDirByToken)(cwd, phasesDir, phaseArg);
72
72
  if (!phaseDir) {
73
73
  // An unresolvable phase is "could not look" (#5170): there is no phase directory to evaluate.
74
74
  return schemaDriftUnreadable(`Phase directory not found: ${phaseArg}`);
@@ -32,14 +32,9 @@ function excludeFromGit(dir, filename) {
32
32
  // gitignore cannot express a newline in a pattern, and git's line output could not be split.
33
33
  if (/[\r\n]/.test(dir))
34
34
  return;
35
- // cwd must pick the repo: drop inherited repository-location variables (e.g. inside a git hook).
36
- const env = { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, GIT_COMMON_DIR: undefined };
37
35
  // The prefix goes last: stdout is trimmed, and a non-empty prefix ends in `/`, so only an
38
36
  // empty prefix (dir is the git root) loses its line; a leading space survives.
39
- const revParse = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'info/exclude', '--show-prefix'], {
40
- cwd: dir,
41
- env: env,
42
- });
37
+ const revParse = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'info/exclude', '--show-prefix'], { cwd: dir });
43
38
  const [excludeRel, prefix = ''] = (0, text_lines_cjs_1.splitLines)(String(revParse.stdout));
44
39
  if (revParse.exitCode !== 0 || !excludeRel)
45
40
  return;
@@ -54,7 +54,7 @@ const validateMod = require("../validate.cjs");
54
54
  const { isPhaseDirName } = validateMod;
55
55
  // eslint-disable-next-line @typescript-eslint/no-require-imports
56
56
  const phaseIdMod = require("../phase-id.cjs");
57
- const { extractPhaseToken, normalizePhaseName, comparePhaseNum } = phaseIdMod;
57
+ const { extractPhaseToken, normalizePhaseName, phaseKeyFromDir, comparePhaseNum } = phaseIdMod;
58
58
  // #5060: the Phase Status Module owns the status-label ladder.
59
59
  const phase_status_cjs_1 = require("../phase-status.cjs");
60
60
  // #5291: W030 asks whether a raw report status has a known repair. The lookup is
@@ -63,8 +63,11 @@ const verification_cjs_1 = require("../verification.cjs");
63
63
  // ─── W005 — phase directory doesn't follow NN-name format (verify.cts:1893-1902) ─
64
64
  function checkW005(snapshot) {
65
65
  const diagnostics = [];
66
+ const context = snapshot.directoryContext ?? phaseIdMod.phaseDirectoryContext(snapshot.config.value ?? {}, '', snapshot.roadmapDeclaredPhases.value.map(({ phaseId }) => phaseId));
66
67
  for (const name of snapshot.phaseDirs.value) {
67
- if (!isPhaseDirName(name, snapshot.phaseIdConvention)) {
68
+ const custom = snapshot.phaseIdConvention === 'bracket' ? null : phaseIdMod.customPhaseQuery(name, context);
69
+ const owner = custom && phaseIdMod.customDirectoryOwner(name, custom);
70
+ if (!owner && !isPhaseDirName(name, snapshot.phaseIdConvention)) {
68
71
  diagnostics.push({
69
72
  code: 'W005',
70
73
  severity: SEVERITY.WARNING,
@@ -77,17 +80,18 @@ function checkW005(snapshot) {
77
80
  }
78
81
  // ─── W023 — phase directories collide on normalized key (verify.cts:1904-1950) ─
79
82
  //
80
- // Groups `snapshot.phaseDirs.value` by `normalizePhaseName(extractPhaseToken(name))`
81
- // — the exact same two owners (`phase-id.cjs`) the original `verify.cts:1917-1918`
82
- // call site uses, relocated verbatim rather than reimplemented. Sorted with
83
- // `comparePhaseNum` + a `localeCompare` tiebreak, mirroring
83
+ // Groups directories by the canonical identity owner, preserving recorded
84
+ // custom IDs instead of collapsing their shared token prefixes. Sorted with
85
+ // contextual `comparePhaseNum` + a `localeCompare` tiebreak, mirroring
84
86
  // `verify.cts:1930-1932`'s deterministic-output rationale. See the file-level
85
87
  // comment for the disclosed "described" fidelity reduction.
86
88
  function checkW023(snapshot) {
89
+ const context = snapshot.directoryContext;
87
90
  const groups = new Map();
88
91
  for (const name of snapshot.phaseDirs.value) {
89
- const token = extractPhaseToken(name);
90
- const key = normalizePhaseName(token);
92
+ const key = context?.phaseNaming === 'custom'
93
+ ? phaseKeyFromDir(name, undefined, context)
94
+ : normalizePhaseName(extractPhaseToken(name));
91
95
  const list = groups.get(key);
92
96
  if (list)
93
97
  list.push(name);
@@ -101,7 +105,7 @@ function checkW023(snapshot) {
101
105
  continue;
102
106
  const described = dirs
103
107
  .slice()
104
- .sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b)))
108
+ .sort((a, b) => comparePhaseNum(a, b, context) || String(a).localeCompare(String(b)))
105
109
  .map((d) => {
106
110
  const phase = phaseByDir.get(d);
107
111
  const plans = phase ? phase.planCount : 0;
@@ -104,7 +104,7 @@ const planningScopeMod = require("../planning-scope.cjs");
104
104
  const { SCOPE } = planningScopeMod;
105
105
  // eslint-disable-next-line @typescript-eslint/no-require-imports
106
106
  const phaseIdMod = require("../phase-id.cjs");
107
- const { matchPhaseDirs, normalizePhaseName, extractPhaseToken, isSentinelPhaseId, isSentinelPhaseDir } = phaseIdMod;
107
+ const { matchPhaseDirs, phaseLookupQuery, extractPhaseToken, isSentinelPhaseId, isSentinelPhaseDir } = phaseIdMod;
108
108
  // eslint-disable-next-line @typescript-eslint/no-require-imports
109
109
  const validateMod = require("../validate.cjs");
110
110
  const { phaseVariants } = validateMod;
@@ -116,7 +116,7 @@ const { phaseVariants } = validateMod;
116
116
  * does the roadmap claim, for W007) call this — one matcher, reused, per the
117
117
  * file-level comment.
118
118
  */
119
- function dirsForPhase(dirs, phaseId, convention) {
119
+ function dirsForPhase(dirs, phaseId, convention, directoryContext) {
120
120
  // #612: `convention` reaches BOTH operands of this pairing or it pairs
121
121
  // nothing. The phases come from a bracket-aware heading scan and the dirs
122
122
  // from a bracket-aware disk scan, so a convention-less selector here leaves
@@ -125,7 +125,7 @@ function dirsForPhase(dirs, phaseId, convention) {
125
125
  // disk" AND every bracket directory reads as unclaimed. `matchPhaseDirs`
126
126
  // forwards it to its primary `phaseTokenMatches`; handed nothing it is
127
127
  // byte-identical, so no legacy repo's pairing changes.
128
- const canonical = matchPhaseDirs(dirs, normalizePhaseName(phaseId), convention).matches;
128
+ const canonical = matchPhaseDirs(dirs, phaseLookupQuery(phaseId, directoryContext), convention, undefined, directoryContext).matches;
129
129
  if (canonical.length > 0)
130
130
  return canonical;
131
131
  // Bug 2 (#3309 W006/W007 migration cluster, found while fixing the
@@ -187,6 +187,7 @@ function checkW006(snapshot) {
187
187
  return [];
188
188
  const dirs = snapshot.allPhaseDirNames.value;
189
189
  const convention = snapshot.phaseIdConvention;
190
+ const directoryContext = snapshot.directoryContext ?? phaseIdMod.phaseDirectoryContext(snapshot.config.value ?? {}, '', snapshot.roadmapDeclaredPhases.value.map(({ phaseId }) => phaseId));
190
191
  const archivedTokens = new Set(snapshot.archivedPhaseTokens.value);
191
192
  // No separate scope guard: `roadmapSentinelPhaseTokens` and
192
193
  // `roadmapDeclaredPhases` are produced by ONE builder call
@@ -219,7 +220,7 @@ function checkW006(snapshot) {
219
220
  // not change its result.
220
221
  if (sentinelTokens.has(phaseId) || isSentinelPhaseId(phaseId))
221
222
  continue;
222
- if (dirsForPhase(dirs, phaseId, convention).length > 0)
223
+ if (dirsForPhase(dirs, phaseId, convention, directoryContext).length > 0)
223
224
  continue;
224
225
  // Bug 1 (#3309 W006/W007 migration cluster): a phase whose ONLY
225
226
  // directory lives under a milestone archive
@@ -247,10 +248,10 @@ function checkW006(snapshot) {
247
248
  // ─── W007 — an on-disk phase directory has no matching ROADMAP entry ──────
248
249
  // (verify.cts:2086-2101)
249
250
  /** Every directory in `dirs` that ANY declared roadmap phase resolves to. */
250
- function computeClaimedDirs(dirs, declaredPhases, convention) {
251
+ function computeClaimedDirs(dirs, declaredPhases, convention, directoryContext) {
251
252
  const claimed = new Set();
252
253
  for (const { phaseId } of declaredPhases) {
253
- for (const dir of dirsForPhase(dirs, phaseId, convention))
254
+ for (const dir of dirsForPhase(dirs, phaseId, convention, directoryContext))
254
255
  claimed.add(dir);
255
256
  }
256
257
  return claimed;
@@ -261,7 +262,8 @@ function checkW007(snapshot) {
261
262
  return [];
262
263
  const dirs = snapshot.allPhaseDirNames.value;
263
264
  const convention = snapshot.phaseIdConvention;
264
- const claimedDirs = computeClaimedDirs(dirs, snapshot.roadmapDeclaredPhases.value, convention);
265
+ const directoryContext = snapshot.directoryContext ?? phaseIdMod.phaseDirectoryContext(snapshot.config.value ?? {}, '', snapshot.roadmapDeclaredPhases.value.map(({ phaseId }) => phaseId));
266
+ const claimedDirs = computeClaimedDirs(dirs, snapshot.roadmapDeclaredPhases.value, convention, directoryContext);
265
267
  const diagnostics = [];
266
268
  for (const dirName of dirs) {
267
269
  // `extractPhaseToken` is the phase-id.cts owner `PHASE_TOKEN_FROM_DIR_RE`
@@ -46,7 +46,7 @@ const healthDiagnosticMod = require("../health-diagnostic-types.cjs");
46
46
  const { SEVERITY, adviseRemedy } = healthDiagnosticMod;
47
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
48
48
  const phaseIdMod = require("../phase-id.cjs");
49
- const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
49
+ const { getMilestoneFromPhaseId, matchPhaseDirs, phaseLookupQuery, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
50
50
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- the single owner of the persisted status vocabulary
51
51
  const stateDocumentMod = require("../state-document.cjs");
52
52
  const { normalizeStateStatus } = stateDocumentMod;
@@ -332,8 +332,8 @@ const RULE_W026 = {
332
332
  // field's own doc comment on `PlanningSnapshot` for why
333
333
  // `roadmapDeclaredPhases`'s `milestone` attribution is the wrong fit).
334
334
  const unstarted = [];
335
+ const directoryContext = snapshot.directoryContext ?? phaseIdMod.phaseDirectoryContext(snapshot.config.value ?? {}, '', snapshot.currentMilestoneRoadmapPhaseIds.value);
335
336
  for (const phaseId of snapshot.currentMilestoneRoadmapPhaseIds.value) {
336
- const normalized = normalizePhaseName(phaseId);
337
337
  // `allPhaseDirNames` — every directory under `phases/`, UNWINDOWED by
338
338
  // ROADMAP-declaration membership — mirrors the original's own
339
339
  // unwindowed `phaseDirNames2` (`verify.cts:2372-2382`, a direct
@@ -346,7 +346,7 @@ const RULE_W026 = {
346
346
  // moved the read onto the shared selector; the convention rides along,
347
347
  // since the selector's primary match IS the `phaseTokenMatches` call this
348
348
  // line used to make directly.
349
- const hasDirectory = matchPhaseDirs(snapshot.allPhaseDirNames.value, normalized, snapshot.phaseIdConvention).matches.length > 0;
349
+ const hasDirectory = matchPhaseDirs(snapshot.allPhaseDirNames.value, phaseLookupQuery(phaseId, directoryContext), snapshot.phaseIdConvention, undefined, directoryContext).matches.length > 0;
350
350
  if (!hasDirectory)
351
351
  unstarted.push(phaseId);
352
352
  }