@holmes-lab/holmes-kit 0.13.0 → 0.15.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 +97 -0
- package/README.md +5 -3
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve.d.ts +14 -0
- package/dist/holmes/cli/approve.js +60 -3
- package/dist/holmes/cli/gitignore-merge.js +5 -0
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/governance/approval-queue.d.ts +33 -0
- package/dist/holmes/governance/approval-queue.js +94 -7
- package/dist/holmes/governance/autonomy.d.ts +1 -0
- package/dist/holmes/governance/autonomy.js +26 -1
- package/dist/holmes/guardrail/dependency-delta.d.ts +3 -0
- package/dist/holmes/guardrail/dependency-delta.js +118 -0
- package/dist/holmes/hooks/stop.d.ts +21 -0
- package/dist/holmes/hooks/stop.js +164 -2
- package/dist/holmes/mcp/handlers.js +19 -0
- package/dist/holmes/project/root.js +9 -1
- package/dist/holmes/review/test-outcomes.d.ts +17 -2
- package/dist/holmes/review/test-outcomes.js +54 -15
- package/dist/holmes/spec/approval-blockers.js +8 -0
- package/dist/holmes/spec/compat-impact.d.ts +26 -0
- package/dist/holmes/spec/compat-impact.js +140 -0
- package/dist/holmes/spec/spec-types.js +3 -1
- package/package.json +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-559.1
|
|
3
|
+
// @implements A-SPEC-560.1
|
|
4
|
+
// Pure external-dependency-delta detector: given a source file's content, extract its EXTERNAL
|
|
5
|
+
// (non-relative, "bare") import specifiers normalized to the top-level package, and report which
|
|
6
|
+
// external packages a before→after change newly introduces. The repo convention is `importTargetOf`'s
|
|
7
|
+
// (`guardrail/forbidden-edges.ts`): a `.`-prefixed specifier is relative (internal); anything else is
|
|
8
|
+
// external. Pure, deterministic, never throws — the git/env/queue wiring lives in the Stop hook.
|
|
9
|
+
//
|
|
10
|
+
// Hardened after the post-ship adversarial audit (REQ-560): prettier-style MULTILINE imports are the
|
|
11
|
+
// most common real-world form and the original line-anchored regex missed them entirely; CRLF files
|
|
12
|
+
// broke line-comment stripping into false deltas; a string-embedded `/*` swallowed real imports; and
|
|
13
|
+
// the un-anchored require()/import() patterns matched inside string literals (our own test fixtures
|
|
14
|
+
// self-triggered). The pipeline below is: normalize CRLF → strip line-START block comments only →
|
|
15
|
+
// strip template/triple-quoted literals → strip line comments (URL-preserving) → match, with a
|
|
16
|
+
// quote-parity rejection for require()/import() so string-embedded calls stay silent.
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.externalImportsOf = externalImportsOf;
|
|
19
|
+
exports.dependencyDelta = dependencyDelta;
|
|
20
|
+
// A new import of one of these is a language builtin, not an added third-party dependency, so it is
|
|
21
|
+
// NOT an architecture-drift signal. The lists are deliberately common-case, not exhaustive: a missed
|
|
22
|
+
// builtin only produces an observe-first warning, never a block.
|
|
23
|
+
const TS_BUILTINS = new Set([
|
|
24
|
+
'fs', 'path', 'os', 'crypto', 'util', 'events', 'stream', 'http', 'https', 'url', 'child_process',
|
|
25
|
+
'process', 'assert', 'buffer', 'net', 'tls', 'zlib', 'querystring', 'string_decoder', 'timers',
|
|
26
|
+
'tty', 'dgram', 'dns', 'readline', 'repl', 'vm', 'worker_threads', 'cluster', 'perf_hooks',
|
|
27
|
+
'async_hooks', 'v8', 'module', 'constants', 'punycode', 'inspector', 'diagnostics_channel', 'test',
|
|
28
|
+
]);
|
|
29
|
+
const PY_STDLIB = new Set([
|
|
30
|
+
'os', 'sys', 're', 'json', 'typing', 'pathlib', 'subprocess', 'time', 'datetime', 'collections',
|
|
31
|
+
'itertools', 'functools', 'math', 'random', 'logging', 'io', 'abc', 'enum', 'dataclasses', 'asyncio',
|
|
32
|
+
'contextlib', 'hashlib', 'base64', 'tempfile', 'shutil', 'glob', 'argparse', 'unittest', 'threading',
|
|
33
|
+
'queue', 'socket', 'struct', 'copy', 'warnings', 'traceback', 'inspect', 'textwrap', 'operator',
|
|
34
|
+
'importlib', 'urllib', 'pickle', 'csv', 'uuid', 'string', 'multiprocessing', 'concurrent', 'sqlite3',
|
|
35
|
+
'secrets', 'signal', 'types', 'ast', 'email', 'http', 'xml', 'array',
|
|
36
|
+
]);
|
|
37
|
+
/** Is position `idx` of `line` inside a single- or double-quoted string? (parity of quotes before it) */
|
|
38
|
+
function insideString(line, idx) {
|
|
39
|
+
let sq = 0, dq = 0;
|
|
40
|
+
for (let i = 0; i < idx; i++) {
|
|
41
|
+
const c = line[i];
|
|
42
|
+
if (c === "'")
|
|
43
|
+
sq += 1;
|
|
44
|
+
else if (c === '"')
|
|
45
|
+
dq += 1;
|
|
46
|
+
}
|
|
47
|
+
return sq % 2 === 1 || dq % 2 === 1;
|
|
48
|
+
}
|
|
49
|
+
/** Top-level package of a TS/JS specifier: `@scope/pkg/sub` → `@scope/pkg`, `pkg/sub` → `pkg`. */
|
|
50
|
+
function tsTopLevel(spec) {
|
|
51
|
+
const parts = spec.split('/');
|
|
52
|
+
return spec.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
53
|
+
}
|
|
54
|
+
function externalImportsOf(source, lang) {
|
|
55
|
+
const out = new Set();
|
|
56
|
+
try {
|
|
57
|
+
let src = source.replace(/\r\n?/g, '\n'); // CRLF/CR → LF first
|
|
58
|
+
if (lang === 'ts') {
|
|
59
|
+
// Line-START-anchored block comments only: kills a commented-out import block WITHOUT letting a
|
|
60
|
+
// string-embedded "/*" swallow the real code after it (and keeps the scan linear — the engine
|
|
61
|
+
// only attempts matches at line starts).
|
|
62
|
+
src = src.replace(/^[ \t]*\/\*[\s\S]*?\*\//gm, '');
|
|
63
|
+
src = src.replace(/`[^`]*`/g, '``'); // template literals are data
|
|
64
|
+
src = src.replace(/(?<!:)\/\/.*$/gm, ''); // line comments; `https://` survives
|
|
65
|
+
const add = (raw) => {
|
|
66
|
+
if (raw === undefined || raw === '' || raw.startsWith('.') || raw.startsWith('node:'))
|
|
67
|
+
return;
|
|
68
|
+
const pkg = tsTopLevel(raw);
|
|
69
|
+
if (pkg !== '' && !TS_BUILTINS.has(pkg))
|
|
70
|
+
out.add(pkg);
|
|
71
|
+
};
|
|
72
|
+
// A from-clause ENDS some line — any non-quote prefix, so prettier's `} from 'pkg'` is caught.
|
|
73
|
+
for (const m of src.matchAll(/^[^'"`\n]*\bfrom\s*['"]([^'"\n]+)['"]/gm))
|
|
74
|
+
add(m[1]);
|
|
75
|
+
for (const m of src.matchAll(/^\s*import\s*['"]([^'"\n]+)['"]/gm))
|
|
76
|
+
add(m[1]); // side-effect import
|
|
77
|
+
// require()/dynamic import() are position-free, so reject matches sitting inside a string
|
|
78
|
+
// literal (quote parity before the match on its line) and method calls (`loader.require`).
|
|
79
|
+
for (const line of src.split('\n')) {
|
|
80
|
+
for (const m of line.matchAll(/(?<!\.)\brequire\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) {
|
|
81
|
+
if (!insideString(line, m.index ?? 0))
|
|
82
|
+
add(m[1]);
|
|
83
|
+
}
|
|
84
|
+
for (const m of line.matchAll(/\bimport\s*\(\s*['"]([^'"\n]+)['"]\s*\)/g)) {
|
|
85
|
+
if (!insideString(line, m.index ?? 0))
|
|
86
|
+
add(m[1]);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
src = src.replace(/"""[\s\S]*?"""/g, '').replace(/'''[\s\S]*?'''/g, ''); // docstrings are data
|
|
92
|
+
src = src.split('\n').map((l) => l.replace(/#.*$/, '')).join('\n');
|
|
93
|
+
const add = (raw) => {
|
|
94
|
+
if (raw === undefined || raw === '' || raw.startsWith('.'))
|
|
95
|
+
return;
|
|
96
|
+
const pkg = raw.split('.')[0];
|
|
97
|
+
if (pkg !== '' && !PY_STDLIB.has(pkg))
|
|
98
|
+
out.add(pkg);
|
|
99
|
+
};
|
|
100
|
+
for (const m of src.matchAll(/^\s*import\s+(.+)$/gm)) {
|
|
101
|
+
// `import a, b as c` — every comma part counts; sanitize each to a leading dotted name so a
|
|
102
|
+
// trailing `;`/comment fragment can never fabricate a package token.
|
|
103
|
+
for (const part of m[1].split(','))
|
|
104
|
+
add(/^[A-Za-z_][\w.]*/.exec(part.trim())?.[0]);
|
|
105
|
+
}
|
|
106
|
+
for (const m of src.matchAll(/^\s*from\s+([.\w]+)\s+import\b/gm))
|
|
107
|
+
add(m[1]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return out; // never throw — observe-first
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function dependencyDelta(before, after, lang) {
|
|
116
|
+
const had = externalImportsOf(before, lang);
|
|
117
|
+
return [...externalImportsOf(after, lang)].filter((x) => !had.has(x)).sort();
|
|
118
|
+
}
|
|
@@ -114,6 +114,27 @@ export declare function changedAnchoredAspecs(root: string): string[] | undefine
|
|
|
114
114
|
* which produces no violation. Failing closed here would block every ungoverned scratch directory.
|
|
115
115
|
*/
|
|
116
116
|
export declare function unanchoredChangedSources(root: string): string[] | undefined;
|
|
117
|
+
export interface DependencyReappraisal {
|
|
118
|
+
file: string;
|
|
119
|
+
aspecs: string[];
|
|
120
|
+
packages: string[];
|
|
121
|
+
}
|
|
122
|
+
export interface ReappraisalIO {
|
|
123
|
+
status?: () => string | undefined;
|
|
124
|
+
show?: (rel: string) => string | undefined;
|
|
125
|
+
read?: (rel: string) => string | undefined;
|
|
126
|
+
}
|
|
127
|
+
export declare function dependencyReappraisals(root: string, isApproved: (id: string) => boolean, io?: ReappraisalIO): DependencyReappraisal[];
|
|
128
|
+
export declare function reappraisalWarningLine(reaps: DependencyReappraisal[]): string | null;
|
|
129
|
+
export declare function escalateReappraisals(root: string, reaps: DependencyReappraisal[], opts: {
|
|
130
|
+
autonomous: boolean;
|
|
131
|
+
enqueue: (root: string, req: {
|
|
132
|
+
kind: string;
|
|
133
|
+
target: string;
|
|
134
|
+
why: string;
|
|
135
|
+
}) => boolean;
|
|
136
|
+
readQueueLines?: (root: string) => string[];
|
|
137
|
+
}): number;
|
|
117
138
|
/**
|
|
118
139
|
* @implements A-SPEC-453
|
|
119
140
|
* ART-5 evidence: approvals that happened without the approving act.
|
|
@@ -36,6 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.MAX_CONSECUTIVE_BLOCKS = void 0;
|
|
37
37
|
exports.changedAnchoredAspecs = changedAnchoredAspecs;
|
|
38
38
|
exports.unanchoredChangedSources = unanchoredChangedSources;
|
|
39
|
+
exports.dependencyReappraisals = dependencyReappraisals;
|
|
40
|
+
exports.reappraisalWarningLine = reappraisalWarningLine;
|
|
41
|
+
exports.escalateReappraisals = escalateReappraisals;
|
|
39
42
|
exports.unrecordedApprovals = unrecordedApprovals;
|
|
40
43
|
exports.rolledBackLedgers = rolledBackLedgers;
|
|
41
44
|
exports.governanceLostPreflight = governanceLostPreflight;
|
|
@@ -50,6 +53,9 @@ exports.readGuardCount = readGuardCount;
|
|
|
50
53
|
exports.writeGuardCount = writeGuardCount;
|
|
51
54
|
const fs = __importStar(require("node:fs"));
|
|
52
55
|
const npx_bin_1 = require("../project/npx-bin");
|
|
56
|
+
const approval_queue_1 = require("../governance/approval-queue");
|
|
57
|
+
const dependency_delta_1 = require("../guardrail/dependency-delta");
|
|
58
|
+
const autonomy_1 = require("../governance/autonomy");
|
|
53
59
|
const risk_gate_1 = require("../guardrail/risk-gate");
|
|
54
60
|
const json_state_1 = require("../project/json-state");
|
|
55
61
|
const node_child_process_1 = require("node:child_process");
|
|
@@ -74,7 +80,9 @@ function changedAnchoredAspecs(root) {
|
|
|
74
80
|
const VENDORED = /^(?:reference|node_modules|dist|build|vendor|third_party)\//;
|
|
75
81
|
let raw;
|
|
76
82
|
try {
|
|
77
|
-
|
|
83
|
+
// @implements A-SPEC-560.3 — quotepath OFF, or a non-ASCII-named source arrives octal-escaped
|
|
84
|
+
// and its ART-8 evidence silently vanishes (shared defect with the reappraisal detector).
|
|
85
|
+
raw = (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'status', '--porcelain', '-uall'], {
|
|
78
86
|
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
|
|
79
87
|
});
|
|
80
88
|
}
|
|
@@ -120,7 +128,8 @@ function unanchoredChangedSources(root) {
|
|
|
120
128
|
try {
|
|
121
129
|
// `-uall`: without it git folds a wholly-untracked directory into one `?? src/` line and the
|
|
122
130
|
// files inside it are never seen — which is precisely where a bypassed write lands.
|
|
123
|
-
|
|
131
|
+
// @implements A-SPEC-560.3 — quotepath OFF (same octal-escape blindness as the siblings).
|
|
132
|
+
raw = (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'status', '--porcelain', '-uall'], {
|
|
124
133
|
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
|
|
125
134
|
});
|
|
126
135
|
}
|
|
@@ -148,6 +157,141 @@ function unanchoredChangedSources(root) {
|
|
|
148
157
|
}
|
|
149
158
|
return out;
|
|
150
159
|
}
|
|
160
|
+
const REAPPRAISAL_VENDORED = /^(?:reference|node_modules|dist|build|vendor|third_party)\//;
|
|
161
|
+
function depLangOf(rel) {
|
|
162
|
+
if (/\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(rel))
|
|
163
|
+
return 'ts';
|
|
164
|
+
if (/\.py$/.test(rel))
|
|
165
|
+
return 'py';
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
function dependencyReappraisals(root, isApproved, io = {}) {
|
|
169
|
+
// @implements A-SPEC-560.3 — quotepath OFF: with the default core.quotepath, a Korean- (or any
|
|
170
|
+
// non-ASCII/quote-) named file arrives octal-escaped, the read then ENOENTs, and the signal is
|
|
171
|
+
// silently lost. Measured: 2 of 4 drifting files detected before this flag.
|
|
172
|
+
const status = io.status ?? (() => {
|
|
173
|
+
try {
|
|
174
|
+
return (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'status', '--porcelain', '-uall'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const show = io.show ?? ((rel) => {
|
|
181
|
+
try {
|
|
182
|
+
return (0, node_child_process_1.execFileSync)('git', ['-c', 'core.quotepath=false', 'show', `HEAD:${rel}`], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() });
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
const read = io.read ?? ((rel) => {
|
|
189
|
+
try {
|
|
190
|
+
return fs.readFileSync(path.join(root, rel), 'utf8');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
let raw;
|
|
197
|
+
try {
|
|
198
|
+
raw = status();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
raw = undefined;
|
|
202
|
+
}
|
|
203
|
+
if (raw === undefined)
|
|
204
|
+
return [];
|
|
205
|
+
const out = [];
|
|
206
|
+
for (const line of raw.split('\n')) {
|
|
207
|
+
if (line.trim() === '')
|
|
208
|
+
continue;
|
|
209
|
+
let rel = line.slice(3).trim().replace(/^"|"$/g, '');
|
|
210
|
+
if (rel.includes(' -> '))
|
|
211
|
+
rel = rel.split(' -> ')[1]; // renames name the destination
|
|
212
|
+
const lang = depLangOf(rel);
|
|
213
|
+
if (lang === undefined || REAPPRAISAL_VENDORED.test(rel))
|
|
214
|
+
continue;
|
|
215
|
+
let after;
|
|
216
|
+
try {
|
|
217
|
+
after = read(rel);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
after = undefined;
|
|
221
|
+
}
|
|
222
|
+
if (after === undefined)
|
|
223
|
+
continue; // deleted / unreadable
|
|
224
|
+
const aspecs = [...new Set([...after.matchAll(/@implements\s+(A-SPEC-\d+(?:\.\d+)?)/g)].map((m) => m[1]))].filter(isApproved).sort();
|
|
225
|
+
if (aspecs.length === 0)
|
|
226
|
+
continue; // not inside an APPROVED A-SPEC scope
|
|
227
|
+
let before;
|
|
228
|
+
try {
|
|
229
|
+
before = show(rel);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
before = undefined;
|
|
233
|
+
}
|
|
234
|
+
if (before === undefined)
|
|
235
|
+
continue; // no HEAD version = a new file, not in-scope drift
|
|
236
|
+
let packages = [];
|
|
237
|
+
try {
|
|
238
|
+
packages = (0, dependency_delta_1.dependencyDelta)(before, after, lang);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
packages = [];
|
|
242
|
+
}
|
|
243
|
+
if (packages.length > 0)
|
|
244
|
+
out.push({ file: rel, aspecs, packages });
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
function reappraisalWarningLine(reaps) {
|
|
249
|
+
if (reaps.length === 0)
|
|
250
|
+
return null;
|
|
251
|
+
return reaps.map((r) => `[Holmes-Kit] spec-reappraisal: ${r.file} introduces external dependency ${r.packages.join(', ')} inside approved ${r.aspecs.join(', ')} scope — this looks like an architecture change; update the spec or open a sub-slice.`).join('\n');
|
|
252
|
+
}
|
|
253
|
+
// @implements A-SPEC-560.3 — decision-respecting escalation: a reappraisal the owner already decided
|
|
254
|
+
// (granted/denied in the queue ledger) must not re-pend every turn while the tree stays dirty, and an
|
|
255
|
+
// already-pending one needs no duplicate append. A reader failure fails OPEN (the signal survives).
|
|
256
|
+
function escalateReappraisals(root, reaps, opts) {
|
|
257
|
+
if (!opts.autonomous)
|
|
258
|
+
return 0;
|
|
259
|
+
const readLines = opts.readQueueLines ?? ((r) => fs.readFileSync(path.join(r, approval_queue_1.QUEUE_RELPATH), 'utf8').split('\n'));
|
|
260
|
+
let lines;
|
|
261
|
+
try {
|
|
262
|
+
lines = readLines(root);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
lines = [];
|
|
266
|
+
} // a broken reader fails OPEN — the signal survives
|
|
267
|
+
let n = 0;
|
|
268
|
+
for (const r of reaps) {
|
|
269
|
+
try {
|
|
270
|
+
const id = (0, approval_queue_1.approvalRequestId)('spec-reappraisal', r.file);
|
|
271
|
+
let decided = false;
|
|
272
|
+
let lastEvent;
|
|
273
|
+
for (const raw of lines) {
|
|
274
|
+
if (raw.trim() === '')
|
|
275
|
+
continue;
|
|
276
|
+
try {
|
|
277
|
+
const e = JSON.parse(raw);
|
|
278
|
+
if (e.id !== id)
|
|
279
|
+
continue;
|
|
280
|
+
lastEvent = String(e.event);
|
|
281
|
+
if (lastEvent === 'granted' || lastEvent === 'denied')
|
|
282
|
+
decided = true;
|
|
283
|
+
}
|
|
284
|
+
catch { /* a corrupt line is not this id's history */ }
|
|
285
|
+
}
|
|
286
|
+
if (decided || lastEvent === 'requested')
|
|
287
|
+
continue; // decided, or already pending — no re-append
|
|
288
|
+
if (opts.enqueue(root, { kind: 'spec-reappraisal', target: r.file, why: `new external dependency ${r.packages.join(', ')} inside approved ${r.aspecs.join(', ')} — spec update or sub-slice needed` }))
|
|
289
|
+
n += 1;
|
|
290
|
+
}
|
|
291
|
+
catch { /* enqueue failure is best-effort; the warning still surfaced */ }
|
|
292
|
+
}
|
|
293
|
+
return n;
|
|
294
|
+
}
|
|
151
295
|
/**
|
|
152
296
|
* @implements A-SPEC-453
|
|
153
297
|
* ART-5 evidence: approvals that happened without the approving act.
|
|
@@ -769,6 +913,24 @@ if (require.main === module) {
|
|
|
769
913
|
let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack, redFirstMode, changedAspecs, outcomesByAspec });
|
|
770
914
|
// @implements A-SPEC-534.4 — track mode records ART-8 findings without blocking: surface them so
|
|
771
915
|
// the operator observes RED-first gaps before an owner promotes the posture to strict.
|
|
916
|
+
// @implements A-SPEC-559.2 — spec-evolution trigger (observe-first, NEVER blocks): a dirty
|
|
917
|
+
// in-scope source that newly introduces an external dependency is an architecture-drift signal.
|
|
918
|
+
// Manual mode warns; autonomy mode also enqueues it so the owner sees the drift async. Fail-open.
|
|
919
|
+
// @implements A-SPEC-560.3 — placed BEFORE the guard decisions and their early returns, so a
|
|
920
|
+
// degraded-yield turn (guard state unwritable) still surfaces the drift; stderr-only, so no
|
|
921
|
+
// verdict path is affected.
|
|
922
|
+
try {
|
|
923
|
+
const approvedIds = new Set(specs.filter((s) => s.type === 'A-SPEC' && s.status === 'approved').map((s) => s.id));
|
|
924
|
+
const reaps = dependencyReappraisals(stopProjectRoot(), (id) => approvedIds.has(id));
|
|
925
|
+
const line = reappraisalWarningLine(reaps);
|
|
926
|
+
if (line)
|
|
927
|
+
process.stderr.write(`${line}\n`);
|
|
928
|
+
const nowIso = new Date().toISOString();
|
|
929
|
+
if (line && (0, autonomy_1.autonomousApprovalEnabled)(process.env, stopProjectRoot(), nowIso)) {
|
|
930
|
+
escalateReappraisals(stopProjectRoot(), reaps, { autonomous: true, enqueue: approval_queue_1.enqueueApprovalRequest });
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
catch { /* the reappraisal signal is advisory; a failure never affects the stop verdict */ }
|
|
772
934
|
if (out.tracked && out.tracked.length > 0) {
|
|
773
935
|
process.stderr.write(`[Holmes-Kit] ART-8 RED-first (track): ${out.tracked.map((t) => t.detail).join(' | ')}\n`);
|
|
774
936
|
}
|
|
@@ -189,6 +189,7 @@ const approval_grants_1 = require("../governance/approval-grants");
|
|
|
189
189
|
const spec_digest_1 = require("../spec/spec-digest");
|
|
190
190
|
const spec_store_2 = require("../spec/spec-store");
|
|
191
191
|
const breaking_change_1 = require("../spec/breaking-change");
|
|
192
|
+
const compat_impact_1 = require("../spec/compat-impact");
|
|
192
193
|
const approval_blockers_1 = require("../spec/approval-blockers");
|
|
193
194
|
const approval_status_1 = require("../spec/approval-status");
|
|
194
195
|
const ledger_timeline_1 = require("../governance/ledger-timeline");
|
|
@@ -1560,6 +1561,22 @@ function makeRawHandlers(store, opts) {
|
|
|
1560
1561
|
const breakingIssue = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
|
|
1561
1562
|
if (breakingIssue)
|
|
1562
1563
|
return { ok: false, reason: breakingIssue };
|
|
1564
|
+
// @implements A-SPEC-565.1 — the compat declaration duty rides the SAME act (REQ-565): sealing
|
|
1565
|
+
// is when "did you consider the three harnesses and the three OSes" is due, and act-time is
|
|
1566
|
+
// what keeps 512 already-approved specs out of retroactive violation (the 38-violation incident
|
|
1567
|
+
// above). The bound reader feeds the OS cross-check from this root's working tree.
|
|
1568
|
+
const compatIssue = (0, compat_impact_1.checkCompatDeclared)(candidate, {
|
|
1569
|
+
// No root → no working tree to read: the OS cross-check skips file-by-file (fail-open),
|
|
1570
|
+
// while the declaration syntax itself is still enforced — the duty never depends on `root`.
|
|
1571
|
+
readFile: (rel) => { try {
|
|
1572
|
+
return a.root ? fs.readFileSync(path.join(a.root, rel), 'utf8') : null;
|
|
1573
|
+
}
|
|
1574
|
+
catch {
|
|
1575
|
+
return null;
|
|
1576
|
+
} },
|
|
1577
|
+
});
|
|
1578
|
+
if (compatIssue)
|
|
1579
|
+
return { ok: false, reason: compatIssue };
|
|
1563
1580
|
// @implements A-SPEC-182
|
|
1564
1581
|
// A document whose prose is still the generator's placeholder must not be sealed. Measured
|
|
1565
1582
|
// 2026-08-13 on a brownfield adoption: H-SPEC-100 took `status: approved` and an
|
|
@@ -3347,6 +3364,8 @@ independent_test: true
|
|
|
3347
3364
|
depends_on:
|
|
3348
3365
|
- ${hspecId}
|
|
3349
3366
|
breaking_change: 'none'
|
|
3367
|
+
harness_impact: 'none: TODO — 3하네스(claude/codex/agy) 영향 검토 후 기술'
|
|
3368
|
+
os_impact: 'none: TODO — 3OS(windows/mac/linux) 영향 검토 후 기술'
|
|
3350
3369
|
---
|
|
3351
3370
|
|
|
3352
3371
|
## Objective
|
|
@@ -77,7 +77,15 @@ function cleanSubprocessEnv(env = process.env) {
|
|
|
77
77
|
* evidence run would disarm every test that verifies a gate, and that contamination masks red as
|
|
78
78
|
* green — the worse direction of the two.
|
|
79
79
|
*/
|
|
80
|
-
|
|
80
|
+
// @implements A-SPEC-561.1
|
|
81
|
+
// `HOLMES_AUTONOMOUS_APPROVAL` joins the list for the SAME reason, pointing the same way as the
|
|
82
|
+
// HOLMES_SPECS incident above: measured 2026-09-06, a project that opted into autonomy
|
|
83
|
+
// (`init --autonomy` persists the switch into .mcp.json) starts its MCP server with that env, the
|
|
84
|
+
// server's `test_run` handed it to the jest children, and REQ-551's autonomy branch then skipped the
|
|
85
|
+
// elicitation path — 9 elicitation tests red on a tree whose full suite was green, twice, and
|
|
86
|
+
// identically on the previous commit. A posture is an approval channel, never a property of the code
|
|
87
|
+
// under test; an evidence run must judge the same in an autonomous workspace and a human-gated one.
|
|
88
|
+
const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE', 'HOLMES_AUTONOMOUS_APPROVAL']);
|
|
81
89
|
function cleanTestEnv(env = process.env) {
|
|
82
90
|
const cleaned = cleanSubprocessEnv(env);
|
|
83
91
|
for (const k of Object.keys(cleaned)) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { TestOutcome } from './test-runner';
|
|
2
2
|
export declare const OUTCOMES_FILE: string;
|
|
3
|
+
export declare function outcomesFilename(replica: string): string;
|
|
4
|
+
export declare function isOutcomesFilename(name: string): boolean;
|
|
3
5
|
/**
|
|
4
6
|
* A durable, append-only record of a per-A-SPEC test outcome (REQ-534 RED-first evidence). Unlike
|
|
5
7
|
* `test-evidence.json` (overwritten each run), the SEQUENCE matters here — a red-assertion followed
|
|
@@ -14,9 +16,22 @@ export interface OutcomeRecord {
|
|
|
14
16
|
head: string;
|
|
15
17
|
testFileDigest?: string;
|
|
16
18
|
}
|
|
17
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Append outcome records as JSONL lines. Fail-open (recording must never break a run).
|
|
21
|
+
*
|
|
22
|
+
* @implements A-SPEC-562.1 — writes to THIS machine's chain (`test-outcomes.<replica>.jsonl`), never
|
|
23
|
+
* to the shared legacy file, so two machines appending in parallel produce two files git merges
|
|
24
|
+
* without a conflict. The call site is unchanged; only the destination moved.
|
|
25
|
+
*/
|
|
18
26
|
export declare function appendOutcomes(root: string, records: OutcomeRecord[]): boolean;
|
|
19
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers).
|
|
29
|
+
*
|
|
30
|
+
* @implements A-SPEC-562.1 — reads the legacy file AND every replica chain, merged by `ts`. The sort
|
|
31
|
+
* is what keeps ART-8 honest after a merge: the article reads a red-assertion FOLLOWED BY a green, and
|
|
32
|
+
* with two machines that pair can straddle two files. A stable sort keeps same-timestamp records in
|
|
33
|
+
* read order rather than inventing an ordering between them.
|
|
34
|
+
*/
|
|
20
35
|
export declare function readOutcomes(root: string): OutcomeRecord[];
|
|
21
36
|
/**
|
|
22
37
|
* @implements A-SPEC-534.5
|
|
@@ -34,6 +34,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.OUTCOMES_FILE = void 0;
|
|
37
|
+
exports.outcomesFilename = outcomesFilename;
|
|
38
|
+
exports.isOutcomesFilename = isOutcomesFilename;
|
|
37
39
|
exports.appendOutcomes = appendOutcomes;
|
|
38
40
|
exports.readOutcomes = readOutcomes;
|
|
39
41
|
exports.buildOutcomeRecords = buildOutcomeRecords;
|
|
@@ -41,11 +43,30 @@ exports.groupOutcomesByAspec = groupOutcomesByAspec;
|
|
|
41
43
|
// @implements A-SPEC-534.3
|
|
42
44
|
const fs = __importStar(require("node:fs"));
|
|
43
45
|
const path = __importStar(require("node:path"));
|
|
46
|
+
const replica_id_1 = require("../governance/replica-id");
|
|
44
47
|
exports.OUTCOMES_FILE = path.join('.ax', 'ledger', 'test-outcomes.jsonl');
|
|
45
|
-
|
|
48
|
+
// @implements A-SPEC-562.1 — outcomes join the ledger's REPLICA convention (A-SPEC-148): one file per
|
|
49
|
+
// machine, so two machines appending never conflict on merge and a colleague's clone carries both
|
|
50
|
+
// histories. The shape mirrors `isLedgerFilename` exactly — legacy single file OR a dot-free segment —
|
|
51
|
+
// because two rules disagreeing about the same file is worse than either being wrong. Wired below.
|
|
52
|
+
const OUTCOMES_LEGACY = 'test-outcomes.jsonl';
|
|
53
|
+
const OUTCOMES_REPLICA_FILE = /^test-outcomes\.([^.]+)\.jsonl$/;
|
|
54
|
+
function outcomesFilename(replica) {
|
|
55
|
+
return `test-outcomes.${replica}.jsonl`;
|
|
56
|
+
}
|
|
57
|
+
function isOutcomesFilename(name) {
|
|
58
|
+
return name === OUTCOMES_LEGACY || OUTCOMES_REPLICA_FILE.test(name);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Append outcome records as JSONL lines. Fail-open (recording must never break a run).
|
|
62
|
+
*
|
|
63
|
+
* @implements A-SPEC-562.1 — writes to THIS machine's chain (`test-outcomes.<replica>.jsonl`), never
|
|
64
|
+
* to the shared legacy file, so two machines appending in parallel produce two files git merges
|
|
65
|
+
* without a conflict. The call site is unchanged; only the destination moved.
|
|
66
|
+
*/
|
|
46
67
|
function appendOutcomes(root, records) {
|
|
47
68
|
try {
|
|
48
|
-
const file = path.join(root,
|
|
69
|
+
const file = path.join(root, '.ax', 'ledger', outcomesFilename((0, replica_id_1.resolveReplicaId)(root)));
|
|
49
70
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
50
71
|
fs.appendFileSync(file, records.map((r) => `${JSON.stringify(r)}\n`).join(''));
|
|
51
72
|
return true;
|
|
@@ -54,30 +75,48 @@ function appendOutcomes(root, records) {
|
|
|
54
75
|
return false;
|
|
55
76
|
}
|
|
56
77
|
}
|
|
57
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Read every outcome record; missing file → [], broken lines skipped (same convention as the other ledgers).
|
|
80
|
+
*
|
|
81
|
+
* @implements A-SPEC-562.1 — reads the legacy file AND every replica chain, merged by `ts`. The sort
|
|
82
|
+
* is what keeps ART-8 honest after a merge: the article reads a red-assertion FOLLOWED BY a green, and
|
|
83
|
+
* with two machines that pair can straddle two files. A stable sort keeps same-timestamp records in
|
|
84
|
+
* read order rather than inventing an ordering between them.
|
|
85
|
+
*/
|
|
58
86
|
function readOutcomes(root) {
|
|
59
|
-
|
|
87
|
+
const dir = path.join(root, '.ax', 'ledger');
|
|
88
|
+
let names;
|
|
60
89
|
try {
|
|
61
|
-
|
|
90
|
+
names = fs.readdirSync(dir).filter(isOutcomesFilename).sort();
|
|
62
91
|
}
|
|
63
92
|
catch {
|
|
64
93
|
return [];
|
|
65
94
|
}
|
|
66
95
|
const out = [];
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
continue;
|
|
96
|
+
let i = 0;
|
|
97
|
+
for (const name of names) {
|
|
98
|
+
let text;
|
|
71
99
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
100
|
+
text = fs.readFileSync(path.join(dir, name), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const line of text.split('\n')) {
|
|
106
|
+
const s = line.trim();
|
|
107
|
+
if (!s)
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
const r = JSON.parse(s);
|
|
111
|
+
if (r && typeof r === 'object' && typeof r.aspec === 'string' && typeof r.outcome === 'string'
|
|
112
|
+
&& typeof r.ts === 'string' && typeof r.head === 'string') {
|
|
113
|
+
out.push({ r: r, i: i++ });
|
|
114
|
+
}
|
|
76
115
|
}
|
|
116
|
+
catch { /* skip a corrupt line rather than fail the whole read */ }
|
|
77
117
|
}
|
|
78
|
-
catch { /* skip a corrupt line rather than fail the whole read */ }
|
|
79
118
|
}
|
|
80
|
-
return out;
|
|
119
|
+
return out.sort((a, b) => (a.r.ts < b.r.ts ? -1 : a.r.ts > b.r.ts ? 1 : a.i - b.i)).map((e) => e.r);
|
|
81
120
|
}
|
|
82
121
|
/**
|
|
83
122
|
* @implements A-SPEC-534.5
|
|
@@ -10,6 +10,7 @@ exports.unactionableCriteriaBlocker = unactionableCriteriaBlocker;
|
|
|
10
10
|
const acceptance_quality_1 = require("./acceptance-quality");
|
|
11
11
|
const validator_1 = require("./validator");
|
|
12
12
|
const breaking_change_1 = require("./breaking-change");
|
|
13
|
+
const compat_impact_1 = require("./compat-impact");
|
|
13
14
|
const spec_digest_1 = require("./spec-digest");
|
|
14
15
|
const spec_types_1 = require("./spec-types");
|
|
15
16
|
const draft_1 = require("../reverse/draft");
|
|
@@ -204,6 +205,13 @@ function approvalBlockers(spec, resolve) {
|
|
|
204
205
|
const breaking = (0, breaking_change_1.checkBreakingChangeDeclared)(candidate);
|
|
205
206
|
if (breaking)
|
|
206
207
|
out.push(breaking);
|
|
208
|
+
// @implements A-SPEC-565.1 — the compat duty pre-announced where breaking_change is: whatever the
|
|
209
|
+
// act refuses with, the gate must already have told the author (A-SPEC-182's parity). No file
|
|
210
|
+
// reader here — the gate context has no root — so the OS *content* cross-check stays act-only;
|
|
211
|
+
// the declaration syntax and the FtT harness-surface contradiction are fully pre-announced.
|
|
212
|
+
const compat = (0, compat_impact_1.checkCompatDeclared)(candidate);
|
|
213
|
+
if (compat)
|
|
214
|
+
out.push(compat);
|
|
207
215
|
const stubs = placeholderSections(spec);
|
|
208
216
|
if (stubs.length > 0) {
|
|
209
217
|
out.push((0, exports.placeholderMessage)(stubs));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Spec } from './spec-parser';
|
|
2
|
+
/**
|
|
3
|
+
* REQ-565's enforcement device: the obligation to declare HARNESS (claude/codex/agy) and OS
|
|
4
|
+
* (windows/mac/linux) impact lives on the ACT of approval — the exact shape ADR-013 gave
|
|
5
|
+
* `breaking_change`, and for the same measured reason (a static `requiredFields` predicate turned
|
|
6
|
+
* 38 already-approved specs into ART-3 violations and bricked the harness; act-time converges
|
|
7
|
+
* instead: any spec that changes re-approves and acquires the fields, a spec that never changes
|
|
8
|
+
* can introduce no new incompatibility).
|
|
9
|
+
*
|
|
10
|
+
* WHY a gate and not a habit, measured twice in one day: an observability design came out
|
|
11
|
+
* Claude-biased (Stop hook + transcript_path is a Claude-only channel — the owner caught it), and
|
|
12
|
+
* the Stop hook's own header records that voluntarily-invoked discipline fired 0/143 times. Memory
|
|
13
|
+
* is rationale storage; control is a gate.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS DOES NOT DO: verify the declarations are TRUE. Truth belongs to the layers that measure
|
|
16
|
+
* it — adapter parity (A-SPEC-336~338), doctor's wiring checks, on-device E2E. This gate makes
|
|
17
|
+
* skipping the thought impossible and makes a false declaration an auditable record.
|
|
18
|
+
*/
|
|
19
|
+
export declare const HARNESS_CELLS: readonly ["claude", "codex", "agy"];
|
|
20
|
+
export declare const OS_CELLS: readonly ["windows", "mac", "linux"];
|
|
21
|
+
export declare const CELL_VERDICTS: readonly ["supported", "unavailable", "n-a"];
|
|
22
|
+
export interface CompatCheckOpts {
|
|
23
|
+
/** Repo-relative reader for the OS cross-check; null = file absent (skip, fail-open). */
|
|
24
|
+
readFile?: (rel: string) => string | null;
|
|
25
|
+
}
|
|
26
|
+
export declare function checkCompatDeclared(spec: Spec, opts?: CompatCheckOpts): string | null;
|