@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -2
- package/.opencode/plugins/gsd-core.js +251 -1
- package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
- package/GEMINI.md +1 -1
- package/agents/gsd-debug-session-manager.compact.md +6 -0
- package/agents/gsd-debug-session-manager.md +6 -0
- package/bin/gsd-mcp-server.js +3 -1
- package/bin/install.js +361 -94
- package/gsd-core/bin/gsd-tools.cjs +17 -2
- package/gsd-core/bin/lib/adr-parser.cjs +37 -5
- package/gsd-core/bin/lib/capability-registry.cjs +132 -80
- package/gsd-core/bin/lib/capability-validator.cjs +2 -1
- package/gsd-core/bin/lib/capability-writer.cjs +2 -1
- package/gsd-core/bin/lib/commands.cjs +28 -8
- package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
- package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
- package/gsd-core/bin/lib/graphify.cjs +3 -2
- package/gsd-core/bin/lib/init.cjs +32 -12
- package/gsd-core/bin/lib/install-engine.cjs +11 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
- package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
- package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
- package/gsd-core/bin/lib/mcp-server.cjs +5 -1
- package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
- package/gsd-core/bin/lib/opencode-version.cjs +486 -0
- package/gsd-core/bin/lib/phase.cjs +21 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +95 -4
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
- package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
- package/gsd-core/bin/lib/smart-entry.cjs +92 -53
- package/gsd-core/bin/lib/state-transition.cjs +67 -9
- package/gsd-core/bin/lib/state.cjs +454 -66
- package/gsd-core/bin/lib/update-context.cjs +18 -1
- package/gsd-core/bin/lib/verification.cjs +107 -17
- package/gsd-core/workflows/code-review.md +16 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
- package/gsd-core/workflows/execute-phase.md +3 -1
- package/gsd-core/workflows/resume-project.md +14 -10
- package/hooks/dist/gsd-check-update.js +26 -20
- package/hooks/dist/gsd-context-monitor.js +14 -5
- package/hooks/dist/gsd-secret-read-guard.js +117 -1
- package/hooks/dist/gsd-statusline.js +321 -22
- package/hooks/gsd-check-update.js +26 -20
- package/hooks/gsd-context-monitor.js +14 -5
- package/hooks/gsd-secret-read-guard.js +117 -1
- package/hooks/gsd-statusline.js +321 -22
- package/package.json +1 -1
- package/scripts/gen-capability-registry.cjs +7 -1
- package/scripts/gen-loop-host-contract.cjs +4 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +10 -0
- package/scripts/mutation-matrix.cjs +3 -1
- package/scripts/release-tarball-smoke.cjs +14 -7
- 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
|
-
|
|
1972
|
-
|
|
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
|
-
|
|
1984
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
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.
|
|
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', '
|
|
568
|
-
action('map-codebase', 'Map an existing codebase', '
|
|
569
|
-
action('quick', 'Quick task', '
|
|
570
|
-
action('help', 'Show 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', '
|
|
575
|
-
action('progress', 'Show progress', '
|
|
576
|
-
action('quick', 'Quick task', '
|
|
577
|
-
action('help', 'Show 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', '
|
|
582
|
-
action('verify-work', 'Verify current work', '
|
|
583
|
-
action('capture', 'Capture a note', '
|
|
584
|
-
action('progress', 'Show 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', '
|
|
589
|
-
action('debug', 'Debug the failure', '
|
|
590
|
-
action('code-review', 'Review recent work', '
|
|
591
|
-
action('progress', 'Show 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', '
|
|
596
|
-
action('plan-phase', 'Plan phase 1', '
|
|
597
|
-
action('quick', 'Quick task', '
|
|
598
|
-
action('progress', 'Show 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})`, '
|
|
604
|
-
action('plan-phase', `Plan phase ${phaseN || 1}`, '
|
|
605
|
-
action('discuss-phase', 'Discuss before planning', '
|
|
606
|
-
action('progress', 'Show 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', '
|
|
616
|
-
action('execute-phase', execLabel, '
|
|
617
|
-
action('quick', 'Quick task', '
|
|
618
|
-
action('code-review', 'Review recent work', '
|
|
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)', '
|
|
624
|
-
action('verify-work', 'Verify work', '
|
|
625
|
-
action('code-review', 'Review recent work', '
|
|
626
|
-
action('ship', 'Ship completed work', '
|
|
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', '
|
|
631
|
-
action('complete-milestone', 'Complete the milestone', '
|
|
632
|
-
action('progress', 'Show progress', '
|
|
633
|
-
action('capture', 'Capture a note', '
|
|
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', '
|
|
638
|
-
action('extract-learnings', 'Extract learnings', '
|
|
639
|
-
action('quick', 'Quick task', '
|
|
640
|
-
action('progress', 'Show 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', '
|
|
646
|
-
action('progress-next', 'Advance to the next step', '
|
|
647
|
-
action('quick', 'Quick task', '
|
|
648
|
-
action('help', 'Show 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
|
|
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(
|
|
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 ?? '
|
|
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
|
}
|