@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
@@ -295,6 +295,61 @@ function buildKiloAgentPermissionBlock(claudeTools) {
295
295
  }
296
296
  return lines;
297
297
  }
298
+ // OpenCode agent tool restrictions (#5338, ADR-5331 D4). Claude's `tools:` allowlist and
299
+ // `disallowedTools:` denylist become OpenCode `permission:` entries. The V1 `permission` form is the one
300
+ // written because OpenCode 2.x translates it automatically ("legacy agent frontmatter … is translated"),
301
+ // so one form serves both generations — verified: a `permission: {edit: deny, bash: deny}` agent drops the
302
+ // edit/write/shell tools from the model's tool list on 1.18.29 and 2.0.27. Only denials are emitted: an
303
+ // allowed tool keeps the user's own configured default instead of being forced to `allow`, and MCP tool
304
+ // entries are not restricted.
305
+ const claudeToOpencodePermission = {
306
+ Read: 'read',
307
+ Write: 'edit',
308
+ Edit: 'edit',
309
+ MultiEdit: 'edit',
310
+ NotebookEdit: 'edit',
311
+ Bash: 'bash',
312
+ Grep: 'grep',
313
+ Glob: 'glob',
314
+ WebFetch: 'webfetch',
315
+ WebSearch: 'websearch',
316
+ Task: 'task',
317
+ Agent: 'task',
318
+ Skill: 'skill',
319
+ SlashCommand: 'skill',
320
+ TodoWrite: 'todowrite',
321
+ };
322
+ const opencodeAgentPermissionOrder = ['read', 'edit', 'bash', 'grep', 'glob', 'webfetch', 'websearch', 'task', 'skill', 'todowrite'];
323
+ /**
324
+ * `permission:` frontmatter lines for an OpenCode agent, or `[]` when the agent declares no tool
325
+ * restriction (an agent with neither `tools` nor `disallowedTools` inherits everything, as in Claude).
326
+ */
327
+ function buildOpencodeAgentPermissionBlock(allowedTools, disallowedTools) {
328
+ const allowed = new Set();
329
+ for (const tool of allowedTools) {
330
+ const mapped = claudeToOpencodePermission[tool];
331
+ if (mapped)
332
+ allowed.add(mapped);
333
+ }
334
+ const denied = new Set();
335
+ if (allowedTools.length > 0) {
336
+ for (const permission of opencodeAgentPermissionOrder) {
337
+ if (!allowed.has(permission))
338
+ denied.add(permission);
339
+ }
340
+ }
341
+ for (const tool of disallowedTools) {
342
+ const mapped = claudeToOpencodePermission[tool];
343
+ if (mapped)
344
+ denied.add(mapped);
345
+ }
346
+ const lines = [];
347
+ for (const permission of opencodeAgentPermissionOrder) {
348
+ if (denied.has(permission))
349
+ lines.push(` ${permission}: deny`);
350
+ }
351
+ return lines.length > 0 ? ['permission:', ...lines] : [];
352
+ }
298
353
  function replaceRelativePathReference(content, fromPath, toPath) {
299
354
  const escapedPath = (0, pattern_cjs_1.escapeRegex)(fromPath);
300
355
  return content.replace(new RegExp(`(^|[^A-Za-z0-9_./-])${escapedPath}`, 'g'), (_, prefix) => `${prefix}${toPath}`);
@@ -1953,7 +2008,11 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
1953
2008
  const newLines = [];
1954
2009
  let inAllowedTools = false;
1955
2010
  let inSkippedArray = false;
2011
+ let inAgentTools = false;
2012
+ let inDisallowedTools = false;
1956
2013
  const allowedTools = [];
2014
+ const agentTools = [];
2015
+ const agentDisallowed = [];
1957
2016
  for (const line of lines) {
1958
2017
  const trimmed = line.trim();
1959
2018
  // For agents: skip commented-out lines (e.g. hooks blocks)
@@ -1965,11 +2024,31 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
1965
2024
  inAllowedTools = true;
1966
2025
  continue;
1967
2026
  }
2027
+ // Agents: collect the `tools:` allowlist and `disallowedTools:` denylist (block-list continuation
2028
+ // lines) so they can be rendered as `permission:` entries below.
2029
+ if (isAgent && (inAgentTools || inDisallowedTools)) {
2030
+ if (trimmed.startsWith('- ')) {
2031
+ const tool = decodeToolScalar(trimmed.substring(2));
2032
+ if (tool !== null)
2033
+ (inAgentTools ? agentTools : agentDisallowed).push(tool);
2034
+ continue;
2035
+ }
2036
+ if (trimmed && !trimmed.startsWith('-')) {
2037
+ inAgentTools = false;
2038
+ inDisallowedTools = false;
2039
+ }
2040
+ }
1968
2041
  // Detect inline tools: field (comma-separated string)
1969
2042
  if (trimmed.startsWith('tools:')) {
1970
2043
  if (isAgent) {
1971
- // Agents: strip tools entirely (not supported in OpenCode agent frontmatter)
1972
- inSkippedArray = true;
2044
+ const toolsValue = trimmed.substring(6).trim();
2045
+ // A comment-only value (`tools: # note`) is not inline content: read the block list instead.
2046
+ if (toolsValue && !toolsValue.startsWith('#')) {
2047
+ agentTools.push(...splitToolScalars(toolsValue).map(decodeToolScalar).filter((tool) => tool !== null));
2048
+ }
2049
+ else {
2050
+ inAgentTools = true;
2051
+ }
1973
2052
  continue;
1974
2053
  }
1975
2054
  const toolsValue = trimmed.substring(6).trim();
@@ -1980,8 +2059,18 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
1980
2059
  }
1981
2060
  continue;
1982
2061
  }
1983
- // For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:, disallowedTools:
1984
- if (isAgent && /^(skills|color|memory|maxTurns|permissionMode|disallowedTools):/.test(trimmed)) {
2062
+ if (isAgent && trimmed.startsWith('disallowedTools:')) {
2063
+ const value = trimmed.substring(16).trim();
2064
+ if (value && !value.startsWith('#')) {
2065
+ agentDisallowed.push(...splitToolScalars(value).map(decodeToolScalar).filter((tool) => tool !== null));
2066
+ }
2067
+ else {
2068
+ inDisallowedTools = true;
2069
+ }
2070
+ continue;
2071
+ }
2072
+ // For agents: strip skills:, color:, memory:, maxTurns:, permissionMode:
2073
+ if (isAgent && /^(skills|color|memory|maxTurns|permissionMode):/.test(trimmed)) {
1985
2074
  inSkippedArray = true;
1986
2075
  continue;
1987
2076
  }
@@ -2065,6 +2154,7 @@ function convertClaudeToOpencodeFrontmatter(content, { isAgent = false, modelOve
2065
2154
  if (variant) {
2066
2155
  newLines.push(frontmatterScalar('variant:', variant));
2067
2156
  }
2157
+ newLines.push(...buildOpencodeAgentPermissionBlock(agentTools, agentDisallowed));
2068
2158
  }
2069
2159
  // For commands: add tools object if we had allowed-tools or tools
2070
2160
  if (!isAgent && allowedTools.length > 0) {
@@ -3816,6 +3906,7 @@ module.exports = {
3816
3906
  convertClaudeToOpencodeFrontmatter,
3817
3907
  convertClaudeToKiloFrontmatter,
3818
3908
  _decodeToolScalar: decodeToolScalar,
3909
+ _buildOpencodeAgentPermissionBlock: buildOpencodeAgentPermissionBlock,
3819
3910
  _splitToolScalars: splitToolScalars,
3820
3911
  readGsdCommandNames,
3821
3912
  transformContentToHyphen,
@@ -992,7 +992,16 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
992
992
  const scriptQuoted = JSON.stringify(targetAbs);
993
993
  const cmdPath = scriptAbsPath.replace(/\.js$/, '.cmd');
994
994
  const hookCommand = JSON.stringify(shellCmdProjection.posixNormalize(cmdPath));
995
- const runnerQuoted = JSON.stringify(interpreter);
995
+ // #4838: node is resolved when the hook fires. The install-time path dies when a
996
+ // version manager (WinGet) removes the versioned directory on upgrade, and a
997
+ // shim that only knew that path failed silently until the installer re-ran.
998
+ // Order: explicit GSD_NODE_PATH; the install-time path while it still exists
999
+ // (keeps the #3662 minimal-PATH guarantee); node.exe on PATH. `where $PATH:`
1000
+ // searches PATH only, never the current directory, so repo content cannot
1001
+ // become the runner. `%` is doubled so cmd.exe cannot expand a variable out
1002
+ // of an install path.
1003
+ const bakedBat = JSON.stringify(interpreter).slice(1, -1).replace(/%/g, '%%');
1004
+ const scriptBat = scriptQuoted.replace(/%/g, '%%');
996
1005
  return {
997
1006
  invocation: { interpreter, target: scriptAbsPath },
998
1007
  cmdPath,
@@ -1000,7 +1009,16 @@ function buildCodexHookWindowsShimIR(scriptAbsPath, absoluteRunnerToken) {
1000
1009
  eol: { cmd: '\r\n' },
1001
1010
  passthroughArgs: true,
1002
1011
  render: {
1003
- cmd: () => `@ECHO OFF\r\n@SETLOCAL\r\n@${runnerQuoted} ${scriptQuoted} %*\r\n`,
1012
+ cmd: () => [
1013
+ '@ECHO OFF',
1014
+ '@SETLOCAL',
1015
+ '@SET "GSD_NODE="',
1016
+ '@IF DEFINED GSD_NODE_PATH IF EXIST "%GSD_NODE_PATH%" SET "GSD_NODE=%GSD_NODE_PATH%"',
1017
+ `@IF NOT DEFINED GSD_NODE IF EXIST "${bakedBat}" SET "GSD_NODE=${bakedBat}"`,
1018
+ '@IF NOT DEFINED GSD_NODE FOR /F "delims=" %%N IN (\'where $PATH:node.exe 2^>NUL\') DO @IF NOT DEFINED GSD_NODE SET "GSD_NODE=%%N"',
1019
+ `@"%GSD_NODE%" ${scriptBat} %*`,
1020
+ '',
1021
+ ].join('\r\n'),
1004
1022
  },
1005
1023
  };
1006
1024
  }
@@ -2279,6 +2297,35 @@ function pushHookGroup(entries, matcher, command, timeout) {
2279
2297
  hook['timeout'] = timeout;
2280
2298
  entries.push(matcher === undefined ? { hooks: [hook] } : { matcher, hooks: [hook] });
2281
2299
  }
2300
+ /** #4840: the hook that reads the statusline's metrics bridge file. */
2301
+ const CONTEXT_MONITOR_HOOK = 'gsd-context-monitor.js';
2302
+ /**
2303
+ * #4840: remove GSD-managed registrations of `stem` from one event's groups,
2304
+ * leaving user-authored hooks (even in the same group) untouched. Returns
2305
+ * whether anything was removed. A group left with no hooks is dropped.
2306
+ */
2307
+ function stripManagedHookRegistrations(entries, stem) {
2308
+ let removed = false;
2309
+ for (let i = entries.length - 1; i >= 0; i--) {
2310
+ const entry = entries[i];
2311
+ if (!entry || !Array.isArray(entry.hooks))
2312
+ continue;
2313
+ const kept = entry.hooks.filter((h) => {
2314
+ const managed = referencesHook(h, stem)
2315
+ && isManagedHookCommand(h.command, { surface: 'settings-json', includeLegacyAliases: true });
2316
+ if (managed)
2317
+ removed = true;
2318
+ return !managed;
2319
+ });
2320
+ if (kept.length === entry.hooks.length)
2321
+ continue;
2322
+ if (kept.length === 0)
2323
+ entries.splice(i, 1);
2324
+ else
2325
+ entry.hooks = kept;
2326
+ }
2327
+ return removed;
2328
+ }
2282
2329
  function applySettingsJsonHookTables(settings, opts, tables) {
2283
2330
  const { runtime, postToolEvent, hookEvents, extendedHookEvents, hooksSurface, targetDir, hookOpts } = opts;
2284
2331
  // ADR-857 phase 5f-3: extended hook events are driven by the registry
@@ -2326,6 +2373,12 @@ function applySettingsJsonHookTables(settings, opts, tables) {
2326
2373
  settings.hooks[key] = [];
2327
2374
  }
2328
2375
  const entries = settings.hooks[key];
2376
+ if (row.file === CONTEXT_MONITOR_HOOK && opts.contextWarnings === false) {
2377
+ if (stripManagedHookRegistrations(entries, hookStem(row.file))) {
2378
+ console.log(` ${green}✓${reset} Removed stale context monitor hook (${_capabilityTitle(runtime)} has no context metrics writer, #4840)`);
2379
+ }
2380
+ continue;
2381
+ }
2329
2382
  const command = resolveRowCommand(row, opts);
2330
2383
  if ('build' in row.command && row.command.build === 'sh')
2331
2384
  shellCommands[row.file] = command;
@@ -2468,7 +2521,8 @@ function buildKimiHooksTomlBlock(targetDir, opts) {
2468
2521
  { event: 'PreToolUse', command: cmd('gsd-workflow-guard.js'), matcher: 'Shell|WriteFile|StrReplaceFile', timeout: kimiBlockingGuardTimeout('gsd-workflow-guard.js') },
2469
2522
  { event: 'PreToolUse', command: cmd('gsd-validate-commit.sh'), matcher: 'Shell', timeout: kimiBlockingGuardTimeout('gsd-validate-commit.sh') },
2470
2523
  // PostToolUse
2471
- { event: 'PostToolUse', command: cmd('gsd-context-monitor.js'), timeout: 10 },
2524
+ // #4840: no metrics writer on kimi, so the warning registration is skipped (a block from an earlier install is replaced wholesale).
2525
+ { event: 'PostToolUse', command: opts.contextWarnings === false ? null : cmd('gsd-context-monitor.js'), timeout: 10 },
2472
2526
  { event: 'PostToolUse', command: cmd('gsd-phase-boundary.sh'), matcher: 'WriteFile|StrReplaceFile', timeout: 5 },
2473
2527
  { event: 'PostToolUse', command: cmd('gsd-read-injection-scanner.js'), matcher: 'ReadFile', timeout: 5 },
2474
2528
  { event: 'PostToolUse', command: cmd('gsd-graphify-update.sh'), matcher: 'Shell', timeout: 5 },
@@ -2541,6 +2595,7 @@ function stripKimiHooksTomlBlock(content) {
2541
2595
  function writeKimiHooksToml(configPath, targetDir, opts) {
2542
2596
  const configuredEntrypoints = [];
2543
2597
  const trackedOpts = {
2598
+ contextWarnings: opts.contextWarnings,
2544
2599
  hookOpts: {
2545
2600
  ...opts.hookOpts,
2546
2601
  configPath,
@@ -36,6 +36,7 @@ exports.resolveExplicitRuntime = resolveExplicitRuntime;
36
36
  exports.resolveRuntime = resolveRuntime;
37
37
  exports.resolveActiveRuntime = resolveActiveRuntime;
38
38
  exports.formatGsdSlashFor = formatGsdSlashFor;
39
+ exports.runtimeCommand = runtimeCommand;
39
40
  const node_fs_1 = __importDefault(require("node:fs"));
40
41
  const node_path_1 = __importDefault(require("node:path"));
41
42
  const runtime_name_policy_cjs_1 = require("./runtime-name-policy.cjs");
@@ -227,3 +228,15 @@ function resolveActiveRuntime(config, env = process.env) {
227
228
  function formatGsdSlashFor(projectDir, commandName) {
228
229
  return formatGsdSlash(commandName, resolveRuntime(projectDir));
229
230
  }
231
+ /**
232
+ * #4859: the command as the project's runtime registers it, typed as a string —
233
+ * for runtime code that builds user-facing guidance. String literals in compiled
234
+ * runtime code are printed verbatim (the install-time conversion only rewrites
235
+ * markdown artifact bodies), so a hard-coded `/gsd:<cmd>` names a command no
236
+ * install route registers. Route every such reference through here:
237
+ * `runtimeCommand(cwd, 'plan-phase 3 --research')` → `/gsd-plan-phase 3 --research`
238
+ * (`$gsd-plan-phase …` on Codex). `commandName` is the bare name plus any argument tail.
239
+ */
240
+ function runtimeCommand(projectDir, commandName) {
241
+ return String(formatGsdSlashFor(projectDir, commandName));
242
+ }
@@ -43,6 +43,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
43
43
  const node_path_1 = __importDefault(require("node:path"));
44
44
  const node_child_process_1 = require("node:child_process");
45
45
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
46
+ const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
46
47
  // #3696: the calendar-validity predicate moved to the STATE.md document module
47
48
  // so `state validate` can assert the same `last_activity` invariant this reader
48
49
  // already enforces (ADR-227). Two copies would let the two surfaces disagree
@@ -52,7 +53,7 @@ const ioMod = require("./io.cjs");
52
53
  const { output } = ioMod;
53
54
  // eslint-disable-next-line @typescript-eslint/no-require-imports
54
55
  const planningWorkspace = require("./planning-workspace.cjs");
55
- const { planningPaths } = planningWorkspace;
56
+ const { planningPaths, resolvePhaseIdConvention } = planningWorkspace;
56
57
  // eslint-disable-next-line @typescript-eslint/no-require-imports
57
58
  const frontmatter = require("./frontmatter.cjs");
58
59
  const { extractFrontmatter, frontmatterBlock } = frontmatter;
@@ -60,6 +61,9 @@ const { extractFrontmatter, frontmatterBlock } = frontmatter;
60
61
  const phaseLifecycle = require("./phase-lifecycle.cjs");
61
62
  const { deriveProgressFromRoadmap } = phaseLifecycle;
62
63
  // eslint-disable-next-line @typescript-eslint/no-require-imports
64
+ const roadmapParser = require("./roadmap-parser.cjs");
65
+ const { extractCurrentMilestone, scanMilestonePhaseIds } = roadmapParser;
66
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
63
67
  const stateDocument = require("./state-document.cjs");
64
68
  const { stateFieldValue } = stateDocument;
65
69
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -352,6 +356,7 @@ function detectSignals(cwd, now = Date.now) {
352
356
  stale_activity: false,
353
357
  roadmap_total_phases: null,
354
358
  roadmap_completed_phases: null,
359
+ milestone_phases: [],
355
360
  // No STATE.md (or unreadable) → no stamp to compare. Unknown, not fresh.
356
361
  state_commits_behind: null,
357
362
  state_commit_stale: null,
@@ -425,12 +430,18 @@ function detectSignals(cwd, now = Date.now) {
425
430
  // Progress table — isComplete falls back to the legacy comparison in that case.
426
431
  let roadmapTotalPhases = null;
427
432
  let roadmapCompletedPhases = null;
433
+ let milestonePhases = [];
428
434
  if (hasRoadmap) {
429
435
  try {
430
436
  const roadmapContent = node_fs_1.default.readFileSync(paths.roadmap, 'utf8');
431
437
  const derived = deriveProgressFromRoadmap(roadmapContent);
432
438
  roadmapTotalPhases = derived.totalPhases;
433
439
  roadmapCompletedPhases = derived.completedPhases;
440
+ // #5228: the ordered phase list of the current milestone, from the same
441
+ // scan the milestone-scoped commands use.
442
+ milestonePhases = [
443
+ ...scanMilestonePhaseIds(extractCurrentMilestone(roadmapContent, cwd), resolvePhaseIdConvention(cwd)),
444
+ ].sort(comparePhaseNum);
434
445
  }
435
446
  catch {
436
447
  /* ROADMAP.md unreadable — leave null; isComplete falls back to legacy. */
@@ -457,6 +468,7 @@ function detectSignals(cwd, now = Date.now) {
457
468
  stale_activity: staleActivity,
458
469
  roadmap_total_phases: roadmapTotalPhases,
459
470
  roadmap_completed_phases: roadmapCompletedPhases,
471
+ milestone_phases: milestonePhases,
460
472
  state_commits_behind: freshness.commits_behind,
461
473
  state_commit_stale: freshness.commit_stale,
462
474
  };
@@ -488,10 +500,21 @@ function isComplete(s) {
488
500
  // Legacy path: STATE.md comparison. It only fires when ROADMAP.md is
489
501
  // absent or has no Progress table, and uses canonical phase-id ordering
490
502
  // so dotted phase ids are never coerced to JavaScript numbers.
491
- if (s.total_phases === null || s.current_phase === null)
492
- return false;
493
- if (comparePhaseNum(s.current_phase, s.total_phases) < 0)
503
+ if (s.current_phase === null)
494
504
  return false;
505
+ if (s.milestone_phases.length > 0) {
506
+ // #5228: compare against the milestone's LAST phase. The phase count is
507
+ // not a phase number once numbering continues from an earlier milestone.
508
+ const last = s.milestone_phases[s.milestone_phases.length - 1];
509
+ if (comparePhaseNum(s.current_phase, last) < 0)
510
+ return false;
511
+ }
512
+ else {
513
+ if (s.total_phases === null)
514
+ return false;
515
+ if (comparePhaseNum(s.current_phase, s.total_phases) < 0)
516
+ return false;
517
+ }
495
518
  }
496
519
  // Status regex: require milestone-level completion language. The pre-fix
497
520
  // regex matched any "shipped" / "done" substring (per-phase language).
@@ -558,52 +581,54 @@ function rec(id, label, command) {
558
581
  return action(id, label, command, true);
559
582
  }
560
583
  /** Per-situation ordered action list; recommended action first. */
561
- function actionsFor(situation, s) {
584
+ function actionsFor(situation, s, runtime = 'claude') {
585
+ // #4859: commands are printed verbatim, so name them the way this runtime registers them.
586
+ const cmd = (name) => (0, runtime_slash_cjs_1.formatGsdSlash)(name, runtime);
562
587
  const phaseN = s.current_phase ?? '';
563
588
  const execLabel = phaseN === '' ? 'Continue executing' : `Continue executing phase ${phaseN}`;
564
589
  switch (situation) {
565
590
  case 'no-project':
566
591
  return [
567
- rec('new-project', 'Start a new project', '/gsd:new-project'),
568
- action('map-codebase', 'Map an existing codebase', '/gsd:map-codebase'),
569
- action('quick', 'Quick task', '/gsd:quick'),
570
- action('help', 'Show help', '/gsd:help'),
592
+ rec('new-project', 'Start a new project', cmd('new-project')),
593
+ action('map-codebase', 'Map an existing codebase', cmd('map-codebase')),
594
+ action('quick', 'Quick task', cmd('quick')),
595
+ action('help', 'Show help', cmd('help')),
571
596
  ];
572
597
  case 'paused':
573
598
  return [
574
- rec('resume-work', 'Resume work', '/gsd:resume-work'),
575
- action('progress', 'Show progress', '/gsd:progress'),
576
- action('quick', 'Quick task', '/gsd:quick'),
577
- action('help', 'Show help', '/gsd:help'),
599
+ rec('resume-work', 'Resume work', cmd('resume-work')),
600
+ action('progress', 'Show progress', cmd('progress')),
601
+ action('quick', 'Quick task', cmd('quick')),
602
+ action('help', 'Show help', cmd('help')),
578
603
  ];
579
604
  case 'blocked':
580
605
  return [
581
- rec('debug', 'Debug the blocker', '/gsd:debug'),
582
- action('verify-work', 'Verify current work', '/gsd:verify-work'),
583
- action('capture', 'Capture a note', '/gsd:capture'),
584
- action('progress', 'Show progress', '/gsd:progress'),
606
+ rec('debug', 'Debug the blocker', cmd('debug')),
607
+ action('verify-work', 'Verify current work', cmd('verify-work')),
608
+ action('capture', 'Capture a note', cmd('capture')),
609
+ action('progress', 'Show progress', cmd('progress')),
585
610
  ];
586
611
  case 'verify-failed':
587
612
  return [
588
- rec('verify-work', 'Re-verify work', '/gsd:verify-work'),
589
- action('debug', 'Debug the failure', '/gsd:debug'),
590
- action('code-review', 'Review recent work', '/gsd:code-review'),
591
- action('progress', 'Show progress', '/gsd:progress'),
613
+ rec('verify-work', 'Re-verify work', cmd('verify-work')),
614
+ action('debug', 'Debug the failure', cmd('debug')),
615
+ action('code-review', 'Review recent work', cmd('code-review')),
616
+ action('progress', 'Show progress', cmd('progress')),
592
617
  ];
593
618
  case 'needs-first-phase':
594
619
  return [
595
- rec('discuss-phase', 'Discuss the first phase', '/gsd:discuss-phase'),
596
- action('plan-phase', 'Plan phase 1', '/gsd:plan-phase'),
597
- action('quick', 'Quick task', '/gsd:quick'),
598
- action('progress', 'Show progress', '/gsd:progress'),
620
+ rec('discuss-phase', 'Discuss the first phase', cmd('discuss-phase')),
621
+ action('plan-phase', 'Plan phase 1', cmd('plan-phase')),
622
+ action('quick', 'Quick task', cmd('quick')),
623
+ action('progress', 'Show progress', cmd('progress')),
599
624
  ];
600
625
  case 'planning':
601
626
  // Forward motion → delegate to the single gated engine (see 'executing').
602
627
  return [
603
- rec('progress-next', `Advance to the next step (plan phase ${phaseN || 1})`, '/gsd:progress --next'),
604
- action('plan-phase', `Plan phase ${phaseN || 1}`, '/gsd:plan-phase'),
605
- action('discuss-phase', 'Discuss before planning', '/gsd:discuss-phase'),
606
- action('progress', 'Show progress', '/gsd:progress'),
628
+ rec('progress-next', `Advance to the next step (plan phase ${phaseN || 1})`, cmd('progress --next')),
629
+ action('plan-phase', `Plan phase ${phaseN || 1}`, cmd('plan-phase')),
630
+ action('discuss-phase', 'Discuss before planning', cmd('discuss-phase')),
631
+ action('progress', 'Show progress', cmd('progress')),
607
632
  ];
608
633
  case 'executing':
609
634
  // In-project forward motion delegates to the single gated engine
@@ -612,40 +637,40 @@ function actionsFor(situation, s) {
612
637
  // /gsd:execute-phase here — that divergence is why the old flat /gsd-next
613
638
  // was removed (#3054). Direct execute stays as an explicit secondary choice.
614
639
  return [
615
- rec('progress-next', 'Advance to the next step', '/gsd:progress --next'),
616
- action('execute-phase', execLabel, '/gsd:execute-phase'),
617
- action('quick', 'Quick task', '/gsd:quick'),
618
- action('code-review', 'Review recent work', '/gsd:code-review'),
640
+ rec('progress-next', 'Advance to the next step', cmd('progress --next')),
641
+ action('execute-phase', execLabel, cmd('execute-phase')),
642
+ action('quick', 'Quick task', cmd('quick')),
643
+ action('code-review', 'Review recent work', cmd('code-review')),
619
644
  ];
620
645
  case 'verify-pending':
621
646
  // Forward motion → delegate to the single gated engine (see 'executing').
622
647
  return [
623
- rec('progress-next', 'Advance to the next step (verify)', '/gsd:progress --next'),
624
- action('verify-work', 'Verify work', '/gsd:verify-work'),
625
- action('code-review', 'Review recent work', '/gsd:code-review'),
626
- action('ship', 'Ship completed work', '/gsd:ship'),
648
+ rec('progress-next', 'Advance to the next step (verify)', cmd('progress --next')),
649
+ action('verify-work', 'Verify work', cmd('verify-work')),
650
+ action('code-review', 'Review recent work', cmd('code-review')),
651
+ action('ship', 'Ship completed work', cmd('ship')),
627
652
  ];
628
653
  case 'idle-stranded':
629
654
  return [
630
- rec('ship', 'Ship committed work', '/gsd:ship'),
631
- action('complete-milestone', 'Complete the milestone', '/gsd:complete-milestone'),
632
- action('progress', 'Show progress', '/gsd:progress'),
633
- action('capture', 'Capture a note', '/gsd:capture'),
655
+ rec('ship', 'Ship committed work', cmd('ship')),
656
+ action('complete-milestone', 'Complete the milestone', cmd('complete-milestone')),
657
+ action('progress', 'Show progress', cmd('progress')),
658
+ action('capture', 'Capture a note', cmd('capture')),
634
659
  ];
635
660
  case 'complete':
636
661
  return [
637
- rec('new-milestone', 'Start a new milestone', '/gsd:new-milestone'),
638
- action('extract-learnings', 'Extract learnings', '/gsd:extract-learnings'),
639
- action('quick', 'Quick task', '/gsd:quick'),
640
- action('progress', 'Show progress', '/gsd:progress'),
662
+ rec('new-milestone', 'Start a new milestone', cmd('new-milestone')),
663
+ action('extract-learnings', 'Extract learnings', cmd('extract-learnings')),
664
+ action('quick', 'Quick task', cmd('quick')),
665
+ action('progress', 'Show progress', cmd('progress')),
641
666
  ];
642
667
  case 'unknown':
643
668
  default:
644
669
  return [
645
- rec('progress', 'Show progress', '/gsd:progress'),
646
- action('progress-next', 'Advance to the next step', '/gsd:progress --next'),
647
- action('quick', 'Quick task', '/gsd:quick'),
648
- action('help', 'Show help', '/gsd:help'),
670
+ rec('progress', 'Show progress', cmd('progress')),
671
+ action('progress-next', 'Advance to the next step', cmd('progress --next')),
672
+ action('quick', 'Quick task', cmd('quick')),
673
+ action('help', 'Show help', cmd('help')),
649
674
  ];
650
675
  }
651
676
  }
@@ -663,7 +688,7 @@ function buildSummary(situation, s) {
663
688
  case 'needs-first-phase':
664
689
  return 'Project initialized — plan your first phase';
665
690
  case 'planning':
666
- return `Phase ${s.current_phase ?? '?'} of ${s.total_phases ?? '?'} — needs a plan`;
691
+ return `${phasePosition(s)} — needs a plan`;
667
692
  case 'executing':
668
693
  return progressLine('executing', s);
669
694
  case 'verify-pending':
@@ -677,10 +702,24 @@ function buildSummary(situation, s) {
677
702
  return 'Unsure of state — showing progress';
678
703
  }
679
704
  }
705
+ /**
706
+ * "Phase N of M" for the current phase. N is the phase's 1-based position in
707
+ * the current milestone's ordered phase list (#5228), so a milestone that
708
+ * continues the numbering of an earlier one (phases 9-17) reads "Phase 1 of 9",
709
+ * not "Phase 9 of 9". When the phase is not in that list (or the roadmap lists
710
+ * none) the STATE.md figures are printed as recorded.
711
+ */
712
+ function phasePosition(s) {
713
+ const current = s.current_phase;
714
+ const at = current === null ? -1 : s.milestone_phases.findIndex((id) => comparePhaseNum(id, current) === 0);
715
+ if (at >= 0)
716
+ return `Phase ${at + 1} of ${s.milestone_phases.length}`;
717
+ return `Phase ${s.current_phase ?? '?'} of ${s.total_phases ?? '?'}`;
718
+ }
680
719
  function progressLine(tail, s) {
681
720
  const parts = [];
682
721
  if (s.current_phase !== null && s.total_phases !== null) {
683
- parts.push(`Phase ${s.current_phase} of ${s.total_phases}`);
722
+ parts.push(phasePosition(s));
684
723
  }
685
724
  else if (s.current_phase !== null) {
686
725
  parts.push(`Phase ${s.current_phase}`);
@@ -699,7 +738,7 @@ function progressLine(tail, s) {
699
738
  function classifyProject(cwd, now = Date.now) {
700
739
  const signals = detectSignals(cwd, now);
701
740
  const situation = classify(signals);
702
- const actions = actionsFor(situation, signals);
741
+ const actions = actionsFor(situation, signals, (0, runtime_slash_cjs_1.resolveRuntime)(cwd));
703
742
  const recommended = actions.find((a) => a.recommended)?.id ?? 'progress';
704
743
  const summary = buildSummary(situation, signals);
705
744
  return { situation, recommended, summary, signals, actions };
@@ -718,6 +757,6 @@ function runSmartEntry(cwd, args, raw) {
718
757
  return;
719
758
  }
720
759
  // Human mode: a compact one-liner plus the recommended action.
721
- const human = `${result.summary}\nRecommended: ${result.recommended} → ${result.actions.find((a) => a.id === result.recommended)?.command ?? '/gsd:progress'}`;
760
+ const human = `${result.summary}\nRecommended: ${result.recommended} → ${result.actions.find((a) => a.id === result.recommended)?.command ?? (0, runtime_slash_cjs_1.runtimeCommand)(cwd, 'progress')}`;
722
761
  output(null, true, human);
723
762
  }