@holmes-lab/holmes-kit 0.23.2 → 0.24.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 +86 -0
- package/README.md +3 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve.js +2 -0
- package/dist/holmes/cli/index.js +41 -1
- package/dist/holmes/cli/open-url.d.ts +30 -0
- package/dist/holmes/cli/open-url.js +38 -0
- package/dist/holmes/cli/report.d.ts +20 -0
- package/dist/holmes/cli/report.js +115 -0
- package/dist/holmes/cli/reported-log.d.ts +23 -0
- package/dist/holmes/cli/reported-log.js +31 -0
- package/dist/holmes/hooks/pre-tool-use.js +2 -0
- package/dist/holmes/hooks/stop.d.ts +14 -0
- package/dist/holmes/hooks/stop.js +82 -1
- package/dist/holmes/mcp/tool-schemas.js +2 -0
- package/dist/holmes/project/analysis-currency.d.ts +22 -0
- package/dist/holmes/project/analysis-currency.js +96 -0
- package/dist/holmes/project/ci-lock.d.ts +20 -0
- package/dist/holmes/project/ci-lock.js +111 -0
- package/dist/holmes/project/dist-freshness.d.ts +30 -0
- package/dist/holmes/project/dist-freshness.js +114 -0
- package/dist/holmes/project/field-report-url.d.ts +47 -0
- package/dist/holmes/project/field-report-url.js +63 -0
- package/dist/holmes/project/field-report.d.ts +54 -0
- package/dist/holmes/project/field-report.js +107 -0
- package/dist/holmes/semantic/credentials.js +2 -0
- package/dist/holmes/update/workspaces.d.ts +53 -2
- package/dist/holmes/update/workspaces.js +77 -4
- package/package.json +3 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FIELD_REPORT_KEYS = void 0;
|
|
4
|
+
exports.machineIdentity = machineIdentity;
|
|
5
|
+
exports.isSafeValue = isSafeValue;
|
|
6
|
+
exports.composeFieldReport = composeFieldReport;
|
|
7
|
+
exports.fieldReportBody = fieldReportBody;
|
|
8
|
+
// @implements A-SPEC-675
|
|
9
|
+
/**
|
|
10
|
+
* A field report: what a consumer workspace may tell us about a holmes-kit defect.
|
|
11
|
+
*
|
|
12
|
+
* There is no channel today. The one field defect we know about — REQ-671, where the AGENTS.md we
|
|
13
|
+
* ship told agents to author specs with four tools and none of them can write a section body —
|
|
14
|
+
* reached us because a user pasted a transcript. It reproduced for every consumer on every slice.
|
|
15
|
+
*
|
|
16
|
+
* What may be sent is decided by an ALLOWLIST, never by scrubbing. This repository has already been
|
|
17
|
+
* punctured by a denylist once (the secret-free path rule, holed by UNC paths), and the hazards here
|
|
18
|
+
* are measured rather than imagined: its replica ids carry a person's name, its homedir carries the
|
|
19
|
+
* OS username, and its git remote carries a forty-character token before the `@`. That last one is
|
|
20
|
+
* why an ALLOWED FIELD can still hold a dangerous VALUE — hence two layers, shape and content.
|
|
21
|
+
*
|
|
22
|
+
* It never guesses a cause. This module carries what was OBSERVED and leaves the diagnosis to a
|
|
23
|
+
* person: in one session here, four instrument failures and three confident wrong conclusions were
|
|
24
|
+
* caught only by re-measuring, and an issue tracker full of confident auto-diagnoses costs a
|
|
25
|
+
* maintainer more than an empty one.
|
|
26
|
+
*/
|
|
27
|
+
const advisory_outcomes_1 = require("../rtm/advisory-outcomes");
|
|
28
|
+
/** The only keys a report may carry. Adding one is an edit here, which the pin makes visible. */
|
|
29
|
+
exports.FIELD_REPORT_KEYS = ['kit', 'harness', 'os', 'arch', 'node', 'message',
|
|
30
|
+
'article', 'tool', 'specIds', 'observations', 'fingerprint', 'withheld'];
|
|
31
|
+
/**
|
|
32
|
+
* Injected rather than read from `os` here, so a pin can search a composed body for THIS machine's
|
|
33
|
+
* real values. A module that reads the environment itself can only be tested against fixtures, and
|
|
34
|
+
* fixtures are exactly what a redaction pin must not trust.
|
|
35
|
+
*
|
|
36
|
+
* Tokens shorter than two characters are dropped: a one-character identity would redact every
|
|
37
|
+
* sentence, which is destruction rather than redaction.
|
|
38
|
+
*/
|
|
39
|
+
function machineIdentity(env) {
|
|
40
|
+
const raw = [env.hostname, env.username, env.homedir, ...(env.replicas ?? [])];
|
|
41
|
+
return [...new Set(raw.filter((s) => typeof s === 'string' && s.trim().length >= 2).map((s) => s.trim()))];
|
|
42
|
+
}
|
|
43
|
+
const ABSOLUTE_PATH = /(^|[\s"'`(])(\/(?:Users|home|root|var|opt|private)\/|[A-Za-z]:[\\/])/;
|
|
44
|
+
/** `user@host` with a long opaque user is how this repository's own remote carries its token. */
|
|
45
|
+
const CREDENTIAL = /[A-Za-z0-9_.-]{8,}@[A-Za-z0-9.-]+/;
|
|
46
|
+
function isSafeValue(text, identity) {
|
|
47
|
+
if (typeof text !== 'string' || text === '')
|
|
48
|
+
return true;
|
|
49
|
+
if (ABSOLUTE_PATH.test(text) || CREDENTIAL.test(text))
|
|
50
|
+
return false;
|
|
51
|
+
const lower = text.toLowerCase();
|
|
52
|
+
return !identity.some((id) => id.length >= 2 && lower.includes(id.toLowerCase()));
|
|
53
|
+
}
|
|
54
|
+
/** Ids only. A spec TITLE is unreleased product intent and must not ride in on the id field. */
|
|
55
|
+
const SPEC_ID = /^(?:REQ|H-SPEC|A-SPEC|T-SPEC|C-SPEC)-\d+(?:\.\d+)?$/;
|
|
56
|
+
function composeFieldReport(input) {
|
|
57
|
+
const identity = input.identity ?? [];
|
|
58
|
+
const withheld = [];
|
|
59
|
+
const guard = (name, value) => {
|
|
60
|
+
const v = value ?? '';
|
|
61
|
+
if (v === '')
|
|
62
|
+
return '';
|
|
63
|
+
if (isSafeValue(v, identity))
|
|
64
|
+
return v;
|
|
65
|
+
withheld.push(name);
|
|
66
|
+
return '';
|
|
67
|
+
};
|
|
68
|
+
const message = guard('message', input.message);
|
|
69
|
+
const article = guard('article', input.article);
|
|
70
|
+
const tool = guard('tool', input.tool);
|
|
71
|
+
const specIds = (input.specIds ?? []).filter((s) => typeof s === 'string' && SPEC_ID.test(s));
|
|
72
|
+
// Numbers only: prose in a measurement's place is how a narrative smuggles itself past the
|
|
73
|
+
// allowlist, and the reports worth having in this repository were always numbers.
|
|
74
|
+
const observations = (input.observations ?? []).filter((o) => o && typeof o.label === 'string'
|
|
75
|
+
&& typeof o.value === 'number' && Number.isFinite(o.value) && isSafeValue(o.label, identity));
|
|
76
|
+
const fingerprint = (0, advisory_outcomes_1.advisoryId)('field-report', article || tool || 'unknown', { kit: input.kit, harness: input.harness, os: input.os, message, article, tool, specIds });
|
|
77
|
+
return {
|
|
78
|
+
kit: input.kit, harness: input.harness, os: input.os, arch: input.arch, node: input.node,
|
|
79
|
+
message, article, tool, specIds, observations, fingerprint, withheld,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The bytes a human will read and paste. Anything withheld is named, never dropped in silence. */
|
|
83
|
+
function fieldReportBody(r) {
|
|
84
|
+
const lines = [
|
|
85
|
+
`holmes-kit ${r.kit} · ${r.harness} · ${r.os}/${r.arch} · node ${r.node}`,
|
|
86
|
+
'',
|
|
87
|
+
`fingerprint: ${r.fingerprint}`,
|
|
88
|
+
];
|
|
89
|
+
if (r.article)
|
|
90
|
+
lines.push(`article: ${r.article}`);
|
|
91
|
+
if (r.tool)
|
|
92
|
+
lines.push(`tool: ${r.tool}`);
|
|
93
|
+
if (r.specIds.length > 0)
|
|
94
|
+
lines.push(`specs: ${r.specIds.join(', ')}`);
|
|
95
|
+
if (r.message)
|
|
96
|
+
lines.push('', 'what holmes-kit said:', '', '> ' + r.message);
|
|
97
|
+
if (r.observations.length > 0) {
|
|
98
|
+
lines.push('', 'observed:');
|
|
99
|
+
for (const o of r.observations)
|
|
100
|
+
lines.push(`- ${o.label}: ${o.value}`);
|
|
101
|
+
}
|
|
102
|
+
if (r.withheld.length > 0) {
|
|
103
|
+
lines.push('', `withheld (contained a path, a credential or a machine identifier): ${r.withheld.join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('', 'This report states what was observed. It does not diagnose a cause.');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
@@ -37,6 +37,8 @@ exports.resolveSemanticKey = resolveSemanticKey;
|
|
|
37
37
|
exports.storeSemanticKey = storeSemanticKey;
|
|
38
38
|
exports.removeSemanticKey = removeSemanticKey;
|
|
39
39
|
// @implements A-SPEC-592
|
|
40
|
+
// @implements A-SPEC-549.4 — declared by that spec's Files to Touch and carries no Hangul; note that
|
|
41
|
+
// the guard's own SOURCES list does not cover this file (reported, not fixed here).
|
|
40
42
|
// @implements A-SPEC-477
|
|
41
43
|
/**
|
|
42
44
|
* The cloud tier's credential: WHERE the consent lives, and in what order it is looked up.
|
|
@@ -9,6 +9,20 @@ export interface Registry {
|
|
|
9
9
|
version: 1;
|
|
10
10
|
workspaces: WorkspaceEntry[];
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* @implements A-SPEC-678
|
|
14
|
+
* Where the registry lives — with one seam a test can reach.
|
|
15
|
+
*
|
|
16
|
+
* Measured 2026-09-18: inside jest, `process.env` is a plain object copy in the sandbox, so
|
|
17
|
+
* assigning `HOME` never reaches `setenv` and `os.homedir()` keeps answering the developer's real
|
|
18
|
+
* home. Plain node follows `$HOME`; jest does not. An in-process test therefore cannot isolate a
|
|
19
|
+
* module that calls `os.homedir()` directly — which is how 599 of this machine's 603 registry
|
|
20
|
+
* entries came to be test temp directories.
|
|
21
|
+
*
|
|
22
|
+
* jest DOES control `process.env`, so one lookup there is the smallest seam that works. Consumers
|
|
23
|
+
* set nothing and get `os.homedir()`; an empty value is not a setting.
|
|
24
|
+
*/
|
|
25
|
+
export declare function holmesHome(env: NodeJS.ProcessEnv, fallback: string): string;
|
|
12
26
|
export declare const EMPTY_REGISTRY: Registry;
|
|
13
27
|
/**
|
|
14
28
|
* @implements A-SPEC-543.1
|
|
@@ -28,8 +42,45 @@ export declare function mergeWorkspaceEntry(reg: Registry, entry: WorkspaceEntry
|
|
|
28
42
|
* Thin best-effort recorder: read → merge → write `~/.holmes/workspaces.json`. Every failure is
|
|
29
43
|
* swallowed (returns false) — a registry problem must never fail the init that feeds it.
|
|
30
44
|
*/
|
|
31
|
-
|
|
45
|
+
/**
|
|
46
|
+
* @implements A-SPEC-677 — the I/O a recorder needs. `exists` and `tmpdir` are injected so a pin can
|
|
47
|
+
* judge without depending on this machine's real paths.
|
|
48
|
+
*/
|
|
49
|
+
export interface RecordIo {
|
|
32
50
|
read: (p: string) => string;
|
|
33
51
|
write: (p: string, c: string) => void;
|
|
34
52
|
mkdir: (p: string) => void;
|
|
35
|
-
|
|
53
|
+
exists?: (p: string) => boolean;
|
|
54
|
+
tmpdir?: () => string;
|
|
55
|
+
onPrune?: (dropped: number) => void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* @implements A-SPEC-677
|
|
59
|
+
* Whether a target lives under the OS temp root — and therefore is ephemeral by construction.
|
|
60
|
+
*
|
|
61
|
+
* macOS spells that root two ways: `os.tmpdir()` answers `/var/folders/...` while a realpath gives
|
|
62
|
+
* `/private/var/folders/...`. Both appear in this machine's registry, so both are judged. A `/tmp`
|
|
63
|
+
* literal would have missed all 599 of them.
|
|
64
|
+
*
|
|
65
|
+
* An empty root filters NOTHING. When the judgement cannot be made, keeping an entry costs a line
|
|
66
|
+
* and dropping one the user wanted costs them something they cannot get back.
|
|
67
|
+
*/
|
|
68
|
+
export declare function isTempTarget(target: string, tmpdir: string): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* @implements A-SPEC-677
|
|
71
|
+
* Drop what has certainly gone; COUNT what merely cannot be seen.
|
|
72
|
+
*
|
|
73
|
+
* The asymmetry is the whole judgement. A vanished temp path is gone by construction — nothing
|
|
74
|
+
* re-creates a `mkdtemp` directory. A vanished normal path may be an unmounted volume, an external
|
|
75
|
+
* disk or a network share, and `upgrade` already skips it harmlessly with one line. Deleting it is
|
|
76
|
+
* the one outcome the user cannot undo, so absence outside the temp root is reported, never acted on.
|
|
77
|
+
*/
|
|
78
|
+
export declare function pruneRegistry(reg: Registry, p: {
|
|
79
|
+
exists(t: string): boolean;
|
|
80
|
+
isTemp(t: string): boolean;
|
|
81
|
+
}): {
|
|
82
|
+
registry: Registry;
|
|
83
|
+
dropped: number;
|
|
84
|
+
absent: number;
|
|
85
|
+
};
|
|
86
|
+
export declare function recordWorkspace(home: string, entry: WorkspaceEntry, io?: RecordIo): boolean;
|
|
@@ -34,12 +34,33 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.EMPTY_REGISTRY = void 0;
|
|
37
|
+
exports.holmesHome = holmesHome;
|
|
37
38
|
exports.readRegistry = readRegistry;
|
|
38
39
|
exports.mergeWorkspaceEntry = mergeWorkspaceEntry;
|
|
40
|
+
exports.isTempTarget = isTempTarget;
|
|
41
|
+
exports.pruneRegistry = pruneRegistry;
|
|
39
42
|
exports.recordWorkspace = recordWorkspace;
|
|
40
43
|
// @implements A-SPEC-543.1
|
|
41
44
|
const fs = __importStar(require("node:fs"));
|
|
45
|
+
const os = __importStar(require("node:os"));
|
|
42
46
|
const path = __importStar(require("node:path"));
|
|
47
|
+
/**
|
|
48
|
+
* @implements A-SPEC-678
|
|
49
|
+
* Where the registry lives — with one seam a test can reach.
|
|
50
|
+
*
|
|
51
|
+
* Measured 2026-09-18: inside jest, `process.env` is a plain object copy in the sandbox, so
|
|
52
|
+
* assigning `HOME` never reaches `setenv` and `os.homedir()` keeps answering the developer's real
|
|
53
|
+
* home. Plain node follows `$HOME`; jest does not. An in-process test therefore cannot isolate a
|
|
54
|
+
* module that calls `os.homedir()` directly — which is how 599 of this machine's 603 registry
|
|
55
|
+
* entries came to be test temp directories.
|
|
56
|
+
*
|
|
57
|
+
* jest DOES control `process.env`, so one lookup there is the smallest seam that works. Consumers
|
|
58
|
+
* set nothing and get `os.homedir()`; an empty value is not a setting.
|
|
59
|
+
*/
|
|
60
|
+
function holmesHome(env, fallback) {
|
|
61
|
+
const override = env.HOLMES_HOME;
|
|
62
|
+
return typeof override === 'string' && override.trim() !== '' ? override : fallback;
|
|
63
|
+
}
|
|
43
64
|
exports.EMPTY_REGISTRY = { version: 1, workspaces: [] };
|
|
44
65
|
/**
|
|
45
66
|
* @implements A-SPEC-543.1
|
|
@@ -79,16 +100,59 @@ function mergeWorkspaceEntry(reg, entry) {
|
|
|
79
100
|
};
|
|
80
101
|
}
|
|
81
102
|
/**
|
|
82
|
-
* @implements A-SPEC-
|
|
83
|
-
*
|
|
84
|
-
*
|
|
103
|
+
* @implements A-SPEC-677
|
|
104
|
+
* Whether a target lives under the OS temp root — and therefore is ephemeral by construction.
|
|
105
|
+
*
|
|
106
|
+
* macOS spells that root two ways: `os.tmpdir()` answers `/var/folders/...` while a realpath gives
|
|
107
|
+
* `/private/var/folders/...`. Both appear in this machine's registry, so both are judged. A `/tmp`
|
|
108
|
+
* literal would have missed all 599 of them.
|
|
109
|
+
*
|
|
110
|
+
* An empty root filters NOTHING. When the judgement cannot be made, keeping an entry costs a line
|
|
111
|
+
* and dropping one the user wanted costs them something they cannot get back.
|
|
85
112
|
*/
|
|
113
|
+
function isTempTarget(target, tmpdir) {
|
|
114
|
+
if (typeof target !== 'string' || typeof tmpdir !== 'string' || tmpdir.trim() === '')
|
|
115
|
+
return false;
|
|
116
|
+
const roots = new Set([tmpdir, tmpdir.startsWith('/private') ? tmpdir.slice('/private'.length) : `/private${tmpdir}`]);
|
|
117
|
+
return [...roots].some((r) => r !== '' && (target === r || target.startsWith(r.endsWith('/') ? r : `${r}/`)));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* @implements A-SPEC-677
|
|
121
|
+
* Drop what has certainly gone; COUNT what merely cannot be seen.
|
|
122
|
+
*
|
|
123
|
+
* The asymmetry is the whole judgement. A vanished temp path is gone by construction — nothing
|
|
124
|
+
* re-creates a `mkdtemp` directory. A vanished normal path may be an unmounted volume, an external
|
|
125
|
+
* disk or a network share, and `upgrade` already skips it harmlessly with one line. Deleting it is
|
|
126
|
+
* the one outcome the user cannot undo, so absence outside the temp root is reported, never acted on.
|
|
127
|
+
*/
|
|
128
|
+
function pruneRegistry(reg, p) {
|
|
129
|
+
let dropped = 0, absent = 0;
|
|
130
|
+
const workspaces = reg.workspaces.filter((w) => {
|
|
131
|
+
if (p.exists(w.target))
|
|
132
|
+
return true;
|
|
133
|
+
if (p.isTemp(w.target)) {
|
|
134
|
+
dropped += 1;
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
absent += 1;
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
return { registry: { version: 1, workspaces }, dropped, absent };
|
|
141
|
+
}
|
|
86
142
|
function recordWorkspace(home, entry, io = {
|
|
87
143
|
read: (p) => fs.readFileSync(p, 'utf8'),
|
|
88
144
|
write: (p, c) => fs.writeFileSync(p, c),
|
|
89
145
|
mkdir: (p) => { fs.mkdirSync(p, { recursive: true }); },
|
|
146
|
+
exists: (p) => fs.existsSync(p),
|
|
147
|
+
tmpdir: () => os.tmpdir(),
|
|
90
148
|
}) {
|
|
91
149
|
try {
|
|
150
|
+
// @implements A-SPEC-677 — an ephemeral target is not recorded at all. What never enters cannot
|
|
151
|
+
// pile up, and 599 of this machine's 603 entries entered exactly this way. Returning true is
|
|
152
|
+
// honest: nothing failed, there was simply nothing worth remembering.
|
|
153
|
+
const tmpdir = io.tmpdir?.() ?? '';
|
|
154
|
+
if (isTempTarget(entry.target, tmpdir))
|
|
155
|
+
return true;
|
|
92
156
|
const dir = path.join(home, '.holmes');
|
|
93
157
|
const file = path.join(dir, 'workspaces.json');
|
|
94
158
|
let raw = null;
|
|
@@ -98,9 +162,18 @@ function recordWorkspace(home, entry, io = {
|
|
|
98
162
|
catch {
|
|
99
163
|
raw = null;
|
|
100
164
|
}
|
|
101
|
-
const
|
|
165
|
+
const exists = io.exists;
|
|
166
|
+
// @implements A-SPEC-677 — prune while we are already here. Without an `exists` probe there is
|
|
167
|
+
// no way to tell gone from present, so the registry is merged unpruned rather than guessed at.
|
|
168
|
+
const base = exists
|
|
169
|
+
? pruneRegistry(readRegistry(raw), { exists, isTemp: (t) => isTempTarget(t, tmpdir) })
|
|
170
|
+
: { registry: readRegistry(raw), dropped: 0, absent: 0 };
|
|
171
|
+
const merged = mergeWorkspaceEntry(base.registry, entry);
|
|
102
172
|
io.mkdir(dir);
|
|
103
173
|
io.write(file, `${JSON.stringify(merged, null, 2)}\n`);
|
|
174
|
+
// Never a silent deletion: whoever asked can learn how many entries went.
|
|
175
|
+
if (base.dropped > 0)
|
|
176
|
+
io.onPrune?.(base.dropped);
|
|
104
177
|
return true;
|
|
105
178
|
}
|
|
106
179
|
catch {
|
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.24.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",
|
|
@@ -62,7 +62,8 @@
|
|
|
62
62
|
"testTimeout": 30000,
|
|
63
63
|
"setupFilesAfterEnv": [
|
|
64
64
|
"<rootDir>/src/holmes/test-support/jest-timeouts-setup.ts"
|
|
65
|
-
]
|
|
65
|
+
],
|
|
66
|
+
"maxWorkers": "50%"
|
|
66
67
|
},
|
|
67
68
|
"dependencies": {
|
|
68
69
|
"@modelcontextprotocol/sdk": "^1.29.0",
|