@holmes-lab/holmes-kit 0.19.0 → 0.19.3
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 +120 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.d.ts +22 -0
- package/dist/holmes/cli/agents.js +76 -1
- package/dist/holmes/cli/approve.js +6 -1
- package/dist/holmes/cli/doctor.d.ts +51 -1
- package/dist/holmes/cli/doctor.js +211 -36
- package/dist/holmes/cli/index.js +7 -1
- package/dist/holmes/cli/init.js +12 -0
- package/dist/holmes/cli/native-deps.d.ts +65 -0
- package/dist/holmes/cli/native-deps.js +131 -0
- package/dist/holmes/cpg/cycle-observation.d.ts +65 -0
- package/dist/holmes/cpg/cycle-observation.js +146 -0
- package/dist/holmes/governance/approval-queue.d.ts +23 -4
- package/dist/holmes/governance/approval-queue.js +44 -6
- package/dist/holmes/hooks/stop.d.ts +15 -0
- package/dist/holmes/hooks/stop.js +46 -3
- package/dist/holmes/mcp/handlers.d.ts +2 -0
- package/dist/holmes/mcp/handlers.js +29 -2
- package/dist/holmes/mcp/maintenance-analyze.d.ts +37 -0
- package/dist/holmes/mcp/maintenance-analyze.js +73 -1
- package/dist/holmes/mcp/maintenance-evidence.d.ts +41 -0
- package/dist/holmes/mcp/maintenance-evidence.js +71 -4
- package/dist/holmes/project/install-scripts-policy.d.ts +76 -0
- package/dist/holmes/project/install-scripts-policy.js +131 -0
- package/dist/holmes/project/npx-bin.d.ts +6 -0
- package/dist/holmes/project/npx-bin.js +10 -0
- package/dist/holmes/review/failed-test-names.d.ts +19 -0
- package/dist/holmes/review/failed-test-names.js +43 -0
- package/dist/holmes/review/run-replay.d.ts +23 -0
- package/dist/holmes/review/run-replay.js +30 -0
- package/dist/holmes/review/test-runner.d.ts +27 -0
- package/dist/holmes/review/test-runner.js +59 -3
- package/docs/install-guide.md +54 -5
- package/package.json +4 -1
|
@@ -37,7 +37,11 @@ exports.STRIPPED_FOR_PROBE = void 0;
|
|
|
37
37
|
exports.globalInstallDir = globalInstallDir;
|
|
38
38
|
exports.npmCliEntry = npmCliEntry;
|
|
39
39
|
exports.prefixVerdict = prefixVerdict;
|
|
40
|
+
exports.grammarProbe = grammarProbe;
|
|
41
|
+
exports.resolveDepDir = resolveDepDir;
|
|
42
|
+
exports.gatherNativeEvidence = gatherNativeEvidence;
|
|
40
43
|
exports.probeEnv = probeEnv;
|
|
44
|
+
exports.resolveWiringPath = resolveWiringPath;
|
|
41
45
|
exports.runDoctor = runDoctor;
|
|
42
46
|
exports.wiringSpawnCheck = wiringSpawnCheck;
|
|
43
47
|
exports.pushGateCheck = pushGateCheck;
|
|
@@ -48,8 +52,11 @@ exports.detectTreeKeyTemporary = detectTreeKeyTemporary;
|
|
|
48
52
|
// @implements A-SPEC-442
|
|
49
53
|
// @implements A-SPEC-207
|
|
50
54
|
// @implements A-SPEC-100.2
|
|
55
|
+
// @implements A-SPEC-580
|
|
51
56
|
const fs = __importStar(require("node:fs"));
|
|
52
57
|
const npx_bin_1 = require("../project/npx-bin");
|
|
58
|
+
const install_scripts_policy_1 = require("../project/install-scripts-policy");
|
|
59
|
+
const native_deps_1 = require("./native-deps");
|
|
53
60
|
const tier_1 = require("../semantic/tier");
|
|
54
61
|
const path = __importStar(require("node:path"));
|
|
55
62
|
const role_policy_1 = require("../governance/role-policy");
|
|
@@ -175,6 +182,140 @@ function prefixVerdict(input) {
|
|
|
175
182
|
fix,
|
|
176
183
|
};
|
|
177
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* @implements A-SPEC-580
|
|
187
|
+
* Parse a trivial source with every grammar, in a child node whose module resolution starts at
|
|
188
|
+
* `packageRoot`. `parsed` counts typescript and tsx separately (8 for the 7 grammar packages).
|
|
189
|
+
*/
|
|
190
|
+
function grammarProbe(packageRoot) {
|
|
191
|
+
const script = `
|
|
192
|
+
const out = { parsed: 0, failed: [] };
|
|
193
|
+
let Parser;
|
|
194
|
+
try { Parser = require('tree-sitter'); }
|
|
195
|
+
catch (e) { process.stdout.write(JSON.stringify({ ...out, error: String(e && e.message || e).split('\\n')[0] })); process.exit(0); }
|
|
196
|
+
const loaders = [
|
|
197
|
+
['typescript', () => require('tree-sitter-typescript').typescript],
|
|
198
|
+
['tsx', () => require('tree-sitter-typescript').tsx],
|
|
199
|
+
];
|
|
200
|
+
for (const g of ${JSON.stringify(GRAMMARS.filter((g) => g !== 'tree-sitter-typescript'))}) {
|
|
201
|
+
loaders.push([g.replace('tree-sitter-', ''), () => require(g)]);
|
|
202
|
+
}
|
|
203
|
+
for (const [name, load] of loaders) {
|
|
204
|
+
try {
|
|
205
|
+
const p = new Parser();
|
|
206
|
+
p.setLanguage(load());
|
|
207
|
+
const node = p.parse('x').rootNode;
|
|
208
|
+
const root = node && node.type;
|
|
209
|
+
if (!root) throw new Error('parse produced no root node');
|
|
210
|
+
out.parsed++;
|
|
211
|
+
} catch (e) { out.failed.push(name + ': ' + String(e && e.message || e).split('\\n')[0]); }
|
|
212
|
+
}
|
|
213
|
+
process.stdout.write(JSON.stringify(out));
|
|
214
|
+
`;
|
|
215
|
+
try {
|
|
216
|
+
const r = (0, node_child_process_1.spawnSync)(process.execPath, ['-e', script], { cwd: packageRoot, encoding: 'utf8', timeout: 30000, env: { ...process.env, NODE_PATH: path.join(packageRoot, 'node_modules') } });
|
|
217
|
+
if (r.status !== 0 || !r.stdout)
|
|
218
|
+
return { parsed: 0, failed: [], error: (r.stderr || `exit ${r.status}`).split('\n')[0] };
|
|
219
|
+
return JSON.parse(r.stdout);
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
return { parsed: 0, failed: [], error: e.message.split('\n')[0] };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** A short spawn whose failure means "not observed" — never a verdict. */
|
|
226
|
+
function observe(file, args) {
|
|
227
|
+
try {
|
|
228
|
+
return (0, node_child_process_1.execFileSync)(file, args, { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* @implements A-SPEC-580
|
|
236
|
+
* Everything observable about the better-sqlite3 install, gathered for `nativeVerdict`. Each probe
|
|
237
|
+
* is independent and optional: a spawn that fails leaves its field `undefined` (not observed), and
|
|
238
|
+
* the verdict says so rather than treating silence as evidence.
|
|
239
|
+
*
|
|
240
|
+
* Which package.json holds the approval policy depends on the layout: this checkout's own for a
|
|
241
|
+
* repository run, the PROJECT's (three levels up from node_modules/@holmes-lab/holmes-kit) for a
|
|
242
|
+
* local dependency. A global or npx install has no project package.json that npm consults, so its
|
|
243
|
+
* coverage is `uncovered` by construction and the remedy is the per-command `--allow-scripts`.
|
|
244
|
+
*/
|
|
245
|
+
// @implements A-SPEC-583.1
|
|
246
|
+
/**
|
|
247
|
+
* Where an installed dependency actually lives, asked of NODE rather than guessed from a path.
|
|
248
|
+
*
|
|
249
|
+
* 0.19.2 shipped a false FAIL to every third-party consumer: it built the directory by joining
|
|
250
|
+
* `packageRoot/node_modules/<name>`, and npm HOISTS dependencies to the project root, so for an
|
|
251
|
+
* install at `<proj>/node_modules/@holmes-lab/holmes-kit` the joined path never existed — while the
|
|
252
|
+
* binding sat at `<proj>/node_modules/better-sqlite3` and loaded fine. Hoisting is npm's default,
|
|
253
|
+
* which made the false verdict the default path of a normal install.
|
|
254
|
+
*
|
|
255
|
+
* `package.json` is the resolution target because we want the DIRECTORY, not the entry point: a
|
|
256
|
+
* package whose `exports` hides its main still resolves its own manifest by convention. A failure
|
|
257
|
+
* to resolve returns null, and the caller reports "absent" exactly as before — the repair changes
|
|
258
|
+
* how we look, never whether a real miss is reported.
|
|
259
|
+
*/
|
|
260
|
+
function resolveDepDir(fromRoot, name) {
|
|
261
|
+
if (name === '')
|
|
262
|
+
return null;
|
|
263
|
+
try {
|
|
264
|
+
return path.dirname(require.resolve(`${name}/package.json`, { paths: [fromRoot] }));
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function gatherNativeEvidence(packageRoot) {
|
|
271
|
+
const name = 'better-sqlite3';
|
|
272
|
+
// @implements A-SPEC-583.1 — resolved, not joined. A hoisted install is the npm default.
|
|
273
|
+
const pkgDir = resolveDepDir(packageRoot, name);
|
|
274
|
+
const bindingPresent = pkgDir !== null && fs.existsSync(path.join(pkgDir, 'build', 'Release', 'better_sqlite3.node'));
|
|
275
|
+
let packageVersion = '(unknown)';
|
|
276
|
+
try {
|
|
277
|
+
packageVersion = JSON.parse(fs.readFileSync(path.join(pkgDir ?? '', 'package.json'), 'utf8')).version ?? packageVersion;
|
|
278
|
+
}
|
|
279
|
+
catch { /* not installed */ }
|
|
280
|
+
let loadError;
|
|
281
|
+
try {
|
|
282
|
+
const Database = require(name);
|
|
283
|
+
const db = new Database(':memory:');
|
|
284
|
+
db.prepare('SELECT 1 AS ok').get();
|
|
285
|
+
db.close();
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
loadError = e.message.split('\n')[0];
|
|
289
|
+
}
|
|
290
|
+
const npmCli = npmCliEntry();
|
|
291
|
+
const npmVersion = npmCli !== null ? observe(process.execPath, [npmCli, '--version']) : observe('npm', ['--version']);
|
|
292
|
+
const npmMajor = npmVersion !== undefined && /^\d+/.test(npmVersion) ? Number(npmVersion.match(/^\d+/)[0]) : undefined;
|
|
293
|
+
const prefix = npmCli !== null ? observe(process.execPath, [npmCli, 'config', 'get', 'prefix']) : observe('npm', ['config', 'get', 'prefix']);
|
|
294
|
+
const kind = (0, native_deps_1.installKind)(packageRoot, prefix ? globalInstallDir(prefix, process.platform) : undefined);
|
|
295
|
+
let coverage = 'uncovered';
|
|
296
|
+
const policyJson = kind === 'repo' ? path.join(packageRoot, 'package.json')
|
|
297
|
+
: kind === 'local' ? path.join(packageRoot, '..', '..', '..', 'package.json')
|
|
298
|
+
: undefined;
|
|
299
|
+
if (policyJson !== undefined) {
|
|
300
|
+
try {
|
|
301
|
+
const allow = JSON.parse(fs.readFileSync(policyJson, 'utf8')).allowScripts;
|
|
302
|
+
coverage = (0, install_scripts_policy_1.allowScriptsCoverage)(allow, name, packageVersion);
|
|
303
|
+
}
|
|
304
|
+
catch { /* unreadable → uncovered, which is what npm would see too */ }
|
|
305
|
+
}
|
|
306
|
+
const ev = {
|
|
307
|
+
platform: process.platform, packageName: name, packageVersion, bindingPresent, loadError, npmMajor,
|
|
308
|
+
coverage, installKind: kind, pathHasSpace: packageRoot.includes(' '),
|
|
309
|
+
};
|
|
310
|
+
if (process.platform === 'win32') {
|
|
311
|
+
const python = observe('where.exe', ['python']) !== undefined || observe('where.exe', ['py']) !== undefined;
|
|
312
|
+
const vswhere = path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft Visual Studio', 'Installer', 'vswhere.exe');
|
|
313
|
+
const msvc = fs.existsSync(vswhere) || observe('where.exe', ['cl']) !== undefined || process.env.VCINSTALLDIR !== undefined;
|
|
314
|
+
ev.toolchain = { python, msvc };
|
|
315
|
+
ev.psPolicy = observe('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-ExecutionPolicy']);
|
|
316
|
+
}
|
|
317
|
+
return ev;
|
|
318
|
+
}
|
|
178
319
|
/** The parent environment minus the variables that legitimately change a gate decision. Pure. */
|
|
179
320
|
function probeEnv(parent) {
|
|
180
321
|
const out = { ...parent };
|
|
@@ -208,6 +349,23 @@ function wiredSettingsPath(target) {
|
|
|
208
349
|
return (0, init_1.settingsPathOf)(target, 'project');
|
|
209
350
|
return (0, init_1.settingsPathOf)(target, fs.existsSync((0, init_1.settingsPathOf)(target, 'local')) ? 'local' : 'project');
|
|
210
351
|
}
|
|
352
|
+
// @implements A-SPEC-581.1
|
|
353
|
+
/**
|
|
354
|
+
* Where a wiring's executable actually is, judged from the PROJECT rather than from the caller.
|
|
355
|
+
*
|
|
356
|
+
* Measured 2026-09-10: the same `.codex/config.toml` under the same `--target` read PASS from the
|
|
357
|
+
* repository root and FAIL from `/tmp`, because a relative arg is resolved by Node against
|
|
358
|
+
* `process.cwd()`. A relative arg is the CONVENTION here — `.mcp.json` has always carried
|
|
359
|
+
* `bin/holmes-mcp.js` — so the verdict was decided by where the person diagnosing stood.
|
|
360
|
+
*
|
|
361
|
+
* An absolute arg is returned untouched: agy's wiring is absolute and must not move. An empty arg
|
|
362
|
+
* stays empty — resolving it would conjure the target directory itself into a "file that exists".
|
|
363
|
+
*/
|
|
364
|
+
function resolveWiringPath(target, arg) {
|
|
365
|
+
if (arg === '')
|
|
366
|
+
return '';
|
|
367
|
+
return path.isAbsolute(arg) ? arg : path.resolve(target, arg);
|
|
368
|
+
}
|
|
211
369
|
async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
212
370
|
const checks = [];
|
|
213
371
|
const add = (name, level, detail, fix) => checks.push({ name, level, detail, fix });
|
|
@@ -223,39 +381,39 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
223
381
|
// 2. Node version vs better-sqlite3's declared range (WARN only — a mismatch still often works).
|
|
224
382
|
let sqliteRange = '(unknown)';
|
|
225
383
|
try {
|
|
226
|
-
|
|
384
|
+
// @implements A-SPEC-583.1 — the SIBLING of the evidence lookup; both move together.
|
|
385
|
+
sqliteRange = JSON.parse(fs.readFileSync(path.join(resolveDepDir(packageRoot, 'better-sqlite3') ?? '', 'package.json'), 'utf8')).engines?.node ?? '(none)';
|
|
227
386
|
}
|
|
228
387
|
catch { /* not resolvable from here */ }
|
|
229
388
|
add('node version', 'PASS', `node ${process.version}; better-sqlite3 declares engines.node=${sqliteRange}`, 'If a native module fails to load after a Node major upgrade, reinstall holmes-kit.');
|
|
230
|
-
// 3. tree-sitter + EACH grammar
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
389
|
+
// 3. tree-sitter + EACH grammar — PARSED, not merely resolved. `require.resolve` only proves the
|
|
390
|
+
// package directory exists; a grammar whose prebuilt binding is missing for this platform
|
|
391
|
+
// resolves fine and dies at `setLanguage`. language-parser.ts loads them at module scope, so
|
|
392
|
+
// ONE dead grammar kills MCP server startup — name the dead ones individually.
|
|
393
|
+
// @implements A-SPEC-580
|
|
394
|
+
// Measured in a FRESH process: tree-sitter's native addon binds to the first module registry
|
|
395
|
+
// that loads it, so a second `require` inside the same process (jest workers; any host that
|
|
396
|
+
// already loaded it) hands back a Parser whose `parse()` yields no rootNode — a false FAIL that
|
|
397
|
+
// says nothing about the install. The probe therefore runs in a child node, which is also
|
|
398
|
+
// exactly what the MCP server does at startup.
|
|
399
|
+
{
|
|
400
|
+
const g = grammarProbe(packageRoot);
|
|
401
|
+
if (g.error !== undefined) {
|
|
402
|
+
add('tree-sitter grammars', 'FAIL', `tree-sitter failed to load: ${g.error}`, 'The tree-sitter runtime binding for this platform is missing from its prebuilds. Reinstall; a source build needs Python and a C++ toolchain.');
|
|
403
|
+
}
|
|
404
|
+
else if (g.failed.length === 0) {
|
|
405
|
+
add('tree-sitter grammars', 'PASS', `tree-sitter + ${g.parsed} grammars parse`);
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
add('tree-sitter grammars', 'FAIL', `failed to parse with: ${g.failed.join('; ')}`, 'Grammars load from shipped prebuilds (no install script needed) — reinstall the package. A single dead grammar prevents the MCP server from starting.');
|
|
237
409
|
}
|
|
238
|
-
catch {
|
|
239
|
-
return true;
|
|
240
|
-
} });
|
|
241
|
-
if (missing.length === 0)
|
|
242
|
-
add('tree-sitter grammars', 'PASS', `tree-sitter + ${GRAMMARS.length} grammars resolve`);
|
|
243
|
-
else
|
|
244
|
-
add('tree-sitter grammars', 'FAIL', `missing: ${missing.join(', ')}`, 'A single missing grammar prevents the MCP server from starting. Reinstall; if a native build failed, ensure a C++ toolchain is available.');
|
|
245
|
-
}
|
|
246
|
-
catch (e) {
|
|
247
|
-
add('tree-sitter grammars', 'FAIL', `tree-sitter failed to load: ${e.message}`, 'Native module build failed. Install Xcode Command Line Tools (macOS) or build-essential, then reinstall.');
|
|
248
|
-
}
|
|
249
|
-
// 4. better-sqlite3 — ABI-locked (not N-API), the most fragile dependency.
|
|
250
|
-
try {
|
|
251
|
-
const Database = require('better-sqlite3');
|
|
252
|
-
const db = new Database(':memory:');
|
|
253
|
-
db.prepare('SELECT 1 AS ok').get();
|
|
254
|
-
db.close();
|
|
255
|
-
add('better-sqlite3', 'PASS', 'loads and executes against :memory:');
|
|
256
410
|
}
|
|
257
|
-
|
|
258
|
-
|
|
411
|
+
// 4. better-sqlite3 — the one dependency whose install script must RUN (prebuild-install ||
|
|
412
|
+
// node-gyp rebuild). Its failure has at least five causes with five different remedies, so the
|
|
413
|
+
// verdict is computed from evidence (A-SPEC-580), never from the load error's wording alone.
|
|
414
|
+
{
|
|
415
|
+
const v = (0, native_deps_1.nativeVerdict)(gatherNativeEvidence(packageRoot));
|
|
416
|
+
add('better-sqlite3', v.level, v.detail, v.fix);
|
|
259
417
|
}
|
|
260
418
|
// 5. Hooks actually gate — prove it with a live allow AND a live deny (exit 2).
|
|
261
419
|
if (built) {
|
|
@@ -809,11 +967,18 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
809
967
|
// the whole check for the common wiring, so "the file is there" was reported as if the
|
|
810
968
|
// server had been proven to start. `wiringHandshakeChecks` below now proves that, for
|
|
811
969
|
// every wired harness; this stays as the cheap, fast FAIL for a path that is simply gone.
|
|
970
|
+
// @implements A-SPEC-581.1 — resolved against the TARGET; the message keeps the string
|
|
971
|
+
// as written, because what the operator must fix is the wiring file, not our arithmetic.
|
|
812
972
|
const bin = server.args[0] ?? '';
|
|
813
|
-
|
|
973
|
+
const binAt = resolveWiringPath(target, bin);
|
|
974
|
+
add('mcp wiring spawn', fs.existsSync(binAt) ? 'PASS' : 'FAIL', fs.existsSync(binAt) ? `node wiring resolves: ${bin} (handshake proven separately)` : `node wiring points at a missing file: ${bin}`, fs.existsSync(binAt) ? undefined : 'Run `holmes-kit init` in the target to rewire the absolute path.');
|
|
814
975
|
}
|
|
815
976
|
else {
|
|
816
|
-
|
|
977
|
+
// @implements A-SPEC-581.1 — the SIBLING of the handshake spawn, and it must move with
|
|
978
|
+
// it. Measured on a consumer install: the same npx wiring read PASS from the handshake
|
|
979
|
+
// (launched in the target) and FAIL here (launched in doctor's cwd) — two checks, one
|
|
980
|
+
// wiring, opposite verdicts.
|
|
981
|
+
checks.push(await wiringSpawnCheck(server.command, server.args, undefined, undefined, target));
|
|
817
982
|
}
|
|
818
983
|
}
|
|
819
984
|
catch {
|
|
@@ -883,17 +1048,19 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
883
1048
|
// the "resolves" claim stronger than the check (adversarial round, Finding 2). Verify the
|
|
884
1049
|
// basename too, to close that gap.
|
|
885
1050
|
const bin = entry.args[0] ?? '';
|
|
1051
|
+
// @implements A-SPEC-581.1 — against the TARGET, not the cwd.
|
|
1052
|
+
const binAt = resolveWiringPath(target, bin);
|
|
886
1053
|
// Resolve the link before judging the name (round-2 F4): a symlink literally named
|
|
887
1054
|
// `holmes-mcp.js` pointing at /etc/hosts must not read as "resolves to this install".
|
|
888
1055
|
const realBin = (() => { try {
|
|
889
|
-
return fs.realpathSync(
|
|
1056
|
+
return fs.realpathSync(binAt);
|
|
890
1057
|
}
|
|
891
1058
|
catch {
|
|
892
1059
|
return '';
|
|
893
1060
|
} })();
|
|
894
1061
|
const resolvesHere = realBin !== '' && path.basename(realBin) === 'holmes-mcp.js';
|
|
895
1062
|
add('codex wiring', resolvesHere ? 'PASS' : 'FAIL', resolvesHere ? `MCP wiring resolves to this install: ${bin}`
|
|
896
|
-
: (fs.existsSync(
|
|
1063
|
+
: (fs.existsSync(binAt) ? `MCP wiring points at a file that is not holmes-mcp.js: ${bin}` : `MCP wiring points at a missing file: ${bin}`), resolvesHere ? undefined : 'Run holmes-kit init --target <dir> --agent codex --force to refresh the absolute path.');
|
|
897
1064
|
}
|
|
898
1065
|
else {
|
|
899
1066
|
const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
|
|
@@ -1095,7 +1262,12 @@ function cleanupOnSignal(dir) {
|
|
|
1095
1262
|
// harness will actually run reaches a server — the gap the dead npx form lived in.
|
|
1096
1263
|
// @implements A-SPEC-499.1 — exported for the prescription tests; `platform` is injectable so the
|
|
1097
1264
|
// win32 branch is testable off-Windows.
|
|
1098
|
-
function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.platform
|
|
1265
|
+
function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.platform,
|
|
1266
|
+
// @implements A-SPEC-581.1 — the directory the wiring is launched FROM. A harness starts the
|
|
1267
|
+
// server in the project, so a relative arg (`bin/holmes-mcp.js`, the .mcp.json convention)
|
|
1268
|
+
// resolves there. Left undefined the child inherits doctor's cwd, which made the handshake
|
|
1269
|
+
// verdict depend on where doctor ran — measured 2026-09-10.
|
|
1270
|
+
cwd) {
|
|
1099
1271
|
// The DISPLAYED command stays the original wiring string even when the win32 adapter rewraps the
|
|
1100
1272
|
// execution — the user compares this against their wiring file, not against cmd.exe plumbing.
|
|
1101
1273
|
const quoted = `${command} ${args.join(' ')}`;
|
|
@@ -1124,7 +1296,7 @@ function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.p
|
|
|
1124
1296
|
};
|
|
1125
1297
|
const timer = setTimeout(() => finish('FAIL', `wiring did not answer initialize within ${timeoutMs / 1000}s: \`${quoted}\``, 'Re-run `holmes-kit init` in the target to rewrite the wiring, then re-run doctor.'), timeoutMs);
|
|
1126
1298
|
try {
|
|
1127
|
-
child = (0, node_child_process_1.spawn)(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
1299
|
+
child = (0, node_child_process_1.spawn)(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], ...(cwd === undefined ? {} : { cwd }) });
|
|
1128
1300
|
}
|
|
1129
1301
|
catch (e) {
|
|
1130
1302
|
finish('FAIL', `wiring could not be spawned: \`${quoted}\` — ${e.message}`, spawnFailFix(e));
|
|
@@ -1385,13 +1557,16 @@ async function wiringHandshakeChecks(target) {
|
|
|
1385
1557
|
}
|
|
1386
1558
|
if (entry.command === 'node') {
|
|
1387
1559
|
const bin = entry.args[0] ?? '';
|
|
1388
|
-
|
|
1560
|
+
// @implements A-SPEC-581.1 — the handshake covers ALL THREE harnesses, so the cwd-dependent
|
|
1561
|
+
// read here was the widest instance of the same defect.
|
|
1562
|
+
if (!fs.existsSync(resolveWiringPath(target, bin))) {
|
|
1389
1563
|
out.push({ name, level: 'FAIL', detail: `The wiring points at a missing file: ${bin}`,
|
|
1390
1564
|
fix: `Run holmes-kit init --target <dir> --agent ${wiring.label} --force to refresh the path.` });
|
|
1391
1565
|
continue;
|
|
1392
1566
|
}
|
|
1393
1567
|
}
|
|
1394
|
-
|
|
1568
|
+
// @implements A-SPEC-581.1 — launched FROM the target, exactly as the harness would.
|
|
1569
|
+
const check = await wiringSpawnCheck(entry.command, entry.args, undefined, undefined, target);
|
|
1395
1570
|
out.push({ ...check, name });
|
|
1396
1571
|
}
|
|
1397
1572
|
return out;
|
package/dist/holmes/cli/index.js
CHANGED
|
@@ -770,7 +770,13 @@ async function main(argv) {
|
|
|
770
770
|
// The detail row travels with it: round-5 measured the one-line form at 81 columns, so the flag
|
|
771
771
|
// path prints the same two rows the screen does — subject read, id acted on.
|
|
772
772
|
const resolveRef = (ref) => {
|
|
773
|
-
|
|
773
|
+
// @implements A-SPEC-576.1 — the grammar decides which queue the reference is resolved against.
|
|
774
|
+
// A NUMBER is an index into the list the operator was just shown, so it must resolve against
|
|
775
|
+
// the same filtered list or `--grant 3` would act on a row nobody saw. An ID or prefix is the
|
|
776
|
+
// operator naming a specific request — including a `shell` refusal they approved out of band
|
|
777
|
+
// (A-SPEC-563.2), which the inbox filter hides from the list but must never make ungrantable.
|
|
778
|
+
const byIndex = /^\d+$/.test(ref.trim());
|
|
779
|
+
const r = resolveRequestRef(readQueue(root, byIndex ? undefined : { includeAllKinds: true }).pending, ref);
|
|
774
780
|
return r.ok ? { id: r.entry.id, subject: (prefix) => decisionSubject(r.entry, prefix), detail: (tail) => decisionDetail(r.entry, tail) } : { refusal: r.reason };
|
|
775
781
|
};
|
|
776
782
|
const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
|
package/dist/holmes/cli/init.js
CHANGED
|
@@ -458,6 +458,18 @@ function runInit(opts) {
|
|
|
458
458
|
for (const agent of opts.agents ?? []) {
|
|
459
459
|
for (const f of (0, agents_1.agentFiles)(agent, { target: opts.target, packageRoot: opts.packageRoot, specsDir: opts.specsDir, launcher: opts.mcpLauncher })) {
|
|
460
460
|
const before = fs.existsSync(f.path) ? fs.readFileSync(f.path, 'utf8') : null;
|
|
461
|
+
// @implements A-SPEC-576.2 — AGENTS.md is the one file here a PERSON edits. Regeneration
|
|
462
|
+
// used to overwrite it whole: it deleted the ADR-018 parity rule a commit had just added,
|
|
463
|
+
// and said nothing. The merge runs in the COMPUTE phase so `--dry-run` predicts the same
|
|
464
|
+
// bytes the write produces (A-SPEC-190 §9), and whatever it moves, it names.
|
|
465
|
+
if (path.basename(f.path) === 'AGENTS.md') {
|
|
466
|
+
const merged = (0, agents_1.mergeAgentsMd)(before, f.content);
|
|
467
|
+
changes.push({ path: f.path, before, after: merged.content });
|
|
468
|
+
if (merged.preserved.length > 0) {
|
|
469
|
+
messages.push(`${f.path}: ${merged.note} — ${merged.preserved.join(' | ')}`);
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
461
473
|
changes.push({ path: f.path, before, after: f.content });
|
|
462
474
|
}
|
|
463
475
|
messages.push(agents_1.HARNESS_ENFORCES[agent]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Coverage } from '../project/install-scripts-policy';
|
|
2
|
+
/**
|
|
3
|
+
* @implements A-SPEC-580
|
|
4
|
+
* Why better-sqlite3 has no binary — judged from EVIDENCE, not from the load error's wording.
|
|
5
|
+
*
|
|
6
|
+
* Measured 2026-09-09 (Windows, npm 12.0.1, Node 24.19.0): doctor said "ABI-locked … reinstall"
|
|
7
|
+
* when the real cause was npm 12 skipping the install script for lack of an `allowScripts` entry.
|
|
8
|
+
* A reinstall reproduces the same state. At least five causes hide behind one FAIL and each has a
|
|
9
|
+
* different remedy, so this module collects what is observable and refuses to assert what is not:
|
|
10
|
+
* a prebuilt-download failure leaves no trace doctor can read, and is named as a possibility only.
|
|
11
|
+
*
|
|
12
|
+
* Pure. The caller (doctor) gathers the evidence; every process it spawns for that is optional,
|
|
13
|
+
* and "not observed" is carried as `undefined`, never as `false`.
|
|
14
|
+
*/
|
|
15
|
+
export type InstallKind = 'repo' | 'local' | 'global' | 'npx' | 'unknown';
|
|
16
|
+
export type NativeCause = 'ok' | 'scripts-blocked' | 'abi-mismatch' | 'build-failed' | 'unknown';
|
|
17
|
+
export type Level = 'PASS' | 'WARN' | 'FAIL';
|
|
18
|
+
export interface NativeEvidence {
|
|
19
|
+
platform: string;
|
|
20
|
+
packageName: string;
|
|
21
|
+
packageVersion: string;
|
|
22
|
+
/** `build/Release/<name>.node` exists under the package. */
|
|
23
|
+
bindingPresent: boolean;
|
|
24
|
+
/** The first line of the `require` failure, when it failed. */
|
|
25
|
+
loadError?: string;
|
|
26
|
+
/** `npm --version` major; undefined when npm could not be consulted. */
|
|
27
|
+
npmMajor?: number;
|
|
28
|
+
/** How the ROOT package.json that governs this install covers the package (A-SPEC-579). */
|
|
29
|
+
coverage: Coverage;
|
|
30
|
+
installKind: InstallKind;
|
|
31
|
+
/** win32 only: whether a source build could even start. */
|
|
32
|
+
toolchain?: {
|
|
33
|
+
python: boolean;
|
|
34
|
+
msvc: boolean;
|
|
35
|
+
};
|
|
36
|
+
/** node-gyp is known to trip over spaces in the install path. */
|
|
37
|
+
pathHasSpace: boolean;
|
|
38
|
+
/** win32 only: `Get-ExecutionPolicy`, when observed. */
|
|
39
|
+
psPolicy?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Where this package lives — the layout decides which package.json (if any) holds the policy. */
|
|
42
|
+
export declare function installKind(packageRoot: string, globalDir?: string): InstallKind;
|
|
43
|
+
/**
|
|
44
|
+
* The narrowest commands that repair each layout. `--allow-scripts=<pkg>` is a per-invocation flag
|
|
45
|
+
* scoped to ONE package — never `--dangerously-allow-all-scripts`, never a change to npm config.
|
|
46
|
+
*
|
|
47
|
+
* Global targets the DEPENDENCY, not the holmes-kit package: measured 2026-09-09 (npm 12.0.1,
|
|
48
|
+
* Windows), `npm rebuild -g @holmes-lab/holmes-kit` re-links the bin and dies EEXIST on the
|
|
49
|
+
* existing `holmes-kit` shim before any script runs, while `npm rebuild -g better-sqlite3
|
|
50
|
+
* --allow-scripts=better-sqlite3` runs `prebuild-install` in place. (It then needs the global
|
|
51
|
+
* prefix to be writable — a protected prefix fails EPERM there, which is the `global prefix`
|
|
52
|
+
* check's territory, not this one's.)
|
|
53
|
+
*/
|
|
54
|
+
export declare function recoveryCommands(ev: Pick<NativeEvidence, 'platform' | 'installKind' | 'packageName' | 'packageVersion'>): string[];
|
|
55
|
+
/**
|
|
56
|
+
* What a PowerShell execution policy means for the emitted commands. Only the two policies that
|
|
57
|
+
* refuse every unsigned local script block the `npm.ps1`/`npx.ps1` shims; the rest add nothing.
|
|
58
|
+
*/
|
|
59
|
+
export declare function powershellPolicyNote(policy: string | undefined): string;
|
|
60
|
+
export declare function nativeVerdict(ev: NativeEvidence): {
|
|
61
|
+
cause: NativeCause;
|
|
62
|
+
level: Level;
|
|
63
|
+
detail: string;
|
|
64
|
+
fix?: string;
|
|
65
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.installKind = installKind;
|
|
4
|
+
exports.recoveryCommands = recoveryCommands;
|
|
5
|
+
exports.powershellPolicyNote = powershellPolicyNote;
|
|
6
|
+
exports.nativeVerdict = nativeVerdict;
|
|
7
|
+
// @implements A-SPEC-580
|
|
8
|
+
// @implements A-SPEC-580.1
|
|
9
|
+
const npx_bin_1 = require("../project/npx-bin");
|
|
10
|
+
const install_scripts_policy_1 = require("../project/install-scripts-policy");
|
|
11
|
+
const HOLMES_PKG = '@holmes-lab/holmes-kit';
|
|
12
|
+
/** Where this package lives — the layout decides which package.json (if any) holds the policy. */
|
|
13
|
+
function installKind(packageRoot, globalDir) {
|
|
14
|
+
const norm = packageRoot.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
15
|
+
if (norm.split('/').includes('_npx'))
|
|
16
|
+
return 'npx';
|
|
17
|
+
if (globalDir) {
|
|
18
|
+
const g = globalDir.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
19
|
+
if (norm.toLowerCase().startsWith(`${g.toLowerCase()}/`))
|
|
20
|
+
return 'global';
|
|
21
|
+
}
|
|
22
|
+
if (/\/node_modules\/@holmes-lab\/holmes-kit$/.test(norm))
|
|
23
|
+
return 'local';
|
|
24
|
+
return 'repo';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The narrowest commands that repair each layout. `--allow-scripts=<pkg>` is a per-invocation flag
|
|
28
|
+
* scoped to ONE package — never `--dangerously-allow-all-scripts`, never a change to npm config.
|
|
29
|
+
*
|
|
30
|
+
* Global targets the DEPENDENCY, not the holmes-kit package: measured 2026-09-09 (npm 12.0.1,
|
|
31
|
+
* Windows), `npm rebuild -g @holmes-lab/holmes-kit` re-links the bin and dies EEXIST on the
|
|
32
|
+
* existing `holmes-kit` shim before any script runs, while `npm rebuild -g better-sqlite3
|
|
33
|
+
* --allow-scripts=better-sqlite3` runs `prebuild-install` in place. (It then needs the global
|
|
34
|
+
* prefix to be writable — a protected prefix fails EPERM there, which is the `global prefix`
|
|
35
|
+
* check's territory, not this one's.)
|
|
36
|
+
*/
|
|
37
|
+
function recoveryCommands(ev) {
|
|
38
|
+
const npm = (0, npx_bin_1.npmBin)(ev.platform);
|
|
39
|
+
const project = [
|
|
40
|
+
(0, install_scripts_policy_1.approveCommand)(ev.packageName, ev.packageVersion, npm),
|
|
41
|
+
`${npm} rebuild ${ev.packageName} --foreground-scripts`,
|
|
42
|
+
];
|
|
43
|
+
switch (ev.installKind) {
|
|
44
|
+
case 'global': return [`${npm} rebuild -g ${ev.packageName} --foreground-scripts --allow-scripts=${ev.packageName}`];
|
|
45
|
+
case 'npx': return [`${npm} install --save-dev ${HOLMES_PKG}`, ...project];
|
|
46
|
+
default: return project;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** A rebuild alone (the script is approved or the policy is not the problem). */
|
|
50
|
+
function rebuildCommand(ev) {
|
|
51
|
+
const npm = (0, npx_bin_1.npmBin)(ev.platform);
|
|
52
|
+
return ev.installKind === 'global'
|
|
53
|
+
? `${npm} rebuild -g ${ev.packageName} --foreground-scripts --allow-scripts=${ev.packageName}`
|
|
54
|
+
: `${npm} rebuild ${ev.packageName} --foreground-scripts`;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* What a PowerShell execution policy means for the emitted commands. Only the two policies that
|
|
58
|
+
* refuse every unsigned local script block the `npm.ps1`/`npx.ps1` shims; the rest add nothing.
|
|
59
|
+
*/
|
|
60
|
+
function powershellPolicyNote(policy) {
|
|
61
|
+
if (policy === 'Restricted' || policy === 'AllSigned') {
|
|
62
|
+
return `PowerShell execution policy ${policy} blocks the npm.ps1/npx.ps1 shims — use npm.cmd/npx.cmd (the commands above already do).`;
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
const ABI_RE = /NODE_MODULE_VERSION|compiled against a different Node\.js version/;
|
|
67
|
+
function nativeVerdict(ev) {
|
|
68
|
+
const pkg = `${ev.packageName}@${ev.packageVersion}`;
|
|
69
|
+
const isWin = ev.platform === 'win32';
|
|
70
|
+
const psNote = isWin ? powershellPolicyNote(ev.psPolicy) : '';
|
|
71
|
+
const withPs = (detail) => (psNote ? `${detail} ${psNote}` : detail);
|
|
72
|
+
const approved = ev.coverage === 'approved-pinned' || ev.coverage === 'approved-unpinned';
|
|
73
|
+
const policyHome = ev.installKind === 'repo' || ev.installKind === 'local'
|
|
74
|
+
? 'the project package.json (allowScripts)'
|
|
75
|
+
: `no package.json can carry the approval for a ${ev.installKind} install — approve per command instead`;
|
|
76
|
+
// 1. Loads → nothing to diagnose. The wording is read by other suites; keep it byte-identical.
|
|
77
|
+
if (ev.bindingPresent && !ev.loadError) {
|
|
78
|
+
return { cause: 'ok', level: 'PASS', detail: 'loads and executes against :memory:' };
|
|
79
|
+
}
|
|
80
|
+
// 2. The binary exists but was built for another Node ABI — the ONE case the old wording fit.
|
|
81
|
+
if (ev.loadError && ABI_RE.test(ev.loadError)) {
|
|
82
|
+
return {
|
|
83
|
+
cause: 'abi-mismatch', level: 'FAIL',
|
|
84
|
+
detail: withPs(`${pkg} was built for a different Node ABI (running ${process.version}): ${ev.loadError}`),
|
|
85
|
+
fix: `Rebuild against this Node: ${rebuildCommand(ev)}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!ev.bindingPresent && !approved) {
|
|
89
|
+
// 3. npm ≥ 12 skips the script without an approval. That the approval is missing IS observed;
|
|
90
|
+
// an unobserved npm version does not change the remedy, so it is said and the same commands go out.
|
|
91
|
+
if (ev.npmMajor === undefined || ev.npmMajor >= 12) {
|
|
92
|
+
const npmSaid = ev.npmMajor === undefined ? 'npm (npm version not observed)' : `npm ${ev.npmMajor}`;
|
|
93
|
+
const denied = ev.coverage === 'denied' ? ` (allowScripts explicitly denied ${ev.packageName})` : '';
|
|
94
|
+
return {
|
|
95
|
+
cause: 'scripts-blocked', level: 'FAIL',
|
|
96
|
+
detail: withPs(`no binary — ${npmSaid} blocks dependency install scripts unless allowScripts covers ${pkg}${denied}; policy home: ${policyHome}.`),
|
|
97
|
+
fix: `Approve the one script that must run, then rebuild: ${recoveryCommands(ev).join(' && ')}`,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// 6b. Early npm 11 did not block scripts, so a missing approval proves nothing about why the
|
|
101
|
+
// script left no binary — say so instead of inventing a cause.
|
|
102
|
+
return {
|
|
103
|
+
cause: 'unknown', level: 'FAIL',
|
|
104
|
+
detail: withPs(`no binary — npm ${ev.npmMajor} may or may not have run the install script; rerun with --foreground-scripts to see what happened.`),
|
|
105
|
+
fix: `${rebuildCommand(ev)} — then run doctor again.`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
// 4. Approved, yet no binary: the script ran and produced nothing. What it could not do is
|
|
109
|
+
// partly observable (toolchain, path); a failed prebuild download is not.
|
|
110
|
+
if (!ev.bindingPresent) {
|
|
111
|
+
const missing = [];
|
|
112
|
+
if (ev.toolchain?.python === false)
|
|
113
|
+
missing.push('Python is not on PATH (node-gyp needs it)');
|
|
114
|
+
if (ev.toolchain?.msvc === false)
|
|
115
|
+
missing.push('Visual Studio C++ Build Tools (MSVC) were not found');
|
|
116
|
+
if (ev.pathHasSpace)
|
|
117
|
+
missing.push('the install path contains a space, which node-gyp is known to mishandle');
|
|
118
|
+
const because = missing.length ? ` Observed obstacles to a source build: ${missing.join('; ')}.` : '';
|
|
119
|
+
return {
|
|
120
|
+
cause: 'build-failed', level: 'FAIL',
|
|
121
|
+
detail: withPs(`the install script for ${pkg} is approved but produced no binary — a prebuilt download failure is not observable here.${because} Rerun with --foreground-scripts to see the script's own output.`),
|
|
122
|
+
fix: `${rebuildCommand(ev)} — install the missing toolchain (or use a Node version with a prebuilt binary) if the output shows a compile step.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// 6a. Present but failing to load for a reason we do not recognise — quote it, do not classify it.
|
|
126
|
+
return {
|
|
127
|
+
cause: 'unknown', level: 'FAIL',
|
|
128
|
+
detail: withPs(`${pkg} is present but failed to load: ${ev.loadError ?? '(no error text)'}`),
|
|
129
|
+
fix: `${rebuildCommand(ev)} — then run doctor again.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Cycle } from './cycle-detect';
|
|
2
|
+
/**
|
|
3
|
+
* How many cycles one record lists before it starts counting instead.
|
|
4
|
+
*
|
|
5
|
+
* A record must not grow with the tree: a repository with a thousand cycles would otherwise write a
|
|
6
|
+
* thousand-entry line every turn, and the ledger this exists to make readable would be the thing
|
|
7
|
+
* that makes it unreadable. What is dropped is COUNTED, never silently cut.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CYCLE_LIST_CAP = 50;
|
|
10
|
+
/**
|
|
11
|
+
* One turn's observation — paths, integers and enums only.
|
|
12
|
+
*
|
|
13
|
+
* ADR-012's redaction rule is what lets this file be git-tracked at all: no prose, no command
|
|
14
|
+
* strings, no file content. `files` are the scanner's project-relative paths, which the impact and
|
|
15
|
+
* density ledgers already carry.
|
|
16
|
+
*/
|
|
17
|
+
export interface CycleObservationRecord {
|
|
18
|
+
ts: string;
|
|
19
|
+
mode: 'strict' | 'track' | 'off';
|
|
20
|
+
cycles: Array<{
|
|
21
|
+
key: string;
|
|
22
|
+
files: string[];
|
|
23
|
+
runtime: boolean;
|
|
24
|
+
edges: number;
|
|
25
|
+
}>;
|
|
26
|
+
/** Cycles beyond CYCLE_LIST_CAP: counted, so a truncation is never mistaken for a clean tree. */
|
|
27
|
+
cyclesOmitted: number;
|
|
28
|
+
/** Keys of the runtime cycles nobody allowed — the ratchet's verdict at this moment. */
|
|
29
|
+
violations: string[];
|
|
30
|
+
allowed: number;
|
|
31
|
+
scope: {
|
|
32
|
+
judged: string[];
|
|
33
|
+
unavailable: string[];
|
|
34
|
+
};
|
|
35
|
+
replica?: string;
|
|
36
|
+
}
|
|
37
|
+
/** The cycle evidence the Stop hook computes, in the shape it already holds it. */
|
|
38
|
+
export interface CycleEvidence {
|
|
39
|
+
current: Cycle[];
|
|
40
|
+
allowed: string[];
|
|
41
|
+
mode: 'strict' | 'track' | 'off';
|
|
42
|
+
scope: {
|
|
43
|
+
judged: string[];
|
|
44
|
+
unavailable: string[];
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Build the record. PURE — the clock is an argument, so a test can pin it and two callers cannot
|
|
49
|
+
* disagree about what "now" was.
|
|
50
|
+
*
|
|
51
|
+
* A CLEAN run produces a record too. That is the whole design: a false-positive RATE is violations
|
|
52
|
+
* over chances, and a ledger that only speaks when something is wrong records the numerator and
|
|
53
|
+
* throws the denominator away.
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildCycleObservation(ev: CycleEvidence, ts: string): CycleObservationRecord;
|
|
56
|
+
/**
|
|
57
|
+
* Append one record. Never throws, and never creates `.ax` where governance was not opted into
|
|
58
|
+
* (A-SPEC-191 §25 — the same refusal the approval queue makes).
|
|
59
|
+
*
|
|
60
|
+
* A failure returns `false` and changes nothing else: this is an OBSERVATION, and an observation
|
|
61
|
+
* that could alter a verdict would be a gate wearing a different name.
|
|
62
|
+
*/
|
|
63
|
+
export declare function appendCycleObservation(root: string, rec: CycleObservationRecord): boolean;
|
|
64
|
+
/** Every replica's records, merged. A corrupt line is skipped, never fatal. */
|
|
65
|
+
export declare function readCycleObservations(root: string): CycleObservationRecord[];
|