@holmes-lab/holmes-kit 0.15.0 → 0.16.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 CHANGED
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
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
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.16.0] - 2026-09-07
9
+
10
+ The graph learns to speak intent, and the call graph learns to speak up at sealing time. An A-SPEC
11
+ approval now returns what your Files-to-Touch declaration *missed* — and every spec the graph
12
+ names arrives with its one-sentence intent beside the id, extracted (never generated) from the
13
+ spec store. Two more mechanisms were benchmarked under pre-registration and honestly discarded.
14
+
15
+ ### Added
16
+ - **Impact advisory on approval (REQ-566)** — a successful A-SPEC `spec_approve` may now carry
17
+ `impactAdvisory`: files whose symbols **call into** the declared Files-to-Touch but sit outside
18
+ the declaration (1-hop, capped at 10, repo-relative allow-list). Advisory, never verdict: it
19
+ rides the response *after* the seal is committed, degrades to absence on any failure, and every
20
+ emission lands in an observation ledger (`.ax/ledger/impact-advisories.<replica>.jsonl` — paths,
21
+ ids and integers only) so its false-positive rate is measured *before* anyone proposes a hard
22
+ gate. The RTM graph persists at `.ax/rtm.sqlite` and keeps itself fresh: `rtm_impact` rebuilds
23
+ on basis drift, and the Stop hook spawns a TTL-gated detached reindex (staleness was measured as
24
+ the advisory's quality factor: 7 findings on an 8-day-old graph, 17 after a fresh one).
25
+ - **SPEC intent summaries in the graph (REQ-568)** — graph nodes gain a `summary` column (schema
26
+ `rtm-graph/2` → `/3`; old stores rebuild automatically on first use, no migration step). Every
27
+ SPEC node stores `"<title> — <first sentence of its intent section>"`, extracted
28
+ deterministically (REQ=Problem / Need, H-SPEC=Intent, A-SPEC=Objective; 200-char cap; title-less
29
+ legacy specs — 20 measured in this very store — fall back to sentence, then id). Same store,
30
+ byte-identical graph; measured cost +6.4% build time / +4.2% file size. `RtmGraph.summaryOf(id)`
31
+ reads it back, and `rtm_impact` responses add `impactedSummaries: [{id, summary}]` beside the
32
+ unchanged `impacted` list.
33
+ - **Session-context observability (REQ-564)** — the ledger records which agent/model drove a
34
+ session and what the governance overhead cost, per replica
35
+ (`.ax/ledger/session-context.<replica>.jsonl`), grounding field reports in machine attribution.
36
+
37
+ ### Changed
38
+ - **`impactAdvisory.files[].anchors` shape (REQ-568, code-interface)** — anchors are now
39
+ `[{id, summary}]` instead of `string[]`, so the reader sees *which intent* is at risk without a
40
+ spec-store round trip. Consumers parsing anchors must read `.id`. The summary is information
41
+ only: value tests pin that the advisory's file list, ordering and `more` count are independent
42
+ of the prose, and the observation ledger stays prose-free.
43
+
44
+ ### Notes
45
+ - Two pre-registered negatives, kept as records rather than shipped: the judgement-axis
46
+ FACT/EVIDENCE bundle tied its bench (0.480 vs 0.478) and was discarded with its re-attempt
47
+ conditions sealed (REQ-567, 15th rejection); a spec-intent **vector assist** for uncited
48
+ requests was built, measured byte-identical to the lexical path over 441 held-out traceability
49
+ cases (long queries already match ~371 specs lexically; the lexical-zero segment is protected by
50
+ the reorder-not-admit rule), reverted, and its A/T-SPECs retired (REQ-568 S3, 16th rejection).
51
+ The S-491 embedding win belongs to direct query→file ranking and does not transport through the
52
+ spec-matching layer.
53
+
8
54
  ## [0.15.0] - 2026-09-06
9
55
 
10
56
  The first field report on Holmes-Kit's own operational data (1,412 unique approval requests vs. 8
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 2a7fb45-mtps37rf
1
+ b540876-mtqeiitb
@@ -27,6 +27,9 @@ export interface RefusalRecord {
27
27
  why: string;
28
28
  ts: string;
29
29
  replica?: string;
30
+ /** @implements A-SPEC-564.2 — the byte size of the deny feedback this refusal put back into the
31
+ * conversation (base reason + queue hint). Observation only; absent on legacy records. */
32
+ reasonBytes?: number;
30
33
  }
31
34
  export declare function refusalsFilename(replica: string): string;
32
35
  /** Born split: there is no legacy single `refusals.jsonl` — the file did not exist before replicas did. */
@@ -121,6 +124,7 @@ export declare function enqueueApprovalRequest(root: string, req: {
121
124
  kind: string;
122
125
  target: string;
123
126
  why: string;
127
+ reasonBytes?: number;
124
128
  }): boolean;
125
129
  /** Read and fold the queue on disk. A missing file is an empty queue, not an error. */
126
130
  export declare function readQueue(root: string, opts?: {
@@ -137,4 +141,6 @@ export declare function queueHint(root: string, req: {
137
141
  kind: string;
138
142
  target: string;
139
143
  why: string;
144
+ }, opts?: {
145
+ baseReasonBytes?: number;
140
146
  }): string;
@@ -281,6 +281,10 @@ function enqueueApprovalRequest(root, req) {
281
281
  why: req.why,
282
282
  ts: new Date().toISOString(),
283
283
  ...(replica !== undefined && replica !== '' ? { replica } : {}),
284
+ // @implements A-SPEC-564.2 — only the refusal log carries the feedback cost; inbox kinds have
285
+ // no deny text to measure.
286
+ ...(!exports.DECISION_KINDS.has(req.kind) && typeof req.reasonBytes === 'number' && Number.isFinite(req.reasonBytes)
287
+ ? { reasonBytes: Math.max(0, Math.round(req.reasonBytes)) } : {}),
284
288
  };
285
289
  // TYPE BEFORE WRITE, for the same reason as the read (round-7): `appendFileSync` on a FIFO with
286
290
  // no reader blocks in open(2) forever. Guarding only the reader left the GATE wedged — measured:
@@ -344,7 +348,7 @@ function readQueue(root, opts) {
344
348
  * Appended ONLY when the enqueue succeeded: a hint naming an id that was never written would send
345
349
  * the operator to an empty list.
346
350
  */
347
- function queueHint(root, req) {
351
+ function queueHint(root, req, opts) {
348
352
  const id = approvalRequestId(req.kind, req.target);
349
353
  // @implements A-SPEC-246
350
354
  // Decisions are read BEFORE filing, so a human's answer reaches the agent in the very refusal
@@ -375,7 +379,11 @@ function queueHint(root, req) {
375
379
  const denialLine = decided?.event === 'denied'
376
380
  ? (0, screen_safe_1.wrapColumns)(`\n[거부됨${decided.reason ? `: ${(0, screen_safe_1.flattenField)(decided.reason, 300)}` : ''}] 같은 요청을 반복하지 말고 거부 사유를 해소해 사용자와 상의하십시오.`, 76, ' \u2502 ')
377
381
  : '';
378
- return enqueueApprovalRequest(root, req)
379
- ? `${denialLine}\n[승인 대기 ${id}] 운영자: ${(0, npx_bin_1.npxBin)()} holmes-kit approve`
382
+ // @implements A-SPEC-564.2 — the hint is composed BEFORE the enqueue so the refusal record can
383
+ // carry its own feedback cost: reasonBytes = the caller's base reason + this hint, i.e. the text
384
+ // that actually re-enters the conversation. Observation only — nothing reads it on a gate path.
385
+ const hint = `${denialLine}\n[승인 대기 ${id}] 운영자: ${(0, npx_bin_1.npxBin)()} holmes-kit approve`;
386
+ return enqueueApprovalRequest(root, { ...req, reasonBytes: (opts?.baseReasonBytes ?? 0) + hint.length })
387
+ ? hint
380
388
  : denialLine;
381
389
  }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The session-context ledger: WHO attached (agent name@version) and — where a harness exposes it —
3
+ * WITH WHAT (model ids, token totals). REQ-564's whole point is that none of this existed anywhere
4
+ * in the ledgers (measured across provenance, outcomes and the queue), while the sources were one
5
+ * line away: MCP clientInfo carries {name, version} for ALL three harnesses (the server used the
6
+ * name and dropped the version), and Claude's transcript carries model + usage per message.
7
+ *
8
+ * HARNESS-NEUTRAL BY CONSTRUCTION: the client stamp is written by the MCP SERVER, which every
9
+ * harness talks to — the first design pinned it on the Stop hook and was caught as Claude-biased
10
+ * (the owner's catch that also produced REQ-565's gate). Enrichment beyond the stamp is
11
+ * capability-declared per harness, never faked.
12
+ *
13
+ * NUMBERS ONLY. Records carry names, versions, model ids and integers. The transcript's
14
+ * conversation text, commands and paths have no field to land in — the same structural privacy
15
+ * QueueEventLite uses. Observation NEVER feeds a verdict (Judgments must not be budgeted).
16
+ */
17
+ export interface UsageTotals {
18
+ input: number;
19
+ output: number;
20
+ cacheRead: number;
21
+ cacheCreation: number;
22
+ }
23
+ export interface ClientRecord {
24
+ kind: 'client';
25
+ sessionKey: string;
26
+ client: string;
27
+ clientVersion: string;
28
+ ts: string;
29
+ replica?: string;
30
+ }
31
+ export interface UsageRecord {
32
+ kind: 'usage';
33
+ sessionKey: string;
34
+ models: string[];
35
+ usage: UsageTotals;
36
+ turns: number;
37
+ truncated?: boolean;
38
+ ts: string;
39
+ replica?: string;
40
+ }
41
+ export type SessionContextRecord = ClientRecord | UsageRecord;
42
+ export declare function sessionContextFilename(replica: string): string;
43
+ /** Born split: no legacy single file — this ledger never existed before replicas did. */
44
+ export declare function isSessionContextFilename(name: string): boolean;
45
+ /** Append to THIS machine's chain. Fail-open: observation must never break the observed. */
46
+ export declare function appendSessionContext(root: string, rec: SessionContextRecord): boolean;
47
+ /** Every replica's records, merged by ts (stable). Missing dir → []; corrupt lines skipped. */
48
+ export declare function readSessionContext(root: string): SessionContextRecord[];
49
+ /**
50
+ * @implements A-SPEC-564.1
51
+ * The lazy-once stamper the MCP server wires into its call path. Lazy because `getClientVersion()`
52
+ * has a value only AFTER initialize — the first tool call is the earliest honest moment — and
53
+ * because the ledger's location arrives with the first `root`-carrying call. Once, because a
54
+ * session has one identity; a root-less call defers rather than consumes the chance.
55
+ */
56
+ export declare function makeSessionStamper(info: () => {
57
+ name?: string;
58
+ version?: string;
59
+ } | undefined, append?: (root: string, rec: ClientRecord) => boolean, sessionKey?: string): (root: unknown) => void;
60
+ /**
61
+ * @implements A-SPEC-564.2
62
+ * Numbers out of a Claude transcript — model ids, the four usage sums, a turn count. Streaming and
63
+ * capped: a giant transcript yields an honest partial sum with `truncated`, never a slow Stop (a
64
+ * huge response once blocked the very fix that would have shrunk it — observation must not repeat
65
+ * that shape). The return type has no field a conversation could leak through.
66
+ */
67
+ export declare function summarizeTranscript(lines: Iterable<string>, opts?: {
68
+ maxBytes?: number;
69
+ }): {
70
+ models: string[];
71
+ usage: UsageTotals;
72
+ turns: number;
73
+ truncated: boolean;
74
+ };
@@ -0,0 +1,179 @@
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.sessionContextFilename = sessionContextFilename;
37
+ exports.isSessionContextFilename = isSessionContextFilename;
38
+ exports.appendSessionContext = appendSessionContext;
39
+ exports.readSessionContext = readSessionContext;
40
+ exports.makeSessionStamper = makeSessionStamper;
41
+ exports.summarizeTranscript = summarizeTranscript;
42
+ // @implements A-SPEC-564.1
43
+ const fs = __importStar(require("node:fs"));
44
+ const path = __importStar(require("node:path"));
45
+ const replica_id_1 = require("./replica-id");
46
+ const FILE_RE = /^session-context\.([^.]+)\.jsonl$/;
47
+ function sessionContextFilename(replica) {
48
+ return `session-context.${replica}.jsonl`;
49
+ }
50
+ /** Born split: no legacy single file — this ledger never existed before replicas did. */
51
+ function isSessionContextFilename(name) {
52
+ return FILE_RE.test(name);
53
+ }
54
+ /** Append to THIS machine's chain. Fail-open: observation must never break the observed. */
55
+ function appendSessionContext(root, rec) {
56
+ try {
57
+ if (!fs.existsSync(path.join(root, '.ax')))
58
+ return false;
59
+ let replica = 'local';
60
+ try {
61
+ replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
62
+ }
63
+ catch { /* keep the fallback */ }
64
+ const file = path.join(root, '.ax', 'ledger', sessionContextFilename(replica));
65
+ fs.mkdirSync(path.dirname(file), { recursive: true });
66
+ fs.appendFileSync(file, `${JSON.stringify({ ...rec, replica })}\n`);
67
+ return true;
68
+ }
69
+ catch {
70
+ return false;
71
+ }
72
+ }
73
+ /** Every replica's records, merged by ts (stable). Missing dir → []; corrupt lines skipped. */
74
+ function readSessionContext(root) {
75
+ const dir = path.join(root, '.ax', 'ledger');
76
+ let names;
77
+ try {
78
+ names = fs.readdirSync(dir).filter(isSessionContextFilename).sort();
79
+ }
80
+ catch {
81
+ return [];
82
+ }
83
+ const out = [];
84
+ let i = 0;
85
+ for (const name of names) {
86
+ let text;
87
+ try {
88
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
89
+ }
90
+ catch {
91
+ continue;
92
+ }
93
+ for (const line of text.split('\n')) {
94
+ const s = line.trim();
95
+ if (!s)
96
+ continue;
97
+ try {
98
+ const r = JSON.parse(s);
99
+ if (r && typeof r === 'object' && (r.kind === 'client' || r.kind === 'usage')
100
+ && typeof r.sessionKey === 'string' && typeof r.ts === 'string') {
101
+ out.push({ r: r, i: i++ });
102
+ }
103
+ }
104
+ catch { /* a corrupt line never breaks the read */ }
105
+ }
106
+ }
107
+ return out.sort((a, b) => (a.r.ts < b.r.ts ? -1 : a.r.ts > b.r.ts ? 1 : a.i - b.i)).map((e) => e.r);
108
+ }
109
+ /**
110
+ * @implements A-SPEC-564.1
111
+ * The lazy-once stamper the MCP server wires into its call path. Lazy because `getClientVersion()`
112
+ * has a value only AFTER initialize — the first tool call is the earliest honest moment — and
113
+ * because the ledger's location arrives with the first `root`-carrying call. Once, because a
114
+ * session has one identity; a root-less call defers rather than consumes the chance.
115
+ */
116
+ function makeSessionStamper(info, append = appendSessionContext, sessionKey = `mcp-${process.pid}`) {
117
+ let stamped = false;
118
+ return (root) => {
119
+ if (stamped || typeof root !== 'string' || root === '')
120
+ return;
121
+ try {
122
+ const v = (() => { try {
123
+ return info();
124
+ }
125
+ catch {
126
+ return undefined;
127
+ } })();
128
+ stamped = append(root, {
129
+ kind: 'client',
130
+ sessionKey,
131
+ client: v?.name ?? 'unknown',
132
+ clientVersion: v?.version ?? 'unknown',
133
+ ts: new Date().toISOString(),
134
+ });
135
+ }
136
+ catch { /* observation must never break the observed call */ }
137
+ };
138
+ }
139
+ /**
140
+ * @implements A-SPEC-564.2
141
+ * Numbers out of a Claude transcript — model ids, the four usage sums, a turn count. Streaming and
142
+ * capped: a giant transcript yields an honest partial sum with `truncated`, never a slow Stop (a
143
+ * huge response once blocked the very fix that would have shrunk it — observation must not repeat
144
+ * that shape). The return type has no field a conversation could leak through.
145
+ */
146
+ function summarizeTranscript(lines, opts) {
147
+ const maxBytes = opts?.maxBytes ?? 64 * 1024 * 1024;
148
+ const models = [];
149
+ const usage = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
150
+ let turns = 0;
151
+ let bytes = 0;
152
+ let truncated = false;
153
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
154
+ for (const line of lines) {
155
+ bytes += line.length + 1;
156
+ if (bytes > maxBytes) {
157
+ truncated = true;
158
+ break;
159
+ }
160
+ let m;
161
+ try {
162
+ const d = JSON.parse(line);
163
+ m = d && typeof d === 'object' ? d.message : undefined;
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (!m || typeof m !== 'object' || !m.usage || typeof m.usage !== 'object')
169
+ continue;
170
+ turns += 1;
171
+ if (typeof m.model === 'string' && m.model !== '' && !models.includes(m.model))
172
+ models.push(m.model);
173
+ usage.input += num(m.usage.input_tokens);
174
+ usage.output += num(m.usage.output_tokens);
175
+ usage.cacheRead += num(m.usage.cache_read_input_tokens);
176
+ usage.cacheCreation += num(m.usage.cache_creation_input_tokens);
177
+ }
178
+ return { models, usage, turns, truncated };
179
+ }
@@ -461,8 +461,11 @@ function evaluateHook(input, specsDir, opts) {
461
461
  // @implements A-SPEC-244 — the refusal itself files the review request. The queue kind is the
462
462
  // approval SCOPE kind ('shell'), so the reviewing CLI can mint a covering grant mechanically.
463
463
  if (!covers) {
464
- return deny('requires an approval that covers this command'
465
- + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'shell', target: command, why: assessment.reasons.join('; ') }));
464
+ // @implements A-SPEC-564.2 — the base reason's length rides along so the refusal record
465
+ // knows the FULL feedback cost (base + hint), not just the hint's half.
466
+ const base = 'requires an approval that covers this command';
467
+ return deny(base
468
+ + (0, approval_queue_1.queueHint)(opts.projectRoot, { kind: 'shell', target: command, why: assessment.reasons.join('; ') }, { baseReasonBytes: base.length }));
466
469
  }
467
470
  // @implements A-SPEC-141
468
471
  // Check and spend in ONE atomic operation. The previous shape asked `isNonceConsumed(...)`
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,56 @@
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
+ // @implements A-SPEC-566.4
37
+ /**
38
+ * The detached refresh child — the ONLY place this slice touches the scan/build machinery. Spawned
39
+ * by `maybeSpawnRtmRefresh` (rtm-refresh.ts) outside any turn; nothing in the hook path imports
40
+ * this file, which is what keeps A-SPEC-510.2 honest (no wasm rides into a gate process).
41
+ * rtm_impact's warm path IS the refresh: hash-cached scan, reusable-graph rebuild when stale.
42
+ */
43
+ const fs = __importStar(require("node:fs"));
44
+ const path = __importStar(require("node:path"));
45
+ if (require.main === module) {
46
+ try {
47
+ const root = process.argv[2];
48
+ if (root && fs.existsSync(path.join(root, '.ax'))) {
49
+ const { makeHandlers } = require('../mcp/handlers');
50
+ const { LocalMarkdownRepository } = require('../spec/spec-store');
51
+ const h = makeHandlers(new LocalMarkdownRepository(path.join(root, '.ax', 'specs')));
52
+ void h.rtm_impact({ root, changed: [] }).catch(() => undefined);
53
+ }
54
+ }
55
+ catch { /* fail-soft: a failed refresh leaves the old graph, which the advisory tolerates */ }
56
+ }
@@ -0,0 +1,13 @@
1
+ export declare const RTM_REFRESH_TTL_MS: number;
2
+ export declare const RTM_REFRESH_MARKER: string;
3
+ export interface RtmRefreshOpts {
4
+ root: string;
5
+ now: number;
6
+ execPath: string;
7
+ scriptPath: string;
8
+ spawn: (cmd: string, args: string[], opts: object) => {
9
+ unref?: () => void;
10
+ };
11
+ ttlMs?: number;
12
+ }
13
+ export declare function maybeSpawnRtmRefresh(opts: RtmRefreshOpts): void;
@@ -0,0 +1,76 @@
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.RTM_REFRESH_MARKER = exports.RTM_REFRESH_TTL_MS = void 0;
37
+ exports.maybeSpawnRtmRefresh = maybeSpawnRtmRefresh;
38
+ // @implements A-SPEC-566.4
39
+ /**
40
+ * Graph freshness, kept by the product itself. The impact advisory's quality factor was MEASURED to
41
+ * be staleness (an 8-day-old graph found 7 advisories; a 1-second reindex found 17), so the Stop
42
+ * hook — the moment changes exist — spawns a DETACHED reindex behind a TTL gate. The idiom is
43
+ * A-SPEC-547.2's update refresh verbatim: TTL file, detached child, fail-soft everywhere; the
44
+ * parent never waits (zero turn latency) and the approval path still only ever REOPENS the graph.
45
+ */
46
+ const fs = __importStar(require("node:fs"));
47
+ const path = __importStar(require("node:path"));
48
+ exports.RTM_REFRESH_TTL_MS = 30 * 60 * 1000;
49
+ exports.RTM_REFRESH_MARKER = path.join('.ax', 'state', 'rtm-refresh-last');
50
+ function maybeSpawnRtmRefresh(opts) {
51
+ try {
52
+ if (!fs.existsSync(path.join(opts.root, '.ax')))
53
+ return; // nothing to refresh
54
+ const marker = path.join(opts.root, exports.RTM_REFRESH_MARKER);
55
+ const ttl = opts.ttlMs ?? exports.RTM_REFRESH_TTL_MS;
56
+ try {
57
+ const age = opts.now - fs.statSync(marker).mtimeMs;
58
+ if (age < ttl)
59
+ return; // one refresh per window
60
+ }
61
+ catch { /* absent marker → refresh is due */ }
62
+ // Touch BEFORE spawning: a burst of Stops inside one window must not stampede children.
63
+ try {
64
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
65
+ fs.writeFileSync(marker, String(opts.now));
66
+ fs.utimesSync(marker, new Date(opts.now), new Date(opts.now));
67
+ }
68
+ catch { /* an unwritable marker is not a reason to skip the refresh itself */ }
69
+ const child = opts.spawn(opts.execPath, [opts.scriptPath, opts.root], { detached: true, stdio: 'ignore' });
70
+ child?.unref?.();
71
+ }
72
+ catch { /* freshness is maintenance, never a hook failure */ }
73
+ }
74
+ // The child lives in rtm-refresh-child.ts, NOT here: this module is required by the Stop hook, and
75
+ // the gate path must never carry the AST substrate (A-SPEC-510.2 pinned it, and its static check
76
+ // caught the first cut of this slice doing exactly that). The gate side is fs+path only.
@@ -910,6 +910,48 @@ if (require.main === module) {
910
910
  const unrecorded = unrecordedApprovals(stopProjectRoot());
911
911
  // @implements A-SPEC-455
912
912
  const rolledBack = rolledBackLedgers(stopProjectRoot());
913
+ // @implements A-SPEC-564.2 — the Claude usage enrichment: numbers out of transcript_path into
914
+ // the session-context ledger, once per session. Capability-declared: Codex/AGY pass no
915
+ // transcript_path and skip silently. Fail-open around EVERYTHING — observation never touches
916
+ // the verdict below (Judgments must not be budgeted), and a giant transcript yields a capped
917
+ // partial sum, never a slow Stop.
918
+ try {
919
+ const tPath = input.transcript_path;
920
+ if (typeof tPath === 'string' && tPath !== '') {
921
+ const sc = require('../governance/session-context');
922
+ const root = stopProjectRoot();
923
+ const already = sc.readSessionContext(root).some((r) => r.kind === 'usage' && r.sessionKey === sessionId);
924
+ if (!already) {
925
+ // Adversarial round (2026-09-06): a FIFO at transcript_path blocked this read FOREVER,
926
+ // wedging every turn end — the queue writer's round-7 lesson, replayed on the observer.
927
+ // TYPE BEFORE READ: only a regular file is a transcript; anything else is a silent skip.
928
+ if (!fs.lstatSync(tPath).isFile())
929
+ throw new Error('not a regular file');
930
+ const text = fs.readFileSync(tPath, 'utf8');
931
+ const sum = sc.summarizeTranscript(text.split('\n'));
932
+ if (sum.turns > 0) {
933
+ sc.appendSessionContext(root, {
934
+ kind: 'usage', sessionKey: sessionId, models: sum.models, usage: sum.usage,
935
+ turns: sum.turns, ...(sum.truncated ? { truncated: true } : {}), ts: new Date().toISOString(),
936
+ });
937
+ }
938
+ }
939
+ }
940
+ }
941
+ catch { /* silent skip — the other harnesses' path, and any read failure, land here */ }
942
+ // @implements A-SPEC-566.4 — graph freshness rides the same turn boundary, detached and
943
+ // TTL-gated (A-SPEC-547.2's idiom): staleness was MEASURED to be the advisory's quality
944
+ // factor (7 findings on an 8-day graph, 17 after a 1s reindex). Never waits, never judges.
945
+ try {
946
+ const { maybeSpawnRtmRefresh } = require('./rtm-refresh');
947
+ const cp = require('node:child_process');
948
+ maybeSpawnRtmRefresh({
949
+ root: stopProjectRoot(), now: Date.now(), execPath: process.execPath,
950
+ scriptPath: path.resolve(__dirname, 'rtm-refresh-child.js'),
951
+ spawn: (cmd, args, o) => cp.spawn(cmd, args, o),
952
+ });
953
+ }
954
+ catch { /* maintenance, never a hook failure */ }
913
955
  let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec });
914
956
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
915
957
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
@@ -261,23 +261,18 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
261
261
  reason: string;
262
262
  findings?: undefined;
263
263
  conflict?: undefined;
264
- approved?: undefined;
265
- digest?: undefined;
266
264
  } | {
267
265
  ok: boolean;
268
266
  reason: string;
269
267
  findings: import("../spec/validator").Finding[];
270
268
  conflict?: undefined;
271
- approved?: undefined;
272
- digest?: undefined;
273
269
  } | {
274
270
  ok: boolean;
275
271
  reason: string;
276
272
  conflict: import("../spec/version-conflict").ConflictDetail;
277
273
  findings?: undefined;
278
- approved?: undefined;
279
- digest?: undefined;
280
274
  } | {
275
+ impactAdvisory?: import("../rtm/impact-advisory").ImpactAdvisory | undefined;
281
276
  approved: string;
282
277
  digest: string;
283
278
  ok?: undefined;
@@ -536,6 +531,10 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
536
531
  }): Promise<{
537
532
  breadthWarning?: string | undefined;
538
533
  impacted: string[];
534
+ impactedSummaries: {
535
+ id: string;
536
+ summary: string | null;
537
+ }[];
539
538
  rankedImpact: {
540
539
  file: string;
541
540
  score: number;