@opengsd/gsd-core 1.6.0 → 1.7.0-rc.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 (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-verifier.md +1 -0
  3. package/bin/gsd-mcp-server.js +31 -0
  4. package/bin/install.js +293 -1145
  5. package/commands/gsd/review.md +6 -0
  6. package/gemini-extension.json +1 -1
  7. package/gsd-core/bin/gsd-tools.cjs +116 -1
  8. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  9. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  10. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  12. package/gsd-core/bin/lib/capability-loader.cjs +18 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  14. package/gsd-core/bin/lib/capability-registry.cjs +889 -82
  15. package/gsd-core/bin/lib/capability-source.cjs +4 -4
  16. package/gsd-core/bin/lib/capability-validator.cjs +198 -0
  17. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  18. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  19. package/gsd-core/bin/lib/config.cjs +27 -0
  20. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  21. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  22. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +408 -0
  24. package/gsd-core/bin/lib/init.cjs +1 -1
  25. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  26. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  27. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  28. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  29. package/gsd-core/bin/lib/milestone.cjs +27 -30
  30. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  31. package/gsd-core/bin/lib/phase.cjs +41 -72
  32. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  33. package/gsd-core/bin/lib/probe-core.cjs +91 -1
  34. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  35. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  36. package/gsd-core/bin/lib/roadmap.cjs +17 -3
  37. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  38. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  39. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  40. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  41. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  42. package/gsd-core/bin/lib/shell-command-projection.cjs +37 -1
  43. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  44. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  45. package/gsd-core/bin/lib/state-io.cjs +55 -0
  46. package/gsd-core/bin/lib/state-transition.cjs +1588 -0
  47. package/gsd-core/bin/lib/state.cjs +306 -681
  48. package/gsd-core/bin/lib/surface.cjs +4 -1
  49. package/gsd-core/bin/lib/workstream.cjs +4 -4
  50. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  51. package/gsd-core/references/honest-verifier.md +105 -0
  52. package/gsd-core/references/reviewer-instances.md +99 -0
  53. package/gsd-core/workflows/autonomous.md +9 -9
  54. package/gsd-core/workflows/manager.md +15 -15
  55. package/gsd-core/workflows/plan-phase.md +1 -1
  56. package/gsd-core/workflows/review.md +26 -0
  57. package/gsd-core/workflows/thread.md +4 -4
  58. package/gsd-core/workflows/verify-phase.md +11 -4
  59. package/hooks/dist/gsd-graphify-update.sh +7 -1
  60. package/hooks/gsd-graphify-update.sh +7 -1
  61. package/package.json +4 -4
  62. package/scripts/ci-test-scope.cjs +38 -9
  63. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  64. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  65. package/scripts/lint-test-file-count.allowlist.json +19 -5
  66. package/scripts/mutation-matrix.cjs +45 -3
  67. package/scripts/prompt-injection-scan.sh +8 -0
  68. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -30,13 +30,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
30
30
  return (mod && mod.__esModule) ? mod : { "default": mod };
31
31
  };
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.PROHIBITION_VALIDATORS = exports.VALID_STATUS = void 0;
33
+ exports.INSUFFICIENT_SPEC = exports.PROHIBITION_VALIDATORS = exports.VALID_STATUS = void 0;
34
34
  exports.validateRequirement = validateRequirement;
35
35
  exports.validateResolution = validateResolution;
36
36
  exports.analyzeCoverage = analyzeCoverage;
37
37
  exports.validateProhibitionResolution = validateProhibitionResolution;
38
38
  exports.projectProhibitions = projectProhibitions;
39
39
  exports.dispositionForProhibition = dispositionForProhibition;
40
+ exports.truthStatement = truthStatement;
41
+ exports.truthVerification = truthVerification;
42
+ exports.projectTruths = projectTruths;
43
+ exports.dispositionForUnverifiableTruth = dispositionForUnverifiableTruth;
40
44
  exports.runProbeCli = runProbeCli;
41
45
  const node_fs_1 = __importDefault(require("node:fs"));
42
46
  /** The LOCKED set of valid lifecycle statuses (the re-cut: no covered/backstop). */
@@ -355,6 +359,92 @@ function dispositionForProhibition(prohibition, context = {}) {
355
359
  reason: 'judgment-tier prohibition routes to judgment review — never a silent green (ADR-550 D4)',
356
360
  };
357
361
  }
362
+ /** Extract a truth's statement text from either the string or the object form (the Hyrum normalizer). */
363
+ function truthStatement(truth) {
364
+ if (typeof truth === 'string')
365
+ return truth;
366
+ if (truth != null && typeof truth === 'object') {
367
+ const s = truth.statement;
368
+ if (typeof s === 'string')
369
+ return s;
370
+ }
371
+ return '';
372
+ }
373
+ /**
374
+ * Extract a truth's verification tier, or `null` when it carries none (a plain string, or an object
375
+ * with no/garbled marker). Failing toward `null` is the Postel-safe direction: an unrecognized marker
376
+ * grades NORMALLY (never a spurious abstention — the over-abstention guard, AC#3), and the marker is
377
+ * machine-emitted from validated edge data so garbling is not a live input path.
378
+ */
379
+ function truthVerification(truth) {
380
+ if (truth == null || typeof truth !== 'object')
381
+ return null;
382
+ const v = truth.verification;
383
+ return v === 'explicit' || v === 'backstop' ? v : null;
384
+ }
385
+ /**
386
+ * Conservative serializer (Postel: "send well-formed, minimal data") for projecting truths into a
387
+ * `must_haves.truths` block — the truth-axis analogue of `projectProhibitions`. A `backstop` truth is
388
+ * emitted as a flat-scalar object `{ statement, verification: 'backstop' }` (ADR-550 #1278: flat
389
+ * scalars round-trip the existing `parseMustHavesBlock`; a nested object would mangle it). Every other
390
+ * truth collapses to a bare statement string — only the non-inferable tier needs a structured marker,
391
+ * so an `explicit`/inferable truth never carries one (no spurious markers). Empty statements are dropped.
392
+ */
393
+ function projectTruths(items) {
394
+ if (!Array.isArray(items))
395
+ return [];
396
+ const out = [];
397
+ for (const item of items) {
398
+ const statement = truthStatement(item);
399
+ if (!statement)
400
+ continue;
401
+ if (truthVerification(item) === 'backstop') {
402
+ out.push({ statement, verification: 'backstop' });
403
+ }
404
+ else {
405
+ out.push(statement);
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+ /** The stable, distinguishable verdict-reason token for an abstained non-inferable truth (review condition 1). */
411
+ exports.INSUFFICIENT_SPEC = 'insufficient_spec';
412
+ /**
413
+ * Deterministic verify-time disposition for a single truth (ADR-550 D4 truth-axis mirror, #1154).
414
+ * PURE — no LLM judgment (ADR-550 D5); the LLM verifier's only job is to decide whether `evidence`
415
+ * exists, this helper owns the routing once that is known.
416
+ *
417
+ * - A `backstop` (non-inferable) truth with NO explicit evidence → `{ unverified, flagged }`,
418
+ * reason `insufficient_spec`. NEVER green — the verify-time companion to D4's never-silent-pass.
419
+ * - A `backstop` truth WITH explicit evidence (a passing wired held-out/property test) → `green`.
420
+ * Abstention is for the *unconfirmable*, not for every non-inferable check.
421
+ * - Any non-`backstop` truth (explicit, or a plain inferable string) → `green`, never flagged.
422
+ * This is the over-abstention guard (AC#3): abstention fires ONLY on the exogenous backstop tag.
423
+ */
424
+ function dispositionForUnverifiableTruth(truth, context = {}) {
425
+ const tier = truthVerification(truth);
426
+ // Over-abstention guard (AC#3): only a backstop (non-inferable) truth is ever a candidate to abstain.
427
+ if (tier !== 'backstop') {
428
+ return {
429
+ status: 'green',
430
+ flagged: false,
431
+ tier,
432
+ reason: 'inferable truth — verified normally (no abstention; ADR-550 D4 over-abstention guard)',
433
+ };
434
+ }
435
+ const evidence = Array.isArray(context.evidence) ? context.evidence : [];
436
+ if (evidence.length === 0) {
437
+ // ABSTAIN: a non-inferable truth the verifier cannot confirm with explicit evidence. Routes to
438
+ // human_needed with the distinguishable insufficient_spec reason — never a silent pass (ADR-550 D4).
439
+ return { status: 'unverified', flagged: true, tier, reason: exports.INSUFFICIENT_SPEC };
440
+ }
441
+ return {
442
+ status: 'green',
443
+ flagged: false,
444
+ tier,
445
+ reason: 'backstop truth confirmed by explicit evidence (a passing wired held-out/property test or directly-observed behavior)',
446
+ };
447
+ }
358
448
  /**
359
449
  * Read the requirements file (and optional resolutions file), run the adapter's `analyze`,
360
450
  * and write the report as pretty JSON + newline. With no requirements path, writes the usage
@@ -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
  }
@@ -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
  }
@@ -55,8 +55,11 @@ function coerceTruthToString(t) {
55
55
  return String(t);
56
56
  }
57
57
  if (typeof t === 'object') {
58
- // Prefer common title-bearing keys produced by parseMustHavesBlock
59
- for (const k of ['title', 'text', 'name', 'rule', 'path', 'provides']) {
58
+ // Prefer common title-bearing keys produced by parseMustHavesBlock. `statement` is the canonical
59
+ // truth/prohibition payload field — and the carrier of #1154's object-form backstop truth
60
+ // `{ statement, verification: backstop }`, so it leads (a non-inferable truth must be coerced by
61
+ // its statement, never dropped — the Hyrum backward-compat guard for the new marker).
62
+ for (const k of ['statement', 'title', 'text', 'name', 'rule', 'path', 'provides']) {
60
63
  const v = t[k];
61
64
  if (typeof v === 'string' && v.trim())
62
65
  return v;
@@ -249,6 +252,15 @@ function cmdRoadmapAnalyze(cwd, raw) {
249
252
  const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+(\d+[A-Z]?(?:[.-]\d+)*)\s*:\s*([^\n]+)/gi;
250
253
  const phases = [];
251
254
  let match;
255
+ // Phase 0 (pre-milestone) and Phase 999 (backlog) are sentinels, not real
256
+ // phases. They legitimately have no directory and must never be surfaced as
257
+ // current/next phase or counted in phase_count. Mirrors the engine-wide
258
+ // sentinel convention (phase-id getMilestoneFromPhaseId, roadmap-command-router
259
+ // SENTINELS, the #1445 /^999/ progress filters). (#1580)
260
+ const isSentinelPhase = (num) => {
261
+ const major = parseInt(num, 10);
262
+ return major === 0 || major === 999;
263
+ };
252
264
  // Build phase directory lookup once (O(1) readdir instead of O(N) per phase)
253
265
  const _phaseDirNames = (() => {
254
266
  try {
@@ -262,6 +274,8 @@ function cmdRoadmapAnalyze(cwd, raw) {
262
274
  })();
263
275
  while ((match = phasePattern.exec(content)) !== null) {
264
276
  const phaseNum = match[1];
277
+ if (isSentinelPhase(phaseNum))
278
+ continue;
265
279
  const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
266
280
  // Extract goal from the section
267
281
  const sectionStart = match.index;
@@ -362,7 +376,7 @@ function cmdRoadmapAnalyze(cwd, raw) {
362
376
  checklistPhases.add(checklistMatch[1]);
363
377
  }
364
378
  const detailPhases = new Set(phases.map(p => p.number));
365
- const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p));
379
+ const missingDetails = [...checklistPhases].filter(p => !detailPhases.has(p) && !isSentinelPhase(p));
366
380
  const result = {
367
381
  milestones,
368
382
  phases,
@@ -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,
@@ -9,6 +9,30 @@
9
9
  // In .cts (CommonJS output) files, `require` is available as a global.
10
10
  const _require = require;
11
11
  const path = _require('node:path');
12
+ /**
13
+ * Asserts that `destSubpath` resolves to a path inside `configDir`.
14
+ *
15
+ * Rejects any path that escapes the configDir root (e.g. "../../etc") and any
16
+ * path containing a NUL byte. This is a security gate for Phase B of
17
+ * ADR-1239: third-party descriptors must never be able to write outside the
18
+ * designated config home directory.
19
+ *
20
+ * @param configDir - The root config directory (e.g. ~/.claude).
21
+ * @param destSubpath - The relative path declared by the runtime descriptor.
22
+ * @returns The resolved absolute path under configDir.
23
+ * @throws {Error} if destSubpath escapes configDir or contains a NUL byte.
24
+ */
25
+ function assertDestWithinConfigHome(configDir, destSubpath) {
26
+ if (destSubpath.includes('\0')) {
27
+ throw new Error(`destSubpath "${destSubpath}" contains a NUL byte and is not valid`);
28
+ }
29
+ const root = path.resolve(configDir);
30
+ const resolved = path.resolve(configDir, destSubpath);
31
+ if (resolved === root || !resolved.startsWith(root + path.sep)) {
32
+ throw new Error(`destSubpath "${destSubpath}" must be a strict subpath of configHome "${configDir}" — not configHome itself or outside it (escapes configHome)`);
33
+ }
34
+ return resolved;
35
+ }
12
36
  function errorMessage(err) {
13
37
  if (err instanceof Error)
14
38
  return err.message;
@@ -36,10 +60,34 @@ function createRuntimeArtifactInstallPlan(args) {
36
60
  platform,
37
61
  resolveAttribution,
38
62
  };
63
+ // ADR-1235 §1: build agentCtx once per plan so agents kind entries can apply
64
+ // the CORRECT pre-converter cross-cutting (path rewrites → attribution → converter
65
+ // → normalize). This mirrors the exact per-file order in the inline agent loop
66
+ // in bin/install.js (lines 9330-9415). agentCtx is passed as the second arg
67
+ // to kind.stage() for agents kind entries with a converter (convertedAgentsKind).
68
+ // NO _stampNonClaudeRuntimeDefaults — agents are NOT stamped in the inline loop.
69
+ const os = _require('node:os');
70
+ const homedirFn = homedir ?? (() => os.homedir());
71
+ const resolvedTarget = path.resolve(layout.configDir).replace(/\\/g, '/');
72
+ const homeDir = homedirFn().replace(/\\/g, '/');
73
+ const isGlobal = scope === 'global';
74
+ const isOpencode = layout.runtime === 'opencode';
75
+ const isWindowsHost = (platform ?? process.platform) === 'win32';
76
+ const pathPrefix = conversionExports._computePathPrefix({ isGlobal, isOpencode, isWindowsHost, resolvedTarget, homeDir });
77
+ const attribution = resolveAttribution ? resolveAttribution(layout.runtime) : undefined;
78
+ const agentCtx = { runtime: layout.runtime, pathPrefix, attribution };
39
79
  for (const kind of layout.kinds) {
40
80
  let stagedDir;
41
81
  try {
42
- stagedDir = kind.stage(resolvedProfile);
82
+ if (kind.kind === 'agents') {
83
+ // ADR-1235 §1: pass agentCtx so stageAgentsForRuntimeWithConverter applies
84
+ // the full inline-loop order: pathRewrites → attribution → converter → normalize.
85
+ // The cross-cutting is now PRE-converter (inside staging), not POST.
86
+ stagedDir = kind.stage(resolvedProfile, agentCtx);
87
+ }
88
+ else {
89
+ stagedDir = kind.stage(resolvedProfile);
90
+ }
43
91
  }
44
92
  catch (err) {
45
93
  return { ok: false, kind: 'stage_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
@@ -54,6 +102,8 @@ function createRuntimeArtifactInstallPlan(args) {
54
102
  const rewrittenDir = rewriteStagedSkillBodies(stagedDir, rewriteOpts);
55
103
  sourceDir = addCleanupDir(cleanupDirs, stagedDir, rewrittenDir);
56
104
  }
105
+ // agents kind: cross-cutting already applied INSIDE kind.stage() via agentCtx.
106
+ // No POST-step needed. sourceDir stays as stagedDir.
57
107
  }
58
108
  catch (err) {
59
109
  return { ok: false, kind: 'rewrite_failed', message: errorMessage(err), cleanupDirs, failedKind: kind.kind };
@@ -61,7 +111,7 @@ function createRuntimeArtifactInstallPlan(args) {
61
111
  items.push({
62
112
  kind: kind.kind,
63
113
  sourceDir,
64
- destDir: path.join(layout.configDir, kind.destSubpath),
114
+ destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
65
115
  });
66
116
  }
67
117
  return { ok: true, plan: { items, cleanupDirs } };
@@ -70,8 +120,8 @@ function createRuntimeArtifactUninstallPlan(layout) {
70
120
  return {
71
121
  items: layout.kinds.map((kind) => ({
72
122
  kind: kind.kind,
73
- destDir: path.join(layout.configDir, kind.destSubpath),
123
+ destDir: assertDestWithinConfigHome(layout.configDir, kind.destSubpath),
74
124
  })),
75
125
  };
76
126
  }
77
- module.exports = { createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
127
+ module.exports = { assertDestWithinConfigHome, createRuntimeArtifactInstallPlan, createRuntimeArtifactUninstallPlan };
@@ -156,13 +156,16 @@ function convertedAgentsKind(destSubpath, prefix, converterName, configDir, scop
156
156
  kind: 'agents',
157
157
  destSubpath,
158
158
  prefix,
159
- stage: (resolved) => {
159
+ stage: (resolved, agentCtx) => {
160
160
  // isGlobal is threaded so scope-aware agent converters (copilot, antigravity)
161
161
  // choose global-home vs workspace-relative paths; converters that only take
162
162
  // (content) ignore the extra positional arg. Mirrors skillsKind's scope
163
163
  // threading (#1173).
164
164
  const converter = conversionExports[converterName];
165
- return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter, scope === 'global');
165
+ // ADR-1235 §1: when agentCtx is provided (by createRuntimeArtifactInstallPlan
166
+ // for descriptor-driven runtimes), thread it through so stageAgentsForRuntimeWithConverter
167
+ // can apply the full pre-converter + post-converter sequence in the correct order.
168
+ return stageAgentsForRuntimeWithConverter(findAgentsSourceRoot(configDir), resolved, converter, scope === 'global', agentCtx);
166
169
  },
167
170
  };
168
171
  }
@@ -84,7 +84,7 @@ function atomicWriteFileSync(target, data, options) {
84
84
  __atomicWrittenTmps.add(tmp);
85
85
  try {
86
86
  node_fs_1.default.writeFileSync(tmp, data, options);
87
- node_fs_1.default.renameSync(tmp, target);
87
+ shellCmdProjection.retryRenameSync(tmp, target);
88
88
  // Successful rename: the tmp path no longer exists, but leave it in the
89
89
  // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow
90
90
  // lingers (e.g. a rename succeeded but left a stale entry on some FS).