@holmes-lab/holmes-kit 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +5 -1
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/doctor.js +15 -1
  5. package/dist/holmes/cli/mcp-version.d.ts +4 -1
  6. package/dist/holmes/cli/mcp-version.js +5 -2
  7. package/dist/holmes/governance/approval-queue.d.ts +6 -0
  8. package/dist/holmes/governance/approval-queue.js +11 -3
  9. package/dist/holmes/governance/autonomy.js +16 -1
  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 +8 -7
  19. package/dist/holmes/mcp/handlers.js +114 -5
  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/rtm/anchor-density.d.ts +43 -0
  27. package/dist/holmes/rtm/anchor-density.js +117 -0
  28. package/dist/holmes/rtm/impact-advisory.d.ts +52 -0
  29. package/dist/holmes/rtm/impact-advisory.js +182 -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 +50 -1
  33. package/dist/holmes/rtm/rtm-graph.d.ts +28 -1
  34. package/dist/holmes/rtm/rtm-graph.js +61 -8
  35. package/dist/holmes/spec/compat-impact.d.ts +5 -0
  36. package/dist/holmes/spec/compat-impact.js +1 -0
  37. package/package.json +1 -1
  38. package/playbooks/publish/PLAYBOOK.md +16 -7
package/CHANGELOG.md CHANGED
@@ -5,6 +5,124 @@ 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.17.0] - 2026-09-07
9
+
10
+ An adversarial review of 0.16.0's own new surfaces, run against the shipped tarball the day it
11
+ went out, drove this release: the newest repair is always the next target, and this time the
12
+ findings were sealed before anyone else could find them. The release gate itself also gets the
13
+ fix for the incident that let 0.16.0 ship with a frozen README.
14
+
15
+ ### Fixed
16
+ - **Graph-row forgery via spec prose (REQ-569 S1, HIGH)** — a DRAFT spec (no approval needed to
17
+ exist) whose YAML double-quoted title carried `\n`/`\t` escapes could forge rows in the graph's
18
+ canonical dump, which the PPR view parsed as **real call edges** — poisoning `rankedImpact` and
19
+ `maintenance_analyze` (reproduced against the shipped 0.16.0 artifact, then killed). Sealed at
20
+ BOTH boundaries independently: `specSummary` now folds whitespace over the whole summary (title
21
+ included — the sentence-only fold was the hole), and `RtmGraph.addNode` folds structural
22
+ characters at the storage boundary so no future caller can break a dump row either. Each face
23
+ verified alone. A high-effort review of this very fix then widened it: tabs riding in via spec
24
+ `id:`/`depends_on:` still shifted dump columns, so `dumpCanonical` now folds EVERY text column
25
+ at emission (single choke point; the 13-cell row invariant is pinned by test), and the graph
26
+ extractor version bumped to `holmes-rtm/2` so a persisted store written by unfixed 0.16.0 —
27
+ whose summary column may already carry forged rows — is force-rebuilt instead of reused.
28
+ - **Grader/doctor parity pair (REQ-569 S4)** — `isHighRiskPath` folds `.` path segments before
29
+ judging (`src/holmes/./governance/x.ts` no longer dodges the risk roots; `..` is deliberately
30
+ NOT folded — the grader widens sight, never impersonates path resolution, and the enforcer
31
+ stays byte-literal). The codex doctor's npx-pin branch now compares the pin against the
32
+ installed version and WARNs on a stale pin (parity with the `.mcp.json` drift check) — an old
33
+ fixture that had enshrined the gap as PASS was repinned to the current version. Review
34
+ follow-ups sealed in the same release: a `..`-bearing Files-to-Touch token is now FLAGGED
35
+ high-risk (the hook does not canonicalize relative paths, so a `src/app/../holmes/...` spelling
36
+ stayed admissible at the byte-literal enforcer while the grader called it benign — flagging the
37
+ ambiguous ascent token closes the self-approval hole without impersonating path resolution);
38
+ non-semver pins (`@latest`, `^x.y.z`) judge `unknown` instead of an inverted "stale pin" WARN;
39
+ and the sealing-time advisory now closes its SQLite handle (a per-approval native-handle leak in
40
+ the long-lived MCP server, and a file-lock risk on Windows).
41
+ - **The docs-currency gate is bidirectional now (REQ-570)** — 0.16.0 shipped with README's
42
+ feature list frozen at "v0.14.x" and zero 0.16.0 entries, because the gate's instruction was
43
+ "grep for stale phrases": a feature never written produces zero hits, and zero hits read as
44
+ "no drift". The publish playbook now demands three checks per user-facing change — ADDITION
45
+ (the entry must exist; zero grep hits are a missing-entry signal, not a pass), drift (the old
46
+ wording), and stale markers (version-pinned section labels are drift generators and get
47
+ removed, not policed). The incident is recorded in the playbook itself, the README repaired
48
+ retroactively, and the new wording pinned by tests.
49
+
50
+ ### Changed
51
+ - **The impact/advisory graph is approved-only (REQ-569 S2)** — the persisted RTM graph that
52
+ feeds `rtm_impact`, the sealing-time advisory and the intent summaries now builds from SEALED
53
+ specs only: a draft needs no approval to exist, so it can no longer reach the agent-visible
54
+ channels (that was the forgery's delivery vehicle). The closure walks edges, so the channel
55
+ filter is node existence — an anchor naming a draft keeps its id but carries no prose until
56
+ approval. Diagnosis (`rtm_check`) and matching (`issue_localize`) keep their existing
57
+ populations; old `rtm.sqlite` files rebuild automatically on basis drift. Replay pins unmoved
58
+ to the digit.
59
+ - **Annotation caps (REQ-569 S3)** — `impactedSummaries` caps at 40 (`summariesOmitted` counts
60
+ the rest; `impacted` itself is never truncated) and advisory anchors cap at 10 per file
61
+ (`anchorsOmitted`). Measured on this repository's hub-grade impact (342 specs): the response
62
+ shrank **104,706 → 18,708 bytes (−82%)**, and the omitted tail costs no summary lookups.
63
+
64
+ ### Added
65
+ - **Anchor-density advisory (REQ-569 S5, observation only)** — sealing an A-SPEC whose
66
+ Files-to-Touch contains an anchor-dense file (live `implements` count ≥ max(8, p90 of the
67
+ store's distribution)) annotates the response with `anchorDensity: [{path, anchors, p90}]` and
68
+ records it to `anchor-density.<replica>.jsonl` (paths and integers only). Grounded in the
69
+ measured precision tax of anchor accumulation (authoring one spec moved replay recall
70
+ 0.5476→0.5060); a count GATE was considered and refused — the thresholds are prose constants,
71
+ and promotion or rejection will be decided by this ledger, the impactAdvisory lifecycle.
72
+
73
+ ### Notes
74
+ - One inherited finding was honestly killed instead of "fixed": the recorded
75
+ "scanTestAnchors only sees a file's first anchor" defect does **not reproduce** at HEAD (all
76
+ standalone-comment anchors are consumed by ART-4 and the execution-evidence attribution alike);
77
+ the only non-recognition is the trailing-comment form, which is the sealed anchor idiom rule.
78
+ Stale findings get re-measured, not re-fixed.
79
+
80
+ ## [0.16.0] - 2026-09-07
81
+
82
+ The graph learns to speak intent, and the call graph learns to speak up at sealing time. An A-SPEC
83
+ approval now returns what your Files-to-Touch declaration *missed* — and every spec the graph
84
+ names arrives with its one-sentence intent beside the id, extracted (never generated) from the
85
+ spec store. Two more mechanisms were benchmarked under pre-registration and honestly discarded.
86
+
87
+ ### Added
88
+ - **Impact advisory on approval (REQ-566)** — a successful A-SPEC `spec_approve` may now carry
89
+ `impactAdvisory`: files whose symbols **call into** the declared Files-to-Touch but sit outside
90
+ the declaration (1-hop, capped at 10, repo-relative allow-list). Advisory, never verdict: it
91
+ rides the response *after* the seal is committed, degrades to absence on any failure, and every
92
+ emission lands in an observation ledger (`.ax/ledger/impact-advisories.<replica>.jsonl` — paths,
93
+ ids and integers only) so its false-positive rate is measured *before* anyone proposes a hard
94
+ gate. The RTM graph persists at `.ax/rtm.sqlite` and keeps itself fresh: `rtm_impact` rebuilds
95
+ on basis drift, and the Stop hook spawns a TTL-gated detached reindex (staleness was measured as
96
+ the advisory's quality factor: 7 findings on an 8-day-old graph, 17 after a fresh one).
97
+ - **SPEC intent summaries in the graph (REQ-568)** — graph nodes gain a `summary` column (schema
98
+ `rtm-graph/2` → `/3`; old stores rebuild automatically on first use, no migration step). Every
99
+ SPEC node stores `"<title> — <first sentence of its intent section>"`, extracted
100
+ deterministically (REQ=Problem / Need, H-SPEC=Intent, A-SPEC=Objective; 200-char cap; title-less
101
+ legacy specs — 20 measured in this very store — fall back to sentence, then id). Same store,
102
+ byte-identical graph; measured cost +6.4% build time / +4.2% file size. `RtmGraph.summaryOf(id)`
103
+ reads it back, and `rtm_impact` responses add `impactedSummaries: [{id, summary}]` beside the
104
+ unchanged `impacted` list.
105
+ - **Session-context observability (REQ-564)** — the ledger records which agent/model drove a
106
+ session and what the governance overhead cost, per replica
107
+ (`.ax/ledger/session-context.<replica>.jsonl`), grounding field reports in machine attribution.
108
+
109
+ ### Changed
110
+ - **`impactAdvisory.files[].anchors` shape (REQ-568, code-interface)** — anchors are now
111
+ `[{id, summary}]` instead of `string[]`, so the reader sees *which intent* is at risk without a
112
+ spec-store round trip. Consumers parsing anchors must read `.id`. The summary is information
113
+ only: value tests pin that the advisory's file list, ordering and `more` count are independent
114
+ of the prose, and the observation ledger stays prose-free.
115
+
116
+ ### Notes
117
+ - Two pre-registered negatives, kept as records rather than shipped: the judgement-axis
118
+ FACT/EVIDENCE bundle tied its bench (0.480 vs 0.478) and was discarded with its re-attempt
119
+ conditions sealed (REQ-567, 15th rejection); a spec-intent **vector assist** for uncited
120
+ requests was built, measured byte-identical to the lexical path over 441 held-out traceability
121
+ cases (long queries already match ~371 specs lexically; the lexical-zero segment is protected by
122
+ the reorder-not-admit rule), reverted, and its A/T-SPECs retired (REQ-568 S3, 16th rejection).
123
+ The S-491 embedding win belongs to direct query→file ranking and does not transport through the
124
+ spec-matching layer.
125
+
8
126
  ## [0.15.0] - 2026-09-06
9
127
 
10
128
  The first field report on Holmes-Kit's own operational data (1,412 unique approval requests vs. 8
package/README.md CHANGED
@@ -14,7 +14,11 @@
14
14
 
15
15
  ---
16
16
 
17
- ### 🛡️ Currently Supported Features (v0.14.x Production Features)
17
+ ### 🛡️ Currently Supported Features (Production Features)
18
+
19
+ - 📣 **Impact Advisory at sealing time** *(new in 0.16.0)*: approving an A-SPEC now returns what your Files-to-Touch declaration *missed* — files whose symbols **call into** the declared scope from outside it (1-hop, capped, repo-relative allow-list), computed from the persisted RTM graph at the moment of sealing. Advisory, never verdict: it rides the response *after* the seal commits, degrades to absence on any failure, and every emission lands in an observation ledger so its false-positive rate is **measured before** anyone proposes a hard gate. The graph keeps itself fresh — `rtm_impact` rebuilds on basis drift and the Stop hook spawns a TTL-gated detached reindex (staleness was measured as the advisory's quality factor: 7 findings on an 8-day-old graph, 17 after a fresh one). *(0.17.0 hardening)*: the advisory/impact graph is **approved-only** (a draft needs no approval to exist, so it can no longer reach these agent-visible channels), summary prose can't forge graph rows (structural characters fold at both the extraction and storage boundaries), and annotations are capped with explicit omission counts (a hub-grade response shrank 104.7KB → 18.7KB, −82%). Sealing also gains an **anchor-density advisory** (observation-only): an A-SPEC whose Files-to-Touch contains an anchor-dense file (live anchors ≥ max(8, p90)) is annotated with `anchorDensity: [{path, anchors, p90}]` and ledgered — grounded in the measured precision tax of anchor accumulation; a count *gate* was considered and refused.
20
+ - 🗣️ **The graph speaks intent** *(new in 0.16.0)*: every SPEC node stores a one-sentence intent summary (`"<title> — <first sentence of its intent section>"`, schema `rtm-graph/3`, old stores rebuild automatically) — extracted deterministically, **never generated** (same store, byte-identical graph; measured cost +6.4% build time / +4.2% file size). Advisory anchors arrive as `{id, summary}` and `rtm_impact` adds `impactedSummaries`, so the reader sees *which intent* is at risk without a spec-store round trip. Information only: value tests pin that no verdict, ranking or gate reads the prose.
21
+ - 📇 **Session-context observability** *(new in 0.16.0)*: the ledger records which agent/model drove a session and what the governance overhead cost, per replica (`session-context.<replica>.jsonl`), grounding field reports in machine attribution instead of guesswork.
18
22
 
19
23
  - 📋 **Requirements & Specification Governance**: Strict **"No Spec, No Code"** enforcement with 4-tier spec chain traceability (`REQ ➔ H-SPEC ➔ A-SPEC ➔ T-SPEC`) and `// @implements A-SPEC-XXX` code anchors (comma-lists and every anchor in a file participate in the gate).
20
24
  - 🔴 **Inbuilt TDD — RED-first, enforced not asked** *(new in 0.9.0)*: the test-first discipline is a holmes-installed `holmes-tdd-slice` skill **and** a new constitution article **ART-8**. A changed A-SPEC must show a recorded `red-assertion → green` sequence in the ledger; a `red-error` (a test that could not run) is not a valid RED, so "the covering test failed *correctly*" is judged mechanically, not on trust. `test_run` classifies each covered file (`red-assertion`/`red-error`/`green`) and records per-A-SPEC outcomes the Stop hook reads. Ships at `redFirstEvidence: track` (observe-first, non-blocking; `strict`/`off` per repo), evidence-gated and jest-only for now. A T-SPEC may also declare `kills:` mutations and `test_run --mutate` reports which SURVIVED (a coverage gap). Where superpowers *asks* for RED-first and discriminating power, holmes-kit *proves* them.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 2a7fb45-mtps37rf
1
+ 20f5f64-mtrggtxw
@@ -897,7 +897,21 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
897
897
  }
898
898
  else {
899
899
  const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
900
- add('codex wiring', pin !== null ? 'PASS' : 'FAIL', pin !== null ? `resolves via the npx pin ${pin}` : `cannot read a launch version from the wiring: ${entry.command} ${entry.args.join(' ')}`, pin !== null ? undefined : 'Rewire with holmes-kit init --target <dir> --agent codex.');
900
+ // @implements A-SPEC-569.4 parity with the .mcp.json drift check (A-SPEC-251.2): a pin
901
+ // that RESOLVES is not enough, because a stale pin quietly keeps launching yesterday's
902
+ // gate. Same verdict function (non-semver pins like @latest judge 'unknown' → PASS, not
903
+ // an inverted "stale" WARN), same fix shape; an unreadable pin stays FAIL as before.
904
+ const cliVer = (0, mcp_launcher_1.readPackageVersion)(packageRoot); // same accessor as the .mcp.json check — one source of truth
905
+ const drift = cliVer ? (0, mcp_version_1.versionDriftVerdict)(pin, cliVer) : 'unknown';
906
+ if (pin === null) {
907
+ add('codex wiring', 'FAIL', `cannot read a launch version from the wiring: ${entry.command} ${entry.args.join(' ')}`, 'Rewire with holmes-kit init --target <dir> --agent codex.');
908
+ }
909
+ else if (drift === 'drift') {
910
+ add('codex wiring', 'WARN', `resolves via the npx pin ${pin}, but this install is ${cliVer} — a stale pin keeps launching the old server`, 'Rewire with holmes-kit init --target <dir> --agent codex --force to refresh the pin.');
911
+ }
912
+ else {
913
+ add('codex wiring', 'PASS', `resolves via the npx pin ${pin}`);
914
+ }
901
915
  }
902
916
  // @implements A-SPEC-442 (was A-SPEC-423)
903
917
  // Codex CAN hard-enforce, but only from an INSTALLED plugin: it loads plugins from
@@ -19,5 +19,8 @@ export interface McpEntryShape {
19
19
  */
20
20
  export declare function mcpLaunchVersion(entry: McpEntryShape, readVersion?: (packageDir: string) => string | undefined): string | null;
21
21
  export type DriftVerdict = 'match' | 'drift' | 'unknown';
22
- /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift. */
22
+ /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift.
23
+ * @implements A-SPEC-569.4 — 비-semver 런치 문자열(`latest`·`next`·`^0.16.0`)도 unknown:
24
+ * `@latest` 핀은 최신을 띄우는데 "구 서버를 계속 띄운다"는 drift 경고는 역진단이었다(고강도
25
+ * 리뷰 F5). 정확 semver 만 오프라인에서 비교 가능하다 — 판정 불능은 결함으로 둔갑시키지 않는다. */
23
26
  export declare function versionDriftVerdict(launchVersion: string | null, cliVersion: string): DriftVerdict;
@@ -74,9 +74,12 @@ function mcpLaunchVersion(entry, readVersion) {
74
74
  }
75
75
  return null;
76
76
  }
77
- /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift. */
77
+ /** launch 를 못 뽑았으면 unknown(거짓 drift 를 만들지 않는다). 같으면 match, 다르면 drift.
78
+ * @implements A-SPEC-569.4 — 비-semver 런치 문자열(`latest`·`next`·`^0.16.0`)도 unknown:
79
+ * `@latest` 핀은 최신을 띄우는데 "구 서버를 계속 띄운다"는 drift 경고는 역진단이었다(고강도
80
+ * 리뷰 F5). 정확 semver 만 오프라인에서 비교 가능하다 — 판정 불능은 결함으로 둔갑시키지 않는다. */
78
81
  function versionDriftVerdict(launchVersion, cliVersion) {
79
- if (launchVersion === null)
82
+ if (launchVersion === null || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(launchVersion))
80
83
  return 'unknown';
81
84
  return launchVersion === cliVersion ? 'match' : 'drift';
82
85
  }
@@ -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
  }
@@ -80,7 +80,22 @@ const TAINT_MARKERS = ['taint', 'dataflow-taint', 'flow-sensitive'];
80
80
  // purpose, because a glob spanning the gate surface must never self-approve.
81
81
  const GLOB_RISK_ROOTS = [...HIGH_RISK_PREFIXES, 'src/holmes/rtm/'];
82
82
  function isHighRiskPath(p) {
83
- const raw = p.replace(/^\.\//, '').replace(/^["'`]|["'`]$/g, '');
83
+ // @implements A-SPEC-569.4 '.' segments fold BEFORE the verdict: `src/holmes/./governance/x`
84
+ // dodged every prefix check while naming the gate surface exactly (REQ-556's remaining edge).
85
+ // Deliberately NOT `..`: folding that would impersonate path resolution this grader cannot do
86
+ // (shape is not location) — over-inclusion is allowed here, invented precision is not. The
87
+ // enforcer (matchesFtt) stays byte-literal, so a /./-token still admits nothing: only the
88
+ // grader's sight widened, never the gate's admission.
89
+ const unquoted = p.replace(/^["'`]|["'`]$/g, '');
90
+ // @implements A-SPEC-569.4 (revision, high-effort review F3) — a `..` segment is NOT folded
91
+ // (folding would impersonate path resolution this grader cannot do) but it IS flagged: the hook
92
+ // does not canonicalize relative paths, so `src/app/../holmes/governance/x.ts` stays admissible
93
+ // at the byte-literal enforcer under its own spelling while a fold-blind grader called it
94
+ // benign. An ascent token is ambiguous about where it lands, and ambiguity over the gate
95
+ // surface grades high-risk — over-inclusive on purpose, the rule this module already owns.
96
+ if (/(^|\/)\.\.(\/|$)/.test(unquoted))
97
+ return true;
98
+ const raw = unquoted.replace(/\/\.(?=\/|$)/g, '').replace(/^(\.\/)+/, '');
84
99
  const s = raw.toLowerCase();
85
100
  if (HIGH_RISK_PREFIXES.some((pre) => s.startsWith(pre)))
86
101
  return true;
@@ -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;