@opengsd/gsd-core 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -2
  3. package/.opencode/plugins/gsd-core.js +251 -1
  4. package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
  5. package/GEMINI.md +1 -1
  6. package/agents/gsd-debug-session-manager.compact.md +6 -0
  7. package/agents/gsd-debug-session-manager.md +6 -0
  8. package/bin/gsd-mcp-server.js +3 -1
  9. package/bin/install.js +361 -94
  10. package/gsd-core/bin/gsd-tools.cjs +17 -2
  11. package/gsd-core/bin/lib/adr-parser.cjs +37 -5
  12. package/gsd-core/bin/lib/capability-registry.cjs +132 -80
  13. package/gsd-core/bin/lib/capability-validator.cjs +2 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +2 -1
  15. package/gsd-core/bin/lib/commands.cjs +28 -8
  16. package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
  17. package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
  18. package/gsd-core/bin/lib/graphify.cjs +3 -2
  19. package/gsd-core/bin/lib/init.cjs +32 -12
  20. package/gsd-core/bin/lib/install-engine.cjs +11 -0
  21. package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
  22. package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
  23. package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
  24. package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
  25. package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
  26. package/gsd-core/bin/lib/mcp-server.cjs +5 -1
  27. package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
  28. package/gsd-core/bin/lib/opencode-version.cjs +486 -0
  29. package/gsd-core/bin/lib/phase.cjs +21 -0
  30. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
  31. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +95 -4
  32. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
  33. package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
  34. package/gsd-core/bin/lib/smart-entry.cjs +92 -53
  35. package/gsd-core/bin/lib/state-transition.cjs +67 -9
  36. package/gsd-core/bin/lib/state.cjs +454 -66
  37. package/gsd-core/bin/lib/update-context.cjs +18 -1
  38. package/gsd-core/bin/lib/verification.cjs +107 -17
  39. package/gsd-core/workflows/code-review.md +16 -1
  40. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
  41. package/gsd-core/workflows/execute-phase.md +3 -1
  42. package/gsd-core/workflows/resume-project.md +14 -10
  43. package/hooks/dist/gsd-check-update.js +26 -20
  44. package/hooks/dist/gsd-context-monitor.js +14 -5
  45. package/hooks/dist/gsd-secret-read-guard.js +117 -1
  46. package/hooks/dist/gsd-statusline.js +321 -22
  47. package/hooks/gsd-check-update.js +26 -20
  48. package/hooks/gsd-context-monitor.js +14 -5
  49. package/hooks/gsd-secret-read-guard.js +117 -1
  50. package/hooks/gsd-statusline.js +321 -22
  51. package/package.json +1 -1
  52. package/scripts/gen-capability-registry.cjs +7 -1
  53. package/scripts/gen-loop-host-contract.cjs +4 -1
  54. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  55. package/scripts/lib/platform-conformance-tier.generated.cjs +10 -0
  56. package/scripts/mutation-matrix.cjs +3 -1
  57. package/scripts/release-tarball-smoke.cjs +14 -7
  58. package/vscode/package.json +1 -1
@@ -1936,6 +1936,7 @@ const KNOWN_HOST_BEHAVIORS = new Set([
1936
1936
  'managedHookEvents',
1937
1937
  'mcpCompanion',
1938
1938
  'namedSubagentsSupported',
1939
+ 'nativeCliPlugin',
1939
1940
  'nativeModelAliases',
1940
1941
  'nativePlugin',
1941
1942
  'noPathRewrite',
@@ -2572,7 +2573,7 @@ function validateLaneProbe(ctx, probe) {
2572
2573
  if (!isPositiveIntegerMs(probe.timeoutMs)) {
2573
2574
  errors.push(
2574
2575
  ctx + ' reviewer.probe.timeoutMs must be a positive integer of milliseconds for kind "' +
2575
- probe.kind + '" — an unbounded probe hangs every /gsd:review invocation ' +
2576
+ probe.kind + '" — an unbounded probe hangs every /gsd-review invocation ' +
2576
2577
  '(got: ' + describeValue(probe.timeoutMs) + ')',
2577
2578
  );
2578
2579
  }
@@ -50,6 +50,7 @@ const { planningDir } = planningWorkspaceMod;
50
50
  const nodefs = require("fs");
51
51
  // eslint-disable-next-line @typescript-eslint/no-require-imports
52
52
  const nodepath = require("path");
53
+ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
53
54
  // ─── Implementation ───────────────────────────────────────────────────────────
54
55
  /**
55
56
  * Write-side capability state mutator.
@@ -305,7 +306,7 @@ function setCapabilityState(cwd, runtimeConfigDir, desired, opts) {
305
306
  if (entry.enabled === true) {
306
307
  const afterCap = after.capabilities.find((c) => c.id === entry.id);
307
308
  if (afterCap && afterCap.enabled !== true) {
308
- errors.push(`cannot enable "${entry.id}": not in the active surface/profile (widen the profile or use /gsd:surface enable)`);
309
+ errors.push(`cannot enable "${entry.id}": not in the active surface/profile (widen the profile or use ${(0, runtime_slash_cjs_1.runtimeCommand)(cwd, 'surface enable')})`);
309
310
  }
310
311
  }
311
312
  // Fix B: desired enabled=false — assert it is actually disabled after write.
@@ -1233,6 +1233,24 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1233
1233
  // null — it would leave an unresolvable `variant: null` key on disk
1234
1234
  // forever. Test presence with its own regex, and only compare values
1235
1235
  // once presence is known.
1236
+ // Native OpenCode 2.x form (ADR-5331 D5): the effort rides on the model reference as
1237
+ // `provider/model#variant`, with no separate `variant:` key. When the agent's `model:` carries a
1238
+ // `#` suffix that suffix IS the effort, so the sync edits the model instead of adding a second,
1239
+ // conflicting `variant:` line.
1240
+ const modelLineMatch = /^model:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch.body);
1241
+ const modelValue = modelLineMatch ? modelLineMatch[1].replace(/\r$/, '').replace(/^(["'])(.*)\1$/, '$2') : null;
1242
+ const hashAt = modelValue === null ? -1 : modelValue.lastIndexOf('#');
1243
+ let modelFormContent = null;
1244
+ let modelFormFrom = null;
1245
+ if (modelValue !== null && hashAt > 0) {
1246
+ const base = modelValue.slice(0, hashAt);
1247
+ modelFormFrom = modelValue.slice(hashAt + 1);
1248
+ if (target !== null && modelFormFrom === target) {
1249
+ skipped++;
1250
+ continue;
1251
+ }
1252
+ modelFormContent = setFrontmatterKeyLine(content, 'model', target === null ? base : `${base}#${target}`);
1253
+ }
1236
1254
  const variantPresent = /^variant:/m.test(fmMatch.body);
1237
1255
  const variantMatch = /^variant:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch.body);
1238
1256
  // `null` (key absent) and `''` (key present, value empty) are distinct
@@ -1241,17 +1259,19 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1241
1259
  // "empty key" indistinguishable in the sync output, even though only one
1242
1260
  // of them actually has a `variant:` line to remove.
1243
1261
  const currentVariant = variantPresent ? (variantMatch ? variantMatch[1] : '') : null;
1244
- if (target === null) {
1245
- if (!variantPresent) {
1262
+ if (modelFormContent === null) {
1263
+ if (target === null) {
1264
+ if (!variantPresent) {
1265
+ skipped++;
1266
+ continue;
1267
+ }
1268
+ }
1269
+ else if (variantPresent && currentVariant === target) {
1246
1270
  skipped++;
1247
1271
  continue;
1248
1272
  }
1249
1273
  }
1250
- else if (variantPresent && currentVariant === target) {
1251
- skipped++;
1252
- continue;
1253
- }
1254
- changes.push({ agent: agentName, from: currentVariant, to: target });
1274
+ changes.push({ agent: agentName, from: modelFormContent === null ? currentVariant : modelFormFrom, to: target });
1255
1275
  synced++;
1256
1276
  if (!dryRun) {
1257
1277
  // Atomic publish AND mode preservation, same discipline as
@@ -1279,7 +1299,7 @@ function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
1279
1299
  }
1280
1300
  catch { /* non-fatal: fall back to writing without an explicit mode */ }
1281
1301
  try {
1282
- node_fs_1.default.writeFileSync(tmpPath, target === null ? removeFrontmatterKeyLine(content, 'variant') : setFrontmatterKeyLine(content, 'variant', target), originalMode !== undefined ? { mode: originalMode } : undefined);
1302
+ node_fs_1.default.writeFileSync(tmpPath, modelFormContent ?? (target === null ? removeFrontmatterKeyLine(content, 'variant') : setFrontmatterKeyLine(content, 'variant', target)), originalMode !== undefined ? { mode: originalMode } : undefined);
1283
1303
  // Not redundant with the `mode` option above: `mode` only applies
1284
1304
  // when the file is actually created (O_CREAT). A leftover tmp file
1285
1305
  // from an earlier crashed run would be reused (truncated) at its OLD
@@ -28,6 +28,7 @@ const node_path_1 = __importDefault(require("node:path"));
28
28
  const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
29
29
  const gate_evidence_cjs_1 = require("./gate-evidence.cjs");
30
30
  const gate_config_cjs_1 = require("./gate-config.cjs");
31
+ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
31
32
  const gate_phase_context_cjs_1 = require("./gate-phase-context.cjs");
32
33
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module
33
34
  const planningWorkspace = require("./planning-workspace.cjs");
@@ -45,13 +46,13 @@ const { defaultPhaseCleanCommitTimesMs } = verificationMod;
45
46
  function computeContextDrift(contextEffectiveMs, entries) {
46
47
  return entries.filter((e) => e.effectiveMs < contextEffectiveMs).map((e) => e.file);
47
48
  }
48
- function buildContextDriftMessage(staleArtifacts, phaseArg) {
49
+ function buildContextDriftMessage(staleArtifacts, phaseArg, projectDir) {
49
50
  const parts = [`CONTEXT.md decisions are newer than: ${staleArtifacts.join(', ')}.`];
50
51
  if (staleArtifacts.some((f) => f.endsWith('-RESEARCH.md'))) {
51
- parts.push(`Regenerate research: /gsd:plan-phase ${phaseArg} --research.`);
52
+ parts.push(`Regenerate research: ${(0, runtime_slash_cjs_1.runtimeCommand)(projectDir, `plan-phase ${phaseArg} --research`)}.`);
52
53
  }
53
54
  if (staleArtifacts.some((f) => f.endsWith('-PATTERNS.md'))) {
54
- parts.push('Regenerate patterns: delete the PATTERNS.md file, then re-run /gsd:plan-phase.');
55
+ parts.push(`Regenerate patterns: delete the PATTERNS.md file, then re-run ${(0, runtime_slash_cjs_1.runtimeCommand)(projectDir, 'plan-phase')}.`);
55
56
  }
56
57
  if (staleArtifacts.some((f) => f.endsWith('-VALIDATION.md') || (f.endsWith('-SPEC.md') && !f.endsWith('-AI-SPEC.md') && !f.endsWith('-UI-SPEC.md')))) {
57
58
  parts.push('Regenerate or manually reconcile VALIDATION.md / SPEC.md against the current decisions.');
@@ -114,7 +115,7 @@ function runContextDriftGate(cwd, phaseArg) {
114
115
  // missing or malformed config is "key absent", nothing is printed).
115
116
  const action = (0, gate_config_cjs_1.readWorkflowConfigValue)(cwd, 'workflow.context_drift_action').value === 'block' ? 'block' : 'warn';
116
117
  const block = staleArtifacts.length > 0 && action === 'block';
117
- const message = staleArtifacts.length > 0 ? buildContextDriftMessage(staleArtifacts, phaseArg) : '';
118
+ const message = staleArtifacts.length > 0 ? buildContextDriftMessage(staleArtifacts, phaseArg, cwd) : '';
118
119
  return (0, gate_verdict_cjs_1.gateVerdict)(block ? 'block' : (staleArtifacts.length > 0 ? 'advisory' : 'pass'), block, {
119
120
  block,
120
121
  skipped: false,
@@ -19,6 +19,7 @@ exports.evaluateUiSafetyGate = evaluateUiSafetyGate;
19
19
  const gate_verdict_cjs_1 = require("./gate-verdict.cjs");
20
20
  const gate_phase_context_cjs_1 = require("./gate-phase-context.cjs");
21
21
  const gate_evaluation_scope_cjs_1 = require("./gate-evaluation-scope.cjs");
22
+ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
22
23
  const ui_safety_gate_cjs_1 = require("./ui-safety-gate.cjs");
23
24
  const UI_FILE_EXTENSIONS_RE = /\.(tsx|jsx|css|scss|sass|less|vue|svelte|html)$/i;
24
25
  const UI_PATH_PATTERNS_RE = /\/(components|pages|views|screens|layouts|ui|frontend)\//i;
@@ -52,7 +53,7 @@ function computeUiSafetyGate(projectDir, phase) {
52
53
  const result = { frontend, hasUiFiles, hasUiSpec, block };
53
54
  if (block) {
54
55
  result.message = `UI files changed in this wave but no UI-SPEC.md exists for Phase ${phase}. ` +
55
- `Run /gsd:ui-phase ${phase} to generate the design contract before continuing.`;
56
+ `Run ${(0, runtime_slash_cjs_1.runtimeCommand)(projectDir, `ui-phase ${phase}`)} to generate the design contract before continuing.`;
56
57
  }
57
58
  if (phaseLookupFailed)
58
59
  result.phaseLookupFailed = true;
@@ -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 shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
16
+ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
16
17
  // eslint-disable-next-line @typescript-eslint/no-require-imports
17
18
  const capabilityStateMod = require("./capability-state.cjs");
18
19
  const { isCapabilityActive } = capabilityStateMod;
@@ -408,7 +409,7 @@ function graphifyQuery(cwd, term, options = {}) {
408
409
  const { graphPath, configured } = resolveGraphLocation(cwd, planningDir);
409
410
  if (!node_fs_1.default.existsSync(graphPath)) {
410
411
  return { error: configured
411
- ? `Configured graph not found at ${graphPath}. Set graphify.graph_path or run /gsd:graphify build.`
412
+ ? `Configured graph not found at ${graphPath}. Set graphify.graph_path or run ${(0, runtime_slash_cjs_1.runtimeCommand)(cwd, 'graphify build')}.`
412
413
  : 'No graph built yet. Run graphify build first.' };
413
414
  }
414
415
  const graph = safeReadJson(graphPath);
@@ -446,7 +447,7 @@ function graphifyStatus(cwd) {
446
447
  const { graphPath, configured } = resolveGraphLocation(cwd, planningDir);
447
448
  if (!node_fs_1.default.existsSync(graphPath)) {
448
449
  return { exists: false, graph_path: graphPath, message: configured
449
- ? `Configured graph not found at ${graphPath}. Set graphify.graph_path or run /gsd:graphify build.`
450
+ ? `Configured graph not found at ${graphPath}. Set graphify.graph_path or run ${(0, runtime_slash_cjs_1.runtimeCommand)(cwd, 'graphify build')}.`
450
451
  : 'No graph built yet. Run graphify build to create one.' };
451
452
  }
452
453
  const stat = node_fs_1.default.statSync(graphPath);
@@ -157,6 +157,24 @@ function slugifyPhaseName(phaseName) {
157
157
  // the same reported phase_slug.
158
158
  return phaseName ? coreUtils.generateSlugInternal(phaseName, null) : null;
159
159
  }
160
+ /**
161
+ * #4854: the one place that decides which name an `init` verb emits as
162
+ * `phase_name`. When a phase directory exists, `searchPhaseInDir` derives
163
+ * `phaseInfo.phase_name` from the directory-name remainder — already slugified
164
+ * (`phase.add` writes `${num}-${slug}` dirs), so it comes out byte-identical to
165
+ * `phase_slug`. The ROADMAP carries the curated display name
166
+ * (`### Phase N: <Name>`), so it wins; the disk-derived name is the fallback
167
+ * for a phase the ROADMAP does not describe, then `null`. #3171 fixed this
168
+ * inline for `init execute-phase` only; plan-phase (whose `phase_name` reaches
169
+ * STATE.md through `state planned-phase --name`), verify-work, code-review,
170
+ * discuss-phase-assumptions and phase-op read the raw disk value. `phase_slug`
171
+ * is deliberately not routed through here — it stays disk-derived.
172
+ */
173
+ function resolvePhaseDisplayName(phaseInfo, roadmapPhase) {
174
+ return roadmapPhase?.['phase_name']
175
+ || phaseInfo?.['phase_name']
176
+ || null;
177
+ }
160
178
  /**
161
179
  * #2994 (review finding, DEFECT.GENERATIVE-FIX): shared archived/not-found
162
180
  * fallback applied identically by `cmdInitExecutePhase`, `cmdInitPlanPhase`,
@@ -164,10 +182,11 @@ function slugifyPhaseName(phaseName) {
164
182
  * `cmdInitDiscussPhaseAssumptions` — 6 call sites previously reproducing the
165
183
  * exact same two-branch control flow verbatim (only the synthetic
166
184
  * replacement object's field set differs per caller, supplied here via
167
- * `buildFallback`). `cmdInitPhaseOp` is deliberately left untouched (CRITICAL
168
- * blast radius, 179 dependents) even though it follows the same shape, since
169
- * its own fallback object differs by one field (`has_reviews` absent) and is
170
- * not a byte-identical copy.
185
+ * `buildFallback`). `cmdInitPhaseOp` does not use this helper (its own fallback
186
+ * object differs by one field, `has_reviews` absent, so it is not a
187
+ * byte-identical copy), but since #4854 it resolves `roadmapPhase` once up
188
+ * front like the others, so `resolvePhaseDisplayName` sees it in the ordinary
189
+ * case too.
171
190
  *
172
191
  * Behavior-preserving by construction: every original call site either (a)
173
192
  * unconditionally computed `roadmapPhase` once up front and then applied
@@ -924,7 +943,7 @@ function cmdInitExecutePhase(cwd, phase, raw, options = {}) {
924
943
  // the human-curated display name (`### Phase N: <Name>`); prefer it,
925
944
  // matching the no-disk fallback above. phase_slug stays disk-derived — it
926
945
  // correctly feeds branch-name construction below and is unchanged here.
927
- phase_name: (roadmapPhase?.['phase_name']) || (phaseInfo?.['phase_name']) || null,
946
+ phase_name: resolvePhaseDisplayName(phaseInfo, roadmapPhase),
928
947
  phase_slug: phaseInfo?.['phase_slug'] || null,
929
948
  phase_req_ids,
930
949
  plans: phaseInfo?.['plans'] || [],
@@ -1043,7 +1062,7 @@ function cmdInitPlanPhase(cwd, phase, raw, options = {}) {
1043
1062
  const phase_req_ids = reqExtracted && reqExtracted !== 'TBD' ? reqExtracted : null;
1044
1063
  const phaseDirPlan = phaseInfo?.['directory'] || null;
1045
1064
  const phaseNumberPlan = phaseInfo?.['phase_number'] || null;
1046
- const phaseNamePlan = phaseInfo?.['phase_name'] || null;
1065
+ const phaseNamePlan = resolvePhaseDisplayName(phaseInfo, roadmapPhase);
1047
1066
  const rawProjectCodePlan = config.project_code || '';
1048
1067
  let expectedPhaseDirPlan = null;
1049
1068
  if (!phaseDirPlan && phaseNumberPlan && phaseNamePlan) {
@@ -1689,7 +1708,7 @@ function cmdInitVerifyWork(cwd, phase, raw) {
1689
1708
  // itself stays relative — evaluateUatPassed above still joins it against cwd.
1690
1709
  phase_dir: phaseDir ? toPosixPath(node_path_1.default.join(cwd, phaseDir)) : null,
1691
1710
  phase_number: phaseInfo?.['phase_number'] || null,
1692
- phase_name: phaseInfo?.['phase_name'] || null,
1711
+ phase_name: resolvePhaseDisplayName(phaseInfo, roadmapPhase),
1693
1712
  // #2376: verify-work.md's plan_gap_closure step reads these instead of
1694
1713
  // hardcoding '.planning/STATE.md' / '.planning/ROADMAP.md' literals.
1695
1714
  state_path: toPosixPath(node_path_1.default.join(planningDir(cwd), 'STATE.md')),
@@ -1761,7 +1780,7 @@ function cmdInitCodeReview(cwd, phase, raw, options = {}) {
1761
1780
  });
1762
1781
  const phaseDir = phaseInfo?.['directory'] || null;
1763
1782
  const phaseNumber = phaseInfo?.['phase_number'] || null;
1764
- const phaseName = phaseInfo?.['phase_name'] || null;
1783
+ const phaseName = resolvePhaseDisplayName(phaseInfo, roadmapPhase);
1765
1784
  const fallow = detectFallowConfig(cwd);
1766
1785
  const result = {
1767
1786
  commit_docs: config.commit_docs,
@@ -1890,7 +1909,7 @@ function cmdInitDiscussPhaseAssumptions(cwd, phase, raw, options = {}) {
1890
1909
  });
1891
1910
  const phaseDir = phaseInfo?.['directory'] || null;
1892
1911
  const phaseNumber = phaseInfo?.['phase_number'] || null;
1893
- const phaseName = phaseInfo?.['phase_name'] || null;
1912
+ const phaseName = resolvePhaseDisplayName(phaseInfo, roadmapPhase);
1894
1913
  // #2994: mirrors discuss-phase-assumptions.md's own auto_advance step
1895
1914
  // resolver — `--auto` flag OR the consolidated `check auto-mode --pick
1896
1915
  // active` fact (workflow._auto_chain_active OR workflow.auto_advance).
@@ -1942,8 +1961,10 @@ function cmdInitPhaseOp(cwd, phase, raw) {
1942
1961
  output(withProjectRoot(cwd, result), raw);
1943
1962
  return;
1944
1963
  }
1964
+ // #4854: resolved once, up front (a pure read — guardedGetRoadmapPhase has no side effects),
1965
+ // so the ordinary case (phase dir on disk, not archived) also sees the ROADMAP display name.
1966
+ const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1945
1967
  if (phaseInfo?.['archived']) {
1946
- const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1947
1968
  if (roadmapPhase?.['found']) {
1948
1969
  const phaseName = roadmapPhase['phase_name'];
1949
1970
  phaseInfo = {
@@ -1966,7 +1987,6 @@ function cmdInitPhaseOp(cwd, phase, raw) {
1966
1987
  }
1967
1988
  }
1968
1989
  if (!phaseInfo) {
1969
- const roadmapPhase = guardedGetRoadmapPhase(cwd, phase, config.project_code);
1970
1990
  if (roadmapPhase?.['found']) {
1971
1991
  const phaseName = roadmapPhase['phase_name'];
1972
1992
  phaseInfo = {
@@ -1990,7 +2010,7 @@ function cmdInitPhaseOp(cwd, phase, raw) {
1990
2010
  }
1991
2011
  const phaseDir = phaseInfo?.['directory'] || null;
1992
2012
  const phaseNumber = phaseInfo?.['phase_number'] || null;
1993
- const phaseName = phaseInfo?.['phase_name'] || null;
2013
+ const phaseName = resolvePhaseDisplayName(phaseInfo, roadmapPhase);
1994
2014
  const rawProjectCode = config.project_code || '';
1995
2015
  let expectedPhaseDir = null;
1996
2016
  if (!phaseDir && phaseNumber && phaseName) {
@@ -1741,6 +1741,17 @@ function _installNativePluginIfDeclared(runtime, configDir, behaviors, src) {
1741
1741
  }
1742
1742
  }
1743
1743
  }
1744
+ // OpenCode 2.x terminal-client plugin (ADR-5331 Phase 3): a TUI entrypoint kept in its own directory under
1745
+ // plugins/. OpenCode 1.x scans plugins/*.js|ts only, so it never loads this directory.
1746
+ const cli = behaviors.nativeCliPlugin;
1747
+ if (cli && cli.source) {
1748
+ const cliSrc = node_path_1.default.join(src, cli.source);
1749
+ if (installFs().existsSync(cliSrc)) {
1750
+ const cliDest = runtimeArtifactInstallPlan.assertDestWithinConfigHome(configDir, node_path_1.default.join(cli.dir, cli.file));
1751
+ installFs().mkdirSync(node_path_1.default.dirname(cliDest), { recursive: true });
1752
+ installFs().copyFileSync(cliSrc, cliDest);
1753
+ }
1754
+ }
1744
1755
  }
1745
1756
  // ---------------------------------------------------------------------------
1746
1757
  // _migrateLegacyOpencodeCommandDir
@@ -23,6 +23,10 @@
23
23
  * (design row #5 / "Not-corruption"). `kindsDiffer` on `ShadowReport` is what
24
24
  * lets `renderShadowReport` word the two cases correctly.
25
25
  *
26
+ * Same-scope pairs are not reported: a runtime whose layout installs `commands` and `skills` side by side at one
27
+ * scope (OpenCode, Kilo, Augment, CodeBuddy, ZCode) writes both on purpose, and `isGenuinelyShadowed` requires the
28
+ * winner and the loser to sit in different scopes (#5344).
29
+ *
26
30
  * ── Report, don't correct (mirrors the resolver's own law) ─────────────────
27
31
  * `mismatches` surfaces a declared runtime/scope that disagrees with the
28
32
  * probed one (Postel's Law, design doc: liberal in what is accepted, but the
@@ -218,6 +222,14 @@ function stemFromTrigger(trigger, prefix) {
218
222
  function isGenuinelyShadowed(t, scopeRecords, prefixFor) {
219
223
  if (t.shadowedBy === null)
220
224
  return false;
225
+ // This is a CROSS-SCOPE report (see the module comment). A winner and a loser in the SAME scope are the two
226
+ // kinds one install deliberately wrote side by side — OpenCode, Kilo, Augment, CodeBuddy and ZCode install
227
+ // `commands` and `skills` for every trigger at one scope. GSD wrote both, the operator cannot act on it, and
228
+ // the host's own dedupe (OpenCode 1.x lists the command and drops the same-named skill; 2.x keeps them on
229
+ // separate surfaces) is not a loss of reachability. Reporting it printed "72 triggers shadowed" on every
230
+ // install of those five runtimes (#5344).
231
+ if (t.shadowedBy.scope === t.scope)
232
+ return false;
221
233
  const winnerRecord = scopeRecords.get(t.shadowedBy.scope);
222
234
  const shadowedRecord = scopeRecords.get(t.scope);
223
235
  const winnerStem = stemFromTrigger(t.trigger, prefixFor(t.shadowedBy.scope, t.shadowedBy.kind));
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ /**
3
+ * Installer migration: stop the GSD MCP companion from duplicating every GSD command in OpenCode
4
+ * (#5338; epic #5331, ADR-5331 D6).
5
+ *
6
+ * OpenCode turns each MCP prompt into a `<server>:<prompt>` slash command. The GSD companion server
7
+ * advertised its whole prompt catalog, so with the command files the installer already writes every GSD
8
+ * command appeared twice (`gsd-plan-phase` and `gsd:plan-phase`; 143 entries against 72 observed on
9
+ * OpenCode 2.0.27). New installs register the server with `--no-prompts`. This migration converges an
10
+ * existing install by adding that flag to the `gsd` MCP entry GSD itself wrote — and only that entry: a
11
+ * `gsd` server the user edited or defined does not match the installer's shape and is never touched.
12
+ *
13
+ * It applies to OpenCode on both generations (the entry is the same shape), covers the 1.x `mcp.gsd` and
14
+ * the 2.x native `mcp.servers.gsd` locations, and edits the config as text so a commented `opencode.jsonc`
15
+ * keeps its comments and layout (#5334).
16
+ *
17
+ * Is the action safe in non-interactive install?
18
+ * Yes. `rewrite-json` is non-interactive and journaled; the rewrite is a one-token append to a command
19
+ * array the installer wrote.
20
+ *
21
+ * See docs/installer-migrations.md#shipped-migrations and the OpenCode row of
22
+ * docs/installer-migrations.md#runtime-configuration-contract-registry.
23
+ */
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ const node_fs_1 = __importDefault(require("node:fs"));
28
+ const node_path_1 = __importDefault(require("node:path"));
29
+ const jsonc_edit_cjs_1 = require("../jsonc-edit.cjs");
30
+ const opencode_mcp_entry_cjs_1 = require("../opencode-mcp-entry.cjs");
31
+ /** OpenCode reads `opencode.jsonc` in preference to `opencode.json` when both exist. */
32
+ const CONFIG_CANDIDATES = ['opencode.jsonc', 'opencode.json'];
33
+ function isPlainObject(v) {
34
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
35
+ }
36
+ const migration = {
37
+ id: '2026-10-12-opencode-mcp-no-prompts',
38
+ title: 'Stop the GSD MCP companion duplicating GSD commands in OpenCode',
39
+ description: 'Add --no-prompts to the GSD-written `gsd` MCP server entry in opencode.json(c) (mcp.gsd, and the ' +
40
+ 'OpenCode 2.x native mcp.servers.gsd) so OpenCode no longer lists every GSD command a second time as ' +
41
+ 'a `gsd:<name>` MCP-prompt command. Only the entry the installer wrote is changed; comments and ' +
42
+ 'layout in a JSONC config are preserved (#5338, #5334).',
43
+ introducedIn: '1.17.0',
44
+ runtimes: ['opencode'],
45
+ scopes: ['global', 'local'],
46
+ destructive: false,
47
+ runtimeContract: 'docs/installer-migrations.md#runtime-configuration-contract-registry OpenCode row',
48
+ plan: (ctx) => {
49
+ if (ctx.runtime !== 'opencode')
50
+ return [];
51
+ // The file OpenCode (and the installer's resolveOpencodeConfigPath) treats as THE config.
52
+ const relPath = CONFIG_CANDIDATES.find((name) => node_fs_1.default.existsSync(node_path_1.default.join(ctx.configDir, name)));
53
+ if (!relPath)
54
+ return [];
55
+ let config;
56
+ try {
57
+ config = (0, jsonc_edit_cjs_1.parseJsonc)(node_fs_1.default.readFileSync(node_path_1.default.join(ctx.configDir, relPath), 'utf8'));
58
+ }
59
+ catch {
60
+ return []; // unparseable: never touch a config we cannot read (same posture as the installer)
61
+ }
62
+ if (!isPlainObject(config) || !isPlainObject(config['mcp']))
63
+ return [];
64
+ const mcp = config['mcp'];
65
+ const edits = [];
66
+ const next = { ...config, mcp: { ...mcp } };
67
+ const nextMcp = next['mcp'];
68
+ const legacy = mcp['gsd'];
69
+ if ((0, opencode_mcp_entry_cjs_1.isGsdMcpEntry)(legacy) && !(0, opencode_mcp_entry_cjs_1.hasNoPromptsFlag)(legacy)) {
70
+ const updated = (0, opencode_mcp_entry_cjs_1.withNoPromptsFlag)(legacy);
71
+ nextMcp['gsd'] = updated;
72
+ edits.push({ path: ['mcp', 'gsd', 'command'], value: updated.command });
73
+ }
74
+ const servers = mcp['servers'];
75
+ if (isPlainObject(servers)) {
76
+ const native = servers['gsd'];
77
+ if ((0, opencode_mcp_entry_cjs_1.isGsdMcpEntry)(native) && !(0, opencode_mcp_entry_cjs_1.hasNoPromptsFlag)(native)) {
78
+ const updated = (0, opencode_mcp_entry_cjs_1.withNoPromptsFlag)(native);
79
+ nextMcp['servers'] = { ...servers, gsd: updated };
80
+ edits.push({ path: ['mcp', 'servers', 'gsd', 'command'], value: updated.command });
81
+ }
82
+ }
83
+ if (edits.length === 0)
84
+ return [];
85
+ return [
86
+ {
87
+ type: 'rewrite-json',
88
+ relPath,
89
+ value: next,
90
+ edits,
91
+ reason: 'the GSD MCP companion advertised its prompt catalog, which OpenCode lists as a second set of ' +
92
+ '`gsd:<name>` slash commands next to the installed command files',
93
+ ownershipEvidence: "the entry matches the exact shape configureOpencodePermissions writes: type 'local' and a command " +
94
+ "array of `npx … <package> gsd-mcp-server`; a user-edited or user-defined `gsd` server does not match",
95
+ },
96
+ ];
97
+ },
98
+ };
99
+ module.exports = migration;
@@ -18,6 +18,7 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
18
18
  const clock_cjs_1 = require("./clock.cjs");
19
19
  const install_scope_cjs_1 = require("./install-scope.cjs");
20
20
  const security_cjs_1 = require("./security.cjs");
21
+ const jsonc_edit_cjs_1 = require("./jsonc-edit.cjs");
21
22
  // #2874 (ADR-58 cleanup phase): this file is the ~1200-line migration
22
23
  // plan/apply/rollback/lock/journal engine — almost none of it is on the
23
24
  // installRuntimeArtifacts call tree. Only `readInstallManifest` and
@@ -293,15 +294,25 @@ function readInstallManifest(configDir) {
293
294
  scope: (0, install_scope_cjs_1.isInstallScopeId)(m.scope) ? m.scope : null,
294
295
  };
295
296
  }
297
+ function normalizeHostGeneration(raw) {
298
+ if (!raw || typeof raw !== 'object')
299
+ return undefined;
300
+ const r = raw;
301
+ if (r.runtime !== 'opencode' || (r.generation !== 1 && r.generation !== 2))
302
+ return undefined;
303
+ return { runtime: 'opencode', generation: r.generation, version: typeof r.version === 'string' ? r.version : null };
304
+ }
296
305
  function readInstallState(configDir) {
297
306
  const state = readJsonIfPresent(node_path_1.default.join(configDir, INSTALL_STATE_NAME), STRICT_JSON);
298
307
  if (!state || typeof state !== 'object') {
299
308
  return { schemaVersion: 1, appliedMigrations: [] };
300
309
  }
301
310
  const s = state;
311
+ const hostGeneration = normalizeHostGeneration(s.hostGeneration);
302
312
  return {
303
313
  schemaVersion: typeof s.schemaVersion === 'number' ? s.schemaVersion : 1,
304
314
  appliedMigrations: Array.isArray(s.appliedMigrations) ? s.appliedMigrations : [],
315
+ ...(hostGeneration ? { hostGeneration } : {}),
305
316
  };
306
317
  }
307
318
  // Strict atomic write for the install state: must never be left half-written.
@@ -654,6 +665,31 @@ function journalAction(action, status, extras = {}) {
654
665
  const { value: _value, ...safeAction } = action;
655
666
  return { ...safeAction, ...extras, status };
656
667
  }
668
+ /**
669
+ * The text a `rewrite-json` action writes. A file with JSONC syntax (comments, trailing commas, or a
670
+ * `.jsonc` name) is edited in place from the action's `edits`, so the user's annotations survive (#5334);
671
+ * everything else, and any edit the text editor cannot place, is written as the action's whole `value`.
672
+ */
673
+ function renderRewrittenJson(fullPath, action) {
674
+ if (Array.isArray(action.edits) && action.edits.length > 0) {
675
+ let original = null;
676
+ try {
677
+ original = node_fs_1.default.readFileSync(fullPath, 'utf8');
678
+ }
679
+ catch { /* fall through to the value rewrite */ }
680
+ if (original !== null && (fullPath.endsWith('.jsonc') || (0, jsonc_edit_cjs_1.hasJsoncFeatures)(original))) {
681
+ let text = original;
682
+ for (const edit of action.edits) {
683
+ text = (0, jsonc_edit_cjs_1.setJsoncValue)(text, edit.path, edit.value);
684
+ if (text === null)
685
+ break;
686
+ }
687
+ if (text !== null)
688
+ return text;
689
+ }
690
+ }
691
+ return JSON.stringify(action.value, null, 2) + '\n';
692
+ }
657
693
  function planInstallerMigrations({ configDir, runtime = null, scope = null, migrations, baselineScan = false, now = () => new Date().toISOString(), }) {
658
694
  if (!configDir)
659
695
  throw new Error('configDir is required');
@@ -725,6 +761,8 @@ function planInstallerMigrations({ configDir, runtime = null, scope = null, migr
725
761
  }
726
762
  if (action.type === 'rewrite-json') {
727
763
  action.value = rawAction.value;
764
+ if (Array.isArray(rawAction.edits))
765
+ action.edits = rawAction.edits;
728
766
  action.deleteIfEmpty = rawAction.deleteIfEmpty === true;
729
767
  }
730
768
  if (rawAction.prompt)
@@ -896,7 +934,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
896
934
  }));
897
935
  }
898
936
  else {
899
- (0, shell_command_projection_cjs_1.platformWriteSync)(fullPath, JSON.stringify(action.value, null, 2) + '\n');
937
+ (0, shell_command_projection_cjs_1.platformWriteSync)(fullPath, renderRewrittenJson(fullPath, action));
900
938
  journal.actions.push(journalAction(action, 'rewritten', {
901
939
  rollbackRelPath: node_path_1.default.posix.join(rollbackRootRelPath, normalized),
902
940
  }));
@@ -945,6 +983,7 @@ function applyInstallerMigrationPlan({ configDir, plan, now = () => new Date().t
945
983
  writeInstallState(configDir, {
946
984
  schemaVersion: 1,
947
985
  appliedMigrations: nextApplied,
986
+ ...(state.hostGeneration ? { hostGeneration: state.hostGeneration } : {}),
948
987
  });
949
988
  return {
950
989
  appliedMigrationIds: journal.appliedMigrationIds,
@@ -1017,6 +1056,7 @@ function markPendingMigrationsApplied({ configDir, plan, now = () => new Date().
1017
1056
  writeInstallState(configDir, {
1018
1057
  schemaVersion: 1,
1019
1058
  appliedMigrations: nextApplied,
1059
+ ...(state.hostGeneration ? { hostGeneration: state.hostGeneration } : {}),
1020
1060
  });
1021
1061
  }
1022
1062
  return newlyApplied;
@@ -1073,6 +1113,18 @@ function runInstallerMigrations({ configDir, runtime = null, scope = null, migra
1073
1113
  // Unused but kept to satisfy eslint — sleepSync is referenced in the original
1074
1114
  // and may be used by test code that patches this module.
1075
1115
  void sleepSync;
1116
+ /**
1117
+ * Record which OpenCode generation this install targets (ADR-5331 D2; #5337). Idempotent and
1118
+ * atomic; leaves the applied-migrations journal untouched.
1119
+ */
1120
+ function recordHostGeneration(configDir, info) {
1121
+ const state = readInstallState(configDir);
1122
+ const next = { runtime: 'opencode', generation: info.generation, version: info.version ?? null };
1123
+ const prev = state.hostGeneration;
1124
+ if (prev && prev.generation === next.generation && prev.version === next.version)
1125
+ return;
1126
+ writeInstallState(configDir, { ...state, hostGeneration: next });
1127
+ }
1076
1128
  module.exports = {
1077
1129
  DEFAULT_MIGRATIONS_DIR,
1078
1130
  INSTALL_MIGRATION_LOCK_NAME,
@@ -1089,6 +1141,7 @@ module.exports = {
1089
1141
  planInstallerMigrations,
1090
1142
  readInstallManifest,
1091
1143
  readInstallState,
1144
+ recordHostGeneration,
1092
1145
  runInstallerMigrations,
1093
1146
  writeInstallState,
1094
1147
  };