@holmes-lab/holmes-kit 0.21.0 → 0.23.1

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 (42) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +8 -0
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/agents.d.ts +38 -0
  5. package/dist/holmes/cli/agents.js +106 -4
  6. package/dist/holmes/cli/doctor.d.ts +1 -0
  7. package/dist/holmes/cli/doctor.js +42 -0
  8. package/dist/holmes/cli/index.js +1 -0
  9. package/dist/holmes/cli/init.js +18 -0
  10. package/dist/holmes/cpg/language-parser-walk.js +1 -0
  11. package/dist/holmes/hooks/stop.d.ts +7 -0
  12. package/dist/holmes/hooks/stop.js +54 -1
  13. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +29 -1
  14. package/dist/holmes/mcp/handlers/operator-inspection.js +68 -2
  15. package/dist/holmes/mcp/handlers/spec-approval.d.ts +5 -0
  16. package/dist/holmes/mcp/handlers/spec-approval.js +59 -1
  17. package/dist/holmes/mcp/handlers/test-execution.d.ts +4 -0
  18. package/dist/holmes/mcp/handlers/test-execution.js +6 -2
  19. package/dist/holmes/mcp/handlers.d.ts +33 -1
  20. package/dist/holmes/mcp/handlers.js +1 -0
  21. package/dist/holmes/mcp/maintenance-analyze.js +1 -0
  22. package/dist/holmes/mcp/supervisor.d.ts +35 -0
  23. package/dist/holmes/mcp/supervisor.js +105 -2
  24. package/dist/holmes/mcp/tool-schemas.js +1 -0
  25. package/dist/holmes/project/ci-runs.d.ts +46 -0
  26. package/dist/holmes/project/ci-runs.js +137 -0
  27. package/dist/holmes/project/install-scripts-policy.js +1 -0
  28. package/dist/holmes/review/evaluation-metrics.js +1 -0
  29. package/dist/holmes/review/kills-check.d.ts +40 -0
  30. package/dist/holmes/review/kills-check.js +147 -0
  31. package/dist/holmes/review/manual-baseline.js +1 -0
  32. package/dist/holmes/rtm/advisory-outcomes.d.ts +137 -0
  33. package/dist/holmes/rtm/advisory-outcomes.js +314 -0
  34. package/dist/holmes/rtm/declared-scope.d.ts +28 -0
  35. package/dist/holmes/rtm/declared-scope.js +60 -0
  36. package/dist/holmes/rtm/rtm-graph.js +1 -0
  37. package/dist/holmes/rtm/taint-benchmark.js +1 -0
  38. package/dist/holmes/server/dashboard-launcher.d.ts +7 -0
  39. package/dist/holmes/server/dashboard-launcher.js +3 -0
  40. package/dist/holmes/server/dashboard.js +14 -1
  41. package/package.json +1 -1
  42. package/playbooks/author-slice/PLAYBOOK.md +19 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,118 @@ 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.23.1] - 2026-09-18
9
+
10
+ A release that could not be published, and the documentation that had fallen three versions behind.
11
+
12
+ ### Fixed
13
+ - **The release gate knows the ledgers 0.22.0 began tracking** (A-SPEC-668). `v0.23.0`'s publish was
14
+ refused on a dirty tree: 0.22.0 taught `.gitignore` to track `advisory-outcomes` and `ci-runs`, and
15
+ `verify-release.js`'s append-only exclusion list never heard about it. The first is appended by every
16
+ seal and every `approval_status`; the second by the launchd CI runner every fifteen minutes — between
17
+ them this repository could no longer publish at an arbitrary moment. Both are excused now, and a pin
18
+ asserts that every TRACKED ledger is excused, naming any that is not so the next person knows which
19
+ file to edit. The reverse slack is reported rather than asserted away (an excused-but-untracked ledger
20
+ costs nothing, because a gitignored file never shows as dirty).
21
+
22
+ ### Docs
23
+ - **The README lists 0.21.0 through 0.23.0.** The feature list stopped at 0.20.0 while three releases
24
+ shipped. The publish playbook's docs-currency gate forbids exactly that — "a stale doc is a false
25
+ claim" — and it is a written step with no automation, so it was skipped twice. Automating it is the
26
+ next candidate.
27
+
28
+ ## [0.23.0] - 2026-09-18
29
+
30
+ Three things this release stops doing quietly: deleting the neighbours in a config file it shares,
31
+ going deaf when its own child process dies, and reporting a coverage shortfall it cannot explain.
32
+
33
+ ### Fixed
34
+ - **A re-wire no longer deletes the other tools' entries** (A-SPEC-667). `init --agent antigravity`
35
+ and `init --agent codex` replaced `.agents/mcp_config.json`, `.agents/hooks.json` and
36
+ `marketplace.json` whole. Measured with a real `--dry-run` before the change: a neighbour MCP
37
+ server, a neighbour hook namespace, a neighbour plugin, a marketplace's own name and an operator's
38
+ `disabled` flag all survived a re-wire at a rate of zero. They now survive; only the holmes-kit
39
+ entry is refreshed, and `init` names what it kept. A `disabled` flag an operator set is preserved
40
+ rather than silently switched back on, and `init` says the gate will not run while it stands. An
41
+ existing file that is not readable JSON is refused with a reason instead of being replaced. The
42
+ Claude wiring already merged (A-SPEC-179); this gives the other two harnesses the same discipline.
43
+ - **The MCP supervisor notices a child that died** (A-SPEC-666). With no exit handler it kept writing
44
+ to a dead pipe, its in-flight count never returned to zero, and the swap that would have replaced
45
+ the child therefore never fired — the server was permanently deaf. Reproduced against the real
46
+ supervisor, twice in one session and once on demand. It now answers every outstanding request with
47
+ a JSON-RPC error before anything else (a client must not wait for ever), resets, respawns and
48
+ replays the opening exchange. A child that dies having never answered spends a restart budget, so a
49
+ broken build stops quickly while a single crash under load restarts freely; an intentional swap or
50
+ `stop()` is not counted as an accident. Affects supervised runs only (`HOLMES_MCP_AUTORELOAD`).
51
+
52
+ ### Added
53
+ - **The census says what an unlinked spec declared, not just where it is anchored** (A-SPEC-665).
54
+ `rtm_dashboard` and `/api/rtm` now classify every unlinked approved A-SPEC on a second axis —
55
+ `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` —
56
+ beside the existing `unlinkedByReason`, and report how many specs declare a path no anchor
57
+ mechanism can read. Measured here: of 52 unlinked specs, zero declare a file the scanner parses, so
58
+ the remaining 8.1% of `codeLinkedPct` is not a missing anchor. Bucket names state what was
59
+ declared; whether that is a defect remains `traceGaps`'s answer.
60
+
61
+ ### Measured
62
+ - macOS: 528 suites / 6,607 passed / 11 skipped, every suite green; each slice recorded red-assertion → green with an official `test_run` at both ends.
63
+ - Linux (OrbStack Ubuntu 24.04 arm64, Node 22): judged automatically on every commit by the launchd
64
+ runner added in 0.22.0. Residual reds are load-dependent suites that pass when run alone —
65
+ `dashboard.test` was verified at 120 s alone on the VM against 370 s under load.
66
+ - Windows: not re-run; no Windows-specific change landed.
67
+
68
+ ## [0.22.0] - 2026-09-18
69
+
70
+ The advisories learn what happened next. Until now this repository issued findings and recorded that
71
+ it had issued them; nothing recorded whether anyone acted. That missing half is the numerator every
72
+ "promote this to a hard gate once we know the false-positive rate" sentence needed.
73
+
74
+ ### Added
75
+ - **Advisory reaction ledger** (A-SPEC-663). Every finding — impact-advisory, anchor-density,
76
+ Files-to-Touch fulfilment, trace-gap, kills-unapplicable — now has a deterministic id (kind + spec +
77
+ canonical payload). `spec_approve` records it as `issued` and answers with `advisoryIds`; the next
78
+ `approval_status` re-runs the SAME finding functions and records `resolved` when the cause is gone or
79
+ `persisted` when it is not, at most one row per finding per day. An author who judges a finding
80
+ unhelpful passes `dismiss: [id]` to `spec_approve`; an id the ledger does not know comes back in
81
+ `dismissUnknown` rather than inventing a row. `rtm_dashboard`'s census gains `advisoryOutcomes`
82
+ (issued / resolved / persisted / dismissed per kind) and `approval_status` gains `advisoryHistory`.
83
+ Rows live in `.ax/ledger/advisory-outcomes.<replica>.jsonl` and carry ids, spec ids, outcome words and
84
+ commit hashes only. Queries never issue a finding the seal did not, so the observation denominator
85
+ stays the seal's.
86
+ - **`kills` applicability** (A-SPEC-662). `test_run --mutate` now reports mutations that never applied
87
+ as `unapplied`, separately from `survivors` — measured here, all 22 `kills` entries in this repository
88
+ write `where` as a file path and `mutate` as prose, so the literal-replacement engine applied none of
89
+ them and the response still read `survivors: []`, the shape of a clean run. Sealing a T-SPEC (and
90
+ previewing it with `approval_status`) reports entries whose `where` literal is absent from the
91
+ A-SPEC's anchored production source as a `kills-unapplicable` finding. Never blocking. The
92
+ author-slice playbook now shows the grammar.
93
+ - **CI matrix, minimal form** (A-SPEC-664). `scripts/ci-orb-linux.sh` runs one commit's full suite on an
94
+ OrbStack Linux machine and appends exactly one row to `.ax/ledger/ci-runs.<host>.jsonl` — for every
95
+ outcome, including the ones where the run could not judge (`clone-failed`, `install-failed`,
96
+ `vm-unreachable`). A watch script and a launchd agent trigger it. The Stop hook reports the matrix's
97
+ last word on a tracked line and `doctor` gains a `ci matrix` check, and neither ever reads a missing
98
+ row as green: absence is "not run". The scripts are maintainer tools and are not shipped; a workspace
99
+ with no `ci-runs` ledger hears nothing about the matrix at all.
100
+
101
+ ### Fixed
102
+ - **Trace-gap anchors: 21 approved specs joined the graph.** Each declared a scanned production file in
103
+ its Files to Touch while anchoring only tests, so `rtm_impact` could not see them. Their anchors now
104
+ sit in the files they declared. Unlinked approved specs fell from 73 to 52 and trace gaps from 21 to
105
+ 0; `codeLinkedPct` rose from 88.6 to 91.9. The remaining 52 declare no scanned production file — they
106
+ are test- and document-only specs, not gaps.
107
+ - **The runner counted focused suites as failures.** jest reports a suite that used `test.only` with
108
+ status `focused`; filtering on `!== 'passed'` made five healthy suites look red in the first two
109
+ Linux rows. Only `failed` counts now.
110
+
111
+ ### Measured
112
+ - macOS: 524 suites / 6,581 passed / 11 skipped; every slice recorded red-assertion → green; official
113
+ `test_run` green at each step.
114
+ - Linux (OrbStack Ubuntu 24.04 arm64, Node 22): five recorded runs, the last one unattended via launchd.
115
+ The residual reds are load-dependent suites (`dashboard.test`, `entity-store-boundaries`,
116
+ `entity-git-snapshot`) that pass when run alone; the host was running other agents' work at the time.
117
+ - First reaction rows in this repository: 22 trace-gap findings issued, 22 resolved by the anchor move.
118
+ - Windows: not re-run for this release; no Windows-specific change landed.
119
+
8
120
  ## [0.21.0] - 2026-09-17
9
121
 
10
122
  The backlog-hardening cycle: the RTM stops claiming coverage it cannot see, sealing reports what a
package/README.md CHANGED
@@ -16,6 +16,14 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 🧩 **Your config files survive a re-wire** *(new in 0.23.0)*: `init --agent antigravity` and `init --agent codex` used to replace `.agents/mcp_config.json`, `.agents/hooks.json` and `marketplace.json` **whole**. Measured with a real `--dry-run` before the fix: a neighbour MCP server, a neighbour hook namespace, a neighbour plugin, a marketplace's own name and an operator's `disabled` flag all survived a re-wire at a rate of **zero**. They now survive — only the holmes-kit entry is refreshed, and `init` names what it kept. A `disabled` you set stays set (and `init` says the gate will not run while it stands, rather than switching it back on silently); an existing file that is not readable JSON is refused with a reason instead of being replaced. The Claude wiring already merged; the other two harnesses now have the same discipline.
20
+ - 🫀 **The MCP supervisor notices a child that died** *(new in 0.23.0)*: under `HOLMES_MCP_AUTORELOAD` the supervisor had no exit handler, so a crashed child left it writing to a dead pipe — its in-flight count never returned to zero, the swap that would have replaced the child never fired, and the server went **permanently deaf** (observed twice in one session, then reproduced on demand against the real class). It now answers every outstanding request with a JSON-RPC error **first** (a client must never wait for ever), then resets, respawns and replays the opening exchange. A child that dies having never answered spends a restart budget, so a broken build stops quickly while one crash under load restarts freely; an intentional swap or shutdown is not counted as an accident.
21
+ - 🧮 **Coverage you can explain** *(new in 0.23.0)*: the RTM census already said *where* an unlinked spec is anchored; it now also says *what it declared* — `scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` — plus how many specs declare a path no anchor mechanism can read. On this repository, of 52 unlinked approved specs **zero** declare a file the scanner parses, so the remaining 8.1% of `codeLinkedPct` is not a missing anchor. Bucket names state the fact; whether one is a defect stays `traceGaps`'s answer.
22
+ - 🤖 **A CI matrix that judges every commit, and never reads silence as green** *(new in 0.22.0)*: a maintainer-side runner takes one commit onto a Linux VM (clone → install → build → full suite) and appends exactly one row to a `ci-runs` ledger — for every outcome, including the ones where the run could not judge (`clone-failed`, `install-failed`, `vm-unreachable`). A launchd agent triggers it per commit. The Stop hook reports the matrix's last word and `doctor` gains a `ci matrix` check; a missing row reads as **"not run"**, never as a pass. Workspaces that never adopted the matrix hear nothing about it.
23
+ - 🔁 **Advisories learn what happened next** *(new in 0.22.0)*: every finding — impact, anchor density, Files-to-Touch fulfilment, trace gap, `kills`-unapplicable — now carries a deterministic id. Sealing records it as `issued`; the next `approval_status` re-runs the **same** functions and records `resolved` when the cause is gone or `persisted` when it is not, at most one row per finding per day. An author who judges a finding unhelpful passes `dismiss: [id]`; an unknown id comes back in `dismissUnknown` rather than inventing a row. `rtm_dashboard`'s census gains the per-kind tallies. This is the numerator every "promote to a hard gate once we know the false-positive rate" sentence was missing.
24
+ - 🧪 **`kills` that cannot apply say so** *(new in 0.22.0)*: `test_run --mutate` reports mutations that never applied as `unapplied`, separately from `survivors`. Measured here, all 22 `kills` entries in this repository wrote `where` as a file path and `mutate` as prose, so the literal-replacement engine applied **none** of them while the response still read `survivors: []` — the shape of a clean run. Sealing a T-SPEC now reports entries whose `where` is absent from the A-SPEC's anchored source, and the authoring playbook shows the grammar.
25
+ - 🔎 **The RTM stops claiming coverage it cannot see** *(new in 0.21.0)*: `rtm_dashboard` and `/api/rtm` report `codeLinkedPct`, `unlinkedCount` and `unlinkedByReason` beside the old `coveragePct`, which read 100 while 11.4% of approved specs carried no `implements` edge. The scanner now also reads every anchor the injector can write (`.sh`/`.yml`/`.toml` anchors were planted and never indexed). Sealing gained a **Files-to-Touch fulfilment advisory** (declared paths that do not exist, as `missing`/`moved`/`alternatives`, ledgered, never blocking) and `rtm_impact` gained **trace gaps** — approved specs that declare a changed production file yet anchor only tests, with `impact_gate_check` answering `trace-gap` instead of silently widening.
26
+ - 🩹 **`@known-defect(reason, expires=YYYY-MM-DD)` and constitution article ART-9** *(new in 0.21.0)*: a test that pins a known defect as its expected value carries a machine-readable marker. Unexpired markers are listed on the Stop hook's `tracked` channel as debt; the gate blocks only when a marker has **expired** or cannot be read (`no-reason`, `no-expires`, `bad-date`). String literals are stripped first, the expiry day itself counts as expired (UTC), and a walk that cannot start is no signal rather than a clean bill. A bypass is sometimes the right call — the marker is there so the next person can see it.
19
27
  - 🧑‍🤝‍🧑 **Concurrent Multi-Agent Workspace** *(new in 0.20.0)*: several agents, machines and clones work on one spec store and converge through Git. A Git-shareable **workspace identity** with per-checkout replica registration (`workspace_identity`) stamps every governed write with run provenance; specs become **UUID-keyed entities** (`entity_store`) whose display ids can be **renumbered without losing identity, anchors or approval closure** (`entity_renumber`); independent work from a common ancestor is **integrated** with stable identity, per-side conflict evidence, automatic renumbering of colliding new ids and explicit derived-state discharge (`entity_integrate`); single-use approvals are spent on the writer's own ledger chain so offline clones merge cleanly, and a nonce spent twice is a structured **double-spend** that freezes every authority-spending act until `ledger_reconcile`. Evidence (RED→GREEN outcomes, timelines) follows the entity across renames. Reproduced end to end in a project outside this repository on macOS and Linux.
20
28
  - 🗂️ **Approval decisions you can actually see** *(new in 0.20.0)*: `holmes-kit approve --status` (and the `--watch` header) shows the workspace, the delegation posture, and per request the risk grade, who asked (run · replica · workspace), the subject digest and exactly what a grant would open — plus recent decisions with their outcome (live until / consumed / expired / revoked / denied). A grant is now **bound to the workspace it was minted in and to the content the human read**: a copied grant is refused as `foreign-workspace`, a seal of changed content as `stale-subject`, and `approve --revoke` withdraws a live grant. Grants are git-ignored by `init`.
21
29
  - 🔁 **Import cycles are governed, at the three moments that can each do something** *(new in 0.19.0)*: the spec graph has been required to be acyclic since early on; the code graph now is too. **Guidance** reaches the agent before it designs (the authoring playbooks say "share types through a third module", pinned by test). **A design-time advisory** — `approval_status`'s `graphPreview` gains `cycles` — names the cycles your declared files are *already* in, with each edge classified as `type-erasable` (TypeScript deletes it, so it is not a runtime cycle), `lazy-require` (a workaround someone already paid for) or `eager-value`; the note says in words that this is **not** a prediction, because at approval time the code that would create a cycle does not exist yet. **A ratchet** in the Stop hook's constitution catches new cycles after the code is written — shipped in `track` (records, never blocks), and the escape is a **named exception**, never a threshold, so a project carrying legacy cycles can still adopt the harness. This repository went from three cycles to zero, and the two lazy `require()` workarounds they forced are gone.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- bdbd4f85-mu4ywfad
1
+ d2304488-mu6dv817
@@ -98,6 +98,44 @@ export declare function mergeAgentsMd(existing: string | null, generated: string
98
98
  * 계산과 쓰기를 나누는 이유는 dry-run 이 실제 실행과 같은 집합을 예고해야 하기 때문이다
99
99
  * (A-SPEC-190 §9). 같은 함수가 두 경로에 답하면 둘이 어긋날 수 없다.
100
100
  */
101
+ /**
102
+ * @implements A-SPEC-667
103
+ * Merging a wiring file the harness shares with other tools.
104
+ *
105
+ * Measured 2026-09-18 with a real `--dry-run`: a neighbour MCP server, a neighbour hook namespace, a
106
+ * neighbour plugin, a marketplace's own name and an operator's `disabled` flag all survived a
107
+ * re-wire at a rate of ZERO — the agy and codex writers replaced each file whole. The Claude path
108
+ * learned this in A-SPEC-179 ("merge, not replace"; `--force` means refresh the wiring, not turn off
109
+ * what I switched on). These functions give the other two harnesses the same discipline.
110
+ *
111
+ * Pure text in, pure text out, so `init` can merge during its COMPUTE phase and `--dry-run` predicts
112
+ * the bytes the write produces (the A-SPEC-190 §9 rule).
113
+ */
114
+ export interface WiringMerge {
115
+ content: string;
116
+ /** Names of things that were kept because they are not ours — reported to the person verbatim. */
117
+ preserved: string[];
118
+ /** Sentences about the merged state a person needs (e.g. the wiring is present but disabled). */
119
+ notes: string[];
120
+ /** Set when the existing file could not be read; `content` is then the ORIGINAL, and init skips it. */
121
+ refused?: string;
122
+ }
123
+ /**
124
+ * `.agents/mcp_config.json`. Neighbour servers survive whole; inside the holmes-kit entry our
125
+ * command, args and `HOLMES_SPECS` win while every other key a person put there — `disabled` above
126
+ * all — is kept. Turning someone's switch back on without a word is the surprise this prevents.
127
+ */
128
+ export declare function mergeMcpConfigJson(before: string | null, ours: string): WiringMerge;
129
+ /** `.agents/hooks.json`. Top-level keys are tool namespaces; only ours is replaced. */
130
+ export declare function mergeHooksJson(before: string | null, ours: string): WiringMerge;
131
+ /**
132
+ * `marketplace.json`. Only the holmes-kit plugin entry is ours; the marketplace's identity and every
133
+ * other plugin belong to whoever created the file. Renaming someone's marketplace to ours would be
134
+ * the same silent theft as deleting their plugins.
135
+ */
136
+ export declare function mergeMarketplaceJson(before: string | null, ours: string): WiringMerge;
137
+ /** The merged set, by basename. `null` means "no merger" — the caller writes as it always did. */
138
+ export declare function mergeWiringFile(basename: string, before: string | null, ours: string): WiringMerge | null;
101
139
  export declare function agentFiles(agent: Agent, opts: AgentWiringOptions): AgentFile[];
102
140
  export interface AgentLink {
103
141
  path: string;
@@ -38,8 +38,13 @@ exports.antigravityHookCommand = antigravityHookCommand;
38
38
  exports.shortPathIfSpaced = shortPathIfSpaced;
39
39
  exports.antigravityHookWarnings = antigravityHookWarnings;
40
40
  exports.mergeAgentsMd = mergeAgentsMd;
41
+ exports.mergeMcpConfigJson = mergeMcpConfigJson;
42
+ exports.mergeHooksJson = mergeHooksJson;
43
+ exports.mergeMarketplaceJson = mergeMarketplaceJson;
44
+ exports.mergeWiringFile = mergeWiringFile;
41
45
  exports.agentFiles = agentFiles;
42
46
  exports.agentLinks = agentLinks;
47
+ // @implements A-SPEC-202, A-SPEC-250, A-SPEC-341
43
48
  // @implements A-SPEC-442
44
49
  // @implements A-SPEC-193
45
50
  const path = __importStar(require("node:path"));
@@ -296,12 +301,109 @@ function closeRun(runs, run) {
296
301
  runs.push(run);
297
302
  return [];
298
303
  }
304
+ const HOLMES = 'holmes-kit';
305
+ /** Parse, or explain why not. An unreadable file is never replaced: silently deleting it is the defect. */
306
+ function parseOr(before, ours, what) {
307
+ if (before === null)
308
+ return { content: ours, preserved: [], notes: [] };
309
+ let prev;
310
+ try {
311
+ prev = JSON.parse(before);
312
+ }
313
+ catch {
314
+ return { content: before, preserved: [], notes: [], refused: `${what} is not readable JSON — left untouched; fix or remove it, then re-run init` };
315
+ }
316
+ if (prev === null || typeof prev !== 'object' || Array.isArray(prev)) {
317
+ return { content: before, preserved: [], notes: [], refused: `${what} is not a JSON object — left untouched; fix or remove it, then re-run init` };
318
+ }
319
+ return { prev: prev };
320
+ }
321
+ const isMerge = (x) => 'content' in x;
322
+ const asObject = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {});
299
323
  /**
300
- * 하네스에 써야 파일들. **쓰지는 않는다** 무엇을 쓸지만 말한다.
301
- *
302
- * 계산과 쓰기를 나누는 이유는 dry-run 실제 실행과 같은 집합을 예고해야 하기 때문이다
303
- * (A-SPEC-190 §9). 같은 함수가 두 경로에 답하면 둘이 어긋날 수 없다.
324
+ * `.agents/mcp_config.json`. Neighbour servers survive whole; inside the holmes-kit entry our
325
+ * command, args and `HOLMES_SPECS` win while every other key a person put there — `disabled` above
326
+ * all is kept. Turning someone's switch back on without a word is the surprise this prevents.
327
+ */
328
+ function mergeMcpConfigJson(before, ours) {
329
+ const parsed = parseOr(before, ours, 'mcp_config.json');
330
+ if (isMerge(parsed))
331
+ return parsed;
332
+ const oursObj = asObject(JSON.parse(ours));
333
+ const oursEntry = asObject(asObject(oursObj.mcpServers)[HOLMES]);
334
+ const prevServers = asObject(parsed.prev.mcpServers);
335
+ const prevEntry = asObject(prevServers[HOLMES]);
336
+ const keptKeys = Object.keys(prevEntry).filter((k) => k !== 'command' && k !== 'args' && k !== 'env');
337
+ const merged = {
338
+ ...prevEntry,
339
+ command: oursEntry.command,
340
+ args: oursEntry.args,
341
+ env: { ...asObject(prevEntry.env), ...asObject(oursEntry.env) },
342
+ };
343
+ const out = { ...parsed.prev, mcpServers: { ...prevServers, [HOLMES]: merged } };
344
+ const notes = [];
345
+ if (prevEntry.disabled === true || prevEntry.disabled === 'true') {
346
+ notes.push(`mcp_config.json: holmes-kit is wired but DISABLED in this config — the gate will not run until it is enabled.`);
347
+ }
348
+ return {
349
+ content: `${JSON.stringify(out, null, 2)}\n`,
350
+ preserved: [...Object.keys(prevServers).filter((k) => k !== HOLMES), ...keptKeys],
351
+ notes,
352
+ };
353
+ }
354
+ /** `.agents/hooks.json`. Top-level keys are tool namespaces; only ours is replaced. */
355
+ function mergeHooksJson(before, ours) {
356
+ const parsed = parseOr(before, ours, 'hooks.json');
357
+ if (isMerge(parsed))
358
+ return parsed;
359
+ const oursObj = asObject(JSON.parse(ours));
360
+ const out = { ...parsed.prev, [HOLMES]: oursObj[HOLMES] };
361
+ return {
362
+ content: `${JSON.stringify(out, null, 2)}\n`,
363
+ preserved: Object.keys(parsed.prev).filter((k) => k !== HOLMES),
364
+ notes: [],
365
+ };
366
+ }
367
+ /**
368
+ * `marketplace.json`. Only the holmes-kit plugin entry is ours; the marketplace's identity and every
369
+ * other plugin belong to whoever created the file. Renaming someone's marketplace to ours would be
370
+ * the same silent theft as deleting their plugins.
304
371
  */
372
+ function mergeMarketplaceJson(before, ours) {
373
+ const parsed = parseOr(before, ours, 'marketplace.json');
374
+ if (isMerge(parsed))
375
+ return parsed;
376
+ const oursObj = asObject(JSON.parse(ours));
377
+ const oursPlugin = (Array.isArray(oursObj.plugins) ? oursObj.plugins : []).find((p) => asObject(p).name === HOLMES);
378
+ const prevPlugins = Array.isArray(parsed.prev.plugins) ? parsed.prev.plugins : [];
379
+ const hadOurs = prevPlugins.some((p) => asObject(p).name === HOLMES);
380
+ const plugins = hadOurs
381
+ ? prevPlugins.map((p) => (asObject(p).name === HOLMES ? oursPlugin : p))
382
+ : [...prevPlugins, oursPlugin];
383
+ const keptName = typeof parsed.prev.name === 'string' && parsed.prev.name !== '' ? parsed.prev.name : undefined;
384
+ const out = {
385
+ ...parsed.prev,
386
+ name: keptName ?? oursObj.name,
387
+ interface: parsed.prev.interface ?? oursObj.interface,
388
+ plugins,
389
+ };
390
+ const neighbours = prevPlugins.map((p) => String(asObject(p).name ?? '')).filter((n) => n !== '' && n !== HOLMES);
391
+ return {
392
+ content: `${JSON.stringify(out, null, 2)}\n`,
393
+ preserved: [...neighbours, ...(keptName !== undefined && keptName !== oursObj.name ? [keptName] : [])],
394
+ notes: [],
395
+ };
396
+ }
397
+ /** The merged set, by basename. `null` means "no merger" — the caller writes as it always did. */
398
+ function mergeWiringFile(basename, before, ours) {
399
+ if (basename === 'mcp_config.json')
400
+ return mergeMcpConfigJson(before, ours);
401
+ if (basename === 'hooks.json')
402
+ return mergeHooksJson(before, ours);
403
+ if (basename === 'marketplace.json')
404
+ return mergeMarketplaceJson(before, ours);
405
+ return null;
406
+ }
305
407
  function agentFiles(agent, opts) {
306
408
  const { target, packageRoot, specsDir } = opts;
307
409
  switch (agent) {
@@ -171,6 +171,7 @@ export declare function wiringSpawnCheck(command: string, args: string[], timeou
171
171
  * the same honest-diagnosis lineage as the codex-wiring WARN (never a gate).
172
172
  */
173
173
  export declare function pushGateCheck(target: string): Check | null;
174
+ export declare function ciMatrixCheck(target: string): Check | null;
174
175
  export declare function formatChecks(checks: Check[]): string;
175
176
  /**
176
177
  * Prove that EVERY wired harness can actually start a server, not merely that its file parses.
@@ -47,10 +47,12 @@ exports.resolveWiringPath = resolveWiringPath;
47
47
  exports.runDoctor = runDoctor;
48
48
  exports.wiringSpawnCheck = wiringSpawnCheck;
49
49
  exports.pushGateCheck = pushGateCheck;
50
+ exports.ciMatrixCheck = ciMatrixCheck;
50
51
  exports.formatChecks = formatChecks;
51
52
  exports.wiringHandshakeChecks = wiringHandshakeChecks;
52
53
  exports.semanticTierVerdict = semanticTierVerdict;
53
54
  exports.detectTreeKeyTemporary = detectTreeKeyTemporary;
55
+ // @implements A-SPEC-264, A-SPEC-423, A-SPEC-549.1, A-SPEC-590
54
56
  // @implements A-SPEC-594
55
57
  // @implements A-SPEC-592
56
58
  // @implements A-SPEC-591
@@ -65,6 +67,7 @@ const native_deps_1 = require("./native-deps");
65
67
  const tier_1 = require("../semantic/tier");
66
68
  const probe_process_1 = require("./probe-process");
67
69
  const npx_cache_check_1 = require("./npx-cache-check");
70
+ const ci_runs_1 = require("../project/ci-runs");
68
71
  const path = __importStar(require("node:path"));
69
72
  const role_policy_1 = require("../governance/role-policy");
70
73
  const blind_spots_1 = require("../guardrail/blind-spots");
@@ -1261,6 +1264,9 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
1261
1264
  const pg = pushGateCheck(target ?? process.cwd());
1262
1265
  if (pg)
1263
1266
  checks.push(pg);
1267
+ const ci = ciMatrixCheck(target ?? process.cwd()); // @implements A-SPEC-664
1268
+ if (ci)
1269
+ checks.push(ci);
1264
1270
  }
1265
1271
  if (extraChecks) {
1266
1272
  checks.push(...extraChecks);
@@ -1554,6 +1560,42 @@ function pushGateCheck(target) {
1554
1560
  return null;
1555
1561
  } // diagnosis must never crash doctor
1556
1562
  }
1563
+ // @implements A-SPEC-664
1564
+ // The CI matrix as doctor sees it: PASS only for a green row at HEAD; everything else is a WARN that
1565
+ // says exactly what the ledger says — "not run" when there is no row, the failed suites when red,
1566
+ // the infrastructure status when the run could not judge. Never a FAIL: the matrix informs.
1567
+ function ciMatrixCheck(target) {
1568
+ try {
1569
+ if (!fs.existsSync(path.join(target, '.ax')))
1570
+ return null;
1571
+ if (!(0, ci_runs_1.hasCiLedger)(target))
1572
+ return null; // @implements A-SPEC-664 — same adoption predicate as the hook
1573
+ const runs = (0, ci_runs_1.readCiRuns)(target);
1574
+ let head;
1575
+ try {
1576
+ head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
1577
+ }
1578
+ catch {
1579
+ head = undefined;
1580
+ }
1581
+ const behind = (rev) => {
1582
+ try {
1583
+ return Number((0, node_child_process_1.execFileSync)('git', ['rev-list', '--count', `${rev}..HEAD`], { cwd: target, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim());
1584
+ }
1585
+ catch {
1586
+ return undefined;
1587
+ }
1588
+ };
1589
+ const v = (0, ci_runs_1.ciVerdict)(runs, 'linux', head, behind);
1590
+ const detail = (0, ci_runs_1.ciStatusLine)(v);
1591
+ if (v.state === 'green' && v.behind === 0)
1592
+ return { name: 'ci matrix', level: 'PASS', detail };
1593
+ return { name: 'ci matrix', level: 'WARN', detail, fix: 'Run scripts/ci-orb-linux.sh HEAD (or install the launchd agent: scripts/ci-orb-linux-install.sh)' };
1594
+ }
1595
+ catch {
1596
+ return null;
1597
+ } // diagnosis must never crash doctor
1598
+ }
1557
1599
  function formatChecks(checks) {
1558
1600
  const lines = checks.map((c) => {
1559
1601
  const head = `${c.level.padEnd(4)} ${c.name} — ${c.detail}`;
@@ -37,6 +37,7 @@ exports.isBrokenPipe = void 0;
37
37
  exports.packageRoot = packageRoot;
38
38
  exports.main = main;
39
39
  exports.installPipeGuard = installPipeGuard;
40
+ // @implements A-SPEC-549.2
40
41
  // @implements A-SPEC-591, A-SPEC-626
41
42
  const cli_execution_1 = require("./cli-execution");
42
43
  // @implements A-SPEC-100.2
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.settingsPathOf = exports.MATCHERS = exports.SERVER_NAME = void 0;
37
37
  exports.buildHookPlan = buildHookPlan;
38
38
  exports.runInit = runInit;
39
+ // @implements A-SPEC-340
39
40
  // @implements A-SPEC-100.2
40
41
  const fs = __importStar(require("node:fs"));
41
42
  const path = __importStar(require("node:path"));
@@ -470,6 +471,23 @@ function runInit(opts) {
470
471
  }
471
472
  continue;
472
473
  }
474
+ // @implements A-SPEC-667 — these files are shared with other tools. Replacing one whole
475
+ // deletes a neighbour's MCP server, hooks or plugins without a word (measured 2026-09-18:
476
+ // every third-party entry survived at a rate of zero). Merging happens HERE, in the compute
477
+ // phase, so `--dry-run` predicts the bytes the write produces (A-SPEC-190 §9).
478
+ const wiring = (0, agents_1.mergeWiringFile)(path.basename(f.path), before, f.content);
479
+ if (wiring) {
480
+ if (wiring.refused) {
481
+ messages.push(`${f.path}: ${wiring.refused}`);
482
+ continue;
483
+ }
484
+ changes.push({ path: f.path, before, after: wiring.content });
485
+ if (wiring.preserved.length > 0)
486
+ messages.push(`${f.path}: kept ${wiring.preserved.length} ${wiring.preserved.length === 1 ? 'entry that is' : 'entries that are'} not holmes-kit's — ${wiring.preserved.join(', ')}`);
487
+ for (const n of wiring.notes)
488
+ messages.push(n);
489
+ continue;
490
+ }
473
491
  changes.push({ path: f.path, before, after: f.content });
474
492
  }
475
493
  // @implements A-SPEC-586 — an antigravity hook whose path still carries a space is a hook the
@@ -1,3 +1,4 @@
1
+ // @implements A-SPEC-302
1
2
  'use strict';
2
3
  // Single source of truth for the per-language symbol/edge tree-walking
3
4
  // logic. Called from BOTH the inline (production) parse path and the
@@ -2,7 +2,9 @@ import { PendingRequest } from '../governance/approval-queue';
2
2
  import { Spec } from '../spec/spec-parser';
3
3
  import type { TestOutcome } from '../review/test-runner';
4
4
  import { type KnownDefectJudgement } from '../rtm/known-defects';
5
+ import { type CiVerdict } from '../project/ci-runs';
5
6
  export declare function collectKnownDefects(root: string, now: Date): KnownDefectJudgement | undefined;
7
+ export declare function collectCiVerdicts(root: string, now?: Date): CiVerdict[];
6
8
  /**
7
9
  * @implements A-SPEC-100.2
8
10
  * Stop-hook governance gate (Phase-2 #1: push, not pull).
@@ -31,6 +33,11 @@ export interface StopEvidence {
31
33
  * `undefined` is no signal — a walk that could not run must not read as marker-free.
32
34
  */
33
35
  knownDefects?: KnownDefectJudgement;
36
+ /**
37
+ * @implements A-SPEC-664 — what the CI matrix last said about this tree, per OS. Absent when the
38
+ * hook could not look; a `not-run` verdict when it looked and found no row (never silence).
39
+ */
40
+ ci?: CiVerdict[];
34
41
  /** Provenance-chain verification result (CLI-supplied). A broken chain blocks the stop. */
35
42
  provenance?: {
36
43
  ok: boolean;
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MAX_CONSECUTIVE_BLOCKS = void 0;
37
37
  exports.collectKnownDefects = collectKnownDefects;
38
+ exports.collectCiVerdicts = collectCiVerdicts;
38
39
  exports.changedAnchoredAspecs = changedAnchoredAspecs;
39
40
  exports.unanchoredChangedSources = unanchoredChangedSources;
40
41
  exports.dependencyReappraisals = dependencyReappraisals;
@@ -79,6 +80,7 @@ const constitution_debt_1 = require("../governance/constitution-debt");
79
80
  const root_1 = require("../project/root");
80
81
  const known_defects_1 = require("../rtm/known-defects");
81
82
  const test_files_1 = require("../cpg/test-files");
83
+ const ci_runs_1 = require("../project/ci-runs");
82
84
  // @implements A-SPEC-660 — the I/O half of the known-defect marker: walk the workspace's test files
83
85
  // (the same directory rule and test predicate ART-4's anchor scan uses), parse each for markers,
84
86
  // judge them against the injected clock. A walk that cannot START is NO SIGNAL (undefined) — never
@@ -126,6 +128,40 @@ function collectKnownDefects(root, now) {
126
128
  return undefined;
127
129
  }
128
130
  }
131
+ // @implements A-SPEC-664 — the I/O half of the CI line: read the ci-runs ledger, judge Linux (always)
132
+ // and any other OS that has a row, with git measuring how far each judged commit sits behind HEAD.
133
+ // A tree without a ledger yields a `not-run` verdict — a line, not an absence.
134
+ function collectCiVerdicts(root, now = new Date()) {
135
+ void now;
136
+ // @implements A-SPEC-664 — a workspace that never ran the matrix hears nothing about it. Past this
137
+ // line the matrix IS adopted here, so a missing row is reported rather than passed over in silence.
138
+ if (!(0, ci_runs_1.hasCiLedger)(root))
139
+ return [];
140
+ let runs = [];
141
+ try {
142
+ runs = (0, ci_runs_1.readCiRuns)(root);
143
+ }
144
+ catch {
145
+ runs = [];
146
+ }
147
+ let head;
148
+ try {
149
+ head = (0, node_child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).trim();
150
+ }
151
+ catch {
152
+ head = undefined;
153
+ }
154
+ const behind = (rev) => {
155
+ try {
156
+ return Number((0, node_child_process_1.execFileSync)('git', ['rev-list', '--count', `${rev}..HEAD`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).trim());
157
+ }
158
+ catch {
159
+ return undefined;
160
+ }
161
+ };
162
+ const oses = ['linux', ...[...new Set(runs.map((r) => r.os))].filter((o) => o !== 'linux').sort()];
163
+ return oses.map((os) => (0, ci_runs_1.ciVerdict)(runs, os, head, behind));
164
+ }
129
165
  /**
130
166
  * @implements A-SPEC-534.4
131
167
  * ART-8 evidence (I/O half): the A-SPECs whose DIRTY source files carry an @implements anchor. git is
@@ -492,6 +528,7 @@ const TRACK_LABELS = {
492
528
  'ART-8': 'RED-first',
493
529
  'ART-2': 'code-graph cycles',
494
530
  'ART-9': 'known-defect debt',
531
+ 'CI': 'matrix',
495
532
  };
496
533
  /**
497
534
  * One line per ARTICLE, each under its own name.
@@ -557,6 +594,14 @@ function evaluateStop(specs, evidence) {
557
594
  const t = evidence.knownDefects.unexpired.map((k) => ({ article: 'ART-9', detail: `${k.file}:${k.line}: known defect "${k.reason}" until ${k.expires}` }));
558
595
  tracked = [...(tracked ?? []), ...t];
559
596
  }
597
+ // @implements A-SPEC-664 — the CI matrix's last word, one tracked line per OS. Never a block: a
598
+ // red Linux run is information for the person closing the turn, and "not run" is said out loud
599
+ // because the alternative — silence — reads as green (measured 2026-09-17: 136 environmental reds
600
+ // surfaced only because someone ran the suite by hand before a release).
601
+ if (evidence?.ci && evidence.ci.length > 0) {
602
+ const t = evidence.ci.map((v) => ({ article: 'CI', detail: (0, ci_runs_1.ciStatusLine)(v) }));
603
+ tracked = [...(tracked ?? []), ...t];
604
+ }
560
605
  const problems = violations.map((x) => `[${x.article}] ${x.detail}`);
561
606
  // @implements A-SPEC-247 — structured list so the caller can ask acknowledgeStop which of these
562
607
  // are waiting on an owner. Mirrors `problems` exactly, including the two synthesized below.
@@ -1141,7 +1186,15 @@ if (require.main === module) {
1141
1186
  catch { /* maintenance, never a hook failure */ }
1142
1187
  // @implements A-SPEC-660 — the marker walk: no signal when it cannot run (never a clean verdict).
1143
1188
  const knownDefects = collectKnownDefects(stopProjectRoot(), new Date());
1144
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}) });
1189
+ // @implements A-SPEC-664 the CI matrix line rides beside the constitution's verdict.
1190
+ let ci;
1191
+ try {
1192
+ ci = collectCiVerdicts(stopProjectRoot());
1193
+ }
1194
+ catch {
1195
+ ci = undefined;
1196
+ }
1197
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec, ...(knownDefects ? { knownDefects } : {}), ...(ci ? { ci } : {}) });
1145
1198
  // @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
1146
1199
  // the operator observes RED-first gaps before an owner promotes the posture to strict.
1147
1200
  // @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
@@ -15,6 +15,11 @@ export interface OperatorInspectionContext {
15
15
  reason: string;
16
16
  };
17
17
  fetchJson(url: string): Promise<any>;
18
+ /** A-SPEC-663 — the production scan, for the trace-gap finding (same closure test_run uses). */
19
+ cachedScan?(root: string, repoRoot?: string): Array<{
20
+ path: string;
21
+ implementsSpecs?: string[];
22
+ }>;
18
23
  /** A-SPEC-642 — the shared approval channel and queue hint, for the reconciliation act. */
19
24
  resolveHandlerApproval(root: string | undefined, approval: Approval | undefined, action: {
20
25
  kind: string;
@@ -46,6 +51,9 @@ export declare function createOperatorInspectionHandlers(context: OperatorInspec
46
51
  ok: boolean;
47
52
  reason: string;
48
53
  } | {
54
+ advisoryHistory?: import("../../rtm/advisory-outcomes").AdvisoryState[] | undefined;
55
+ advisoryIds?: Partial<Record<string, string>> | undefined;
56
+ killsAdvisory?: import("../../review/kills-check").KillsAdvisory | undefined;
49
57
  fttFulfilment?: import("../../rtm/ftt-fulfilment").FttFulfilment | undefined;
50
58
  graphPreview?: {
51
59
  impact?: import("../../rtm/impact-advisory").ImpactAdvisory;
@@ -162,7 +170,27 @@ export declare function createOperatorInspectionHandlers(context: OperatorInspec
162
170
  ok: boolean;
163
171
  url: string;
164
172
  running: boolean;
165
- census: import("../../server/dashboard-launcher").DashboardCensusExtended;
173
+ census: {
174
+ advisoryOutcomes?: import("../../rtm/advisory-outcomes").AdvisoryCensus | undefined;
175
+ codeLinkedPct: number;
176
+ codeLinkedCount: number;
177
+ unlinkedCount: number;
178
+ unlinkedByReason: Record<import("../../rtm/link-census").UnlinkedReason, number>;
179
+ excluded: {
180
+ total: number;
181
+ retired: number;
182
+ unmapped: number;
183
+ nonSpec: number;
184
+ };
185
+ declaredScopeByScope: Record<import("../../rtm/declared-scope").DeclaredScope, number>;
186
+ unreachableDeclaringCount: number;
187
+ reqCount: number;
188
+ pipelineCount: number;
189
+ coveredCount: number;
190
+ coveragePct: number;
191
+ retiredCount: number;
192
+ findingsScanned: boolean;
193
+ };
166
194
  reason?: undefined;
167
195
  }>;
168
196
  };