@opengsd/gsd-core 1.6.0 → 1.7.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-verifier.md +1 -0
- package/bin/gsd-mcp-server.js +31 -0
- package/bin/install.js +293 -1145
- package/commands/gsd/review.md +6 -0
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +116 -1
- package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
- package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
- package/gsd-core/bin/lib/capability-loader.cjs +18 -0
- package/gsd-core/bin/lib/capability-lock.cjs +2 -2
- package/gsd-core/bin/lib/capability-registry.cjs +889 -82
- package/gsd-core/bin/lib/capability-source.cjs +4 -4
- package/gsd-core/bin/lib/capability-validator.cjs +198 -0
- package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/config.cjs +27 -0
- package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
- package/gsd-core/bin/lib/hook-bus.cjs +81 -0
- package/gsd-core/bin/lib/host-integration.cjs +408 -0
- package/gsd-core/bin/lib/init.cjs +1 -1
- package/gsd-core/bin/lib/install-engine.cjs +755 -0
- package/gsd-core/bin/lib/install-profiles.cjs +35 -4
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/mcp-server.cjs +194 -0
- package/gsd-core/bin/lib/milestone.cjs +27 -30
- package/gsd-core/bin/lib/model-adapter.cjs +50 -0
- package/gsd-core/bin/lib/phase.cjs +41 -72
- package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
- package/gsd-core/bin/lib/probe-core.cjs +91 -1
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
- package/gsd-core/bin/lib/roadmap.cjs +17 -3
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +37 -1
- package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
- package/gsd-core/bin/lib/state-command-router.cjs +4 -0
- package/gsd-core/bin/lib/state-io.cjs +55 -0
- package/gsd-core/bin/lib/state-transition.cjs +1588 -0
- package/gsd-core/bin/lib/state.cjs +306 -681
- package/gsd-core/bin/lib/surface.cjs +4 -1
- package/gsd-core/bin/lib/workstream.cjs +4 -4
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
- package/gsd-core/references/honest-verifier.md +105 -0
- package/gsd-core/references/reviewer-instances.md +99 -0
- package/gsd-core/workflows/autonomous.md +9 -9
- package/gsd-core/workflows/manager.md +15 -15
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/review.md +26 -0
- package/gsd-core/workflows/thread.md +4 -4
- package/gsd-core/workflows/verify-phase.md +11 -4
- package/hooks/dist/gsd-graphify-update.sh +7 -1
- package/hooks/gsd-graphify-update.sh +7 -1
- package/package.json +4 -4
- package/scripts/ci-test-scope.cjs +38 -9
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-regression-test-names.allowlist.json +3 -0
- package/scripts/lint-test-file-count.allowlist.json +19 -5
- package/scripts/mutation-matrix.cjs +45 -3
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/lint-windows-test-portability.cjs +0 -178
|
@@ -19,6 +19,10 @@ exports.canonicalizeRuntimeName = canonicalizeRuntimeName;
|
|
|
19
19
|
exports.resolveRuntimeNameFromCandidates = resolveRuntimeNameFromCandidates;
|
|
20
20
|
exports.getProjectInstructionFile = getProjectInstructionFile;
|
|
21
21
|
exports.getDirName = getDirName;
|
|
22
|
+
exports.getRuntimeLabel = getRuntimeLabel;
|
|
23
|
+
exports.getGlobalConfigHomeFragment = getGlobalConfigHomeFragment;
|
|
24
|
+
exports.runtimeFlags = runtimeFlags;
|
|
25
|
+
exports.getRuntimeNewProjectCommand = getRuntimeNewProjectCommand;
|
|
22
26
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
23
27
|
const node_path_1 = __importDefault(require("node:path"));
|
|
24
28
|
const FALLBACK_ALIASES = {
|
|
@@ -152,35 +156,161 @@ function getProjectInstructionFile(runtime) {
|
|
|
152
156
|
* `bin/install.js` re-exports this same function for back-compat.
|
|
153
157
|
*/
|
|
154
158
|
function getDirName(runtime) {
|
|
155
|
-
if (runtime
|
|
156
|
-
return '.
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return '.kilo';
|
|
163
|
-
if (runtime === 'codex')
|
|
164
|
-
return '.codex';
|
|
165
|
-
if (runtime === 'antigravity')
|
|
166
|
-
return '.agents';
|
|
167
|
-
if (runtime === 'cursor')
|
|
168
|
-
return '.cursor';
|
|
169
|
-
if (runtime === 'windsurf')
|
|
170
|
-
return '.windsurf';
|
|
171
|
-
if (runtime === 'augment')
|
|
172
|
-
return '.augment';
|
|
173
|
-
if (runtime === 'trae')
|
|
174
|
-
return '.trae';
|
|
175
|
-
if (runtime === 'qwen')
|
|
176
|
-
return '.qwen';
|
|
177
|
-
if (runtime === 'hermes')
|
|
178
|
-
return '.hermes';
|
|
179
|
-
if (runtime === 'kimi')
|
|
180
|
-
return '.kimi-code';
|
|
181
|
-
if (runtime === 'codebuddy')
|
|
182
|
-
return '.codebuddy';
|
|
183
|
-
if (runtime === 'cline')
|
|
184
|
-
return '.cline';
|
|
159
|
+
if (!runtime)
|
|
160
|
+
return '.claude';
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
162
|
+
const { runtimes } = require('./capability-registry.cjs');
|
|
163
|
+
const dir = runtimes[runtime]?.runtime?.localConfigDir;
|
|
164
|
+
if (typeof dir === 'string' && dir.length > 0)
|
|
165
|
+
return dir;
|
|
185
166
|
return '.claude';
|
|
186
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Curated short display labels for the install/uninstall console output, keyed
|
|
170
|
+
* by canonical runtime id. The SINGLE source of truth consumed by both
|
|
171
|
+
* `install()` and `uninstall()` in bin/install.js via `getRuntimeLabel`.
|
|
172
|
+
*
|
|
173
|
+
* Collapses the two duplicated `runtimeLabel` assignment chains that previously
|
|
174
|
+
* lived inline in bin/install.js (ADR-1239 Phase B, #1679) — the add-a-host tax:
|
|
175
|
+
* a new runtime meant remembering to add a label line in BOTH chains, and they
|
|
176
|
+
* had drifted out of sync (uninstall omitted `cline` and used a different
|
|
177
|
+
* `kimi` value than install). This table is the curated canonical resolution:
|
|
178
|
+
* - kimi: install 'Kimi' / uninstall 'Kimi CLI' → 'Kimi CLI' (majority + descriptor title)
|
|
179
|
+
* - cline: install 'Cline' / uninstall (omitted) → 'Cline' (majority + descriptor title)
|
|
180
|
+
*
|
|
181
|
+
* Voice: these are the SHORT UI labels, intentionally distinct from the
|
|
182
|
+
* descriptor `title` (the long product name — e.g. "OpenAI Codex CLI",
|
|
183
|
+
* "GitHub Copilot", "Gemini CLI") which serves documentation/registry display,
|
|
184
|
+
* not the install console. A future slice may relocate this to a
|
|
185
|
+
* `runtime.label` descriptor field; until then this table is the source.
|
|
186
|
+
*
|
|
187
|
+
* Lookup is RAW-ID only (no alias expansion) — callers pass an already-
|
|
188
|
+
* canonicalized runtime id, keeping the label surface explicit. Unknown/empty
|
|
189
|
+
* ids fall back to 'Claude Code' (the always-safe default, fail-closed).
|
|
190
|
+
*
|
|
191
|
+
* The drift-guard test (tests/runtime-label-policy.test.cjs) pins this table's
|
|
192
|
+
* id set to the capability-registry runtime id set, so adding/removing a runtime
|
|
193
|
+
* forces a deliberate update here.
|
|
194
|
+
*/
|
|
195
|
+
const RUNTIME_LABELS = {
|
|
196
|
+
claude: 'Claude Code',
|
|
197
|
+
opencode: 'OpenCode',
|
|
198
|
+
gemini: 'Gemini',
|
|
199
|
+
kilo: 'Kilo',
|
|
200
|
+
codex: 'Codex',
|
|
201
|
+
copilot: 'Copilot',
|
|
202
|
+
antigravity: 'Antigravity',
|
|
203
|
+
cursor: 'Cursor',
|
|
204
|
+
windsurf: 'Windsurf',
|
|
205
|
+
augment: 'Augment',
|
|
206
|
+
trae: 'Trae',
|
|
207
|
+
qwen: 'Qwen Code',
|
|
208
|
+
hermes: 'Hermes Agent',
|
|
209
|
+
kimi: 'Kimi CLI',
|
|
210
|
+
codebuddy: 'CodeBuddy',
|
|
211
|
+
cline: 'Cline',
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Map a canonical runtime id to its short display label for the
|
|
215
|
+
* install/uninstall console output. Unknown/empty inputs fall back to
|
|
216
|
+
* 'Claude Code'. Sibling to `getDirName`; pure (no I/O).
|
|
217
|
+
*/
|
|
218
|
+
function getRuntimeLabel(runtime) {
|
|
219
|
+
if (!runtime)
|
|
220
|
+
return 'Claude Code';
|
|
221
|
+
const label = RUNTIME_LABELS[runtime];
|
|
222
|
+
return typeof label === 'string' && label.length > 0 ? label : 'Claude Code';
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Source-string fragments for the runtime → global config-home path, used by
|
|
226
|
+
* `getConfigDirFromHome` in bin/install.js to template `path.join()` calls in
|
|
227
|
+
* generated hook scripts. Each value is a JS-source snippet (embedded quotes /
|
|
228
|
+
* commas are intentional — it is spliced into generated code as path.join args).
|
|
229
|
+
*
|
|
230
|
+
* Collapses the prior 14-branch `if (runtime === 'x') return "'...'"` chain in
|
|
231
|
+
* bin/install.js (ADR-1239 Phase B / #1679, AC2 slice 2) — the add-a-host tax:
|
|
232
|
+
* a new runtime meant remembering to add a branch here. Values are preserved
|
|
233
|
+
* BYTE-FOR-BYTE from the prior chain; golden install parity asserts generated
|
|
234
|
+
* hook output is unchanged across all 16 runtimes.
|
|
235
|
+
*
|
|
236
|
+
* Two runtimes are intentionally absent (handled by the caller, NOT this table):
|
|
237
|
+
* - `claude` → the default; falls through to `DEFAULT_FRAGMENT`.
|
|
238
|
+
* - `antigravity`→ resolved dynamically via resolveAntigravityGlobalDir +
|
|
239
|
+
* path.relative (multi-segment, env-overridable).
|
|
240
|
+
*
|
|
241
|
+
* Unknown/empty ids fall back to the default (`.claude`).
|
|
242
|
+
*/
|
|
243
|
+
const DEFAULT_CONFIG_HOME_FRAGMENT = "'.claude'";
|
|
244
|
+
const GLOBAL_CONFIG_HOME_FRAGMENTS = {
|
|
245
|
+
copilot: "'.copilot'",
|
|
246
|
+
opencode: "'.config', 'opencode'",
|
|
247
|
+
gemini: "'.gemini'",
|
|
248
|
+
kilo: "'.config', 'kilo'",
|
|
249
|
+
codex: "'.codex'",
|
|
250
|
+
cursor: "'.cursor'",
|
|
251
|
+
windsurf: "'.windsurf'",
|
|
252
|
+
augment: "'.augment'",
|
|
253
|
+
trae: "'.trae'",
|
|
254
|
+
qwen: "'.qwen'",
|
|
255
|
+
hermes: "'.hermes'",
|
|
256
|
+
codebuddy: "'.codebuddy'",
|
|
257
|
+
cline: "'.cline'",
|
|
258
|
+
kimi: "'.config', 'agents'",
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* Return the global config-home path-fragment source snippet for a runtime
|
|
262
|
+
* (for hook path.join() codegen). `claude`/unknown/empty → the default
|
|
263
|
+
* `'.claude'` fragment. `antigravity` is NOT handled here (caller resolves it
|
|
264
|
+
* dynamically). Pure: no I/O. Sibling to `getDirName` / `getRuntimeLabel`.
|
|
265
|
+
*/
|
|
266
|
+
function getGlobalConfigHomeFragment(runtime) {
|
|
267
|
+
if (!runtime)
|
|
268
|
+
return DEFAULT_CONFIG_HOME_FRAGMENT;
|
|
269
|
+
const frag = GLOBAL_CONFIG_HOME_FRAGMENTS[runtime];
|
|
270
|
+
return typeof frag === 'string' && frag.length > 0 ? frag : DEFAULT_CONFIG_HOME_FRAGMENT;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* The runtime ids for which `bin/install.js` needs an `is<Runtime>` boolean
|
|
274
|
+
* predicate (every installed host that takes a non-claude install branch).
|
|
275
|
+
* Single source of truth — adding a runtime is one entry here, not a per-
|
|
276
|
+
* function declaration block (the add-a-host tax ADR-1239 Phase B / #1679 AC2
|
|
277
|
+
* removes).
|
|
278
|
+
*/
|
|
279
|
+
const RUNTIME_FLAG_IDS = Object.freeze([
|
|
280
|
+
'opencode', 'kilo', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor',
|
|
281
|
+
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline', 'kimi',
|
|
282
|
+
]);
|
|
283
|
+
/**
|
|
284
|
+
* Return a frozen map of `is<Runtime>` boolean predicates for the given runtime
|
|
285
|
+
* id (e.g. `flags.isOpencode`). Collapses the four duplicated `const isX =
|
|
286
|
+
* runtime === 'x'` declaration blocks that lived in `bin/install.js`'s
|
|
287
|
+
* `uninstall`/`writeManifest`/`install`/etc. into one helper (sibling to
|
|
288
|
+
* `getDirName`/`getRuntimeLabel`). Pure: no I/O.
|
|
289
|
+
*/
|
|
290
|
+
function runtimeFlags(runtime) {
|
|
291
|
+
const flags = {};
|
|
292
|
+
for (const id of RUNTIME_FLAG_IDS) {
|
|
293
|
+
flags['is' + id.charAt(0).toUpperCase() + id.slice(1)] = runtime === id;
|
|
294
|
+
}
|
|
295
|
+
return Object.freeze(flags);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The `/gsd-new-project` invocation syntax per runtime — the post-install
|
|
299
|
+
* "next step" command string. Most runtimes use the default `/gsd-new-project`;
|
|
300
|
+
* a few hosts need a different surface syntax. Collapses the 14-line
|
|
301
|
+
* `if (runtime === 'x') command = ...` chain in bin/install.js's next-step
|
|
302
|
+
* message (ADR-1239 Phase B / #1679 AC2). Pure: no I/O.
|
|
303
|
+
*/
|
|
304
|
+
const DEFAULT_NEW_PROJECT_COMMAND = '/gsd-new-project';
|
|
305
|
+
const RUNTIME_NEW_PROJECT_COMMANDS = {
|
|
306
|
+
gemini: '/gsd:new-project',
|
|
307
|
+
codex: '$gsd-new-project',
|
|
308
|
+
cursor: 'gsd-new-project (mention the skill name)',
|
|
309
|
+
kimi: '/skill:gsd-new-project',
|
|
310
|
+
};
|
|
311
|
+
function getRuntimeNewProjectCommand(runtime) {
|
|
312
|
+
if (!runtime)
|
|
313
|
+
return DEFAULT_NEW_PROJECT_COMMAND;
|
|
314
|
+
const c = RUNTIME_NEW_PROJECT_COMMANDS[runtime];
|
|
315
|
+
return typeof c === 'string' && c.length > 0 ? c : DEFAULT_NEW_PROJECT_COMMAND;
|
|
316
|
+
}
|
|
@@ -40,6 +40,7 @@ exports.execNpm = execNpm;
|
|
|
40
40
|
exports.execTool = execTool;
|
|
41
41
|
exports.probeTty = probeTty;
|
|
42
42
|
exports.normalizeContent = normalizeContent;
|
|
43
|
+
exports.retryRenameSync = retryRenameSync;
|
|
43
44
|
exports.platformWriteSync = platformWriteSync;
|
|
44
45
|
exports.platformReadSync = platformReadSync;
|
|
45
46
|
exports.platformEnsureDir = platformEnsureDir;
|
|
@@ -259,6 +260,14 @@ function isManagedHookCommand(commandText, opts = {}) {
|
|
|
259
260
|
}
|
|
260
261
|
return false;
|
|
261
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* Detect a `"$VAR"/rest` anchored hook-script token — a path whose leading
|
|
265
|
+
* shell variable is already double-quoted with the remainder left bare (the
|
|
266
|
+
* shape `projectLocalHookPrefix` emits for local installs, e.g.
|
|
267
|
+
* `"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-x.js`). Such a token is ALREADY a
|
|
268
|
+
* valid, correctly-quoted shell argument and must never be re-quoted.
|
|
269
|
+
*/
|
|
270
|
+
const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
|
|
262
271
|
/**
|
|
263
272
|
* Projection helper for legacy settings.json hook rewrites.
|
|
264
273
|
*
|
|
@@ -270,8 +279,20 @@ function projectLegacySettingsHookCommand({ absoluteRunner, scriptPath, scriptTo
|
|
|
270
279
|
if (!absoluteRunner || !scriptPath)
|
|
271
280
|
return null;
|
|
272
281
|
const normalizedScriptPath = platform === 'win32' ? scriptPath.replace(/\\/g, '/') : scriptPath;
|
|
282
|
+
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
|
|
283
|
+
// quoted prefix (local installs) is already a valid shell token — only the
|
|
284
|
+
// variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
|
|
285
|
+
// yields `"\"$CLAUDE_PROJECT_DIR\"/..."` (escaped quotes inside an outer
|
|
286
|
+
// quote); node then receives an argument that *starts* with a `"`, treats it
|
|
287
|
+
// as relative, and dies with MODULE_NOT_FOUND. Emit anchored tokens verbatim;
|
|
288
|
+
// only bare absolute paths (which may contain spaces, e.g. "Program Files")
|
|
289
|
+
// need the JSON.stringify quoting. Scoped to win32: the non-Windows branch
|
|
290
|
+
// already preserves the caller's `scriptToken` (which is the bare anchored
|
|
291
|
+
// token for these inputs), so it never had the double-quote bug.
|
|
273
292
|
const commandScriptToken = platform === 'win32'
|
|
274
|
-
?
|
|
293
|
+
? (ANCHORED_HOOK_SCRIPT_TOKEN.test(normalizedScriptPath)
|
|
294
|
+
? normalizedScriptPath
|
|
295
|
+
: JSON.stringify(normalizedScriptPath))
|
|
275
296
|
: (scriptToken || JSON.stringify(normalizedScriptPath));
|
|
276
297
|
return projectShellCommandText({
|
|
277
298
|
runnerToken: absoluteRunner,
|
|
@@ -555,6 +576,21 @@ function atomicRenameWithRetry(tmpPath, filePath) {
|
|
|
555
576
|
}
|
|
556
577
|
return renameErr;
|
|
557
578
|
}
|
|
579
|
+
/**
|
|
580
|
+
* Drop-in replacement for `fs.renameSync(from, to)` that retries the transient
|
|
581
|
+
* Windows lock errnos (EPERM/EBUSY/EACCES — see DEFECT.WINDOWS-FS-OPS) a bounded
|
|
582
|
+
* number of times with a short backoff before rethrowing the final error.
|
|
583
|
+
*
|
|
584
|
+
* Idempotent on POSIX (the transient errnos do not occur), so callers retain
|
|
585
|
+
* identical semantics on macOS/Linux while gaining resilience on Windows where
|
|
586
|
+
* an antivirus scanner, indexer, or concurrent reader may briefly hold the
|
|
587
|
+
* target open. Enforced by local/require-fs-op-fallback (ADR-1703 Phase 6).
|
|
588
|
+
*/
|
|
589
|
+
function retryRenameSync(fromPath, toPath) {
|
|
590
|
+
const err = atomicRenameWithRetry(fromPath, toPath);
|
|
591
|
+
if (err !== null)
|
|
592
|
+
throw err;
|
|
593
|
+
}
|
|
558
594
|
function platformWriteSync(filePath, content, opts = {}) {
|
|
559
595
|
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
|
|
560
596
|
node_fs_1.default.mkdirSync(node_path_1.default.dirname(filePath), { recursive: true });
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stale-bake guard for static-frontmatter runtimes (#1688, follow-up to #1650).
|
|
5
|
+
*
|
|
6
|
+
* Runtimes `codex` and `opencode` bake the resolved model ID into each agent's
|
|
7
|
+
* static config at install time (bin/install.js ~5667-5767 for codex,
|
|
8
|
+
* ~10008-10026 for opencode). Their task/spawn_agent interfaces do not accept
|
|
9
|
+
* an inline `model` parameter, so editing `model_overrides` in
|
|
10
|
+
* `.planning/config.json` or `~/.gsd/defaults.json` has NO effect until the
|
|
11
|
+
* user re-runs `gsd install <runtime>` (or `gsd update`). The failure is
|
|
12
|
+
* silent — the sub-agent just uses the prior base model. This module detects
|
|
13
|
+
* that staleness at workflow entry and emits a single stderr warning.
|
|
14
|
+
*
|
|
15
|
+
* Design: pure decision + formatter (testable, no I/O) backed by fs probes
|
|
16
|
+
* that swallow every error (the guard must never break the CLI). Dedup'd per
|
|
17
|
+
* (runtime, cwd) within a process so a single `gsd-tools init *` invocation
|
|
18
|
+
* warns at most once even though multiple agents resolve models underneath.
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const os = require('os');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runtimes whose agent config is static frontmatter/TOML baked at install time.
|
|
26
|
+
* MUST stay in sync with the bake paths in bin/install.js. The parity test in
|
|
27
|
+
* tests/stale-bake-guard.test.cjs asserts this matches the runtimes that
|
|
28
|
+
* actually emit a baked model: line id #2256 (opencode) and #49/#2256 (codex).
|
|
29
|
+
*/
|
|
30
|
+
const STATIC_FRONTMATTER_RUNTIMES = Object.freeze(['codex', 'opencode']);
|
|
31
|
+
|
|
32
|
+
const _warnedKeys = new Set();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pure: decide whether a stale-bake condition exists.
|
|
36
|
+
*
|
|
37
|
+
* Returns `{ stale: true, deltaMs }` when `configMtimeMs` is strictly newer
|
|
38
|
+
* than `agentMtimeMs` on a static-frontmatter runtime. Returns `null` when the
|
|
39
|
+
* guard does not apply (claude / other spawn-time runtime, missing or
|
|
40
|
+
* non-finite mtimes, or agents already at least as new as config).
|
|
41
|
+
*/
|
|
42
|
+
function detectStaleBake({ runtime, configMtimeMs, agentMtimeMs }) {
|
|
43
|
+
if (!runtime || !STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return null;
|
|
44
|
+
if (typeof configMtimeMs !== 'number' || typeof agentMtimeMs !== 'number') return null;
|
|
45
|
+
if (!Number.isFinite(configMtimeMs) || !Number.isFinite(agentMtimeMs)) return null;
|
|
46
|
+
if (configMtimeMs <= agentMtimeMs) return null;
|
|
47
|
+
return { stale: true, deltaMs: configMtimeMs - agentMtimeMs };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pure: format the warning string. Returns `''` when no warning is warranted
|
|
52
|
+
* (delegates to detectStaleBake so the decision and the message cannot drift).
|
|
53
|
+
*/
|
|
54
|
+
function formatStaleBakeWarning({ runtime, configPath, configMtimeMs, agentMtimeMs }) {
|
|
55
|
+
const signal = detectStaleBake({ runtime, configMtimeMs, agentMtimeMs });
|
|
56
|
+
if (!signal) return '';
|
|
57
|
+
const configDate = new Date(configMtimeMs).toISOString();
|
|
58
|
+
const installFlag = runtime === 'opencode' ? '--opencode' : '--codex';
|
|
59
|
+
return [
|
|
60
|
+
`gsd: model config in ${configPath} changed since agents were last baked (${configDate}).`,
|
|
61
|
+
` Static-frontmatter runtime '${runtime}' ignores the new model_overrides`,
|
|
62
|
+
` until you re-run: gsd install ${installFlag}`,
|
|
63
|
+
` (or 'gsd update')`,
|
|
64
|
+
].join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Pure: resolve the active runtime id from a parsed config object.
|
|
69
|
+
* Returns the runtime string, or `'claude'` when unset (the spawn-time default
|
|
70
|
+
* for which the guard is a no-op).
|
|
71
|
+
*/
|
|
72
|
+
function resolveRuntimeFromConfig(config) {
|
|
73
|
+
if (config && typeof config === 'object'
|
|
74
|
+
&& typeof config.runtime === 'string' && config.runtime) {
|
|
75
|
+
return config.runtime;
|
|
76
|
+
}
|
|
77
|
+
return 'claude';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve the install root for a runtime's agent files, honoring the same env
|
|
82
|
+
* vars the installer does (CODEX_HOME, OPENCODE_CONFIG_DIR). Returns the
|
|
83
|
+
* absolute directory or `null` for unsupported runtimes.
|
|
84
|
+
*/
|
|
85
|
+
function resolveAgentDir(runtime, { env = process.env, homedir = os.homedir } = {}) {
|
|
86
|
+
if (runtime === 'opencode') {
|
|
87
|
+
const base = (env.OPENCODE_CONFIG_DIR && String(env.OPENCODE_CONFIG_DIR).trim()) || path.join(homedir(), '.config', 'opencode');
|
|
88
|
+
return path.join(base, 'agent');
|
|
89
|
+
}
|
|
90
|
+
if (runtime === 'codex') {
|
|
91
|
+
const base = (env.CODEX_HOME && String(env.CODEX_HOME).trim()) || path.join(homedir(), '.codex');
|
|
92
|
+
return path.join(base, 'agents');
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find the newest mtime across config sources that exist. Mirrors the
|
|
99
|
+
* up-to-8-levels-up walk in readGsdEffectiveModelOverrides (bin/install.js)
|
|
100
|
+
* and includes the global ~/.gsd/defaults.json. Returns
|
|
101
|
+
* `{ mtimeMs, path }` of the newest existing config, or `null` if none exist.
|
|
102
|
+
*
|
|
103
|
+
* `homedir` is injectable so tests can point the global lookup at a fixture
|
|
104
|
+
* dir (otherwise the real ~/.gsd/defaults.json on the CI runner leaks in and
|
|
105
|
+
* skews the newest-config calculation — see warnIfStaleBake orchestrator).
|
|
106
|
+
*/
|
|
107
|
+
function findNewestConfigMtime(cwd, { fsStatSync = fs.statSync, homedir = os.homedir } = {}) {
|
|
108
|
+
const candidates = [];
|
|
109
|
+
let probe = path.resolve(cwd || '.');
|
|
110
|
+
for (let i = 0; i < 8; i += 1) {
|
|
111
|
+
candidates.push(path.join(probe, '.planning', 'config.json'));
|
|
112
|
+
const parent = path.dirname(probe);
|
|
113
|
+
if (parent === probe) break;
|
|
114
|
+
probe = parent;
|
|
115
|
+
}
|
|
116
|
+
candidates.push(path.join(homedir(), '.gsd', 'defaults.json'));
|
|
117
|
+
|
|
118
|
+
let newest = null;
|
|
119
|
+
for (const p of candidates) {
|
|
120
|
+
try {
|
|
121
|
+
const st = fsStatSync(p);
|
|
122
|
+
if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
|
123
|
+
&& (!newest || st.mtimeMs > newest.mtimeMs)) {
|
|
124
|
+
newest = { mtimeMs: st.mtimeMs, path: p };
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// not present / unreadable — skip
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return newest;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Find the oldest mtime across installed gsd-* agent files for the runtime.
|
|
135
|
+
* Returns `{ mtimeMs, dir }` or `null` if the agent dir is absent or holds no
|
|
136
|
+
* gsd-* files (e.g. not yet installed, or uninstalled).
|
|
137
|
+
*/
|
|
138
|
+
function findOldestAgentMtime(runtime, { env = process.env, homedir = os.homedir, fsStatSync = fs.statSync, fsReaddirSync = fs.readdirSync } = {}) {
|
|
139
|
+
const dir = resolveAgentDir(runtime, { env, homedir });
|
|
140
|
+
if (!dir) return null;
|
|
141
|
+
let entries;
|
|
142
|
+
try {
|
|
143
|
+
entries = fsReaddirSync(dir, { withFileTypes: true });
|
|
144
|
+
} catch {
|
|
145
|
+
return null; // dir missing — runtime not installed for this user
|
|
146
|
+
}
|
|
147
|
+
let oldest = null;
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (!entry.isFile()) continue;
|
|
150
|
+
if (!entry.name.startsWith('gsd-')) continue;
|
|
151
|
+
const isAgentFile = (runtime === 'opencode' && entry.name.endsWith('.md'))
|
|
152
|
+
|| (runtime === 'codex' && (entry.name.endsWith('.toml') || entry.name.endsWith('.md')));
|
|
153
|
+
if (!isAgentFile) continue;
|
|
154
|
+
try {
|
|
155
|
+
const st = fsStatSync(path.join(dir, entry.name));
|
|
156
|
+
if (st && typeof st.mtimeMs === 'number' && Number.isFinite(st.mtimeMs)
|
|
157
|
+
&& (!oldest || st.mtimeMs < oldest.mtimeMs)) {
|
|
158
|
+
oldest = { mtimeMs: st.mtimeMs, dir };
|
|
159
|
+
}
|
|
160
|
+
} catch {
|
|
161
|
+
// unreadable — skip
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return oldest;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Orchestrator (side-effecting): probe fs, decide, write warning to stderr.
|
|
169
|
+
*
|
|
170
|
+
* - Silent on claude / other spawn-time runtimes (returns false).
|
|
171
|
+
* - Silent when agents are already at least as new as config.
|
|
172
|
+
* - Silent when config or agent dir is absent (nothing to compare).
|
|
173
|
+
* - Dedup'd per (runtime, cwd): a single process warns at most once per pair,
|
|
174
|
+
* so repeated `resolveModelInternal` calls under one `gsd-tools init *` do
|
|
175
|
+
* not repeat the warning.
|
|
176
|
+
* - Swallows every error: a warning helper must never break the CLI.
|
|
177
|
+
*
|
|
178
|
+
* Pass `config` to skip the internal JSON read (caller already loaded it).
|
|
179
|
+
* Returns `true` if a warning was written, `false` otherwise.
|
|
180
|
+
*/
|
|
181
|
+
function warnIfStaleBake(cwd, options = {}) {
|
|
182
|
+
const {
|
|
183
|
+
stderr = process.stderr,
|
|
184
|
+
config = null,
|
|
185
|
+
env = process.env,
|
|
186
|
+
homedir = os.homedir,
|
|
187
|
+
fsStatSync = fs.statSync,
|
|
188
|
+
fsReaddirSync = fs.readdirSync,
|
|
189
|
+
} = options;
|
|
190
|
+
try {
|
|
191
|
+
const resolvedConfig = config || _readRuntimeConfig(cwd, { fsStatSync });
|
|
192
|
+
const runtime = resolveRuntimeFromConfig(resolvedConfig);
|
|
193
|
+
if (!STATIC_FRONTMATTER_RUNTIMES.includes(runtime)) return false;
|
|
194
|
+
|
|
195
|
+
const dedupKey = `${runtime}::${path.resolve(cwd || '.')}`;
|
|
196
|
+
if (_warnedKeys.has(dedupKey)) return false;
|
|
197
|
+
|
|
198
|
+
const newest = findNewestConfigMtime(cwd, { fsStatSync, homedir });
|
|
199
|
+
const oldest = findOldestAgentMtime(runtime, { env, homedir, fsStatSync, fsReaddirSync });
|
|
200
|
+
if (!newest || !oldest) return false;
|
|
201
|
+
|
|
202
|
+
const warning = formatStaleBakeWarning({
|
|
203
|
+
runtime,
|
|
204
|
+
configPath: newest.path,
|
|
205
|
+
configMtimeMs: newest.mtimeMs,
|
|
206
|
+
agentMtimeMs: oldest.mtimeMs,
|
|
207
|
+
});
|
|
208
|
+
if (!warning) return false;
|
|
209
|
+
|
|
210
|
+
stderr.write(warning + '\n');
|
|
211
|
+
_warnedKeys.add(dedupKey);
|
|
212
|
+
return true;
|
|
213
|
+
} catch {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Best-effort minimal read of `.planning/config.json` for the `runtime` key. */
|
|
219
|
+
function _readRuntimeConfig(cwd, { fsStatSync = fs.statSync } = {}) {
|
|
220
|
+
let probe = path.resolve(cwd || '.');
|
|
221
|
+
for (let i = 0; i < 8; i += 1) {
|
|
222
|
+
const candidate = path.join(probe, '.planning', 'config.json');
|
|
223
|
+
try {
|
|
224
|
+
fsStatSync(candidate);
|
|
225
|
+
const raw = fs.readFileSync(candidate, 'utf8');
|
|
226
|
+
const parsed = JSON.parse(raw);
|
|
227
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
228
|
+
return {};
|
|
229
|
+
} catch {
|
|
230
|
+
// not present / unreadable / malformed — walk up
|
|
231
|
+
}
|
|
232
|
+
const parent = path.dirname(probe);
|
|
233
|
+
if (parent === probe) break;
|
|
234
|
+
probe = parent;
|
|
235
|
+
}
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Test-only: reset the in-process dedup set between cases. */
|
|
240
|
+
function _resetWarnedForTests() {
|
|
241
|
+
_warnedKeys.clear();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
STATIC_FRONTMATTER_RUNTIMES,
|
|
246
|
+
detectStaleBake,
|
|
247
|
+
formatStaleBakeWarning,
|
|
248
|
+
resolveRuntimeFromConfig,
|
|
249
|
+
resolveAgentDir,
|
|
250
|
+
findNewestConfigMtime,
|
|
251
|
+
findOldestAgentMtime,
|
|
252
|
+
warnIfStaleBake,
|
|
253
|
+
_resetWarnedForTests,
|
|
254
|
+
};
|
|
@@ -155,6 +155,10 @@ function routeStateCommand({ state, args, cwd, raw, error }) {
|
|
|
155
155
|
const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, ['keep-recent'], ['dry-run']);
|
|
156
156
|
state.cmdStatePrune(cwd, { keepRecent: strArg(a, 'keep-recent') || '3', dryRun: a['dry-run'] === true }, raw);
|
|
157
157
|
},
|
|
158
|
+
rebuild: () => {
|
|
159
|
+
const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, [], ['dry-run', 'verbose']);
|
|
160
|
+
state.cmdStateRebuild(cwd, { dryRun: a['dry-run'] === true, verbose: a['verbose'] === true }, raw);
|
|
161
|
+
},
|
|
158
162
|
// complete-phase: CJS-only — no SDK counterpart.
|
|
159
163
|
'complete-phase': () => {
|
|
160
164
|
const a = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, ['phase']);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State IO seam (ADR-1239 Phase C-1, AC4 / #1680).
|
|
3
|
+
*
|
|
4
|
+
* Abstracts `.planning/` + config IO behind the negotiated `stateIO` axis
|
|
5
|
+
* (host-integration.cts):
|
|
6
|
+
*
|
|
7
|
+
* - `filesystem` — most hosts: reads/writes under `.planning/` +
|
|
8
|
+
* `configHome`. TODAY's behavior. Delegates to fs.
|
|
9
|
+
* - `sandboxed-storage` — VS Code web (no arbitrary FS). Seam: a
|
|
10
|
+
* host-supplied backend; fail-closed until Phase 5.
|
|
11
|
+
* - `session-log-append` — pi (JSONL session log). Seam: host-supplied
|
|
12
|
+
* backend; fail-closed until Phase 5.
|
|
13
|
+
*
|
|
14
|
+
* `filesystem` is the default and reproduces today's IO byte-for-behavior
|
|
15
|
+
* (planning-workspace.cts keeps routing its fs ops; this seam is the
|
|
16
|
+
* abstraction a non-filesystem host swaps in). `configHome` write-confinement
|
|
17
|
+
* (ADR-1239 Phase B / #1679) applies to the filesystem path.
|
|
18
|
+
*
|
|
19
|
+
* Minimal seam: the host-backend protocol is fixed when a real non-filesystem
|
|
20
|
+
* host lands (Phase 5 / #1682).
|
|
21
|
+
*/
|
|
22
|
+
'use strict';
|
|
23
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
24
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.createStateIO = createStateIO;
|
|
28
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
29
|
+
function createStateIO({ io }, options = {}) {
|
|
30
|
+
if (io !== 'filesystem' && io !== 'sandboxed-storage' && io !== 'session-log-append') {
|
|
31
|
+
throw new TypeError(`createStateIO: io must be 'filesystem' | 'sandboxed-storage' | 'session-log-append' (got ${JSON.stringify(io)})`);
|
|
32
|
+
}
|
|
33
|
+
if (io === 'filesystem') {
|
|
34
|
+
// Today's behavior — straight fs. planning-workspace.cts keeps its routing;
|
|
35
|
+
// this is the swap-point a non-filesystem host replaces.
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
io: 'filesystem',
|
|
38
|
+
read(path) { return node_fs_1.default.readFileSync(path, 'utf-8'); },
|
|
39
|
+
write(path, content) { node_fs_1.default.writeFileSync(path, content, 'utf-8'); },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// sandboxed-storage / session-log-append: host backend, fail-closed until bound.
|
|
43
|
+
const backend = options.backend;
|
|
44
|
+
const unbound = () => {
|
|
45
|
+
throw new Error(`${io} stateIO: no host backend bound — non-filesystem state requires a backend (Phase 5 wires the concrete host).`);
|
|
46
|
+
};
|
|
47
|
+
if (!backend || typeof backend.read !== 'function' || typeof backend.write !== 'function') {
|
|
48
|
+
return Object.freeze({ io, read: unbound, write: unbound });
|
|
49
|
+
}
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
io,
|
|
52
|
+
read(path) { return backend.read(path); },
|
|
53
|
+
write(path, content) { backend.write(path, content); },
|
|
54
|
+
});
|
|
55
|
+
}
|