@opengsd/gsd-core 1.8.0 → 1.9.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 -1
- package/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +186 -55
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +849 -2
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +5 -5
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +57 -5
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +128 -20
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +76 -19
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +57 -8
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- package/scripts/update-size-baseline.cjs +0 -68
|
@@ -268,6 +268,7 @@ const roadmap = require('./lib/roadmap.cjs');
|
|
|
268
268
|
const { detectAssumptionDelta } = require('./lib/assumption-delta.cjs');
|
|
269
269
|
const verify = require('./lib/verify.cjs');
|
|
270
270
|
const config = require('./lib/config.cjs');
|
|
271
|
+
const estimateCli = require('./lib/estimate-cli.cjs');
|
|
271
272
|
const template = require('./lib/template.cjs');
|
|
272
273
|
const milestone = require('./lib/milestone.cjs');
|
|
273
274
|
const commands = require('./lib/commands.cjs');
|
|
@@ -610,6 +611,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
|
|
610
611
|
let fastModeOverride;
|
|
611
612
|
let attempt;
|
|
612
613
|
let failureClass;
|
|
614
|
+
let host;
|
|
613
615
|
const positionals = [];
|
|
614
616
|
// #2296: the valid classes come from the classifier's own frozen enum, so
|
|
615
617
|
// this validator can never drift from what `agent classify-failure` emits.
|
|
@@ -675,6 +677,13 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
|
|
675
677
|
i++;
|
|
676
678
|
continue;
|
|
677
679
|
}
|
|
680
|
+
if (a === '--host') {
|
|
681
|
+
const val = execArgs[i + 1];
|
|
682
|
+
if (val === undefined || val.startsWith('--')) error('Missing value for --host', ERROR_REASON.USAGE);
|
|
683
|
+
host = val;
|
|
684
|
+
i++;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
678
687
|
if (a === '--raw') continue;
|
|
679
688
|
if (a.startsWith('-')) error(`Unknown flag for resolve-execution: ${a}`, ERROR_REASON.USAGE);
|
|
680
689
|
positionals.push(a);
|
|
@@ -687,6 +696,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
|
|
687
696
|
fastModeOverride,
|
|
688
697
|
attempt,
|
|
689
698
|
failureClass,
|
|
699
|
+
host,
|
|
690
700
|
});
|
|
691
701
|
}
|
|
692
702
|
|
|
@@ -1120,6 +1130,357 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
|
|
1120
1130
|
output({ ok: true, row: mutation.row, variant: mutation.variant }, raw, mutation.row);
|
|
1121
1131
|
}
|
|
1122
1132
|
|
|
1133
|
+
/**
|
|
1134
|
+
* ADR-2782 Phase 5b (#2799) — the seam that lets `invoke_reviewers` iterate declared lanes.
|
|
1135
|
+
*
|
|
1136
|
+
* Replaces ~640 lines of hand-authored per-CLI bash with three subcommands:
|
|
1137
|
+
* plan --selected a,b → resolved lanes (slug, section, availability) as JSON
|
|
1138
|
+
* invoke --slug X → probe + run one lane, writing its review/stub into the run dir
|
|
1139
|
+
* sections --selected a,b → ordered `slug<TAB>reviewsSection`, for write_reviews
|
|
1140
|
+
*
|
|
1141
|
+
* Lanes run SEQUENTIALLY (the workflow loops and calls `invoke` once per lane) because the
|
|
1142
|
+
* original legs did — parallel invocation trips provider rate limits.
|
|
1143
|
+
*/
|
|
1144
|
+
async function routeReviewLane({ args, cwd, raw, error }) {
|
|
1145
|
+
const cp = require('node:child_process');
|
|
1146
|
+
const fsx = require('node:fs');
|
|
1147
|
+
const os = require('node:os');
|
|
1148
|
+
const { REVIEWER_LANES } = require('./lib/review-lane-descriptor.cjs');
|
|
1149
|
+
const { resolveLanePlan } = require('./lib/review-lane-invocation.cjs');
|
|
1150
|
+
const runner = require('./lib/review-lane-runner.cjs');
|
|
1151
|
+
const cfgLoader = require('./lib/config-loader.cjs');
|
|
1152
|
+
|
|
1153
|
+
const flag = (name) => {
|
|
1154
|
+
const i = args.indexOf(name);
|
|
1155
|
+
return i !== -1 && args[i + 1] && !String(args[i + 1]).startsWith('--') ? args[i + 1] : null;
|
|
1156
|
+
};
|
|
1157
|
+
const sub = args[1];
|
|
1158
|
+
const runDir = flag('--run-dir') || '.';
|
|
1159
|
+
const repoRoot = flag('--repo-root') || cwd;
|
|
1160
|
+
|
|
1161
|
+
// Resolved config, read ONCE. Reading in-process (rather than shelling out to config-get per
|
|
1162
|
+
// key, as the legs did) is what removes the stringly `"null"` sentinel the bash had to test for.
|
|
1163
|
+
// `loadConfigResolved` returns a PROVENANCE WRAPPER (`{config, source, degraded, reason}`),
|
|
1164
|
+
// not the config — using the wrapper directly silently resolves every key to undefined, which
|
|
1165
|
+
// reads exactly like "nothing configured" and drops every model override without an error.
|
|
1166
|
+
let resolved = {};
|
|
1167
|
+
try { resolved = (cfgLoader.loadConfigResolved(cwd) || {}).config || {}; } catch { resolved = {}; }
|
|
1168
|
+
const configGet = (key) => {
|
|
1169
|
+
let cur = resolved;
|
|
1170
|
+
for (const part of String(key).split('.')) {
|
|
1171
|
+
if (cur === null || typeof cur !== 'object') return undefined;
|
|
1172
|
+
cur = Object.prototype.hasOwnProperty.call(cur, part) ? cur[part] : undefined;
|
|
1173
|
+
}
|
|
1174
|
+
return cur;
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
const selected = (flag('--selected') || '')
|
|
1178
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
1179
|
+
const laneBySlug = new Map(REVIEWER_LANES.map((l) => [l.slug, l]));
|
|
1180
|
+
const chosen = selected.length ? selected : REVIEWER_LANES.map((l) => l.slug);
|
|
1181
|
+
|
|
1182
|
+
if (sub === 'sections') {
|
|
1183
|
+
const rows = chosen
|
|
1184
|
+
.map((s) => laneBySlug.get(s))
|
|
1185
|
+
.filter(Boolean)
|
|
1186
|
+
.map((l) => `${l.slug}\t${l.reviewsSection}`);
|
|
1187
|
+
process.stdout.write(rows.join('\n') + (rows.length ? '\n' : ''));
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// Phase 6 (#2800, closes #2272). The reviewer-flag lists in
|
|
1192
|
+
// plan-review-convergence.md, autonomous.md and next.md were hand-enumerated in three places
|
|
1193
|
+
// and had drifted apart: `--coderabbit` was missing from all three and had been silently
|
|
1194
|
+
// falling back to `--codex`. One declared source, three consumers.
|
|
1195
|
+
//
|
|
1196
|
+
// Emits FLAGS, not slugs: `antigravity` declares two (`--antigravity`, `--agy`), so the flag
|
|
1197
|
+
// count (13) is deliberately not the lane count (12). Same output contract as `sections` —
|
|
1198
|
+
// one token per line, descriptor order, and no trailing newline on an empty result.
|
|
1199
|
+
if (sub === 'flags') {
|
|
1200
|
+
const rows = chosen
|
|
1201
|
+
.map((s) => laneBySlug.get(s))
|
|
1202
|
+
.filter(Boolean)
|
|
1203
|
+
.flatMap((l) => (Array.isArray(l.flags) ? l.flags : []))
|
|
1204
|
+
// Shape-filtered, not merely non-empty. All three consumers read this through an
|
|
1205
|
+
// UNQUOTED `$(gsd_run review-lane flags)` so the newline-separated output word-splits
|
|
1206
|
+
// into loop items — which is the intent. Phase 2 (#2795) admits third-party overlay
|
|
1207
|
+
// lanes into this same descriptor, so an overlay declaring `--foo bar` would inject a
|
|
1208
|
+
// second loop item, and one declaring a glob character would expand against the cwd.
|
|
1209
|
+
// Emitting only well-formed flags keeps that from reaching the shell at all.
|
|
1210
|
+
.filter((f) => typeof f === 'string' && /^--[a-z0-9][a-z0-9-]*$/.test(f));
|
|
1211
|
+
process.stdout.write(rows.join('\n') + (rows.length ? '\n' : ''));
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Effort argv is resolved per lane by the host's own execution policy, exactly as the legs did
|
|
1216
|
+
// via `resolve-execution … --pick effort_argv_string`. A lane whose slug is not a known host
|
|
1217
|
+
// simply gets none.
|
|
1218
|
+
// Resolved through the SAME `resolve-execution` surface the bash legs used
|
|
1219
|
+
// (`--host <slug> --pick effort_argv_string`), so the host's negotiated effortSurface still
|
|
1220
|
+
// decides whether an argument is emitted and the catalog still owns the syntax (ADR-1239 #2481,
|
|
1221
|
+
// ADR-443's escalation ladder). `cmdResolveExecution` writes to stdout and exits, so it cannot
|
|
1222
|
+
// be called in-process for a value — this spawns the same bounded query the legs did, once per
|
|
1223
|
+
// selected lane. A lane whose slug is not a known host resolves to no effort argument at all.
|
|
1224
|
+
const effortFor = (slug) => {
|
|
1225
|
+
try {
|
|
1226
|
+
const r = cp.spawnSync(
|
|
1227
|
+
process.execPath,
|
|
1228
|
+
[__filename, 'query', 'resolve-execution', 'gsd-plan-checker',
|
|
1229
|
+
// NOT `--raw`: that prints the resolved EFFORT ('low'), ignoring --pick. The picked
|
|
1230
|
+
// field is what carries the host-specific syntax ('--effort low' for claude,
|
|
1231
|
+
// '-c model_reasoning_effort=low' for codex), which is the whole point of asking.
|
|
1232
|
+
'--host', slug, '--pick', 'effort_argv_string'],
|
|
1233
|
+
{ cwd, encoding: 'utf8', timeout: 15000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 },
|
|
1234
|
+
);
|
|
1235
|
+
if (r.status !== 0) return [];
|
|
1236
|
+
const s = String(r.stdout || '').trim();
|
|
1237
|
+
return s ? s.split(/\s+/).filter(Boolean) : [];
|
|
1238
|
+
} catch { return []; }
|
|
1239
|
+
};
|
|
1240
|
+
|
|
1241
|
+
/**
|
|
1242
|
+
* Per-lane prompt budget (#2797 semantics, preserved exactly).
|
|
1243
|
+
*
|
|
1244
|
+
* `-1` is the UNSET sentinel and falls back to the central `review.max_prompt_tokens`, because
|
|
1245
|
+
* `0` is a legitimate value meaning "do not trim this lane". Treating 0 as unset would silently
|
|
1246
|
+
* switch a user who deliberately disabled trimming onto the global budget.
|
|
1247
|
+
*
|
|
1248
|
+
* Only the budget VALUE is resolved here. Assembly and trimming stay in `prompt-budget`, which
|
|
1249
|
+
* already owns that machinery and is already tested; the workflow calls it and hands the
|
|
1250
|
+
* trimmed file back via `--prompt-file`. Re-implementing it inside the runner would fork a
|
|
1251
|
+
* tested surface for no gain.
|
|
1252
|
+
*/
|
|
1253
|
+
const budgetFor = (lane) => {
|
|
1254
|
+
if (!lane.promptBudgetKey) return null;
|
|
1255
|
+
const per = configGet(lane.promptBudgetKey);
|
|
1256
|
+
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
1257
|
+
if (isNum(per) && per !== -1) return per;
|
|
1258
|
+
const global = configGet('review.max_prompt_tokens');
|
|
1259
|
+
return isNum(global) ? global : null;
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
const plans = chosen.map((slug) => {
|
|
1263
|
+
const lane = laneBySlug.get(slug);
|
|
1264
|
+
if (!lane) return { slug, ok: false, reason: 'malformed_lane', detail: 'no such declared lane' };
|
|
1265
|
+
// Per-lane isolation. resolveLanePlan is documented total, but this map is the seam where a
|
|
1266
|
+
// single throw would take down EVERY selected lane rather than the one that is malformed —
|
|
1267
|
+
// and "a cross-AI review that silently drops a lane" is the failure this epic exists to end,
|
|
1268
|
+
// so losing all of them to one bad manifest is strictly worse. Belt and braces on purpose.
|
|
1269
|
+
let r;
|
|
1270
|
+
try {
|
|
1271
|
+
r = resolveLanePlan({ lane, configGet, runDir, repoRoot, effortArgs: effortFor(slug) });
|
|
1272
|
+
} catch (e) {
|
|
1273
|
+
return { slug, ok: false, reason: 'malformed_lane', detail: `resolver threw: ${e && e.message ? e.message : String(e)}` };
|
|
1274
|
+
}
|
|
1275
|
+
return r.ok
|
|
1276
|
+
? {
|
|
1277
|
+
slug,
|
|
1278
|
+
ok: true,
|
|
1279
|
+
section: lane.reviewsSection,
|
|
1280
|
+
transport: r.plan.transport,
|
|
1281
|
+
promptBudget: budgetFor(lane),
|
|
1282
|
+
promptPath: r.plan.transport === 'spawn' ? r.plan.stdin : r.plan.promptPath,
|
|
1283
|
+
plan: r.plan,
|
|
1284
|
+
}
|
|
1285
|
+
: { slug, ok: false, reason: r.reason, detail: r.detail };
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
if (sub === 'plan') {
|
|
1289
|
+
output(plans.map(({ plan, ...rest }) => rest), raw);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (sub !== 'invoke') {
|
|
1294
|
+
error("Usage: review-lane <plan|invoke|sections|flags> [--selected a,b] [--run-dir D] [--repo-root R]");
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const slug = flag('--slug');
|
|
1299
|
+
if (!slug) { error('review-lane invoke requires --slug'); return; }
|
|
1300
|
+
const entry = plans.find((p) => p.slug === slug);
|
|
1301
|
+
if (!entry || !entry.ok) {
|
|
1302
|
+
output({ slug, ok: false, reason: entry ? entry.reason : 'malformed_lane', detail: entry ? entry.detail : 'unknown lane' }, raw);
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// EVERY spawn bounded — `DEFECT.UNBOUNDED-SUBPROCESS` (CONTEXT.md:772). A frozen sync spawn
|
|
1307
|
+
// cannot be interrupted by --test-force-exit and hangs a whole CI chunk to its 10-minute kill.
|
|
1308
|
+
const deps = {
|
|
1309
|
+
spawn: (binary, argv, opts) => {
|
|
1310
|
+
const r = cp.spawnSync(binary, argv, {
|
|
1311
|
+
input: opts.input,
|
|
1312
|
+
encoding: 'utf8',
|
|
1313
|
+
timeout: opts.timeoutMs,
|
|
1314
|
+
killSignal: 'SIGKILL',
|
|
1315
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
1316
|
+
shell: false, // argv array only — never a shell string (no interpolation of config values)
|
|
1317
|
+
});
|
|
1318
|
+
return {
|
|
1319
|
+
status: r.status,
|
|
1320
|
+
stdout: r.stdout || '',
|
|
1321
|
+
stderr: r.stderr || '',
|
|
1322
|
+
errorCode: r.error && r.error.code ? r.error.code : undefined,
|
|
1323
|
+
};
|
|
1324
|
+
},
|
|
1325
|
+
httpJson: async (url, opts) => {
|
|
1326
|
+
try {
|
|
1327
|
+
const res = await fetch(url, {
|
|
1328
|
+
method: opts.method,
|
|
1329
|
+
headers: opts.body ? { 'Content-Type': 'application/json' } : undefined,
|
|
1330
|
+
body: opts.body,
|
|
1331
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
1332
|
+
});
|
|
1333
|
+
return { ok: res.ok, status: res.status, body: await res.text() };
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
return { ok: false, status: 0, body: '', error: e && e.message ? e.message : String(e) };
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
readFile: (p) => fsx.readFileSync(p, 'utf8'),
|
|
1339
|
+
writeFile: (p, c) => fsx.writeFileSync(p, c, 'utf8'),
|
|
1340
|
+
exists: (p) => fsx.existsSync(p),
|
|
1341
|
+
// PATH scan rather than spawning `command -v` / `where`. Two reasons: it spawns nothing at
|
|
1342
|
+
// all (a probe that costs a process is a probe you avoid running, which is how the original
|
|
1343
|
+
// Kimi probe ended up unbounded), and `shell: true` with an args array is deprecated in
|
|
1344
|
+
// Node 26 (DEP0190) because the arguments are concatenated rather than escaped.
|
|
1345
|
+
hasBinary: (name) => {
|
|
1346
|
+
if (!name || name.includes('/') || name.includes('\\')) {
|
|
1347
|
+
try { return fsx.statSync(name).isFile(); } catch { return false; }
|
|
1348
|
+
}
|
|
1349
|
+
const exts = process.platform === 'win32'
|
|
1350
|
+
? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
|
|
1351
|
+
: [''];
|
|
1352
|
+
for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
1353
|
+
for (const ext of exts) {
|
|
1354
|
+
const candidate = path.join(dir, name + ext);
|
|
1355
|
+
try {
|
|
1356
|
+
const st = fsx.statSync(candidate);
|
|
1357
|
+
if (st.isFile()) return true;
|
|
1358
|
+
} catch { /* next candidate */ }
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return false;
|
|
1362
|
+
},
|
|
1363
|
+
configGet,
|
|
1364
|
+
homeDir: os.homedir(),
|
|
1365
|
+
warn: (m) => process.stderr.write(`${m}\n`),
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
// ADR-1517 reviewer instances resolve THROUGH a lane rather than being lanes themselves
|
|
1369
|
+
// (ADR-2782 D8), so they reuse this seam with three substitutions instead of duplicating the
|
|
1370
|
+
// lane's invocation. Everything the lane declares — probe, channels, timeout, empty-output
|
|
1371
|
+
// policy, handler — then applies to the instance unchanged, which is the whole reason to route
|
|
1372
|
+
// them here: a cross-cutting fix reaches instances for free.
|
|
1373
|
+
const asIdentity = flag('--as');
|
|
1374
|
+
const instanceModel = flag('--model');
|
|
1375
|
+
const instanceAgent = flag('--agent');
|
|
1376
|
+
|
|
1377
|
+
if (asIdentity) {
|
|
1378
|
+
// Write under the INSTANCE name so two instances of one adapter never overwrite each other.
|
|
1379
|
+
const safe = String(asIdentity).replace(/[^A-Za-z0-9._-]/g, '-');
|
|
1380
|
+
entry.plan.reviewPath = `${runDir.replace(/\/+$/, '')}/gsd-review-${safe}.md`;
|
|
1381
|
+
entry.plan.errPath = `${runDir.replace(/\/+$/, '')}/gsd-review-${safe}.err`;
|
|
1382
|
+
if (entry.plan.transport === 'spawn' && entry.plan.outputTarget.kind === 'file') {
|
|
1383
|
+
// A file-arg lane names its output inside argv; retarget both together or the runner reads
|
|
1384
|
+
// the lane's file while the tool writes the instance's.
|
|
1385
|
+
const old = entry.plan.outputTarget.path;
|
|
1386
|
+
entry.plan.argv = entry.plan.argv.map((a) => (a === old ? entry.plan.reviewPath : a));
|
|
1387
|
+
entry.plan.outputTarget = { kind: 'file', path: entry.plan.reviewPath };
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// The instance's own model replaces whatever the lane resolved from config. Opaque
|
|
1392
|
+
// pass-through: it reaches the tool as one argv element and is never shell-interpolated.
|
|
1393
|
+
//
|
|
1394
|
+
// Done by RE-RESOLVING with an overridden config rather than by patching argv afterwards. A
|
|
1395
|
+
// post-hoc splice has to guess where the flag belongs when the lane resolved no model at all
|
|
1396
|
+
// (the placeholder expanded to nothing, so there is no position to find), and guessing puts it
|
|
1397
|
+
// before a subcommand — `opencode --model M run …`, the same defect the argv template exists to
|
|
1398
|
+
// prevent. Re-resolving lets the lane's own `{{model}}` placeholder decide the position.
|
|
1399
|
+
if (instanceModel) {
|
|
1400
|
+
const lane = laneBySlug.get(entry.slug);
|
|
1401
|
+
const key = lane && lane.modelConfigKey;
|
|
1402
|
+
// A lane that declares NO model key accepts no model override at all (`cursor`, `qwen`,
|
|
1403
|
+
// `coderabbit`). `review.reviewer_instances` validates that `cli` is a known slug but never
|
|
1404
|
+
// that the slug accepts a model, so a user can configure {"cli":"cursor","model":"gpt-5"},
|
|
1405
|
+
// get a clean config-set and a clean run, and never learn their model was ignored. Say so —
|
|
1406
|
+
// a review silently produced by a different model than the one configured is exactly the
|
|
1407
|
+
// "looked like it worked" failure this epic exists to end.
|
|
1408
|
+
if (!key) {
|
|
1409
|
+
process.stderr.write(
|
|
1410
|
+
`reviewer instance model '${instanceModel}' ignored: lane '${entry.slug}' accepts no ` +
|
|
1411
|
+
`model override (it declares no modelConfigKey). The review will use the CLI's own default.\n`,
|
|
1412
|
+
);
|
|
1413
|
+
}
|
|
1414
|
+
const overridden = resolveLanePlan({
|
|
1415
|
+
lane,
|
|
1416
|
+
configGet: (k) => (key && k === key ? instanceModel : configGet(k)),
|
|
1417
|
+
runDir,
|
|
1418
|
+
repoRoot,
|
|
1419
|
+
effortArgs: effortFor(entry.slug),
|
|
1420
|
+
});
|
|
1421
|
+
if (overridden.ok) {
|
|
1422
|
+
// Preserve any instance retargeting already applied above.
|
|
1423
|
+
const { reviewPath, errPath } = entry.plan;
|
|
1424
|
+
entry.plan = overridden.plan;
|
|
1425
|
+
if (asIdentity) {
|
|
1426
|
+
entry.plan.reviewPath = reviewPath;
|
|
1427
|
+
entry.plan.errPath = errPath;
|
|
1428
|
+
if (entry.plan.transport === 'spawn' && entry.plan.outputTarget.kind === 'file') {
|
|
1429
|
+
const old = entry.plan.outputTarget.path;
|
|
1430
|
+
entry.plan.argv = entry.plan.argv.map((a) => (a === old ? reviewPath : a));
|
|
1431
|
+
entry.plan.outputTarget = { kind: 'file', path: reviewPath };
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// `--agent` is OpenCode's native subagent flag and is honoured only by adapters that have the
|
|
1438
|
+
// concept; ignored elsewhere rather than passed to a tool that would reject it.
|
|
1439
|
+
if (instanceAgent && entry.slug === 'opencode' && entry.plan.transport === 'spawn') {
|
|
1440
|
+
const runIdx = entry.plan.argv.indexOf('run');
|
|
1441
|
+
const insertAt = runIdx === -1 ? 0 : runIdx + 1;
|
|
1442
|
+
entry.plan.argv = [
|
|
1443
|
+
...entry.plan.argv.slice(0, insertAt),
|
|
1444
|
+
'--agent',
|
|
1445
|
+
instanceAgent,
|
|
1446
|
+
...entry.plan.argv.slice(insertAt),
|
|
1447
|
+
];
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
// `--prompt-file` lets the caller substitute a budget-trimmed prompt. Applied to whichever
|
|
1451
|
+
// channel this lane actually reads from, so one flag serves both transports.
|
|
1452
|
+
const promptOverride = flag('--prompt-file');
|
|
1453
|
+
if (promptOverride) {
|
|
1454
|
+
if (entry.plan.transport === 'spawn') {
|
|
1455
|
+
if (entry.plan.stdin) entry.plan.stdin = promptOverride;
|
|
1456
|
+
} else {
|
|
1457
|
+
entry.plan.promptPath = promptOverride;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ADR-2782 D5 rule 4: re-resolve the consented destination at INVOCATION. `undefined` (no
|
|
1462
|
+
// consent record, or a record predating rule 1) means "nothing to compare" and ALLOWS — a
|
|
1463
|
+
// first-party lane ships inside the SHA-pinned distribution and is never consent-gated, so
|
|
1464
|
+
// denying on absence would break every existing local-model user.
|
|
1465
|
+
let consentedHost;
|
|
1466
|
+
if (entry.plan.transport === 'openai-http') {
|
|
1467
|
+
try {
|
|
1468
|
+
const consent = require('./lib/capability-consent.cjs');
|
|
1469
|
+
const projectRoot = require('./lib/project-root.cjs').consentProjectRoot(cwd);
|
|
1470
|
+
// The capability id is kebab; the lane slug may be snake (lm_studio / lm-studio).
|
|
1471
|
+
const capId = String(entry.slug).replace(/_/g, '-');
|
|
1472
|
+
consentedHost = consent.readConsentedReviewerHost({ projectRoot, id: capId });
|
|
1473
|
+
} catch { consentedHost = undefined; }
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
const result = await runner.runLane(entry.plan, deps, {
|
|
1477
|
+
consentedHost,
|
|
1478
|
+
explicitlyRequested: args.includes('--explicit'),
|
|
1479
|
+
repoRoot,
|
|
1480
|
+
});
|
|
1481
|
+
output(result, raw);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1123
1484
|
function routeNormalizeTestCommand({ args, cwd, raw, error }) {
|
|
1124
1485
|
// #1857: rewrite a resolved test command to a one-shot form so a
|
|
1125
1486
|
// watch-mode runner (vitest/jest) cannot hang a verification gate. Shared
|
|
@@ -1178,6 +1539,135 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
|
|
1178
1539
|
}
|
|
1179
1540
|
}
|
|
1180
1541
|
|
|
1542
|
+
function routeDispatchIsolation({ args, cwd, raw, error }) {
|
|
1543
|
+
// #2584 Phase 3 (#2627): typed query exposing the negotiated
|
|
1544
|
+
// `dispatch.isolation` to the execute-phase wave scheduler, so the
|
|
1545
|
+
// scheduler branches on a declared capability instead of on a runtime id.
|
|
1546
|
+
// Direct sibling of `dispatch-should-flatten` above, which exists (#1708 /
|
|
1547
|
+
// #853) for exactly this reason — it replaced a `RUNTIME === 'codex'`
|
|
1548
|
+
// prose rule.
|
|
1549
|
+
//
|
|
1550
|
+
// Resolves the current runtime (GSD_RUNTIME > config.runtime > 'claude'),
|
|
1551
|
+
// reads registry.runtimes[id].runtime.hostIntegration.dispatch.isolation,
|
|
1552
|
+
// and validates it against the closed vocabulary.
|
|
1553
|
+
//
|
|
1554
|
+
// Fail-closed: unknown runtime, missing/undeclared/out-of-vocabulary value,
|
|
1555
|
+
// or any thrown error yields `none` — sequential execution, never an
|
|
1556
|
+
// unsafe parallel path (ADR-1239, "Fail-closed").
|
|
1557
|
+
//
|
|
1558
|
+
// Output:
|
|
1559
|
+
// --raw → prints exactly harness-worktree | orchestrator-worktree | none
|
|
1560
|
+
// --json → prints { runtime, isolation, exec }
|
|
1561
|
+
// default → same as --raw
|
|
1562
|
+
//
|
|
1563
|
+
// `exec` is the resolved orchestratorExec argv/cwd shape, present only for
|
|
1564
|
+
// `orchestrator-worktree`. It requires `--cwd-target` (the GSD-created
|
|
1565
|
+
// worktree path) and optionally `--prompt`; without a target there is
|
|
1566
|
+
// nothing to bind, so `exec` is null.
|
|
1567
|
+
const VALID_ISOLATION = new Set(['harness-worktree', 'orchestrator-worktree', 'none']);
|
|
1568
|
+
let isolation = 'none';
|
|
1569
|
+
let runtimeId = null;
|
|
1570
|
+
let exec = null;
|
|
1571
|
+
let harnessFlag = null;
|
|
1572
|
+
try {
|
|
1573
|
+
const { resolveRuntime } = require('./lib/runtime-slash.cjs');
|
|
1574
|
+
runtimeId = resolveRuntime(cwd);
|
|
1575
|
+
|
|
1576
|
+
const registry = require('./lib/capability-registry.cjs');
|
|
1577
|
+
const runtimeEntry = registry.runtimes != null
|
|
1578
|
+
? registry.runtimes[runtimeId]
|
|
1579
|
+
: null;
|
|
1580
|
+
const declared = runtimeEntry?.runtime?.hostIntegration?.dispatch?.isolation ?? null;
|
|
1581
|
+
if (typeof declared === 'string' && VALID_ISOLATION.has(declared)) {
|
|
1582
|
+
isolation = declared;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
if (isolation === 'harness-worktree') {
|
|
1586
|
+
const declaredFlag = runtimeEntry?.runtime?.harnessIsolationFlag ?? null;
|
|
1587
|
+
// A host claiming harness isolation with no declared flag gives the
|
|
1588
|
+
// scheduler nothing to pass — degrade to sequential rather than
|
|
1589
|
+
// dispatch unisolated executors believing they are isolated.
|
|
1590
|
+
if (typeof declaredFlag === 'string' && declaredFlag.length > 0) {
|
|
1591
|
+
harnessFlag = declaredFlag;
|
|
1592
|
+
} else {
|
|
1593
|
+
isolation = 'none';
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
if (isolation === 'orchestrator-worktree') {
|
|
1598
|
+
const cwdIdx = args.indexOf('--cwd-target');
|
|
1599
|
+
const cwdTarget = cwdIdx !== -1 ? args[cwdIdx + 1] : '';
|
|
1600
|
+
if (cwdTarget) {
|
|
1601
|
+
const promptIdx = args.indexOf('--prompt');
|
|
1602
|
+
const promptArg = promptIdx !== -1 ? args[promptIdx + 1] : undefined;
|
|
1603
|
+
const hostIntegration = require('./lib/host-integration.cjs');
|
|
1604
|
+
const resolution = hostIntegration.resolveOrchestratorExec(
|
|
1605
|
+
runtimeEntry?.runtime?.orchestratorExec,
|
|
1606
|
+
cwdTarget,
|
|
1607
|
+
promptArg,
|
|
1608
|
+
);
|
|
1609
|
+
// A host declaring orchestrator-worktree whose exec descriptor does
|
|
1610
|
+
// not resolve cannot be spawned — degrade to sequential rather than
|
|
1611
|
+
// hand the scheduler an unusable command.
|
|
1612
|
+
if (resolution.ok) {
|
|
1613
|
+
exec = { command: resolution.command, args: resolution.args, cwd: resolution.cwd };
|
|
1614
|
+
} else {
|
|
1615
|
+
isolation = 'none';
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
} catch {
|
|
1620
|
+
isolation = 'none';
|
|
1621
|
+
exec = null;
|
|
1622
|
+
harnessFlag = null;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
if (args.indexOf('--json') !== -1) {
|
|
1626
|
+
output({ runtime: runtimeId, isolation, exec, harnessFlag }, raw);
|
|
1627
|
+
} else {
|
|
1628
|
+
process.stdout.write(isolation);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
function routeResolveDispatchType({ args, cwd, raw, error }) {
|
|
1633
|
+
// #2508 Phase 4 Option A: resolve a requested GSD subagent name to the
|
|
1634
|
+
// type an Agent() call should use on the current runtime. On
|
|
1635
|
+
// named-dispatch runtimes (Claude, OpenCode, …) the name is returned
|
|
1636
|
+
// unchanged; on built-in-only runtimes (kimi-code) it maps to the
|
|
1637
|
+
// closest built-in (coder/explore/plan) by role-suffix heuristic.
|
|
1638
|
+
// The persona rides ${AGENT_SKILLS_*} (Phase 3 / #2510) regardless.
|
|
1639
|
+
//
|
|
1640
|
+
// Output:
|
|
1641
|
+
// --raw (default) → prints the resolved type (e.g. "gsd-planner" or "plan")
|
|
1642
|
+
// --json → prints { runtime, requested, resolved, dispatch }
|
|
1643
|
+
try {
|
|
1644
|
+
const requestedIdx = args.indexOf('--requested');
|
|
1645
|
+
const requested = requestedIdx !== -1 ? args[requestedIdx + 1] : '';
|
|
1646
|
+
const { resolveRuntime } = require('./lib/runtime-slash.cjs');
|
|
1647
|
+
const runtimeId = resolveRuntime(cwd);
|
|
1648
|
+
const registry = require('./lib/capability-registry.cjs');
|
|
1649
|
+
const runtimeEntry = registry.runtimes != null
|
|
1650
|
+
? registry.runtimes[runtimeId]
|
|
1651
|
+
: null;
|
|
1652
|
+
const dispatch = runtimeEntry?.runtime?.hostIntegration?.dispatch ?? null;
|
|
1653
|
+
const hostIntegration = require('./lib/host-integration.cjs');
|
|
1654
|
+
const resolved = hostIntegration.resolveDispatchType(requested, dispatch);
|
|
1655
|
+
const jsonIdx = args.indexOf('--json');
|
|
1656
|
+
if (jsonIdx !== -1) {
|
|
1657
|
+
output({ runtime: runtimeId, requested, resolved, dispatch }, raw);
|
|
1658
|
+
} else {
|
|
1659
|
+
process.stdout.write(String(resolved));
|
|
1660
|
+
}
|
|
1661
|
+
} catch {
|
|
1662
|
+
// Fail-closed: on any error, echo the requested name unchanged
|
|
1663
|
+
// (named-dispatch is the GSD default; degrading to it preserves
|
|
1664
|
+
// behavior for every runtime already in the field).
|
|
1665
|
+
const requestedIdx = args.indexOf('--requested');
|
|
1666
|
+
const requested = requestedIdx !== -1 ? args[requestedIdx + 1] : '';
|
|
1667
|
+
process.stdout.write(String(requested));
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1181
1671
|
function routeAgentSkills({ args, cwd, raw, error }) {
|
|
1182
1672
|
// --json emits typed IR { agent_type, block, skills_count } for test assertions
|
|
1183
1673
|
// (#455). Default (no flag) outputs raw XML so workflow shell expansions work.
|
|
@@ -1457,8 +1947,10 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
|
|
1457
1947
|
require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2));
|
|
1458
1948
|
} else if (subcommand === 'set-baseref') {
|
|
1459
1949
|
require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
|
|
1950
|
+
} else if (subcommand === 'create') {
|
|
1951
|
+
worktreeSafety.cmdWorktreeCreate(cwd, args.slice(2));
|
|
1460
1952
|
} else {
|
|
1461
|
-
error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
1953
|
+
error('Unknown worktree subcommand. Available: cleanup-wave, record-agent, reap-orphans, base-check, set-baseref, create', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
1462
1954
|
}
|
|
1463
1955
|
}
|
|
1464
1956
|
|
|
@@ -1634,6 +2126,349 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
|
|
|
1634
2126
|
process.stdout.write(JSON.stringify(out, null, 2));
|
|
1635
2127
|
}
|
|
1636
2128
|
|
|
2129
|
+
// ─── restore-custom-files (#1854) ───────────────────────────────────────────
|
|
2130
|
+
// The counterpart to detect-custom-files. `backup_custom_files` copies
|
|
2131
|
+
// user-added files into <config-dir>/gsd-user-files-backup/ before the
|
|
2132
|
+
// clean-install wipe; until #1854 nothing ever read them back — the update
|
|
2133
|
+
// workflow just printed "Restore them after the update if needed" and moved
|
|
2134
|
+
// on. (`/gsd:update --reapply` covers the OTHER bucket: shipped files the
|
|
2135
|
+
// user MODIFIED, kept in gsd-local-patches/.)
|
|
2136
|
+
//
|
|
2137
|
+
// Two modes, both emitting the same JSON report:
|
|
2138
|
+
// plan (default) — walk the backup, run the compatibility pass, write nothing
|
|
2139
|
+
// --apply — same, then copy the eligible entries back
|
|
2140
|
+
//
|
|
2141
|
+
// Invariants: the backup is never deleted; a shipped file is never clobbered;
|
|
2142
|
+
// an existing differing file is never clobbered; one failed entry never
|
|
2143
|
+
// aborts the rest; nothing is written outside the config dir.
|
|
2144
|
+
const RESTORE_OUTCOME = Object.freeze({
|
|
2145
|
+
ELIGIBLE: 'eligible',
|
|
2146
|
+
RESTORED: 'restored',
|
|
2147
|
+
SKIPPED_DESTINATION_MANAGED: 'skipped_destination_managed',
|
|
2148
|
+
SKIPPED_DESTINATION_EXISTS: 'skipped_destination_exists',
|
|
2149
|
+
SKIPPED_COPY_FAILED: 'skipped_copy_failed',
|
|
2150
|
+
SKIPPED_UNSAFE_PATH: 'skipped_unsafe_path',
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
const RESTORE_WARNING = Object.freeze({
|
|
2154
|
+
DESTINATION_MANAGED: 'destination_managed',
|
|
2155
|
+
DESTINATION_EXISTS: 'destination_exists',
|
|
2156
|
+
MISSING_REFERENCED_PATH: 'missing_referenced_path',
|
|
2157
|
+
MISSING_REFERENCED_COMMAND: 'missing_referenced_command',
|
|
2158
|
+
FRONTMATTER_MISSING_FIELD: 'frontmatter_missing_field',
|
|
2159
|
+
WRITE_FAILED: 'write_failed',
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
// Compatibility scanning reads backed-up files whole. Cap the read so a
|
|
2163
|
+
// stray large artifact in the backup cannot balloon memory; oversized files
|
|
2164
|
+
// still restore, they just skip the (advisory) content scan.
|
|
2165
|
+
const RESTORE_SCAN_MAX_BYTES = 1024 * 1024;
|
|
2166
|
+
const RESTORE_BACKUP_DIR_NAME = 'gsd-user-files-backup';
|
|
2167
|
+
|
|
2168
|
+
// Referenced shipped paths (`@gsd-core/workflows/foo.md`) and slash commands
|
|
2169
|
+
// (`/gsd:plan-phase`) are the two references a custom skill most commonly
|
|
2170
|
+
// makes into GSD itself, and the two that a release most commonly renames.
|
|
2171
|
+
const RESTORE_GSD_PATH_RE = /gsd-core\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.(?:md|cjs|js|json|sh)/g;
|
|
2172
|
+
const RESTORE_SLASH_COMMAND_RE = /\/gsd:[a-z0-9][a-z0-9-]*/g;
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* Walk the backup tree, refusing to follow symlinks. Returns entries in
|
|
2176
|
+
* stable sorted order; `unsafe` marks a link we saw but will not traverse
|
|
2177
|
+
* or copy (reported for auditability rather than silently dropped).
|
|
2178
|
+
*/
|
|
2179
|
+
function collectBackupEntries(dir, baseDir, out) {
|
|
2180
|
+
let dirents;
|
|
2181
|
+
try {
|
|
2182
|
+
dirents = fs.readdirSync(dir, { withFileTypes: true });
|
|
2183
|
+
} catch {
|
|
2184
|
+
return out;
|
|
2185
|
+
}
|
|
2186
|
+
for (const entry of dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
|
|
2187
|
+
const fullPath = path.join(dir, entry.name);
|
|
2188
|
+
// Path separators are normalized unconditionally: backslash-shaped
|
|
2189
|
+
// relative paths reach Linux too (backups copied between machines).
|
|
2190
|
+
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
2191
|
+
if (entry.isSymbolicLink()) {
|
|
2192
|
+
out.push({ relPath, unsafe: true });
|
|
2193
|
+
continue;
|
|
2194
|
+
}
|
|
2195
|
+
if (entry.isDirectory()) {
|
|
2196
|
+
collectBackupEntries(fullPath, baseDir, out);
|
|
2197
|
+
continue;
|
|
2198
|
+
}
|
|
2199
|
+
if (!entry.isFile()) continue;
|
|
2200
|
+
out.push({ relPath, unsafe: false });
|
|
2201
|
+
}
|
|
2202
|
+
return out;
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
// Why these three checks rather than security.cjs's `validatePath`: that seam
|
|
2206
|
+
// resolves symlinks with realpathSync and then tests containment, so a link
|
|
2207
|
+
// whose target sits inside the config dir passes. For a restore that is still
|
|
2208
|
+
// wrong — writing through any link overwrites whatever it points at instead
|
|
2209
|
+
// of materializing a regular file at the backed-up path. These checks reject
|
|
2210
|
+
// links outright, which is strictly stricter than validatePath, not a
|
|
2211
|
+
// reimplementation of it. Do not "simplify" this to validatePath.
|
|
2212
|
+
|
|
2213
|
+
/** True when `target` resolves strictly inside `root`. */
|
|
2214
|
+
function isInsideDir(root, target) {
|
|
2215
|
+
const rel = path.relative(path.resolve(root), path.resolve(target));
|
|
2216
|
+
return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
/**
|
|
2220
|
+
* True when `target` itself exists and is a symlink. `fs.existsSync` and
|
|
2221
|
+
* `copyFileSync` both FOLLOW links, so a symlinked destination would let a
|
|
2222
|
+
* restore write through to the link's target — outside the config dir —
|
|
2223
|
+
* even though every ancestor is a real directory.
|
|
2224
|
+
*/
|
|
2225
|
+
function isSymlinkPath(target) {
|
|
2226
|
+
try {
|
|
2227
|
+
return fs.lstatSync(target).isSymbolicLink();
|
|
2228
|
+
} catch {
|
|
2229
|
+
return false;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* True when the deepest already-existing ancestor of `target` is a symlink.
|
|
2235
|
+
* A symlinked parent directory would let a copy land outside the config dir
|
|
2236
|
+
* even though the joined path looks contained.
|
|
2237
|
+
*/
|
|
2238
|
+
function hasSymlinkedAncestor(root, target) {
|
|
2239
|
+
let cursor = path.dirname(path.resolve(target));
|
|
2240
|
+
const stop = path.resolve(root);
|
|
2241
|
+
while (cursor.length >= stop.length && cursor.startsWith(stop)) {
|
|
2242
|
+
let st;
|
|
2243
|
+
try {
|
|
2244
|
+
st = fs.lstatSync(cursor);
|
|
2245
|
+
} catch {
|
|
2246
|
+
cursor = path.dirname(cursor);
|
|
2247
|
+
if (cursor === stop) return false;
|
|
2248
|
+
continue;
|
|
2249
|
+
}
|
|
2250
|
+
if (st.isSymbolicLink()) return true;
|
|
2251
|
+
if (cursor === stop) return false;
|
|
2252
|
+
cursor = path.dirname(cursor);
|
|
2253
|
+
}
|
|
2254
|
+
return false;
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
/**
|
|
2258
|
+
* Best-effort compatibility pass of one backed-up file against the NEWLY
|
|
2259
|
+
* installed release. Every finding is advisory — a warning never blocks a
|
|
2260
|
+
* restore, it just travels with the entry into the report.
|
|
2261
|
+
*/
|
|
2262
|
+
function scanRestoreCompatibility(srcPath, relPath, configDir) {
|
|
2263
|
+
const warnings = [];
|
|
2264
|
+
let size = 0;
|
|
2265
|
+
try {
|
|
2266
|
+
size = fs.statSync(srcPath).size;
|
|
2267
|
+
} catch {
|
|
2268
|
+
return warnings;
|
|
2269
|
+
}
|
|
2270
|
+
if (size > RESTORE_SCAN_MAX_BYTES) return warnings;
|
|
2271
|
+
|
|
2272
|
+
let content;
|
|
2273
|
+
try {
|
|
2274
|
+
content = fs.readFileSync(srcPath, 'utf8');
|
|
2275
|
+
} catch {
|
|
2276
|
+
return warnings;
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
const missingPaths = new Set();
|
|
2280
|
+
for (const match of content.match(RESTORE_GSD_PATH_RE) || []) {
|
|
2281
|
+
if (!fs.existsSync(path.join(configDir, match))) missingPaths.add(match);
|
|
2282
|
+
}
|
|
2283
|
+
for (const missing of missingPaths) {
|
|
2284
|
+
warnings.push({
|
|
2285
|
+
code: RESTORE_WARNING.MISSING_REFERENCED_PATH,
|
|
2286
|
+
detail: `references ${missing}, which the installed release does not ship`,
|
|
2287
|
+
});
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
const missingCommands = new Set();
|
|
2291
|
+
for (const match of content.match(RESTORE_SLASH_COMMAND_RE) || []) {
|
|
2292
|
+
const verb = match.slice('/gsd:'.length);
|
|
2293
|
+
if (!fs.existsSync(path.join(configDir, 'commands', 'gsd', `${verb}.md`))) {
|
|
2294
|
+
missingCommands.add(match);
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
for (const missing of missingCommands) {
|
|
2298
|
+
warnings.push({
|
|
2299
|
+
code: RESTORE_WARNING.MISSING_REFERENCED_COMMAND,
|
|
2300
|
+
detail: `references ${missing}, which the installed release does not provide`,
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// Skills and agents/commands are frontmatter-driven surfaces: a file the
|
|
2305
|
+
// runtime cannot parse is restored-but-dead, which is worth saying out loud.
|
|
2306
|
+
const base = relPath.split('/').pop();
|
|
2307
|
+
const isFrontmatterSurface = base === 'SKILL.md'
|
|
2308
|
+
|| relPath.startsWith('agents/')
|
|
2309
|
+
|| relPath.startsWith('commands/');
|
|
2310
|
+
if (isFrontmatterSurface) {
|
|
2311
|
+
const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
|
|
2312
|
+
const missingFields = [];
|
|
2313
|
+
if (!block) {
|
|
2314
|
+
missingFields.push('name', 'description');
|
|
2315
|
+
} else {
|
|
2316
|
+
if (!/^name:\s*\S/m.test(block[1])) missingFields.push('name');
|
|
2317
|
+
if (!/^description:\s*\S/m.test(block[1])) missingFields.push('description');
|
|
2318
|
+
}
|
|
2319
|
+
if (missingFields.length > 0) {
|
|
2320
|
+
warnings.push({
|
|
2321
|
+
code: RESTORE_WARNING.FRONTMATTER_MISSING_FIELD,
|
|
2322
|
+
detail: `frontmatter is missing required field(s): ${missingFields.join(', ')}`,
|
|
2323
|
+
});
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
return warnings;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
function routeRestoreCustomFiles({ args, error }) {
|
|
2331
|
+
// Last-wins so a duplicated flag resolves rather than erroring, matching
|
|
2332
|
+
// the rest of the gsd-tools flag surface.
|
|
2333
|
+
let configDir = null;
|
|
2334
|
+
for (let i = 0; i < args.length; i++) {
|
|
2335
|
+
if (args[i] !== '--config-dir') continue;
|
|
2336
|
+
configDir = args[i + 1] === undefined ? null : args[i + 1];
|
|
2337
|
+
}
|
|
2338
|
+
const apply = args.includes('--apply');
|
|
2339
|
+
|
|
2340
|
+
if (configDir === null || configDir.trim() === '' || configDir.startsWith('--')) {
|
|
2341
|
+
error('Usage: gsd-tools restore-custom-files --config-dir <path> [--apply]', ERROR_REASON.USAGE);
|
|
2342
|
+
}
|
|
2343
|
+
const resolvedConfigDir = path.resolve(configDir);
|
|
2344
|
+
if (!fs.existsSync(resolvedConfigDir)) {
|
|
2345
|
+
error(`Config directory not found: ${resolvedConfigDir}`, ERROR_REASON.USAGE);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// lstat, not stat: a symlinked backup root would let the walk read files
|
|
2349
|
+
// from anywhere on disk and present them as the user's own backup.
|
|
2350
|
+
const backupDir = path.join(resolvedConfigDir, RESTORE_BACKUP_DIR_NAME);
|
|
2351
|
+
let backupFound = false;
|
|
2352
|
+
try {
|
|
2353
|
+
backupFound = fs.lstatSync(backupDir).isDirectory();
|
|
2354
|
+
} catch {
|
|
2355
|
+
backupFound = false;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// The manifest describes what the NEW release ships. Without it the
|
|
2359
|
+
// destination-managed check has no source of truth — degrade to restoring
|
|
2360
|
+
// without that check rather than refusing to restore the user's own data.
|
|
2361
|
+
let manifestKeys = new Set();
|
|
2362
|
+
let manifestFound = false;
|
|
2363
|
+
const manifestPath = path.join(resolvedConfigDir, 'gsd-file-manifest.json');
|
|
2364
|
+
if (fs.existsSync(manifestPath)) {
|
|
2365
|
+
try {
|
|
2366
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
2367
|
+
// Shape, not just type (ADR-227): `files` must be a plain object for
|
|
2368
|
+
// its keys to mean "paths this release ships". An array or a scalar
|
|
2369
|
+
// yields numeric-index keys that silently match nothing, which would
|
|
2370
|
+
// report a usable manifest while the managed-path check is dead.
|
|
2371
|
+
const files = manifest && manifest.files;
|
|
2372
|
+
const isPlainObject = typeof files === 'object'
|
|
2373
|
+
&& files !== null
|
|
2374
|
+
&& !Array.isArray(files);
|
|
2375
|
+
if (isPlainObject) {
|
|
2376
|
+
manifestKeys = new Set(Object.keys(files));
|
|
2377
|
+
manifestFound = true;
|
|
2378
|
+
}
|
|
2379
|
+
} catch {
|
|
2380
|
+
manifestFound = false;
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
const entries = [];
|
|
2385
|
+
for (const found of backupFound ? collectBackupEntries(backupDir, backupDir, []) : []) {
|
|
2386
|
+
const { relPath } = found;
|
|
2387
|
+
const srcPath = path.join(backupDir, relPath);
|
|
2388
|
+
const destPath = path.join(resolvedConfigDir, relPath);
|
|
2389
|
+
|
|
2390
|
+
if (found.unsafe
|
|
2391
|
+
|| !isInsideDir(resolvedConfigDir, destPath)
|
|
2392
|
+
|| isSymlinkPath(destPath)
|
|
2393
|
+
|| hasSymlinkedAncestor(resolvedConfigDir, destPath)) {
|
|
2394
|
+
entries.push({
|
|
2395
|
+
path: relPath,
|
|
2396
|
+
outcome: RESTORE_OUTCOME.SKIPPED_UNSAFE_PATH,
|
|
2397
|
+
warnings: [],
|
|
2398
|
+
});
|
|
2399
|
+
continue;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
const warnings = scanRestoreCompatibility(srcPath, relPath, resolvedConfigDir);
|
|
2403
|
+
|
|
2404
|
+
if (manifestFound && manifestKeys.has(relPath)) {
|
|
2405
|
+
warnings.unshift({
|
|
2406
|
+
code: RESTORE_WARNING.DESTINATION_MANAGED,
|
|
2407
|
+
detail: 'the installed release now ships this path — restoring would overwrite it',
|
|
2408
|
+
});
|
|
2409
|
+
entries.push({ path: relPath, outcome: RESTORE_OUTCOME.SKIPPED_DESTINATION_MANAGED, warnings });
|
|
2410
|
+
continue;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
// An identical destination is a no-op restore, not a conflict: re-running
|
|
2414
|
+
// the restore after a successful one must stay quiet and idempotent.
|
|
2415
|
+
let destDiffers = false;
|
|
2416
|
+
if (fs.existsSync(destPath)) {
|
|
2417
|
+
try {
|
|
2418
|
+
destDiffers = !fs.readFileSync(destPath).equals(fs.readFileSync(srcPath));
|
|
2419
|
+
} catch {
|
|
2420
|
+
destDiffers = true;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
if (destDiffers) {
|
|
2424
|
+
warnings.unshift({
|
|
2425
|
+
code: RESTORE_WARNING.DESTINATION_EXISTS,
|
|
2426
|
+
detail: 'a different file already exists at this path — restoring would overwrite it',
|
|
2427
|
+
});
|
|
2428
|
+
entries.push({ path: relPath, outcome: RESTORE_OUTCOME.SKIPPED_DESTINATION_EXISTS, warnings });
|
|
2429
|
+
continue;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
if (!apply) {
|
|
2433
|
+
entries.push({ path: relPath, outcome: RESTORE_OUTCOME.ELIGIBLE, warnings });
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
try {
|
|
2438
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
2439
|
+
fs.copyFileSync(srcPath, destPath);
|
|
2440
|
+
entries.push({ path: relPath, outcome: RESTORE_OUTCOME.RESTORED, warnings });
|
|
2441
|
+
} catch (err) {
|
|
2442
|
+
const code = err && err.code ? String(err.code) : 'ERROR';
|
|
2443
|
+
entries.push({
|
|
2444
|
+
path: relPath,
|
|
2445
|
+
outcome: RESTORE_OUTCOME.SKIPPED_COPY_FAILED,
|
|
2446
|
+
warnings: warnings.concat([{
|
|
2447
|
+
code: RESTORE_WARNING.WRITE_FAILED,
|
|
2448
|
+
detail: `could not write the destination [${code}] — the backup copy is unchanged`,
|
|
2449
|
+
}]),
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
const restoredCount = entries.filter(e => e.outcome === RESTORE_OUTCOME.RESTORED).length;
|
|
2455
|
+
const eligibleCount = entries.filter(
|
|
2456
|
+
e => e.outcome === RESTORE_OUTCOME.ELIGIBLE || e.outcome === RESTORE_OUTCOME.RESTORED,
|
|
2457
|
+
).length;
|
|
2458
|
+
|
|
2459
|
+
process.stdout.write(JSON.stringify({
|
|
2460
|
+
backup_dir: backupDir,
|
|
2461
|
+
backup_found: backupFound,
|
|
2462
|
+
manifest_found: manifestFound,
|
|
2463
|
+
applied: apply,
|
|
2464
|
+
entries,
|
|
2465
|
+
eligible_count: eligibleCount,
|
|
2466
|
+
restored_count: restoredCount,
|
|
2467
|
+
skipped_count: entries.length - eligibleCount,
|
|
2468
|
+
warning_count: entries.reduce((sum, e) => sum + e.warnings.length, 0),
|
|
2469
|
+
}, null, 2));
|
|
2470
|
+
}
|
|
2471
|
+
|
|
1637
2472
|
function routeFromGsd2({ args, cwd, raw, error }) {
|
|
1638
2473
|
const gsd2Import = require('./lib/gsd2-import.cjs');
|
|
1639
2474
|
gsd2Import.cmdFromGsd2(args.slice(1), cwd, raw);
|
|
@@ -2042,6 +2877,11 @@ const HOST_COMMAND_ROUTERS = {
|
|
|
2042
2877
|
'config-set': routeConfigSet,
|
|
2043
2878
|
'config-set-model-profile': routeConfigSetModelProfile,
|
|
2044
2879
|
'config-get': routeConfigGet,
|
|
2880
|
+
// Phase-effort estimation (#2630, ADR-2629). A PAIR of verbs, so leaves
|
|
2881
|
+
// rather than a family — ADR-2346 promotes to a family only at >=3.
|
|
2882
|
+
'estimate-check': ({ args, cwd, raw }) => estimateCli.cmdEstimateCheck(cwd, args.slice(1), raw),
|
|
2883
|
+
'estimate-calibration': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibration(cwd, args.slice(1), raw),
|
|
2884
|
+
'estimate-calibrate': ({ args, cwd, raw }) => estimateCli.cmdEstimateCalibrate(cwd, args.slice(1), raw),
|
|
2045
2885
|
'config-new-project': routeConfigNewProject,
|
|
2046
2886
|
'config-path': routeConfigPath,
|
|
2047
2887
|
'migrate-config': routeMigrateConfig,
|
|
@@ -2071,6 +2911,8 @@ const HOST_COMMAND_ROUTERS = {
|
|
|
2071
2911
|
'quick-tasks-append': routeQuickTasksAppend,
|
|
2072
2912
|
'normalize-test-command': routeNormalizeTestCommand,
|
|
2073
2913
|
'dispatch-should-flatten': routeDispatchShouldFlatten,
|
|
2914
|
+
'dispatch-isolation': routeDispatchIsolation,
|
|
2915
|
+
'resolve-dispatch-type': routeResolveDispatchType,
|
|
2074
2916
|
'agent-skills': routeAgentSkills,
|
|
2075
2917
|
'skill-manifest': routeSkillManifest,
|
|
2076
2918
|
'history-digest': routeHistoryDigest,
|
|
@@ -2095,8 +2937,10 @@ const HOST_COMMAND_ROUTERS = {
|
|
|
2095
2937
|
'learnings': routeLearnings,
|
|
2096
2938
|
'teams-status': routeTeamsStatus,
|
|
2097
2939
|
'detect-custom-files': routeDetectCustomFiles,
|
|
2940
|
+
'restore-custom-files': routeRestoreCustomFiles,
|
|
2098
2941
|
'from-gsd2': routeFromGsd2,
|
|
2099
2942
|
'prompt-budget': routePromptBudget,
|
|
2943
|
+
'review-lane': routeReviewLane,
|
|
2100
2944
|
'update-context': routeUpdateContext,
|
|
2101
2945
|
'classify-confidence': routeClassifyConfidence,
|
|
2102
2946
|
'package-legitimacy': routePackageLegitimacy,
|
|
@@ -2418,7 +3262,7 @@ async function main() {
|
|
|
2418
3262
|
'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' +
|
|
2419
3263
|
'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' +
|
|
2420
3264
|
'capability, classify-confidence, git, learnings, list-seeds, list-todos, loop, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' +
|
|
2421
|
-
'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, smart-entry, state, ' +
|
|
3265
|
+
'profile-sample, progress, project-instruction-file, prompt-budget, quick-tasks-append, requirements, research-plan, research-store, resolve-granularity, resolve-model, restore-custom-files, roadmap, scaffold, smart-entry, state, ' +
|
|
2422
3266
|
'task, template, user-story, validate, verify, verify-path-exists, verify-summary, eval, workstream, worktree\n\n' +
|
|
2423
3267
|
'Global flags:\n' +
|
|
2424
3268
|
' --raw Emit raw output without post-processing\n' +
|
|
@@ -2466,6 +3310,9 @@ async function main() {
|
|
|
2466
3310
|
const SKIP_ROOT_RESOLUTION = new Set([
|
|
2467
3311
|
'generate-slug', 'current-timestamp', 'verify-path-exists',
|
|
2468
3312
|
'verify-summary', 'template', 'frontmatter', 'detect-custom-files',
|
|
3313
|
+
// #1854: restore-custom-files operates on a runtime config dir passed
|
|
3314
|
+
// explicitly via --config-dir; it never reads .planning/.
|
|
3315
|
+
'restore-custom-files',
|
|
2469
3316
|
'worktree', 'prompt-budget',
|
|
2470
3317
|
'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence',
|
|
2471
3318
|
'user-story', // pure string validation — no .planning/ access needed
|