@holmes-lab/holmes-kit 0.3.7 → 0.3.9

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 CHANGED
@@ -4,6 +4,68 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.3.9] - 2026-09-01
9
+
10
+ Scope de-amplification and the push gate — the governance model stops taxing legitimate work
11
+ (one test-class addition used to cost >=3 human interventions) and starts guarding the one
12
+ moment nothing guarded: the push.
13
+
14
+ ### Added
15
+
16
+ - **Tenure/admission scope model** (A-SPEC-508.1): a file already anchored on disk is IN its
17
+ specs' scope — editing it is free and silent (the double registration with `Files to Touch`
18
+ is gone). Only NEW claims (a new file's anchors, a re-anchor) pass admission: an FtT **glob**
19
+ (`src/core/**`, `test_*.py`), the **test-file dispensation** (a new test joining an approved
20
+ spec is coverage growth, which ART-4 asks for), or an out-of-band approval. All disk anchors
21
+ participate (first-match-only is dead), deleting an anchor line warns (RTM loss made audible)
22
+ but never blocks, and a re-anchor refusal names the transfer. Dogfooded: 96+ anchored files
23
+ in this repo, zero tenure regressions (pinned as a permanent sweep test).
24
+ - **pre-push evidence gate** (A-SPEC-509.1): `npx holmes-kit install-push-gate` installs a
25
+ git pre-push hook (honoring `core.hooksPath`; a foreign hook is never overwritten) that
26
+ refuses a push unless the test-evidence ledger vouches for EXACTLY the pushed HEAD — green,
27
+ with real executions. Commit-message success claims never move the verdict; an unbacked claim
28
+ is surfaced as a warning. Collection failures allow (this safens the default; security stays
29
+ with the tool gates), `--no-verify` is respected, and doctor diagnoses a missing hook. Live
30
+ probe on this repo: a stale-ledger push was refused with both heads and the recovery command.
31
+
32
+ ### Fixed
33
+
34
+ - **FtT parent-directory amplification**: a FILE token in Files-to-Touch silently covered its
35
+ whole sibling directory (`src/a/x.ts` admitted all of `src/a/`); a file token now names that
36
+ file only, and directory intent is written as a glob.
37
+
38
+ <!-- @implements A-SPEC-209 -->
39
+ ## [0.3.8] - 2026-09-01
40
+
41
+ Approval-UX slice pair (REQ-497 완결): the resident CLI surface decides on ONE raw keypress with
42
+ an OS arrival ping, and the in-session dialog lane gets a one-choice form, a finite re-display
43
+ and per-path deadlines.
44
+
45
+ ### Added
46
+
47
+ - **`approve --watch` single-key TUI + OS notification** (A-SPEC-497.2): a pending item is
48
+ decided by one raw keypress — `a` approve / `d` deny / `q` one-line question / `s` skip — no
49
+ Enter, no deadline (a human terminal can wait forever). Unknown keys decide nothing
50
+ (fail-closed) and Ctrl-C leaves without deciding. Each NEW arrival fires one OS-native
51
+ notification (macOS `osascript`, Windows WinRT toast; flattened text, every failure swallowed —
52
+ a dead notifier changes nothing about the queue or the gates).
53
+ - **Elicitation one-choice form, finite re-display, per-path deadlines** (A-SPEC-497.3): the
54
+ in-session approval dialog asks the DECISION alone (reason moves to a second dialog shown only
55
+ after deny/question — a question requires text, a deny stands without it); an expired dialog is
56
+ re-presented exactly once with a visible "재표시 2/2" counter (stale widgets survive on screen —
57
+ measured); and the deadline differs by path — lock-free `spec-approve` gets 240s, lock-holding
58
+ `review-resolve` keeps 120s with no re-display (starvation ceiling preserved). Decline, cancel
59
+ and any answer are never re-asked. Multi-cycle expiry measured end-to-end over the real MCP
60
+ protocol: two 240s presentations, summed 480s expired refusal, outer call alive.
61
+
62
+ ### Changed
63
+
64
+ - The `--watch` input path replaces the readline line-reader with a raw-mode key stream; the
65
+ non-watch interactive surface is unchanged. The elicitation stage-1 form no longer carries a
66
+ `reason` field (moved to stage 2); silent-path refusal text stays byte-identical
67
+ (A-SPEC-263.1), and the expiry notice format is unchanged (summed seconds).
68
+
7
69
  <!-- @implements A-SPEC-209 -->
8
70
  ## [0.3.7] - 2026-09-01
9
71
 
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // @implements A-SPEC-509.1
3
+ // HOLMES-KIT PUSH GATE v1 — thin stdin adapter over dist/holmes/governance/push-gate-runner.js.
4
+ // Fail-open by contract (H-SPEC-509): this gate safens the default, it is not a security
5
+ // boundary, so any load or collection failure lets the push proceed.
6
+ try {
7
+ const { runPrePush } = require('../dist/holmes/governance/push-gate-runner.js');
8
+ let text = '';
9
+ process.stdin.setEncoding('utf8');
10
+ process.stdin.on('data', (c) => { text += c; });
11
+ process.stdin.on('end', () => {
12
+ let code = 0;
13
+ try {
14
+ code = runPrePush({ cwd: process.cwd(), stdinText: text, write: (s) => process.stderr.write(s) });
15
+ } catch { code = 0; }
16
+ process.exit(code);
17
+ });
18
+ } catch {
19
+ process.exit(0);
20
+ }
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- eb3164c-mthu5jwj
1
+ ab67afc-mti0dz4k
@@ -1,4 +1,28 @@
1
+ import { readQueue, PendingRequest } from '../governance/approval-queue';
1
2
  import { ApproveIO } from './approve';
3
+ /** The raw-mode key source a single-key watch listens to (process.stdin in the shipped CLI). */
4
+ export interface KeyStream {
5
+ setRawMode?: (raw: boolean) => unknown;
6
+ resume?: () => unknown;
7
+ pause?: () => unknown;
8
+ on(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
9
+ removeListener(event: 'data', fn: (chunk: Buffer | string) => void): unknown;
10
+ }
11
+ /**
12
+ * ONE raw keypress decides an item — a/d/q/s; q collects one follow-up line (the question).
13
+ * Unknown keys decide NOTHING (a typo must not become an approval — fail-closed), and Ctrl-C
14
+ * rejects with AbortError: leaving is not deciding. No deadline anywhere: the MAIN LANE's whole
15
+ * point is that a human terminal can wait forever (H-SPEC-497).
16
+ */
17
+ export declare function decideByKey(stream: KeyStream, _item: Pick<PendingRequest, 'id'>): Promise<'granted' | 'denied' | 'skipped' | {
18
+ question: string;
19
+ }>;
20
+ /**
21
+ * Present each pending item and apply the single-key decision. The item summary goes through the
22
+ * SAME escaped cells the list uses (agent-controlled text never owns a line), and a denial gets a
23
+ * canned-but-visible reason — the agent must still see that a human said no.
24
+ */
25
+ export declare function presentByKey(root: string, io: ApproveIO, actor: string, stream: KeyStream, state: ReturnType<typeof readQueue>): Promise<void>;
2
26
  /** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
3
27
  export declare const WATCH_IDLE = "\u25C6 \uC2B9\uC778 \uB300\uAE30 \uC911\u2026 (Ctrl-C \uB85C \uC885\uB8CC)";
4
28
  /** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
@@ -13,6 +37,14 @@ export interface WatchOpts {
13
37
  * Must resolve early if `signal` aborts, so an idle watch shuts down at once.
14
38
  */
15
39
  waitIdle?: (ms: number, signal?: AbortSignal) => Promise<void>;
40
+ /**
41
+ * @implements A-SPEC-497.2
42
+ * When present, NEW items are presented via the single-key surface (presentByKey) instead of
43
+ * runInteractive — the shipped CLI passes raw-mode stdin here. Absent: legacy path, byte-for-byte.
44
+ */
45
+ keyStream?: KeyStream;
46
+ /** Called once per NEW pending item (lastPresented dedup) — a throwing notifier is swallowed. */
47
+ notify?: (item: PendingRequest) => void;
16
48
  }
17
49
  /** Exported for test: the shipped idle wait must resolve at once on abort, not after the timer. */
18
50
  export declare function defaultWaitIdle(ms: number, signal?: AbortSignal): Promise<void>;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_POLL_MS = exports.WATCH_IDLE = void 0;
4
+ exports.decideByKey = decideByKey;
5
+ exports.presentByKey = presentByKey;
4
6
  exports.defaultWaitIdle = defaultWaitIdle;
5
7
  exports.runWatch = runWatch;
6
8
  // @implements A-SPEC-262.2
@@ -14,6 +16,92 @@ exports.runWatch = runWatch;
14
16
  // existing runInteractive, and waits between polls when the queue is empty.
15
17
  const approval_queue_1 = require("../governance/approval-queue");
16
18
  const approve_1 = require("./approve");
19
+ // @implements A-SPEC-497.2
20
+ /**
21
+ * ONE raw keypress decides an item — a/d/q/s; q collects one follow-up line (the question).
22
+ * Unknown keys decide NOTHING (a typo must not become an approval — fail-closed), and Ctrl-C
23
+ * rejects with AbortError: leaving is not deciding. No deadline anywhere: the MAIN LANE's whole
24
+ * point is that a human terminal can wait forever (H-SPEC-497).
25
+ */
26
+ function decideByKey(stream, _item) {
27
+ return new Promise((resolve, reject) => {
28
+ let questionMode = false;
29
+ let line = '';
30
+ const onData = (chunk) => {
31
+ const s = chunk.toString();
32
+ for (const ch of s) {
33
+ if (ch === '') {
34
+ cleanup();
35
+ const e = new Error('operator left');
36
+ e.name = 'AbortError';
37
+ reject(e);
38
+ return;
39
+ }
40
+ if (questionMode) {
41
+ if (ch === '\r' || ch === '\n') {
42
+ cleanup();
43
+ resolve({ question: line });
44
+ return;
45
+ }
46
+ if (ch === '' || ch === '\b') {
47
+ line = line.slice(0, -1);
48
+ continue;
49
+ }
50
+ line += ch;
51
+ continue;
52
+ }
53
+ if (ch === 'a') {
54
+ cleanup();
55
+ resolve('granted');
56
+ return;
57
+ }
58
+ if (ch === 'd') {
59
+ cleanup();
60
+ resolve('denied');
61
+ return;
62
+ }
63
+ if (ch === 's') {
64
+ cleanup();
65
+ resolve('skipped');
66
+ return;
67
+ }
68
+ if (ch === 'q') {
69
+ questionMode = true;
70
+ continue;
71
+ }
72
+ // any other key: not a decision — keep listening
73
+ }
74
+ };
75
+ const cleanup = () => { stream.removeListener('data', onData); };
76
+ stream.on('data', onData);
77
+ });
78
+ }
79
+ // @implements A-SPEC-497.2
80
+ /**
81
+ * Present each pending item and apply the single-key decision. The item summary goes through the
82
+ * SAME escaped cells the list uses (agent-controlled text never owns a line), and a denial gets a
83
+ * canned-but-visible reason — the agent must still see that a human said no.
84
+ */
85
+ async function presentByKey(root, io, actor, stream, state) {
86
+ for (const p of state.pending) {
87
+ io.print(`\n─ ${(0, approve_1.subjectCells)('─ ', p)}`);
88
+ io.print('[a]승인 [d]거부 [q]질문 [s]건너뜀 > ');
89
+ const d = await decideByKey(stream, p);
90
+ if (d === 'granted') {
91
+ const r = (0, approve_1.grantRequest)(root, p.id, { actor });
92
+ io.print(r.ok ? `✓ 승인 — ${(0, approve_1.subjectCells)('✓ 승인 — ', p)}` : `✗ ${r.reason}`);
93
+ }
94
+ else if (d === 'denied') {
95
+ const r = (0, approve_1.denyRequest)(root, p.id, '운영자가 watch에서 단일 키로 거부(사유 즉답 생략)', actor);
96
+ io.print(r.ok ? `✗ 거부 — ${(0, approve_1.subjectCells)('✗ 거부 — ', p)}` : `✗ ${r.reason}`);
97
+ }
98
+ else if (typeof d === 'object') {
99
+ const r = (0, approve_1.holdRequest)(root, p.id, d.question, actor);
100
+ io.print(r.ok ? `? 보류 — 질문이 다음 거부 문면에 실립니다` : `✗ ${r.reason}`);
101
+ }
102
+ // skipped: leave it pending, silently
103
+ }
104
+ }
17
105
  /** The idle line, printed once on entering idle (not on every poll — flood is the template's enemy). */
18
106
  exports.WATCH_IDLE = '◆ 승인 대기 중… (Ctrl-C 로 종료)';
19
107
  /** Default idle poll cadence. The reaction DEADLINE is a tested judgment (T-SPEC), not this number. */
@@ -60,9 +148,24 @@ async function runWatch(root, io, actor, opts = {}) {
60
148
  const hasNew = state.pending.some((p) => !lastPresented.has(p.id));
61
149
  if (state.pending.length > 0 && hasNew) {
62
150
  idleAnnounced = false;
151
+ // @implements A-SPEC-497.2 — one notification per NEW item, and never a lever: a throwing
152
+ // notifier is swallowed (the same doctrine as the queue writer's own failures).
153
+ if (opts.notify) {
154
+ for (const p of state.pending) {
155
+ if (lastPresented.has(p.id))
156
+ continue;
157
+ try {
158
+ opts.notify(p);
159
+ }
160
+ catch { /* fire-and-forget */ }
161
+ }
162
+ }
63
163
  const shown = new Set(state.pending.map((p) => p.id)); // what this pass presents
64
164
  try {
65
- await (0, approve_1.runInteractive)(root, io, actor);
165
+ if (opts.keyStream)
166
+ await presentByKey(root, io, actor, opts.keyStream, state);
167
+ else
168
+ await (0, approve_1.runInteractive)(root, io, actor);
66
169
  }
67
170
  catch (e) {
68
171
  // The operator leaving mid-decision (Ctrl-C / EOF) surfaces as AbortError through the
@@ -102,6 +102,12 @@ export declare function runDoctor(packageRoot: string, target?: string, opts?: D
102
102
  * very first run against a healthy server).
103
103
  */
104
104
  export declare function wiringSpawnCheck(command: string, args: string[], timeoutMs?: number, platform?: NodeJS.Platform): Promise<Check>;
105
+ /**
106
+ * Push-gate presence, diagnosed only where it applies: a repo that opted into governance (.ax)
107
+ * AND has git. Absence or a hook without our signature is a WARN carrying the install command —
108
+ * the same honest-diagnosis lineage as the codex-wiring WARN (never a gate).
109
+ */
110
+ export declare function pushGateCheck(target: string): Check | null;
105
111
  export declare function formatChecks(checks: Check[]): string;
106
112
  /**
107
113
  * Prove that EVERY wired harness can actually start a server, not merely that its file parses.
@@ -40,6 +40,7 @@ exports.prefixVerdict = prefixVerdict;
40
40
  exports.probeEnv = probeEnv;
41
41
  exports.runDoctor = runDoctor;
42
42
  exports.wiringSpawnCheck = wiringSpawnCheck;
43
+ exports.pushGateCheck = pushGateCheck;
43
44
  exports.formatChecks = formatChecks;
44
45
  exports.wiringHandshakeChecks = wiringHandshakeChecks;
45
46
  exports.semanticTierVerdict = semanticTierVerdict;
@@ -972,6 +973,13 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
972
973
  }
973
974
  }
974
975
  add('environment', 'PASS', present);
976
+ // @implements A-SPEC-509.1 — honest diagnosis, not a gate: a governed git repo without our
977
+ // pre-push evidence gate is told so once, with the one command that installs it.
978
+ {
979
+ const pg = pushGateCheck(target ?? process.cwd());
980
+ if (pg)
981
+ checks.push(pg);
982
+ }
975
983
  if (extraChecks) {
976
984
  checks.push(...extraChecks);
977
985
  }
@@ -1227,6 +1235,35 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
1227
1235
  send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'holmes-doctor', version: '1' } } });
1228
1236
  });
1229
1237
  }
1238
+ // @implements A-SPEC-509.1
1239
+ /**
1240
+ * Push-gate presence, diagnosed only where it applies: a repo that opted into governance (.ax)
1241
+ * AND has git. Absence or a hook without our signature is a WARN carrying the install command —
1242
+ * the same honest-diagnosis lineage as the codex-wiring WARN (never a gate).
1243
+ */
1244
+ function pushGateCheck(target) {
1245
+ try {
1246
+ if (!fs.existsSync(path.join(target, '.ax')))
1247
+ return null;
1248
+ if (!fs.existsSync(path.join(target, '.git')))
1249
+ return null;
1250
+ const { PUSH_HOOK_SIGNATURE } = require('../governance/push-gate');
1251
+ const { effectiveHooksDir } = require('../governance/push-gate-runner');
1252
+ // core.hooksPath honored (live-probe finding): diagnose where git actually looks.
1253
+ const hookPath = path.join(effectiveHooksDir(target), 'pre-push');
1254
+ const installed = fs.existsSync(hookPath) && fs.readFileSync(hookPath, 'utf8').includes(PUSH_HOOK_SIGNATURE);
1255
+ return installed
1256
+ ? { name: 'push gate', level: 'PASS', detail: 'pre-push 증빙 게이트가 설치되어 있습니다 (test_run 원장 대조)' }
1257
+ : {
1258
+ name: 'push gate', level: 'WARN',
1259
+ detail: 'pre-push 증빙 게이트가 설치되어 있지 않습니다 — 미검증 HEAD 가 다른 에이전트의 기준선이 될 수 있습니다',
1260
+ fix: 'npx holmes-kit install-push-gate 를 실행하십시오 (기존 훅은 덮지 않습니다)',
1261
+ };
1262
+ }
1263
+ catch {
1264
+ return null;
1265
+ } // diagnosis must never crash doctor
1266
+ }
1230
1267
  function formatChecks(checks) {
1231
1268
  const lines = checks.map((c) => {
1232
1269
  const head = `${c.level.padEnd(4)} ${c.name} — ${c.detail}`;
@@ -80,6 +80,8 @@ const KNOWN_FLAGS = {
80
80
  ledger: ['help', 'target', 'ref', 'dry-run'],
81
81
  // @implements A-SPEC-477 — the human's opt-in act for the cloud semantic tier.
82
82
  'semantic-key': ['help'],
83
+ // @implements A-SPEC-509.1 — the explicit installation act for the pre-push evidence gate.
84
+ 'install-push-gate': ['help', 'target'],
83
85
  };
84
86
  // @implements A-SPEC-171 — subcommands that render their OWN usage on `--help`. A-SPEC-171 governs
85
87
  // "help before any side effect"; a command whose usage lives past this handler (approve, whose
@@ -814,17 +816,27 @@ async function main(argv) {
814
816
  process.stderr.write('✗ 대화형 상주 모드(--watch)는 TTY 가 필요합니다 — 스크립트에서는 --list 를 쓰십시오\n');
815
817
  return 1;
816
818
  }
819
+ // @implements A-SPEC-497.2 — the resident surface decides on ONE raw keypress (a/d/q/s, no
820
+ // Enter, no deadline) and pings the OS on each new arrival. Raw mode is restored on the way
821
+ // out whatever happens; Ctrl-C arrives as \x03 through decideByKey and ends the loop.
817
822
  const { runWatch } = require('./approve-watch');
818
- const rl = require('node:readline/promises').createInterface({ input: process.stdin, output: process.stdout });
823
+ const { notifyQueueArrival } = require('./os-notify');
819
824
  const ac = new AbortController();
820
- rl.once('close', () => ac.abort());
821
825
  const onSig = () => ac.abort();
822
826
  process.once('SIGINT', onSig);
827
+ const stdin = process.stdin;
823
828
  try {
829
+ stdin.setRawMode?.(true);
830
+ stdin.resume();
824
831
  await runWatch(root, {
825
832
  print: (t) => process.stdout.write(t + '\n'),
826
- ask: (q) => rl.question(q, { signal: ac.signal }),
827
- }, actor, { signal: ac.signal, ...(pollMs !== undefined ? { pollMs } : {}) });
833
+ ask: async () => '', // unused on the key surface; kept for the ApproveIO shape
834
+ }, actor, {
835
+ signal: ac.signal,
836
+ keyStream: stdin,
837
+ notify: (p) => notifyQueueArrival(p),
838
+ ...(pollMs !== undefined ? { pollMs } : {}),
839
+ });
828
840
  }
829
841
  catch (e) {
830
842
  if (e?.name !== 'AbortError')
@@ -832,7 +844,8 @@ async function main(argv) {
832
844
  }
833
845
  finally {
834
846
  process.removeListener('SIGINT', onSig);
835
- rl.close();
847
+ stdin.setRawMode?.(false);
848
+ stdin.pause();
836
849
  }
837
850
  return 0;
838
851
  }
@@ -886,6 +899,40 @@ async function main(argv) {
886
899
  await runServeCommand({ root: target, port });
887
900
  return 0;
888
901
  }
902
+ // @implements A-SPEC-509.1 — installing the pre-push evidence gate is an EXPLICIT operator act:
903
+ // holmes-kit never rewires a repository's git hooks as a side effect, and it never overwrites a
904
+ // hook it did not write (a foreign pre-push is refused byte-intact, with manual-merge guidance).
905
+ if (cmd === 'install-push-gate') {
906
+ const target = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
907
+ if (!fs.existsSync(path.join(target, '.git'))) {
908
+ process.stderr.write(`✗ ${target} 는 git 저장소가 아닙니다 — pre-push 게이트는 .git/hooks 에 설치됩니다\n`);
909
+ return 1;
910
+ }
911
+ const { PUSH_HOOK_SIGNATURE } = require('../governance/push-gate');
912
+ // Live-probe finding (2026-09-01, this repo): `core.hooksPath` redirects git away from
913
+ // `.git/hooks`, so installing there is a SILENT no-op — wiring presence is not enforcement.
914
+ // Install where git actually looks, honoring the operator's config instead of editing it.
915
+ const { effectiveHooksDir } = require('../governance/push-gate-runner');
916
+ const hookPath = path.join(effectiveHooksDir(target), 'pre-push');
917
+ // dist/holmes/cli → three up is the package root, the same derivation server.ts uses.
918
+ const runner = path.join(path.resolve(__dirname, '..', '..', '..'), 'bin', 'holmes-pre-push.js');
919
+ const script = `#!/usr/bin/env node\n// ${PUSH_HOOK_SIGNATURE} — \`npx holmes-kit install-push-gate\` 가 설치했습니다.\n`
920
+ + `// test-evidence 원장과 push HEAD 를 대조합니다. 로드 실패는 통과(fail-open) — 게이트 오류가 push 를 막지 않습니다.\n`
921
+ + `try { require(${JSON.stringify(runner)}); } catch { process.exit(0); }\n`;
922
+ if (fs.existsSync(hookPath)) {
923
+ const current = fs.readFileSync(hookPath, 'utf8');
924
+ if (!current.includes(PUSH_HOOK_SIGNATURE)) {
925
+ process.stderr.write(`✗ 기존 pre-push 훅이 있고 holmes-kit 이 쓴 것이 아닙니다 — 덮지 않습니다.\n`
926
+ + ` 수동 병합: 기존 훅 끝에 \`node ${runner} || exit 1\` 형태로 이 게이트를 이어 붙이십시오.\n`);
927
+ return 1;
928
+ }
929
+ }
930
+ fs.mkdirSync(path.dirname(hookPath), { recursive: true });
931
+ fs.writeFileSync(hookPath, script);
932
+ fs.chmodSync(hookPath, 0o755);
933
+ process.stdout.write(`✓ pre-push 증빙 게이트 설치 — ${hookPath}\n 통과 조건: test_run 원장 head == push HEAD, 초록, 실행 > 0. 우회: git push --no-verify\n`);
934
+ return 0;
935
+ }
889
936
  if (cmd === 'init') {
890
937
  const mode = (typeof flags.mode === 'string' ? flags.mode : 'guardrail');
891
938
  // @implements A-SPEC-251.1 — an unknown launch mode is refused, not defaulted: substituting a
@@ -0,0 +1,19 @@
1
+ /**
2
+ * OS-native arrival notification for the approval queue — the MAIN LANE's "you are needed" ping.
3
+ * Fire-and-forget by doctrine: a notification failure must change NOTHING about the watch loop,
4
+ * the gate verdicts or the queue (the same swallow rule enqueueApprovalRequest lives by — the
5
+ * moment a dead notifier could change behavior, killing it becomes a lever).
6
+ *
7
+ * kind/target are AGENT-CONTROLLED text, so both are flattened before they reach a notification
8
+ * body: no control bytes, no forged lines in the notification center.
9
+ */
10
+ import { ChildProcess } from 'node:child_process';
11
+ import type { PendingRequest } from '../governance/approval-queue';
12
+ export interface NotifyDeps {
13
+ platform: NodeJS.Platform;
14
+ spawn: (cmd: string, args: string[], opts?: {
15
+ stdio: 'ignore';
16
+ detached?: boolean;
17
+ }) => Pick<ChildProcess, 'unref' | 'on'>;
18
+ }
19
+ export declare function notifyQueueArrival(item: Pick<PendingRequest, 'kind' | 'target'>, deps?: NotifyDeps): void;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.notifyQueueArrival = notifyQueueArrival;
4
+ // @implements A-SPEC-497.2
5
+ /**
6
+ * OS-native arrival notification for the approval queue — the MAIN LANE's "you are needed" ping.
7
+ * Fire-and-forget by doctrine: a notification failure must change NOTHING about the watch loop,
8
+ * the gate verdicts or the queue (the same swallow rule enqueueApprovalRequest lives by — the
9
+ * moment a dead notifier could change behavior, killing it becomes a lever).
10
+ *
11
+ * kind/target are AGENT-CONTROLLED text, so both are flattened before they reach a notification
12
+ * body: no control bytes, no forged lines in the notification center.
13
+ */
14
+ const node_child_process_1 = require("node:child_process");
15
+ const screen_safe_1 = require("./screen-safe");
16
+ function notifyQueueArrival(item, deps = { platform: process.platform, spawn: (cmd, args) => (0, node_child_process_1.spawn)(cmd, args, { stdio: 'ignore', detached: false }) }) {
17
+ try {
18
+ const kind = (0, screen_safe_1.flattenField)(item.kind, 40);
19
+ const target = (0, screen_safe_1.flattenField)(item.target, 80);
20
+ const body = `${kind} — ${target} · 결정: holmes approve`;
21
+ let cmd;
22
+ let args;
23
+ if (deps.platform === 'darwin') {
24
+ // AppleScript strings escape only backslash and double-quote; flatten already removed the rest.
25
+ const esc = body.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
+ cmd = 'osascript';
27
+ args = ['-e', `display notification "${esc}" with title "Holmes-Kit 승인 요청"`];
28
+ }
29
+ else if (deps.platform === 'win32') {
30
+ // Toast via the WinRT bridge; single-quoted PowerShell literal with quotes doubled.
31
+ const esc = body.replace(/'/g, "''");
32
+ cmd = 'powershell.exe';
33
+ args = ['-NoProfile', '-Command',
34
+ `[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; ` +
35
+ `$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); ` +
36
+ `$t = $x.GetElementsByTagName('text'); $t.Item(0).AppendChild($x.CreateTextNode('Holmes-Kit 승인 요청')) > $null; ` +
37
+ `$t.Item(1).AppendChild($x.CreateTextNode('${esc}')) > $null; ` +
38
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('holmes-kit').Show([Windows.UI.Notifications.ToastNotification]::new($x))`];
39
+ }
40
+ else {
41
+ return; // unsupported platform: silent pass-through, by contract
42
+ }
43
+ const child = deps.spawn(cmd, args, { stdio: 'ignore', detached: false });
44
+ child.on?.('error', () => { });
45
+ child.unref?.();
46
+ }
47
+ catch { /* a notification must never take the loop down with it */ }
48
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Where git ACTUALLY looks for hooks. Live probe (2026-09-01): this repo carries
3
+ * `core.hooksPath=.git/hooks-disabled`, so a hook written to `.git/hooks` never ran and the
4
+ * install success message was a lie — presence is not enforcement. A relative hooksPath is
5
+ * resolved against the repo root, matching git's own semantics.
6
+ */
7
+ export declare function effectiveHooksDir(repoRoot: string): string;
8
+ export interface PrePushIo {
9
+ cwd: string;
10
+ stdinText: string;
11
+ /** Refusals and notices land here (stderr in the shipped hook). */
12
+ write: (s: string) => void;
13
+ }
14
+ /** Exit code of the hook: 0 = push proceeds, 1 = refused. */
15
+ export declare function runPrePush(io: PrePushIo): 0 | 1;
@@ -0,0 +1,101 @@
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
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.effectiveHooksDir = effectiveHooksDir;
37
+ exports.runPrePush = runPrePush;
38
+ // @implements A-SPEC-509.1
39
+ /**
40
+ * The push gate's IMPURE half: collect (stdin lines, git facts, the evidence ledger) and hand
41
+ * everything to the pure judgePush. Any collection failure ALLOWS — this gate safens the default
42
+ * and must never be the thing that bricks a push (security stays with the tool gates).
43
+ */
44
+ const fs = __importStar(require("node:fs"));
45
+ const path = __importStar(require("node:path"));
46
+ const node_child_process_1 = require("node:child_process");
47
+ const push_gate_1 = require("./push-gate");
48
+ const test_evidence_1 = require("../review/test-evidence");
49
+ /**
50
+ * Where git ACTUALLY looks for hooks. Live probe (2026-09-01): this repo carries
51
+ * `core.hooksPath=.git/hooks-disabled`, so a hook written to `.git/hooks` never ran and the
52
+ * install success message was a lie — presence is not enforcement. A relative hooksPath is
53
+ * resolved against the repo root, matching git's own semantics.
54
+ */
55
+ function effectiveHooksDir(repoRoot) {
56
+ try {
57
+ const p = (0, node_child_process_1.execFileSync)('git', ['config', 'core.hooksPath'], { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
58
+ if (p)
59
+ return path.isAbsolute(p) ? p : path.join(repoRoot, p);
60
+ }
61
+ catch { /* unset config exits 1 — the default location applies */ }
62
+ return path.join(repoRoot, '.git', 'hooks');
63
+ }
64
+ /** Exit code of the hook: 0 = push proceeds, 1 = refused. */
65
+ function runPrePush(io) {
66
+ try {
67
+ const lines = (0, push_gate_1.parsePushLines)(io.stdinText);
68
+ if (lines.length === 0)
69
+ return 0; // deletions / nothing to judge
70
+ const hasGovernance = fs.existsSync(path.join(io.cwd, '.ax'));
71
+ if (!hasGovernance)
72
+ return 0; // un-adopted: silent
73
+ const evidence = (0, test_evidence_1.readTestEvidence)(io.cwd) ?? null;
74
+ let refused = false;
75
+ for (const line of lines) {
76
+ let claimCommits = [];
77
+ try {
78
+ const range = /^0{40}$/.test(line.remoteSha)
79
+ ? ['-n', '20', line.localSha] // new branch: a bounded recent window
80
+ : [`${line.remoteSha}..${line.localSha}`];
81
+ claimCommits = (0, node_child_process_1.execFileSync)('git', ['log', '--format=%H%x09%s', ...range], { cwd: io.cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
82
+ .split('\n').filter(Boolean).map((l) => {
83
+ const [sha, ...rest] = l.split('\t');
84
+ return { sha, subject: rest.join('\t') };
85
+ });
86
+ }
87
+ catch { /* claim surfacing is optional — judgment inputs stay deterministic */ }
88
+ const v = (0, push_gate_1.judgePush)({ pushedHead: line.localSha, evidence, hasGovernance, claimCommits });
89
+ for (const n of v.notices)
90
+ io.write(n + '\n');
91
+ if (!v.allow) {
92
+ io.write((v.reason ?? '[Holmes-Kit push 게이트] 거부') + '\n');
93
+ refused = true;
94
+ }
95
+ }
96
+ return refused ? 1 : 0;
97
+ }
98
+ catch {
99
+ return 0; // fail-open by contract (H-SPEC-509)
100
+ }
101
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The push gate's PURE judgment. A push is the moment a commit becomes other agents' baseline
3
+ * (field basis: a multi-agent regression reached production unguarded), so the default is made
4
+ * safe: the test-evidence ledger must vouch for EXACTLY the head being pushed, green, with real
5
+ * executions. Deterministic facts only — commit-message prose ("전체 스위트 초록") never moves
6
+ * the verdict; an unbacked claim is SURFACED as a notice so the dishonesty is audible.
7
+ *
8
+ * Deliberately NOT a security boundary: collection failures and un-adopted repos pass (the tool
9
+ * gates own security). And `git push --no-verify` is git's own escape hatch — respected, not
10
+ * re-litigated: this gate safens the default, it does not take the human's final say.
11
+ */
12
+ import type { TestEvidence } from '../review/test-evidence';
13
+ /** Identifies OUR installed hook — the single truth doctor and re-install both key on. */
14
+ export declare const PUSH_HOOK_SIGNATURE = "HOLMES-KIT PUSH GATE v1";
15
+ export interface PushJudgeInput {
16
+ pushedHead: string;
17
+ evidence: TestEvidence | null;
18
+ /** `.ax` exists — the repo opted into governance. */
19
+ hasGovernance: boolean;
20
+ /** Commits in the pushed range, for the claim cross-check (surfacing only). */
21
+ claimCommits: Array<{
22
+ sha: string;
23
+ subject: string;
24
+ }>;
25
+ }
26
+ export interface PushVerdict {
27
+ allow: boolean;
28
+ reason?: string;
29
+ notices: string[];
30
+ }
31
+ export declare function judgePush(input: PushJudgeInput): PushVerdict;
32
+ /**
33
+ * The pre-push stdin format: `<local ref> <local sha> <remote ref> <remote sha>` per line.
34
+ * A deletion push (all-zero local sha) deletes a remote ref — there is nothing to vouch for.
35
+ */
36
+ export declare function parsePushLines(stdinText: string): Array<{
37
+ localSha: string;
38
+ remoteSha: string;
39
+ }>;