@holmes-lab/holmes-kit 0.22.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.
- package/CHANGELOG.md +60 -0
- package/README.md +8 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +38 -0
- package/dist/holmes/cli/agents.js +105 -4
- package/dist/holmes/cli/init.js +17 -0
- package/dist/holmes/mcp/handlers/operator-inspection.d.ts +2 -0
- package/dist/holmes/mcp/handlers.d.ts +2 -0
- package/dist/holmes/mcp/supervisor.d.ts +35 -0
- package/dist/holmes/mcp/supervisor.js +105 -2
- package/dist/holmes/rtm/declared-scope.d.ts +28 -0
- package/dist/holmes/rtm/declared-scope.js +60 -0
- package/dist/holmes/server/dashboard-launcher.d.ts +7 -0
- package/dist/holmes/server/dashboard-launcher.js +3 -0
- package/dist/holmes/server/dashboard.js +14 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,66 @@ 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
|
+
|
|
8
68
|
## [0.22.0] - 2026-09-18
|
|
9
69
|
|
|
10
70
|
The advisories learn what happened next. Until now this repository issued findings and recorded that
|
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
|
-
|
|
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,6 +38,10 @@ 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;
|
|
43
47
|
// @implements A-SPEC-202, A-SPEC-250, A-SPEC-341
|
|
@@ -297,12 +301,109 @@ function closeRun(runs, run) {
|
|
|
297
301
|
runs.push(run);
|
|
298
302
|
return [];
|
|
299
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 : {});
|
|
300
323
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
|
|
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.
|
|
305
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
|
+
}
|
|
306
407
|
function agentFiles(agent, opts) {
|
|
307
408
|
const { target, packageRoot, specsDir } = opts;
|
|
308
409
|
switch (agent) {
|
package/dist/holmes/cli/init.js
CHANGED
|
@@ -471,6 +471,23 @@ function runInit(opts) {
|
|
|
471
471
|
}
|
|
472
472
|
continue;
|
|
473
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
|
+
}
|
|
474
491
|
changes.push({ path: f.path, before, after: f.content });
|
|
475
492
|
}
|
|
476
493
|
// @implements A-SPEC-586 — an antigravity hook whose path still carries a space is a hook the
|
|
@@ -182,6 +182,8 @@ export declare function createOperatorInspectionHandlers(context: OperatorInspec
|
|
|
182
182
|
unmapped: number;
|
|
183
183
|
nonSpec: number;
|
|
184
184
|
};
|
|
185
|
+
declaredScopeByScope: Record<import("../../rtm/declared-scope").DeclaredScope, number>;
|
|
186
|
+
unreachableDeclaringCount: number;
|
|
185
187
|
reqCount: number;
|
|
186
188
|
pipelineCount: number;
|
|
187
189
|
coveredCount: number;
|
|
@@ -839,6 +839,8 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
|
839
839
|
unmapped: number;
|
|
840
840
|
nonSpec: number;
|
|
841
841
|
};
|
|
842
|
+
declaredScopeByScope: Record<import("../rtm/declared-scope").DeclaredScope, number>;
|
|
843
|
+
unreachableDeclaringCount: number;
|
|
842
844
|
reqCount: number;
|
|
843
845
|
pipelineCount: number;
|
|
844
846
|
coveredCount: number;
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/** Opt-in switch. Absent means the entry point behaves exactly as it did before this existed. */
|
|
2
2
|
export declare const AUTORELOAD_ENV = "HOLMES_MCP_AUTORELOAD";
|
|
3
|
+
/**
|
|
4
|
+
* @implements A-SPEC-666 — how many times a child may die WITHOUT ever answering before the
|
|
5
|
+
* supervisor stops replacing it. A server that worked and then died once has not spent this budget;
|
|
6
|
+
* only a child that produced nothing in its whole life counts, so "a broken build that dies on every
|
|
7
|
+
* spawn" stops quickly while "one OOM under load" restarts freely.
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_CONSECUTIVE_SILENT_DEATHS = 5;
|
|
3
10
|
export declare function autoreloadEnabled(env: NodeJS.ProcessEnv): boolean;
|
|
4
11
|
/**
|
|
5
12
|
* Whether the child may be replaced right now.
|
|
@@ -63,10 +70,38 @@ export declare class Supervisor {
|
|
|
63
70
|
* supervisor makes, that the connection does not drop.
|
|
64
71
|
*/
|
|
65
72
|
private handshake;
|
|
73
|
+
/**
|
|
74
|
+
* @implements A-SPEC-666 — WHICH requests are unanswered, not merely how many. `inflight` counts
|
|
75
|
+
* for the swap decision and its counter is contracted to count without identifying (A-SPEC-516.1);
|
|
76
|
+
* answering a death needs identity, so it is tracked separately rather than by widening that one.
|
|
77
|
+
*/
|
|
78
|
+
private pending;
|
|
79
|
+
/** True while an exit we caused (a swap, a stop) is expected — not every death is an accident. */
|
|
80
|
+
private expectedExit;
|
|
81
|
+
/** Consecutive children that died having never answered anything. Reset by any answer. */
|
|
82
|
+
private silentDeaths;
|
|
83
|
+
private answeredSinceSpawn;
|
|
84
|
+
/** True once the limit is reached: the supervisor stops replacing and says so. */
|
|
85
|
+
private givenUp;
|
|
66
86
|
constructor(repoRoot: string, onSwap?: ((from: string, to: string, replayed: number) => void) | undefined);
|
|
67
87
|
start(stdin: NodeJS.ReadableStream, stdout: NodeJS.WritableStream): void;
|
|
68
88
|
private forward;
|
|
69
89
|
private swap;
|
|
70
90
|
private spawnChild;
|
|
91
|
+
/**
|
|
92
|
+
* @implements A-SPEC-666
|
|
93
|
+
* Drop the ids of every COMPLETE response line from `pending`; return the unfinished tail so a
|
|
94
|
+
* reply split across chunks is not lost. A line with neither `result` nor `error` is a
|
|
95
|
+
* notification, not an answer, and retires nothing.
|
|
96
|
+
*/
|
|
97
|
+
private retireAnsweredIds;
|
|
98
|
+
/**
|
|
99
|
+
* @implements A-SPEC-666
|
|
100
|
+
* The child is gone. Measured 2026-09-18: without this, `this.child` kept pointing at the corpse,
|
|
101
|
+
* every later request was written to a dead pipe, `inflight` never returned to zero and
|
|
102
|
+
* `shouldSwap`'s `inflight === 0` therefore never fired — the server was deaf for the rest of the
|
|
103
|
+
* session, twice in one day. Order matters: free the client first, then decide about a replacement.
|
|
104
|
+
*/
|
|
105
|
+
private onChildGone;
|
|
71
106
|
stop(): void;
|
|
72
107
|
}
|
|
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.Supervisor = exports.AUTORELOAD_ENV = void 0;
|
|
36
|
+
exports.Supervisor = exports.MAX_CONSECUTIVE_SILENT_DEATHS = exports.AUTORELOAD_ENV = void 0;
|
|
37
37
|
exports.autoreloadEnabled = autoreloadEnabled;
|
|
38
38
|
exports.shouldSwap = shouldSwap;
|
|
39
39
|
exports.createResponseCounter = createResponseCounter;
|
|
@@ -43,6 +43,13 @@ const path = __importStar(require("node:path"));
|
|
|
43
43
|
const basis_1 = require("./basis");
|
|
44
44
|
/** Opt-in switch. Absent means the entry point behaves exactly as it did before this existed. */
|
|
45
45
|
exports.AUTORELOAD_ENV = 'HOLMES_MCP_AUTORELOAD';
|
|
46
|
+
/**
|
|
47
|
+
* @implements A-SPEC-666 — how many times a child may die WITHOUT ever answering before the
|
|
48
|
+
* supervisor stops replacing it. A server that worked and then died once has not spent this budget;
|
|
49
|
+
* only a child that produced nothing in its whole life counts, so "a broken build that dies on every
|
|
50
|
+
* spawn" stops quickly while "one OOM under load" restarts freely.
|
|
51
|
+
*/
|
|
52
|
+
exports.MAX_CONSECUTIVE_SILENT_DEATHS = 5;
|
|
46
53
|
function autoreloadEnabled(env) {
|
|
47
54
|
const v = env[exports.AUTORELOAD_ENV];
|
|
48
55
|
return typeof v === 'string' && v.trim() !== '';
|
|
@@ -144,6 +151,19 @@ class Supervisor {
|
|
|
144
151
|
* supervisor makes, that the connection does not drop.
|
|
145
152
|
*/
|
|
146
153
|
handshake = [];
|
|
154
|
+
/**
|
|
155
|
+
* @implements A-SPEC-666 — WHICH requests are unanswered, not merely how many. `inflight` counts
|
|
156
|
+
* for the swap decision and its counter is contracted to count without identifying (A-SPEC-516.1);
|
|
157
|
+
* answering a death needs identity, so it is tracked separately rather than by widening that one.
|
|
158
|
+
*/
|
|
159
|
+
pending = new Set();
|
|
160
|
+
/** True while an exit we caused (a swap, a stop) is expected — not every death is an accident. */
|
|
161
|
+
expectedExit = false;
|
|
162
|
+
/** Consecutive children that died having never answered anything. Reset by any answer. */
|
|
163
|
+
silentDeaths = 0;
|
|
164
|
+
answeredSinceSpawn = false;
|
|
165
|
+
/** True once the limit is reached: the supervisor stops replacing and says so. */
|
|
166
|
+
givenUp = false;
|
|
147
167
|
constructor(repoRoot, onSwap) {
|
|
148
168
|
this.repoRoot = repoRoot;
|
|
149
169
|
this.onSwap = onSwap;
|
|
@@ -165,12 +185,14 @@ class Supervisor {
|
|
|
165
185
|
forward(line, stdout) {
|
|
166
186
|
const trimmed = line.trim();
|
|
167
187
|
let isRequest = false;
|
|
188
|
+
let requestId;
|
|
168
189
|
if (trimmed.startsWith('{')) {
|
|
169
190
|
try {
|
|
170
191
|
const msg = JSON.parse(trimmed);
|
|
171
192
|
if (msg.method === 'initialize' || msg.method === 'notifications/initialized')
|
|
172
193
|
this.handshake.push(line);
|
|
173
194
|
isRequest = msg.id !== undefined;
|
|
195
|
+
requestId = msg.id;
|
|
174
196
|
}
|
|
175
197
|
catch { /* unparseable input is relayed untouched; the child owns protocol errors */ }
|
|
176
198
|
}
|
|
@@ -181,12 +203,16 @@ class Supervisor {
|
|
|
181
203
|
const disk = (0, basis_1.loadedBuildId)(this.repoRoot);
|
|
182
204
|
if (shouldSwap(this.loaded, disk, this.inflight))
|
|
183
205
|
this.swap(disk, stdout);
|
|
184
|
-
if (isRequest)
|
|
206
|
+
if (isRequest) {
|
|
185
207
|
this.inflight++;
|
|
208
|
+
if (requestId !== undefined)
|
|
209
|
+
this.pending.add(requestId); // @implements A-SPEC-666
|
|
210
|
+
}
|
|
186
211
|
this.child?.stdin.write(line);
|
|
187
212
|
}
|
|
188
213
|
swap(to, stdout) {
|
|
189
214
|
const from = this.loaded;
|
|
215
|
+
this.expectedExit = true; // @implements A-SPEC-666 — we are replacing it on purpose
|
|
190
216
|
this.child?.kill();
|
|
191
217
|
this.loaded = to;
|
|
192
218
|
this.spawnChild(stdout);
|
|
@@ -217,6 +243,10 @@ class Supervisor {
|
|
|
217
243
|
// @implements A-SPEC-516.1 — one counter per child: a half-read line from the process being
|
|
218
244
|
// replaced must not be finished by its successor's first chunk.
|
|
219
245
|
const counter = createResponseCounter();
|
|
246
|
+
// @implements A-SPEC-666 — a fresh child has answered nothing yet; the silent-death budget is
|
|
247
|
+
// spent only by children that stay silent for their whole life.
|
|
248
|
+
this.answeredSinceSpawn = false;
|
|
249
|
+
let idCarry = '';
|
|
220
250
|
child.stdout.on('data', (d) => {
|
|
221
251
|
const text = d.toString();
|
|
222
252
|
// Relay FIRST. Counting is an observation and must never delay or alter the bytes.
|
|
@@ -226,9 +256,82 @@ class Supervisor {
|
|
|
226
256
|
// count would let a swap happen mid-request, which is the mis-delivered answer this whole
|
|
227
257
|
// mechanism defers swaps to avoid.
|
|
228
258
|
this.inflight = Math.max(0, this.inflight - counter.push(text));
|
|
259
|
+
// @implements A-SPEC-666 — and separately, WHICH ids were answered, so a death can reply to
|
|
260
|
+
// the rest. Its own line assembly: the counter above is contracted to count, not to identify.
|
|
261
|
+
idCarry = this.retireAnsweredIds(idCarry + text);
|
|
229
262
|
});
|
|
263
|
+
child.on('exit', () => this.onChildGone(stdout));
|
|
264
|
+
child.on('error', () => this.onChildGone(stdout));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* @implements A-SPEC-666
|
|
268
|
+
* Drop the ids of every COMPLETE response line from `pending`; return the unfinished tail so a
|
|
269
|
+
* reply split across chunks is not lost. A line with neither `result` nor `error` is a
|
|
270
|
+
* notification, not an answer, and retires nothing.
|
|
271
|
+
*/
|
|
272
|
+
retireAnsweredIds(buffered) {
|
|
273
|
+
const parts = buffered.split('\n');
|
|
274
|
+
const tail = parts.pop() ?? '';
|
|
275
|
+
for (const line of parts) {
|
|
276
|
+
const trimmed = line.trim();
|
|
277
|
+
if (!trimmed.startsWith('{'))
|
|
278
|
+
continue;
|
|
279
|
+
try {
|
|
280
|
+
const msg = JSON.parse(trimmed);
|
|
281
|
+
if (msg.id === undefined)
|
|
282
|
+
continue;
|
|
283
|
+
if (msg.result === undefined && msg.error === undefined)
|
|
284
|
+
continue;
|
|
285
|
+
if (this.pending.delete(msg.id))
|
|
286
|
+
this.answeredSinceSpawn = true;
|
|
287
|
+
else
|
|
288
|
+
this.answeredSinceSpawn = true; // an answer we did not track is still an answer
|
|
289
|
+
}
|
|
290
|
+
catch { /* a partial or malformed line retires nothing */ }
|
|
291
|
+
}
|
|
292
|
+
return tail;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* @implements A-SPEC-666
|
|
296
|
+
* The child is gone. Measured 2026-09-18: without this, `this.child` kept pointing at the corpse,
|
|
297
|
+
* every later request was written to a dead pipe, `inflight` never returned to zero and
|
|
298
|
+
* `shouldSwap`'s `inflight === 0` therefore never fired — the server was deaf for the rest of the
|
|
299
|
+
* session, twice in one day. Order matters: free the client first, then decide about a replacement.
|
|
300
|
+
*/
|
|
301
|
+
onChildGone(stdout) {
|
|
302
|
+
if (this.expectedExit) {
|
|
303
|
+
this.expectedExit = false;
|
|
304
|
+
return;
|
|
305
|
+
} // a swap or a stop, not an accident
|
|
306
|
+
if (this.givenUp)
|
|
307
|
+
return;
|
|
308
|
+
// 1. Answer what the dead child never will. A request without a reply is a client waiting for ever.
|
|
309
|
+
for (const id of this.pending) {
|
|
310
|
+
stdout.write(`${JSON.stringify({
|
|
311
|
+
jsonrpc: '2.0', id,
|
|
312
|
+
error: { code: -32603, message: 'holmes-kit MCP child process exited before answering this request' },
|
|
313
|
+
})}\n`);
|
|
314
|
+
}
|
|
315
|
+
const orphaned = this.pending.size;
|
|
316
|
+
this.pending.clear();
|
|
317
|
+
this.inflight = 0;
|
|
318
|
+
// 2. A child that produced nothing in its whole life spends the budget; one that worked does not.
|
|
319
|
+
this.silentDeaths = this.answeredSinceSpawn ? 0 : this.silentDeaths + 1;
|
|
320
|
+
if (this.silentDeaths >= exports.MAX_CONSECUTIVE_SILENT_DEATHS) {
|
|
321
|
+
this.givenUp = true;
|
|
322
|
+
this.child = undefined;
|
|
323
|
+
process.stderr.write(`[holmes-kit] MCP child died ${this.silentDeaths} times without answering — stopped restarting it. Check the build (\`npm run build\`) and restart the server.\n`);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
// 3. Replace it and re-send the opening exchange, exactly as a swap does.
|
|
327
|
+
process.stderr.write(`[holmes-kit] MCP child exited unexpectedly; restarting (${orphaned} unanswered request(s) failed).\n`);
|
|
328
|
+
this.spawnChild(stdout);
|
|
329
|
+
for (const line of this.handshake)
|
|
330
|
+
this.child?.stdin.write(line);
|
|
230
331
|
}
|
|
231
332
|
stop() {
|
|
333
|
+
this.expectedExit = true; // @implements A-SPEC-666 — a shutdown is not an accident
|
|
334
|
+
this.givenUp = true; // ... and nothing may respawn after it
|
|
232
335
|
this.child?.kill();
|
|
233
336
|
}
|
|
234
337
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type DeclaredScope = 'scanned-source' | 'file-anchor-target' | 'test-target' | 'unreachable-target' | 'no-declaration';
|
|
2
|
+
export interface ScopePredicates {
|
|
3
|
+
isScanned(rel: string): boolean;
|
|
4
|
+
isFileAnchorable(rel: string): boolean;
|
|
5
|
+
isTest(rel: string): boolean;
|
|
6
|
+
}
|
|
7
|
+
export declare const DECLARED_SCOPES: readonly DeclaredScope[];
|
|
8
|
+
export declare function emptyByScope(): Record<DeclaredScope, number>;
|
|
9
|
+
/**
|
|
10
|
+
* One label per spec, by the STRONGEST reachability its declaration offers: a scanned source beats a
|
|
11
|
+
* file-anchor target beats a test file beats nothing any mechanism reads. `unreachable` is filled
|
|
12
|
+
* whatever the label is, because a single label hides the `docs/x.md` sitting beside a test file —
|
|
13
|
+
* measured here, 21 of the 52 unlinked specs declare at least one such path.
|
|
14
|
+
* The Files-to-Touch parser is REQ-656's, so path extraction has exactly one definition.
|
|
15
|
+
*/
|
|
16
|
+
export declare function declaredScopeOf(fttText: string, p: ScopePredicates): {
|
|
17
|
+
scope: DeclaredScope;
|
|
18
|
+
unreachable: string[];
|
|
19
|
+
};
|
|
20
|
+
/** Every entry labelled exactly once, so the aggregate sums to the population it was given. */
|
|
21
|
+
export declare function declaredScopeCensus(entries: ReadonlyArray<{
|
|
22
|
+
id: string;
|
|
23
|
+
ftt: string;
|
|
24
|
+
}>, p: ScopePredicates): {
|
|
25
|
+
byId: Record<string, DeclaredScope>;
|
|
26
|
+
byScope: Record<DeclaredScope, number>;
|
|
27
|
+
unreachableDeclaring: number;
|
|
28
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DECLARED_SCOPES = void 0;
|
|
4
|
+
exports.emptyByScope = emptyByScope;
|
|
5
|
+
exports.declaredScopeOf = declaredScopeOf;
|
|
6
|
+
exports.declaredScopeCensus = declaredScopeCensus;
|
|
7
|
+
// @implements A-SPEC-665
|
|
8
|
+
/**
|
|
9
|
+
* The DECLARATION axis of an unlinked approved A-SPEC.
|
|
10
|
+
*
|
|
11
|
+
* REQ-655's census answers "where is this spec anchored" (`UnlinkedReason`). It cannot answer the
|
|
12
|
+
* question a reader actually has when `codeLinkedPct` reads 91.9: is there production code for this
|
|
13
|
+
* spec to be linked to at all? Measured 2026-09-18, after REQ-664's S3 moved 21 anchors: of the 52
|
|
14
|
+
* unlinked approved specs, ZERO declare a file the scanner parses. They are unlinked because they
|
|
15
|
+
* have no such file, not because an anchor is missing.
|
|
16
|
+
*
|
|
17
|
+
* The two axes sit side by side and never share a name. This one names what a spec DECLARED — a
|
|
18
|
+
* fact — and says nothing about whether that is acceptable; `traceGaps` remains the reader that
|
|
19
|
+
* calls one of these situations a gap.
|
|
20
|
+
*
|
|
21
|
+
* Pure: every reachability question is injected, so the classification follows the mechanisms'
|
|
22
|
+
* own definitions rather than a second copy of them.
|
|
23
|
+
*/
|
|
24
|
+
const ftt_fulfilment_1 = require("./ftt-fulfilment");
|
|
25
|
+
exports.DECLARED_SCOPES = ['scanned-source', 'file-anchor-target', 'test-target', 'unreachable-target', 'no-declaration'];
|
|
26
|
+
function emptyByScope() {
|
|
27
|
+
return { 'scanned-source': 0, 'file-anchor-target': 0, 'test-target': 0, 'unreachable-target': 0, 'no-declaration': 0 };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One label per spec, by the STRONGEST reachability its declaration offers: a scanned source beats a
|
|
31
|
+
* file-anchor target beats a test file beats nothing any mechanism reads. `unreachable` is filled
|
|
32
|
+
* whatever the label is, because a single label hides the `docs/x.md` sitting beside a test file —
|
|
33
|
+
* measured here, 21 of the 52 unlinked specs declare at least one such path.
|
|
34
|
+
* The Files-to-Touch parser is REQ-656's, so path extraction has exactly one definition.
|
|
35
|
+
*/
|
|
36
|
+
function declaredScopeOf(fttText, p) {
|
|
37
|
+
const items = [...new Set((0, ftt_fulfilment_1.fttItems)(String(fttText ?? '')).map((i) => i.path))];
|
|
38
|
+
if (items.length === 0)
|
|
39
|
+
return { scope: 'no-declaration', unreachable: [] };
|
|
40
|
+
const unreachable = items.filter((rel) => !p.isScanned(rel) && !p.isFileAnchorable(rel) && !p.isTest(rel)).sort();
|
|
41
|
+
const scope = items.some((rel) => p.isScanned(rel)) ? 'scanned-source'
|
|
42
|
+
: items.some((rel) => p.isFileAnchorable(rel)) ? 'file-anchor-target'
|
|
43
|
+
: items.some((rel) => p.isTest(rel)) ? 'test-target'
|
|
44
|
+
: 'unreachable-target';
|
|
45
|
+
return { scope, unreachable };
|
|
46
|
+
}
|
|
47
|
+
/** Every entry labelled exactly once, so the aggregate sums to the population it was given. */
|
|
48
|
+
function declaredScopeCensus(entries, p) {
|
|
49
|
+
const byId = {};
|
|
50
|
+
const byScope = emptyByScope();
|
|
51
|
+
let unreachableDeclaring = 0;
|
|
52
|
+
for (const e of entries) {
|
|
53
|
+
const r = declaredScopeOf(e.ftt, p);
|
|
54
|
+
byId[e.id] = r.scope;
|
|
55
|
+
byScope[r.scope] += 1;
|
|
56
|
+
if (r.unreachable.length > 0)
|
|
57
|
+
unreachableDeclaring += 1;
|
|
58
|
+
}
|
|
59
|
+
return { byId, byScope, unreachableDeclaring };
|
|
60
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { UnlinkedReason } from '../rtm/link-census';
|
|
2
|
+
import { DeclaredScope } from '../rtm/declared-scope';
|
|
2
3
|
/** Honesty summary of what a launched dashboard shows — derived from the endpoints it serves. */
|
|
3
4
|
export interface DashboardCensus {
|
|
4
5
|
reqCount: number;
|
|
@@ -26,6 +27,12 @@ export interface DashboardCensusExtended extends DashboardCensus {
|
|
|
26
27
|
unmapped: number;
|
|
27
28
|
nonSpec: number;
|
|
28
29
|
};
|
|
30
|
+
/**
|
|
31
|
+
* @implements A-SPEC-665 — the declaration axis beside the anchor axis: what the unlinked specs
|
|
32
|
+
* DECLARED, next to where they are anchored. A payload without it folds to zeros like the rest.
|
|
33
|
+
*/
|
|
34
|
+
declaredScopeByScope: Record<DeclaredScope, number>;
|
|
35
|
+
unreachableDeclaringCount: number;
|
|
29
36
|
}
|
|
30
37
|
/**
|
|
31
38
|
* Pure: the pinned census plus the code-link axis from `/api/rtm`'s `linkCensus`, `coveragePct`
|
|
@@ -7,6 +7,7 @@ exports._resetLauncher = _resetLauncher;
|
|
|
7
7
|
exports._stopAll = _stopAll;
|
|
8
8
|
// @implements A-SPEC-545.3
|
|
9
9
|
const link_census_1 = require("../rtm/link-census");
|
|
10
|
+
const declared_scope_1 = require("../rtm/declared-scope");
|
|
10
11
|
/**
|
|
11
12
|
* @implements A-SPEC-545.3
|
|
12
13
|
* Pure: fold the /api/rtm and /api/rtm/heatmap payloads into a census. Missing fields read as 0/false;
|
|
@@ -45,6 +46,8 @@ function dashboardCensusExtended(rtm, heatmap) {
|
|
|
45
46
|
unlinkedCount: Array.isArray(lc?.unlinked) ? lc.unlinked.length : 0,
|
|
46
47
|
unlinkedByReason: { ...(0, link_census_1.emptyByReason)(), ...(lc?.byReason ?? {}) },
|
|
47
48
|
excluded: { total: retired + unmapped + nonSpec, retired, unmapped, nonSpec },
|
|
49
|
+
declaredScopeByScope: { ...(0, declared_scope_1.emptyByScope)(), ...(rtm?.declaredScope?.byScope ?? {}) },
|
|
50
|
+
unreachableDeclaringCount: typeof rtm?.declaredScope?.unreachableDeclaring === 'number' ? rtm.declaredScope.unreachableDeclaring : 0,
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
const live = new Map();
|
|
@@ -58,6 +58,8 @@ const cfg_view_1 = require("./cfg-view");
|
|
|
58
58
|
const link_census_1 = require("../rtm/link-census");
|
|
59
59
|
const test_scope_1 = require("../rtm/test-scope");
|
|
60
60
|
const file_anchors_1 = require("../rtm/file-anchors");
|
|
61
|
+
const declared_scope_1 = require("../rtm/declared-scope");
|
|
62
|
+
const test_files_1 = require("../cpg/test-files");
|
|
61
63
|
/**
|
|
62
64
|
* Start a lightweight standalone Node.js HTTP server for interactive dashboard & RTM visualization.
|
|
63
65
|
*
|
|
@@ -130,11 +132,20 @@ async function startDashboardServer(options) {
|
|
|
130
132
|
}
|
|
131
133
|
const census = (0, link_census_1.linkCensus)({ approvedIds: approvedAspecs.map((s) => s.id), implemented: implementedSpecIds, testAnchors, fileAnchors, weakMentions });
|
|
132
134
|
const reasonOf = new Map(census.unlinked.map((u) => [u.id, u.reason]));
|
|
135
|
+
// @implements A-SPEC-665 — the second axis. The anchor axis above says WHERE a spec is
|
|
136
|
+
// anchored; this one says what it DECLARED, which is the only way to tell "an anchor is
|
|
137
|
+
// missing" from "there is no production file to anchor". Predicates come from the mechanisms
|
|
138
|
+
// themselves (the scan's own paths, the file-anchor predicate, the test predicate) so the two
|
|
139
|
+
// readers cannot drift apart.
|
|
140
|
+
const scannedPaths = new Set(files.map((f) => f.sourcePath));
|
|
141
|
+
const fttOf = new Map(specs.map((s) => [s.id, String(s.sections?.['Files to Touch'] ?? '')]));
|
|
142
|
+
const declaredScope = (0, declared_scope_1.declaredScopeCensus)(census.unlinked.map((u) => ({ id: u.id, ftt: fttOf.get(u.id) ?? '' })), { isScanned: (rel) => scannedPaths.has(rel), isFileAnchorable: (rel) => (0, file_anchors_1.isFileAnchorPath)(rel), isTest: (rel) => (0, test_files_1.isTestFile)(rel) });
|
|
133
143
|
const enrichedSpecs = specs.map((s) => {
|
|
134
144
|
const isA = s.id.startsWith('A-SPEC');
|
|
135
145
|
const covered = isA ? implementedSpecIds.has(s.id) : s.status === 'approved';
|
|
136
146
|
const reason = reasonOf.get(s.id);
|
|
137
|
-
|
|
147
|
+
const scope = declaredScope.byId[s.id];
|
|
148
|
+
return { ...s, covered, legacyStatus: !isCanonicalStatus(s.status), ...(reason ? { unlinkedReason: reason } : {}), ...(scope ? { declaredScope: scope } : {}) };
|
|
138
149
|
});
|
|
139
150
|
// Two axes, never one number. Approval (a spec was signed off) and implementation (code is
|
|
140
151
|
// anchored to it) answer different questions, and a spec whose status is outside the
|
|
@@ -194,6 +205,8 @@ async function startDashboardServer(options) {
|
|
|
194
205
|
approval,
|
|
195
206
|
implementation,
|
|
196
207
|
linkCensus: census,
|
|
208
|
+
// @implements A-SPEC-665
|
|
209
|
+
declaredScope: { byScope: declaredScope.byScope, unreachableDeclaring: declaredScope.unreachableDeclaring },
|
|
197
210
|
unmapped,
|
|
198
211
|
nonSpec,
|
|
199
212
|
retired,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@implements A-SPEC-209",
|
|
3
3
|
"name": "@holmes-lab/holmes-kit",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.23.1",
|
|
5
5
|
"description": "Holmes-Kit — deterministic Agentic Software Engineering (ASE) harness with causal traceability (spec chain + D-CPG + RTM + phase guardrail)",
|
|
6
6
|
"main": "dist/holmes/mcp/server.js",
|
|
7
7
|
"types": "dist/holmes/mcp/server.d.ts",
|