@holmes-lab/holmes-kit 0.22.0 → 0.23.2
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 +110 -0
- package/README.md +10 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +39 -0
- package/dist/holmes/cli/agents.js +113 -9
- 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/rtm/ftt-fulfilment.d.ts +24 -3
- package/dist/holmes/rtm/ftt-fulfilment.js +60 -16
- 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/dist/holmes/spec/approval-blockers.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,116 @@ 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.2] - 2026-09-18
|
|
9
|
+
|
|
10
|
+
Three rules that existed only in prose, and the one number a broken parser had published.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- **The Files-to-Touch parser reads every declared path** (A-SPEC-670). It took only the **first word
|
|
14
|
+
of a list item**, so measured over all 678 approved specs here, **88 specs declared 172 paths that
|
|
15
|
+
were never read** — 24 of them parsed to zero while naming files plainly. Five forms accounted for
|
|
16
|
+
every miss: several paths on one line (71), an indented continuation line (39), the second path on a
|
|
17
|
+
line whose first was read (32), a Korean first word (22), and a prose paragraph instead of a list (5).
|
|
18
|
+
A slashed path is now read wherever it sits; a bare `name.ext` keeps the old position rule, because
|
|
19
|
+
the root-file predicate accepts any extension and without it `module.exports` and a prose `b.ts`
|
|
20
|
+
become declared files. A declared path that does not exist but is the suffix of exactly one
|
|
21
|
+
repository file is reported as `abbreviated` rather than a defect (measured: unique in all 8 cases),
|
|
22
|
+
and two candidates stays an ambiguity where nothing is guessed. Cost over the whole corpus: **zero**
|
|
23
|
+
new `missing` findings, zero new `moved`, 26 abbreviations resolved.
|
|
24
|
+
- **A number 0.23.0 published was the parser's, not the corpus's** (A-SPEC-665, corrected). The
|
|
25
|
+
declaration census read `scanned-source 0 / no-declaration 4` for this repository's 52 unlinked
|
|
26
|
+
specs, and the README said the remaining 8.1% of `codeLinkedPct` was therefore not a missing anchor.
|
|
27
|
+
With the parser fixed the same census reads **3 and 0**: `A-SPEC-495`, `A-SPEC-549.3` and
|
|
28
|
+
`A-SPEC-549.4` declare scanned production files that carry no anchor, and **five real trace gaps**
|
|
29
|
+
were behind them. The 665 chain was unsealed leaf-first and corrected, and its pin no longer names
|
|
30
|
+
spec ids — pinning those ids pinned the defect. Its own rule ("assert the sum and the property, not
|
|
31
|
+
the numbers") is what kept the fix from looking like a violation.
|
|
32
|
+
- **The shipped instruction says how a spec body is written** (A-SPEC-671). `init` wrote into every
|
|
33
|
+
consumer's `AGENTS.md` that specs are authored with four MCP tools, and **none of the 44 tools takes
|
|
34
|
+
section text**. Authoring here means editing the scaffolded markdown, which the same rule read as
|
|
35
|
+
forbidden — and the collision is guaranteed, not occasional: of the four documents `spec_slice_init`
|
|
36
|
+
scaffolds, exactly one section is left blank (the REQ's `Success Criteria`) and that blank is
|
|
37
|
+
precisely what approval refuses. An agent in another workspace hit this, refused to break the rule,
|
|
38
|
+
and asked for a tool that does not exist; its judgement was correct. Rule 1 now separates the two
|
|
39
|
+
acts the tools perform (**scaffold** and **seal**), states that a body is written by editing the
|
|
40
|
+
generated markdown, and says the Success Criteria is empty on purpose. The empty-criteria refusal
|
|
41
|
+
now names the file and the heading. No new tool and no loosened gate: taking bodies as tool
|
|
42
|
+
arguments is a worse authoring surface, and auto-filling the stub would restore the pass nobody
|
|
43
|
+
earned that A-SPEC-505.1 exists to prevent.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- **The publish gate reads the docs** (A-SPEC-669). `playbooks/publish/PLAYBOOK.md` step 2.5 has
|
|
47
|
+
demanded a documentation pass since 0.16.0 with nothing enforcing it, and it failed four times —
|
|
48
|
+
0.16.0, and 0.21.0/0.22.0/0.23.0, which shipped with the README feature list stuck at 0.20.0.
|
|
49
|
+
`verify-release.js` now refuses a missing CHANGELOG entry for the version being published, and an
|
|
50
|
+
entry carrying `### Added` while `README.md` has not changed since the previous release. Validated
|
|
51
|
+
against eight releases before it was written: it refuses exactly 0.21.0, 0.22.0 and 0.23.0 and
|
|
52
|
+
passes the other five. Uncited sealed specs and version-pinned README headings are **reported only**
|
|
53
|
+
— measured poor gates, since 0.20.0 cited none of its 57 sealed specs and was an honest release. The
|
|
54
|
+
drift question (is the old wording still true?) is judgement and is deliberately not automated: a
|
|
55
|
+
machine imitating it would produce a pass nobody earned, which is the 0.16.0 incident itself. With no
|
|
56
|
+
previous tag the README rule is **skipped and says so**.
|
|
57
|
+
|
|
58
|
+
## [0.23.1] - 2026-09-18
|
|
59
|
+
|
|
60
|
+
A release that could not be published, and the documentation that had fallen three versions behind.
|
|
61
|
+
|
|
62
|
+
### Fixed
|
|
63
|
+
- **The release gate knows the ledgers 0.22.0 began tracking** (A-SPEC-668). `v0.23.0`'s publish was
|
|
64
|
+
refused on a dirty tree: 0.22.0 taught `.gitignore` to track `advisory-outcomes` and `ci-runs`, and
|
|
65
|
+
`verify-release.js`'s append-only exclusion list never heard about it. The first is appended by every
|
|
66
|
+
seal and every `approval_status`; the second by the launchd CI runner every fifteen minutes — between
|
|
67
|
+
them this repository could no longer publish at an arbitrary moment. Both are excused now, and a pin
|
|
68
|
+
asserts that every TRACKED ledger is excused, naming any that is not so the next person knows which
|
|
69
|
+
file to edit. The reverse slack is reported rather than asserted away (an excused-but-untracked ledger
|
|
70
|
+
costs nothing, because a gitignored file never shows as dirty).
|
|
71
|
+
|
|
72
|
+
### Docs
|
|
73
|
+
- **The README lists 0.21.0 through 0.23.0.** The feature list stopped at 0.20.0 while three releases
|
|
74
|
+
shipped. The publish playbook's docs-currency gate forbids exactly that — "a stale doc is a false
|
|
75
|
+
claim" — and it is a written step with no automation, so it was skipped twice. Automating it is the
|
|
76
|
+
next candidate.
|
|
77
|
+
|
|
78
|
+
## [0.23.0] - 2026-09-18
|
|
79
|
+
|
|
80
|
+
Three things this release stops doing quietly: deleting the neighbours in a config file it shares,
|
|
81
|
+
going deaf when its own child process dies, and reporting a coverage shortfall it cannot explain.
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
- **A re-wire no longer deletes the other tools' entries** (A-SPEC-667). `init --agent antigravity`
|
|
85
|
+
and `init --agent codex` replaced `.agents/mcp_config.json`, `.agents/hooks.json` and
|
|
86
|
+
`marketplace.json` whole. Measured with a real `--dry-run` before the change: a neighbour MCP
|
|
87
|
+
server, a neighbour hook namespace, a neighbour plugin, a marketplace's own name and an operator's
|
|
88
|
+
`disabled` flag all survived a re-wire at a rate of zero. They now survive; only the holmes-kit
|
|
89
|
+
entry is refreshed, and `init` names what it kept. A `disabled` flag an operator set is preserved
|
|
90
|
+
rather than silently switched back on, and `init` says the gate will not run while it stands. An
|
|
91
|
+
existing file that is not readable JSON is refused with a reason instead of being replaced. The
|
|
92
|
+
Claude wiring already merged (A-SPEC-179); this gives the other two harnesses the same discipline.
|
|
93
|
+
- **The MCP supervisor notices a child that died** (A-SPEC-666). With no exit handler it kept writing
|
|
94
|
+
to a dead pipe, its in-flight count never returned to zero, and the swap that would have replaced
|
|
95
|
+
the child therefore never fired — the server was permanently deaf. Reproduced against the real
|
|
96
|
+
supervisor, twice in one session and once on demand. It now answers every outstanding request with
|
|
97
|
+
a JSON-RPC error before anything else (a client must not wait for ever), resets, respawns and
|
|
98
|
+
replays the opening exchange. A child that dies having never answered spends a restart budget, so a
|
|
99
|
+
broken build stops quickly while a single crash under load restarts freely; an intentional swap or
|
|
100
|
+
`stop()` is not counted as an accident. Affects supervised runs only (`HOLMES_MCP_AUTORELOAD`).
|
|
101
|
+
|
|
102
|
+
### Added
|
|
103
|
+
- **The census says what an unlinked spec declared, not just where it is anchored** (A-SPEC-665).
|
|
104
|
+
`rtm_dashboard` and `/api/rtm` now classify every unlinked approved A-SPEC on a second axis —
|
|
105
|
+
`scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration` —
|
|
106
|
+
beside the existing `unlinkedByReason`, and report how many specs declare a path no anchor
|
|
107
|
+
mechanism can read. Measured here: of 52 unlinked specs, zero declare a file the scanner parses, so
|
|
108
|
+
the remaining 8.1% of `codeLinkedPct` is not a missing anchor. Bucket names state what was
|
|
109
|
+
declared; whether that is a defect remains `traceGaps`'s answer.
|
|
110
|
+
|
|
111
|
+
### Measured
|
|
112
|
+
- 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.
|
|
113
|
+
- Linux (OrbStack Ubuntu 24.04 arm64, Node 22): judged automatically on every commit by the launchd
|
|
114
|
+
runner added in 0.22.0. Residual reds are load-dependent suites that pass when run alone —
|
|
115
|
+
`dashboard.test` was verified at 120 s alone on the VM against 370 s under load.
|
|
116
|
+
- Windows: not re-run; no Windows-specific change landed.
|
|
117
|
+
|
|
8
118
|
## [0.22.0] - 2026-09-18
|
|
9
119
|
|
|
10
120
|
The advisories learn what happened next. Until now this repository issued findings and recorded that
|
package/README.md
CHANGED
|
@@ -16,6 +16,16 @@
|
|
|
16
16
|
|
|
17
17
|
### 🛡️ Currently Supported Features (Production Features)
|
|
18
18
|
|
|
19
|
+
- 📐 **Declarations are read as written** *(new in 0.23.2)*: `Files to Touch` is where a spec declares the files it will touch, and three things read it — fulfilment advisories, the declaration census and the approval impact note. The parser took only the **first word of a list item**, so measured over 678 approved specs here, **88 specs and 172 paths were declared and never read**; 24 of them parsed to zero while naming files plainly. Several paths on one line, an indented continuation, a Korean first word, a prose paragraph — all invisible. They are read now, wherever they sit, and a bare `name.ext` keeps its old position rule so a property access like `module.exports` is still not a file. A declared path that does not exist but is the suffix of exactly one repository file is reported as an abbreviation rather than a defect; two candidates stays an ambiguity and nothing is guessed. Cost, measured across the whole corpus: **zero** new `missing` findings.
|
|
20
|
+
- 📄 **The publish gate reads the docs** *(new in 0.23.2)*: the publish playbook has demanded "bring README and CHANGELOG up to this release" since 0.16.0 and only prose enforced it, so it failed four times — including 0.21.0, 0.22.0 and 0.23.0, which each shipped with a feature list frozen at 0.20.0. The release gate now refuses two things it can decide: a missing CHANGELOG entry for the version being published, and an entry with `### Added` while `README.md` has not changed since the previous release. Replayed over eight releases it refuses exactly the three that were stale and passes the other five. What needs judgement — is the old wording still true? — stays with the person and is **reported**, never faked; a check that could not run says so instead of reading as a pass.
|
|
21
|
+
- 🧩 **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.
|
|
22
|
+
- 🫀 **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.
|
|
23
|
+
- 🧮 **Coverage you can explain** *(new in 0.23.0, corrected in 0.23.2)*: 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. Bucket names state the fact; whether one is a defect stays `traceGaps`'s answer. **0.23.0 published a wrong number here**: it read "of 52 unlinked specs, zero declare a file the scanner parses, so the remaining 8.1% is not a missing anchor". That zero was the Files-to-Touch parser's, not the corpus's — see the 0.23.2 entry. The same census now reads three, and five real trace gaps were behind it.
|
|
24
|
+
- 🤖 **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.
|
|
25
|
+
- 🔁 **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.
|
|
26
|
+
- 🧪 **`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.
|
|
27
|
+
- 🔎 **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.
|
|
28
|
+
- 🩹 **`@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
29
|
- 🧑🤝🧑 **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
30
|
- 🗂️ **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
31
|
- 🔁 **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
|
+
86f8d89c-mu6hz0ua
|
|
@@ -70,6 +70,7 @@ export declare function shortPathIfSpaced(p: string): string;
|
|
|
70
70
|
* space, or win32 where 8.3 resolved it).
|
|
71
71
|
*/
|
|
72
72
|
export declare function antigravityHookWarnings(packageRoot: string): string[];
|
|
73
|
+
export declare const AGENTS_MD: (enforced: boolean) => string;
|
|
73
74
|
export declare const MANAGED_BEGIN = "<!-- holmes-kit:managed:begin -->";
|
|
74
75
|
export declare const MANAGED_END = "<!-- holmes-kit:managed:end -->";
|
|
75
76
|
/**
|
|
@@ -98,6 +99,44 @@ export declare function mergeAgentsMd(existing: string | null, generated: string
|
|
|
98
99
|
* 계산과 쓰기를 나누는 이유는 dry-run 이 실제 실행과 같은 집합을 예고해야 하기 때문이다
|
|
99
100
|
* (A-SPEC-190 §9). 같은 함수가 두 경로에 답하면 둘이 어긋날 수 없다.
|
|
100
101
|
*/
|
|
102
|
+
/**
|
|
103
|
+
* @implements A-SPEC-667
|
|
104
|
+
* Merging a wiring file the harness shares with other tools.
|
|
105
|
+
*
|
|
106
|
+
* Measured 2026-09-18 with a real `--dry-run`: a neighbour MCP server, a neighbour hook namespace, a
|
|
107
|
+
* neighbour plugin, a marketplace's own name and an operator's `disabled` flag all survived a
|
|
108
|
+
* re-wire at a rate of ZERO — the agy and codex writers replaced each file whole. The Claude path
|
|
109
|
+
* learned this in A-SPEC-179 ("merge, not replace"; `--force` means refresh the wiring, not turn off
|
|
110
|
+
* what I switched on). These functions give the other two harnesses the same discipline.
|
|
111
|
+
*
|
|
112
|
+
* Pure text in, pure text out, so `init` can merge during its COMPUTE phase and `--dry-run` predicts
|
|
113
|
+
* the bytes the write produces (the A-SPEC-190 §9 rule).
|
|
114
|
+
*/
|
|
115
|
+
export interface WiringMerge {
|
|
116
|
+
content: string;
|
|
117
|
+
/** Names of things that were kept because they are not ours — reported to the person verbatim. */
|
|
118
|
+
preserved: string[];
|
|
119
|
+
/** Sentences about the merged state a person needs (e.g. the wiring is present but disabled). */
|
|
120
|
+
notes: string[];
|
|
121
|
+
/** Set when the existing file could not be read; `content` is then the ORIGINAL, and init skips it. */
|
|
122
|
+
refused?: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `.agents/mcp_config.json`. Neighbour servers survive whole; inside the holmes-kit entry our
|
|
126
|
+
* command, args and `HOLMES_SPECS` win while every other key a person put there — `disabled` above
|
|
127
|
+
* all — is kept. Turning someone's switch back on without a word is the surprise this prevents.
|
|
128
|
+
*/
|
|
129
|
+
export declare function mergeMcpConfigJson(before: string | null, ours: string): WiringMerge;
|
|
130
|
+
/** `.agents/hooks.json`. Top-level keys are tool namespaces; only ours is replaced. */
|
|
131
|
+
export declare function mergeHooksJson(before: string | null, ours: string): WiringMerge;
|
|
132
|
+
/**
|
|
133
|
+
* `marketplace.json`. Only the holmes-kit plugin entry is ours; the marketplace's identity and every
|
|
134
|
+
* other plugin belong to whoever created the file. Renaming someone's marketplace to ours would be
|
|
135
|
+
* the same silent theft as deleting their plugins.
|
|
136
|
+
*/
|
|
137
|
+
export declare function mergeMarketplaceJson(before: string | null, ours: string): WiringMerge;
|
|
138
|
+
/** The merged set, by basename. `null` means "no merger" — the caller writes as it always did. */
|
|
139
|
+
export declare function mergeWiringFile(basename: string, before: string | null, ours: string): WiringMerge | null;
|
|
101
140
|
export declare function agentFiles(agent: Agent, opts: AgentWiringOptions): AgentFile[];
|
|
102
141
|
export interface AgentLink {
|
|
103
142
|
path: string;
|
|
@@ -33,11 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.MANAGED_END = exports.MANAGED_BEGIN = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
|
|
36
|
+
exports.MANAGED_END = exports.MANAGED_BEGIN = exports.AGENTS_MD = exports.CODEX_MARKETPLACE = exports.CODEX_PLUGIN_DIR = exports.HARNESS_ENFORCES = exports.AGENTS = void 0;
|
|
37
37
|
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
|
|
@@ -197,7 +201,9 @@ This repository operates under **No Spec, No Code** governance. An approved spec
|
|
|
197
201
|
|
|
198
202
|
1. **Tool-First Principle (NO Workaround Scripts)**:
|
|
199
203
|
- Creating or mutating \`.ax/specs\` or governance ledgers via arbitrary temporary scripts (\`/tmp/*.js\`) is strictly forbidden.
|
|
200
|
-
-
|
|
204
|
+
- \`spec_slice_init\` and \`spec_create\` SCAFFOLD spec documents; \`spec_slice_approve\` and \`spec_approve\` SEAL them. Use these tools for those two acts — never a script.
|
|
205
|
+
- **Writing the body is editing the file.** After scaffolding, fill the \`TODO\` placeholders by editing the generated spec markdown in \`.ax/specs/\` with your normal editor. This is the expected authoring path, not a workaround — no MCP tool takes section text.
|
|
206
|
+
- A REQ's \`## Success Criteria\` is scaffolded EMPTY on purpose, because a generator must not invent what "satisfied" means. Approval is refused until you write observable criteria there.
|
|
201
207
|
|
|
202
208
|
2. **Anchor-First Principle (Mandatory Code Anchors)**:
|
|
203
209
|
- When creating or modifying source code and test files, line 1 MUST include an explicit anchor comment: \`// @\` + \`implements A-SPEC-XXX\`.
|
|
@@ -208,7 +214,7 @@ This repository operates under **No Spec, No Code** governance. An approved spec
|
|
|
208
214
|
|
|
209
215
|
## Procedure
|
|
210
216
|
|
|
211
|
-
1. Run \`spec_slice_init\` or \`spec_create\` to
|
|
217
|
+
1. Run \`spec_slice_init\` or \`spec_create\` to scaffold REQ -> H-SPEC -> A-SPEC, then edit the generated markdown to replace every \`TODO\` and to write the REQ's Success Criteria.
|
|
212
218
|
2. Approve specs via \`spec_slice_approve\` or \`spec_approve\`.
|
|
213
219
|
3. **Before editing source**, run \`maintenance_analyze({ root, request, persist: true })\` and keep the digest it returns — that persisted analysis is what the pre-edit evidence gate checks.
|
|
214
220
|
4. **Write tests first** and verify failure (RED stage).
|
|
@@ -223,6 +229,7 @@ ${enforced
|
|
|
223
229
|
? `Gates are **enforced**. Tool calls that write un-anchored code or target unapproved specifications will be denied. Unresolved critical findings block completion.`
|
|
224
230
|
: `This harness does not have measured **hook enforcement points**. Holmes-Kit provides tools and guidance here without active gate blocking.`}
|
|
225
231
|
`;
|
|
232
|
+
exports.AGENTS_MD = AGENTS_MD;
|
|
226
233
|
exports.MANAGED_BEGIN = '<!-- holmes-kit:managed:begin -->';
|
|
227
234
|
exports.MANAGED_END = '<!-- holmes-kit:managed:end -->';
|
|
228
235
|
const PRESERVED_NOTE = '<!-- kept from this file by holmes-kit init — holmes-kit does not write below this line -->';
|
|
@@ -297,12 +304,109 @@ function closeRun(runs, run) {
|
|
|
297
304
|
runs.push(run);
|
|
298
305
|
return [];
|
|
299
306
|
}
|
|
307
|
+
const HOLMES = 'holmes-kit';
|
|
308
|
+
/** Parse, or explain why not. An unreadable file is never replaced: silently deleting it is the defect. */
|
|
309
|
+
function parseOr(before, ours, what) {
|
|
310
|
+
if (before === null)
|
|
311
|
+
return { content: ours, preserved: [], notes: [] };
|
|
312
|
+
let prev;
|
|
313
|
+
try {
|
|
314
|
+
prev = JSON.parse(before);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
return { content: before, preserved: [], notes: [], refused: `${what} is not readable JSON — left untouched; fix or remove it, then re-run init` };
|
|
318
|
+
}
|
|
319
|
+
if (prev === null || typeof prev !== 'object' || Array.isArray(prev)) {
|
|
320
|
+
return { content: before, preserved: [], notes: [], refused: `${what} is not a JSON object — left untouched; fix or remove it, then re-run init` };
|
|
321
|
+
}
|
|
322
|
+
return { prev: prev };
|
|
323
|
+
}
|
|
324
|
+
const isMerge = (x) => 'content' in x;
|
|
325
|
+
const asObject = (v) => (v !== null && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
300
326
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
|
|
327
|
+
* `.agents/mcp_config.json`. Neighbour servers survive whole; inside the holmes-kit entry our
|
|
328
|
+
* command, args and `HOLMES_SPECS` win while every other key a person put there — `disabled` above
|
|
329
|
+
* all — is kept. Turning someone's switch back on without a word is the surprise this prevents.
|
|
330
|
+
*/
|
|
331
|
+
function mergeMcpConfigJson(before, ours) {
|
|
332
|
+
const parsed = parseOr(before, ours, 'mcp_config.json');
|
|
333
|
+
if (isMerge(parsed))
|
|
334
|
+
return parsed;
|
|
335
|
+
const oursObj = asObject(JSON.parse(ours));
|
|
336
|
+
const oursEntry = asObject(asObject(oursObj.mcpServers)[HOLMES]);
|
|
337
|
+
const prevServers = asObject(parsed.prev.mcpServers);
|
|
338
|
+
const prevEntry = asObject(prevServers[HOLMES]);
|
|
339
|
+
const keptKeys = Object.keys(prevEntry).filter((k) => k !== 'command' && k !== 'args' && k !== 'env');
|
|
340
|
+
const merged = {
|
|
341
|
+
...prevEntry,
|
|
342
|
+
command: oursEntry.command,
|
|
343
|
+
args: oursEntry.args,
|
|
344
|
+
env: { ...asObject(prevEntry.env), ...asObject(oursEntry.env) },
|
|
345
|
+
};
|
|
346
|
+
const out = { ...parsed.prev, mcpServers: { ...prevServers, [HOLMES]: merged } };
|
|
347
|
+
const notes = [];
|
|
348
|
+
if (prevEntry.disabled === true || prevEntry.disabled === 'true') {
|
|
349
|
+
notes.push(`mcp_config.json: holmes-kit is wired but DISABLED in this config — the gate will not run until it is enabled.`);
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
content: `${JSON.stringify(out, null, 2)}\n`,
|
|
353
|
+
preserved: [...Object.keys(prevServers).filter((k) => k !== HOLMES), ...keptKeys],
|
|
354
|
+
notes,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
/** `.agents/hooks.json`. Top-level keys are tool namespaces; only ours is replaced. */
|
|
358
|
+
function mergeHooksJson(before, ours) {
|
|
359
|
+
const parsed = parseOr(before, ours, 'hooks.json');
|
|
360
|
+
if (isMerge(parsed))
|
|
361
|
+
return parsed;
|
|
362
|
+
const oursObj = asObject(JSON.parse(ours));
|
|
363
|
+
const out = { ...parsed.prev, [HOLMES]: oursObj[HOLMES] };
|
|
364
|
+
return {
|
|
365
|
+
content: `${JSON.stringify(out, null, 2)}\n`,
|
|
366
|
+
preserved: Object.keys(parsed.prev).filter((k) => k !== HOLMES),
|
|
367
|
+
notes: [],
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* `marketplace.json`. Only the holmes-kit plugin entry is ours; the marketplace's identity and every
|
|
372
|
+
* other plugin belong to whoever created the file. Renaming someone's marketplace to ours would be
|
|
373
|
+
* the same silent theft as deleting their plugins.
|
|
305
374
|
*/
|
|
375
|
+
function mergeMarketplaceJson(before, ours) {
|
|
376
|
+
const parsed = parseOr(before, ours, 'marketplace.json');
|
|
377
|
+
if (isMerge(parsed))
|
|
378
|
+
return parsed;
|
|
379
|
+
const oursObj = asObject(JSON.parse(ours));
|
|
380
|
+
const oursPlugin = (Array.isArray(oursObj.plugins) ? oursObj.plugins : []).find((p) => asObject(p).name === HOLMES);
|
|
381
|
+
const prevPlugins = Array.isArray(parsed.prev.plugins) ? parsed.prev.plugins : [];
|
|
382
|
+
const hadOurs = prevPlugins.some((p) => asObject(p).name === HOLMES);
|
|
383
|
+
const plugins = hadOurs
|
|
384
|
+
? prevPlugins.map((p) => (asObject(p).name === HOLMES ? oursPlugin : p))
|
|
385
|
+
: [...prevPlugins, oursPlugin];
|
|
386
|
+
const keptName = typeof parsed.prev.name === 'string' && parsed.prev.name !== '' ? parsed.prev.name : undefined;
|
|
387
|
+
const out = {
|
|
388
|
+
...parsed.prev,
|
|
389
|
+
name: keptName ?? oursObj.name,
|
|
390
|
+
interface: parsed.prev.interface ?? oursObj.interface,
|
|
391
|
+
plugins,
|
|
392
|
+
};
|
|
393
|
+
const neighbours = prevPlugins.map((p) => String(asObject(p).name ?? '')).filter((n) => n !== '' && n !== HOLMES);
|
|
394
|
+
return {
|
|
395
|
+
content: `${JSON.stringify(out, null, 2)}\n`,
|
|
396
|
+
preserved: [...neighbours, ...(keptName !== undefined && keptName !== oursObj.name ? [keptName] : [])],
|
|
397
|
+
notes: [],
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/** The merged set, by basename. `null` means "no merger" — the caller writes as it always did. */
|
|
401
|
+
function mergeWiringFile(basename, before, ours) {
|
|
402
|
+
if (basename === 'mcp_config.json')
|
|
403
|
+
return mergeMcpConfigJson(before, ours);
|
|
404
|
+
if (basename === 'hooks.json')
|
|
405
|
+
return mergeHooksJson(before, ours);
|
|
406
|
+
if (basename === 'marketplace.json')
|
|
407
|
+
return mergeMarketplaceJson(before, ours);
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
306
410
|
function agentFiles(agent, opts) {
|
|
307
411
|
const { target, packageRoot, specsDir } = opts;
|
|
308
412
|
switch (agent) {
|
|
@@ -314,7 +418,7 @@ function agentFiles(agent, opts) {
|
|
|
314
418
|
return [
|
|
315
419
|
{ path: path.join(target, '.agents', 'hooks.json'), content: hooksJson(packageRoot) },
|
|
316
420
|
{ path: path.join(target, '.agents', 'mcp_config.json'), content: mcpConfig(packageRoot, specsDir, opts.launcher) },
|
|
317
|
-
{ path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.antigravity) },
|
|
421
|
+
{ path: path.join(target, 'AGENTS.md'), content: (0, exports.AGENTS_MD)(exports.HARNESS_ENFORCES.antigravity) },
|
|
318
422
|
];
|
|
319
423
|
case 'codex':
|
|
320
424
|
// @implements A-SPEC-442 (was A-SPEC-266, A-SPEC-441) — Codex plugin, at the layout Codex reads:
|
|
@@ -379,7 +483,7 @@ function agentFiles(agent, opts) {
|
|
|
379
483
|
},
|
|
380
484
|
}, null, 2)}\n`,
|
|
381
485
|
},
|
|
382
|
-
{ path: path.join(target, 'AGENTS.md'), content: AGENTS_MD(exports.HARNESS_ENFORCES.codex) },
|
|
486
|
+
{ path: path.join(target, 'AGENTS.md'), content: (0, exports.AGENTS_MD)(exports.HARNESS_ENFORCES.codex) },
|
|
383
487
|
];
|
|
384
488
|
default:
|
|
385
489
|
// 모르는 하네스를 조용히 건너뛰면 "배선했다"는 보고와 실제가 어긋난다.
|
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
|
+
}
|
|
@@ -19,6 +19,14 @@ export interface FttFulfilment {
|
|
|
19
19
|
path: string;
|
|
20
20
|
line: string;
|
|
21
21
|
}>;
|
|
22
|
+
/**
|
|
23
|
+
* @implements A-SPEC-670 — a declared path that does not exist but is the suffix of exactly ONE
|
|
24
|
+
* repository file is the same file written short, not a defect — reported, never counted as one.
|
|
25
|
+
*/
|
|
26
|
+
abbreviated?: Array<{
|
|
27
|
+
path: string;
|
|
28
|
+
resolvedTo: string;
|
|
29
|
+
}>;
|
|
22
30
|
}
|
|
23
31
|
export interface FulfilmentRecord {
|
|
24
32
|
aspec: string;
|
|
@@ -29,9 +37,22 @@ export interface FulfilmentRecord {
|
|
|
29
37
|
replica?: string;
|
|
30
38
|
}
|
|
31
39
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
40
|
+
* @implements A-SPEC-670
|
|
41
|
+
* Every path-shaped token in the section, wherever it sits on the line.
|
|
42
|
+
*
|
|
43
|
+
* REQ-654 read only the first word of a list item. Measured over all 678 A-SPECs on 2026-09-18,
|
|
44
|
+
* that lost 172 declared paths across 88 specs — 24 of which parsed to zero while naming files
|
|
45
|
+
* plainly, so the census called them "no declaration" and one wrong conclusion was published from
|
|
46
|
+
* it. Five forms accounted for every miss: several paths on one line, an indented continuation
|
|
47
|
+
* line, the second path on a line whose first was read, a Korean first word, and a prose paragraph
|
|
48
|
+
* written instead of a list.
|
|
49
|
+
*
|
|
50
|
+
* The widened rule is safe because of WHERE it reads: this is the Files-to-Touch section, whose
|
|
51
|
+
* whole purpose is declaration. One line in the entire corpus carries a negation word, and the path
|
|
52
|
+
* on it is a declaration too — so there is no measured population of "mentioned but not declared".
|
|
53
|
+
*
|
|
54
|
+
* What REQ-654 excluded stays excluded, for its original reason: globs and extension-less paths
|
|
55
|
+
* (directories) are not items, because their fulfilment is not one file's existence.
|
|
35
56
|
*/
|
|
36
57
|
export declare function fttItems(fttText: string): FttItem[];
|
|
37
58
|
export declare function fttFulfilment(fttText: string, exists: (rel: string) => boolean, locate: (basename: string) => string[]): FttFulfilment | null;
|
|
@@ -57,30 +57,62 @@ const fs = __importStar(require("node:fs"));
|
|
|
57
57
|
const path = __importStar(require("node:path"));
|
|
58
58
|
const scope_judgment_1 = require("../guardrail/scope-judgment");
|
|
59
59
|
const replica_id_1 = require("../governance/replica-id");
|
|
60
|
-
const ITEM_RE = /^\s*[-*]\s+(.*)$/;
|
|
61
60
|
const NEW_RE = /\((신규|new)\)/i;
|
|
62
61
|
const ALT_RE = /또는|\bor\b/;
|
|
63
62
|
const SLASHED = /^[\w@.-]+(?:\/[\w@.-]+)+$/;
|
|
64
63
|
const HAS_EXT = /\.[A-Za-z][A-Za-z0-9]*$/;
|
|
64
|
+
/** Token boundaries as the corpus actually writes them: backticks, quotes, brackets, `·`, commas. */
|
|
65
|
+
const TOKEN_SPLIT = /[`'"()[\]{}<>,;·、,\s]+/;
|
|
65
66
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
67
|
+
* @implements A-SPEC-670
|
|
68
|
+
* Every path-shaped token in the section, wherever it sits on the line.
|
|
69
|
+
*
|
|
70
|
+
* REQ-654 read only the first word of a list item. Measured over all 678 A-SPECs on 2026-09-18,
|
|
71
|
+
* that lost 172 declared paths across 88 specs — 24 of which parsed to zero while naming files
|
|
72
|
+
* plainly, so the census called them "no declaration" and one wrong conclusion was published from
|
|
73
|
+
* it. Five forms accounted for every miss: several paths on one line, an indented continuation
|
|
74
|
+
* line, the second path on a line whose first was read, a Korean first word, and a prose paragraph
|
|
75
|
+
* written instead of a list.
|
|
76
|
+
*
|
|
77
|
+
* The widened rule is safe because of WHERE it reads: this is the Files-to-Touch section, whose
|
|
78
|
+
* whole purpose is declaration. One line in the entire corpus carries a negation word, and the path
|
|
79
|
+
* on it is a declaration too — so there is no measured population of "mentioned but not declared".
|
|
80
|
+
*
|
|
81
|
+
* What REQ-654 excluded stays excluded, for its original reason: globs and extension-less paths
|
|
82
|
+
* (directories) are not items, because their fulfilment is not one file's existence.
|
|
69
83
|
*/
|
|
70
84
|
function fttItems(fttText) {
|
|
71
85
|
const out = [];
|
|
86
|
+
const seen = new Set();
|
|
72
87
|
for (const raw of String(fttText ?? '').replace(/\\/g, '/').split('\n')) {
|
|
73
|
-
const
|
|
74
|
-
|
|
88
|
+
const isListItem = /^\s*[-*]\s+/.test(raw);
|
|
89
|
+
const line = raw.replace(/^\s*[-*]\s+/, '').trim();
|
|
90
|
+
if (line === '')
|
|
75
91
|
continue;
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
92
|
+
const isNew = NEW_RE.test(line);
|
|
93
|
+
const alternative = ALT_RE.test(line);
|
|
94
|
+
// Splitting on backticks leaves an empty first piece for `` `a.ts` ``, so "first" counts real
|
|
95
|
+
// words, not array slots — otherwise every backticked root filename loses its position.
|
|
96
|
+
const pieces = line.split(TOKEN_SPLIT).filter((w) => w !== '');
|
|
97
|
+
for (let i = 0; i < pieces.length; i += 1) {
|
|
98
|
+
// Trailing sentence punctuation is not part of a path; a leading `./` is not part of its identity.
|
|
99
|
+
const token = pieces[i].replace(/^\.\//, '').replace(/[.,;:]+$/, '');
|
|
100
|
+
if (!token || token.includes('*') || !HAS_EXT.test(token))
|
|
101
|
+
continue;
|
|
102
|
+
// A SLASHED path is admitted wherever it sits — that is the whole point of REQ-670, and a
|
|
103
|
+
// slash makes it unmistakably a path. A bare `name.ext` is NOT: `isRootFileToken` accepts any
|
|
104
|
+
// extension, so mid-line it would swallow `module.exports` and a prose `b.ts` (both measured
|
|
105
|
+
// in A-SPEC-656's pins). Root-file tokens therefore keep REQ-654's position exactly: the first
|
|
106
|
+
// word of a LIST ITEM. Measured: dropping the list condition admitted `semanticArm.gates` from
|
|
107
|
+
// a continuation line in A-SPEC-488 — one new false "missing" across the whole corpus, and the
|
|
108
|
+
// only one. With the condition restored the widening adds 172 declarations and no new finding.
|
|
109
|
+
if (!SLASHED.test(token) && !(isListItem && i === 0 && (0, scope_judgment_1.isRootFileToken)(token)))
|
|
110
|
+
continue;
|
|
111
|
+
if (seen.has(token))
|
|
112
|
+
continue;
|
|
113
|
+
seen.add(token);
|
|
114
|
+
out.push({ path: token, line, isNew, alternative });
|
|
115
|
+
}
|
|
84
116
|
}
|
|
85
117
|
return out;
|
|
86
118
|
}
|
|
@@ -91,6 +123,7 @@ function fttFulfilment(fttText, exists, locate) {
|
|
|
91
123
|
const missing = [];
|
|
92
124
|
const moved = [];
|
|
93
125
|
const alternatives = [];
|
|
126
|
+
const abbreviated = [];
|
|
94
127
|
for (const it of items) {
|
|
95
128
|
if (it.alternative) {
|
|
96
129
|
alternatives.push({ path: it.path, line: it.line });
|
|
@@ -100,14 +133,25 @@ function fttFulfilment(fttText, exists, locate) {
|
|
|
100
133
|
continue;
|
|
101
134
|
const base = it.path.slice(it.path.lastIndexOf('/') + 1);
|
|
102
135
|
const foundAt = [...new Set(locate(base))].filter((p) => p !== it.path).sort();
|
|
136
|
+
// @implements A-SPEC-670 — `project/root.ts` beside `src/holmes/project/root.ts` is the same
|
|
137
|
+
// file written short, measured 8 times in this corpus and unique every time. Only a UNIQUE
|
|
138
|
+
// suffix match earns this: two candidates is an ambiguity, and a guess there would invent a
|
|
139
|
+
// fact. Anything else keeps its old verdict, so the rule swallows no real absence.
|
|
140
|
+
const suffixHits = foundAt.filter((p) => p.endsWith(`/${it.path}`));
|
|
141
|
+
if (suffixHits.length === 1) {
|
|
142
|
+
abbreviated.push({ path: it.path, resolvedTo: suffixHits[0] });
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
103
145
|
if (foundAt.length > 0)
|
|
104
146
|
moved.push({ path: it.path, foundAt });
|
|
105
147
|
else
|
|
106
148
|
missing.push({ path: it.path, isNew: it.isNew });
|
|
107
149
|
}
|
|
108
|
-
if (missing.length === 0 && moved.length === 0 && alternatives.length === 0)
|
|
150
|
+
if (missing.length === 0 && moved.length === 0 && alternatives.length === 0 && abbreviated.length === 0)
|
|
109
151
|
return null;
|
|
110
|
-
|
|
152
|
+
// Present only when it has something to say: an always-there empty array would change the shape
|
|
153
|
+
// every existing caller and pin reads, for a fact that is not there.
|
|
154
|
+
return { declared: items.length, missing, moved, alternatives, ...(abbreviated.length > 0 ? { abbreviated } : {}) };
|
|
111
155
|
}
|
|
112
156
|
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
|
|
113
157
|
/** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
|
|
@@ -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,
|
|
@@ -272,7 +272,11 @@ function unactionableCriteriaBlocker(spec) {
|
|
|
272
272
|
const quality = (0, acceptance_quality_1.acceptanceQuality)(spec);
|
|
273
273
|
if (quality === 'stated')
|
|
274
274
|
return null;
|
|
275
|
+
// @implements A-SPEC-671 — the refusal now says WHERE. It named what was wrong and stopped, and on
|
|
276
|
+
// 2026-09-18 an agent in another workspace read the shipped AGENTS.md rule ("author with these
|
|
277
|
+
// four tools"), found no tool that writes a section body, and halted rather than break the rule.
|
|
278
|
+
// The section is filled by editing the file, so the refusal hands over the file and the heading.
|
|
275
279
|
return quality === 'absent'
|
|
276
|
-
? `REQ ${spec.id}의 Success Criteria가 비어 있습니다 — 관측 가능한 기준을 채운 뒤 승인하십시오`
|
|
280
|
+
? `REQ ${spec.id}의 Success Criteria가 비어 있습니다 — \`.ax/specs/01_req/${spec.id}.md\` 의 \`## Success Criteria\` 절을 편집기로 열어 관측 가능한 기준을 채운 뒤 승인하십시오`
|
|
277
281
|
: `REQ ${spec.id}의 Success Criteria가 전량 보일러플레이트입니다('구현·테스트 완료'는 충족 정의가 아닙니다) — 각 항목이 검증 동사·측정 대상을 갖게 고친 뒤 승인하십시오`;
|
|
278
282
|
}
|
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.2",
|
|
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",
|