@holmes-lab/holmes-kit 0.26.2 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +8 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/attest.d.ts +15 -0
- package/dist/holmes/cli/attest.js +168 -0
- package/dist/holmes/cli/index.d.ts +1 -0
- package/dist/holmes/cli/index.js +20 -1
- package/dist/holmes/rtm/traced-files.d.ts +83 -0
- package/dist/holmes/rtm/traced-files.js +207 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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.27.0] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
A repository can now say, as a number anyone can reproduce, how much of it is governed.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`holmes-kit attest` — how many source files trace to an APPROVED spec** (A-SPEC-702). The request
|
|
14
|
+
was a "generated under holmes-kit" header in every source file. That was declined: a header is a
|
|
15
|
+
claim that can be pasted and keeps saying "governed" after an ungoverned edit, it brands files that
|
|
16
|
+
belong to the consumer, the same sentence in every file is measured retrieval noise, and line 1
|
|
17
|
+
already carries the `implements` anchor a gate verifies. What was missing is a repository-level
|
|
18
|
+
statement: the spec-side census lived only inside the dashboard and the file-side one did not exist.
|
|
19
|
+
`attest` adds **no new judgement** — the denominator is what the scanner returned, the numerator is
|
|
20
|
+
anchors ∩ approved ids (a draft anchor is not a trace), the spec axis is the existing link census —
|
|
21
|
+
and prints where the number is true: commit and a digest of the approved specs. `+dirty` is set only
|
|
22
|
+
when a path that *decides* the number is uncommitted (a scanned source or a spec), so writing the
|
|
23
|
+
badge does not make the badge a lie. `--exclude a,b` names trees that are not yours (vendored);
|
|
24
|
+
exclusions match at a path boundary, a prefix meaning "everything" is dropped, and they are echoed in
|
|
25
|
+
every output. What cannot be measured is `n/a` with the reason, never `0%` or `100%`; percentages are
|
|
26
|
+
floored, so `100` appears only when every file is traced; ten or fewer untraced files are named.
|
|
27
|
+
`--badge` writes a self-contained SVG (`.ax/badges/traced.svg` — no href, font, script or fetch).
|
|
28
|
+
`--readme` rewrites **only** the region between `<!-- holmes-kit:attest:begin -->` and
|
|
29
|
+
`<!-- holmes-kit:attest:end -->`: a README without the markers is never written (placing them is the
|
|
30
|
+
consent), a half, doubled or reversed marker is refused with exit 1, and a CRLF README stays CRLF.
|
|
31
|
+
The numbers never change the exit code — this is a statement, not a gate. CLI only, so it is the
|
|
32
|
+
same in every harness and adds no MCP schema cost. Measured on this repository: 396/614 (64.4%) as
|
|
33
|
+
scanned; 386/386 once `reference/` (212 vendored files), `dist-tools/` (build output that carried
|
|
34
|
+
anchors and had been counting as traced source) and `docs/goals/probes/` are named as excluded.
|
|
35
|
+
|
|
8
36
|
## [0.26.2] - 2026-09-20
|
|
9
37
|
|
|
10
38
|
The last stretch of the number-collision repair: the plan `doctor` has been printing since 0.26.1 can
|
package/README.md
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
> **"No Spec, No Code"** — Deterministic Agentic Software Engineering (ASE) harness with causal traceability.
|
|
8
8
|
|
|
9
|
+
<!-- holmes-kit:attest:begin -->
|
|
10
|
+

|
|
11
|
+
|
|
12
|
+
**386/386** source files (100%) trace to an approved spec · **629/680** approved A-SPECs (92.5%) are anchored from code.
|
|
13
|
+
Measured at `173b99650442` (specs `sha256:215f84d4e9f4`) — reproduce with `holmes-kit attest --exclude dist-tools/,docs/goals/probes/,reference/`.
|
|
14
|
+
<!-- holmes-kit:attest:end -->
|
|
15
|
+
|
|
9
16
|
---
|
|
10
17
|
|
|
11
18
|
## 🕵️ Philosophy & Vision: Beyond Code Generation
|
|
@@ -16,6 +23,7 @@
|
|
|
16
23
|
|
|
17
24
|
### 🛡️ Currently Supported Features (Production Features)
|
|
18
25
|
|
|
26
|
+
- 📏 **Say how much of the repository is governed — as a number, not a label** *(new in 0.27.0)*: `holmes-kit attest` reports how many scanned source files are anchored to an **approved** spec, beside how many approved A-SPECs are anchored from code, and says where that is true (commit, plus a digest of the approved specs) so anyone can reproduce it. No new judgement: the scanner's files, the anchors, the existing link census. A per-file "generated under holmes-kit" header was declined — it is a claim that survives an ungoverned edit, and line 1 already carries the anchor a gate verifies. `--exclude a,b` names vendored trees at a path boundary and is echoed in every output; what cannot be measured is `n/a`, never `0%`; `100` appears only when every file is traced. `--badge` writes a self-contained local SVG, and `--readme` rewrites **only** the region between the `holmes-kit:attest` markers — a README without them is never touched, and a half marker is refused. A statement, not a gate: the numbers never change the exit code.
|
|
19
27
|
- 🤝 **The collision plan runs under one approval** *(new in 0.26.2)*: 0.26.1 saw a spec number another machine took and printed the plan; carrying it out was still one `spec_renumber` call per family, and that tool's apply asks for no approval although it rewrites approved documents and source anchors. `spec_reconcile` `plan` is read-only and returns the moves, what each rewrites, the re-seal order, the prose it will **not** touch, and a `target` derived from the plan's content alone. `apply` rebuilds the plan inside the store hold, refuses `plan-changed` if a fetch moved it since you looked, demands one `config-write` approval bound to that `target`, and runs every move through the path `spec_renumber` already uses. Forged `moves` are never read — what runs is the recomputed plan. It never seals: `spec_approve` stays the only sealer. No remote is an answer, not a refusal.
|
|
20
28
|
- 🔢 **A spec number another machine took is seen before the merge** *(new in 0.26.1)*: two checkouts allocated `REQ-694` thirteen minutes apart, neither could see the other, and it surfaced only as an add/add conflict after the push was refused — a rebuilt slice and five out-of-band approvals. `doctor --target` now reads each remote-tracking ref **as last fetched** (no network) and judges only the documents each side *added* since they parted, so a spec one side merely edited is divergence, not an alarm. On a collision the advice is the plan: `Before merging — move 901 → 903: spec_renumber(oldBase=901, newBase=903); then re-seal`. The side that moves is the one not published yet, the destination is free on both sides, and "published" means equal content — never path, because a spec's path is its number.
|
|
21
29
|
- 🔁 **The cycle ratchet finally speaks — and takes a named exception** *(new in 0.26.1)*: since the commit that introduced it, the Stop hook computed the code cycles and called the verdict without them, so the `ART-2` line had never been printed; every unit test passed because each handed the evidence in directly. It is emitted now, bounded (five cycles, five members each, the rest counted — one vendored cycle here has 46 members), and `ax.config.json` gains `architecture.cycleIgnore`: prefixes for trees that are not yours to fix. A cycle is excepted only when every member lies under one, `reference` does not pardon `reference-impl/`, a broken config excepts nothing, and excepted cycles stay counted in the ledger.
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4ec4b788-mu9vw1zt
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type AttestReport } from '../rtm/traced-files';
|
|
2
|
+
export interface AttestOptions {
|
|
3
|
+
root: string;
|
|
4
|
+
exclude: string[];
|
|
5
|
+
json: boolean;
|
|
6
|
+
badge: boolean;
|
|
7
|
+
readme: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface AttestIo {
|
|
10
|
+
stdout(s: string): void;
|
|
11
|
+
stderr(s: string): void;
|
|
12
|
+
}
|
|
13
|
+
export declare const BADGE_REL: string;
|
|
14
|
+
export declare function buildAttestReport(root: string, exclude: string[]): Promise<AttestReport>;
|
|
15
|
+
export declare function runAttest(o: AttestOptions, io: AttestIo): Promise<number>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.BADGE_REL = void 0;
|
|
37
|
+
exports.buildAttestReport = buildAttestReport;
|
|
38
|
+
exports.runAttest = runAttest;
|
|
39
|
+
// @implements A-SPEC-702
|
|
40
|
+
/**
|
|
41
|
+
* `holmes-kit attest` — the surface that calls `rtm/traced-files`.
|
|
42
|
+
*
|
|
43
|
+
* Reads: one scan, the spec store, git (optional — REQ-128). Writes: AT MOST two files, and only
|
|
44
|
+
* when asked: the badge (`--badge`) and the region between the README markers (`--readme`). A README
|
|
45
|
+
* without markers is never written: placing the markers is the consent. Nothing reaches the network,
|
|
46
|
+
* and the measured numbers never change the exit code — this is a statement, not a gate.
|
|
47
|
+
*/
|
|
48
|
+
const fs = __importStar(require("node:fs"));
|
|
49
|
+
const path = __importStar(require("node:path"));
|
|
50
|
+
const node_child_process_1 = require("node:child_process");
|
|
51
|
+
const cpg_scanner_1 = require("../cpg/cpg-scanner");
|
|
52
|
+
const spec_store_1 = require("../spec/spec-store");
|
|
53
|
+
const root_1 = require("../project/root");
|
|
54
|
+
const test_scope_1 = require("../rtm/test-scope");
|
|
55
|
+
const file_anchors_1 = require("../rtm/file-anchors");
|
|
56
|
+
const link_census_1 = require("../rtm/link-census");
|
|
57
|
+
const traced_files_1 = require("../rtm/traced-files");
|
|
58
|
+
exports.BADGE_REL = path.join('.ax', 'badges', 'traced.svg');
|
|
59
|
+
const SPECS_REL = path.join('.ax', 'specs');
|
|
60
|
+
const git = (root, args) => {
|
|
61
|
+
try {
|
|
62
|
+
return (0, node_child_process_1.execFileSync)('git', ['-C', root, ...args], { stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(process.env) }).toString('utf8');
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* `+dirty` only when something that DECIDES the number is uncommitted: a scanned source file or a
|
|
70
|
+
* spec. The badge and the README are outputs of this very command, and a session ledger is always
|
|
71
|
+
* moving — counting them would make a committed badge say `+dirty` forever.
|
|
72
|
+
*/
|
|
73
|
+
function decidingPathsDirty(root, scanned) {
|
|
74
|
+
const out = git(root, ['status', '--porcelain', '-z', '--untracked-files=all']);
|
|
75
|
+
if (out === undefined)
|
|
76
|
+
return false;
|
|
77
|
+
const specs = SPECS_REL.split(path.sep).join('/') + '/';
|
|
78
|
+
for (const entry of out.split('\0')) {
|
|
79
|
+
if (entry.length < 4)
|
|
80
|
+
continue;
|
|
81
|
+
const p = entry.slice(3);
|
|
82
|
+
if (p.startsWith(specs) || scanned.has(p))
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
async function buildAttestReport(root, exclude) {
|
|
88
|
+
const scanned = new cpg_scanner_1.CpgScanner().scan(root, root);
|
|
89
|
+
const specs = await new spec_store_1.LocalMarkdownRepository(path.join(root, SPECS_REL)).list();
|
|
90
|
+
const approved = specs.filter((s) => s.status === 'approved');
|
|
91
|
+
const files = scanned.map((f) => ({ path: f.sourcePath.split(path.sep).join('/'), anchors: f.implementsSpecs ?? [] }));
|
|
92
|
+
// The spec axis, the way the dashboard computes it (A-SPEC-655). `weakMentions` stays empty: it
|
|
93
|
+
// only splits the REASON a spec is unlinked, and this command reports the count.
|
|
94
|
+
const excludes = (0, traced_files_1.normalizeExcludes)(exclude);
|
|
95
|
+
const kept = files.filter((f) => !excludes.some((e) => f.path.startsWith(e)));
|
|
96
|
+
const census = (0, link_census_1.linkCensus)({
|
|
97
|
+
approvedIds: approved.filter((s) => s.id.startsWith('A-SPEC')).map((s) => s.id),
|
|
98
|
+
implemented: new Set(kept.flatMap((f) => f.anchors)),
|
|
99
|
+
testAnchors: (0, test_scope_1.scanTestAnchors)(root),
|
|
100
|
+
fileAnchors: (0, file_anchors_1.scanFileAnchors)(root),
|
|
101
|
+
weakMentions: {},
|
|
102
|
+
});
|
|
103
|
+
const commit = git(root, ['rev-parse', 'HEAD'])?.trim();
|
|
104
|
+
return {
|
|
105
|
+
files: (0, traced_files_1.tracedFiles)({ files, approvedIds: approved.map((s) => s.id), exclude }),
|
|
106
|
+
specs: { total: census.total, linked: census.codeLinked },
|
|
107
|
+
provenance: (0, traced_files_1.attestProvenance)({
|
|
108
|
+
commit,
|
|
109
|
+
dirty: commit ? decidingPathsDirty(root, new Set(files.map((f) => f.path))) : false,
|
|
110
|
+
approved: approved.map((s) => ({ id: s.id, digest: String(s.frontmatter.approved_digest ?? '') })),
|
|
111
|
+
exclude,
|
|
112
|
+
}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
async function runAttest(o, io) {
|
|
116
|
+
if (!fs.existsSync(o.root) || !fs.statSync(o.root).isDirectory()) {
|
|
117
|
+
io.stderr(`attest: ${o.root} is not a directory\n`);
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
const report = await buildAttestReport(o.root, o.exclude);
|
|
121
|
+
io.stdout(o.json ? (0, traced_files_1.renderAttestJson)(report) : (0, traced_files_1.renderAttestText)(report));
|
|
122
|
+
let code = 0;
|
|
123
|
+
const note = (s) => { if (!o.json)
|
|
124
|
+
io.stdout(s);
|
|
125
|
+
else
|
|
126
|
+
io.stderr(s); };
|
|
127
|
+
if (o.badge) {
|
|
128
|
+
const file = path.join(o.root, exports.BADGE_REL);
|
|
129
|
+
const svg = (0, traced_files_1.renderAttestSvg)(report);
|
|
130
|
+
let same = false;
|
|
131
|
+
try {
|
|
132
|
+
same = fs.readFileSync(file, 'utf8') === svg;
|
|
133
|
+
}
|
|
134
|
+
catch { /* absent */ }
|
|
135
|
+
if (!same) {
|
|
136
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
137
|
+
fs.writeFileSync(file, svg);
|
|
138
|
+
}
|
|
139
|
+
note(`badge ${exports.BADGE_REL.split(path.sep).join('/')}${same ? ' (unchanged)' : ' written'}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (o.readme) {
|
|
142
|
+
const file = path.join(o.root, 'README.md');
|
|
143
|
+
let text;
|
|
144
|
+
try {
|
|
145
|
+
text = fs.readFileSync(file, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
text = undefined;
|
|
149
|
+
}
|
|
150
|
+
if (text === undefined)
|
|
151
|
+
note('readme README.md not found — nothing written\n');
|
|
152
|
+
else {
|
|
153
|
+
const r = (0, traced_files_1.replaceMarkerRegion)(text, (0, traced_files_1.renderAttestMarker)(report));
|
|
154
|
+
if (r.ok) {
|
|
155
|
+
if (r.changed)
|
|
156
|
+
fs.writeFileSync(file, r.text);
|
|
157
|
+
note(`readme marker region ${r.changed ? 'updated' : 'unchanged'}\n`);
|
|
158
|
+
}
|
|
159
|
+
else if (r.reason === 'no-marker')
|
|
160
|
+
note('readme no marker in README.md — nothing written (add the begin/end markers to opt in)\n');
|
|
161
|
+
else {
|
|
162
|
+
io.stderr('attest: README.md has an unbalanced holmes-kit:attest marker (each must appear exactly once, begin first) — nothing written\n');
|
|
163
|
+
code = 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return code;
|
|
168
|
+
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Commands:
|
|
5
5
|
* init [--mode guardrail|governed] [--target dir] [--dry-run] [--remove] [--force] ...
|
|
6
6
|
* doctor [--target dir] [--json]
|
|
7
|
+
* attest [--target dir] [--exclude a,b] [--json] [--badge] [--readme]
|
|
7
8
|
*/
|
|
8
9
|
/**
|
|
9
10
|
* The installed package root. From `dist/holmes/cli/index.js` that is three levels up. Node resolves
|
package/dist/holmes/cli/index.js
CHANGED
|
@@ -55,6 +55,7 @@ const colophon_1 = require("./colophon");
|
|
|
55
55
|
* Commands:
|
|
56
56
|
* init [--mode guardrail|governed] [--target dir] [--dry-run] [--remove] [--force] ...
|
|
57
57
|
* doctor [--target dir] [--json]
|
|
58
|
+
* attest [--target dir] [--exclude a,b] [--json] [--badge] [--readme]
|
|
58
59
|
*/
|
|
59
60
|
/**
|
|
60
61
|
* The installed package root. From `dist/holmes/cli/index.js` that is three levels up. Node resolves
|
|
@@ -78,6 +79,8 @@ const KNOWN_FLAGS = {
|
|
|
78
79
|
init: ['help', 'target', 'mode', 'specs-dir', 'settings', 'matcher', 'dry-run', 'no-mcp', 'remove', 'force', 'agent', 'mcp-launcher', 'autonomy', 'no-autonomy'],
|
|
79
80
|
autonomy: ['help', 'target', 'for'],
|
|
80
81
|
doctor: ['help', 'target', 'json'],
|
|
82
|
+
// @implements A-SPEC-702 — a statement, not a gate: how many source files trace to an approved spec.
|
|
83
|
+
attest: ['help', 'target', 'exclude', 'json', 'badge', 'readme'],
|
|
81
84
|
skills: ['help', 'target'],
|
|
82
85
|
ci: ['help', 'target', 'specs-dir', 'json'],
|
|
83
86
|
serve: ['help', 'target', 'port'],
|
|
@@ -119,7 +122,7 @@ class UnknownFlagError extends Error {
|
|
|
119
122
|
* what you'd do" spelling performed a REAL write (round-3 HIGH; same class inverted --remove and
|
|
120
123
|
* --force). A boolean flag must never consume a token.
|
|
121
124
|
*/
|
|
122
|
-
const BOOLEAN_FLAGS = new Set(['help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list', 'status', 'watch', 'autonomy', 'no-autonomy']); // A-SPEC-650: --status
|
|
125
|
+
const BOOLEAN_FLAGS = new Set(['badge', 'readme', 'help', 'dry-run', 'no-mcp', 'remove', 'force', 'json', 'list', 'status', 'watch', 'autonomy', 'no-autonomy']); // A-SPEC-650: --status
|
|
123
126
|
function parseFlags(argv, known) {
|
|
124
127
|
const flags = {};
|
|
125
128
|
const positionals = [];
|
|
@@ -239,6 +242,14 @@ const USAGE = `holmes-kit — deterministic ASE governance for a project
|
|
|
239
242
|
--target <dir> also verify that target's wiring points at this install
|
|
240
243
|
--json machine-readable output
|
|
241
244
|
|
|
245
|
+
holmes-kit attest [flags] say how many source files trace to an APPROVED spec (a statement, not a gate)
|
|
246
|
+
--target <dir> the project to measure (default: current directory)
|
|
247
|
+
--exclude <a,b> path prefixes that are not this project's source (vendored trees); echoed in the output
|
|
248
|
+
--json machine-readable output
|
|
249
|
+
--badge write a self-contained SVG to .ax/badges/traced.svg
|
|
250
|
+
--readme update ONLY the region between the holmes-kit:attest markers in README.md;
|
|
251
|
+
a README without the markers is never written
|
|
252
|
+
|
|
242
253
|
holmes-kit upgrade [flags] install the latest and re-pin every recorded workspace (one command)
|
|
243
254
|
--dry-run show the plan (current→latest, workspaces to re-pin), change nothing
|
|
244
255
|
--yes skip the confirmation prompt (required in a non-interactive shell)
|
|
@@ -376,6 +387,14 @@ async function main(argv) {
|
|
|
376
387
|
}
|
|
377
388
|
return reported.code;
|
|
378
389
|
}
|
|
390
|
+
// @implements A-SPEC-702 — reads one scan and the spec store; writes only the badge and the README's
|
|
391
|
+
// marker region, and only when asked. The numbers never change the exit code.
|
|
392
|
+
if (cmd === 'attest') {
|
|
393
|
+
const { runAttest } = require('./attest');
|
|
394
|
+
const target = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
|
|
395
|
+
const exclude = typeof flags.exclude === 'string' ? flags.exclude.split(',') : [];
|
|
396
|
+
return runAttest({ root: target, exclude, json: flags.json === true, badge: flags.badge === true, readme: flags.readme === true }, { stdout: (t) => process.stdout.write(t), stderr: (t) => process.stderr.write(t) });
|
|
397
|
+
}
|
|
379
398
|
if (cmd === 'doctor') {
|
|
380
399
|
// A nonexistent target read as "healthy but unwired" and both advised remedies then refused it
|
|
381
400
|
// (round-6) — the typo is the diagnosis, so say it.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface TracedFilesInput {
|
|
2
|
+
/** What the scanner returned: repo-relative path and the spec ids the file anchors. */
|
|
3
|
+
files: readonly {
|
|
4
|
+
path: string;
|
|
5
|
+
anchors: readonly string[];
|
|
6
|
+
}[];
|
|
7
|
+
/** Ids whose status is `approved`. A draft anchor is not a trace. */
|
|
8
|
+
approvedIds: Iterable<string>;
|
|
9
|
+
/** Path prefixes that are not this project's source (vendored trees). Explicit, and echoed back. */
|
|
10
|
+
exclude?: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export interface TracedFiles {
|
|
13
|
+
total: number;
|
|
14
|
+
traced: number;
|
|
15
|
+
excluded: number;
|
|
16
|
+
/** Sorted. */
|
|
17
|
+
untraced: string[];
|
|
18
|
+
measurable: boolean;
|
|
19
|
+
reason?: string;
|
|
20
|
+
/** One decimal, FLOORED; `null` when not measurable. */
|
|
21
|
+
tracedPct: number | null;
|
|
22
|
+
}
|
|
23
|
+
export interface AttestProvenance {
|
|
24
|
+
at: string;
|
|
25
|
+
specs: string;
|
|
26
|
+
exclude: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface AttestReport {
|
|
29
|
+
files: TracedFiles;
|
|
30
|
+
specs: {
|
|
31
|
+
total: number;
|
|
32
|
+
linked: number;
|
|
33
|
+
};
|
|
34
|
+
provenance: AttestProvenance;
|
|
35
|
+
}
|
|
36
|
+
export declare const ATTEST_BEGIN = "<!-- holmes-kit:attest:begin -->";
|
|
37
|
+
export declare const ATTEST_END = "<!-- holmes-kit:attest:end -->";
|
|
38
|
+
export declare const ATTEST_SCHEMA = "holmes-attest/1";
|
|
39
|
+
/** Above this many untraced files the text names none and points at `--json`. */
|
|
40
|
+
export declare const UNTRACED_LINE_CAP = 10;
|
|
41
|
+
/**
|
|
42
|
+
* Backslashes folded, one trailing slash. A prefix that means "everything" is DROPPED: excluding the
|
|
43
|
+
* whole tree would read as n/a at best and 100% at worst (the same rule A-SPEC-694 pinned for
|
|
44
|
+
* `cycleIgnore`). Sorted and de-duplicated so the provenance does not depend on how it was typed.
|
|
45
|
+
*/
|
|
46
|
+
export declare function normalizeExcludes(raw: readonly string[] | undefined): string[];
|
|
47
|
+
/** Floored to one decimal: 99.96 must not read as 100 — 100 means every file. */
|
|
48
|
+
export declare function flooredPct(num: number, den: number): number | null;
|
|
49
|
+
export declare function tracedFiles(input: TracedFilesInput): TracedFiles;
|
|
50
|
+
/**
|
|
51
|
+
* Where and when the number is true. `+dirty` because a commit alone would call an unreproducible
|
|
52
|
+
* number reproducible. The spec digest covers (id, seal) of every approved spec — that set is what
|
|
53
|
+
* decides the numerator. No clock anywhere.
|
|
54
|
+
*/
|
|
55
|
+
export declare function attestProvenance(input: {
|
|
56
|
+
commit?: string;
|
|
57
|
+
dirty: boolean;
|
|
58
|
+
approved: readonly {
|
|
59
|
+
id: string;
|
|
60
|
+
digest: string;
|
|
61
|
+
}[];
|
|
62
|
+
exclude: readonly string[];
|
|
63
|
+
}): AttestProvenance;
|
|
64
|
+
export declare function renderAttestText(r: AttestReport): string;
|
|
65
|
+
export declare function renderAttestJson(r: AttestReport): string;
|
|
66
|
+
/** Self-contained: no href, font, script or fetch. Only the ratio, the percentage and `at` go in. */
|
|
67
|
+
export declare function renderAttestSvg(r: AttestReport): string;
|
|
68
|
+
/** What goes between the markers. Carries the excludes, so the README says how to reproduce it. */
|
|
69
|
+
export declare function renderAttestMarker(r: AttestReport): string;
|
|
70
|
+
/**
|
|
71
|
+
* Replace what lies between the two markers — and ONLY when each appears exactly once, begin first.
|
|
72
|
+
* No marker is `no-marker`: the person never asked, so nothing is written. Anything else malformed is
|
|
73
|
+
* `unbalanced`: a lone begin marker followed by "replace to the end" is this feature's worst failure.
|
|
74
|
+
* The README's own line ending is kept, so a CRLF file does not become one whole-file diff.
|
|
75
|
+
*/
|
|
76
|
+
export declare function replaceMarkerRegion(readme: string, inner: string): {
|
|
77
|
+
ok: true;
|
|
78
|
+
text: string;
|
|
79
|
+
changed: boolean;
|
|
80
|
+
} | {
|
|
81
|
+
ok: false;
|
|
82
|
+
reason: 'no-marker' | 'unbalanced';
|
|
83
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.UNTRACED_LINE_CAP = exports.ATTEST_SCHEMA = exports.ATTEST_END = exports.ATTEST_BEGIN = void 0;
|
|
37
|
+
exports.normalizeExcludes = normalizeExcludes;
|
|
38
|
+
exports.flooredPct = flooredPct;
|
|
39
|
+
exports.tracedFiles = tracedFiles;
|
|
40
|
+
exports.attestProvenance = attestProvenance;
|
|
41
|
+
exports.renderAttestText = renderAttestText;
|
|
42
|
+
exports.renderAttestJson = renderAttestJson;
|
|
43
|
+
exports.renderAttestSvg = renderAttestSvg;
|
|
44
|
+
exports.renderAttestMarker = renderAttestMarker;
|
|
45
|
+
exports.replaceMarkerRegion = replaceMarkerRegion;
|
|
46
|
+
// @implements A-SPEC-702
|
|
47
|
+
/**
|
|
48
|
+
* How many of a repository's source files trace to an APPROVED spec — said as a number anyone can
|
|
49
|
+
* reproduce, with the commit and spec-store state it was measured at.
|
|
50
|
+
*
|
|
51
|
+
* This exists INSTEAD of a "generated under holmes-kit" header in every file (REQ-702). A header is
|
|
52
|
+
* a claim: it can be pasted, and it keeps saying "governed" after an ungoverned edit. Line 1 already
|
|
53
|
+
* carries the `implements` anchor, which a gate verifies. What was missing is a repository-level
|
|
54
|
+
* statement, and the spec-side half of it (`linkCensus`) lived only inside the dashboard.
|
|
55
|
+
*
|
|
56
|
+
* NO NEW JUDGEMENT. The denominator is whatever the scanner returned, the numerator is anchors ∩
|
|
57
|
+
* approved ids. Everything here is pure — no clock, no filesystem, no git — so the same input is the
|
|
58
|
+
* same bytes and a committed badge does not churn. The CLI that feeds it is `cli/attest.ts`.
|
|
59
|
+
*/
|
|
60
|
+
const crypto = __importStar(require("node:crypto"));
|
|
61
|
+
exports.ATTEST_BEGIN = '<!-- holmes-kit:attest:begin -->';
|
|
62
|
+
exports.ATTEST_END = '<!-- holmes-kit:attest:end -->';
|
|
63
|
+
exports.ATTEST_SCHEMA = 'holmes-attest/1';
|
|
64
|
+
/** Above this many untraced files the text names none and points at `--json`. */
|
|
65
|
+
exports.UNTRACED_LINE_CAP = 10;
|
|
66
|
+
const posix = (p) => p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
67
|
+
/**
|
|
68
|
+
* Backslashes folded, one trailing slash. A prefix that means "everything" is DROPPED: excluding the
|
|
69
|
+
* whole tree would read as n/a at best and 100% at worst (the same rule A-SPEC-694 pinned for
|
|
70
|
+
* `cycleIgnore`). Sorted and de-duplicated so the provenance does not depend on how it was typed.
|
|
71
|
+
*/
|
|
72
|
+
function normalizeExcludes(raw) {
|
|
73
|
+
const out = new Set();
|
|
74
|
+
for (const r of raw ?? []) {
|
|
75
|
+
const p = posix(String(r).trim()).replace(/^\/+/, '').replace(/\/+$/, '');
|
|
76
|
+
if (p === '' || p === '.')
|
|
77
|
+
continue;
|
|
78
|
+
out.add(p + '/');
|
|
79
|
+
}
|
|
80
|
+
return [...out].sort();
|
|
81
|
+
}
|
|
82
|
+
/** Floored to one decimal: 99.96 must not read as 100 — 100 means every file. */
|
|
83
|
+
function flooredPct(num, den) {
|
|
84
|
+
if (den <= 0)
|
|
85
|
+
return null;
|
|
86
|
+
return Math.floor((num / den) * 1000) / 10;
|
|
87
|
+
}
|
|
88
|
+
function tracedFiles(input) {
|
|
89
|
+
const approved = new Set(input.approvedIds);
|
|
90
|
+
const excludes = normalizeExcludes(input.exclude);
|
|
91
|
+
let excluded = 0, traced = 0, total = 0;
|
|
92
|
+
const untraced = [];
|
|
93
|
+
for (const f of input.files) {
|
|
94
|
+
const p = posix(f.path);
|
|
95
|
+
// Boundary match: every prefix ends in '/', so `reference/` cannot pardon `reference-impl/`.
|
|
96
|
+
if (excludes.some((e) => p.startsWith(e))) {
|
|
97
|
+
excluded++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
total++;
|
|
101
|
+
if (f.anchors.some((id) => approved.has(id)))
|
|
102
|
+
traced++;
|
|
103
|
+
else
|
|
104
|
+
untraced.push(p);
|
|
105
|
+
}
|
|
106
|
+
untraced.sort();
|
|
107
|
+
const reason = total === 0 ? 'no source files' : approved.size === 0 ? 'no approved specs' : undefined;
|
|
108
|
+
return {
|
|
109
|
+
total, traced, excluded, untraced,
|
|
110
|
+
measurable: reason === undefined,
|
|
111
|
+
...(reason !== undefined ? { reason } : {}),
|
|
112
|
+
tracedPct: reason === undefined ? flooredPct(traced, total) : null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Where and when the number is true. `+dirty` because a commit alone would call an unreproducible
|
|
117
|
+
* number reproducible. The spec digest covers (id, seal) of every approved spec — that set is what
|
|
118
|
+
* decides the numerator. No clock anywhere.
|
|
119
|
+
*/
|
|
120
|
+
function attestProvenance(input) {
|
|
121
|
+
const lines = input.approved.map((a) => `${a.id}\t${a.digest}`).sort();
|
|
122
|
+
const specs = crypto.createHash('sha256').update(lines.join('\n')).digest('hex').slice(0, 12);
|
|
123
|
+
const commit = (input.commit ?? '').trim();
|
|
124
|
+
const at = commit === '' ? 'unversioned' : commit.slice(0, 12) + (input.dirty ? '+dirty' : '');
|
|
125
|
+
return { at, specs, exclude: normalizeExcludes(input.exclude) };
|
|
126
|
+
}
|
|
127
|
+
const pctText = (pct) => (pct === null ? 'n/a' : `${pct}%`);
|
|
128
|
+
const ratio = (num, den, sep) => `${num}${sep}${den}`;
|
|
129
|
+
function renderAttestText(r) {
|
|
130
|
+
const f = r.files;
|
|
131
|
+
const filesLine = f.measurable
|
|
132
|
+
? `${ratio(f.traced, f.total, ' / ')} (${pctText(f.tracedPct)})`
|
|
133
|
+
: `n/a — ${f.reason}`;
|
|
134
|
+
const specPct = flooredPct(r.specs.linked, r.specs.total);
|
|
135
|
+
const specsLine = specPct === null ? 'n/a — no approved A-SPECs' : `${ratio(r.specs.linked, r.specs.total, ' / ')} (${pctText(specPct)})`;
|
|
136
|
+
const lines = [
|
|
137
|
+
`traced files ${filesLine} — source files anchored to an APPROVED spec`,
|
|
138
|
+
`linked specs ${specsLine} — approved A-SPECs anchored from code`,
|
|
139
|
+
];
|
|
140
|
+
// Few enough to act on → name them; a wall of paths helps nobody, so beyond that point to --json.
|
|
141
|
+
if (f.measurable && f.untraced.length > 0) {
|
|
142
|
+
lines.push(f.untraced.length <= exports.UNTRACED_LINE_CAP
|
|
143
|
+
? `untraced ${f.untraced.join(', ')}`
|
|
144
|
+
: `untraced ${f.untraced.length} file(s) — list them with --json`);
|
|
145
|
+
}
|
|
146
|
+
if (r.provenance.exclude.length > 0)
|
|
147
|
+
lines.push(`excluded ${f.excluded} file(s) under: ${r.provenance.exclude.join(', ')}`);
|
|
148
|
+
lines.push(`at ${r.provenance.at} · specs sha256:${r.provenance.specs}`);
|
|
149
|
+
return lines.join('\n') + '\n';
|
|
150
|
+
}
|
|
151
|
+
function renderAttestJson(r) {
|
|
152
|
+
return JSON.stringify({ schema: exports.ATTEST_SCHEMA, files: r.files, specs: r.specs, provenance: r.provenance }, null, 2) + '\n';
|
|
153
|
+
}
|
|
154
|
+
const xml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
155
|
+
/** Self-contained: no href, font, script or fetch. Only the ratio, the percentage and `at` go in. */
|
|
156
|
+
function renderAttestSvg(r) {
|
|
157
|
+
const f = r.files;
|
|
158
|
+
const value = f.measurable ? `${ratio(f.traced, f.total, '/')} · ${pctText(f.tracedPct)}` : 'n/a';
|
|
159
|
+
const label = 'traced files';
|
|
160
|
+
const lw = 86, vw = Math.max(64, 14 + value.length * 7), w = lw + vw;
|
|
161
|
+
const colour = !f.measurable ? '#9f9f9f' : (f.tracedPct ?? 0) >= 90 ? '#2e7d32' : (f.tracedPct ?? 0) >= 60 ? '#b08900' : '#b3541e';
|
|
162
|
+
return [
|
|
163
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="34" role="img" aria-label="${xml(label + ': ' + value)}">`,
|
|
164
|
+
`<title>${xml(label + ': ' + value + ' @ ' + r.provenance.at)}</title>`,
|
|
165
|
+
`<rect width="${lw}" height="20" fill="#444"/>`,
|
|
166
|
+
`<rect x="${lw}" width="${vw}" height="20" fill="${colour}"/>`,
|
|
167
|
+
`<g fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">`,
|
|
168
|
+
`<text x="${lw / 2}" y="14" text-anchor="middle">${xml(label)}</text>`,
|
|
169
|
+
`<text x="${lw + vw / 2}" y="14" text-anchor="middle">${xml(value)}</text>`,
|
|
170
|
+
`</g>`,
|
|
171
|
+
`<text x="0" y="31" fill="#666" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="9">${xml('at ' + r.provenance.at)}</text>`,
|
|
172
|
+
`</svg>`,
|
|
173
|
+
].join('') + '\n';
|
|
174
|
+
}
|
|
175
|
+
/** What goes between the markers. Carries the excludes, so the README says how to reproduce it. */
|
|
176
|
+
function renderAttestMarker(r) {
|
|
177
|
+
const f = r.files;
|
|
178
|
+
const files = f.measurable ? `**${ratio(f.traced, f.total, '/')}** source files (${pctText(f.tracedPct)}) trace to an approved spec` : `traced files: n/a — ${f.reason}`;
|
|
179
|
+
const specPct = flooredPct(r.specs.linked, r.specs.total);
|
|
180
|
+
const specs = specPct === null ? '' : ` · **${ratio(r.specs.linked, r.specs.total, '/')}** approved A-SPECs (${pctText(specPct)}) are anchored from code`;
|
|
181
|
+
const excl = r.provenance.exclude.length > 0 ? ` --exclude ${r.provenance.exclude.join(',')}` : '';
|
|
182
|
+
return [
|
|
183
|
+
``,
|
|
184
|
+
``,
|
|
185
|
+
`${files}${specs}.`,
|
|
186
|
+
`Measured at \`${r.provenance.at}\` (specs \`sha256:${r.provenance.specs}\`) — reproduce with \`holmes-kit attest${excl}\`.`,
|
|
187
|
+
].join('\n');
|
|
188
|
+
}
|
|
189
|
+
const count = (hay, needle) => hay.split(needle).length - 1;
|
|
190
|
+
/**
|
|
191
|
+
* Replace what lies between the two markers — and ONLY when each appears exactly once, begin first.
|
|
192
|
+
* No marker is `no-marker`: the person never asked, so nothing is written. Anything else malformed is
|
|
193
|
+
* `unbalanced`: a lone begin marker followed by "replace to the end" is this feature's worst failure.
|
|
194
|
+
* The README's own line ending is kept, so a CRLF file does not become one whole-file diff.
|
|
195
|
+
*/
|
|
196
|
+
function replaceMarkerRegion(readme, inner) {
|
|
197
|
+
const begins = count(readme, exports.ATTEST_BEGIN), ends = count(readme, exports.ATTEST_END);
|
|
198
|
+
if (begins === 0 && ends === 0)
|
|
199
|
+
return { ok: false, reason: 'no-marker' };
|
|
200
|
+
const b = readme.indexOf(exports.ATTEST_BEGIN), e = readme.indexOf(exports.ATTEST_END);
|
|
201
|
+
if (begins !== 1 || ends !== 1 || e < b)
|
|
202
|
+
return { ok: false, reason: 'unbalanced' };
|
|
203
|
+
const eol = readme.includes('\r\n') ? '\r\n' : '\n';
|
|
204
|
+
const body = inner.replace(/\r\n/g, '\n').split('\n').join(eol);
|
|
205
|
+
const text = readme.slice(0, b + exports.ATTEST_BEGIN.length) + eol + body + eol + readme.slice(e);
|
|
206
|
+
return { ok: true, text, changed: text !== readme };
|
|
207
|
+
}
|
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.27.0",
|
|
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",
|