@opengsd/gsd-core 1.20.0 → 1.21.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 (154) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +33 -2
  4. package/agents/gsd-executor.md +1 -1
  5. package/agents/gsd-plan-checker.md +1 -1
  6. package/agents/gsd-planner.md +3 -3
  7. package/agents/gsd-verifier.md +2 -2
  8. package/bin/install.js +265 -161
  9. package/commands/gsd/autonomous.md +1 -1
  10. package/commands/gsd/new-milestone.md +2 -2
  11. package/commands/gsd/new-project.md +2 -2
  12. package/gsd-core/bin/gsd-tools.cjs +554 -823
  13. package/gsd-core/bin/lib/capability-activation.cjs +8 -2
  14. package/gsd-core/bin/lib/capability-command-router.cjs +6 -2
  15. package/gsd-core/bin/lib/capability-ledger.cjs +1 -1
  16. package/gsd-core/bin/lib/capability-lock.cjs +19 -27
  17. package/gsd-core/bin/lib/capability-registry.cjs +74 -71
  18. package/gsd-core/bin/lib/capability-validator.cjs +1 -0
  19. package/gsd-core/bin/lib/capability-writer.cjs +7 -4
  20. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +142 -62
  21. package/gsd-core/bin/lib/claude-orchestration.cjs +214 -122
  22. package/gsd-core/bin/lib/cli-surface.cjs +545 -0
  23. package/gsd-core/bin/lib/command-arg-projection.cjs +356 -103
  24. package/gsd-core/bin/lib/commands.cjs +99 -2
  25. package/gsd-core/bin/lib/config-file-read.cjs +85 -0
  26. package/gsd-core/bin/lib/config-loader.cjs +23 -15
  27. package/gsd-core/bin/lib/config.cjs +139 -101
  28. package/gsd-core/bin/lib/configuration.cjs +41 -2
  29. package/gsd-core/bin/lib/criterion-baseline.cjs +346 -0
  30. package/gsd-core/bin/lib/criterion-replay.cjs +380 -0
  31. package/gsd-core/bin/lib/estimate-cli.cjs +5 -1
  32. package/gsd-core/bin/lib/gap-checker.cjs +12 -1
  33. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +593 -27
  34. package/gsd-core/bin/lib/gate-phase-context.cjs +16 -4
  35. package/gsd-core/bin/lib/graphify.cjs +19 -2
  36. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  37. package/gsd-core/bin/lib/health-diagnostic.cjs +5 -1
  38. package/gsd-core/bin/lib/init.cjs +20 -18
  39. package/gsd-core/bin/lib/install-effort-resolver.cjs +24 -4
  40. package/gsd-core/bin/lib/install-engine.cjs +20 -28
  41. package/gsd-core/bin/lib/install-fs-adapter.cjs +5 -1
  42. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  43. package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -3
  44. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  45. package/gsd-core/bin/lib/io.cjs +6 -0
  46. package/gsd-core/bin/lib/markdown-sectionizer.cjs +62 -31
  47. package/gsd-core/bin/lib/markdown-table.cjs +39 -1
  48. package/gsd-core/bin/lib/milestone.cjs +8 -6
  49. package/gsd-core/bin/lib/model-resolver.cjs +8 -4
  50. package/gsd-core/bin/lib/phase-entry-grammar.cjs +14 -0
  51. package/gsd-core/bin/lib/phase-id.cjs +26 -9
  52. package/gsd-core/bin/lib/phase-locator.cjs +8 -3
  53. package/gsd-core/bin/lib/phase.cjs +170 -45
  54. package/gsd-core/bin/lib/plan-dependency-graph.cjs +46 -23
  55. package/gsd-core/bin/lib/planning-snapshot.cjs +10 -6
  56. package/gsd-core/bin/lib/planning-workspace.cjs +20 -10
  57. package/gsd-core/bin/lib/profile-output.cjs +9 -6
  58. package/gsd-core/bin/lib/project-context.cjs +439 -0
  59. package/gsd-core/bin/lib/project-root.cjs +9 -230
  60. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +1 -1
  61. package/gsd-core/bin/lib/report-parser.cjs +16 -0
  62. package/gsd-core/bin/lib/roadmap-parser.cjs +357 -50
  63. package/gsd-core/bin/lib/roadmap-upgrade.cjs +24 -5
  64. package/gsd-core/bin/lib/roadmap.cjs +2 -2
  65. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +317 -134
  66. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +81 -3
  67. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  68. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +279 -8
  69. package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
  70. package/gsd-core/bin/lib/shell-command-projection.cjs +1010 -10
  71. package/gsd-core/bin/lib/stale-bake-guard.cjs +4 -1
  72. package/gsd-core/bin/lib/state-transition.cjs +103 -46
  73. package/gsd-core/bin/lib/state.cjs +448 -63
  74. package/gsd-core/bin/lib/surface.cjs +4 -7
  75. package/gsd-core/bin/lib/tdd-red-evidence.cjs +10 -3
  76. package/gsd-core/bin/lib/verify-command-router.cjs +6 -1
  77. package/gsd-core/bin/lib/verify.cjs +49 -6
  78. package/gsd-core/bin/lib/workstream-inventory.cjs +4 -4
  79. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  80. package/gsd-core/bin/lib/worktree-safety.cjs +8 -111
  81. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  82. package/gsd-core/references/autonomous-smart-discuss.md +99 -18
  83. package/gsd-core/references/checkpoints.md +1 -1
  84. package/gsd-core/references/planner-guidance.md +11 -0
  85. package/gsd-core/references/planner-human-verify-mode.md +1 -1
  86. package/gsd-core/references/planner-reversibility.md +2 -0
  87. package/gsd-core/references/planner-verify-command-grounding.md +22 -6
  88. package/gsd-core/workflows/autonomous.md +4 -4
  89. package/gsd-core/workflows/code-review-fix.md +5 -7
  90. package/gsd-core/workflows/execute-phase.md +21 -7
  91. package/gsd-core/workflows/plan-phase/steps/plan-pre-dispatch.md +101 -0
  92. package/gsd-core/workflows/plan-phase.md +18 -19
  93. package/hooks/dist/gsd-agent-isolation-guard.js +4 -3
  94. package/hooks/dist/gsd-config-reload.js +2 -2
  95. package/hooks/dist/gsd-context-monitor.js +2 -1
  96. package/hooks/dist/gsd-cursor-subagent-start.js +9 -6
  97. package/hooks/dist/gsd-read-injection-scanner.js +2 -2
  98. package/hooks/dist/gsd-secret-read-guard.js +141 -6
  99. package/hooks/dist/gsd-statusline.js +3 -2
  100. package/hooks/dist/gsd-workflow-guard.js +2 -1
  101. package/hooks/dist/lib/config-read.js +49 -0
  102. package/hooks/dist/lib/git-cmd.js +3 -1
  103. package/hooks/dist/lib/git-head-tree.js +474 -0
  104. package/hooks/dist/lib/isolation-sentinel.js +168 -13
  105. package/hooks/dist/lib/secret-read-exemptions.js +161 -0
  106. package/hooks/gsd-agent-isolation-guard.js +4 -3
  107. package/hooks/gsd-config-reload.js +2 -2
  108. package/hooks/gsd-context-monitor.js +2 -1
  109. package/hooks/gsd-cursor-subagent-start.js +9 -6
  110. package/hooks/gsd-read-injection-scanner.js +2 -2
  111. package/hooks/gsd-secret-read-guard.js +141 -6
  112. package/hooks/gsd-statusline.js +3 -2
  113. package/hooks/gsd-workflow-guard.js +2 -1
  114. package/hooks/lib/config-read.js +49 -0
  115. package/hooks/lib/git-cmd.js +3 -1
  116. package/hooks/lib/git-head-tree.js +474 -0
  117. package/hooks/lib/isolation-sentinel.js +168 -13
  118. package/hooks/lib/secret-read-exemptions.js +161 -0
  119. package/package.json +7 -2
  120. package/pi/package-agents/gsd-executor.md +1 -1
  121. package/pi/package-agents/gsd-plan-checker.md +1 -1
  122. package/pi/package-agents/gsd-planner.md +3 -3
  123. package/pi/package-agents/gsd-verifier.md +2 -2
  124. package/pi/package-agents/manifest.json +4 -4
  125. package/pi/package-skills/gsd-autonomous/SKILL.md +1 -1
  126. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  127. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  128. package/scripts/changeset/cli.cjs +2 -2
  129. package/scripts/changeset/lint.cjs +28 -3
  130. package/scripts/changeset/serialize.cjs +12 -2
  131. package/scripts/ci-compact-v8-coverage.cjs +204 -0
  132. package/scripts/ci-fix-next-rerun.cjs +149 -0
  133. package/scripts/ci-next-health.cjs +52 -9
  134. package/scripts/ci-test-scope.cjs +77 -71
  135. package/scripts/command-contract-helpers.cjs +56 -7
  136. package/scripts/docs-guard-registry.cjs +3 -0
  137. package/scripts/gen-cli-surface-manifest.cjs +287 -0
  138. package/scripts/gen-platform-conformance-tier.cjs +258 -19
  139. package/scripts/lib/macos-conformance-tier.generated.cjs +59 -47
  140. package/scripts/lib/platform-conformance-tier.generated.cjs +23 -64
  141. package/scripts/lib/shellcheck-fetch.cjs +71 -4
  142. package/scripts/lint-removed-but-needed.cjs +562 -50
  143. package/scripts/merge-commit-pr.cjs +186 -0
  144. package/scripts/release-ancestry.cjs +86 -0
  145. package/scripts/release-notes/changelog-release-notes.cjs +345 -0
  146. package/scripts/release-notes/discord-release-summary.cjs +82 -1
  147. package/scripts/release-pipeline-pr.cjs +92 -0
  148. package/scripts/require-issue-link-policy.cjs +18 -9
  149. package/scripts/sync-next-version.cjs +11 -2
  150. package/scripts/timeout-evidence-from-ci.cjs +964 -0
  151. package/skills/gsd-autonomous/SKILL.md +1 -1
  152. package/skills/gsd-new-milestone/SKILL.md +1 -1
  153. package/skills/gsd-new-project/SKILL.md +1 -1
  154. package/vscode/package.json +1 -1
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * Install manifest per-file hash ownership (#5683).
4
+ *
5
+ * `gsd-file-manifest.json` records `sha256(file bytes)` per installed file,
6
+ * keyed by path relative to the runtime config dir. `effort sync --apply`
7
+ * rewrites installed agents, so it re-records exactly those files here.
8
+ * `bin/install.js` fileHash delegates to this module: one hash function.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.fileHash = fileHash;
12
+ exports.tryFileHash = tryFileHash;
13
+ exports.recordManifestFileHashes = recordManifestFileHashes;
14
+ const node_fs_1 = require("node:fs");
15
+ const node_path_1 = require("node:path");
16
+ const node_crypto_1 = require("node:crypto");
17
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
18
+ const MANIFEST_NAME = 'gsd-file-manifest.json';
19
+ /** SHA256 (lowercase hex) of a file's bytes — the manifest's per-file hash. */
20
+ function fileHash(filePath) {
21
+ return (0, node_crypto_1.createHash)('sha256').update((0, node_fs_1.readFileSync)(filePath)).digest('hex');
22
+ }
23
+ /** fileHash that returns null instead of throwing — for capturing a pre-rewrite hash. */
24
+ function tryFileHash(filePath) {
25
+ try {
26
+ return fileHash(filePath);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /**
33
+ * Update ONLY the manifest entries for `rewritten` files (under `configDir`),
34
+ * and only those that were GSD-owned before the rewrite: `preHash` equal to the
35
+ * recorded manifest entry. A file the user had already edited keeps its stale
36
+ * entry so the next install still detects it and backs it up.
37
+ * Skips silently (returns false) when the manifest is absent, unparseable or
38
+ * not the documented shape; publishes via tmp + rename.
39
+ */
40
+ function recordManifestFileHashes(configDir, rewritten) {
41
+ if (rewritten.length === 0)
42
+ return false;
43
+ const manifestPath = (0, node_path_1.join)(configDir, MANIFEST_NAME);
44
+ let manifest;
45
+ try {
46
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, 'utf8'));
47
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
48
+ return false;
49
+ manifest = parsed;
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ const files = manifest['files'];
55
+ if (!files || typeof files !== 'object' || Array.isArray(files))
56
+ return false;
57
+ const entries = files;
58
+ let changed = false;
59
+ for (const { path: absPath, preHash } of rewritten) {
60
+ const rel = (0, node_path_1.relative)(configDir, absPath).replace(/\\/g, '/');
61
+ if (rel === '' || rel.startsWith('..') || (0, node_path_1.isAbsolute)(rel))
62
+ continue;
63
+ if (preHash === null || entries[rel] !== preHash)
64
+ continue;
65
+ try {
66
+ entries[rel] = fileHash(absPath);
67
+ changed = true;
68
+ }
69
+ catch { /* unreadable file: leave its entry as recorded */ }
70
+ }
71
+ if (!changed)
72
+ return false;
73
+ const tmpPath = `${manifestPath}.tmp.${process.pid}`;
74
+ try {
75
+ (0, node_fs_1.writeFileSync)(tmpPath, JSON.stringify(manifest, null, 2));
76
+ (0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, manifestPath);
77
+ return true;
78
+ }
79
+ catch {
80
+ try {
81
+ (0, node_fs_1.unlinkSync)(tmpPath);
82
+ }
83
+ catch { /* already gone or never created */ }
84
+ return false;
85
+ }
86
+ }
@@ -35,9 +35,33 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
35
35
  const node_path_1 = __importDefault(require("node:path"));
36
36
  const node_os_1 = __importDefault(require("node:os"));
37
37
  const runtime_homes_cjs_1 = require("./runtime-homes.cjs");
38
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-file-read.cjs is an export= CommonJS module
39
+ const configFileReadMod = require("./config-file-read.cjs");
40
+ const { unboundedConfigFileReason, printablePath, readConfigFileBounded } = configFileReadMod;
41
+ /**
42
+ * Text of an install-time config file, or null (with a printable-path warning) when it is not a
43
+ * regular file within the cap or cannot be read. The real fs uses the descriptor-bound reader;
44
+ * an injected adapter gets the stat guard before its own read (#5759).
45
+ */
46
+ function readInstallConfigText(absPath, label) {
47
+ try {
48
+ if (isRealInstallFs())
49
+ return readConfigFileBounded(absPath);
50
+ const unbounded = unboundedConfigFileReason(absPath);
51
+ if (unbounded !== null) {
52
+ process.stderr.write(`gsd: warning — could not read ${label} (${printablePath(absPath)}): ${unbounded}\n`);
53
+ return null;
54
+ }
55
+ return installFs().readFileSync(absPath, 'utf-8');
56
+ }
57
+ catch (err) {
58
+ process.stderr.write(`gsd: warning — could not read ${label} (${printablePath(absPath)}): ${printablePath(err.message)}\n`);
59
+ return null;
60
+ }
61
+ }
38
62
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-fs-adapter.cjs is an export= CommonJS module
39
63
  const installFsAdapter = require("./install-fs-adapter.cjs");
40
- const { installFs } = installFsAdapter;
64
+ const { installFs, isRealInstallFs } = installFsAdapter;
41
65
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- install-effort-resolver.cjs is an export= CommonJS module
42
66
  const installEffortResolver = require("./install-effort-resolver.cjs");
43
67
  const { _readGsdConfigFile, _findAncestorGsdConfigPath } = installEffortResolver;
@@ -62,7 +86,9 @@ function readGsdGlobalModelOverrides(options = {}) {
62
86
  const defaultsPath = node_path_1.default.join(home, '.gsd', 'defaults.json');
63
87
  if (!installFs().existsSync(defaultsPath))
64
88
  return null;
65
- const raw = installFs().readFileSync(defaultsPath, 'utf-8');
89
+ const raw = readInstallConfigText(defaultsPath, 'global defaults'); // #5759: never block on a planted FIFO
90
+ if (raw === null)
91
+ return null;
66
92
  const parsed = JSON.parse(raw);
67
93
  const overrides = parsed.model_overrides;
68
94
  if (!overrides || typeof overrides !== 'object')
@@ -99,7 +125,11 @@ function readGsdEffectiveModelOverrides(targetDir = null, options = {}) {
99
125
  const candidate = _findAncestorGsdConfigPath(targetDir);
100
126
  if (candidate) {
101
127
  try {
102
- const parsed = JSON.parse(installFs().readFileSync(candidate, 'utf-8'));
128
+ // #5759: refuse a FIFO/device/oversized project config before the adapter's blocking read.
129
+ const text = readInstallConfigText(candidate, 'project config');
130
+ if (text === null)
131
+ throw new Error('unusable project config');
132
+ const parsed = JSON.parse(text);
103
133
  if (parsed && typeof parsed === 'object' && parsed.model_overrides && typeof parsed.model_overrides === 'object') {
104
134
  projectOverrides = parsed.model_overrides;
105
135
  }
@@ -35,7 +35,7 @@ const external_descriptor_trust_cjs_1 = require("./external-descriptor-trust.cjs
35
35
  const capabilityLedger = require("./capability-ledger.cjs");
36
36
  // eslint-disable-next-line @typescript-eslint/no-require-imports
37
37
  const conversionModule = require("./runtime-artifact-conversion.cjs");
38
- const { applyAgentPathRewrites: _applyAgentPathRewrites, processAttribution: _processAttribution, normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, readGsdCommandNames: _readGsdCommandNames, deriveAgentName: _deriveAgentName, applyAgentFrontmatterExtensions: _applyAgentFrontmatterExtensions, appendAgentTools: _appendAgentTools, } = conversionModule;
38
+ const { applyAgentPathRewrites: _applyAgentPathRewrites, processAttribution: _processAttribution, normalizeAgentBodyForRuntime: _normalizeAgentBodyForRuntime, readGsdCommandNames: _readGsdCommandNames, deriveAgentName: _deriveAgentName, applyAgentFrontmatterExtensions: _applyAgentFrontmatterExtensions, appendAgentTools: _appendAgentTools, _shieldAgentConverterPrefix, } = conversionModule;
39
39
  // #2995 (epic #1671 Phase 6.4): agent bodies join the fragment model. Markers are
40
40
  // stripped at emit BEFORE any path rewrite or converter runs, so a `.claude/` ->
41
41
  // `.windsurf/` regex can never reach inside a marker attribute and corrupt it —
@@ -1011,7 +1011,9 @@ function stageAgentsForRuntimeWithConverter(srcAgentsDir, resolvedProfile, conve
1011
1011
  // Step 3: validated canonical grants, before host conversion.
1012
1012
  content = _appendAgentTools(content, grants);
1013
1013
  // Step 4: converter (runtime-specific frontmatter/body transform)
1014
- content = converter(content, isGlobal, { agentName });
1014
+ // #5715: the converter runs after the prefix pass above; shield the emitted prefix
1015
+ // from it, except where the runtime declares no path rewrite (nothing was emitted).
1016
+ content = _shieldAgentConverterPrefix(content, agentCtx.runtime, agentCtx.pathPrefix, (shielded) => converter(shielded, isGlobal, { agentName, pathPrefix: agentCtx.pathPrefix }));
1015
1017
  // Step 5: frontmatter extensions (effort/disallowedTools; no-op unless
1016
1018
  // the runtime declares hostBehaviors.agentFrontmatterExtensions)
1017
1019
  content = _applyAgentFrontmatterExtensions(content, { runtime: agentCtx.runtime, agentName, targetDir: agentCtx.targetDir });
@@ -364,6 +364,10 @@ const ERROR_REASON = Object.freeze({
364
364
  COMMIT_DOCS_GUARD_NOT_A_REPO: 'commit_docs_guard_not_a_repo',
365
365
  COMMIT_DOCS_GUARD_FOREIGN_HOOK: 'commit_docs_guard_foreign_hook',
366
366
  COMMIT_DOCS_GUARD_HOOKS_PATH_SET: 'commit_docs_guard_hooks_path_set',
367
+ // commit verb (#6043, ADR-4908 §3): it commits in the worktree the process
368
+ // runs in, or refuses with one of these. Wire values are the typed `reason`.
369
+ COMMIT_NOT_GIT_REPO: 'not_git_repo',
370
+ COMMIT_OUTSIDE_WORKTREE: 'outside_worktree',
367
371
  // security-scan
368
372
  SECURITY_SCAN_FAILED: 'security_scan_failed',
369
373
  // --pick (#3365 / #3358, ADR-3473 §8.4): an absent field or non-JSON
@@ -499,6 +503,8 @@ const REASON_TO_OUTCOME = Object.freeze({
499
503
  [ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO]: 'UNAVAILABLE',
500
504
  [ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK]: 'UNAVAILABLE',
501
505
  [ERROR_REASON.COMMIT_DOCS_GUARD_HOOKS_PATH_SET]: 'UNAVAILABLE',
506
+ [ERROR_REASON.COMMIT_NOT_GIT_REPO]: 'UNAVAILABLE',
507
+ [ERROR_REASON.COMMIT_OUTSIDE_WORKTREE]: 'FAIL',
502
508
  // Its own docstring: "an absent field or non-JSON command output is a
503
509
  // failure, never a demotion to an empty answer" — the field/output was
504
510
  // supposed to be there and was not; a prerequisite of the query failed.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  Object.defineProperty(exports, "__esModule", { value: true });
14
14
  exports.XML_DECISION_TAG_NAMES = void 0;
15
+ exports.matchFenceDelimiter = matchFenceDelimiter;
15
16
  exports.stripFencedCode = stripFencedCode;
16
17
  exports.stripInlineCode = stripInlineCode;
17
18
  exports.scanInlineCodeSpans = scanInlineCodeSpans;
@@ -24,6 +25,7 @@ exports.iterateBullets = iterateBullets;
24
25
  exports.updateBullet = updateBullet;
25
26
  exports.updateHeading = updateHeading;
26
27
  exports.extractTaggedBlocks = extractTaggedBlocks;
28
+ exports.extractTaggedBlockSpans = extractTaggedBlockSpans;
27
29
  exports.stripTaggedBlocks = stripTaggedBlocks;
28
30
  exports.editOutsideTaggedBlocks = editOutsideTaggedBlocks;
29
31
  exports.maskFences = maskFences;
@@ -35,6 +37,30 @@ exports.editHeadingSlice = editHeadingSlice;
35
37
  exports.deleteSection = deleteSection;
36
38
  exports.extractXmlTagBodies = extractXmlTagBodies;
37
39
  const pattern_cjs_1 = require("./pattern.cjs");
40
+ const FENCE_DELIMITER_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
41
+ /**
42
+ * The single CommonMark fence-delimiter grammar: ≤3 spaces of indent, a run of
43
+ * ≥3 backticks or tildes, then trailing text. A trailing `\r` is stripped first
44
+ * (CRLF safety). Returns `null` when the line is not a delimiter line at all.
45
+ * `stripFencedCode`, `scanFencedBlocks`, `tokenizeHeadings` and the roadmap
46
+ * parser's comment/fence blanking all classify delimiter lines through this one
47
+ * matcher.
48
+ */
49
+ function matchFenceDelimiter(line) {
50
+ const m = FENCE_DELIMITER_RE.exec(line.replace(/\r$/, ''));
51
+ if (m === null)
52
+ return null;
53
+ const char = m[2][0];
54
+ const len = m[2].length;
55
+ const trailing = m[3];
56
+ return {
57
+ char,
58
+ len,
59
+ trailing,
60
+ canOpen: !(char === '`' && trailing.includes('`')),
61
+ closes: (opener) => char === opener.char && len >= opener.len && /^\s*$/.test(trailing),
62
+ };
63
+ }
38
64
  // ─── stripFencedCode ──────────────────────────────────────────────────────────
39
65
  /**
40
66
  * CommonMark-correct fenced-code-block stripper.
@@ -62,27 +88,20 @@ function stripFencedCode(content) {
62
88
  const lines = content.split('\n');
63
89
  const kept = [];
64
90
  let openFence = null;
65
- // Matches: optional indent (≤3 spaces per CommonMark), fence run, optional info string
66
- const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
67
91
  for (const rawLine of lines) {
68
- // Strip trailing \r for delimiter matching (CRLF safety)
69
- const line = rawLine.replace(/\r$/, '');
70
- const m = delimRe.exec(line);
92
+ const m = matchFenceDelimiter(rawLine);
71
93
  if (m) {
72
- const char = m[2][0];
73
- const len = m[2].length;
74
- const trailing = m[3];
75
94
  if (openFence === null) {
76
95
  // CommonMark §4.5: backtick fence info string must not contain a backtick.
77
96
  // If it does, this line is NOT a valid fence opener (treat as ordinary content).
78
- if (char === '`' && trailing.includes('`')) {
97
+ if (!m.canOpen) {
79
98
  kept.push(rawLine);
80
99
  continue;
81
100
  }
82
101
  // Opening delimiter — record fence state, drop this line
83
- openFence = { char, len };
102
+ openFence = { char: m.char, len: m.len };
84
103
  }
85
- else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
104
+ else if (m.closes(openFence)) {
86
105
  // Closing delimiter (same char, sufficient length, no trailing content) — close and drop
87
106
  openFence = null;
88
107
  }
@@ -213,9 +232,8 @@ function stripInlineCodeLine(line) {
213
232
  * different-info-string fence, a fence nested/indented inside another fence,
214
233
  * and a `~~~` fence are all classified exactly as `stripFencedCode` would.
215
234
  *
216
- * Tracked duplication (same status as `tokenizeHeadings`'s copy, see its
217
- * comment above): this is a second independent copy of the fence state
218
- * machine, pending a T-tier consolidation.
235
+ * Delimiter classification goes through `matchFenceDelimiter`, the one shared
236
+ * grammar (also used by `stripFencedCode` and `tokenizeHeadings`).
219
237
  *
220
238
  * Exported so `context-predicates.cts` can consume this seam directly for its
221
239
  * line-preserving fenced-line skip detection, instead of carrying a third
@@ -223,24 +241,19 @@ function stripInlineCodeLine(line) {
223
241
  * comment).
224
242
  */
225
243
  function scanFencedBlocks(lines) {
226
- const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
227
244
  const blocks = [];
228
245
  let open = null;
229
246
  for (let i = 0; i < lines.length; i++) {
230
- const line = lines[i].replace(/\r$/, '');
231
- const m = delimRe.exec(line);
247
+ const m = matchFenceDelimiter(lines[i]);
232
248
  if (!m)
233
249
  continue;
234
- const char = m[2][0];
235
- const len = m[2].length;
236
- const trailing = m[3];
237
250
  if (open === null) {
238
251
  // CommonMark §4.5: backtick fence info string must not contain a backtick.
239
- if (char === '`' && trailing.includes('`'))
252
+ if (!m.canOpen)
240
253
  continue; // not a valid opener — ordinary content
241
- open = { char, len, infoString: trailing.trim(), openLineIdx: i };
254
+ open = { char: m.char, len: m.len, infoString: m.trailing.trim(), openLineIdx: i };
242
255
  }
243
- else if (char === open.char && len >= open.len && /^\s*$/.test(trailing)) {
256
+ else if (m.closes(open)) {
244
257
  blocks.push({
245
258
  char: open.char,
246
259
  len: open.len,
@@ -319,30 +332,26 @@ function tokenizeHeadings(content) {
319
332
  const tokens = [];
320
333
  // We re-run the fence state machine to know which lines are "kept", so we
321
334
  // can map line index in original to whether it survived.
322
- const delimRe = /^( {0,3})(`{3,}|~{3,})(.*)$/;
323
335
  let openFence = null;
324
336
  // Accumulate character offset as we iterate lines
325
337
  let charOffset = 0;
326
338
  for (let i = 0; i < originalLines.length; i++) {
327
339
  const rawLine = originalLines[i];
328
340
  const line = rawLine.replace(/\r$/, '');
329
- const dm = delimRe.exec(line);
341
+ const dm = matchFenceDelimiter(rawLine);
330
342
  if (dm) {
331
- const char = dm[2][0];
332
- const len = dm[2].length;
333
- const trailing = dm[3];
334
343
  if (openFence === null) {
335
344
  // CommonMark §4.5: backtick fence info string must not contain a backtick.
336
- if (char === '`' && trailing.includes('`')) {
345
+ if (!dm.canOpen) {
337
346
  // Not a valid fence opener — check for heading on this line (will fall through)
338
347
  }
339
348
  else {
340
- openFence = { char, len };
349
+ openFence = { char: dm.char, len: dm.len };
341
350
  charOffset += rawLine.length + 1;
342
351
  continue;
343
352
  }
344
353
  }
345
- else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
354
+ else if (dm.closes(openFence)) {
346
355
  openFence = null;
347
356
  charOffset += rawLine.length + 1;
348
357
  continue;
@@ -818,6 +827,28 @@ function extractTaggedBlocks(content, tagName, allowAttributes = false) {
818
827
  }
819
828
  return results;
820
829
  }
830
+ /**
831
+ * Offset spans of the bodies `extractTaggedBlocks` returns (#4309): same
832
+ * pattern, same blocks, but each body is reported as `{ start, end }` into
833
+ * `content` so a caller can test whether an arbitrary offset lies inside a
834
+ * block (and dedupe nested blocks) without re-harvesting the text.
835
+ * `content.slice(span.start, span.end)` equals the matching extracted body.
836
+ */
837
+ function extractTaggedBlockSpans(content, tagName, allowAttributes = false) {
838
+ if (typeof content !== 'string' || content.length === 0)
839
+ return [];
840
+ if (typeof tagName !== 'string' || tagName.length === 0)
841
+ return [];
842
+ const closeLength = `</${tagName}>`.length;
843
+ const pattern = taggedBlockPattern(tagName, 'g', allowAttributes);
844
+ const spans = [];
845
+ let match;
846
+ while ((match = pattern.exec(content)) !== null) {
847
+ const end = match.index + match[0].length - closeLength;
848
+ spans.push({ start: end - match[1].length, end });
849
+ }
850
+ return spans;
851
+ }
821
852
  /**
822
853
  * Build the single, ReDoS-safe `<tag>…</tag>` block regex shared by
823
854
  * `extractTaggedBlocks` (extract bodies) and `stripTaggedBlocks` (remove blocks).
@@ -26,6 +26,7 @@ exports.appendQuickTaskRow = appendQuickTaskRow;
26
26
  exports.migrateQuickTasksTable = migrateQuickTasksTable;
27
27
  exports.resetQuickTaskRows = resetQuickTaskRows;
28
28
  exports.updateTraceabilityCell = updateTraceabilityCell;
29
+ exports.traceabilityRowExists = traceabilityRowExists;
29
30
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
30
31
  // ─── Schema registry ──────────────────────────────────────────────────────────
31
32
  /**
@@ -979,6 +980,8 @@ function resetQuickTaskRows(stateContent) {
979
980
  const content = (0, markdown_sectionizer_cjs_1.replaceSection)(stateContent, section, newBody);
980
981
  return { ok: true, value: { content, cleared, variant: match.label } };
981
982
  }
983
+ /** The one Traceability heading rule shared by the write and the existence read. */
984
+ const TRACEABILITY_HEADING_RE = /^Traceability(?:[ \t]+Status)?\b/i;
982
985
  /**
983
986
  * `updateTableCell` scoped to REQUIREMENTS.md's `## Traceability` (or
984
987
  * `## Traceability Status`) section (#2245 F1, #5762): `updateTableCell` binds
@@ -991,7 +994,7 @@ function resetQuickTaskRows(stateContent) {
991
994
  */
992
995
  function updateTraceabilityCell(text, match, column, newValue) {
993
996
  const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text);
994
- const index = headings.findIndex((h) => h.level === 2 && /^Traceability(?:[ \t]+Status)?\b/i.test(h.text));
997
+ const index = headings.findIndex((h) => h.level === 2 && TRACEABILITY_HEADING_RE.test(h.text));
995
998
  if (index === -1)
996
999
  return updateTableCell(text, match, column, newValue);
997
1000
  let result = { ok: false, reason: 'no matching row' };
@@ -1001,5 +1004,40 @@ function updateTraceabilityCell(text, match, column, newValue) {
1001
1004
  });
1002
1005
  return result.ok ? { ok: true, value: edited } : result;
1003
1006
  }
1007
+ /**
1008
+ * Row-existence READ for REQUIREMENTS.md's Traceability section (#5844): true
1009
+ * when any table row in the section has `id` (case-insensitive) as its first
1010
+ * cell, whatever the table's columns. `updateTraceabilityCell(..., 'Status', …)`
1011
+ * answers "is there a row" only for tables that HAVE a Status column; this
1012
+ * answers it for every table. Scoping mirrors `updateTraceabilityCell`: the
1013
+ * same heading rule, and only the FIRST table of the scoped text (with no
1014
+ * Traceability heading that is the first table in the file), so a table
1015
+ * elsewhere whose first cell equals the ID is not a Traceability row.
1016
+ */
1017
+ function traceabilityRowExists(text, id) {
1018
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(text);
1019
+ const index = headings.findIndex((h) => h.level === 2 && TRACEABILITY_HEADING_RE.test(h.text));
1020
+ let scoped = text;
1021
+ if (index !== -1) {
1022
+ (0, markdown_sectionizer_cjs_1.editHeadingSlice)(text, (0, markdown_sectionizer_cjs_1.headingSliceRange)(text, headings, index, 2), (slice) => {
1023
+ scoped = slice;
1024
+ return slice;
1025
+ });
1026
+ }
1027
+ const wanted = id.trim().toLowerCase();
1028
+ let inTable = false;
1029
+ for (const line of scoped.split(/\r?\n/)) {
1030
+ if (!line.trim().startsWith('|')) {
1031
+ if (inTable)
1032
+ return false;
1033
+ continue;
1034
+ }
1035
+ inTable = true;
1036
+ const cells = splitTableRow(line);
1037
+ if (cells.length > 0 && cells[0].trim().toLowerCase() === wanted)
1038
+ return true;
1039
+ }
1040
+ return false;
1041
+ }
1004
1042
  // Consumers: require('../gsd-core/bin/lib/markdown-table.cjs')
1005
1043
  // Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).
@@ -43,7 +43,7 @@ const pattern_cjs_1 = require("./pattern.cjs");
43
43
  const milestone_release_map_cjs_1 = require("./milestone-release-map.cjs");
44
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
45
45
  const roadmapParserMod = require("./roadmap-parser.cjs");
46
- const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo, sliceMilestoneWindow, } = roadmapParserMod;
46
+ const { getMilestonePhaseFilter, extractCurrentMilestone, getMilestoneInfo, sliceMilestoneWindow, classifyMilestoneScalar, readStateMilestoneScalar, sameMilestoneVersion, } = roadmapParserMod;
47
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports
48
48
  const planningScopeMod = require("./planning-scope.cjs");
49
49
  const { SCOPE } = planningScopeMod;
@@ -225,7 +225,9 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
225
225
  // Surface traceability drift: checkbox reconciled (this run or before) but the
226
226
  // table has no row for this ID. This is what makes a partial reconcile
227
227
  // distinguishable from a full one (#2140).
228
- if (hasTable && doneCheckbox && !hasRow) {
228
+ // #5844: a row in a table with no Status column is still a row; only the
229
+ // Status write is impossible there, so existence is read directly.
230
+ if (hasTable && doneCheckbox && !hasRow && !(0, markdown_table_cjs_1.traceabilityRowExists)(reqContent, reqId)) {
229
231
  tableUnmatched.push(reqId);
230
232
  }
231
233
  }
@@ -603,15 +605,15 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
603
605
  try {
604
606
  const stateRaw = node_fs_1.default.existsSync(statePath) ? node_fs_1.default.readFileSync(statePath, 'utf-8') : null;
605
607
  if (stateRaw) {
606
- const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
607
- if (milestoneMatch)
608
- stateVersion = milestoneMatch[1].trim();
608
+ // One owner for the STATE `milestone:` scalar (#5726): quoted, null and
609
+ // empty values and a body `milestone:` line are not drift.
610
+ stateVersion = classifyMilestoneScalar(readStateMilestoneScalar(stateRaw)).version;
609
611
  }
610
612
  }
611
613
  catch {
612
614
  /* skip — stateVersion stays null, scan still runs */
613
615
  }
614
- if (stateVersion !== null && stateVersion !== version) {
616
+ if (stateVersion !== null && !sameMilestoneVersion(stateVersion, version)) {
615
617
  // #2946: emit a WARNING so the suspicious STATE mismatch is visible
616
618
  // rather than silently disarming the guard. Plain-text diagnostic on
617
619
  // stderr, matching the existing [gsd-tools] WARNING convention
@@ -11,7 +11,7 @@
11
11
  * epic #1267; callers import resolvers from model-resolver.cjs directly.
12
12
  *
13
13
  * Dependencies (leaf modules only):
14
- * - node:fs / node:path (read the per-install .gsd-runtime marker + project config for the #2297 omit gate)
14
+ * - node:path (project config path for the #2297 omit gate; read via config-loader's bounded reader, #5759)
15
15
  * - ./runtime-name-policy.cjs (resolveRuntimeNameFromCandidates — canonicalize the active runtime)
16
16
  * - ./planning-workspace.cjs (planningDir — workstream/project-aware project-config path)
17
17
  * - ./config-loader.cjs (loadConfig)
@@ -25,14 +25,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  };
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const configLoaderModule = require("./config-loader.cjs");
28
- const { loadConfig } = configLoaderModule;
28
+ const { loadConfig, readConfigFileBounded } = configLoaderModule;
29
29
  // ─── Configuration Module (for CANONICAL_CONFIG_DEFAULTS used by effort/fast_mode resolvers) ─
30
30
  const configuration_cjs_1 = require("./configuration.cjs");
31
31
  // eslint-disable-next-line @typescript-eslint/no-require-imports
32
32
  const modelProfiles = require("./model-profiles.cjs");
33
33
  const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
34
34
  const model_catalog_cjs_1 = require("./model-catalog.cjs");
35
- const node_fs_1 = __importDefault(require("node:fs"));
36
35
  const node_path_1 = __importDefault(require("node:path"));
37
36
  const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
38
37
  const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
@@ -87,7 +86,12 @@ function projectExplicitlySetsOmit(cwd) {
87
86
  const layers = wsDir === rootDir ? [rootDir] : [wsDir, rootDir]; // workstream > root
88
87
  for (const dir of layers) {
89
88
  try {
90
- const parsed = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(dir, 'config.json'), 'utf8'));
89
+ // Same bounded regular-file reader as the loader (#5759): a FIFO/device/oversized
90
+ // layer throws instead of blocking, and an absent layer returns null.
91
+ const raw = readConfigFileBounded(node_path_1.default.join(dir, 'config.json'));
92
+ if (raw === null)
93
+ continue;
94
+ const parsed = JSON.parse(raw);
91
95
  const value = parsed?.['resolve_model_ids'];
92
96
  // First layer that sets the key wins (matches loadConfig's deep-merge
93
97
  // precedence). A layer that omits the key falls through to the next.
@@ -32,6 +32,7 @@ exports.phaseEntryRegExp = phaseEntryRegExp;
32
32
  exports.phaseEntrySplitRegExp = phaseEntrySplitRegExp;
33
33
  exports.isPhaseEntryChecked = isPhaseEntryChecked;
34
34
  exports.isBoxCharChecked = isBoxCharChecked;
35
+ exports.stripInsertedMarker = stripInsertedMarker;
35
36
  const MARKER_SRC = '[-*]';
36
37
  // A list item has 1 to 4 spaces or tabs after its marker (CommonMark: none is not a list item,
37
38
  // five or more makes the rest an indented code block), so `-[ ] **Phase 1**` is not an entry.
@@ -117,3 +118,16 @@ function isPhaseEntryChecked(entryText) {
117
118
  function isBoxCharChecked(boxChar) {
118
119
  return boxChar.toLowerCase() === 'x';
119
120
  }
121
+ /**
122
+ * #5824: `phase insert` writes `(INSERTED)` into the heading text as
123
+ * bookkeeping; it is not part of the phase's name. The single place that
124
+ * removes it: only a TRAILING `(INSERTED)` (case-insensitive) is dropped, then
125
+ * a trim — `phase insert` always appends it last, and a `(INSERTED)` inside the
126
+ * user's own text is kept. A name that is only the marker is returned as
127
+ * written rather than reduced to an empty name. Every phase-name reader goes
128
+ * through here so none can drift from the others.
129
+ */
130
+ function stripInsertedMarker(name) {
131
+ const stripped = name.replace(/\s*\(INSERTED\)\s*$/i, '').trim();
132
+ return stripped === '' ? name.trim() : stripped;
133
+ }
@@ -71,6 +71,8 @@ const OPTIONAL_PHASE_TAG_SOURCE = '(?:\\s*\\([^)\\n]{0,200}\\))?';
71
71
  // (scripts/lint-phase-id-drift.cjs) fails CI if a literal re-derivation is
72
72
  // introduced outside this module without a `// phase-id-owner:` justification.
73
73
  const PHASE_NUMBER_TOKEN_SOURCE = '\\d+[A-Z]?(?:\\.\\d+)*';
74
+ // #5676: milestone-prefixed M-NN token grammar (one optional -NN segment, the #5626 grammar).
75
+ const PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED = '\\d+(?:-\\d+)?[A-Z]?(?:\\.\\d+)*';
74
76
  // #4764: a legacy phase REFERENCE in depends-on PROSE — the token in context, directly
75
77
  // following "Phase"/"Phases", with bare-token list continuation ("Phases 1 and 2",
76
78
  // "Phase 1, 2, and 3", "Phase 1-3"). Built HERE, beside the token grammar it is
@@ -381,7 +383,7 @@ function phaseHeadingPrefixSrcFor(baseline, convention, capturing = false) {
381
383
  function parsePhaseChecklistLine(line, convention, context) {
382
384
  const capturesBracketId = convention === 'bracket';
383
385
  const intro = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.LABEL_ONLY, convention, capturesBracketId);
384
- const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})`
386
+ const pattern = (0, phase_entry_grammar_cjs_1.phaseEntryRegExp)(`${intro}(${context ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})`
385
387
  + `${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])`, 'i', { state: 'captured' });
386
388
  const match = pattern.exec(line);
387
389
  if (!match)
@@ -413,7 +415,7 @@ function buildPhaseHeadingScanRegex(baseline, convention, context) {
413
415
  const bracketGroup = convention === 'bracket' ? 1 : null;
414
416
  const phaseNumGroup = bracketGroup ? 2 : 1;
415
417
  const phaseNameGroup = phaseNumGroup + 1;
416
- const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context) : PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
418
+ const regex = new RegExp(`^ {0,3}#{2,4}\\s*${phaseHeadingPrefixSrcFor(baseline, convention, true)}(${context && convention !== 'bracket' ? phaseScanTokenSource(context, convention) : phaseNumberTokenSource(convention)})${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^\\n]+)`, 'gim');
417
419
  return { regex, bracketGroup, phaseNumGroup, phaseNameGroup };
418
420
  }
419
421
  /**
@@ -435,7 +437,7 @@ function buildPhaseDeclarationScans(convention, withBullets = true) {
435
437
  // Under bracket a numbered-list item (`1. [ ] **[GSD.02] 05: n**`) is a checklist row too.
436
438
  const bullet = bracket ? '^[ \\t]*(?:-|\\d+[.)])[ \\t]*' : '^[ \\t]*-[ \\t]*';
437
439
  // Bracket ids may carry a letter suffix (`12A`), as the heading scan's phase token does.
438
- const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : '(\\d+)(?=[:.\\s*]|$)';
440
+ const tail = bracket ? '(\\d+)(?=[A-Za-z]?(?:[:.\\s*]|$))' : convention === 'milestone-prefixed' ? `(${phaseNumberTokenSource(convention)})(?=[:.\\s*]|$)` : '(\\d+)(?=[:.\\s*]|$)';
439
441
  const checkbox = `\\[${bracket ? `(?!${BRACKET_ID_SRC}\\])` : ''}[^\\]]{0,200}\\][ \\t]*`;
440
442
  if (!bracket) {
441
443
  if (withBullets)
@@ -1582,9 +1584,17 @@ const CUSTOM_PHASE_ID_FILE = '.phase-id';
1582
1584
  function phaseDirectoryContext(config, roadmap = '', knownIds = []) {
1583
1585
  return { phaseNaming: typeof config.phase_naming === 'string' ? config.phase_naming.toLowerCase() : config.phase_naming, projectCode: config.project_code ?? null, roadmap, knownIds };
1584
1586
  }
1585
- /** Custom scans reuse the numeric source verbatim outside configured custom projects. */
1586
- function phaseScanTokenSource(context) {
1587
- return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : PHASE_NUMBER_TOKEN_SOURCE;
1587
+ /** #5676: the single owner of the numeric phase-token source per convention. */
1588
+ function phaseNumberTokenSource(convention) {
1589
+ return convention === 'milestone-prefixed' ? PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED : PHASE_NUMBER_TOKEN_SOURCE;
1590
+ }
1591
+ /**
1592
+ * Custom scans reuse the numeric source verbatim outside configured custom projects.
1593
+ * #5676: under `milestone-prefixed` the numeric source admits the `M-NN` token
1594
+ * (`3-02`), so a `03-02-beta` directory is read as phase `3-02`, not phase `3`.
1595
+ */
1596
+ function phaseScanTokenSource(context, convention) {
1597
+ return context?.phaseNaming === 'custom' ? '[^ \t:\n]+' : phaseNumberTokenSource(convention);
1588
1598
  }
1589
1599
  const customIdentityIndexes = new WeakMap();
1590
1600
  function customIdentityIndex(options) {
@@ -1986,12 +1996,17 @@ function phaseKeyFromDir(dirName, convention, context) {
1986
1996
  * `Phase:` value. Markdown emphasis is stripped first so a bolded cell is not
1987
1997
  * mistaken for a non-phase. Returns null when the value does not BEGIN with a
1988
1998
  * phase token (`parsePhaseFromProse` anchoring, #2111).
1999
+ *
2000
+ * #5675: `convention` is the project's `phase_id_convention`. Under `milestone-prefixed` the id is
2001
+ * the whole `M-NN` (`3-01`), so the key equals `phaseKeyFromDir` for the same phase; every other
2002
+ * convention (and an absent one) keeps the leading-integer read. It is the trailing parameter so the
2003
+ * existing `(value, context)` callers stay byte-identical.
1989
2004
  */
1990
- function phaseKeyFromProse(value, context) {
2005
+ function phaseKeyFromProse(value, context, convention) {
1991
2006
  if (value == null)
1992
2007
  return null;
1993
2008
  const clean = String(value).replace(/[*_`~]/g, '');
1994
- const { phase } = parsePhaseFromProse(clean, undefined, context);
2009
+ const { phase } = parsePhaseFromProse(clean, convention, context);
1995
2010
  return phase === null ? null : phaseKeyFromToken(phase, context);
1996
2011
  }
1997
2012
  /**
@@ -2031,7 +2046,7 @@ function isPlannedPhaseStatusPhrase(value) {
2031
2046
  // (`3-01`), so the number capture takes one optional `-NN` segment; every other convention (and an absent
2032
2047
  // one) keeps the dotted-only capture, where `3-01` reads as phase `3` exactly as before.
2033
2048
  const PHASE_PROSE_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+[A-Z]?(?:\.\d+)*)\b/i;
2034
- const PHASE_PROSE_MILESTONE_PREFIXED_RE = /^\s*(?:Phase\s+)?(?:[A-Z][A-Z0-9_]*-)?(\d+(?:-\d+)?[A-Z]?(?:\.\d+)*)\b/i;
2049
+ const PHASE_PROSE_MILESTONE_PREFIXED_RE = new RegExp(`^\\s*(?:Phase\\s+)?(?:[A-Z][A-Z0-9_]*-)?(${PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED})\\b`, 'i');
2035
2050
  function parsePhaseFromProse(value, convention, context) {
2036
2051
  if (!value)
2037
2052
  return { phase: null, name: null };
@@ -2206,6 +2221,8 @@ const phaseIdExports = {
2206
2221
  OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
2207
2222
  OPTIONAL_PHASE_TAG_SOURCE,
2208
2223
  PHASE_NUMBER_TOKEN_SOURCE,
2224
+ PHASE_NUMBER_TOKEN_SOURCE_MILESTONE_PREFIXED,
2225
+ phaseNumberTokenSource,
2209
2226
  PHASE_DEP_REF_SOURCE,
2210
2227
  tokenizePhaseDependencyReferences,
2211
2228
  extractPhaseDependencyTokens,