@holmes-lab/holmes-kit 0.14.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +2 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.d.ts +14 -0
  5. package/dist/holmes/cli/approve.js +60 -3
  6. package/dist/holmes/cli/gitignore-merge.js +5 -0
  7. package/dist/holmes/cli/index.js +13 -1
  8. package/dist/holmes/governance/approval-queue.d.ts +39 -0
  9. package/dist/holmes/governance/approval-queue.js +105 -10
  10. package/dist/holmes/governance/session-context.d.ts +74 -0
  11. package/dist/holmes/governance/session-context.js +179 -0
  12. package/dist/holmes/hooks/pre-tool-use.js +5 -2
  13. package/dist/holmes/hooks/rtm-refresh-child.d.ts +1 -0
  14. package/dist/holmes/hooks/rtm-refresh-child.js +56 -0
  15. package/dist/holmes/hooks/rtm-refresh.d.ts +13 -0
  16. package/dist/holmes/hooks/rtm-refresh.js +76 -0
  17. package/dist/holmes/hooks/stop.js +42 -0
  18. package/dist/holmes/mcp/handlers.d.ts +5 -6
  19. package/dist/holmes/mcp/handlers.js +76 -2
  20. package/dist/holmes/mcp/server.js +12 -0
  21. package/dist/holmes/mcp/tool-schemas.js +1 -1
  22. package/dist/holmes/review/judgement-bundle.d.ts +49 -0
  23. package/dist/holmes/review/judgement-bundle.js +108 -0
  24. package/dist/holmes/review/run-replay.d.ts +5 -0
  25. package/dist/holmes/review/run-replay.js +32 -0
  26. package/dist/holmes/review/test-outcomes.d.ts +17 -2
  27. package/dist/holmes/review/test-outcomes.js +54 -15
  28. package/dist/holmes/rtm/impact-advisory.d.ts +48 -0
  29. package/dist/holmes/rtm/impact-advisory.js +175 -0
  30. package/dist/holmes/rtm/localize.js +7 -0
  31. package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
  32. package/dist/holmes/rtm/rtm-builder.js +42 -1
  33. package/dist/holmes/rtm/rtm-graph.d.ts +16 -1
  34. package/dist/holmes/rtm/rtm-graph.js +34 -6
  35. package/dist/holmes/spec/approval-blockers.js +8 -0
  36. package/dist/holmes/spec/compat-impact.d.ts +31 -0
  37. package/dist/holmes/spec/compat-impact.js +141 -0
  38. package/dist/holmes/spec/spec-types.js +3 -1
  39. package/package.json +1 -1
@@ -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;
@@ -151,7 +151,8 @@ const cacheDirFor = (root) => {
151
151
  };
152
152
  // @implements A-SPEC-283 — bumped whenever the graph's node/edge shape changes, so a store written
153
153
  // by an older build is rebuilt rather than read with new assumptions.
154
- const RTM_GRAPH_SCHEMA = 'rtm-graph/2';
154
+ // @implements A-SPEC-568.1 /3: nodes gained the intent `summary` column.
155
+ const RTM_GRAPH_SCHEMA = 'rtm-graph/3';
155
156
  const RTM_EXTRACTOR_VERSION = 'holmes-rtm/1';
156
157
  const cachedScan = (root, repoRoot = root) => new cpg_scanner_1.CpgScanner(undefined, new scan_cache_1.ScanFileCache(cacheDirFor(root))).scan(root, repoRoot);
157
158
  // @implements A-SPEC-131
@@ -189,6 +190,7 @@ const approval_grants_1 = require("../governance/approval-grants");
189
190
  const spec_digest_1 = require("../spec/spec-digest");
190
191
  const spec_store_2 = require("../spec/spec-store");
191
192
  const breaking_change_1 = require("../spec/breaking-change");
193
+ const compat_impact_1 = require("../spec/compat-impact");
192
194
  const approval_blockers_1 = require("../spec/approval-blockers");
193
195
  const approval_status_1 = require("../spec/approval-status");
194
196
  const ledger_timeline_1 = require("../governance/ledger-timeline");
@@ -1560,6 +1562,22 @@ function makeRawHandlers(store, opts) {
1560
1562
  const breakingIssue = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
1561
1563
  if (breakingIssue)
1562
1564
  return { ok: false, reason: breakingIssue };
1565
+ // @implements A-SPEC-565.1 — the compat declaration duty rides the SAME act (REQ-565): sealing
1566
+ // is when "did you consider the three harnesses and the three OSes" is due, and act-time is
1567
+ // what keeps 512 already-approved specs out of retroactive violation (the 38-violation incident
1568
+ // above). The bound reader feeds the OS cross-check from this root's working tree.
1569
+ const compatIssue = (0, compat_impact_1.checkCompatDeclared)(candidate, {
1570
+ // No root → no working tree to read: the OS cross-check skips file-by-file (fail-open),
1571
+ // while the declaration syntax itself is still enforced — the duty never depends on `root`.
1572
+ readFile: (rel) => { try {
1573
+ return a.root ? fs.readFileSync(path.join(a.root, rel), 'utf8') : null;
1574
+ }
1575
+ catch {
1576
+ return null;
1577
+ } },
1578
+ });
1579
+ if (compatIssue)
1580
+ return { ok: false, reason: compatIssue };
1563
1581
  // @implements A-SPEC-182
1564
1582
  // A document whose prose is still the generator's placeholder must not be sealed. Measured
1565
1583
  // 2026-08-13 on a brownfield adoption: H-SPEC-100 took `status: approved` and an
@@ -1667,7 +1685,46 @@ function makeRawHandlers(store, opts) {
1667
1685
  if (approveResolved.source === 'grant' && approveResolved.root && approveResolved.approval.nonce) {
1668
1686
  (0, approval_grants_1.consumeGrantFile)(approveResolved.root, approveResolved.approval.nonce);
1669
1687
  }
1670
- return { approved: a.id, digest };
1688
+ // @implements A-SPEC-566.2 — the impact advisory rides the SUCCESS, after the seal is done:
1689
+ // the verdict is already committed, so nothing here can change it (advisory, never gate —
1690
+ // Judgments must not be budgeted). Reuses the persisted graph READ-ONLY; it never scans,
1691
+ // parses or builds (scan:build measured 20~38x — an approval must not pay that), and every
1692
+ // failure below degrades to "no advisory field" on an otherwise identical response.
1693
+ let impactAdvisory;
1694
+ try {
1695
+ if (spec.type === 'A-SPEC' && a.root) {
1696
+ const dbPath = path.join(a.root, '.ax', 'rtm.sqlite');
1697
+ if (fs.existsSync(dbPath)) {
1698
+ const { declaredImpactGap, appendImpactAdvisory } = require('../rtm/impact-advisory');
1699
+ const { filesToTouch } = require('../spec/compat-impact');
1700
+ const { RtmGraph } = require('../rtm/rtm-graph');
1701
+ const graph = new RtmGraph(dbPath);
1702
+ const gap = declaredImpactGap(filesToTouch(candidate), graph, (rel) => { try {
1703
+ return fs.readFileSync(path.join(a.root, rel), 'utf8');
1704
+ }
1705
+ catch {
1706
+ return null;
1707
+ } });
1708
+ if (gap) {
1709
+ const graphAsOf = (() => { try {
1710
+ return fs.statSync(dbPath).mtime.toISOString();
1711
+ }
1712
+ catch {
1713
+ return undefined;
1714
+ } })();
1715
+ impactAdvisory = { ...gap, ...(graphAsOf ? { graphAsOf } : {}) };
1716
+ appendImpactAdvisory(a.root, {
1717
+ aspec: a.id, files: gap.files.map((f) => f.path), more: gap.more,
1718
+ ...(graphAsOf ? { graphAsOf } : {}), ts: new Date().toISOString(),
1719
+ });
1720
+ }
1721
+ }
1722
+ }
1723
+ }
1724
+ catch {
1725
+ impactAdvisory = undefined;
1726
+ }
1727
+ return { approved: a.id, digest, ...(impactAdvisory ? { impactAdvisory } : {}) };
1671
1728
  },
1672
1729
  async spec_list(a) {
1673
1730
  assertSpecStoreReachable('spec_list', store, a.root); // @implements A-SPEC-419
@@ -2188,6 +2245,9 @@ function makeRawHandlers(store, opts) {
2188
2245
  // (cached vectors only, set fixed, covered hits move, why-line attached). Any missing
2189
2246
  // signal — no tier, no key, cold cache, embed failure — leaves the report untouched;
2190
2247
  // localization itself never fails because of the semantic layer.
2248
+ //
2249
+ // A spec-intent-vector assist (REQ-568 S3) was wired ahead of localizeIssue here and REVERTED
2250
+ // on its pre-registered replay — see rtm/localize.ts at the matchedSpecs join for the numbers.
2191
2251
  try {
2192
2252
  if (report.hits.length > 1
2193
2253
  && (0, localize_1.citationsIn)(a.issue, new Set(governed.map((s) => s.id))).cited.length === 0) {
@@ -2491,8 +2551,20 @@ function makeRawHandlers(store, opts) {
2491
2551
  .map((id) => (id.includes('@') ? id.slice(id.lastIndexOf('@') + 1) : ''))
2492
2552
  .filter((f) => f !== ''));
2493
2553
  const rankedImpact = (0, assoc_arm_1.pprImpactRanked)((0, assoc_arm_1.graphViewOf)(g.dumpCanonical()), riSeeds, riExclude, assoc_arm_1.RANKED_IMPACT_K, assoc_arm_1.RANKED_IMPACT_CONFIG);
2554
+ // @implements A-SPEC-568.2 — the intent sentence beside every impacted spec id, same order
2555
+ // as `impacted` (which stays a bare id list for its existing consumers). Information only:
2556
+ // nothing reads it back into the walk, the ranking or any gate.
2557
+ const impactedSummaries = impacted.map((id) => {
2558
+ let summary = null;
2559
+ try {
2560
+ summary = g.summaryOf(id);
2561
+ }
2562
+ catch { /* summary stays null */ }
2563
+ return { id, summary };
2564
+ });
2494
2565
  return {
2495
2566
  impacted,
2567
+ impactedSummaries,
2496
2568
  rankedImpact,
2497
2569
  reachedByDepth,
2498
2570
  bounded: stoppedAt.length > 0 ? stoppedAt.slice(0, 20) : undefined,
@@ -3347,6 +3419,8 @@ independent_test: true
3347
3419
  depends_on:
3348
3420
  - ${hspecId}
3349
3421
  breaking_change: 'none'
3422
+ harness_impact: 'none: TODO — 3하네스(claude/codex/agy) 영향 검토 후 기술'
3423
+ os_impact: 'none: TODO — 3OS(windows/mac/linux) 영향 검토 후 기술'
3350
3424
  ---
3351
3425
 
3352
3426
  ## Objective
@@ -42,6 +42,17 @@ const handlers = (0, handlers_1.makeHandlers)(store, {
42
42
  return 'unknown';
43
43
  } },
44
44
  });
45
+ // @implements A-SPEC-564.1 — the session-context stamp: WHO attached, name AND version (the version
46
+ // was received and dropped for months). Written HERE because the server is the one place all three
47
+ // harnesses pass through; lazy-once via the call path (clientInfo exists only after initialize, and
48
+ // the ledger's root arrives with the first rooted call). Fail-open by construction.
49
+ const { makeSessionStamper } = require('../governance/session-context');
50
+ const stampSession = makeSessionStamper(() => { try {
51
+ return server.getClientVersion();
52
+ }
53
+ catch {
54
+ return undefined;
55
+ } });
45
56
  // @implements A-SPEC-259 — the advertised version is the package's own, not a literal that froze at
46
57
  // 0.1.0: a hardcoded serverInfo.version blinds any client-side drift diagnosis.
47
58
  const PKG_VERSION = (() => {
@@ -109,6 +120,7 @@ const TOOLS = Object.keys(handlers)
109
120
  });
110
121
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({ tools: TOOLS }));
111
122
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
123
+ stampSession(req.params.arguments?.root);
112
124
  // @implements A-SPEC-189
113
125
  // The server is the first consumer of its own advertised schemas. Before this check, 15 of 26
114
126
  // handlers threw raw internal errors at `{}` over the wire, and a one-key typo in reverse_anchor