@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
@@ -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,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 });
@@ -339,6 +339,9 @@ const ERROR_REASON = Object.freeze({
339
339
  SDK_MISSING_ARG: 'sdk_missing_arg',
340
340
  // workflow / phase
341
341
  PHASE_NOT_FOUND: 'phase_not_found',
342
+ PHASE_ID_COLLISION: 'phase_id_collision',
343
+ AMBIGUOUS_PHASE_OWNER: 'ambiguous_phase_owner',
344
+ PHASE_NAMING_NOT_CUSTOM: 'phase_naming_not_custom',
342
345
  PHASE_VERIFICATION_INCOMPLETE: 'phase_verification_incomplete',
343
346
  PHASE_PLAN_COVERAGE_INCOMPLETE: 'phase_plan_coverage_incomplete',
344
347
  SUMMARY_NO_PLANNING: 'summary_no_planning',
@@ -471,6 +474,9 @@ const REASON_TO_OUTCOME = Object.freeze({
471
474
  [ERROR_REASON.SDK_MISSING_ARG]: 'USAGE',
472
475
  [ERROR_REASON.GRAPHIFY_INVALID_QUERY]: 'USAGE',
473
476
  [ERROR_REASON.USAGE]: 'USAGE',
477
+ [ERROR_REASON.PHASE_ID_COLLISION]: 'USAGE',
478
+ [ERROR_REASON.AMBIGUOUS_PHASE_OWNER]: 'USAGE',
479
+ [ERROR_REASON.PHASE_NAMING_NOT_CUSTOM]: 'USAGE',
474
480
  // A specific, named thing does not exist / nothing was there to find —
475
481
  // genuine, known emptiness rather than a broken prerequisite.
476
482
  [ERROR_REASON.CONFIG_KEY_NOT_FOUND]: 'NO_INPUT',
@@ -30,6 +30,8 @@ exports.maskFences = maskFences;
30
30
  exports.editOutsideFences = editOutsideFences;
31
31
  exports.replaceSection = replaceSection;
32
32
  exports.withSection = withSection;
33
+ exports.headingSliceRange = headingSliceRange;
34
+ exports.editHeadingSlice = editHeadingSlice;
33
35
  exports.deleteSection = deleteSection;
34
36
  exports.extractXmlTagBodies = extractXmlTagBodies;
35
37
  const pattern_cjs_1 = require("./pattern.cjs");
@@ -1035,6 +1037,29 @@ function withSection(content, target, edit, opts = {}) {
1035
1037
  return content;
1036
1038
  return replaceSection(content, section, newBody);
1037
1039
  }
1040
+ /**
1041
+ * Range of the slice that starts at `headings[index]` and runs to the next
1042
+ * heading of level <= `maxLevel` (fence-aware: `headings` comes from
1043
+ * `tokenizeHeadings`). Unlike `collectSection` it keeps the heading line and
1044
+ * every trailing byte, so a caller that edits the slice can splice it back
1045
+ * byte-exact (#5762).
1046
+ */
1047
+ function headingSliceRange(content, headings, index, maxLevel = 2) {
1048
+ const next = headings.slice(index + 1).find((h) => h.level <= maxLevel);
1049
+ return { start: headings[index].offset, end: next ? next.offset : content.length };
1050
+ }
1051
+ /**
1052
+ * Run `edit` on `content.slice(range.start, end)` and splice the result back,
1053
+ * where `end` excludes the single newline that precedes the next heading — the
1054
+ * slice the pre-owner writers cut with `search(/\n#{1,2}[ \t]/)` — so a row
1055
+ * inserted at the end of a table keeps the blank line before that heading.
1056
+ */
1057
+ function editHeadingSlice(content, range, edit) {
1058
+ const end = range.end < content.length && range.end > range.start && content[range.end - 1] === '\n'
1059
+ ? range.end - 1
1060
+ : range.end;
1061
+ return content.slice(0, range.start) + edit(content.slice(range.start, end)) + content.slice(end);
1062
+ }
1038
1063
  // ─── deleteSection ────────────────────────────────────────────────────────────
1039
1064
  /**
1040
1065
  * Delete an entire section — the matching heading line ITSELF plus its body —
@@ -1065,6 +1090,8 @@ function withSection(content, target, edit, opts = {}) {
1065
1090
  * double-blank gap accumulates where the section used to sit. Content
1066
1091
  * elsewhere in the document is never touched.
1067
1092
  *
1093
+ * `endOffset` adds an optional caller-owned maximum boundary. It never widens
1094
+ * the heading-level range, and callers that omit it retain the legacy bytes.
1068
1095
  * Returns `content` unchanged when no heading matches `headingPredicate`
1069
1096
  * (bounded no-op, mirroring `withSection`'s miss behaviour).
1070
1097
  */
@@ -1103,7 +1130,13 @@ function deleteSection(content, headingPredicate, opts = {}) {
1103
1130
  }
1104
1131
  const eofOffset = acc;
1105
1132
  const sectionStart = lineOffsets[target.line - 1]; // start of the target heading LINE itself
1106
- const sectionEnd = stopLine <= lines.length ? lineOffsets[stopLine - 1] : eofOffset;
1133
+ const headingBoundedEnd = stopLine <= lines.length ? lineOffsets[stopLine - 1] : eofOffset;
1134
+ const explicitEnd = typeof opts.endOffset === 'number' && Number.isFinite(opts.endOffset)
1135
+ ? Math.max(0, Math.min(content.length, opts.endOffset))
1136
+ : headingBoundedEnd;
1137
+ const sectionEnd = Math.min(headingBoundedEnd, explicitEnd);
1138
+ if (sectionEnd <= sectionStart)
1139
+ return content;
1107
1140
  const before = content.slice(0, sectionStart);
1108
1141
  const after = content.slice(sectionEnd);
1109
1142
  // Collapse a resulting blank-line seam to at most one blank line (2 newlines).
@@ -25,6 +25,7 @@ exports.escapeCell = escapeCell;
25
25
  exports.appendQuickTaskRow = appendQuickTaskRow;
26
26
  exports.migrateQuickTasksTable = migrateQuickTasksTable;
27
27
  exports.resetQuickTaskRows = resetQuickTaskRows;
28
+ exports.updateTraceabilityCell = updateTraceabilityCell;
28
29
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
29
30
  // ─── Schema registry ──────────────────────────────────────────────────────────
30
31
  /**
@@ -978,5 +979,27 @@ function resetQuickTaskRows(stateContent) {
978
979
  const content = (0, markdown_sectionizer_cjs_1.replaceSection)(stateContent, section, newBody);
979
980
  return { ok: true, value: { content, cleared, variant: match.label } };
980
981
  }
982
+ /**
983
+ * `updateTableCell` scoped to REQUIREMENTS.md's `## Traceability` (or
984
+ * `## Traceability Status`) section (#2245 F1, #5762): `updateTableCell` binds
985
+ * to the first table it finds, and the shipped template puts an `## Out of
986
+ * Scope` table (`Feature | Reason`, no `Status` column) before Traceability, so
987
+ * an unscoped call never reaches the real rows. The ONE copy of this slice
988
+ * (it used to live in both milestone.cts and phase.cts); the heading rule is
989
+ * the Progress owner's — first level-2 heading outside fenced code, running to
990
+ * the next level-1/2 heading. No such heading: the whole text, as before.
991
+ */
992
+ function updateTraceabilityCell(text, match, column, newValue) {
993
+ 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));
995
+ if (index === -1)
996
+ return updateTableCell(text, match, column, newValue);
997
+ let result = { ok: false, reason: 'no matching row' };
998
+ const edited = (0, markdown_sectionizer_cjs_1.editHeadingSlice)(text, (0, markdown_sectionizer_cjs_1.headingSliceRange)(text, headings, index, 2), (scoped) => {
999
+ result = updateTableCell(scoped, match, column, newValue);
1000
+ return result.ok ? result.value : scoped;
1001
+ });
1002
+ return result.ok ? { ok: true, value: edited } : result;
1003
+ }
981
1004
  // Consumers: require('../gsd-core/bin/lib/markdown-table.cjs')
982
1005
  // Named CJS exports are the canonical surface (ADR-457 .cts → .cjs build-at-publish).
@@ -109,10 +109,10 @@ const TOOLS = [
109
109
  type: 'object',
110
110
  properties: {
111
111
  family: { type: 'string', description: 'Command family (e.g. "query", "state", "phase").' },
112
- subcommand: { type: 'string', description: 'Subcommand name.' },
112
+ subcommand: { type: 'string', description: 'Subcommand name, if the command takes one. Omit or leave empty for family-only commands.' },
113
113
  args: { type: 'array', items: {}, description: 'Positional args.' },
114
114
  },
115
- required: ['family', 'subcommand'],
115
+ required: ['family'],
116
116
  },
117
117
  },
118
118
  {
@@ -182,10 +182,13 @@ function callTool(name, args, ctx) {
182
182
  if (name === 'gsd_invoke_command') {
183
183
  const family = asString(a.family);
184
184
  const subcommand = asString(a.subcommand);
185
- if (!family || !subcommand) {
186
- return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "family" and "subcommand".' }] };
185
+ if (!family) {
186
+ return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires a non-empty string "family".' }] };
187
187
  }
188
- const res = dispatchGsdCommand({ family, subcommand, args: Array.isArray(a.args) ? a.args : [], cwd });
188
+ if (a.subcommand !== undefined && subcommand === null) {
189
+ return { isError: true, content: [{ type: 'text', text: 'gsd_invoke_command requires string "subcommand" when provided.' }] };
190
+ }
191
+ const res = dispatchGsdCommand({ family, subcommand: subcommand ?? undefined, args: Array.isArray(a.args) ? a.args : [], cwd });
189
192
  if (!res.ok) {
190
193
  return { isError: true, content: [{ type: 'text', text: res.stderr || res.stdout || `dispatch failed (exit ${res.code})` }] };
191
194
  }
@@ -38,7 +38,7 @@ const stateContract = require("./state-contract.cjs");
38
38
  const { publishStateContract } = stateContract;
39
39
  // eslint-disable-next-line @typescript-eslint/no-require-imports
40
40
  const phaseIdMod = require("./phase-id.cjs");
41
- const { normalizePhaseName, matchPhaseDirs, isSentinelPhaseId, isSentinelPhaseDir, buildPhaseHeadingScanRegex, PHASE_HEADING_BASELINE } = phaseIdMod;
41
+ const { isSentinelPhaseId, isSentinelPhaseDir, buildPhaseHeadingScanRegex, PHASE_HEADING_BASELINE } = phaseIdMod;
42
42
  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
@@ -55,7 +55,7 @@ const planScanMod = require("./plan-scan.cjs");
55
55
  const { scanPhasePlans } = planScanMod;
56
56
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
57
57
  const phaseLocatorMod = require("./phase-locator.cjs");
58
- const { listMilestonePhaseDirs } = phaseLocatorMod;
58
+ const { phaseDirectoryOptions, listMilestonePhaseDirs, resolvePhaseDirectoryLookup, matchPhaseDirsForLookup } = phaseLocatorMod;
59
59
  const { planningPaths, resolvePhaseIdConvention } = planningWorkspace;
60
60
  const { extractFrontmatter } = frontmatterMod;
61
61
  // ADR-3408 §8.3 / #3469: `writeStateMd` gets sync and NO preservation — the
@@ -73,43 +73,6 @@ const { syncAndPreserveStateMd, withStateLock, readModifyWriteStateMd, assertVer
73
73
  // the caller-supplied `--archive-version` override and the STATE.md-derived
74
74
  // live-read value, so a crafted value cannot escape `.planning/milestones/`.
75
75
  const ARCHIVE_VERSION_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
76
- /**
77
- * Scope an `updateTableCell` call to the `## Traceability` (or
78
- * `## Traceability Status`) heading's own section — up to the next H1/H2
79
- * heading — instead of handing it the WHOLE REQUIREMENTS.md content.
80
- *
81
- * F1 (#2245 review, BLOCKER): `updateTableCell` binds to the FIRST GFM table
82
- * found in whatever text it is given. The shipped requirements template
83
- * (gsd-core/templates/requirements.md) puts an `## Out of Scope` table
84
- * (`| Feature | Reason |`, no `Status` column) BEFORE `## Traceability` — so
85
- * an unscoped whole-file call targets the Out-of-Scope table instead, fails
86
- * with `{ok:false, reason:'unknown column: Status'}`, and the real
87
- * Traceability row is never flipped, while the checkbox surface still flips
88
- * and the command reports success (the #2140 silent-divergence class one
89
- * level deeper). Mirrors phase.cts's `editProgressHeadingSlice` scoping of
90
- * `## Progress` writes to that heading's own slice.
91
- *
92
- * Falls back to running `updateTableCell` against the whole `text` when no
93
- * `## Traceability` heading exists — matching the previous (unscoped)
94
- * behaviour for a REQUIREMENTS.md whose traceability table sits under some
95
- * other heading, or with no heading at all (never worse than before this fix).
96
- */
97
- function updateTraceabilityCell(text, match, column, newValue) {
98
- const headingMatch = text.match(/^##[ \t]+Traceability(?:[ \t]+Status)?\b/im);
99
- if (!headingMatch || headingMatch.index === undefined) {
100
- return (0, markdown_table_cjs_1.updateTableCell)(text, match, column, newValue);
101
- }
102
- const headingOffset = headingMatch.index;
103
- const before = text.slice(0, headingOffset);
104
- const fromHeading = text.slice(headingOffset);
105
- const nextHeadingOffset = fromHeading.search(/\n#{1,2}[ \t]/);
106
- const scoped = nextHeadingOffset >= 0 ? fromHeading.slice(0, nextHeadingOffset) : fromHeading;
107
- const after = nextHeadingOffset >= 0 ? fromHeading.slice(nextHeadingOffset) : '';
108
- const result = (0, markdown_table_cjs_1.updateTableCell)(scoped, match, column, newValue);
109
- if (!result.ok)
110
- return result;
111
- return { ok: true, value: before + result.value + after };
112
- }
113
76
  function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
114
77
  if (!reqIdsRaw || reqIdsRaw.length === 0) {
115
78
  error('requirement IDs required. Usage: requirements mark-complete REQ-01,REQ-02 or REQ-01 REQ-02');
@@ -187,7 +150,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
187
150
  // Complete" gate is folded into the newValue callback so one
188
151
  // updateTableCell call both probes the current value and writes.
189
152
  let tableHit = false;
190
- const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
153
+ const tableUpdate = (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, rowMatch, 'Status', (current) => {
191
154
  // #2788: accept `Gaps Found` as a forward input too — `revert-phase` (the
192
155
  // documented gaps_found response) leaves a row stranded at Gaps Found with
193
156
  // no inverse; a genuinely-satisfied requirement must be able to reach
@@ -230,7 +193,7 @@ function cmdRequirementsMarkComplete(cwd, reqIdsRaw, raw) {
230
193
  // that genuinely exists. Probe via a no-op updateTableCell write (its own
231
194
  // tolerant scan) instead of findTableWithColumns (whole-table parse gate).
232
195
  let currentStatusCell = '';
233
- const statusProbe = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
196
+ const statusProbe = (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, rowMatch, 'Status', (current) => {
234
197
  currentStatusCell = current;
235
198
  return current;
236
199
  });
@@ -434,7 +397,7 @@ function cmdRequirementsRevertPhase(cwd, reqIdsRaw, raw) {
434
397
  // "only flip Pending -> Complete" gate, in reverse).
435
398
  const rowMatch = (row) => (Object.values(row)[0] ?? '').trim().toLowerCase() === reqId.toLowerCase();
436
399
  let tableHit = false;
437
- const tableUpdate = updateTraceabilityCell(reqContent, rowMatch, 'Status', (current) => {
400
+ const tableUpdate = (0, markdown_table_cjs_1.updateTraceabilityCell)(reqContent, rowMatch, 'Status', (current) => {
438
401
  if (/^complete$/i.test(current.trim())) {
439
402
  tableHit = true;
440
403
  return ' Gaps Found ';
@@ -585,7 +548,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
585
548
  // `platformEnsureDir(archiveDir)` below — a refused run (scope not COMPLETE,
586
549
  // no --force) must be a true no-op on disk, and creating the archive
587
550
  // directory first left an empty directory behind even on refusal.
588
- const isDirInMilestone = getMilestonePhaseFilter(cwd, version);
551
+ const isDirInMilestone = getMilestonePhaseFilter(cwd, version, undefined, undefined, phaseDirectoryOptions(cwd));
589
552
  if (isDirInMilestone.missingExplicitVersion) {
590
553
  error(`no phases found for milestone ${version} in ROADMAP.md`);
591
554
  }
@@ -610,6 +573,10 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
610
573
  `entries, even though the ROADMAP has phase entries elsewhere), so phase scoping ` +
611
574
  `cannot be trusted for this destructive operation. Re-run with --force to override.`);
612
575
  }
576
+ // #2761/#4304: resolve once for every phase-directory consumer in this
577
+ // command. The matcher opts in only for bracket; the milestone enumerator
578
+ // retains the full resolved value it already accepted.
579
+ const phaseConvention = resolvePhaseIdConvention(cwd);
613
580
  // Guard: prevent marking complete when ROADMAP still lists phases that have
614
581
  // no directory on disk (disk_status: no_directory). This catches the case
615
582
  // where the active milestone was erroneously marked complete before phases
@@ -691,8 +658,8 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
691
658
  // properly-scaffolded bracket phase reads as disk_status: 'no_directory'
692
659
  // — turning a real bracket project's own phases into false "ROADMAP
693
660
  // lists N unstarted phase(s)" failures on `milestone complete`.
694
- const convention = resolvePhaseIdConvention(cwd);
695
- const { regex: phasePattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.ANY_BRACKET, convention);
661
+ const directoryContext = phaseDirectoryOptions(cwd, undefined, scopedContent);
662
+ const { regex: phasePattern, phaseNumGroup } = buildPhaseHeadingScanRegex(PHASE_HEADING_BASELINE.ANY_BRACKET, phaseConvention, directoryContext);
696
663
  const noDirectoryPhases = [];
697
664
  let pm;
698
665
  const phaseDirEntries = (() => {
@@ -716,12 +683,14 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
716
683
  // #3185: canonical sentinel predicate (SENTINEL_RANGES [0,999]) — this local check already covered both 0 and 999; now delegates to the single canonical owner.
717
684
  if (isSentinelPhaseId(phaseNum))
718
685
  continue;
719
- const normalized = normalizePhaseName(phaseNum);
686
+ const lookup = resolvePhaseDirectoryLookup(cwd, phaseNum, undefined, directoryContext);
720
687
  // A phase has disk_status: 'no_directory' when no phase directory
721
688
  // with a matching token exists on disk. Use the same matchPhaseDirs
722
689
  // owner that roadmap.analyze uses to avoid false positives on decimal
723
- // (2.1) and letter-suffix (12A) phase IDs. (#2528)
724
- const hasDirectory = matchPhaseDirs(phaseDirEntries, normalized, convention).matches.length > 0;
690
+ // (2.1) and letter-suffix (12A) phase IDs. (#2528) Under #4304 the
691
+ // current checkout's bracket convention and migration-window legacy
692
+ // fallback are both owned by the shared lookup adapter.
693
+ const hasDirectory = matchPhaseDirsForLookup(phaseDirEntries, lookup, undefined, directoryContext).matches.length > 0;
725
694
  if (!hasDirectory) {
726
695
  noDirectoryPhases.push(phaseNum);
727
696
  }
@@ -751,7 +720,7 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
751
720
  let totalPlans = 0;
752
721
  let totalTasks = 0;
753
722
  const accomplishments = [];
754
- // #2761 (round-11 BLOCKER): resolved ONCE, ambiently (no explicit `ws` —
723
+ // #2761: resolved ONCE above, ambiently (no explicit `ws` —
755
724
  // this function has none of its own and already resolves everything else
756
725
  // off `cwd` via planningPaths(cwd), matching the ambient-workstream
757
726
  // contract `resolvePhaseIdConvention`/`planningDir` share), and threaded
@@ -766,7 +735,6 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
766
735
  // the silent-inherit path without changing behavior for `null` /
767
736
  // `milestone-prefixed` projects, whose resolved convention is the same
768
737
  // value `undefined` would have lazily produced anyway.
769
- const phaseConvention = resolvePhaseIdConvention(cwd);
770
738
  // #3597 (ADR-3180 Decision 2): SINGLE resolution of "which phase
771
739
  // directories belong to the current milestone" AND the SCOPE discriminator
772
740
  // that resolution came from — shared verbatim by the read-only stats loop
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * Phase-entry grammar — the one definition of a ROADMAP phase-list entry
4
+ * (`- [ ] **Phase 1: Name**`): bullet marker, checkbox, bold token and line
5
+ * anchoring (#5763, Phase 3 of epic #5692; census in #5703).
6
+ *
7
+ * Before this module every reader owned a private pattern, and the patterns
8
+ * disagreed on each axis (`-` only vs `[-*]`; required, optional or `\*{0,2}`
9
+ * bold; `[ x]` vs `[ xX]` vs `(x| )`; `^`-anchored vs the unanchored forms
10
+ * behind #4965 and #5545). Readers now compose their pattern from the prefix
11
+ * built here and keep only their own tail (label, id token, separator).
12
+ *
13
+ * The accepted grammar:
14
+ * entry := indent marker mgap box gap bold? gap <caller tail>
15
+ * indent := [ \t]* — an entry starts its line
16
+ * marker := '-' | '*'
17
+ * mgap := [ \t]{1,4} — a list item: `-[ ]` is not one (#5764)
18
+ * box := '[' (' ' | 'x' | 'X') ']'
19
+ * bold := '**' | '__' — a reader may require it
20
+ * gap := [ \t]* — horizontal whitespace only
21
+ * A reader that needs plain `- **Phase N**` bullets may make the box optional.
22
+ *
23
+ * Writers (flip, delete, renumber, id rewrite) compose their patterns here too
24
+ * (#5764, Phase 4): `phaseEntryRegExp` for a whole-line match and
25
+ * `phaseEntrySplitRegExp` when the writer rewrites the box and needs the text on
26
+ * either side of it.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.PHASE_ENTRY_BOLD_CLOSE_SRC = exports.PHASE_ENTRY_BOLD_TOKEN_SRC = void 0;
30
+ exports.phaseEntryPrefixSrc = phaseEntryPrefixSrc;
31
+ exports.phaseEntryRegExp = phaseEntryRegExp;
32
+ exports.phaseEntrySplitRegExp = phaseEntrySplitRegExp;
33
+ exports.isPhaseEntryChecked = isPhaseEntryChecked;
34
+ exports.isBoxCharChecked = isBoxCharChecked;
35
+ const MARKER_SRC = '[-*]';
36
+ // A list item has 1 to 4 spaces or tabs after its marker (CommonMark: none is not a list item,
37
+ // five or more makes the rest an indented code block), so `-[ ] **Phase 1**` is not an entry.
38
+ const MARKER_GAP_SRC = '[ \\t]{1,4}';
39
+ const BOLD_TOKEN_SRC = '(?:\\*\\*|__)';
40
+ /** Regex source of one bold token, for a reader that must also consume the closing wrapper. */
41
+ exports.PHASE_ENTRY_BOLD_TOKEN_SRC = BOLD_TOKEN_SRC;
42
+ /**
43
+ * Regex source of a closing bold wrapper for a reader that captures the entry's
44
+ * name up to it. `**` closes anywhere; `__` closes only at the end of the line,
45
+ * so a name that contains `__init__` or `__tests__` is not cut short.
46
+ */
47
+ exports.PHASE_ENTRY_BOLD_CLOSE_SRC = '(?:\\*\\*|__(?=[ \\t]*\\r?$))';
48
+ /**
49
+ * Line start, indent, marker and the marker's gap, up to where the box opens.
50
+ * A leading BOM can only precede the first line; it is not part of the grammar's indent.
51
+ */
52
+ const LINE_OPEN_SRC = `^\\uFEFF?[ \\t]*${MARKER_SRC}${MARKER_GAP_SRC}`;
53
+ /**
54
+ * Source for the bold token and its trailing gap. `(?:token[ \t]*)?` rather than
55
+ * `[ \t]*` + token + `[ \t]*`, which backtracks quadratically on a long blank run.
56
+ */
57
+ function boldSrcFor(bold) {
58
+ return bold === 'required' ? `${BOLD_TOKEN_SRC}[ \\t]*` : `(?:${BOLD_TOKEN_SRC}[ \\t]*)?`;
59
+ }
60
+ /** Source for the box interior, honouring the requested state filter. */
61
+ function stateSrc(state) {
62
+ if (state === 'unchecked')
63
+ return ' ';
64
+ if (state === 'checked')
65
+ return '[xX]';
66
+ if (state === 'captured')
67
+ return '([ xX])';
68
+ return '[ xX]';
69
+ }
70
+ /**
71
+ * Regex source of everything before the caller's `Phase`/label tail: line start, indent,
72
+ * marker, box, bold token and the horizontal gaps between them. A `captured`
73
+ * state adds exactly one capture group (the box character) at the front.
74
+ */
75
+ function phaseEntryPrefixSrc(options = {}) {
76
+ const { bold = 'optional', box = 'required', state = 'any', decorated = false } = options;
77
+ // The decoration may not contain the word `Phase`, so the entry's own label is the first one
78
+ // on the line and `- [ ] **Phase 5**: needs Phase 2` is not an entry of phase 2. No gap
79
+ // before the run: `[ \t]*` then a lazy run overlap and backtrack quadratically.
80
+ if (decorated)
81
+ return `${LINE_OPEN_SRC}\\[${stateSrc(state)}\\](?:(?!Phase)[^\\r\\n])*?`;
82
+ const boxSrc = `\\[${stateSrc(state)}\\][ \\t]*`;
83
+ const body = box === 'optional' ? `(?:${boxSrc})?` : boxSrc;
84
+ return `${LINE_OPEN_SRC}${body}${boldSrcFor(bold)}`;
85
+ }
86
+ /**
87
+ * A RegExp for a phase entry: the grammar prefix followed by the caller's
88
+ * `tailSrc`. The `m` flag is always set, so `^` is a line start.
89
+ */
90
+ function phaseEntryRegExp(tailSrc, flags = '', options = {}) {
91
+ const withM = flags.includes('m') ? flags : `${flags}m`;
92
+ return new RegExp(`${phaseEntryPrefixSrc(options)}${tailSrc}`, withM);
93
+ }
94
+ /**
95
+ * A RegExp for a phase entry a writer rewrites at the box: capture group 1 is
96
+ * everything through the opening `[` (indent, marker, gap) and capture group 2
97
+ * is everything from the closing `]` through the end of `tailSrc` (gap, bold
98
+ * token, label). A flip is `line.replace(re, '$1x$2')`; the box state itself is
99
+ * matched, not captured, so the writer chooses the new state. The `m` flag is
100
+ * always set.
101
+ */
102
+ function phaseEntrySplitRegExp(tailSrc, flags = '', options = {}) {
103
+ const { bold = 'optional', state = 'any' } = options;
104
+ const withM = flags.includes('m') ? flags : `${flags}m`;
105
+ return new RegExp(`(${LINE_OPEN_SRC}\\[)${stateSrc(state)}(\\][ \\t]*${boldSrcFor(bold)}${tailSrc})`, withM);
106
+ }
107
+ const ENTRY_BOX_RE = new RegExp(`^\\uFEFF?[ \\t]*${MARKER_SRC}${MARKER_GAP_SRC}\\[([ xX])\\]`);
108
+ /**
109
+ * Whether a phase entry line (or a regex match of one, which starts at the
110
+ * line) is ticked. The single place the box character is interpreted.
111
+ */
112
+ function isPhaseEntryChecked(entryText) {
113
+ const m = ENTRY_BOX_RE.exec(entryText);
114
+ return m !== null && m[1].toLowerCase() === 'x';
115
+ }
116
+ /** Whether a captured box character (`captured` state option) is a tick. */
117
+ function isBoxCharChecked(boxChar) {
118
+ return boxChar.toLowerCase() === 'x';
119
+ }
@@ -75,4 +75,6 @@ function renderBracketMilestoneDisplay(milestone, projectCode) {
75
75
  module.exports = {
76
76
  renderBracketPhaseDisplay,
77
77
  renderBracketMilestoneDisplay,
78
+ milestoneToken,
79
+ phaseToken,
78
80
  };