@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 +46 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/governance/approval-queue.d.ts +6 -0
- package/dist/holmes/governance/approval-queue.js +11 -3
- package/dist/holmes/governance/session-context.d.ts +74 -0
- package/dist/holmes/governance/session-context.js +179 -0
- package/dist/holmes/hooks/pre-tool-use.js +5 -2
- package/dist/holmes/hooks/rtm-refresh-child.d.ts +1 -0
- package/dist/holmes/hooks/rtm-refresh-child.js +56 -0
- package/dist/holmes/hooks/rtm-refresh.d.ts +13 -0
- package/dist/holmes/hooks/rtm-refresh.js +76 -0
- package/dist/holmes/hooks/stop.js +42 -0
- package/dist/holmes/mcp/handlers.d.ts +5 -6
- package/dist/holmes/mcp/handlers.js +57 -2
- package/dist/holmes/mcp/server.js +12 -0
- package/dist/holmes/mcp/tool-schemas.js +1 -1
- package/dist/holmes/review/judgement-bundle.d.ts +49 -0
- package/dist/holmes/review/judgement-bundle.js +108 -0
- package/dist/holmes/review/run-replay.d.ts +5 -0
- package/dist/holmes/review/run-replay.js +32 -0
- package/dist/holmes/rtm/impact-advisory.d.ts +48 -0
- package/dist/holmes/rtm/impact-advisory.js +175 -0
- package/dist/holmes/rtm/localize.js +7 -0
- package/dist/holmes/rtm/rtm-builder.d.ts +8 -0
- package/dist/holmes/rtm/rtm-builder.js +42 -1
- package/dist/holmes/rtm/rtm-graph.d.ts +16 -1
- package/dist/holmes/rtm/rtm-graph.js +34 -6
- package/dist/holmes/spec/compat-impact.d.ts +5 -0
- package/dist/holmes/spec/compat-impact.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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;
|