@holmes-lab/holmes-kit 0.1.18 → 0.2.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 +18 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/approve-context.d.ts +2 -0
- package/dist/holmes/cli/approve-context.js +180 -0
- package/dist/holmes/cli/approve-ref.d.ts +27 -0
- package/dist/holmes/cli/approve-ref.js +40 -0
- package/dist/holmes/cli/approve-watch.d.ts +29 -0
- package/dist/holmes/cli/approve-watch.js +94 -0
- package/dist/holmes/cli/approve.d.ts +50 -13
- package/dist/holmes/cli/approve.js +354 -38
- package/dist/holmes/cli/doctor.js +182 -0
- package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
- package/dist/holmes/cli/gitignore-merge.js +17 -1
- package/dist/holmes/cli/index.d.ts +23 -0
- package/dist/holmes/cli/index.js +487 -20
- package/dist/holmes/cli/init.js +14 -0
- package/dist/holmes/cli/screen-safe.d.ts +94 -0
- package/dist/holmes/cli/screen-safe.js +760 -0
- package/dist/holmes/governance/approval-queue.js +56 -4
- package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
- package/dist/holmes/governance/ledger-rechain.js +95 -0
- package/dist/holmes/governance/provenance-chain.d.ts +33 -6
- package/dist/holmes/governance/provenance-chain.js +91 -16
- package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
- package/dist/holmes/governance/provenance-ledger.js +10 -0
- package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
- package/dist/holmes/guardrail/risk-gate.js +10 -0
- package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
- package/dist/holmes/mcp/elicit-approval.js +79 -0
- package/dist/holmes/mcp/handlers.d.ts +7 -2
- package/dist/holmes/mcp/handlers.js +190 -24
- package/dist/holmes/mcp/server.js +26 -1
- package/dist/holmes/spec/id-collision.d.ts +39 -0
- package/dist/holmes/spec/id-collision.js +86 -0
- package/dist/holmes/spec/spec-store.js +9 -1
- package/package.json +1 -1
|
@@ -407,6 +407,164 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
407
407
|
: `상위가 approved가 아닌 승인 스펙 ${orphaned.length}건: ${orphaned.map((s) => s.id).slice(0, 5).join(', ')}`);
|
|
408
408
|
}
|
|
409
409
|
catch { /* an unreadable corpus is doctor's other checks' business, not this one's */ }
|
|
410
|
+
// @implements A-SPEC-254.1
|
|
411
|
+
// Distributed id preemption (the 2026-08-23 REQ-220 incident): two offline workspaces issue the
|
|
412
|
+
// same number, silent at create and at push. Judgment lives in the ONE pure detector; identity is
|
|
413
|
+
// the CANONICAL spec digest (specDigest: CRLF-folded, SEAL_FIELDS excluded — round-1: raw bytes
|
|
414
|
+
// turned a CRLF checkout difference into a fake collision, and trusting the self-declared seal
|
|
415
|
+
// hid a real one). The existing `duplicate spec ids` check above answers a different question
|
|
416
|
+
// (id count) and stays untouched. OWN try/catch (round-1): sharing the block above meant an
|
|
417
|
+
// unrelated upstream throw silently erased these checks — a failure here must surface as WARN,
|
|
418
|
+
// never as a vanished line.
|
|
419
|
+
try {
|
|
420
|
+
const { detectIdCollisions } = require('../spec/id-collision');
|
|
421
|
+
const { parseSpec } = require('../spec/spec-parser');
|
|
422
|
+
const crypto = require('node:crypto');
|
|
423
|
+
const specsRoot = path.join(target, '.ax', 'specs');
|
|
424
|
+
const entries = [];
|
|
425
|
+
let skipped = 0; // would-be specs present but not judged — a PASS must name its reduced scope (round-1)
|
|
426
|
+
// The collision identity is the WHOLE BODY, canonicalized — not a composite of parser splits
|
|
427
|
+
// (round-3): the round-2 specDigest+preamble composite inherited every split blind spot one at
|
|
428
|
+
// a time (preamble missing → round-2; fence-gap text in NEITHER half → round-3), and hashing
|
|
429
|
+
// the preamble raw made whitespace-only byte differences collide with a false forgery
|
|
430
|
+
// accusation on top. One key over everything after the frontmatter ends the class: CRLF folded
|
|
431
|
+
// (mirrors parseSpec), per-line trailing whitespace and blank lines dropped (formatting is not
|
|
432
|
+
// substance for an advisory WARN), plus the substantive frontmatter (type/title/dependsOn) via
|
|
433
|
+
// JSON array serialization — no field aliasing. The SEAL digest is untouched, as before.
|
|
434
|
+
const collisionKey = (folded, spec) => {
|
|
435
|
+
const fence = /^---\n[\s\S]*?\n---\n?/.exec(folded);
|
|
436
|
+
const body = (fence ? folded.slice(fence[0].length) : folded)
|
|
437
|
+
.split('\n').map((l) => l.trimEnd()).filter((l) => l !== '').join('\n');
|
|
438
|
+
return 'sha256:' + crypto.createHash('sha256')
|
|
439
|
+
.update(JSON.stringify([spec.type, spec.title, [...spec.dependsOn].sort(), body])).digest('hex');
|
|
440
|
+
};
|
|
441
|
+
// Symlinked directories are FOLLOWED (round-2): `Dirent.isDirectory()` is false for a dir
|
|
442
|
+
// symlink, so specs behind one silently left the judgment with skipped=0 — an unqualified PASS
|
|
443
|
+
// over an unexamined store. The realpath visited-set breaks symlink cycles. An UNREADABLE or
|
|
444
|
+
// un-realpathable directory counts into `skipped` (round-3) — a subtree the walk could not
|
|
445
|
+
// enter is an unexamined region, and a PASS must never silently span it.
|
|
446
|
+
const seen = new Set();
|
|
447
|
+
const collect = (d) => {
|
|
448
|
+
let real;
|
|
449
|
+
// "Nothing there" is not "something escaped judgment" (round-4, extended round-7): a
|
|
450
|
+
// NONEXISTENT (ENOENT) or path-broken (ENOTDIR — .ax created as a stray FILE;
|
|
451
|
+
// ENAMETOOLONG) root/dir holds nothing that could have been judged, so counting it stamped
|
|
452
|
+
// a phantom '판정 밖 1건' on pre-init targets, indistinguishable from a genuinely
|
|
453
|
+
// permission-locked store. Real refusals (EACCES) and cycles (ELOOP — an entry exists, it
|
|
454
|
+
// just cannot be entered) still count as unexamined regions.
|
|
455
|
+
try {
|
|
456
|
+
real = fs.realpathSync(d);
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
const code = err.code;
|
|
460
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ENAMETOOLONG')
|
|
461
|
+
skipped++;
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (seen.has(real))
|
|
465
|
+
return;
|
|
466
|
+
seen.add(real);
|
|
467
|
+
let dirents;
|
|
468
|
+
// The FOURTH errno site of the absence class (round-8): a regular FILE at the specs path
|
|
469
|
+
// (realpath succeeds, readdir throws ENOTDIR) holds nothing judgeable — same doctrine as a
|
|
470
|
+
// TOCTOU-deleted dir (ENOENT, round-5). EACCES stays counted (an enterable-refused dir is a
|
|
471
|
+
// real unexamined region — the round-3 chmod pin rides this catch).
|
|
472
|
+
try {
|
|
473
|
+
dirents = fs.readdirSync(d, { withFileTypes: true });
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
const code = err.code;
|
|
477
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
478
|
+
skipped++;
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
for (const e of dirents) {
|
|
482
|
+
const p = path.join(d, e.name);
|
|
483
|
+
let isDir = e.isDirectory();
|
|
484
|
+
let isFile = e.isFile();
|
|
485
|
+
if (!isDir && !isFile && e.isSymbolicLink()) {
|
|
486
|
+
// The round-4 errno rule applies HERE too (round-5): a target that DOES NOT RESOLVE —
|
|
487
|
+
// ENOENT/ELOOP, and equally ENOTDIR/ENAMETOOLONG (round-6: "any other errno means the
|
|
488
|
+
// target exists" was false for a link traversing a regular file) — is absence, so only
|
|
489
|
+
// a spec-LOOKING name counts (round-3: a dangling assets.png fabricates a phantom scope
|
|
490
|
+
// reduction). A genuinely REFUSED target (EACCES …) exists but cannot even be typed: an
|
|
491
|
+
// unexamined region regardless of its name, counted unconditionally.
|
|
492
|
+
try {
|
|
493
|
+
const st = fs.statSync(p);
|
|
494
|
+
isDir = st.isDirectory();
|
|
495
|
+
isFile = st.isFile();
|
|
496
|
+
}
|
|
497
|
+
catch (err) {
|
|
498
|
+
const code = err.code;
|
|
499
|
+
const unresolved = code === 'ENOENT' || code === 'ELOOP' || code === 'ENOTDIR' || code === 'ENAMETOOLONG';
|
|
500
|
+
if (unresolved) {
|
|
501
|
+
if (e.name.endsWith('.md'))
|
|
502
|
+
skipped++;
|
|
503
|
+
}
|
|
504
|
+
else
|
|
505
|
+
skipped++;
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (isDir) {
|
|
510
|
+
collect(p);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (!e.name.endsWith('.md'))
|
|
514
|
+
continue;
|
|
515
|
+
// TYPE before READ (round-6): readFileSync's open(2) blocks FOREVER on a FIFO with no
|
|
516
|
+
// writer — no throw, no survival catch, doctor simply never completes. A non-regular
|
|
517
|
+
// file named *.md cannot be judged; it is counted, never opened.
|
|
518
|
+
if (!isFile) {
|
|
519
|
+
skipped++;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
// Strip a UTF-8 BOM before parsing (round-1): a BOM broke the frontmatter match, so a
|
|
524
|
+
// BOM'd colliding replica silently vanished from the judgment instead of participating.
|
|
525
|
+
const raw = fs.readFileSync(p, 'utf8').replace(/^/, '');
|
|
526
|
+
const folded = raw.replace(/\r\n/g, '\n');
|
|
527
|
+
const spec = parseSpec(folded);
|
|
528
|
+
if (!spec.id) {
|
|
529
|
+
skipped++;
|
|
530
|
+
continue;
|
|
531
|
+
} // unreadable-as-spec, still counted (round-1)
|
|
532
|
+
entries.push({
|
|
533
|
+
file: path.relative(specsRoot, p).split(path.sep).join('/'),
|
|
534
|
+
id: spec.id,
|
|
535
|
+
approvedDigest: typeof spec.frontmatter.approved_digest === 'string' ? spec.frontmatter.approved_digest : undefined,
|
|
536
|
+
contentDigest: collisionKey(folded, spec),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
skipped++;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
collect(specsRoot);
|
|
545
|
+
// No cross-reference to `spec readability` (round-3): that walker does not follow dir
|
|
546
|
+
// symlinks, so the pointer was false for symlink-reached files — doctor contradicting itself.
|
|
547
|
+
const scopeNote = skipped > 0 ? ` (판정 밖 ${skipped}건)` : '';
|
|
548
|
+
const found = detectIdCollisions(entries);
|
|
549
|
+
const collisions = found.filter((i) => i.kind === 'id-collision');
|
|
550
|
+
const families = found.filter((i) => i.kind === 'family-coexistence');
|
|
551
|
+
add('spec id 선점 충돌', collisions.length === 0 ? 'PASS' : 'WARN', collisions.length === 0
|
|
552
|
+
? `같은 id를 서로 다른 스펙이 쓰는 충돌이 없습니다${scopeNote}`
|
|
553
|
+
: collisions.map((c) => `${c.id}: ${c.files.join(' ↔ ')} (${c.detail})`).join('; ') + scopeNote, collisions.length === 0 ? undefined
|
|
554
|
+
: '같은 번호를 두 스펙이 선점했습니다 — 한쪽을 리넘버하고(REQ-255 전까지는 수동) 재봉인하세요.');
|
|
555
|
+
add('spec id 가족 공존', families.length === 0 ? 'PASS' : 'WARN', families.length === 0
|
|
556
|
+
? `bare id와 dot-suffix 가족의 공존이 없습니다${scopeNote}`
|
|
557
|
+
: families.map((f) => `${f.id}: ${f.files.join(', ')}`).join('; ') + scopeNote, families.length === 0 ? undefined
|
|
558
|
+
: 'bare 스펙과 점 하위 가족이 공존합니다 — 두 워크스페이스가 같은 번호를 다르게 전개한 신호이니 한쪽 전개로 통일하세요.');
|
|
559
|
+
}
|
|
560
|
+
catch (e) {
|
|
561
|
+
// Never a silent vanish: the corpus most likely to hold a collision (a messy half-merged
|
|
562
|
+
// store) is exactly where an upstream throw would otherwise suppress the report. String(e),
|
|
563
|
+
// not e.message — a thrown non-Error must not crash the survival branch itself (round-2).
|
|
564
|
+
const msg = String(e instanceof Error ? e.message : e).split('\n')[0];
|
|
565
|
+
add('spec id 선점 충돌', 'WARN', `검사를 실행하지 못했습니다: ${msg}`);
|
|
566
|
+
add('spec id 가족 공존', 'WARN', `검사를 실행하지 못했습니다: ${msg}`);
|
|
567
|
+
}
|
|
410
568
|
}
|
|
411
569
|
// @implements A-SPEC-176
|
|
412
570
|
// `list()` drops any file it cannot parse, which is correct for a gate but leaves the author
|
|
@@ -554,6 +712,30 @@ async function runDoctor(packageRoot, target, opts, extraChecks) {
|
|
|
554
712
|
const agySkills = path.join(target, '.agents', 'skills');
|
|
555
713
|
add('antigravity skills', fs.existsSync(agySkills) ? 'PASS' : 'WARN', fs.existsSync(agySkills) ? `${agySkills} 가 스킬을 가리킵니다` : `${agySkills} 가 없습니다 — 이 하네스는 스킬을 보지 못합니다`, fs.existsSync(agySkills) ? undefined : `${path.join('..', '.claude', 'skills')} 로 링크하거나 복사하십시오.`);
|
|
556
714
|
}
|
|
715
|
+
// @implements A-SPEC-264 — 세 하네스 중 codex 만 배선 검사가 없었다(REQ-264 실측). 배선된
|
|
716
|
+
// 하네스마다 그 하네스의 배선을 검사한다는 A-SPEC-193 의 원칙 그대로: 파일이 없으면
|
|
717
|
+
// 진단하지 않고, 있으면 이 설치본으로 해석되는지를 판정한다.
|
|
718
|
+
const cdxMcp = path.join(target, '.codex', 'mcp_config.json');
|
|
719
|
+
if (fs.existsSync(cdxMcp)) {
|
|
720
|
+
try {
|
|
721
|
+
const doc = JSON.parse(fs.readFileSync(cdxMcp, 'utf8'));
|
|
722
|
+
const entry = doc.mcpServers?.[init_1.SERVER_NAME];
|
|
723
|
+
if (!entry || typeof entry.command !== 'string' || !Array.isArray(entry.args)) {
|
|
724
|
+
add('codex wiring', 'WARN', `${cdxMcp} 에 실행 가능한 ${init_1.SERVER_NAME} 항목이 없습니다 — 반쯤 된 배선입니다`, 'holmes-kit init --target <dir> --agent codex 로 다시 배선하십시오.');
|
|
725
|
+
}
|
|
726
|
+
else if (entry.command === 'node') {
|
|
727
|
+
const bin = entry.args[0] ?? '';
|
|
728
|
+
add('codex wiring', fs.existsSync(bin) ? 'PASS' : 'FAIL', fs.existsSync(bin) ? `MCP 배선이 이 설치본으로 해석됩니다: ${bin}` : `MCP 배선이 없는 파일을 가리킵니다: ${bin}`, fs.existsSync(bin) ? undefined : 'holmes-kit init --target <dir> --agent codex --force 로 절대 경로를 갱신하십시오.');
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
const pin = (0, mcp_version_1.mcpLaunchVersion)({ command: entry.command, args: entry.args });
|
|
732
|
+
add('codex wiring', pin !== null ? 'PASS' : 'FAIL', pin !== null ? `npx 핀 ${pin} 으로 해석됩니다` : `배선에서 실행 버전을 읽을 수 없습니다: ${entry.command} ${entry.args.join(' ')}`, pin !== null ? undefined : 'holmes-kit init --target <dir> --agent codex 로 다시 배선하십시오.');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
add('codex wiring', 'FAIL', `${cdxMcp} 를 읽을 수 없습니다(JSON 아님)`, '파일을 고치거나 지우고 다시 배선하십시오.');
|
|
737
|
+
}
|
|
738
|
+
}
|
|
557
739
|
try {
|
|
558
740
|
const sp = wiredSettingsPath(target);
|
|
559
741
|
const st = JSON.parse(fs.readFileSync(sp, 'utf8'));
|
|
@@ -7,6 +7,10 @@ export declare const BEGIN = "# >>> holmes-kit >>>";
|
|
|
7
7
|
export declare const END = "# <<< holmes-kit <<<";
|
|
8
8
|
/** Derived/runtime paths holmes-kit creates in a target project. */
|
|
9
9
|
export declare const IGNORE_LINES: string[];
|
|
10
|
+
export declare const ATTRIBUTE_LINES: string[];
|
|
11
|
+
/** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
|
|
12
|
+
* and user-content preservation are inherited rather than re-implemented. */
|
|
13
|
+
export declare function mergeGitattributes(existing: string): string;
|
|
10
14
|
/**
|
|
11
15
|
* Add (or refresh) the holmes-kit block. Idempotent: a second call reproduces the same text.
|
|
12
16
|
* Preserves everything outside the block, and preserves whether the file ended with a newline.
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// @implements A-SPEC-100.2
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
-
exports.IGNORE_LINES = exports.END = exports.BEGIN = void 0;
|
|
4
|
+
exports.ATTRIBUTE_LINES = exports.IGNORE_LINES = exports.END = exports.BEGIN = void 0;
|
|
5
|
+
exports.mergeGitattributes = mergeGitattributes;
|
|
5
6
|
exports.mergeGitignore = mergeGitignore;
|
|
6
7
|
exports.removeGitignoreBlock = removeGitignoreBlock;
|
|
7
8
|
exports.hasGitignoreBlock = hasGitignoreBlock;
|
|
@@ -42,6 +43,21 @@ exports.IGNORE_LINES = [
|
|
|
42
43
|
'.agents/hooks.json',
|
|
43
44
|
'.agents/mcp_config.json',
|
|
44
45
|
];
|
|
46
|
+
// @implements A-SPEC-256.1
|
|
47
|
+
// Merge attributes for the append-only files: `union` keeps BOTH sides' lines on merge — for these
|
|
48
|
+
// files "both" is always the right answer (the 2026-08-23 merge resolved every such conflict by
|
|
49
|
+
// hand to exactly this). The resulting chain seams are `ledger rechain`'s job, not the merge's.
|
|
50
|
+
exports.ATTRIBUTE_LINES = [
|
|
51
|
+
'# @implements A-SPEC-256.1 — append-only 파일은 병합에서 양쪽 줄을 모두 보존한다(union).',
|
|
52
|
+
'# 남는 체인 이음새는 `holmes-kit ledger rechain`의 일이다.',
|
|
53
|
+
'.ax/ledger/*.jsonl merge=union',
|
|
54
|
+
'.ax/approvals/queue.jsonl merge=union',
|
|
55
|
+
];
|
|
56
|
+
/** As `mergeGitignore`, over `.gitattributes` — the same marker block machinery, so idempotence
|
|
57
|
+
* and user-content preservation are inherited rather than re-implemented. */
|
|
58
|
+
function mergeGitattributes(existing) {
|
|
59
|
+
return mergeGitignore(existing, exports.ATTRIBUTE_LINES);
|
|
60
|
+
}
|
|
45
61
|
function findBlock(text) {
|
|
46
62
|
const lines = text.split('\n');
|
|
47
63
|
const start = lines.findIndex((l) => l.trim() === exports.BEGIN);
|
|
@@ -12,3 +12,26 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export declare function packageRoot(): string;
|
|
14
14
|
export declare function main(argv: string[]): Promise<number>;
|
|
15
|
+
/**
|
|
16
|
+
* @implements A-SPEC-262.1
|
|
17
|
+
* Is this the normal end of a pipeline rather than a fault? Round-10: `approve --list | head` printed
|
|
18
|
+
* a Node stack dump and exited 1 — on a command whose own help says it is for scripts, where a
|
|
19
|
+
* consumer that stops reading is how pipelines end. Exported so the decision is testable without
|
|
20
|
+
* spawning a process and racing a pipe.
|
|
21
|
+
*/
|
|
22
|
+
export declare const isBrokenPipe: (err: NodeJS.ErrnoException | undefined) => boolean;
|
|
23
|
+
/**
|
|
24
|
+
* @implements A-SPEC-262.1
|
|
25
|
+
* A broken stdout pipe is the normal end of `| head`, not a fault worth a thousand bytes of Node
|
|
26
|
+
* internals on the operator's screen; exit quietly with the conventional status.
|
|
27
|
+
*
|
|
28
|
+
* Round-10 installed this ONLY inside `if (require.main === module)`. Round-11 measured that the
|
|
29
|
+
* shipped `holmes-kit` never runs that block — `bin/holmes-kit.js` loads this file as a MODULE and
|
|
30
|
+
* calls `main()`, so `require.main` is the bin, not this module. The guard was therefore DEAD on the
|
|
31
|
+
* shipped binary: an interactive or `--watch` surface piped to a reader that closes (`| head`)
|
|
32
|
+
* crashed with an unhandled `'error'` EPIPE stack and exit 1 (measured byte-identical for one-shot
|
|
33
|
+
* and watch — a shared adapter property, not a watch defect). Installing from `main()` — the one
|
|
34
|
+
* path every shipped invocation takes — makes the round-10 fix actually run. Idempotent: the module
|
|
35
|
+
* flag keeps repeated `main()` calls (the test suite) from stacking listeners.
|
|
36
|
+
*/
|
|
37
|
+
export declare function installPipeGuard(): void;
|