@opengsd/gsd-core 1.19.0 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) 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/bin/install.js +36 -40
  9. package/commands/gsd/new-milestone.md +2 -2
  10. package/commands/gsd/new-project.md +2 -2
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +177 -93
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  14. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  15. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  16. package/gsd-core/bin/lib/commands.cjs +46 -17
  17. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  18. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  19. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  20. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  21. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  22. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  23. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  24. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  25. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  26. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  27. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  28. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  29. package/gsd-core/bin/lib/init.cjs +181 -68
  30. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  31. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  32. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  33. package/gsd-core/bin/lib/io.cjs +6 -0
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  35. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  36. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  37. package/gsd-core/bin/lib/milestone.cjs +18 -50
  38. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  39. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  40. package/gsd-core/bin/lib/phase-id.cjs +627 -36
  41. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  42. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  43. package/gsd-core/bin/lib/phase.cjs +3039 -666
  44. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  45. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  47. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  48. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  49. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  51. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  52. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
  54. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  55. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  56. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  57. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  58. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  60. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  61. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  62. package/gsd-core/bin/lib/state-document.cjs +332 -23
  63. package/gsd-core/bin/lib/state-transition.cjs +51 -15
  64. package/gsd-core/bin/lib/state.cjs +499 -180
  65. package/gsd-core/bin/lib/template.cjs +2 -2
  66. package/gsd-core/bin/lib/validate.cjs +3 -2
  67. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  68. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  69. package/gsd-core/bin/lib/verify.cjs +2 -2
  70. package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
  71. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  72. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  73. package/gsd-core/references/cicd-review-patterns.md +45 -0
  74. package/gsd-core/references/necessity-review.md +48 -0
  75. package/gsd-core/workflows/code-review-fix.md +46 -5
  76. package/gsd-core/workflows/code-review.md +3 -0
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/execute-plan.md +1 -1
  79. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/pr-branch.md +2 -0
  82. package/gsd-core/workflows/reapply-patches.md +104 -0
  83. package/gsd-core/workflows/review.md +54 -141
  84. package/gsd-core/workflows/secure-phase.md +1 -1
  85. package/gsd-core/workflows/settings-advanced.md +57 -9
  86. package/gsd-core/workflows/ship.md +12 -0
  87. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  88. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  89. package/hooks/dist/gsd-graphify-update.sh +6 -0
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  92. package/hooks/dist/gsd-workflow-guard.js +2 -1
  93. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  94. package/hooks/dist/lib/git-probe.js +16 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  96. package/hooks/gsd-agent-isolation-guard.js +1 -1
  97. package/hooks/gsd-cursor-subagent-start.js +1 -1
  98. package/hooks/gsd-graphify-update.sh +6 -0
  99. package/hooks/gsd-statusline.js +3 -2
  100. package/hooks/gsd-windsurf-pre-write.js +4 -4
  101. package/hooks/gsd-workflow-guard.js +2 -1
  102. package/hooks/gsd-worktree-path-guard.js +4 -4
  103. package/hooks/lib/git-probe.js +16 -0
  104. package/hooks/lib/isolation-sentinel.js +77 -2
  105. package/package.json +3 -3
  106. package/pi/package-agents/gsd-code-fixer.md +9 -4
  107. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  108. package/pi/package-agents/gsd-executor.md +8 -4
  109. package/pi/package-agents/manifest.json +3 -3
  110. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  111. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  112. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  113. package/scripts/changeset/lint.cjs +28 -3
  114. package/scripts/ci-next-health.cjs +246 -23
  115. package/scripts/docs-guard-registry.cjs +2 -0
  116. package/scripts/gen-features.cjs +14 -2
  117. package/scripts/lib/drift-scan.cjs +27 -0
  118. package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
  119. package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
  120. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  121. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  122. package/scripts/merge-commit-pr.cjs +186 -0
  123. package/scripts/mutation-matrix.cjs +23 -1
  124. package/scripts/release-ancestry.cjs +86 -0
  125. package/scripts/release-pipeline-pr.cjs +92 -0
  126. package/scripts/require-issue-link-policy.cjs +18 -9
  127. package/scripts/sync-next-version.cjs +48 -7
  128. package/skills/gsd-new-milestone/SKILL.md +1 -1
  129. package/skills/gsd-new-project/SKILL.md +1 -1
  130. package/skills/gsd-update/SKILL.md +5 -2
  131. 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(
@@ -14,6 +14,7 @@ const node_path_1 = __importDefault(require("node:path"));
14
14
  const text_lines_cjs_1 = require("./text-lines.cjs");
15
15
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
16
  const pattern_cjs_1 = require("./pattern.cjs");
17
+ const install_manifest_record_cjs_1 = require("./install-manifest-record.cjs");
17
18
  const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
18
19
  const security_cjs_1 = require("./security.cjs");
19
20
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -31,13 +32,13 @@ const coreUtilsMod = require("./core-utils.cjs");
31
32
  const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
32
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports
33
34
  const phaseIdMod = require("./phase-id.cjs");
34
- const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
35
+ const { customPhaseQuery, customDirectoryOwner, isCustomPhaseId, normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
35
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
36
37
  const phaseIdDisplayMod = require("./phase-id-display.cjs");
37
38
  const { renderBracketMilestoneDisplay } = phaseIdDisplayMod;
38
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
39
40
  const phaseLocatorMod = require("./phase-locator.cjs");
40
- const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
41
+ const { phaseDirectoryOptions, getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
41
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports
42
43
  const roadmapParserMod = require("./roadmap-parser.cjs");
43
44
  const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getRoadmapPhaseInternal } = roadmapParserMod;
@@ -835,6 +836,7 @@ function cmdEffortSync(cwd, raw, opts) {
835
836
  // cmdEffortSyncCodex and cmdEffortSyncOpencode, so a fault mid-write can
836
837
  // never leave an agent file truncated or empty.
837
838
  let fileFailureCount = 0;
839
+ const rewrittenFiles = [];
838
840
  for (const file of files) {
839
841
  const agentName = file.replace(/\.md$/, '');
840
842
  const filePath = node_path_1.default.join(agentsDir, file);
@@ -852,6 +854,8 @@ function cmdEffortSync(cwd, raw, opts) {
852
854
  fileFailureCount++;
853
855
  continue;
854
856
  }
857
+ // #5683: hash BEFORE any rewrite — only a GSD-owned file gets re-recorded.
858
+ const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath);
855
859
  // Resolve using install-time logic: home defaults merged with project config.
856
860
  const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
857
861
  // #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
@@ -915,6 +919,7 @@ function cmdEffortSync(cwd, raw, opts) {
915
919
  }
916
920
  catch { /* non-fatal: proceed with default tmp-file mode */ }
917
921
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathInherit, filePath);
922
+ rewrittenFiles.push({ path: filePath, preHash });
918
923
  }
919
924
  catch {
920
925
  try {
@@ -992,6 +997,7 @@ function cmdEffortSync(cwd, raw, opts) {
992
997
  }
993
998
  catch { /* non-fatal: proceed with default tmp-file mode */ }
994
999
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathSet, filePath);
1000
+ rewrittenFiles.push({ path: filePath, preHash });
995
1001
  }
996
1002
  catch {
997
1003
  try {
@@ -1006,6 +1012,9 @@ function cmdEffortSync(cwd, raw, opts) {
1006
1012
  changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
1007
1013
  synced++;
1008
1014
  }
1015
+ // #5683: keep the install manifest in step with the agents just rewritten.
1016
+ if (!dryRun)
1017
+ (0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
1009
1018
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, fileFailureCount > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1010
1019
  }
1011
1020
  /**
@@ -1048,6 +1057,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1048
1057
  const refused = [];
1049
1058
  const writeFailures = [];
1050
1059
  const readFailures = [];
1060
+ const rewrittenFiles = [];
1051
1061
  let synced = 0;
1052
1062
  let skipped = 0;
1053
1063
  for (const file of files) {
@@ -1066,6 +1076,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1066
1076
  readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1067
1077
  continue;
1068
1078
  }
1079
+ const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath); // #5683
1069
1080
  const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
1070
1081
  if (!parsed.ok) {
1071
1082
  // Never partially rewritten (40-design.md, ADR-2313 reader/writer
@@ -1134,6 +1145,7 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1134
1145
  }
1135
1146
  catch { /* non-fatal: proceed with default tmp-file mode */ }
1136
1147
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1148
+ rewrittenFiles.push({ path: filePath, preHash });
1137
1149
  }
1138
1150
  catch (err) {
1139
1151
  // Reported, not thrown — the remaining agents still get processed.
@@ -1150,6 +1162,8 @@ function cmdEffortSyncCodex(raw, dryRun, configDir) {
1150
1162
  changes.push(...pendingChanges);
1151
1163
  synced++;
1152
1164
  }
1165
+ if (!dryRun)
1166
+ (0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
1153
1167
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1154
1168
  }
1155
1169
  /**
@@ -1198,6 +1212,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1198
1212
  const changes = [];
1199
1213
  const writeFailures = [];
1200
1214
  const readFailures = [];
1215
+ const rewrittenFiles = [];
1201
1216
  let synced = 0;
1202
1217
  let skipped = 0;
1203
1218
  for (const file of files) {
@@ -1216,6 +1231,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1216
1231
  readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
1217
1232
  continue;
1218
1233
  }
1234
+ const preHash = (0, install_manifest_record_cjs_1.tryFileHash)(filePath); // #5683
1219
1235
  // `target === null` covers both "no effort configured" (inherit) and "a
1220
1236
  // level OpenCode does not accept" — both must produce NO `variant:` key,
1221
1237
  // exactly what install writes.
@@ -1312,6 +1328,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1312
1328
  }
1313
1329
  catch { /* non-fatal: proceed with default tmp-file mode */ }
1314
1330
  (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
1331
+ rewrittenFiles.push({ path: filePath, preHash });
1315
1332
  }
1316
1333
  catch (err) {
1317
1334
  try {
@@ -1340,6 +1357,8 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1340
1357
  // Making the exit code reflect failure would be a CLI-contract change
1341
1358
  // affecting all three cmdEffortSync* branches (claude, codex, opencode) and
1342
1359
  // is out of scope for this fix.
1360
+ if (!dryRun)
1361
+ (0, install_manifest_record_cjs_1.recordManifestFileHashes)(node_path_1.default.dirname(agentsDir), rewrittenFiles);
1343
1362
  output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
1344
1363
  }
1345
1364
  /**
@@ -1822,7 +1841,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved) {
1822
1841
  // phase — the phase arm must never branch-mutate for it (isSentinelPhaseId
1823
1842
  // is the invariant's single owner, src/phase-id.cts).
1824
1843
  if (phaseNum && !isSentinelPhaseId(phaseNum)) {
1825
- const phaseInfo = findPhaseInternal(cwd, phaseNum);
1844
+ const phaseInfo = findPhaseInternal(cwd, phaseNum, undefined, phaseDirectoryOptions(cwd, config));
1826
1845
  if (phaseInfo) {
1827
1846
  // #4126: shared with init.cts's cmdInitExecutePhase branch_name field
1828
1847
  // via the one canonical renderer (src/phase-id.cts) so an undeliverable
@@ -3053,6 +3072,7 @@ function cmdProgressRender(cwd, format, raw) {
3053
3072
  ? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
3054
3073
  : null;
3055
3074
  const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
3075
+ const directoryContext = phaseDirectoryOptions(cwd);
3056
3076
  const phases = [];
3057
3077
  let totalPlans = 0;
3058
3078
  let totalSummaries = 0;
@@ -3091,9 +3111,11 @@ function cmdProgressRender(cwd, format, raw) {
3091
3111
  }
3092
3112
  }
3093
3113
  else {
3114
+ const custom = customPhaseQuery(dir, directoryContext);
3115
+ const owner = custom && customDirectoryOwner(dir, custom);
3094
3116
  const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
3095
- phaseNum = dm ? dm[1] : dir;
3096
- phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
3117
+ phaseNum = owner ? owner.id : dm ? dm[1] : dir;
3118
+ phaseName = owner ? owner.slug.replace(/-/g, ' ') : dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
3097
3119
  }
3098
3120
  // #3183/#5060: canonical plan/summary counts and lifecycle status
3099
3121
  // (root+nested, superseded-excluded, canonical pairing) from the
@@ -3212,7 +3234,7 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
3212
3234
  .map(w => w.replace(/[^a-z0-9]/g, ''))
3213
3235
  .filter(w => w.length > 2 && !stopWords.has(w)));
3214
3236
  // Find phase directory to get expected file paths
3215
- const phaseInfoDisk = findPhaseInternal(cwd, phase);
3237
+ const phaseInfoDisk = findPhaseInternal(cwd, phase, undefined, phaseDirectoryOptions(cwd));
3216
3238
  const phasePlans = [];
3217
3239
  if (phaseInfoDisk && phaseInfoDisk['found']) {
3218
3240
  try {
@@ -3394,7 +3416,7 @@ function cmdScaffold(cwd, type, options, raw) {
3394
3416
  const padded = phase ? normalizePhaseName(phase) : '00';
3395
3417
  const today = clock_cjs_1.realClock.localToday();
3396
3418
  // Find phase directory
3397
- const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
3419
+ const phaseInfo = phase && type !== 'phase-dir' ? findPhaseInternal(cwd, phase, undefined, phaseDirectoryOptions(cwd)) : null;
3398
3420
  const phaseDir = phaseInfo ? node_path_1.default.join(cwd, phaseInfo['directory']) : null;
3399
3421
  if (phase && !phaseDir && type !== 'phase-dir') {
3400
3422
  error(`Phase ${phase} directory not found`);
@@ -3422,14 +3444,14 @@ function cmdScaffold(cwd, type, options, raw) {
3422
3444
  }
3423
3445
  const slug = generateSlugInternal(name);
3424
3446
  // #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);
3447
+ const scaffoldConfig = phaseDirectoryOptions(cwd, undefined, '');
3448
+ const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
3449
+ const roadmap = scaffoldConfig.phaseNaming === 'custom' && node_fs_1.default.existsSync(roadmapPath)
3450
+ ? node_fs_1.default.readFileSync(roadmapPath, 'utf8') : '';
3451
+ const [{ dirPath }] = phaseLocatorMod.createPhaseDirectories(cwd, [{
3452
+ id: phase, slug: slug || '',
3453
+ directoryId: scaffoldConfig.phaseNaming === 'custom' && isCustomPhaseId(phase) ? phase : padded,
3454
+ }], { ...scaffoldConfig, roadmap }, false);
3433
3455
  output({ created: true, directory: toPosixPath(node_path_1.default.relative(cwd, dirPath)), path: dirPath }, raw, dirPath);
3434
3456
  return;
3435
3457
  }
@@ -3728,12 +3750,19 @@ function cmdStats(cwd, format, raw) {
3728
3750
  */
3729
3751
  function cmdCheckCommit(cwd, raw) {
3730
3752
  const config = loadConfig(cwd);
3731
- const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd });
3753
+ // #5548: execGit scrubs the repo-location vars, but inside the pre-commit hook git sets them
3754
+ // (GIT_DIR/GIT_WORK_TREE of an explicit --git-dir commit, GIT_INDEX_FILE of a `-a` / `<path>`
3755
+ // commit) to name the repo being committed: hand them back or the guard finds no repo and allows.
3756
+ const hookEnv = {};
3757
+ for (const k of shell_command_projection_cjs_1.GIT_LOCATION_ENV_VARS)
3758
+ if (process.env[k] !== undefined)
3759
+ hookEnv[k] = process.env[k];
3760
+ const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd, env: hookEnv });
3732
3761
  if (stagedResult.exitCode === 0) {
3733
3762
  const files = stagedResult.stdout.split('\0').filter(Boolean);
3734
3763
  const planningFiles = files.filter(f => f.startsWith('.planning/'));
3735
3764
  if (planningFiles.length > 0) {
3736
- const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning'));
3765
+ const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning', hookEnv));
3737
3766
  if (!policy.resolved) {
3738
3767
  error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
3739
3768
  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;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ /**
3
+ * Tool-owned `.gsd/` directory owner (#5757).
4
+ *
5
+ * gsd-core keeps small tool state under `<project>/.gsd/` (the dispatch
6
+ * isolation sentinel, the harness fork-probe cache). A user project does not
7
+ * inherit gsd-core's own root `.gitignore`, and the project may contain a
8
+ * `.gsd` symlink, so every writer needs the same two guarantees before it
9
+ * writes anything there:
10
+ *
11
+ * 1. the directory is a real directory that resolves inside the project
12
+ * (never write through a project-supplied symlink or ancestor redirect);
13
+ * 2. the directory is ignored by git (`.gsd/.gitignore`), without clobbering
14
+ * rules a user already put there.
15
+ *
16
+ * This module is the single owner of both. Callers pass their own ignore-file
17
+ * `marker` comment and the `rules` they need; the owner appends only the rules
18
+ * that are missing.
19
+ */
20
+ var __importDefault = (this && this.__importDefault) || function (mod) {
21
+ return (mod && mod.__esModule) ? mod : { "default": mod };
22
+ };
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.GSD_IGNORE_SYMLINK = exports.GSD_DIR_UNSAFE = void 0;
25
+ exports.ignoreAdditions = ignoreAdditions;
26
+ exports.ensureToolOwnedGsdDir = ensureToolOwnedGsdDir;
27
+ const node_crypto_1 = __importDefault(require("node:crypto"));
28
+ const node_fs_1 = __importDefault(require("node:fs"));
29
+ const node_path_1 = __importDefault(require("node:path"));
30
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
31
+ const SYMLINK_IGNORE_MESSAGE = '.gsd/.gitignore is a symlink';
32
+ /** Stable `code` values on the refusal errors; callers translate by code, never by message. */
33
+ exports.GSD_DIR_UNSAFE = 'GSD_DIR_UNSAFE';
34
+ exports.GSD_IGNORE_SYMLINK = 'GSD_IGNORE_SYMLINK';
35
+ function codedError(message, code) {
36
+ return Object.assign(new Error(message), { code });
37
+ }
38
+ function hasLine(text, line) {
39
+ return text.split('\n').some((l) => l.replace(/\r$/, '') === line);
40
+ }
41
+ /**
42
+ * Pure merge: the text to append to an existing ignore file so that `marker`
43
+ * and every rule are present, or '' when nothing is missing. A file that
44
+ * already ignores everything (`*` line) needs nothing.
45
+ */
46
+ function ignoreAdditions(current, { marker, rules }) {
47
+ if (hasLine(current, '*'))
48
+ return '';
49
+ const missing = rules.filter((rule) => !hasLine(current, rule));
50
+ if (missing.length === 0)
51
+ return '';
52
+ const lead = current === '' || current.endsWith('\n') ? '' : '\n';
53
+ const markerLine = current.includes(marker) ? '' : `${marker}\n`;
54
+ return `${lead}${markerLine}${missing.join('\n')}\n`;
55
+ }
56
+ function isLinkError(err) {
57
+ const e = err;
58
+ return !!e && (e.code === 'ELOOP' || e.message === SYMLINK_IGNORE_MESSAGE);
59
+ }
60
+ function mergeExistingIgnore(ignorePath, options) {
61
+ let fd;
62
+ let tmpPath = null;
63
+ try {
64
+ if (node_fs_1.default.lstatSync(ignorePath).isSymbolicLink())
65
+ throw new Error(SYMLINK_IGNORE_MESSAGE);
66
+ // Read through an fd opened without following links where supported
67
+ // (O_NOFOLLOW is absent on Windows, hence `|| 0`), then replace by rename
68
+ // rather than append through the path, so a link swapped in after the
69
+ // check cannot redirect a write elsewhere.
70
+ fd = node_fs_1.default.openSync(ignorePath, node_fs_1.default.constants.O_RDONLY | (node_fs_1.default.constants.O_NOFOLLOW || 0));
71
+ if (!node_fs_1.default.fstatSync(fd).isFile())
72
+ throw new Error('.gsd/.gitignore is not a regular file');
73
+ const current = node_fs_1.default.readFileSync(fd, 'utf8');
74
+ node_fs_1.default.closeSync(fd);
75
+ fd = undefined;
76
+ const additions = ignoreAdditions(current, options);
77
+ if (additions === '')
78
+ return;
79
+ // `tmpPath` is assigned only once the exclusive create succeeded, so an
80
+ // EEXIST never unlinks an entry this process did not create.
81
+ const candidateTmp = `${ignorePath}.tmp-${process.pid}-${node_crypto_1.default.randomBytes(6).toString('hex')}`;
82
+ const tmpFd = node_fs_1.default.openSync(candidateTmp, 'wx');
83
+ tmpPath = candidateTmp;
84
+ try {
85
+ node_fs_1.default.writeFileSync(tmpFd, current + additions);
86
+ }
87
+ finally {
88
+ node_fs_1.default.closeSync(tmpFd);
89
+ }
90
+ if (node_fs_1.default.lstatSync(ignorePath).isSymbolicLink())
91
+ throw new Error(SYMLINK_IGNORE_MESSAGE);
92
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, ignorePath);
93
+ tmpPath = null;
94
+ }
95
+ catch (err) {
96
+ if (isLinkError(err))
97
+ throw codedError(SYMLINK_IGNORE_MESSAGE, exports.GSD_IGNORE_SYMLINK);
98
+ // Ignore seeding is best-effort; the caller's state is still useful if
99
+ // this path cannot be read or updated.
100
+ }
101
+ finally {
102
+ if (fd !== undefined)
103
+ node_fs_1.default.closeSync(fd);
104
+ if (tmpPath) {
105
+ try {
106
+ node_fs_1.default.unlinkSync(tmpPath);
107
+ }
108
+ catch { /* best-effort cleanup */ }
109
+ }
110
+ }
111
+ }
112
+ /**
113
+ * Ensure `<cwd>/.gsd` exists as a real, project-contained, git-ignored
114
+ * directory and return its path. Throws when `.gsd` is a symlink, resolves
115
+ * outside the project, or `.gsd/.gitignore` is a symlink.
116
+ */
117
+ function ensureToolOwnedGsdDir(cwd, options) {
118
+ const dir = node_path_1.default.join(cwd, '.gsd');
119
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
120
+ if (node_fs_1.default.lstatSync(dir).isSymbolicLink() ||
121
+ node_path_1.default.relative(node_fs_1.default.realpathSync(cwd), node_fs_1.default.realpathSync(dir)) !== '.gsd') {
122
+ throw codedError('.gsd directory is a symlink or resolves outside the project', exports.GSD_DIR_UNSAFE);
123
+ }
124
+ const ignorePath = node_path_1.default.join(dir, '.gitignore');
125
+ try {
126
+ node_fs_1.default.writeFileSync(ignorePath, '*\n', { flag: 'wx' });
127
+ }
128
+ catch (err) {
129
+ if (err.code === 'EEXIST')
130
+ mergeExistingIgnore(ignorePath, options);
131
+ // Any other ignore-file IO failure must not prevent the caller's write.
132
+ }
133
+ return dir;
134
+ }
@@ -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;