@opengsd/gsd-core 1.6.1 → 1.7.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +5 -2
  27. package/bin/gsd-mcp-server.js +31 -0
  28. package/bin/install.js +411 -1146
  29. package/commands/gsd/review.md +6 -0
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +134 -8
  32. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  33. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  35. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  36. package/gsd-core/bin/lib/capability-loader.cjs +45 -9
  37. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  38. package/gsd-core/bin/lib/capability-registry.cjs +891 -82
  39. package/gsd-core/bin/lib/capability-source.cjs +26 -11
  40. package/gsd-core/bin/lib/capability-validator.cjs +222 -2
  41. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  43. package/gsd-core/bin/lib/commands.cjs +2 -1
  44. package/gsd-core/bin/lib/config.cjs +27 -0
  45. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  46. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  47. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  48. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  49. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  50. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  51. package/gsd-core/bin/lib/host-integration.cjs +469 -0
  52. package/gsd-core/bin/lib/init.cjs +35 -7
  53. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  54. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  55. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  56. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  57. package/gsd-core/bin/lib/milestone.cjs +68 -40
  58. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  59. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  60. package/gsd-core/bin/lib/phase.cjs +57 -90
  61. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  62. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  63. package/gsd-core/bin/lib/probe-core.cjs +132 -2
  64. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  68. package/gsd-core/bin/lib/roadmap.cjs +33 -22
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  70. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  71. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  73. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
  75. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  76. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  77. package/gsd-core/bin/lib/state-io.cjs +55 -0
  78. package/gsd-core/bin/lib/state-transition.cjs +1603 -0
  79. package/gsd-core/bin/lib/state.cjs +327 -683
  80. package/gsd-core/bin/lib/surface.cjs +4 -1
  81. package/gsd-core/bin/lib/validate.cjs +2 -1
  82. package/gsd-core/bin/lib/verify.cjs +6 -4
  83. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  84. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  85. package/gsd-core/bin/lib/workstream.cjs +4 -4
  86. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  87. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  88. package/gsd-core/references/honest-verifier.md +105 -0
  89. package/gsd-core/references/model-profiles.md +27 -0
  90. package/gsd-core/references/reviewer-instances.md +99 -0
  91. package/gsd-core/workflows/autonomous.md +30 -32
  92. package/gsd-core/workflows/complete-milestone.md +6 -10
  93. package/gsd-core/workflows/execute-phase.md +1 -1
  94. package/gsd-core/workflows/forensics.md +3 -3
  95. package/gsd-core/workflows/help/modes/full.md +1 -1
  96. package/gsd-core/workflows/manager.md +15 -15
  97. package/gsd-core/workflows/milestone-summary.md +3 -3
  98. package/gsd-core/workflows/new-milestone.md +6 -0
  99. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  100. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  101. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  102. package/gsd-core/workflows/plan-phase.md +4 -159
  103. package/gsd-core/workflows/review.md +33 -2
  104. package/gsd-core/workflows/thread.md +4 -4
  105. package/gsd-core/workflows/verify-phase.md +11 -4
  106. package/gsd-core/workflows/verify-work.md +1 -2
  107. package/hooks/dist/gsd-graphify-update.sh +7 -1
  108. package/hooks/gsd-graphify-update.sh +7 -1
  109. package/package.json +6 -4
  110. package/scripts/ci-test-scope.cjs +38 -9
  111. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  112. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  113. package/scripts/lint-test-file-count.allowlist.json +19 -5
  114. package/scripts/mutation-matrix.cjs +45 -3
  115. package/scripts/prompt-injection-scan.sh +8 -0
  116. package/scripts/run-tests.cjs +51 -1
  117. package/scripts/sync-manifest-versions.cjs +66 -14
  118. package/skills/gsd-review/SKILL.md +6 -0
  119. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -7,10 +7,19 @@
7
7
  *
8
8
  * Owns reviewer-selection policy projection for /gsd:review:
9
9
  * explicit flags > --all > review.default_reviewers > all detected.
10
+ *
11
+ * Reviewer instances (#1517): a bounded config surface
12
+ * `review.reviewer_instances.<name> = {cli, model?, agent?}` lets one
13
+ * model-capable adapter (e.g. opencode) run as several independent reviewer
14
+ * identities. Instances participate ONLY in the config_default branch (no
15
+ * per-instance CLI flags). An instance is available iff its base `cli` is
16
+ * detected. The instance→cli mapping lives HERE (single source; see the parity
17
+ * test in tests/review-reviewer-instances.test.cjs — DEFECT.GENERATIVE-FIX).
10
18
  */
11
19
  Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.KNOWN_REVIEWER_SLUGS = void 0;
20
+ exports.INSTANCE_NAME_PATTERN = exports.KNOWN_REVIEWER_SLUGS = void 0;
13
21
  exports.normalizeConfiguredDefaultReviewers = normalizeConfiguredDefaultReviewers;
22
+ exports.normalizeReviewerInstances = normalizeReviewerInstances;
14
23
  exports.resolveReviewerSelection = resolveReviewerSelection;
15
24
  exports.KNOWN_REVIEWER_SLUGS = [
16
25
  'gemini',
@@ -25,6 +34,8 @@ exports.KNOWN_REVIEWER_SLUGS = [
25
34
  'lm_studio',
26
35
  'llama_cpp',
27
36
  ];
37
+ /** Instance names are lowercase slugs that must not shadow a built-in slug. */
38
+ exports.INSTANCE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
28
39
  function normalizeConfiguredDefaultReviewers(rawValue) {
29
40
  if (rawValue === undefined || rawValue === null) {
30
41
  return { absent: true, values: [], errors: [] };
@@ -63,14 +74,75 @@ function normalizeConfiguredDefaultReviewers(rawValue) {
63
74
  }
64
75
  return { absent: false, values: normalized, errors };
65
76
  }
77
+ /**
78
+ * Validate the `review.reviewer_instances` config object (#1517).
79
+ * `cli` MUST be a known adapter (never an arbitrary shell command — Kerckhoffs /
80
+ * Postel: strict at the invocation boundary). `model`/`agent` are opaque
81
+ * pass-through strings; they are never interpolated into shell strings by this
82
+ * module. Instance names must not collide with a built-in slug.
83
+ */
84
+ function normalizeReviewerInstances(rawValue) {
85
+ if (rawValue === undefined || rawValue === null) {
86
+ return { instances: {}, errors: [] };
87
+ }
88
+ if (typeof rawValue !== 'object' || Array.isArray(rawValue)) {
89
+ return {
90
+ instances: {},
91
+ errors: ['review.reviewer_instances must be a JSON object mapping instance names to {cli,model,agent}'],
92
+ };
93
+ }
94
+ const obj = rawValue;
95
+ const instances = {};
96
+ const errors = [];
97
+ for (const [name, spec] of Object.entries(obj)) {
98
+ if (!exports.INSTANCE_NAME_PATTERN.test(name)) {
99
+ errors.push(`invalid reviewer instance name '${name}': must match ^[a-z0-9][a-z0-9-]*$`);
100
+ continue;
101
+ }
102
+ if (exports.KNOWN_REVIEWER_SLUGS.includes(name)) {
103
+ errors.push(`reviewer instance name '${name}' must not equal a built-in reviewer slug`);
104
+ continue;
105
+ }
106
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
107
+ errors.push(`reviewer_instances.${name} must be an object with at least {cli}`);
108
+ continue;
109
+ }
110
+ const s = spec;
111
+ const cli = s.cli;
112
+ if (typeof cli !== 'string' || !exports.KNOWN_REVIEWER_SLUGS.includes(cli)) {
113
+ errors.push(`reviewer_instances.${name}.cli must be a known reviewer adapter (got: ${JSON.stringify(cli)})`);
114
+ continue;
115
+ }
116
+ const instance = { cli };
117
+ if (s.model !== undefined && s.model !== null) {
118
+ if (typeof s.model !== 'string') {
119
+ errors.push(`reviewer_instances.${name}.model must be a string`);
120
+ continue;
121
+ }
122
+ instance.model = s.model;
123
+ }
124
+ if (s.agent !== undefined && s.agent !== null) {
125
+ if (typeof s.agent !== 'string') {
126
+ errors.push(`reviewer_instances.${name}.agent must be a string`);
127
+ continue;
128
+ }
129
+ instance.agent = s.agent;
130
+ }
131
+ instances[name] = instance;
132
+ }
133
+ return { instances, errors };
134
+ }
66
135
  function resolveReviewerSelection(input) {
67
136
  const detected = new Set((input.detected ?? []).map((v) => String(v).toLowerCase()));
68
137
  const explicitFlags = new Set((input.explicitFlags ?? []).map((v) => String(v).toLowerCase()));
69
138
  const allFlag = !!input.allFlag;
70
139
  const normalizedDefaults = normalizeConfiguredDefaultReviewers(input.configuredDefaultReviewers);
140
+ const normalizedInstances = normalizeReviewerInstances(input.reviewerInstances);
141
+ const instances = normalizedInstances.instances;
142
+ const instancesConfigured = Object.keys(instances).length > 0;
71
143
  const warnings = [];
72
144
  const infos = [];
73
- const errors = [...normalizedDefaults.errors];
145
+ const errors = [...normalizedDefaults.errors, ...normalizedInstances.errors];
74
146
  let source = 'no_config_all_detected';
75
147
  let selected = [];
76
148
  if (explicitFlags.size > 0) {
@@ -90,20 +162,40 @@ function resolveReviewerSelection(input) {
90
162
  }
91
163
  else if (!normalizedDefaults.absent) {
92
164
  source = 'config_default';
93
- const knownDefaults = [];
94
- for (const slug of normalizedDefaults.values) {
95
- if (!exports.KNOWN_REVIEWER_SLUGS.includes(slug)) {
96
- warnings.push(`unknown reviewer slug in review.default_reviewers: ${slug}`);
165
+ // #1517: expand instance references BEFORE the built-in-slug check. An
166
+ // instance name and a built-in slug are the two legal kinds of entry.
167
+ for (const entry of normalizedDefaults.values) {
168
+ if (instances[entry]) {
169
+ // Instance reference — available iff its base cli is detected.
170
+ const cli = instances[entry].cli;
171
+ if (!detected.has(cli)) {
172
+ infos.push(`configured instance ${entry} not detected (cli ${cli} missing on this host)`);
173
+ }
174
+ else {
175
+ selected.push(entry);
176
+ }
177
+ }
178
+ else if (exports.KNOWN_REVIEWER_SLUGS.includes(entry)) {
179
+ if (!detected.has(entry)) {
180
+ infos.push(`configured reviewers not detected on this host: ${entry}`);
181
+ }
182
+ else {
183
+ selected.push(entry);
184
+ }
97
185
  }
98
186
  else {
99
- knownDefaults.push(slug);
187
+ // Neither a defined instance nor a built-in slug.
188
+ if (instancesConfigured) {
189
+ // Most likely a typo'd instance name — must be loud (#1517 design Q2).
190
+ errors.push(`reviewer instance '${entry}' referenced in review.default_reviewers is not defined in review.reviewer_instances`);
191
+ }
192
+ else {
193
+ // Backward-compatible behaviour: unknown slug with no instances
194
+ // configured warns and is dropped.
195
+ warnings.push(`unknown reviewer slug in review.default_reviewers: ${entry}`);
196
+ }
100
197
  }
101
198
  }
102
- const undetected = knownDefaults.filter((slug) => !detected.has(slug));
103
- if (undetected.length > 0) {
104
- infos.push(`configured reviewers not detected on this host: ${undetected.join(', ')}`);
105
- }
106
- selected = knownDefaults.filter((slug) => detected.has(slug));
107
199
  if (selected.length === 0 && errors.length === 0) {
108
200
  errors.push('all configured default reviewers are unavailable on this host');
109
201
  }
@@ -111,11 +203,35 @@ function resolveReviewerSelection(input) {
111
203
  else {
112
204
  selected = [...detected];
113
205
  }
206
+ const selectedSorted = selected.sort();
207
+ // Single-source instance→cli resolution projected onto the selected set.
208
+ const resolvedInstances = selectedSorted.map((identity) => {
209
+ const inst = instances[identity];
210
+ if (inst) {
211
+ return {
212
+ identity,
213
+ kind: 'instance',
214
+ cli: inst.cli,
215
+ model: inst.model,
216
+ agent: inst.agent,
217
+ };
218
+ }
219
+ return { identity, kind: 'builtin', cli: identity };
220
+ });
221
+ const cliCounts = {};
222
+ for (const r of resolvedInstances) {
223
+ if (r.kind === 'instance') {
224
+ cliCounts[r.cli] = (cliCounts[r.cli] ?? 0) + 1;
225
+ }
226
+ }
227
+ const sharedAdapterCaveat = Object.values(cliCounts).some((c) => c >= 2);
114
228
  return {
115
229
  source,
116
- selected: selected.sort(),
230
+ selected: selectedSorted,
117
231
  warnings,
118
232
  infos,
119
233
  errors,
234
+ resolvedInstances,
235
+ sharedAdapterCaveat,
120
236
  };
121
237
  }
@@ -46,9 +46,10 @@ function checkW021(content) {
46
46
  // Capture the major integer.
47
47
  const MILESTONE_RE = /^#{1,3}\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.\d+(?:\s|:|\s*—)/iu;
48
48
  // Migrated phase heading: ### Phase M-NN: Name (M-NN or unpadded M-N form)
49
- const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*\s*:/i;
49
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
50
+ const PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+)-(\d+)(?:-\d+)*(?:\s*\([^)\n]*\))?\s*:/i;
50
51
  // Unprefixed legacy phase heading: ### Phase N: Name (no hyphen sub-index)
51
- const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)\s*:/i;
52
+ const UNPREFIXED_PHASE_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Za-z]?(?:\.\d+)*)(?:\s*\([^)\n]*\))?\s*:/i;
52
53
  let currentMilestoneMajor = null;
53
54
  const lines = content.split('\n');
54
55
  for (const line of lines) {
@@ -22,7 +22,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
22
22
  const node_path_1 = __importDefault(require("node:path"));
23
23
  // eslint-disable-next-line @typescript-eslint/no-require-imports
24
24
  const phaseIdModule = require("./phase-id.cjs");
25
- const { escapeRegex, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, } = phaseIdModule;
25
+ const { escapeRegex, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, stripProjectCodePrefix, OPTIONAL_PROJECT_CODE_PREFIX_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, } = phaseIdModule;
26
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports
27
27
  const planningWorkspace = require("./planning-workspace.cjs");
28
28
  const { planningDir } = planningWorkspace;
@@ -82,7 +82,8 @@ function extractCurrentMilestone(content, cwd) {
82
82
  const preambleCutoff = firstMilestoneMatch ? firstMilestoneMatch.index : detailsOpenIdx;
83
83
  const preamble = content.slice(0, preambleCutoff)
84
84
  .replace(/<details>[\s\S]*?<\/details>/gi, '')
85
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
85
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
86
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
86
87
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
87
88
  return preamble + content.slice(detailsOpenIdx, detailsEnd);
88
89
  }
@@ -151,7 +152,8 @@ function extractCurrentMilestone(content, cwd) {
151
152
  }
152
153
  const preamble = beforeMilestones
153
154
  .replace(/<details>[\s\S]*?<\/details>/gi, '')
154
- .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
155
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
156
+ .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*(?:\s*\([^)\n]*\))?\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '')
155
157
  .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, '');
156
158
  return detailsSection
157
159
  ? preamble + currentSection + '\n' + detailsSection
@@ -171,16 +173,20 @@ function replaceInCurrentMilestone(content, pattern, replacement) {
171
173
  return before + after.replace(pattern, replacement);
172
174
  }
173
175
  function findRoadmapPhaseInContent(content, phaseNum, phaseSource) {
174
- const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, 'i');
175
- const headerMatch = content.match(phasePattern);
176
+ // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
177
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
178
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
179
+ const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
180
+ if (headingIndex === -1)
181
+ return null;
182
+ const heading = headings[headingIndex];
183
+ const headerMatch = heading.text.match(headingPattern);
176
184
  if (!headerMatch)
177
185
  return null;
178
186
  const phaseName = headerMatch[1].trim();
179
- const headerIndex = headerMatch.index;
180
- const restOfContent = content.slice(headerIndex);
181
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i);
182
- const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index : content.length;
183
- const section = content.slice(headerIndex, sectionEnd).trim();
187
+ const nextHeading = headings.slice(headingIndex + 1).find((candidate) => candidate.level <= heading.level);
188
+ const sectionEnd = nextHeading ? nextHeading.offset : content.length;
189
+ const section = content.slice(heading.offset, sectionEnd).trim();
184
190
  const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i);
185
191
  const goal = goalMatch ? goalMatch[1].trim() : null;
186
192
  return {
@@ -209,6 +215,9 @@ function roadmapPhaseLookupSources(phaseNum) {
209
215
  function getRoadmapPhaseInternal(cwd, phaseNum) {
210
216
  if (!phaseNum)
211
217
  return null;
218
+ const normalizedPhase = stripProjectCodePrefix(phaseNum);
219
+ if (/^999(?:\.|$)/.test(normalizedPhase))
220
+ return null;
212
221
  const roadmapPath = node_path_1.default.join(planningDir(cwd), 'ROADMAP.md');
213
222
  if (!node_fs_1.default.existsSync(roadmapPath))
214
223
  return null;
@@ -369,7 +378,8 @@ function getMilestonePhaseFilter(cwd, versionOverride, phaseIdConvention) {
369
378
  }
370
379
  // Use tokenizeHeadings (fence-aware) instead of stripFencedLines + regex.
371
380
  // T4 seam migration: phase headings inside fences are excluded automatically.
372
- const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/i;
381
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
382
+ const phaseHeadingPattern = /^(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]*\))?\s*:/i;
373
383
  for (const h of (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(roadmap)) {
374
384
  if (h.level < 2 || h.level > 4)
375
385
  continue;
@@ -13,6 +13,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
13
13
  const node_fs_1 = __importDefault(require("node:fs"));
14
14
  const node_path_1 = __importDefault(require("node:path"));
15
15
  const node_child_process_1 = require("node:child_process");
16
+ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
17
  // eslint-disable-next-line @typescript-eslint/no-require-imports
17
18
  const planningWorkspace = require("./planning-workspace.cjs");
18
19
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -416,7 +417,7 @@ function applyMigration(cwd, plan, options = {}) {
416
417
  const oldPath = node_path_1.default.join(phasesDir, phaseEntry.oldDir);
417
418
  const newPath = node_path_1.default.join(phasesDir, phaseEntry.newDir);
418
419
  if (node_fs_1.default.existsSync(oldPath)) {
419
- node_fs_1.default.renameSync(oldPath, newPath);
420
+ (0, shell_command_projection_cjs_1.retryRenameSync)(oldPath, newPath);
420
421
  performedRenames.push({ oldPath, newPath });
421
422
  renamedDirs.push(`${phaseEntry.oldDir} → ${phaseEntry.newDir}`);
422
423
  }
@@ -483,7 +484,7 @@ function applyMigration(cwd, plan, options = {}) {
483
484
  const { oldPath, newPath } = performedRenames[i];
484
485
  try {
485
486
  if (node_fs_1.default.existsSync(newPath))
486
- node_fs_1.default.renameSync(newPath, oldPath);
487
+ (0, shell_command_projection_cjs_1.retryRenameSync)(newPath, oldPath);
487
488
  }
488
489
  catch { /* best-effort */ }
489
490
  }
@@ -17,13 +17,14 @@ const ioMod = require("./io.cjs");
17
17
  const { output, error } = ioMod;
18
18
  // eslint-disable-next-line @typescript-eslint/no-require-imports
19
19
  const phaseIdMod = require("./phase-id.cjs");
20
- const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches } = phaseIdMod;
20
+ const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, phaseTokenMatches, stripProjectCodePrefix, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
21
21
  // eslint-disable-next-line @typescript-eslint/no-require-imports
22
22
  const phaseLocatorMod = require("./phase-locator.cjs");
23
23
  const { findPhaseInternal } = phaseLocatorMod;
24
24
  // eslint-disable-next-line @typescript-eslint/no-require-imports
25
25
  const roadmapParserModule = require("./roadmap-parser.cjs");
26
26
  const { stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = roadmapParserModule;
27
+ const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
27
28
  const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
28
29
  // eslint-disable-next-line @typescript-eslint/no-require-imports
29
30
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -55,8 +56,11 @@ function coerceTruthToString(t) {
55
56
  return String(t);
56
57
  }
57
58
  if (typeof t === 'object') {
58
- // Prefer common title-bearing keys produced by parseMustHavesBlock
59
- for (const k of ['title', 'text', 'name', 'rule', 'path', 'provides']) {
59
+ // Prefer common title-bearing keys produced by parseMustHavesBlock. `statement` is the canonical
60
+ // truth/prohibition payload field — and the carrier of #1154's object-form backstop truth
61
+ // `{ statement, verification: backstop }`, so it leads (a non-inferable truth must be coerced by
62
+ // its statement, never dropped — the Hyrum backward-compat guard for the new marker).
63
+ for (const k of ['statement', 'title', 'text', 'name', 'rule', 'path', 'provides']) {
60
64
  const v = t[k];
61
65
  if (typeof v === 'string' && v.trim())
62
66
  return v;
@@ -91,12 +95,14 @@ function countPhasePlansAndSummaries(phaseDir) {
91
95
  * checklist-only match), or null if the phase is not present at all.
92
96
  */
93
97
  function searchPhaseInContent(content, escapedPhase, phaseNum) {
94
- // Match "## Phase X:", "### Phase X:", or "#### Phase X:" with optional name
95
- const phasePattern = new RegExp(`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}:\\s*([^\\n]+)`, 'i');
96
- const headerMatch = content.match(phasePattern);
98
+ // #1729: OPTIONAL_PHASE_TAG_SOURCE after the number tolerates a pre-colon ( ) tag.
99
+ const headingPattern = new RegExp(`^(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*(.+)$`, 'i');
100
+ const headings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content);
101
+ const headingIndex = headings.findIndex((heading) => headingPattern.test(heading.text));
102
+ const headerMatch = headingIndex === -1 ? null : headings[headingIndex].text.match(headingPattern);
97
103
  if (!headerMatch) {
98
104
  // Fallback: check if phase exists in summary list but missing detail section
99
- const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}:\\s*([^*]+)\\*\\*`, 'i');
105
+ const checklistPattern = new RegExp(`-\\s*\\[[ x]\\]\\s*\\*\\*Phase\\s+${escapedPhase}${OPTIONAL_PHASE_TAG_SOURCE}:\\s*([^*]+)\\*\\*`, 'i');
100
106
  const checklistMatch = content.match(checklistPattern);
101
107
  if (checklistMatch) {
102
108
  return {
@@ -110,14 +116,12 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
110
116
  return null;
111
117
  }
112
118
  const phaseName = headerMatch[1].trim();
113
- const headerIndex = headerMatch.index;
114
- // Find the end of this section (next ## or ### phase header, or end of file).
115
- // Also matches bracket-prefixed headings like ### [GSD] Phase 2-01:.
116
- const restOfContent = content.slice(headerIndex);
117
- const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w][\w.-]*/i);
118
- const sectionEnd = nextHeaderMatch
119
- ? headerIndex + nextHeaderMatch.index
120
- : content.length;
119
+ const headerIndex = headings[headingIndex].offset;
120
+ const currentHeading = headings[headingIndex];
121
+ const nextHeading = headings
122
+ .slice(headingIndex + 1)
123
+ .find((candidate) => candidate.level <= currentHeading.level);
124
+ const sectionEnd = nextHeading ? nextHeading.offset : content.length;
121
125
  const section = content.slice(headerIndex, sectionEnd).trim();
122
126
  // Extract goal if present (supports both **Goal:** and **Goal**: formats)
123
127
  const goalMatch = section.match(/\*\*Goal(?::\*\*|\*\*:)\s*([^\n]+)/i);
@@ -156,6 +160,8 @@ function searchPhaseInContent(content, escapedPhase, phaseNum) {
156
160
  * phase resolution as `roadmap.get-phase` — not a milestone-only subset.
157
161
  */
158
162
  function getRoadmapPhaseWithFallback(cwd, phaseNum) {
163
+ if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum)))
164
+ return null;
159
165
  const roadmapPath = planningPaths(cwd).roadmap;
160
166
  if (!node_fs_1.default.existsSync(roadmapPath))
161
167
  return null;
@@ -182,6 +188,10 @@ function getRoadmapPhaseWithFallback(cwd, phaseNum) {
182
188
  }
183
189
  // ─── cmdRoadmapGetPhase ───────────────────────────────────────────────────────
184
190
  function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
191
+ if (/^999(?:\.|$)/.test(stripProjectCodePrefix(phaseNum))) {
192
+ output({ found: false, phase_number: phaseNum }, raw, '');
193
+ return;
194
+ }
185
195
  const roadmapPath = planningPaths(cwd).roadmap;
186
196
  if (!node_fs_1.default.existsSync(roadmapPath)) {
187
197
  output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
@@ -246,7 +256,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
246
256
  const content = extractCurrentMilestone(rawContent, cwd);
247
257
  const phasesDir = planningPaths(cwd).phases;
248
258
  // Extract all phase headings: ## Phase N: Name or ### Phase N: Name
249
- const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi;
259
+ // #1729: `(?:\s*\([^)\n]*\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
260
+ const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]*\))?\s*:\s*([^\n]+)/gi;
250
261
  const phases = [];
251
262
  let match;
252
263
  // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
@@ -322,7 +333,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
322
333
  // #3537: padding-tolerant fragment — the heading discovered above may use
323
334
  // a different padding than the summary-bullet checkbox below it (mixed
324
335
  // padding inside one ROADMAP is legal and seen in real projects).
325
- const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}[:\\s]`, 'i');
336
+ const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseMarkdownRegexSource(phaseNum)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, 'i');
326
337
  const checkboxMatch = content.match(checkboxPattern);
327
338
  const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
328
339
  // If roadmap marks phase complete, trust that over disk file structure.
@@ -447,14 +458,14 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
447
458
  // `**Plans**: N plans` — bold word + outer colon (gsd-core/templates/roadmap.md)
448
459
  // `**Plans:** N plans` — bold "Plans:" (colon inside bold)
449
460
  // `Plans: N plans` — plain text header
450
- const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
461
+ const planCountPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*|(?:^|\\n)Plans:)\\s*)[^\\n]+`, 'i');
451
462
  const planCountText = isComplete
452
463
  ? `${summaryCount}/${planCount} plans complete`
453
464
  : `${summaryCount}/${planCount} plans executed`;
454
465
  roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
455
466
  // If complete: check checkbox
456
467
  if (isComplete) {
457
- const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}[:\\s][^\\n]*)`, 'i');
468
+ const checkboxPattern = new RegExp(`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s][^\\n]*)`, 'i');
458
469
  roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
459
470
  }
460
471
  // Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md", "- [ ] 50-01:", or "- [ ] **50-01**")
@@ -504,8 +515,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
504
515
  //
505
516
  // Pattern A: anchor to bare `Plans:` header (preferred).
506
517
  // Pattern B: fallback to bold summary when no bare header exists.
507
- const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
508
- const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
518
+ const insertRowsPatternA = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:^|\\n)(?:Plans:)[^\\n]*)`, 'i');
519
+ const insertRowsPatternB = new RegExp(`(#{2,4}\\s*Phase\\s+${phasePattern}${OPTIONAL_PHASE_TAG_SOURCE}(?=[:\\s])[\\s\\S]*?(?:\\*\\*Plans\\*\\*:|\\*\\*Plans:\\*\\*)[^\\n]*)`, 'i');
509
520
  const sortedMissing = [...missingPlans].sort();
510
521
  const newRows = sortedMissing.map(p => `- [ ] ${p}`).join('\n');
511
522
  const inserter = (match) => `${match}\n${newRows}`;
@@ -642,7 +653,7 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
642
653
  // #3537: padding-tolerant fragment so the caller's resolved padded id
643
654
  // matches un-padded ROADMAP headings.
644
655
  const phaseEscaped = phaseMarkdownRegexSource(phaseNum);
645
- const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}:[^\\n]*)`, 'i');
656
+ const phaseHeaderPattern = new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEscaped}${OPTIONAL_PHASE_TAG_SOURCE}:[^\\n]*)`, 'i');
646
657
  const phaseMatch = content.match(phaseHeaderPattern);
647
658
  if (!phaseMatch)
648
659
  return;
@@ -25,6 +25,7 @@ const commandRoster = require("./command-roster.cjs");
25
25
  const { readGsdCommandNames, transformContentToHyphen } = commandRoster;
26
26
  const runtimeNamePolicy = require("./runtime-name-policy.cjs");
27
27
  const { getDirName } = runtimeNamePolicy;
28
+ const capabilityRegistry = require("./capability-registry.cjs");
28
29
  // #1383: resolve GSD's version WITHOUT a top-level
29
30
  // `require('../../../package.json')`. That require ran at module load on every
30
31
  // gsd-tools invocation (this module sits in the gsd-tools loader chain) and
@@ -1997,16 +1998,15 @@ function computePathPrefix({ isGlobal, isOpencode, isWindowsHost: _isWindowsHost
1997
1998
  }
1998
1999
  /**
1999
2000
  * Canonical list of every non-Claude runtime that gsd-core emits artifacts for.
2000
- * Exported so test files can import this single source of truth rather than
2001
- * maintaining divergent hand-rolled arrays (#1521).
2002
- *
2003
- * Keep in sync with the runtime flags in bin/install.js and getDirName().
2001
+ * DERIVED from the capability registry (ADR-1239 Phase B, #1679) — the registry's
2002
+ * `runtimes` map is the single source of truth for runtime identity, so the
2003
+ * non-Claude set is its key set minus 'claude'. This replaces a hand-maintained
2004
+ * literal that had to be kept in sync with bin/install.js and getDirName(), and
2005
+ * can no longer drift from the registry. Exported so tests import one source (#1521).
2004
2006
  */
2005
- const NON_CLAUDE_RUNTIMES = [
2006
- 'codex', 'opencode', 'kilo', 'gemini', 'copilot', 'antigravity',
2007
- 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'kimi',
2008
- 'codebuddy', 'cline',
2009
- ];
2007
+ const NON_CLAUDE_RUNTIMES = Object.keys(capabilityRegistry.runtimes)
2008
+ .filter((id) => id !== 'claude')
2009
+ .sort();
2010
2010
  /**
2011
2011
  * #1521: Every non-Claude runtime resolves its own runtime identity from a
2012
2012
  * runtime-neutral config, and defaults workflow.use_worktrees to false —
@@ -2310,6 +2310,59 @@ function rewriteStagedCommandBodies(stagedDir, opts) {
2310
2310
  const attribution = resolveAttribution ? resolveAttribution(runtime) : undefined;
2311
2311
  return applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix, isGlobal, attribution);
2312
2312
  }
2313
+ /**
2314
+ * Runtimes that use the hyphen-namespace form `/gsd-<cmd>` in agent bodies.
2315
+ * claude/qwen/hermes use hyphen-name:`...` frontmatter; cursor/windsurf/etc
2316
+ * self-convert. Mirrors the `HYPHEN_NAME_AGENT_RUNTIMES` set in bin/install.js.
2317
+ *
2318
+ * @private — export normalizeAgentBodyForRuntime for callers.
2319
+ */
2320
+ const HYPHEN_NAME_AGENT_RUNTIMES = new Set(['claude', 'qwen', 'hermes']);
2321
+ /**
2322
+ * Normalize `/gsd:<cmd>` colon refs in the agent body to `/gsd-<cmd>` for
2323
+ * hyphen-`name:` runtimes (claude / qwen / hermes). No-op for all other
2324
+ * runtimes. Mirrors the per-file call in bin/install.js line 9400.
2325
+ *
2326
+ * @param content raw agent file content (post-converter)
2327
+ * @param runtime canonical runtime ID
2328
+ * @param cmdNames gsd command names from readGsdCommandNames()
2329
+ */
2330
+ function normalizeAgentBodyForRuntime(content, runtime, cmdNames) {
2331
+ if (!HYPHEN_NAME_AGENT_RUNTIMES.has(runtime))
2332
+ return content;
2333
+ return transformContentToHyphen(content, cmdNames);
2334
+ }
2335
+ /**
2336
+ * Apply the 4 base `~/.claude/` path-prefix rewrites to a single agent content
2337
+ * string. Mirrors the inline agent loop in bin/install.js lines 9330-9340:
2338
+ * ~/\.claude/ → pathPrefix
2339
+ * $HOME/\.claude/ → pathPrefix
2340
+ * ~/\.claude\b → normalizedPathPrefix
2341
+ * $HOME/\.claude\b → normalizedPathPrefix
2342
+ *
2343
+ * Skipped for copilot and antigravity (which do NOT do path rewrites in the
2344
+ * inline loop). NO stamp (_stampNonClaudeRuntimeDefaults) — agents are NOT
2345
+ * stamped in the inline loop.
2346
+ *
2347
+ * ADR-1235 §1: pre-converter cross-cutting for descriptor-driven agent pipeline.
2348
+ * Exported as `applyAgentPathRewrites` for testing and for injection into
2349
+ * stageAgentsForRuntimeWithConverter via agentCtx.
2350
+ *
2351
+ * @param content raw agent file content
2352
+ * @param runtime canonical runtime ID
2353
+ * @param pathPrefix trailing-slash path prefix (e.g. '$HOME/.cursor/')
2354
+ * @returns content with path-prefix rewrites applied (or unchanged for copilot/antigravity)
2355
+ */
2356
+ function applyAgentPathRewrites(content, runtime, pathPrefix) {
2357
+ if (runtime === 'copilot' || runtime === 'antigravity')
2358
+ return content;
2359
+ const normalizedPathPrefix = pathPrefix.replace(/\/$/, '');
2360
+ content = content.replace(/~\/\.claude\//g, pathPrefix);
2361
+ content = content.replace(/\$HOME\/\.claude\//g, pathPrefix);
2362
+ content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix);
2363
+ content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix);
2364
+ return content;
2365
+ }
2313
2366
  // ── End rewrite engine ────────────────────────────────────────────────────────
2314
2367
  /**
2315
2368
  * Apply Co-Authored-By attribution policy to file content.
@@ -2398,6 +2451,9 @@ module.exports = {
2398
2451
  // High-level wrappers (derive pathPrefix + attribution from opts):
2399
2452
  rewriteStagedSkillBodies,
2400
2453
  rewriteStagedCommandBodies,
2454
+ // ADR-1235 §1: descriptor-driven agent cross-cutting
2455
+ applyAgentPathRewrites,
2456
+ normalizeAgentBodyForRuntime,
2401
2457
  _computePathPrefix: computePathPrefix,
2402
2458
  _applyRuntimeRewrites,
2403
2459
  _stampNonClaudeRuntimeDefaults,