@holmes-lab/holmes-kit 0.12.2 → 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.
Files changed (38) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +12 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve-context.js +10 -10
  5. package/dist/holmes/cli/approve-ref.js +5 -5
  6. package/dist/holmes/cli/approve-watch.d.ts +1 -1
  7. package/dist/holmes/cli/approve-watch.js +6 -6
  8. package/dist/holmes/cli/approve.d.ts +3 -3
  9. package/dist/holmes/cli/approve.js +57 -57
  10. package/dist/holmes/cli/autonomy.d.ts +22 -0
  11. package/dist/holmes/cli/autonomy.js +145 -0
  12. package/dist/holmes/cli/colophon.d.ts +6 -0
  13. package/dist/holmes/cli/colophon.js +24 -0
  14. package/dist/holmes/cli/doctor.d.ts +2 -2
  15. package/dist/holmes/cli/doctor.js +104 -87
  16. package/dist/holmes/cli/index.js +122 -63
  17. package/dist/holmes/cli/init.d.ts +2 -0
  18. package/dist/holmes/cli/init.js +31 -19
  19. package/dist/holmes/cli/interactive-prompt.d.ts +8 -0
  20. package/dist/holmes/cli/interactive-prompt.js +23 -0
  21. package/dist/holmes/cli/semantic-key.js +9 -9
  22. package/dist/holmes/cli/settings-merge.d.ts +2 -1
  23. package/dist/holmes/cli/settings-merge.js +15 -3
  24. package/dist/holmes/cli/upgrade.js +7 -7
  25. package/dist/holmes/cpg/proposed-content.js +2 -2
  26. package/dist/holmes/governance/autonomy.d.ts +9 -2
  27. package/dist/holmes/governance/autonomy.js +166 -5
  28. package/dist/holmes/guardrail/blind-spots.js +15 -15
  29. package/dist/holmes/hooks/pre-tool-use.js +111 -42
  30. package/dist/holmes/hooks/session-start.js +17 -0
  31. package/dist/holmes/hooks/stop.d.ts +1 -1
  32. package/dist/holmes/hooks/stop.js +12 -12
  33. package/dist/holmes/mcp/handlers.js +14 -1
  34. package/dist/holmes/semantic/credentials.js +1 -1
  35. package/dist/holmes/spec/id-collision.js +2 -2
  36. package/package.json +2 -2
  37. package/playbooks/publish/PLAYBOOK.md +47 -35
  38. package/playbooks/remediation/PLAYBOOK.md +1 -1
@@ -148,8 +148,8 @@ function writeFileSafe(file, content) {
148
148
  prev = fs.readFileSync(real, 'utf8');
149
149
  }
150
150
  catch (e) {
151
- // §17: 읽지 못한 파일을 덮어쓰지 않는다백업이 0바이트가 되고 사용자 설정이 사라진다.
152
- throw new Error(`${file} 읽을 없어 덮어쓰지 않았습니다: ${e instanceof Error ? e.message : String(e)}`);
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(`${file} 를 쓰지 못했습니다: ${e instanceof Error ? e.message : String(e)}`);
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
- ' 패키지는 컴파일된 dist/ 함께 배포합니다 저장소에서 쓰는 중이라면 `npm run build`, 설치본이라면 재설치하십시오.',
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 — 하네스별 배선물. 계산과 쓰기를 나누므로 dry-run 실제 실행과
444
- // 같은 집합을 예고한다(A-SPEC-190 §9 의 규율). Claude 위에서 이미 배선했으므로 목록이다.
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
- // 링크는 내용이 아니라 배치다 — dry-run 에서는 말만 하고, 실제 실행에서만 만든다.
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
- // 심링크를 만들 없는 파일시스템(일부 Windows 배치)에서는 사실을 말하고 넘어간다
469
- // 조용히 복사본을 만들면 그것이 드리프트의 출발점이다.
470
- messages.push(`${link.path} 를 링크하지 못했습니다(${e instanceof Error ? e.message : String(e)}) —`
471
- + ` 하네스가 스킬을 보려면 ${link.target} 를 직접 링크하거나 복사하십시오.`);
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 키체인 (holmes-kit/semantic)',
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 티어 API 입력(표시되지 않음): ')).trim();
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(`저장됨: ${SOURCE_LABEL[where]} — 설정은 이그레스(스펙 산문·경로·심볼명 외부 전송) 동의입니다. 값은 어떤 출력에도 표시되지 않습니다.`);
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('semantic 제거됨 (키체인·파일 양쪽).');
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('semantic 키: 없음 — cloud 티어 비활성. `holmes-kit semantic-key set` 으로 opt-in 하십시오(키 설정 = 이그레스 동의).');
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(`semantic 키: 설정됨출처 ${SOURCE_LABEL[r.source]}. 값은 표시되지 않습니다.`);
87
+ io.write(`Semantic key: setsource ${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: { ...prev, HOLMES_SPECS: specsDir } },
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('계속합니까? [y/N] ');
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(` 건너뜀 (경로 없음): ${w.target}\n`);
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(` 재핀: ${w.target}\n`);
161
+ io.stdout(` re-pinned: ${w.target}\n`);
162
162
  repinned++;
163
163
  }
164
164
  else {
165
- io.stdout(` 실패: ${w.target}\n`);
165
+ io.stdout(` failed: ${w.target}\n`);
166
166
  failed++;
167
167
  }
168
168
  }
169
169
  catch {
170
- io.stdout(` 실패: ${w.target}\n`);
170
+ io.stdout(` failed: ${w.target}\n`);
171
171
  failed++;
172
172
  }
173
173
  }
174
- io.stdout(`\nUpgraded to ${plan.latest}. 재핀 ${repinned} · 건너뜀 ${skipped} · 실패 ${failed}. 세션에서 훅/MCP 배선을 읽습니다.\n`);
175
- io.stdout(`(개별 워크스페이스는 언제든 ${(0, npx_bin_1.npxBin)()} holmes-kit init --agent <…> --force 로 재배선할 수 있습니다.)\n`);
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
- /** Whether autonomous approval is enabled at all — the out-of-band switch, read at the wiring layer. */
14
- export declare function autonomousApprovalEnabled(env: NodeJS.ProcessEnv): boolean;
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 s = p.replace(/^\.\//, '').replace(/^["'`]|["'`]$/g, '');
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
- /** Whether autonomous approval is enabled at all — the out-of-band switch, read at the wiring layer. */
72
- function autonomousApprovalEnabled(env) {
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
- return typeof v === 'string' && v !== '';
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
  }