@holmes-lab/holmes-kit 0.12.1 → 0.13.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/CHANGELOG.md +104 -0
- package/README.md +13 -4
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-context.js +10 -10
- package/dist/holmes/cli/approve-ref.js +5 -5
- package/dist/holmes/cli/approve-watch.d.ts +1 -1
- package/dist/holmes/cli/approve-watch.js +6 -6
- package/dist/holmes/cli/approve.d.ts +3 -3
- package/dist/holmes/cli/approve.js +57 -57
- package/dist/holmes/cli/autonomy.d.ts +22 -0
- package/dist/holmes/cli/autonomy.js +145 -0
- package/dist/holmes/cli/colophon.d.ts +6 -0
- package/dist/holmes/cli/colophon.js +24 -0
- package/dist/holmes/cli/doctor.d.ts +2 -2
- package/dist/holmes/cli/doctor.js +104 -87
- package/dist/holmes/cli/index.js +122 -63
- package/dist/holmes/cli/init.d.ts +2 -0
- package/dist/holmes/cli/init.js +31 -19
- package/dist/holmes/cli/interactive-prompt.d.ts +8 -0
- package/dist/holmes/cli/interactive-prompt.js +23 -0
- package/dist/holmes/cli/semantic-key.js +9 -9
- package/dist/holmes/cli/settings-merge.d.ts +2 -1
- package/dist/holmes/cli/settings-merge.js +15 -3
- package/dist/holmes/cli/upgrade.js +7 -7
- package/dist/holmes/cpg/proposed-content.js +2 -2
- package/dist/holmes/governance/autonomy.d.ts +9 -2
- package/dist/holmes/governance/autonomy.js +166 -5
- package/dist/holmes/guardrail/blind-spots.js +15 -15
- package/dist/holmes/hooks/pre-tool-use.js +111 -42
- package/dist/holmes/hooks/session-start.js +74 -34
- package/dist/holmes/hooks/stop.d.ts +1 -1
- package/dist/holmes/hooks/stop.js +12 -12
- package/dist/holmes/mcp/handlers.js +14 -1
- package/dist/holmes/mcp/server.js +19 -0
- package/dist/holmes/semantic/credentials.js +1 -1
- package/dist/holmes/spec/id-collision.js +2 -2
- package/dist/holmes/update/refresh.d.ts +49 -0
- package/dist/holmes/update/refresh.js +106 -0
- package/package.json +2 -2
- package/playbooks/publish/PLAYBOOK.md +47 -35
- package/playbooks/remediation/PLAYBOOK.md +1 -1
package/dist/holmes/cli/init.js
CHANGED
|
@@ -148,8 +148,8 @@ function writeFileSafe(file, content) {
|
|
|
148
148
|
prev = fs.readFileSync(real, 'utf8');
|
|
149
149
|
}
|
|
150
150
|
catch (e) {
|
|
151
|
-
// §17:
|
|
152
|
-
throw new Error(
|
|
151
|
+
// §17: do not overwrite a file we could not read — the backup would be 0 bytes and the user's settings would be lost.
|
|
152
|
+
throw new Error(`could not read ${file}, so it was not overwritten: ${e instanceof Error ? e.message : String(e)}`);
|
|
153
153
|
}
|
|
154
154
|
if (prev !== content) {
|
|
155
155
|
const stamp = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -158,7 +158,7 @@ function writeFileSafe(file, content) {
|
|
|
158
158
|
fs.writeFileSync(bak, prev, { flag: 'wx' });
|
|
159
159
|
madeBackup = bak;
|
|
160
160
|
}
|
|
161
|
-
catch { /*
|
|
161
|
+
catch { /* if the backup cannot be made, leave it — the write below is atomic */ }
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
// Same discipline the playbook writer earned in round-5/6: unique suffix + 'wx' (a predictable
|
|
@@ -184,7 +184,7 @@ function writeFileSafe(file, content) {
|
|
|
184
184
|
renamed = true;
|
|
185
185
|
}
|
|
186
186
|
catch (e) {
|
|
187
|
-
throw new Error(
|
|
187
|
+
throw new Error(`could not write ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
188
188
|
}
|
|
189
189
|
finally {
|
|
190
190
|
if (!renamed) {
|
|
@@ -222,7 +222,7 @@ function runInit(opts) {
|
|
|
222
222
|
// @implements A-SPEC-190 §16 (round 12) — this named a `prepare` script package.json does not
|
|
223
223
|
// declare (only build/mcp/release/test/typecheck exist), so the remedy sent readers to inspect
|
|
224
224
|
// npm settings for a hook that was never there. dist/ ships because `files` lists it.
|
|
225
|
-
'
|
|
225
|
+
'This package ships a compiled dist/ — if you are working from the repository run `npm run build`, and if from an install, reinstall.',
|
|
226
226
|
] };
|
|
227
227
|
}
|
|
228
228
|
const s = readJson(settingsPath);
|
|
@@ -253,11 +253,23 @@ function runInit(opts) {
|
|
|
253
253
|
// governance — the exact master-key behaviour A-SPEC-133's narrowing exists to end.
|
|
254
254
|
return { ok: false, exitCode: 2, changes, messages: [
|
|
255
255
|
opts.remove
|
|
256
|
-
? '
|
|
256
|
+
? 'Stripping governance wiring is also changing the wiring — an out-of-band approval is required (self-disarm prevention).'
|
|
257
257
|
: '--force changes existing governance wiring and requires an out-of-band approval that covers it.',
|
|
258
258
|
`Set HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why>"}' (scoped approvals need kind "governance-wiring") and re-run.`,
|
|
259
259
|
] };
|
|
260
260
|
}
|
|
261
|
+
// @implements A-SPEC-552.1 — ENABLING autonomous approval is a self-disarm-shaped act: it lets the
|
|
262
|
+
// agent self-seal low-risk specs. Turning it ON therefore needs a human. The interactive raw-TTY
|
|
263
|
+
// path proves one answered (`autonomyInteractive`), so it needs no token; the non-interactive
|
|
264
|
+
// `--autonomy` flag (what an agent's Bash would reach for) requires an out-of-band HOLMES_APPROVAL
|
|
265
|
+
// that covers kind `autonomy-grant`. Turning it OFF or leaving it unspecified is always free.
|
|
266
|
+
if (opts.autonomy === true && !opts.autonomyInteractive
|
|
267
|
+
&& !(0, risk_gate_1.approvalCovers)(opts.approval, { kind: 'autonomy-grant', target: opts.target }, new Date().toISOString())) {
|
|
268
|
+
return { ok: false, exitCode: 2, changes, messages: [
|
|
269
|
+
'Enabling autonomous spec approval needs a human: answer the interactive prompt at a terminal, or supply an out-of-band approval.',
|
|
270
|
+
`Set HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why>"}' (scoped approvals need kind "autonomy-grant") and re-run with --autonomy.`,
|
|
271
|
+
] };
|
|
272
|
+
}
|
|
261
273
|
const m = readJson(mcpPath);
|
|
262
274
|
if (m.parseError) {
|
|
263
275
|
return { ok: false, exitCode: 2, changes, messages: [`${mcpPath} is not valid JSON — refusing to touch it.`] };
|
|
@@ -318,13 +330,13 @@ function runInit(opts) {
|
|
|
318
330
|
// operator has no other way to confirm the thing they switched on is still on.
|
|
319
331
|
const prevEnv = m.value
|
|
320
332
|
?.mcpServers?.[exports.SERVER_NAME]?.env ?? {};
|
|
321
|
-
const kept = Object.keys(prevEnv).filter((k) => !settings_merge_2.HOLMES_OWNED_MCP_ENV.includes(k));
|
|
333
|
+
const kept = Object.keys(prevEnv).filter((k) => !settings_merge_2.HOLMES_OWNED_MCP_ENV.includes(k) && k !== settings_merge_2.AUTONOMY_MCP_ENV);
|
|
322
334
|
if (kept.length > 0)
|
|
323
335
|
messages.push(`Preserved your MCP server env: ${kept.join(', ')}`);
|
|
324
336
|
// @implements A-SPEC-251.1 — compute the launch entry once (npx-pin for installs, node for source),
|
|
325
337
|
// then merge; env preservation is unchanged.
|
|
326
338
|
const mcpEntry = (0, mcp_launcher_1.mcpEntryForInstall)({ packageRoot: opts.packageRoot, mcpBinPath: mcpBin, flag: opts.mcpLauncher });
|
|
327
|
-
changes.push({ path: mcpPath, before: m.raw, after: JSON.stringify((0, settings_merge_1.mergeMcpServers)(m.value, exports.SERVER_NAME, mcpEntry, opts.specsDir), null, 2) + '\n' });
|
|
339
|
+
changes.push({ path: mcpPath, before: m.raw, after: JSON.stringify((0, settings_merge_1.mergeMcpServers)(m.value, exports.SERVER_NAME, mcpEntry, opts.specsDir, opts.autonomy), null, 2) + '\n' });
|
|
328
340
|
}
|
|
329
341
|
changes.push({ path: settingsPath, before: s.raw, after: JSON.stringify(settings, null, 2) + '\n' });
|
|
330
342
|
const gitignoreAfter = (0, gitignore_merge_1.mergeGitignore)(gitignoreBefore);
|
|
@@ -432,7 +444,7 @@ function runInit(opts) {
|
|
|
432
444
|
messages.push(`Would remove ${planned.length} recovery skill(s): ${planned.map((p) => p.state === 'orphaned' ? p.name : (0, playbook_skills_1.invocableSkillName)(p.name)).join(', ')}`);
|
|
433
445
|
}
|
|
434
446
|
else {
|
|
435
|
-
// aliased
|
|
447
|
+
// even an aliased one is not installed (it is refused before the write) — if the plan promises more than the run, that too is a lie.
|
|
436
448
|
const planned = states.filter((x) => x.state !== 'foreign' && x.state !== 'orphaned' && x.state !== 'source-unreadable' && x.state !== 'aliased');
|
|
437
449
|
for (const p of planned)
|
|
438
450
|
changes.push({ path: (0, playbook_skills_1.skillPathFor)(opts.target, p.name), before: null, after: null });
|
|
@@ -440,8 +452,8 @@ function runInit(opts) {
|
|
|
440
452
|
messages.push(`Would install/refresh ${planned.length} recovery skill(s): ${planned.map((p) => (0, playbook_skills_1.invocableSkillName)(p.name)).join(', ')}`);
|
|
441
453
|
}
|
|
442
454
|
}
|
|
443
|
-
// @implements A-SPEC-193 —
|
|
444
|
-
//
|
|
455
|
+
// @implements A-SPEC-193 — per-harness wiring. Splitting compute from write means a dry-run predicts the same
|
|
456
|
+
// set the real run produces (the A-SPEC-190 §9 discipline). Claude was already wired above, so its list is empty.
|
|
445
457
|
if (!opts.remove) {
|
|
446
458
|
for (const agent of opts.agents ?? []) {
|
|
447
459
|
for (const f of (0, agents_1.agentFiles)(agent, { target: opts.target, packageRoot: opts.packageRoot, specsDir: opts.specsDir, launcher: opts.mcpLauncher })) {
|
|
@@ -449,9 +461,9 @@ function runInit(opts) {
|
|
|
449
461
|
changes.push({ path: f.path, before, after: f.content });
|
|
450
462
|
}
|
|
451
463
|
messages.push(agents_1.HARNESS_ENFORCES[agent]
|
|
452
|
-
? `${agent}:
|
|
453
|
-
: `${agent}:
|
|
454
|
-
//
|
|
464
|
+
? `${agent}: the gate is enforced (hooks wired).`
|
|
465
|
+
: `${agent}: only tools and instructions were wired — this harness does not enforce the gate.`);
|
|
466
|
+
// a link is placement, not content — a dry-run only says so, and only the real run creates it.
|
|
455
467
|
for (const link of (0, agents_1.agentLinks)(agent, { target: opts.target, packageRoot: opts.packageRoot, specsDir: opts.specsDir })) {
|
|
456
468
|
if (opts.dryRun) {
|
|
457
469
|
messages.push(`Would link ${link.path} -> ${link.target} (${link.why})`);
|
|
@@ -465,10 +477,10 @@ function runInit(opts) {
|
|
|
465
477
|
}
|
|
466
478
|
}
|
|
467
479
|
catch (e) {
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
messages.push(
|
|
471
|
-
+ `
|
|
480
|
+
// on a filesystem that cannot make symlinks (some Windows setups), state the fact and move on —
|
|
481
|
+
// silently making a copy is exactly where drift starts.
|
|
482
|
+
messages.push(`could not link ${link.path} (${e instanceof Error ? e.message : String(e)}) —`
|
|
483
|
+
+ ` for this harness to see the skills, link or copy ${link.target} yourself.`);
|
|
472
484
|
}
|
|
473
485
|
}
|
|
474
486
|
}
|
|
@@ -487,7 +499,7 @@ function runInit(opts) {
|
|
|
487
499
|
return { ok: false, exitCode: 2, changes, messages: [
|
|
488
500
|
...messages,
|
|
489
501
|
e instanceof Error ? e.message : String(e),
|
|
490
|
-
'
|
|
502
|
+
'Check permissions and ownership, then run again — a file we could not read was not overwritten.',
|
|
491
503
|
] };
|
|
492
504
|
}
|
|
493
505
|
}
|
|
@@ -7,3 +7,11 @@ export declare function parseAgentList(input: string): Agent[];
|
|
|
7
7
|
* Renders an interactive TTY checkbox selection menu using standard readline & ANSI codes.
|
|
8
8
|
*/
|
|
9
9
|
export declare function promptAgentSelection(availableAgents?: readonly Agent[], currentWired?: Agent[]): Promise<Agent[]>;
|
|
10
|
+
/**
|
|
11
|
+
* @implements A-SPEC-552.1
|
|
12
|
+
* The project autonomy-posture prompt. TTY-only (its raw-terminal read is the human-presence proof —
|
|
13
|
+
* an agent's non-TTY Bash cannot reach it, so a `true` here is a human answer, not a self-grant).
|
|
14
|
+
* Non-TTY returns false (leave HITL); the caller does not treat that as an interactive answer.
|
|
15
|
+
* Default is NO — autonomy is opt-in.
|
|
16
|
+
*/
|
|
17
|
+
export declare function promptAutonomy(): Promise<boolean>;
|
|
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.parseAgentList = parseAgentList;
|
|
37
37
|
exports.promptAgentSelection = promptAgentSelection;
|
|
38
|
+
exports.promptAutonomy = promptAutonomy;
|
|
38
39
|
// @implements A-SPEC-200
|
|
39
40
|
const readline = __importStar(require("node:readline"));
|
|
40
41
|
const agents_1 = require("./agents");
|
|
@@ -149,3 +150,25 @@ async function promptAgentSelection(availableAgents = agents_1.AGENTS, currentWi
|
|
|
149
150
|
render();
|
|
150
151
|
});
|
|
151
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* @implements A-SPEC-552.1
|
|
155
|
+
* The project autonomy-posture prompt. TTY-only (its raw-terminal read is the human-presence proof —
|
|
156
|
+
* an agent's non-TTY Bash cannot reach it, so a `true` here is a human answer, not a self-grant).
|
|
157
|
+
* Non-TTY returns false (leave HITL); the caller does not treat that as an interactive answer.
|
|
158
|
+
* Default is NO — autonomy is opt-in.
|
|
159
|
+
*/
|
|
160
|
+
async function promptAutonomy() {
|
|
161
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
162
|
+
return false;
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
165
|
+
process.stdout.write('\n 🔑 Autonomous spec approval\n'
|
|
166
|
+
+ ' The agent may self-approve LOW-RISK specs (tests, benign changes). Governance-critical,\n'
|
|
167
|
+
+ ' high-risk and irreversible specs (REQ/H-SPEC/C-SPEC, gate/security/architecture) ALWAYS\n'
|
|
168
|
+
+ ' ask you. You can turn this off anytime: holmes-kit autonomy off.\n');
|
|
169
|
+
rl.question(' Grant autonomous spec approval for this project? [y/N] ', (ans) => {
|
|
170
|
+
rl.close();
|
|
171
|
+
resolve(/^\s*y(es)?\s*$/i.test(ans));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -57,34 +57,34 @@ const defaultIo = {
|
|
|
57
57
|
write: (line) => process.stdout.write(line + '\n'),
|
|
58
58
|
};
|
|
59
59
|
const SOURCE_LABEL = {
|
|
60
|
-
'env': 'HOLMES_SEMANTIC_API_KEY (
|
|
61
|
-
'env-compat': 'GEMINI_API_KEY (
|
|
62
|
-
'keychain': 'macOS
|
|
60
|
+
'env': 'HOLMES_SEMANTIC_API_KEY (environment variable)',
|
|
61
|
+
'env-compat': 'GEMINI_API_KEY (environment variable)',
|
|
62
|
+
'keychain': 'macOS keychain (holmes-kit/semantic)',
|
|
63
63
|
'file': '~/.holmes/credentials.json (0600)',
|
|
64
64
|
};
|
|
65
65
|
async function runSemanticKey(sub, io = defaultIo, cred = {}) {
|
|
66
66
|
if (sub === 'set') {
|
|
67
|
-
const key = (await io.readSecret('cloud semantic
|
|
67
|
+
const key = (await io.readSecret('enter the cloud semantic tier API key (not shown): ')).trim();
|
|
68
68
|
if (key === '') {
|
|
69
|
-
io.write('
|
|
69
|
+
io.write('An empty key is not saved.');
|
|
70
70
|
return 2;
|
|
71
71
|
}
|
|
72
72
|
const where = (0, credentials_1.storeSemanticKey)(key, cred);
|
|
73
|
-
io.write(
|
|
73
|
+
io.write(`Saved: ${SOURCE_LABEL[where]} — setting a key is consent to egress (sending spec prose, paths, and symbol names externally). The value is never shown in any output.`);
|
|
74
74
|
return 0;
|
|
75
75
|
}
|
|
76
76
|
if (sub === 'unset') {
|
|
77
77
|
(0, credentials_1.removeSemanticKey)(cred);
|
|
78
|
-
io.write('
|
|
78
|
+
io.write('Semantic key removed (both keychain and file).');
|
|
79
79
|
return 0;
|
|
80
80
|
}
|
|
81
81
|
if (sub === 'status' || sub === undefined) {
|
|
82
82
|
const r = (0, credentials_1.resolveSemanticKey)(cred);
|
|
83
83
|
if (r === null) {
|
|
84
|
-
io.write('
|
|
84
|
+
io.write('Semantic key: none — cloud tier disabled. Opt in with `holmes-kit semantic-key set` (setting a key = consent to egress).');
|
|
85
85
|
}
|
|
86
86
|
else {
|
|
87
|
-
io.write(`
|
|
87
|
+
io.write(`Semantic key: set — source ${SOURCE_LABEL[r.source]}. The value is not shown.`);
|
|
88
88
|
}
|
|
89
89
|
return 0;
|
|
90
90
|
}
|
|
@@ -63,8 +63,9 @@ export declare function disableMcpServer(existing: Settings | undefined, name: s
|
|
|
63
63
|
* put it there.
|
|
64
64
|
*/
|
|
65
65
|
export declare const HOLMES_OWNED_MCP_ENV: readonly ["HOLMES_SPECS"];
|
|
66
|
+
export declare const AUTONOMY_MCP_ENV = "HOLMES_AUTONOMOUS_APPROVAL";
|
|
66
67
|
export declare function mergeMcpServers(existing: McpConfig | undefined, name: string, entry: {
|
|
67
68
|
command: string;
|
|
68
69
|
args: string[];
|
|
69
|
-
}, specsDir: string): McpConfig;
|
|
70
|
+
}, specsDir: string, autonomy?: boolean): McpConfig;
|
|
70
71
|
export declare function removeMcpServer(existing: McpConfig | undefined, name: string): McpConfig;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// @implements A-SPEC-100.2
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.HOLMES_OWNED_MCP_ENV = void 0;
|
|
4
|
+
exports.AUTONOMY_MCP_ENV = exports.HOLMES_OWNED_MCP_ENV = void 0;
|
|
5
5
|
exports.isHolmesCommand = isHolmesCommand;
|
|
6
6
|
exports.hookScriptPath = hookScriptPath;
|
|
7
7
|
exports.mergeHooks = mergeHooks;
|
|
@@ -95,11 +95,15 @@ function disableMcpServer(existing, name) {
|
|
|
95
95
|
* put it there.
|
|
96
96
|
*/
|
|
97
97
|
exports.HOLMES_OWNED_MCP_ENV = ['HOLMES_SPECS'];
|
|
98
|
+
// @implements A-SPEC-552.1 — the autonomy posture key is holmes-MANAGED but NOT in HOLMES_OWNED_MCP_ENV:
|
|
99
|
+
// that list means "always overwritten on a re-wire" (A-SPEC-179), and the posture is 3-state (undefined
|
|
100
|
+
// PRESERVES it). It is listed here only so init does not report it as a "preserved user key".
|
|
101
|
+
exports.AUTONOMY_MCP_ENV = 'HOLMES_AUTONOMOUS_APPROVAL';
|
|
98
102
|
// @implements A-SPEC-251.1
|
|
99
103
|
// The server entry (command/args) is COMPUTED by the caller (mcp-launcher: npx-pin for installs,
|
|
100
104
|
// node for source checkouts) rather than assembled here, so all three harness wirings share one
|
|
101
105
|
// launch contract. This merge owns only env preservation, not how the server is launched.
|
|
102
|
-
function mergeMcpServers(existing, name, entry, specsDir) {
|
|
106
|
+
function mergeMcpServers(existing, name, entry, specsDir, autonomy) {
|
|
103
107
|
const out = { ...(existing ?? {}) };
|
|
104
108
|
// @implements A-SPEC-179
|
|
105
109
|
// Merge, not replace. Measured 2026-08-13 while previewing `--force` on holmes-kit's own
|
|
@@ -111,9 +115,17 @@ function mergeMcpServers(existing, name, entry, specsDir) {
|
|
|
111
115
|
// the one field, defensively: a non-object entry must not throw during a re-wire.
|
|
112
116
|
const prevEntry = existing?.mcpServers?.[name];
|
|
113
117
|
const prev = (prevEntry && typeof prevEntry === 'object' ? prevEntry.env : undefined) ?? {};
|
|
118
|
+
const env = { ...prev, HOLMES_SPECS: specsDir };
|
|
119
|
+
// @implements A-SPEC-552.1 — the autonomy posture is 3-state: `true` sets the switch, `false`
|
|
120
|
+
// clears it (turning autonomy off is always free), and `undefined` leaves whatever was there
|
|
121
|
+
// (a re-wire that does not mention autonomy must not silently flip it).
|
|
122
|
+
if (autonomy === true)
|
|
123
|
+
env[exports.AUTONOMY_MCP_ENV] = '1';
|
|
124
|
+
else if (autonomy === false)
|
|
125
|
+
delete env[exports.AUTONOMY_MCP_ENV];
|
|
114
126
|
out.mcpServers = {
|
|
115
127
|
...(out.mcpServers ?? {}),
|
|
116
|
-
[name]: { command: entry.command, args: entry.args, env
|
|
128
|
+
[name]: { command: entry.command, args: entry.args, env },
|
|
117
129
|
};
|
|
118
130
|
return out;
|
|
119
131
|
}
|
|
@@ -104,7 +104,7 @@ async function runUpgrade(io) {
|
|
|
104
104
|
io.stderr('source checkout: upgrade via git (git pull && npm run build), not npm install.\n');
|
|
105
105
|
return 1;
|
|
106
106
|
}
|
|
107
|
-
const go = io.yes || await io.confirm('
|
|
107
|
+
const go = io.yes || await io.confirm('Continue? [y/N] ');
|
|
108
108
|
if (!go) {
|
|
109
109
|
io.stdout(io.yes ? '' : 'aborted — nothing installed or re-pinned.\n');
|
|
110
110
|
if (!io.yes && (!process.stdin.isTTY))
|
|
@@ -139,7 +139,7 @@ async function runUpgrade(io) {
|
|
|
139
139
|
io.stdout('\nRe-pinning workspaces:\n');
|
|
140
140
|
for (const w of registry.workspaces) {
|
|
141
141
|
if (!fs.existsSync(w.target)) {
|
|
142
|
-
io.stdout(`
|
|
142
|
+
io.stdout(` skipped (no path): ${w.target}\n`);
|
|
143
143
|
skipped++;
|
|
144
144
|
continue;
|
|
145
145
|
}
|
|
@@ -158,20 +158,20 @@ async function runUpgrade(io) {
|
|
|
158
158
|
allowAdditive: true,
|
|
159
159
|
});
|
|
160
160
|
if (res.ok) {
|
|
161
|
-
io.stdout(`
|
|
161
|
+
io.stdout(` re-pinned: ${w.target}\n`);
|
|
162
162
|
repinned++;
|
|
163
163
|
}
|
|
164
164
|
else {
|
|
165
|
-
io.stdout(`
|
|
165
|
+
io.stdout(` failed: ${w.target}\n`);
|
|
166
166
|
failed++;
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
catch {
|
|
170
|
-
io.stdout(`
|
|
170
|
+
io.stdout(` failed: ${w.target}\n`);
|
|
171
171
|
failed++;
|
|
172
172
|
}
|
|
173
173
|
}
|
|
174
|
-
io.stdout(`\nUpgraded to ${plan.latest}.
|
|
175
|
-
io.stdout(`(
|
|
174
|
+
io.stdout(`\nUpgraded to ${plan.latest}. re-pinned ${repinned} · skipped ${skipped} · failed ${failed}. A new session's hooks/MCP will read the new wiring.\n`);
|
|
175
|
+
io.stdout(`(You can re-wire any individual workspace at any time with ${(0, npx_bin_1.npxBin)()} holmes-kit init --agent <…> --force.)\n`);
|
|
176
176
|
return 0;
|
|
177
177
|
}
|
|
@@ -68,5 +68,5 @@ function checkProposedContent(opts) {
|
|
|
68
68
|
* A gate's silence reads as "safe". Stating the approximations where the reader already is — rather
|
|
69
69
|
* than in a document they will not open — is what keeps a pass from becoming false confidence.
|
|
70
70
|
*/
|
|
71
|
-
exports.CSPEC_GATE_LIMITS = '
|
|
72
|
-
+ '
|
|
71
|
+
exports.CSPEC_GATE_LIMITS = 'What this check does NOT guarantee: it does not read control flow, so code that dodges a rule on a branch passes;'
|
|
72
|
+
+ ' a module-top-level call is attributed at file granularity; the judgement is over this one file, not the whole repository.';
|
|
@@ -10,5 +10,12 @@ export declare function isHighRiskPath(p: string): boolean;
|
|
|
10
10
|
* from the spec itself.
|
|
11
11
|
*/
|
|
12
12
|
export declare function specApprovalAutonomy(spec: Spec, _resolveParent: (id: string) => Spec | null): ApprovalAutonomy;
|
|
13
|
-
|
|
14
|
-
export declare function
|
|
13
|
+
export declare const SESSION_AUTONOMY_MARKER: readonly [".ax", "state", "autonomy.json"];
|
|
14
|
+
export declare function sessionAutonomyActive(root: string, now: string): boolean;
|
|
15
|
+
/** Whether autonomous approval is enabled — the out-of-band env switch, OR a valid session envelope
|
|
16
|
+
* (A-SPEC-553.1). `root`/`now` are optional so env-only callers are byte-identical. */
|
|
17
|
+
export declare function autonomousApprovalEnabled(env: NodeJS.ProcessEnv, root?: string, now?: string): boolean;
|
|
18
|
+
export declare function autonomyBanner(env: NodeJS.ProcessEnv, root: string, now: string): string;
|
|
19
|
+
export declare function autonomyMarkerShouldPrune(root: string, now: string): boolean;
|
|
20
|
+
export declare function versionBumpKind(from: string, to: string): 'major' | 'minor' | 'patch' | 'none';
|
|
21
|
+
export declare function releaseAutonomy(specs: Spec[], versionBump: 'major' | 'minor' | 'patch' | 'none', env: NodeJS.ProcessEnv, root?: string, now?: string): ApprovalAutonomy;
|
|
@@ -1,9 +1,59 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.AUTONOMY_ENV = void 0;
|
|
36
|
+
exports.SESSION_AUTONOMY_MARKER = exports.AUTONOMY_ENV = void 0;
|
|
4
37
|
exports.isHighRiskPath = isHighRiskPath;
|
|
5
38
|
exports.specApprovalAutonomy = specApprovalAutonomy;
|
|
39
|
+
exports.sessionAutonomyActive = sessionAutonomyActive;
|
|
6
40
|
exports.autonomousApprovalEnabled = autonomousApprovalEnabled;
|
|
41
|
+
exports.autonomyBanner = autonomyBanner;
|
|
42
|
+
exports.autonomyMarkerShouldPrune = autonomyMarkerShouldPrune;
|
|
43
|
+
exports.versionBumpKind = versionBumpKind;
|
|
44
|
+
exports.releaseAutonomy = releaseAutonomy;
|
|
45
|
+
// @implements A-SPEC-532.1
|
|
46
|
+
// Whether a spec approval may be sealed AUTONOMOUSLY by the agent, or must still ask a human through
|
|
47
|
+
// the MCP elicitation TUI. The governance chain (REQ→H→A→T→CPG + RTM + taint + phase gate) already
|
|
48
|
+
// forces quality structurally, so a low/mid-risk spec self-approving under an explicitly-enabled
|
|
49
|
+
// autonomous mode is a small, bounded relaxation (REQ-532). The bound is what this module owns: the
|
|
50
|
+
// governance-critical specs — gate behaviour, architecture, taint/security boundaries, and every
|
|
51
|
+
// upstream REQ/H-SPEC/C-SPEC — never leave the human channel.
|
|
52
|
+
//
|
|
53
|
+
// PURE: the spec, a parent resolver, and (separately) the env are the only inputs; the wiring layer
|
|
54
|
+
// (A-SPEC-532.2) injects process.env and the elicitor.
|
|
55
|
+
const fs = __importStar(require("node:fs"));
|
|
56
|
+
const path = __importStar(require("node:path"));
|
|
7
57
|
const scope_judgment_1 = require("../guardrail/scope-judgment");
|
|
8
58
|
/** The out-of-band switch. An agent cannot read or set it — the pre-tool-use gate blocks that
|
|
9
59
|
* (A-SPEC-532.2), the same self-disarm protection HOLMES_APPROVAL and HOLMES_GATE_BYPASS have. */
|
|
@@ -21,8 +71,16 @@ const HIGH_RISK_PREFIXES = [
|
|
|
21
71
|
'.claude',
|
|
22
72
|
];
|
|
23
73
|
const TAINT_MARKERS = ['taint', 'dataflow-taint', 'flow-sensitive'];
|
|
74
|
+
// @implements A-SPEC-556.1 — the grader must judge a token by what it can ADMIT, to the same
|
|
75
|
+
// standard as the enforcer `matchesFtt` (globs expand, prose imposes nothing). Case is folded: a NEW
|
|
76
|
+
// file keeps its lexical spelling, so `src/holmes/Governance/x.ts` lands in the real governance/ on a
|
|
77
|
+
// case-insensitive FS. A glob is high-risk when its literal prefix OVERLAPS a risk root in either
|
|
78
|
+
// direction (`src/holmes/**` reaches the gate; `src/holmes/hooks/*` is inside it) — over-inclusive on
|
|
79
|
+
// purpose, because a glob spanning the gate surface must never self-approve.
|
|
80
|
+
const GLOB_RISK_ROOTS = [...HIGH_RISK_PREFIXES, 'src/holmes/rtm/'];
|
|
24
81
|
function isHighRiskPath(p) {
|
|
25
|
-
const
|
|
82
|
+
const raw = p.replace(/^\.\//, '').replace(/^["'`]|["'`]$/g, '');
|
|
83
|
+
const s = raw.toLowerCase();
|
|
26
84
|
if (HIGH_RISK_PREFIXES.some((pre) => s.startsWith(pre)))
|
|
27
85
|
return true;
|
|
28
86
|
if (s === '.mcp.json' || s.endsWith('/.mcp.json'))
|
|
@@ -30,6 +88,11 @@ function isHighRiskPath(p) {
|
|
|
30
88
|
// A taint/security boundary file anywhere under rtm/ — the flow engine and its vocabulary.
|
|
31
89
|
if (s.startsWith('src/holmes/rtm/') && TAINT_MARKERS.some((m) => s.includes(m)))
|
|
32
90
|
return true;
|
|
91
|
+
if (raw.includes('*')) {
|
|
92
|
+
const litPrefix = s.split('*')[0];
|
|
93
|
+
if (GLOB_RISK_ROOTS.some((pre) => litPrefix.startsWith(pre) || pre.startsWith(litPrefix)))
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
33
96
|
return false;
|
|
34
97
|
}
|
|
35
98
|
/** The declared breaking-change grade of an A-SPEC, or null when absent/blank. */
|
|
@@ -60,6 +123,10 @@ function specApprovalAutonomy(spec, _resolveParent) {
|
|
|
60
123
|
if (grade === null || !AUTO_GRADES.has(grade))
|
|
61
124
|
return 'hitl'; // undeclared or gate-behavior
|
|
62
125
|
const paths = (0, scope_judgment_1.fttPathTokens)(spec.sections['Files to Touch'] ?? '');
|
|
126
|
+
// @implements A-SPEC-556.1 — no path tokens (prose-only / empty FtT) is an UNCONSTRAINED scope:
|
|
127
|
+
// the enforcer imposes nothing, so the spec can write ANYWHERE incl. the gate. Never self-approve.
|
|
128
|
+
if (paths.length === 0)
|
|
129
|
+
return 'hitl';
|
|
63
130
|
if (paths.some(isHighRiskPath))
|
|
64
131
|
return 'hitl'; // gate/governance/taint file
|
|
65
132
|
return 'auto';
|
|
@@ -68,8 +135,102 @@ function specApprovalAutonomy(spec, _resolveParent) {
|
|
|
68
135
|
return 'hitl'; // unknown kind: fail-safe to human
|
|
69
136
|
}
|
|
70
137
|
}
|
|
71
|
-
|
|
72
|
-
|
|
138
|
+
// @implements A-SPEC-553.1 — the per-session autonomy envelope. A protected marker (an agent cannot
|
|
139
|
+
// write `.ax/state/`, measured) with an absolute expiry lets a HITL-default project self-drive
|
|
140
|
+
// low-risk specs for ONE bounded, audited session. Read at decision time; fail-closed on anything
|
|
141
|
+
// unreadable/expired. Wired below (RED-first: stub returns false, then honored).
|
|
142
|
+
exports.SESSION_AUTONOMY_MARKER = ['.ax', 'state', 'autonomy.json'];
|
|
143
|
+
function sessionAutonomyActive(root, now) {
|
|
144
|
+
try {
|
|
145
|
+
const m = JSON.parse(fs.readFileSync(path.join(root, ...exports.SESSION_AUTONOMY_MARKER), 'utf8'));
|
|
146
|
+
if (m.enabled !== true)
|
|
147
|
+
return false;
|
|
148
|
+
const exp = Date.parse(String(m.expires));
|
|
149
|
+
const nowMs = Date.parse(now);
|
|
150
|
+
// A deadline we cannot read is one we assume passed (the ledger/grant doctrine): fail closed.
|
|
151
|
+
if (!Number.isFinite(exp) || !Number.isFinite(nowMs))
|
|
152
|
+
return false;
|
|
153
|
+
return nowMs < exp;
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false; // absent / unreadable / malformed marker grants nothing
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Whether autonomous approval is enabled — the out-of-band env switch, OR a valid session envelope
|
|
160
|
+
* (A-SPEC-553.1). `root`/`now` are optional so env-only callers are byte-identical. */
|
|
161
|
+
function autonomousApprovalEnabled(env, root, now) {
|
|
162
|
+
const v = env[exports.AUTONOMY_ENV];
|
|
163
|
+
if (typeof v === 'string' && v !== '')
|
|
164
|
+
return true;
|
|
165
|
+
return root !== undefined && now !== undefined && sessionAutonomyActive(root, now);
|
|
166
|
+
}
|
|
167
|
+
// @implements A-SPEC-554.1 — the session-start REMINDER (the headless hook surfaces, it never decides).
|
|
168
|
+
// Only ON postures get a line; OFF is silent (a permanent line is one nobody reads). Wired below.
|
|
169
|
+
function autonomyBanner(env, root, now) {
|
|
73
170
|
const v = env[exports.AUTONOMY_ENV];
|
|
74
|
-
|
|
171
|
+
if (typeof v === 'string' && v !== '') {
|
|
172
|
+
return '[Holmes-Kit] autonomous spec approval: ON (project default) — governance-critical, high-risk and irreversible specs still ask you.';
|
|
173
|
+
}
|
|
174
|
+
if (sessionAutonomyActive(root, now)) {
|
|
175
|
+
let expires = '';
|
|
176
|
+
try {
|
|
177
|
+
expires = String(JSON.parse(fs.readFileSync(path.join(root, ...exports.SESSION_AUTONOMY_MARKER), 'utf8')).expires);
|
|
178
|
+
}
|
|
179
|
+
catch { /* the marker parsed inside sessionAutonomyActive; a stray read failure just omits the time */ }
|
|
180
|
+
return `[Holmes-Kit] autonomous spec approval: ON (this session, until ${expires}) — governance-critical still ask you. End early: holmes-kit autonomy off.`;
|
|
181
|
+
}
|
|
182
|
+
return '';
|
|
183
|
+
}
|
|
184
|
+
// @implements A-SPEC-554.1 — true when a marker file exists but is NOT active (expired / disabled /
|
|
185
|
+
// malformed): housekeeping the headless hook may do. A LIVE envelope is never prunable.
|
|
186
|
+
function autonomyMarkerShouldPrune(root, now) {
|
|
187
|
+
try {
|
|
188
|
+
if (!fs.existsSync(path.join(root, ...exports.SESSION_AUTONOMY_MARKER)))
|
|
189
|
+
return false;
|
|
190
|
+
return !sessionAutonomyActive(root, now); // present but not active ⇒ expired/disabled/malformed
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// @implements A-SPEC-555.1 — the semver bump between two `major.minor.patch` strings (non-numeric
|
|
197
|
+
// fields read as 0; never throws).
|
|
198
|
+
// @implements A-SPEC-558.1 — a DOWNGRADE (to < from) is not a normal forward bump; it is treated as
|
|
199
|
+
// 'major' so a mis-set lower version can never self-publish (releaseAutonomy sends non-patch/minor
|
|
200
|
+
// to a human).
|
|
201
|
+
function versionBumpKind(from, to) {
|
|
202
|
+
const tri = (v) => {
|
|
203
|
+
const [a, b, c] = v.split('.').map((n) => { const x = parseInt(n, 10); return Number.isFinite(x) ? x : 0; });
|
|
204
|
+
return [a ?? 0, b ?? 0, c ?? 0];
|
|
205
|
+
};
|
|
206
|
+
const [fa, fb, fc] = tri(from), [ta, tb, tc] = tri(to);
|
|
207
|
+
const cmp = ta !== fa ? ta - fa : tb !== fb ? tb - fb : tc - fc;
|
|
208
|
+
if (cmp < 0)
|
|
209
|
+
return 'major'; // downgrade ⇒ high-risk
|
|
210
|
+
if (ta !== fa)
|
|
211
|
+
return 'major';
|
|
212
|
+
if (tb !== fb)
|
|
213
|
+
return 'minor';
|
|
214
|
+
if (tc !== fc)
|
|
215
|
+
return 'patch';
|
|
216
|
+
return 'none';
|
|
217
|
+
}
|
|
218
|
+
// @implements A-SPEC-555.1 — the RELEASE risk verdict, decided by what the release CONTAINS (reusing
|
|
219
|
+
// the per-spec grade), never by the publish act. npm publish is irreversible + outward, so the
|
|
220
|
+
// conservative default is 'hitl': a release self-publishes ONLY when autonomy is on, the bump is not
|
|
221
|
+
// major, and every spec is auto-grade; any governance-critical spec, a major bump, or autonomy off
|
|
222
|
+
// keeps a human in the loop. Wired below (RED-first).
|
|
223
|
+
function releaseAutonomy(specs, versionBump, env, root, now) {
|
|
224
|
+
if (!autonomousApprovalEnabled(env, root, now))
|
|
225
|
+
return 'hitl'; // no autonomy ⇒ a human publishes
|
|
226
|
+
// @implements A-SPEC-558.1 — fail-closed on the edges: an empty/unknown spec set is not positive
|
|
227
|
+
// evidence of a low-risk release, and only a positive patch/minor bump self-publishes (a 'major',
|
|
228
|
+
// a downgrade-graded-major, or a no-op 'none' all keep a human in the loop).
|
|
229
|
+
if (specs.length === 0)
|
|
230
|
+
return 'hitl';
|
|
231
|
+
if (versionBump !== 'patch' && versionBump !== 'minor')
|
|
232
|
+
return 'hitl';
|
|
233
|
+
if (specs.some((s) => specApprovalAutonomy(s, () => null) === 'hitl'))
|
|
234
|
+
return 'hitl'; // any gov-critical change
|
|
235
|
+
return 'auto';
|
|
75
236
|
}
|