@holmes-lab/holmes-kit 0.12.1 → 0.13.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 +104 -0
- package/README.md +13 -4
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-context.js +10 -10
- package/dist/holmes/cli/approve-ref.js +5 -5
- package/dist/holmes/cli/approve-watch.d.ts +1 -1
- package/dist/holmes/cli/approve-watch.js +6 -6
- package/dist/holmes/cli/approve.d.ts +3 -3
- package/dist/holmes/cli/approve.js +57 -57
- package/dist/holmes/cli/autonomy.d.ts +22 -0
- package/dist/holmes/cli/autonomy.js +145 -0
- package/dist/holmes/cli/colophon.d.ts +6 -0
- package/dist/holmes/cli/colophon.js +24 -0
- package/dist/holmes/cli/doctor.d.ts +2 -2
- package/dist/holmes/cli/doctor.js +104 -87
- package/dist/holmes/cli/index.js +122 -63
- package/dist/holmes/cli/init.d.ts +2 -0
- package/dist/holmes/cli/init.js +31 -19
- package/dist/holmes/cli/interactive-prompt.d.ts +8 -0
- package/dist/holmes/cli/interactive-prompt.js +23 -0
- package/dist/holmes/cli/semantic-key.js +9 -9
- package/dist/holmes/cli/settings-merge.d.ts +2 -1
- package/dist/holmes/cli/settings-merge.js +15 -3
- package/dist/holmes/cli/upgrade.js +7 -7
- package/dist/holmes/cpg/proposed-content.js +2 -2
- package/dist/holmes/governance/autonomy.d.ts +9 -2
- package/dist/holmes/governance/autonomy.js +166 -5
- package/dist/holmes/guardrail/blind-spots.js +15 -15
- package/dist/holmes/hooks/pre-tool-use.js +111 -42
- package/dist/holmes/hooks/session-start.js +74 -34
- package/dist/holmes/hooks/stop.d.ts +1 -1
- package/dist/holmes/hooks/stop.js +12 -12
- package/dist/holmes/mcp/handlers.js +14 -1
- package/dist/holmes/mcp/server.js +19 -0
- package/dist/holmes/semantic/credentials.js +1 -1
- package/dist/holmes/spec/id-collision.js +2 -2
- package/dist/holmes/update/refresh.d.ts +49 -0
- package/dist/holmes/update/refresh.js +106 -0
- package/package.json +2 -2
- package/playbooks/publish/PLAYBOOK.md +47 -35
- package/playbooks/remediation/PLAYBOOK.md +1 -1
|
@@ -157,21 +157,21 @@ function npmCliEntry(env = process.env, execPath = process.execPath) {
|
|
|
157
157
|
*/
|
|
158
158
|
function prefixVerdict(input) {
|
|
159
159
|
const reason = input.unreadable ?? (typeof input.prefix !== 'string' || input.prefix === ''
|
|
160
|
-
? `npm
|
|
160
|
+
? `npm returned a prefix that cannot be parsed (${String(input.prefix)})`
|
|
161
161
|
: undefined);
|
|
162
162
|
if (reason !== undefined) {
|
|
163
|
-
return { level: 'WARN', detail:
|
|
163
|
+
return { level: 'WARN', detail: `Could not read the global prefix — ${reason}. A local install (--save-dev) works regardless of this verdict.` };
|
|
164
164
|
}
|
|
165
165
|
const prefix = input.prefix;
|
|
166
166
|
if (input.writable) {
|
|
167
|
-
return { level: 'PASS', detail:
|
|
167
|
+
return { level: 'PASS', detail: `Global prefix (${prefix}) is writable by the current account — npm install -g works without permission trouble.` };
|
|
168
168
|
}
|
|
169
169
|
const fix = input.platform === 'win32'
|
|
170
|
-
? 'npm config set prefix "%APPDATA%\\npm"
|
|
171
|
-
: 'npm config set prefix "$HOME/.npm-global"
|
|
170
|
+
? 'Run npm config set prefix "%APPDATA%\\npm", add %APPDATA%\\npm to PATH, and reopen the terminal. A local install (npm install --save-dev) works without this setting.'
|
|
171
|
+
: 'Run npm config set prefix "$HOME/.npm-global" and add $HOME/.npm-global/bin to PATH. A local install (npm install --save-dev) works without this setting.';
|
|
172
172
|
return {
|
|
173
173
|
level: 'WARN',
|
|
174
|
-
detail:
|
|
174
|
+
detail: `Global prefix (${prefix}) is not writable by the current account — npm install -g dies with EPERM while creating a directory in ${input.dir ?? ''}. This happens before any package is transferred, so no package can fix it.`,
|
|
175
175
|
fix,
|
|
176
176
|
};
|
|
177
177
|
}
|
|
@@ -219,7 +219,7 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
219
219
|
add('build output', built ? 'PASS' : 'FAIL', built ? `compiled output present under ${path.join(packageRoot, 'dist')}` : `missing ${serverJs}`,
|
|
220
220
|
// @implements A-SPEC-190 §16 (round 12) — the old remedy named a `prepare` script package.json
|
|
221
221
|
// never declared (build/mcp/release/test/typecheck only); dist/ ships because `files` lists it.
|
|
222
|
-
built ? undefined : '
|
|
222
|
+
built ? undefined : 'This package ships compiled dist/ — if you are working from the repository run `npm run build`; if this is an install, reinstall it.');
|
|
223
223
|
// 2. Node version vs better-sqlite3's declared range (WARN only — a mismatch still often works).
|
|
224
224
|
let sqliteRange = '(unknown)';
|
|
225
225
|
try {
|
|
@@ -278,7 +278,7 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
278
278
|
// A probe must observe the gate, never feed its audit trail.
|
|
279
279
|
(() => {
|
|
280
280
|
const probeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'holmes-doctor-probe-'));
|
|
281
|
-
cleanupOnSignal(probeCwd); // round-9:
|
|
281
|
+
cleanupOnSignal(probeCwd); // round-9: leaves nothing behind even on an interrupt-kill
|
|
282
282
|
try {
|
|
283
283
|
return (0, node_child_process_1.spawnSync)(process.execPath, [script], { input: JSON.stringify(payload), encoding: 'utf8', timeout: 20000, env: childEnv, cwd: probeCwd });
|
|
284
284
|
}
|
|
@@ -441,10 +441,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
441
441
|
// not PASS: `codex gate` set the precedent — a thing that is wired but not yet enforcing is not
|
|
442
442
|
// a pass. Presence only; the value is an out-of-band secret and is never read into the report.
|
|
443
443
|
if (process.env.HOLMES_LEDGER_KEY) {
|
|
444
|
-
add('ledger signing', 'PASS', '
|
|
444
|
+
add('ledger signing', 'PASS', 'The ledger is signed with HOLMES_LEDGER_KEY — without the key, a forger cannot produce a chain-valid spec-approved record');
|
|
445
445
|
}
|
|
446
446
|
else {
|
|
447
|
-
add('ledger signing', 'WARN', '
|
|
447
|
+
add('ledger signing', 'WARN', 'The ledger is not signed (HOLMES_LEDGER_KEY unset) — the chain is a plain hash, so anyone who can write the file can also mint approval records, and ART-5\'s ledger comparison becomes a cost rather than a barrier', 'Set HOLMES_LEDGER_KEY out-of-band (in the environment of whoever launches the agent), then restart the server. The value is never printed into this report.');
|
|
448
448
|
}
|
|
449
449
|
// @implements A-SPEC-535.1
|
|
450
450
|
// Advisory: holmes-kit's OWN advertised MCP schema cost, and the one holmes-controlled
|
|
@@ -477,10 +477,10 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
477
477
|
}).split('\n').map((l) => l.trim().split('/').pop()).filter(Boolean));
|
|
478
478
|
const untracked = onDisk.filter((f) => !tracked.has(f));
|
|
479
479
|
if (untracked.length > 0) {
|
|
480
|
-
add('ledger tracked', 'WARN',
|
|
480
|
+
add('ledger tracked', 'WARN', `Untracked ledger(s): ${untracked.join(', ')} — not in git, so the ART-2 truncation backstop (A-SPEC-455) is off for these files. The Stop gate would not catch even a wholesale emptying of the ledger`, 'Add a `!.ax/ledger/provenance*.jsonl` re-include rule to .gitignore, then `git add -f .ax/ledger/provenance*.jsonl`, and commit.');
|
|
481
481
|
}
|
|
482
482
|
else {
|
|
483
|
-
add('ledger tracked', 'PASS', '
|
|
483
|
+
add('ledger tracked', 'PASS', 'The ledger is tracked in git — the ART-2 truncation backstop catches a rollback against the committed history');
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
}
|
|
@@ -520,12 +520,12 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
520
520
|
const { rtmCheck } = require('../rtm/rtm-check');
|
|
521
521
|
const dups = rtmCheck(all).filter((i) => i.kind === 'duplicate-id');
|
|
522
522
|
add('duplicate spec ids', dups.length === 0 ? 'PASS' : 'WARN', dups.length === 0
|
|
523
|
-
? '
|
|
524
|
-
:
|
|
525
|
-
: '
|
|
523
|
+
? 'No two spec files share an id'
|
|
524
|
+
: `Two or more spec files share an id: ${dups.map((d) => d.id).join(', ')}`, dups.length === 0 ? undefined
|
|
525
|
+
: 'The gate rules toward the unapproved side — keep one file and delete the copy.');
|
|
526
526
|
add('spec chain', 'PASS', orphaned.length === 0
|
|
527
|
-
? '
|
|
528
|
-
:
|
|
527
|
+
? 'Every approved spec has approved parents'
|
|
528
|
+
: `${orphaned.length} approved spec(s) with a non-approved parent: ${orphaned.map((s) => s.id).slice(0, 5).join(', ')}`);
|
|
529
529
|
}
|
|
530
530
|
catch { /* an unreadable corpus is doctor's other checks' business, not this one's */ }
|
|
531
531
|
// @implements A-SPEC-254.1
|
|
@@ -570,7 +570,7 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
570
570
|
// "Nothing there" is not "something escaped judgment" (round-4, extended round-7): a
|
|
571
571
|
// NONEXISTENT (ENOENT) or path-broken (ENOTDIR — .ax created as a stray FILE;
|
|
572
572
|
// ENAMETOOLONG) root/dir holds nothing that could have been judged, so counting it stamped
|
|
573
|
-
// a phantom
|
|
573
|
+
// a phantom "1 outside the verdict" on pre-init targets, indistinguishable from a genuinely
|
|
574
574
|
// permission-locked store. Real refusals (EACCES) and cycles (ELOOP — an entry exists, it
|
|
575
575
|
// just cannot be entered) still count as unexamined regions.
|
|
576
576
|
try {
|
|
@@ -665,26 +665,26 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
665
665
|
collect(specsRoot);
|
|
666
666
|
// No cross-reference to `spec readability` (round-3): that walker does not follow dir
|
|
667
667
|
// symlinks, so the pointer was false for symlink-reached files — doctor contradicting itself.
|
|
668
|
-
const scopeNote = skipped > 0 ? ` (
|
|
668
|
+
const scopeNote = skipped > 0 ? ` (${skipped} outside the verdict)` : '';
|
|
669
669
|
const found = detectIdCollisions(entries);
|
|
670
670
|
const collisions = found.filter((i) => i.kind === 'id-collision');
|
|
671
671
|
const families = found.filter((i) => i.kind === 'family-coexistence');
|
|
672
|
-
add('spec id
|
|
673
|
-
?
|
|
672
|
+
add('spec id preemption collision', collisions.length === 0 ? 'PASS' : 'WARN', collisions.length === 0
|
|
673
|
+
? `No id is claimed by two different specs${scopeNote}`
|
|
674
674
|
: collisions.map((c) => `${c.id}: ${c.files.join(' ↔ ')} (${c.detail})`).join('; ') + scopeNote, collisions.length === 0 ? undefined
|
|
675
|
-
: '
|
|
676
|
-
add('spec id
|
|
677
|
-
? `bare id
|
|
675
|
+
: 'Two specs preempted the same number — renumber one (manual until REQ-255) and re-seal.');
|
|
676
|
+
add('spec id family coexistence', families.length === 0 ? 'PASS' : 'WARN', families.length === 0
|
|
677
|
+
? `No bare id coexists with its dot-suffix family${scopeNote}`
|
|
678
678
|
: families.map((f) => `${f.id}: ${f.files.join(', ')}`).join('; ') + scopeNote, families.length === 0 ? undefined
|
|
679
|
-
: 'bare
|
|
679
|
+
: 'A bare spec coexists with a dotted sub-family — a sign two workspaces expanded the same number differently; unify on one expansion.');
|
|
680
680
|
}
|
|
681
681
|
catch (e) {
|
|
682
682
|
// Never a silent vanish: the corpus most likely to hold a collision (a messy half-merged
|
|
683
683
|
// store) is exactly where an upstream throw would otherwise suppress the report. String(e),
|
|
684
684
|
// not e.message — a thrown non-Error must not crash the survival branch itself (round-2).
|
|
685
685
|
const msg = String(e instanceof Error ? e.message : e).split('\n')[0];
|
|
686
|
-
add('spec id
|
|
687
|
-
add('spec id
|
|
686
|
+
add('spec id preemption collision', 'WARN', `could not run the check: ${msg}`);
|
|
687
|
+
add('spec id family coexistence', 'WARN', `could not run the check: ${msg}`);
|
|
688
688
|
}
|
|
689
689
|
}
|
|
690
690
|
// @implements A-SPEC-176
|
|
@@ -696,8 +696,8 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
696
696
|
const { unreadableSpecFiles } = require('../spec/spec-store');
|
|
697
697
|
const unreadable = unreadableSpecFiles(path.join(target, '.ax', 'specs'));
|
|
698
698
|
add('spec readability', unreadable.length === 0 ? 'PASS' : 'WARN', unreadable.length === 0
|
|
699
|
-
? '
|
|
700
|
-
:
|
|
699
|
+
? 'Every .md in the spec directory parses'
|
|
700
|
+
: `${unreadable.length} file(s) not treated as specs because they do not parse: ${unreadable.slice(0, 5).join(', ')}`, unreadable.length === 0 ? undefined : 'Check the frontmatter of those files — they exist but are invisible to the gate.');
|
|
701
701
|
}
|
|
702
702
|
// @implements A-SPEC-178
|
|
703
703
|
// Two checks that report the wiring a USER will meet, not what this process happens to see.
|
|
@@ -725,16 +725,32 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
725
725
|
} // absent or unreadable is "not guaranteed", which is the point
|
|
726
726
|
const procApproval = !!process.env.HOLMES_APPROVAL;
|
|
727
727
|
if (mcpApproval) {
|
|
728
|
-
add('approval channel', 'PASS', '
|
|
728
|
+
add('approval channel', 'PASS', 'The approval variable is in .mcp.json\'s server env — spec_approve reaches it');
|
|
729
729
|
}
|
|
730
730
|
else if (!procApproval) {
|
|
731
731
|
// A project that has not started approving is healthy. Warning here would put a permanent line
|
|
732
732
|
// on every install, and a line that is always there is one nobody reads (REQ-153's judgement).
|
|
733
|
-
add('approval channel', 'PASS', '
|
|
733
|
+
add('approval channel', 'PASS', 'The approval variable is not set — set it when an approval is needed');
|
|
734
734
|
}
|
|
735
735
|
else {
|
|
736
|
-
add('approval channel', 'WARN', '
|
|
736
|
+
add('approval channel', 'WARN', 'This process has HOLMES_APPROVAL but .mcp.json\'s server env does not — spec_approve reads the server env', 'There is no guarantee that settings.local.json\'s env reaches the MCP server. Launch Claude Code from the shell where you set that variable, and the child server inherits it.');
|
|
737
737
|
}
|
|
738
|
+
// @implements A-SPEC-552.1 — surface the project's autonomy posture so a human always knows
|
|
739
|
+
// whether the agent may self-approve. Read from the SAME place the MCP server does (.mcp.json
|
|
740
|
+
// server env), never from doctor's own process, for the reason stated above.
|
|
741
|
+
let autonomyOn = false;
|
|
742
|
+
try {
|
|
743
|
+
const raw = fs.readFileSync(path.join(target, '.mcp.json'), 'utf8');
|
|
744
|
+
const env = JSON.parse(raw)
|
|
745
|
+
.mcpServers?.[init_1.SERVER_NAME]?.env;
|
|
746
|
+
autonomyOn = !!env && !!env['HOLMES_AUTONOMOUS_APPROVAL'];
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
autonomyOn = false;
|
|
750
|
+
}
|
|
751
|
+
add('autonomous spec approval', 'PASS', autonomyOn
|
|
752
|
+
? 'ON — the agent may self-approve low-risk specs; governance-critical still ask you (holmes-kit autonomy off to disable)'
|
|
753
|
+
: 'OFF — every spec approval is human (enable at init, or holmes-kit autonomy on for one session)');
|
|
738
754
|
// @implements A-SPEC-251.2 — the version this project's MCP server will ACTUALLY launch, vs this
|
|
739
755
|
// CLI. The stale-global trap (measured 2026-08-23: .mcp.json pinned a 0.1.9 global while the user
|
|
740
756
|
// believed they had upgraded) was invisible because nothing compared them. Offline: the launch
|
|
@@ -757,17 +773,17 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
757
773
|
const launch = (0, mcp_version_1.mcpLaunchVersion)({ command: server.command, args: server.args }, readPkgVersionAt);
|
|
758
774
|
const verdict = (0, mcp_version_1.versionDriftVerdict)(launch, cli);
|
|
759
775
|
if (verdict === 'drift') {
|
|
760
|
-
add('mcp server version', 'WARN',
|
|
776
|
+
add('mcp server version', 'WARN', `This project's MCP server launches holmes-kit ${launch}, but this CLI is ${cli} — the old server keeps starting until it is rewired`,
|
|
761
777
|
// @implements A-SPEC-544.1 — one command installs + re-pins every workspace; init --force is the per-workspace path.
|
|
762
|
-
|
|
778
|
+
`Run ${(0, npx_bin_1.npxBin)()} holmes-kit upgrade to install the latest and re-pin every workspace (to fix only this workspace, ${(0, npx_bin_1.npxBin)()} holmes-kit init --force).`);
|
|
763
779
|
}
|
|
764
780
|
else if (verdict === 'match') {
|
|
765
|
-
add('mcp server version', 'PASS', `MCP
|
|
781
|
+
add('mcp server version', 'PASS', `MCP server version ${launch} matches this CLI`);
|
|
766
782
|
}
|
|
767
|
-
// 'unknown'
|
|
783
|
+
// 'unknown' -> no entry: an undecidable check is not turned into a defect.
|
|
768
784
|
}
|
|
769
785
|
}
|
|
770
|
-
catch { /* .mcp.json
|
|
786
|
+
catch { /* .mcp.json absent or unparseable -> proceed without this check */ }
|
|
771
787
|
// @implements A-SPEC-259 — the wiring is judged by SPAWNING it. String comparison is exactly how
|
|
772
788
|
// the dead `npx -y <pkg>@<ver> holmes-mcp` form (npx resolves the default bin and hands
|
|
773
789
|
// `holmes-mcp` to it as an argument) passed the launcher test, this doctor's own handshake check
|
|
@@ -805,8 +821,8 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
805
821
|
}
|
|
806
822
|
}
|
|
807
823
|
}
|
|
808
|
-
// @implements A-SPEC-193 —
|
|
809
|
-
//
|
|
824
|
+
// @implements A-SPEC-193 — for each wired harness, check that harness's wiring. A harness that is
|
|
825
|
+
// not wired is not diagnosed (calling an absence a defect turns doctor into noise).
|
|
810
826
|
const agyHooks = path.join(target, '.agents', 'hooks.json');
|
|
811
827
|
if (fs.existsSync(agyHooks)) {
|
|
812
828
|
try {
|
|
@@ -820,25 +836,25 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
820
836
|
const stale = [scriptOf(pre?.hooks?.[0]), scriptOf(entry?.Stop?.[0])]
|
|
821
837
|
.filter((p2) => !resolvesToPackage(p2, packageRoot));
|
|
822
838
|
if (entry === undefined) {
|
|
823
|
-
add('antigravity wiring', 'WARN', `${agyHooks}
|
|
839
|
+
add('antigravity wiring', 'WARN', `${agyHooks} has no holmes-kit entry`, 'holmes-kit init --target <dir> --agent antigravity');
|
|
824
840
|
}
|
|
825
841
|
else if (stale.length > 0) {
|
|
826
|
-
add('antigravity wiring', 'FAIL',
|
|
842
|
+
add('antigravity wiring', 'FAIL', `hook path(s) not pointing at this install: ${stale.join(' | ')}`, 'Run holmes-kit init --target <dir> --agent antigravity --force to refresh the absolute paths.');
|
|
827
843
|
}
|
|
828
844
|
else if ((pre?.matcher ?? '') !== '*') {
|
|
829
|
-
//
|
|
830
|
-
add('antigravity wiring', 'WARN', `PreToolUse
|
|
845
|
+
// A narrow matcher lets other tools pass the gate — a silent hole, so say it.
|
|
846
|
+
add('antigravity wiring', 'WARN', `The PreToolUse matcher is '${pre?.matcher ?? ''}' — unless it is '*', other tools never reach the gate`, "Restore the matcher to '*'.");
|
|
831
847
|
}
|
|
832
848
|
else {
|
|
833
|
-
add('antigravity wiring', 'PASS', 'PreToolUse
|
|
849
|
+
add('antigravity wiring', 'PASS', 'PreToolUse and Stop point at this install');
|
|
834
850
|
}
|
|
835
851
|
}
|
|
836
852
|
catch {
|
|
837
|
-
add('antigravity wiring', 'FAIL', `${agyHooks}
|
|
853
|
+
add('antigravity wiring', 'FAIL', `${agyHooks} cannot be read (not JSON)`, 'Fix or delete the file and rewire.');
|
|
838
854
|
}
|
|
839
|
-
//
|
|
855
|
+
// Are the skills visible to that harness — a broken link makes skills silently disappear.
|
|
840
856
|
const agySkills = path.join(target, '.agents', 'skills');
|
|
841
|
-
add('antigravity skills', fs.existsSync(agySkills) ? 'PASS' : 'WARN', fs.existsSync(agySkills) ? `${agySkills}
|
|
857
|
+
add('antigravity skills', fs.existsSync(agySkills) ? 'PASS' : 'WARN', fs.existsSync(agySkills) ? `${agySkills} points at the skills` : `${agySkills} is missing — this harness cannot see the skills`, fs.existsSync(agySkills) ? undefined : `Link or copy it to ${path.join('..', '.claude', 'skills')}.`);
|
|
842
858
|
}
|
|
843
859
|
// @implements A-SPEC-266 (was A-SPEC-264) — Codex reads MCP servers from `.codex/config.toml`
|
|
844
860
|
// (TOML `[mcp_servers.*]`), so we check the file Codex actually loads — not the
|
|
@@ -854,17 +870,18 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
854
870
|
}
|
|
855
871
|
catch { /* handled below */ }
|
|
856
872
|
if (raw === null) {
|
|
857
|
-
add('codex wiring', 'FAIL', `${cdxToml}
|
|
873
|
+
add('codex wiring', 'FAIL', `${cdxToml} cannot be read`, 'Check the file permissions, or delete it and rewire.');
|
|
858
874
|
}
|
|
859
875
|
else {
|
|
860
876
|
const entry = (0, codex_toml_1.readCodexHolmesEntry)(raw);
|
|
861
877
|
if (!entry) {
|
|
862
|
-
add('codex wiring', 'WARN', `${cdxToml}
|
|
878
|
+
add('codex wiring', 'WARN', `${cdxToml} has no runnable [mcp_servers.${init_1.SERVER_NAME}] entry — a half-done wiring`, 'Rewire with holmes-kit init --target <dir> --agent codex.');
|
|
863
879
|
}
|
|
864
880
|
else if (entry.command === 'node') {
|
|
865
|
-
// @implements A-SPEC-266 — "
|
|
866
|
-
// holmes-mcp.js
|
|
867
|
-
//
|
|
881
|
+
// @implements A-SPEC-266 — "resolves to this install" is not enough on existence alone: a
|
|
882
|
+
// node wiring must point at holmes-mcp.js. Passing any existing file (e.g. /etc/hosts) makes
|
|
883
|
+
// the "resolves" claim stronger than the check (adversarial round, Finding 2). Verify the
|
|
884
|
+
// basename too, to close that gap.
|
|
868
885
|
const bin = entry.args[0] ?? '';
|
|
869
886
|
// Resolve the link before judging the name (round-2 F4): a symlink literally named
|
|
870
887
|
// `holmes-mcp.js` pointing at /etc/hosts must not read as "resolves to this install".
|
|
@@ -875,12 +892,12 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
875
892
|
return '';
|
|
876
893
|
} })();
|
|
877
894
|
const resolvesHere = realBin !== '' && path.basename(realBin) === 'holmes-mcp.js';
|
|
878
|
-
add('codex wiring', resolvesHere ? 'PASS' : 'FAIL', resolvesHere ? `MCP
|
|
879
|
-
: (fs.existsSync(bin) ? `MCP
|
|
895
|
+
add('codex wiring', resolvesHere ? 'PASS' : 'FAIL', resolvesHere ? `MCP wiring resolves to this install: ${bin}`
|
|
896
|
+
: (fs.existsSync(bin) ? `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.');
|
|
880
897
|
}
|
|
881
898
|
else {
|
|
882
899
|
const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
|
|
883
|
-
add('codex wiring', pin !== null ? 'PASS' : 'FAIL', pin !== null ? `npx
|
|
900
|
+
add('codex wiring', pin !== null ? 'PASS' : 'FAIL', pin !== null ? `resolves via the npx pin ${pin}` : `cannot read a launch version from the wiring: ${entry.command} ${entry.args.join(' ')}`, pin !== null ? undefined : 'Rewire with holmes-kit init --target <dir> --agent codex.');
|
|
884
901
|
}
|
|
885
902
|
// @implements A-SPEC-442 (was A-SPEC-423)
|
|
886
903
|
// Codex CAN hard-enforce, but only from an INSTALLED plugin: it loads plugins from
|
|
@@ -897,7 +914,7 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
897
914
|
// location only) is caught instead of passing as "wiring is correct" when it cannot install.
|
|
898
915
|
const cdxMarketplaceNew = path.join(target, '.claude-plugin', 'marketplace.json');
|
|
899
916
|
const cdxMarketplaceOld = path.join(target, '.agents', 'plugins', 'marketplace.json');
|
|
900
|
-
const installCmds = `codex plugin marketplace add "${target}"
|
|
917
|
+
const installCmds = `codex plugin marketplace add "${target}", then codex plugin add holmes-kit@${agents_1.CODEX_MARKETPLACE}`;
|
|
901
918
|
if (fs.existsSync(cdxPluginJson) && fs.existsSync(cdxHooksJson) && (fs.existsSync(cdxMarketplaceNew) || fs.existsSync(cdxMarketplaceOld))) {
|
|
902
919
|
try {
|
|
903
920
|
const h = JSON.parse(fs.readFileSync(cdxHooksJson, 'utf8'));
|
|
@@ -909,33 +926,33 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
909
926
|
return !resolvesToPackage(p2, packageRoot);
|
|
910
927
|
});
|
|
911
928
|
if (stale.length > 0) {
|
|
912
|
-
add('codex gate', 'FAIL',
|
|
929
|
+
add('codex gate', 'FAIL', `hook command(s) not pointing at this install: ${stale.join(' | ')}`, 'Run holmes-kit init --target <dir> --agent codex --force to refresh the absolute paths.');
|
|
913
930
|
}
|
|
914
931
|
else if (manifest.hooks !== './hooks.json') {
|
|
915
932
|
// The one path measured to load. `./hooks/hooks.json` parses fine and never fires.
|
|
916
|
-
add('codex gate', 'FAIL',
|
|
933
|
+
add('codex gate', 'FAIL', `the plugin manifest's hooks is not './hooks.json': ${String(manifest.hooks)} — Codex does not load it`, 'Rewire with holmes-kit init --target <dir> --agent codex --force.');
|
|
917
934
|
}
|
|
918
935
|
else if (!fs.existsSync(cdxMarketplaceNew)) {
|
|
919
936
|
// @implements A-SPEC-533.3 — hooks are correct, but the marketplace manifest is only at
|
|
920
937
|
// the old `.agents/plugins/` location; codex 0.152.x reads `.claude-plugin/` and cannot
|
|
921
938
|
// install this. Judged AFTER the stale-hook / manifest FAILs so those still win.
|
|
922
|
-
add('codex gate', 'WARN',
|
|
939
|
+
add('codex gate', 'WARN', `.claude-plugin/marketplace.json (read by codex 0.152.x) is missing — only the old location (.agents/plugins/marketplace.json) is present, so codex plugin marketplace add fails with "no manifest" (0.151.x reads .agents/plugins/, 0.152.x reads .claude-plugin/)`, 'Rewiring with holmes-kit init --target <dir> --agent codex --force places the manifest in both locations.');
|
|
923
940
|
}
|
|
924
941
|
else {
|
|
925
|
-
add('codex gate', 'WARN', '
|
|
942
|
+
add('codex gate', 'WARN', 'The plugin wiring is correct — but the gate is not enforced until it is installed (Codex only loads plugins from an installed marketplace, and doctor cannot verify installation offline)', `Run ${installCmds}, then use it in a new session.`);
|
|
926
943
|
}
|
|
927
944
|
}
|
|
928
945
|
catch {
|
|
929
|
-
add('codex gate', 'FAIL', `${cdxHooksJson}
|
|
946
|
+
add('codex gate', 'FAIL', `${cdxHooksJson} cannot be read (not JSON)`, 'Fix or delete the file and rewire.');
|
|
930
947
|
}
|
|
931
948
|
}
|
|
932
949
|
else {
|
|
933
|
-
add('codex gate', 'WARN', 'codex
|
|
950
|
+
add('codex gate', 'WARN', 'The codex plugin wiring (.agents/plugins/marketplace.json, .holmes/codex-plugin/holmes-kit) is missing — only the MCP tools work; the gate is not enforced', `Wire it with holmes-kit init --target <dir> --agent codex, then install with ${installCmds}.`);
|
|
934
951
|
}
|
|
935
952
|
}
|
|
936
953
|
}
|
|
937
954
|
else if (fs.existsSync(cdxJson)) {
|
|
938
|
-
add('codex wiring', 'WARN', `codex
|
|
955
|
+
add('codex wiring', 'WARN', `codex wiring sits in a legacy location Codex does not read (${cdxJson}) — it must move to config.toml`, 'Rewiring with holmes-kit init --target <dir> --agent codex migrates it to config.toml and cleans up the legacy file.');
|
|
939
956
|
}
|
|
940
957
|
try {
|
|
941
958
|
const sp = wiredSettingsPath(target);
|
|
@@ -945,8 +962,8 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
945
962
|
.map((g) => g.matcher ?? '');
|
|
946
963
|
const drifted = wired.filter((m) => m !== init_1.MATCHERS.guardrail);
|
|
947
964
|
add('hook matcher', drifted.length === 0 ? 'PASS' : 'WARN', drifted.length === 0
|
|
948
|
-
?
|
|
949
|
-
:
|
|
965
|
+
? `The wired matcher equals the shipped default (${init_1.MATCHERS.guardrail})`
|
|
966
|
+
: `wired ${drifted.join(' | ')} / shipped default ${init_1.MATCHERS.guardrail} — a tool the matcher does not catch never reaches the hook`, drifted.length === 0 ? undefined : 'Run holmes-kit init --target <dir> --force to refresh it, and restart Claude Code.');
|
|
950
967
|
}
|
|
951
968
|
catch { /* an unreadable settings file is `target wiring`'s business, not this check's */ }
|
|
952
969
|
}
|
|
@@ -989,13 +1006,13 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
989
1006
|
const t = (0, tier_1.resolveSemanticTier)();
|
|
990
1007
|
const v = semanticTierVerdict(t);
|
|
991
1008
|
add('semantic tier', v.level, v.detail, v.fix);
|
|
992
|
-
// @implements A-SPEC-477 — the ".env
|
|
1009
|
+
// @implements A-SPEC-477 — the ".env stopgap" detection: a key in a project-tree file is a
|
|
993
1010
|
// commit-accident surface, world-readable at 644, and readable by every in-session tool. The
|
|
994
1011
|
// value is read to detect the pattern and never printed.
|
|
995
1012
|
const envTemp = detectTreeKeyTemporary(target ?? process.cwd());
|
|
996
1013
|
if (envTemp.found) {
|
|
997
|
-
add('semantic key location', 'WARN',
|
|
998
|
-
+ (envTemp.mode !== undefined && (envTemp.mode & 0o077) !== 0 ? ` (
|
|
1014
|
+
add('semantic key location', 'WARN', `Found a semantic-key pattern in the project .env — an in-tree key is a commit-accident and in-session-read surface`
|
|
1015
|
+
+ (envTemp.mode !== undefined && (envTemp.mode & 0o077) !== 0 ? ` (file mode ${(envTemp.mode & 0o777).toString(8)} — wider than 0600)` : '') + '.', 'Store it out of the tree with `holmes-kit semantic-key set` (the keychain, or ~/.holmes/credentials.json at 0600), then remove that line from .env.');
|
|
999
1016
|
}
|
|
1000
1017
|
}
|
|
1001
1018
|
add('environment', 'PASS', present);
|
|
@@ -1074,7 +1091,7 @@ function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.p
|
|
|
1074
1091
|
const spec = (0, spawn_spec_1.spawnSpecFor)(command, args, platform);
|
|
1075
1092
|
// The re-init prescription is only honest when the wiring might actually be stale. A command the
|
|
1076
1093
|
// OS cannot find (ENOENT) re-inits into the identical failure forever — the measured loop.
|
|
1077
|
-
const enoentFix =
|
|
1094
|
+
const enoentFix = `The \`${command}\` command was not found (PATH or platform) — if the wiring string is correct, re-init does not help. Run the same command directly in a shell to check.`;
|
|
1078
1095
|
const spawnFailFix = (e) => e?.code === 'ENOENT' ? enoentFix : 'Re-run `holmes-kit init` in the target to rewrite the wiring.';
|
|
1079
1096
|
return new Promise((resolve) => {
|
|
1080
1097
|
let done = false;
|
|
@@ -1120,8 +1137,8 @@ function wiringSpawnCheck(command, args, timeoutMs = 30000, platform = process.p
|
|
|
1120
1137
|
const live = m.result.serverInfo.version ?? '?';
|
|
1121
1138
|
const pinned = /@holmes-lab\/holmes-kit@([0-9][^\s"'`]*)/.exec(quoted)?.[1];
|
|
1122
1139
|
if (pinned !== undefined && live !== '?' && live !== pinned) {
|
|
1123
|
-
finish('WARN', `wiring pins ${pinned} but the live server answered v${live}: \`${quoted}\``, 'npx
|
|
1124
|
-
+ '
|
|
1140
|
+
finish('WARN', `wiring pins ${pinned} but the live server answered v${live}: \`${quoted}\``, 'npx may resolve to a cached old version — run `npx clear-npx-cache` and retry, or'
|
|
1141
|
+
+ ' confirm that version is published to the registry. The harness enforces with whatever version answered.');
|
|
1125
1142
|
return;
|
|
1126
1143
|
}
|
|
1127
1144
|
finish('PASS', `wiring reaches a live server (v${live}): \`${quoted}\``);
|
|
@@ -1227,7 +1244,7 @@ function mcpHandshakeCheck(packageRoot, timeoutMs = 15000) {
|
|
|
1227
1244
|
}
|
|
1228
1245
|
catch {
|
|
1229
1246
|
continue;
|
|
1230
|
-
} //
|
|
1247
|
+
} // a server log line is not the protocol
|
|
1231
1248
|
if (msg.error && (msg.id === 1 || msg.id === 2)) {
|
|
1232
1249
|
finish('FAIL', `MCP server answered ${msg.id === 1 ? 'initialize' : 'tools/list'} with an error: ${String(msg.error.message ?? msg.error.code)}`, 'The server started but refused the handshake — check the installed version against this package.');
|
|
1233
1250
|
return;
|
|
@@ -1279,11 +1296,11 @@ function pushGateCheck(target) {
|
|
|
1279
1296
|
const hookPath = path.join(effectiveHooksDir(target), 'pre-push');
|
|
1280
1297
|
const installed = fs.existsSync(hookPath) && fs.readFileSync(hookPath, 'utf8').includes(PUSH_HOOK_SIGNATURE);
|
|
1281
1298
|
return installed
|
|
1282
|
-
? { name: 'push gate', level: 'PASS', detail: 'pre-push
|
|
1299
|
+
? { name: 'push gate', level: 'PASS', detail: 'The pre-push evidence gate is installed (test_run ledger comparison)' }
|
|
1283
1300
|
: {
|
|
1284
1301
|
name: 'push gate', level: 'WARN',
|
|
1285
|
-
detail: 'pre-push
|
|
1286
|
-
fix:
|
|
1302
|
+
detail: 'The pre-push evidence gate is not installed — an unverified HEAD could become another agent\'s baseline',
|
|
1303
|
+
fix: `Run ${(0, npx_bin_1.npxBin)()} holmes-kit install-push-gate (it does not overwrite an existing hook)`,
|
|
1287
1304
|
};
|
|
1288
1305
|
}
|
|
1289
1306
|
catch {
|
|
@@ -1310,7 +1327,7 @@ function formatChecks(checks) {
|
|
|
1310
1327
|
* doctor flags the old location. But the second half was real and sat one layer further in:
|
|
1311
1328
|
*
|
|
1312
1329
|
* PASS mcp wiring spawn — node wiring resolves: …/holmes-mcp.js <- existence only
|
|
1313
|
-
* PASS codex wiring — MCP
|
|
1330
|
+
* PASS codex wiring — MCP wiring resolves to this install <- path resolution only
|
|
1314
1331
|
*
|
|
1315
1332
|
* The one check that drives a real `initialize -> tools/list` ran for `.mcp.json` ONLY, and only when
|
|
1316
1333
|
* the command was not `node` — which is what init writes for all three harnesses. So on the common
|
|
@@ -1348,15 +1365,15 @@ async function wiringHandshakeChecks(target) {
|
|
|
1348
1365
|
entry = null;
|
|
1349
1366
|
}
|
|
1350
1367
|
if (!entry) {
|
|
1351
|
-
out.push({ name, level: 'WARN', detail:
|
|
1352
|
-
fix: `holmes-kit init --target <dir> --agent ${wiring.label}
|
|
1368
|
+
out.push({ name, level: 'WARN', detail: `Could not read a runnable ${init_1.SERVER_NAME} wiring from ${wiring.file}`,
|
|
1369
|
+
fix: `Rewire with holmes-kit init --target <dir> --agent ${wiring.label}.` });
|
|
1353
1370
|
continue;
|
|
1354
1371
|
}
|
|
1355
1372
|
if (entry.command === 'node') {
|
|
1356
1373
|
const bin = entry.args[0] ?? '';
|
|
1357
1374
|
if (!fs.existsSync(bin)) {
|
|
1358
|
-
out.push({ name, level: 'FAIL', detail:
|
|
1359
|
-
fix: `holmes-kit init --target <dir> --agent ${wiring.label} --force
|
|
1375
|
+
out.push({ name, level: 'FAIL', detail: `The wiring points at a missing file: ${bin}`,
|
|
1376
|
+
fix: `Run holmes-kit init --target <dir> --agent ${wiring.label} --force to refresh the path.` });
|
|
1360
1377
|
continue;
|
|
1361
1378
|
}
|
|
1362
1379
|
}
|
|
@@ -1386,28 +1403,28 @@ function semanticTierVerdict(t) {
|
|
|
1386
1403
|
// never is. Consent must be able to see what it consented to, and where it lives.
|
|
1387
1404
|
const src = t.keySource === 'env' ? 'HOLMES_SEMANTIC_API_KEY'
|
|
1388
1405
|
: t.keySource === 'env-compat' ? 'GEMINI_API_KEY'
|
|
1389
|
-
: t.keySource === 'keychain' ? 'macOS
|
|
1390
|
-
: t.keySource === 'file' ? '~/.holmes/credentials.json' : '
|
|
1406
|
+
: t.keySource === 'keychain' ? 'macOS keychain'
|
|
1407
|
+
: t.keySource === 'file' ? '~/.holmes/credentials.json' : 'unknown';
|
|
1391
1408
|
return {
|
|
1392
1409
|
level: 'PASS',
|
|
1393
|
-
detail:
|
|
1410
|
+
detail: `Semantic search tier: cloud (${t.model}, key source: ${src}) — spec prose, file paths, and symbol names are sent to an external service (Google API). Setting the key is consent to that transfer, and the value is never printed into this report.`,
|
|
1394
1411
|
};
|
|
1395
1412
|
}
|
|
1396
1413
|
if (t.tier === 'local') {
|
|
1397
1414
|
return {
|
|
1398
1415
|
level: 'PASS',
|
|
1399
|
-
detail:
|
|
1416
|
+
detail: `Semantic search tier: local (${t.model}, ${(0, tier_1.POOLING_OF)(t.model ?? '')} pooling) — no egress. Recovers 52% of requests lexical search misses (measured).`,
|
|
1400
1417
|
};
|
|
1401
1418
|
}
|
|
1402
1419
|
return {
|
|
1403
1420
|
level: 'PASS',
|
|
1404
|
-
detail: '
|
|
1405
|
-
fix: '
|
|
1421
|
+
detail: 'Semantic search tier: none — using lexical, citation, and graph signals only. Requests lexical search misses (measured 16.4%) have 0% recall.',
|
|
1422
|
+
fix: 'Local tier (no egress): npm i @xenova/transformers — lexical-zero recall 0→52%. Cloud tier (opt-in, consent to external transfer): set GEMINI_API_KEY — 0→92%.',
|
|
1406
1423
|
};
|
|
1407
1424
|
}
|
|
1408
1425
|
/**
|
|
1409
1426
|
* @implements A-SPEC-477
|
|
1410
|
-
* Detects the "
|
|
1427
|
+
* Detects the "stopgap": a semantic key pattern inside the PROJECT's .env. The value is read
|
|
1411
1428
|
* only to detect the pattern and is never returned or printed.
|
|
1412
1429
|
*/
|
|
1413
1430
|
function detectTreeKeyTemporary(root) {
|