@holmes-lab/holmes-kit 0.1.18 → 0.2.1
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 +32 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/agents.js +5 -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/codex-toml.d.ts +26 -0
- package/dist/holmes/cli/codex-toml.js +282 -0
- package/dist/holmes/cli/doctor.js +206 -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 +490 -21
- package/dist/holmes/cli/init.js +92 -0
- package/dist/holmes/cli/interactive-prompt.js +4 -4
- package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
- 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/guardrail/write-target.js +7 -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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-263.1
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.ELICITABLE_KINDS = void 0;
|
|
5
|
+
exports.buildElicitRequest = buildElicitRequest;
|
|
6
|
+
exports.interpretElicitResult = interpretElicitResult;
|
|
7
|
+
/**
|
|
8
|
+
* The decision comes INTO the session (REQ-263): an approval-shaped refusal (spec_approve without a
|
|
9
|
+
* covering HOLMES_APPROVAL) becomes an in-session question when the client advertises the MCP
|
|
10
|
+
* elicitation capability — the human answers at the point of decision, on the screen they are
|
|
11
|
+
* already looking at, and the tool call completes in place. No capability, an error, a timeout, or
|
|
12
|
+
* a dismissed window all degrade LOSSLESSLY to the current refusal + queue hint.
|
|
13
|
+
*
|
|
14
|
+
* TRUST MODEL (H-SPEC-263): an elicitation response is produced by the harness UI from a human —
|
|
15
|
+
* the model cannot forge it through tool arguments (the request travels server→client, off the tool
|
|
16
|
+
* surface). But the trust boundary sits at the harness, closer than the out-of-band env/grant
|
|
17
|
+
* channels an operator injects from a shell. Hence: (a) the kind allow-list below is conservative —
|
|
18
|
+
* soft-governance approvals only, hard-hitl (nonce-opened shell/config-write) stays out and
|
|
19
|
+
* widening it is a spec revision; (b) every grant is ledgered under an `elicitation:<client>` actor
|
|
20
|
+
* so audits can tell the channel apart; (c) grants are single-use by construction — the synthesized
|
|
21
|
+
* approval lives only inside the one call and is never persisted.
|
|
22
|
+
*
|
|
23
|
+
* PURE — no SDK import, no I/O. The server wires `Server.elicitInput` around these shapes; handlers
|
|
24
|
+
* receive an injected callback and never see the server instance, so tests drive every branch with
|
|
25
|
+
* a fake elicitor.
|
|
26
|
+
*/
|
|
27
|
+
/** Approval kinds that may ask in-session. Widening this set is a spec revision, not a drive-by. */
|
|
28
|
+
exports.ELICITABLE_KINDS = new Set(['spec-approve', 'review-resolve']);
|
|
29
|
+
/**
|
|
30
|
+
* The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
|
|
31
|
+
* is subordinate to the decision — this channel never collects arbitrary input.
|
|
32
|
+
*/
|
|
33
|
+
function buildElicitRequest(req) {
|
|
34
|
+
// FLATTEN attacker-influenced text (round-1): the model chooses spec titles and finding ids, and
|
|
35
|
+
// interpolating them raw let a crafted title inject fake lines ("[시스템] … approve.") into the
|
|
36
|
+
// ONE human-trust surface this feature introduces. Line structure is fixed by this template, never
|
|
37
|
+
// by the data: newlines and control characters collapse to spaces, and each field is length-capped.
|
|
38
|
+
const flat = (s, max) => s.replace(/[\u0000-\u001f\u007f\u0085\u2028\u2029\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]+/g, ' ')
|
|
39
|
+
.replace(/\s{2,}/g, ' ').trim().slice(0, max);
|
|
40
|
+
return {
|
|
41
|
+
message: `[Holmes-Kit 승인 요청] ${flat(req.kind, 40)} — ${flat(req.target, 80)}\n${flat(req.summary, 200)}\n승인(approve) / 거부(deny) / 질문(question) 을 선택하세요. 사유는 선택입니다.`,
|
|
42
|
+
requestedSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
decision: { type: 'string', enum: ['approve', 'deny', 'question'], description: '승인/거부/질문' },
|
|
46
|
+
reason: { type: 'string', description: '사유(선택) — 거부·질문이면 에이전트 문면에 실립니다' },
|
|
47
|
+
},
|
|
48
|
+
required: ['decision'],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Map a client's ElicitResult to a decision — FAIL-CLOSED: only the exact shape
|
|
54
|
+
* `{action:'accept', content:{decision:'approve'}}` grants; every malformed shape (missing action,
|
|
55
|
+
* missing content, a decision outside the enum, a non-string decision) is `null`, the same fallback
|
|
56
|
+
* as a channel that never answered. `decline` is a human's explicit NO — a decision, not a
|
|
57
|
+
* fallback — and `cancel` (window dismissed) is no decision at all.
|
|
58
|
+
*/
|
|
59
|
+
function interpretElicitResult(r) {
|
|
60
|
+
if (!r || typeof r !== 'object')
|
|
61
|
+
return null;
|
|
62
|
+
if (r.action === 'decline')
|
|
63
|
+
return { granted: false, reason: '사람이 세션에서 거절했습니다(decline)' };
|
|
64
|
+
if (r.action !== 'accept')
|
|
65
|
+
return null; // cancel, absent, anything else: no decision
|
|
66
|
+
const content = r.content;
|
|
67
|
+
if (!content || typeof content !== 'object')
|
|
68
|
+
return null;
|
|
69
|
+
const decision = content.decision;
|
|
70
|
+
const rawReason = content.reason;
|
|
71
|
+
const reason = typeof rawReason === 'string' && rawReason.trim() !== '' ? rawReason : undefined;
|
|
72
|
+
if (decision === 'approve')
|
|
73
|
+
return reason ? { granted: true, reason } : { granted: true };
|
|
74
|
+
if (decision === 'deny')
|
|
75
|
+
return { granted: false, reason: reason ?? '사람이 세션에서 거부했습니다(사유 없음)' };
|
|
76
|
+
if (decision === 'question')
|
|
77
|
+
return { granted: false, reason: `질문: ${reason ?? '(내용 없음)'} — 답한 뒤 다시 시도하십시오` };
|
|
78
|
+
return null; // outside the enum / wrong type: never a grant
|
|
79
|
+
}
|
|
@@ -3,6 +3,7 @@ import { Action } from '../guardrail/phase';
|
|
|
3
3
|
import { Basis } from './basis';
|
|
4
4
|
import { Finding } from '../review/findings';
|
|
5
5
|
import { Approval, Enforcement } from '../guardrail/risk-gate';
|
|
6
|
+
import { Elicitor } from './elicit-approval';
|
|
6
7
|
import { RiskAction } from '../guardrail/risk-types';
|
|
7
8
|
/**
|
|
8
9
|
* @implements A-SPEC-189 §7 (round 10)
|
|
@@ -31,7 +32,11 @@ import { AnchorMapping } from '../reverse/anchor';
|
|
|
31
32
|
* precisely how the surface came to answer confidently with nothing backing it. The coverage test
|
|
32
33
|
* walks the tool list for the same reason.
|
|
33
34
|
*/
|
|
34
|
-
export
|
|
35
|
+
export interface ElicitOpts {
|
|
36
|
+
elicit?: Elicitor;
|
|
37
|
+
clientName?: () => string;
|
|
38
|
+
}
|
|
39
|
+
export declare function makeHandlers(store: SpecStore, opts?: ElicitOpts): RawHandlers & {
|
|
35
40
|
basis_detail(a: {
|
|
36
41
|
root?: string;
|
|
37
42
|
}): Promise<Basis & {
|
|
@@ -39,7 +44,7 @@ export declare function makeHandlers(store: SpecStore): RawHandlers & {
|
|
|
39
44
|
}>;
|
|
40
45
|
};
|
|
41
46
|
type RawHandlers = ReturnType<typeof makeRawHandlers>;
|
|
42
|
-
declare function makeRawHandlers(store: SpecStore): {
|
|
47
|
+
declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
|
|
43
48
|
spec_create(a: any): Promise<{
|
|
44
49
|
ok: boolean;
|
|
45
50
|
reason: string;
|
|
@@ -99,6 +99,7 @@ const findings_1 = require("../review/findings");
|
|
|
99
99
|
const package_1 = require("../review/package");
|
|
100
100
|
const risk_classifier_1 = require("../guardrail/risk-classifier");
|
|
101
101
|
const risk_gate_1 = require("../guardrail/risk-gate");
|
|
102
|
+
const elicit_approval_1 = require("./elicit-approval");
|
|
102
103
|
const approval_queue_1 = require("../governance/approval-queue");
|
|
103
104
|
const approval_grants_1 = require("../governance/approval-grants");
|
|
104
105
|
const spec_digest_1 = require("../spec/spec-digest");
|
|
@@ -415,15 +416,8 @@ async function deriveChangedContext(store, rootArg, a, toolName) {
|
|
|
415
416
|
* information, and mtime would manufacture divergence on a checkout or a copy.
|
|
416
417
|
*/
|
|
417
418
|
const LOADED_BUILD = (0, basis_1.loadedBuildId)(path.resolve(__dirname, '..', '..', '..'));
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
* Basis is attached HERE, in one place, rather than inside each handler. Measured reasoning: 25
|
|
421
|
-
* handlers edited by hand is 25 chances to forget, and the one that forgets is invisible — which is
|
|
422
|
-
* precisely how the surface came to answer confidently with nothing backing it. The coverage test
|
|
423
|
-
* walks the tool list for the same reason.
|
|
424
|
-
*/
|
|
425
|
-
function makeHandlers(store) {
|
|
426
|
-
const raw = makeRawHandlers(store);
|
|
419
|
+
function makeHandlers(store, opts) {
|
|
420
|
+
const raw = makeRawHandlers(store, opts);
|
|
427
421
|
const wrapped = {};
|
|
428
422
|
for (const [name, fn] of Object.entries(raw)) {
|
|
429
423
|
wrapped[name] = (0, basis_1.withBasis)(fn, (a) => basisFor(a?.root));
|
|
@@ -471,8 +465,33 @@ function basisFor(root, withDisk = false) {
|
|
|
471
465
|
}
|
|
472
466
|
return (0, basis_1.collectBasis)(ctx);
|
|
473
467
|
}
|
|
474
|
-
function makeRawHandlers(store) {
|
|
468
|
+
function makeRawHandlers(store, opts) {
|
|
475
469
|
const resolver = (specs) => (id) => specs.find((s) => s.id === id) ?? null;
|
|
470
|
+
// @implements A-SPEC-263.1
|
|
471
|
+
// The in-session approval channel: ask ONLY when (a) an elicitor was injected (the server wires
|
|
472
|
+
// one iff the client advertised the elicitation capability — handlers never see the server), and
|
|
473
|
+
// (b) the kind is in the conservative allow-list. Every failure mode (throw, timeout folded to
|
|
474
|
+
// null by the wiring, malformed answers folded to null by interpretElicitResult) returns null,
|
|
475
|
+
// which callers treat as "the channel gave no answer" — the refusal that follows is byte-identical
|
|
476
|
+
// to the pre-elicitation one, so nothing is ever worse than before the channel existed.
|
|
477
|
+
const tryElicit = async (kind, target, summary) => {
|
|
478
|
+
if (!opts?.elicit || !elicit_approval_1.ELICITABLE_KINDS.has(kind))
|
|
479
|
+
return null;
|
|
480
|
+
try {
|
|
481
|
+
return await opts.elicit({ kind, target, summary });
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
// A granted answer becomes a synthesized Approval that rides the EXISTING seal path unchanged —
|
|
488
|
+
// the ledger's authorizationRef then honestly names the channel (`elicitation:<client>`), and
|
|
489
|
+
// single-use is structural: the object exists only inside this call, persisted nowhere.
|
|
490
|
+
const elicitApproval = (reason) => ({
|
|
491
|
+
actor: `elicitation:${opts?.clientName?.() ?? 'unknown'}`,
|
|
492
|
+
token: crypto.randomUUID(),
|
|
493
|
+
rationale: reason ?? 'elicitation grant',
|
|
494
|
+
});
|
|
476
495
|
/**
|
|
477
496
|
* Where the audit record for a governance act belongs — resolved BEFORE the act writes anything.
|
|
478
497
|
*
|
|
@@ -894,7 +913,29 @@ function makeRawHandlers(store) {
|
|
|
894
913
|
// covers, not merely well-formed (round-3 escalation: a token scoped to review-resolve — or
|
|
895
914
|
// expired outright — completed a FULL SEAL here, so A-SPEC-133's narrowing was decorative at
|
|
896
915
|
// the most consequential consumer). Unscoped {actor,token,rationale} stays the session key.
|
|
897
|
-
|
|
916
|
+
let approveResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
|
|
917
|
+
// @implements A-SPEC-263.1 — asked ONLY after the existing channels failed to cover (an open
|
|
918
|
+
// door never summons a human), and only about a spec that exists (a question about a missing
|
|
919
|
+
// id helps no one — the standard refusal handles it).
|
|
920
|
+
if (approveResolved === undefined) {
|
|
921
|
+
const target = await store.read(a.id).catch(() => null);
|
|
922
|
+
if (target) {
|
|
923
|
+
const resealing = typeof target.spec.frontmatter.approved_digest === 'string';
|
|
924
|
+
// The MODEL text is capped BEFORE the server markers are appended (round-2): a ~185+ char
|
|
925
|
+
// title pushed '(재봉인)' past the dialog's 200-char summary cap, dressing a re-seal (the
|
|
926
|
+
// more consequential act) as a first approval. The cap cuts the title, never the marker.
|
|
927
|
+
const d = await tryElicit('spec-approve', a.id, `${a.id} — ${target.spec.title.slice(0, 120)}${resealing ? ' (재봉인)' : ''}`);
|
|
928
|
+
if (d?.granted) {
|
|
929
|
+
approveResolved = { approval: elicitApproval(d.reason), source: 'elicitation' };
|
|
930
|
+
}
|
|
931
|
+
else if (d) {
|
|
932
|
+
// The human ANSWERED (deny/question/decline): the answer is the message, and no queue
|
|
933
|
+
// entry is filed — a decided request is not a pending one (REQ-246 visibility).
|
|
934
|
+
return { ok: false, reason: `spec_approve: 세션에서 거부됨 — ${d.reason ?? '(사유 없음)'}. 사유를 해소한 뒤 다시 시도하십시오.` };
|
|
935
|
+
}
|
|
936
|
+
// d === null: the channel gave no answer — fall through to the byte-identical refusal.
|
|
937
|
+
}
|
|
938
|
+
}
|
|
898
939
|
if (approveResolved === undefined) {
|
|
899
940
|
return { ok: false, reason: 'spec_approve requires an out-of-band HOLMES_APPROVAL that COVERS this act — a request-payload approval is not a channel, and an expired or elsewhere-scoped token does not open this door (scoped approvals need kind "spec-approve"). (fail-closed)'
|
|
900
941
|
+ refusalQueueHint(a.root, store, { kind: 'spec-approve', target: a.id, why: '스펙 봉인 승인' }) };
|
|
@@ -1427,7 +1468,9 @@ function makeRawHandlers(store) {
|
|
|
1427
1468
|
let baseline;
|
|
1428
1469
|
if (verified) {
|
|
1429
1470
|
baseline = a.mark ?? DEFAULT_BASELINE;
|
|
1430
|
-
|
|
1471
|
+
// A-SPEC-256.2 round-4 — surfaced to the report below
|
|
1472
|
+
const ig = (0, ignore_1.loadIgnore)(root); // parse .gitignore ONCE, not per file (round-10)
|
|
1473
|
+
(0, baseline_1.writeBaseline)(root, baseline, (0, change_source_1.hashTree)(root, { isIgnored: (p) => ig.isIgnored(p) }));
|
|
1431
1474
|
}
|
|
1432
1475
|
return { tier: testScope.tier, mode: result.mode, passed: result.passed, skipped: result.skipped,
|
|
1433
1476
|
ranFiles: result.ranFiles, executedByAspec, tail: result.tail,
|
|
@@ -1631,10 +1674,56 @@ function makeRawHandlers(store) {
|
|
|
1631
1674
|
// cross-process TOCTOU — two sessions both recorded open under the same id, silencing an
|
|
1632
1675
|
// open critical at both readers; consumeNonceExclusively already earned this discipline).
|
|
1633
1676
|
const findingsFile = boundFindingsLedger(store, a.root);
|
|
1677
|
+
// @implements A-SPEC-263.1 — elicitation happens BEFORE the lock: the human may take up to
|
|
1678
|
+
// the wiring's timeout to answer, and holding the findings ledger lock for that long starves
|
|
1679
|
+
// every other writer (the lock callback is synchronous by design). This is a best-effort
|
|
1680
|
+
// PRE-SCAN over an unlocked snapshot: for each finding that LOOKS like an open-critical lift
|
|
1681
|
+
// not covered by env/grant, ask now and carry the grant into the lock as data. TOCTOU folds
|
|
1682
|
+
// fail-closed — state moved so the lift is no longer needed → the grant is simply unused;
|
|
1683
|
+
// state moved so a lift IS needed that the snapshot missed → no grant → the standard refusal.
|
|
1684
|
+
const elicitGrants = new Map();
|
|
1685
|
+
// A DENY at pre-scan is carried as data too (round-1): throwing here judged a stale snapshot —
|
|
1686
|
+
// a finding concurrently resolved by someone else no longer needs a lift inside the lock, and
|
|
1687
|
+
// the human's "no" must not abort a batch that never needed the question. The deny bites only
|
|
1688
|
+
// at the in-lock site, and only if the lift is ACTUALLY needed there.
|
|
1689
|
+
const elicitDenials = new Map();
|
|
1690
|
+
if (opts?.elicit) {
|
|
1691
|
+
try {
|
|
1692
|
+
const snapshot = new Map();
|
|
1693
|
+
for (const f of new findings_1.FindingsLedger(findingsFile).list())
|
|
1694
|
+
snapshot.set(f.id, f);
|
|
1695
|
+
for (const f of a.findings) {
|
|
1696
|
+
const last = snapshot.get(f.id);
|
|
1697
|
+
if (!(f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical'))
|
|
1698
|
+
continue;
|
|
1699
|
+
const raw0 = process.env.HOLMES_APPROVAL;
|
|
1700
|
+
let env0;
|
|
1701
|
+
try {
|
|
1702
|
+
env0 = raw0 ? JSON.parse(raw0) : undefined;
|
|
1703
|
+
}
|
|
1704
|
+
catch {
|
|
1705
|
+
env0 = undefined;
|
|
1706
|
+
}
|
|
1707
|
+
if (resolveHandlerApproval(a.root, store, env0, { kind: 'review-resolve', target: f.id }, new Date().toISOString()) !== undefined)
|
|
1708
|
+
continue; // an open door never summons a human
|
|
1709
|
+
const d = await tryElicit('review-resolve', f.id, `열린 치명 발견 ${f.id} 의 해소 기록`);
|
|
1710
|
+
if (d?.granted)
|
|
1711
|
+
elicitGrants.set(f.id, elicitApproval(d.reason));
|
|
1712
|
+
else if (d)
|
|
1713
|
+
elicitDenials.set(f.id, d.reason ?? '(사유 없음)');
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
catch {
|
|
1717
|
+
// A pre-scan failure is never worse than no channel: fall through with no grants.
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1634
1720
|
(0, ledger_lock_1.withLedgerLock)(findingsFile, () => {
|
|
1635
1721
|
const ledger = new findings_1.FindingsLedger(findingsFile);
|
|
1636
1722
|
const latest = new Map();
|
|
1637
1723
|
const liftedCriticals = [];
|
|
1724
|
+
const envLiftedCriticals = []; // lifts the ENV approval authorized (round-1: nonce charges only these)
|
|
1725
|
+
const grantConsumptions = []; // spent AFTER the batch validates (round-2)
|
|
1726
|
+
const elicitationLifts = []; // ledgered per lift (round-5: the promised audit trace)
|
|
1638
1727
|
for (const f of ledger.list())
|
|
1639
1728
|
latest.set(f.id, f);
|
|
1640
1729
|
for (const f of a.findings) {
|
|
@@ -1655,6 +1744,15 @@ function makeRawHandlers(store) {
|
|
|
1655
1744
|
throw new HandlerRefusal(`review_record: id ${f.id} 는 이미 미해소(open) 상태입니다 — 다른 발견이면 새 id 를 쓰고, 같은 발견의 갱신이면 먼저 resolved 를 기록한 뒤 재기록하십시오 (id 충돌이 남의 critical 을 침묵시키는 것을 막는 문입니다)`);
|
|
1656
1745
|
}
|
|
1657
1746
|
if (f.status === 'resolved' && last?.status === 'open' && last.severity === 'critical') {
|
|
1747
|
+
// @implements A-SPEC-191 §11 — checked BEFORE any approval is resolved or consumed
|
|
1748
|
+
// (round-2): with the guard after resolution, a single-use elicitation grant (or grant
|
|
1749
|
+
// file) was spent on the FIRST lift, so the second occurrence saw "no approval" and
|
|
1750
|
+
// surfaced a bogus set-HOLMES_APPROVAL message plus a queue entry for an act a human
|
|
1751
|
+
// already decided. The double-lift is named honestly on EVERY channel, and nothing
|
|
1752
|
+
// single-use is touched by a batch that dies here.
|
|
1753
|
+
if (liftedCriticals.includes(f.id)) {
|
|
1754
|
+
throw new HandlerRefusal(`review_record: 한 배치에서 같은 발견(${f.id})의 치명 해소를 두 번 들 수 없습니다 — 승인은 행위마다 필요합니다`);
|
|
1755
|
+
}
|
|
1658
1756
|
const raw = process.env.HOLMES_APPROVAL;
|
|
1659
1757
|
let approval;
|
|
1660
1758
|
try {
|
|
@@ -1667,21 +1765,45 @@ function makeRawHandlers(store) {
|
|
|
1667
1765
|
// not a master key — an EXPIRED or elsewhere-scoped approval must not lift a critical.
|
|
1668
1766
|
// An unscoped {actor,token,rationale} stays the operator's session key (unchanged).
|
|
1669
1767
|
const nowTs = new Date().toISOString();
|
|
1670
|
-
|
|
1768
|
+
let rrResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'review-resolve', target: f.id }, nowTs);
|
|
1769
|
+
// @implements A-SPEC-263.1 — the second (and last) elicitable kind. The HUMAN was asked
|
|
1770
|
+
// BEFORE the lock (pre-scan above — a lock must not wait on a person); in here the grant
|
|
1771
|
+
// is plain data, consumed synchronously and at most once per id.
|
|
1772
|
+
if (rrResolved === undefined) {
|
|
1773
|
+
const g = elicitGrants.get(f.id);
|
|
1774
|
+
if (g) {
|
|
1775
|
+
elicitGrants.delete(f.id);
|
|
1776
|
+
rrResolved = { approval: g, source: 'elicitation' };
|
|
1777
|
+
elicitationLifts.push({ id: f.id, approval: g });
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1671
1780
|
if (rrResolved === undefined) {
|
|
1781
|
+
// A pre-scan DENY surfaces HERE — only when the lift is genuinely needed at lock time
|
|
1782
|
+
// (round-1). The human decided, so the answer is the message and nothing is queued.
|
|
1783
|
+
const denied = elicitDenials.get(f.id);
|
|
1784
|
+
if (denied !== undefined) {
|
|
1785
|
+
throw new HandlerRefusal(`review_record: 세션에서 거부됨 — ${denied}. 사유를 해소한 뒤 다시 기록하십시오.`);
|
|
1786
|
+
}
|
|
1672
1787
|
throw new HandlerRefusal(`review_record: id ${f.id} 의 열린 치명 발견을 해소하는 기록은 이 행위를 덮는 유효한 대역외 승인이 필요합니다 — 차단당한 쪽이 스스로 이빨을 뽑을 수 없어야 하고, 만료·다른 범위의 승인은 덮지 않습니다. HOLMES_APPROVAL='{"actor":"<you>","token":"<any>","rationale":"<why fixed>"}' (범위를 쓰면 kind "review-resolve") 를 서버 환경에 설정하고 다시 기록하십시오`
|
|
1673
1788
|
+ refusalQueueHint(a.root, store, { kind: 'review-resolve', target: f.id, why: '열린 치명 발견의 해소 기록' }));
|
|
1674
1789
|
}
|
|
1790
|
+
// Grant-file consumption is DEFERRED past the loop (round-2): consuming here burned the
|
|
1791
|
+
// single-use file when a LATER finding in the batch failed validation — nothing was
|
|
1792
|
+
// recorded, yet the operator's legitimate grant was gone and the retry refused. Same
|
|
1793
|
+
// harm class the env nonce fixed in its round-4; collected now, spent only once the
|
|
1794
|
+
// whole batch has validated (directly before the append, like the nonce block).
|
|
1675
1795
|
if (rrResolved.source === 'grant' && rrResolved.root && rrResolved.approval.nonce) {
|
|
1676
|
-
(
|
|
1677
|
-
}
|
|
1678
|
-
// @implements A-SPEC-191 §11 — 한 배치가 같은 id 를 다시 열고 다시 닫으면 lift 는 두 번
|
|
1679
|
-
// 일어난다. 소비는 호출당 1회이므로, 세 번의 호출이면 거부됐을 일이 한 배치에서는
|
|
1680
|
-
// 통과했다(실측: 최종 상태와 게이트 판정이 갈렸다).
|
|
1681
|
-
if (liftedCriticals.includes(f.id)) {
|
|
1682
|
-
throw new HandlerRefusal(`review_record: 한 배치에서 같은 발견(${f.id})의 치명 해소를 두 번 들 수 없습니다 — 승인은 행위마다 필요합니다`);
|
|
1796
|
+
grantConsumptions.push({ root: rrResolved.root, nonce: rrResolved.approval.nonce });
|
|
1683
1797
|
}
|
|
1684
1798
|
liftedCriticals.push(f.id);
|
|
1799
|
+
// The nonce block below enforces single-use on the ENV approval — so it must key on the
|
|
1800
|
+
// lifts the ENV approval actually authorized (round-1): before elicitation existed,
|
|
1801
|
+
// reaching it implied env/grant coverage, but an elicitation-authorized lift flows past
|
|
1802
|
+
// it with a possibly unrelated env token in the environment. Charging THAT token burned
|
|
1803
|
+
// an innocent nonce, misattributed the ledger line, and a stale env nonce made a
|
|
1804
|
+
// human-approved lift permanently refusable.
|
|
1805
|
+
if (rrResolved.source === 'env')
|
|
1806
|
+
envLiftedCriticals.push(f.id);
|
|
1685
1807
|
}
|
|
1686
1808
|
latest.set(f.id, f); // 한 배치 안의 순서도 기록 순서다
|
|
1687
1809
|
}
|
|
@@ -1691,7 +1813,44 @@ function makeRawHandlers(store) {
|
|
|
1691
1813
|
// an act that never happened, the retry was refused, and two resolves in one batch would
|
|
1692
1814
|
// have double-spent). Consumption directly precedes the append; the only failure between
|
|
1693
1815
|
// them is the append itself, which throws loudly.
|
|
1694
|
-
|
|
1816
|
+
// ONE NONCE, ONE ACT — on the grant channel too (round-4): the round-2 deferral removed the
|
|
1817
|
+
// arity the in-loop spend had accidentally enforced, so a wide-scoped single-use grant
|
|
1818
|
+
// backed N lifts in one batch while the same lifts split across calls refused after the
|
|
1819
|
+
// first (the §13 batch-shape dependence). Checked BEFORE anything is spent (round-3 order
|
|
1820
|
+
// doctrine: every throwable validation precedes every single-use spend).
|
|
1821
|
+
{
|
|
1822
|
+
const liftsPerNonce = new Map();
|
|
1823
|
+
for (const g of grantConsumptions)
|
|
1824
|
+
liftsPerNonce.set(g.nonce, (liftsPerNonce.get(g.nonce) ?? 0) + 1);
|
|
1825
|
+
for (const [, n] of liftsPerNonce) {
|
|
1826
|
+
if (n > 1) {
|
|
1827
|
+
throw new HandlerRefusal(`review_record: 단일 사용 승인(grant)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 한 grant 로 ${n}건을 듭니다. 한 건씩 보내십시오`);
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
// The elicitation channel's AUDIT TRACE (round-5, repositioned round-6): the trust model
|
|
1832
|
+
// admits review-resolve at a boundary closer than env/grant BECAUSE every grant is ledgered
|
|
1833
|
+
// under an `elicitation:<client>` actor. This append is throwable I/O (chain lock
|
|
1834
|
+
// contention, disk faults), so it stands BEFORE every single-use spend — round-5 placed it
|
|
1835
|
+
// between the env-nonce spend and the findings append, and a lock-contended mixed batch
|
|
1836
|
+
// burned the operator's env nonce with nothing recorded (the §7 doctrine regression). A
|
|
1837
|
+
// failure HERE burns nothing; an orphaned event left when a LATER step throws stays honest,
|
|
1838
|
+
// because it records the AUTHORIZATION, which already happened at pre-scan.
|
|
1839
|
+
if (elicitationLifts.length > 0) {
|
|
1840
|
+
const chain = new ledger_store_1.FileLedgerStore(path.join(projectRootOf(a.root), '.ax', 'ledger'));
|
|
1841
|
+
for (const lift of elicitationLifts) {
|
|
1842
|
+
chain.append({
|
|
1843
|
+
ts: new Date().toISOString(), actor: lift.approval.actor, kind: 'review-resolve-authorized',
|
|
1844
|
+
summary: `elicitation grant authorized lifting open critical ${lift.id}`.slice(0, 200),
|
|
1845
|
+
inputs: [lift.id], rationale: lift.approval.rationale,
|
|
1846
|
+
authorization: (0, provenance_chain_1.authorizationRef)(lift.approval.actor, lift.approval.token),
|
|
1847
|
+
});
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// Keyed on the lifts the ENV approval AUTHORIZED, not on the ambient env var (round-1):
|
|
1851
|
+
// an elicitation- or grant-authorized lift must not charge — or be blocked by — an env
|
|
1852
|
+
// token that never covered the act. When env authorized nothing, its nonce is untouched.
|
|
1853
|
+
if (envLiftedCriticals.length > 0) {
|
|
1695
1854
|
const raw = process.env.HOLMES_APPROVAL;
|
|
1696
1855
|
let approval;
|
|
1697
1856
|
try {
|
|
@@ -1706,8 +1865,8 @@ function makeRawHandlers(store) {
|
|
|
1706
1865
|
// @implements A-SPEC-191 §13 — 1회용 승인은 한 번의 행위를 authorize 한다. 9라운드
|
|
1707
1866
|
// 실측: 서로 다른 id 의 치명 해소 N 건이 한 배치에서 nonce 한 장으로 통과했고,
|
|
1708
1867
|
// 같은 세 건을 세 호출로 나누면 두 번째부터 거부됐다 — 판정이 묶음 방식에 의존했다.
|
|
1709
|
-
if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce) &&
|
|
1710
|
-
throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 ${
|
|
1868
|
+
if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce) && envLiftedCriticals.length > 1) {
|
|
1869
|
+
throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 ${envLiftedCriticals.length}건(${envLiftedCriticals.join(', ')})을 듭니다. 한 건씩 보내십시오`);
|
|
1711
1870
|
}
|
|
1712
1871
|
if ((0, provenance_chain_1.nonceDeclared)(approval?.nonce)) {
|
|
1713
1872
|
// @implements A-SPEC-191 §10 — the same bound anchor as risk_check: two consumers must
|
|
@@ -1718,7 +1877,7 @@ function makeRawHandlers(store) {
|
|
|
1718
1877
|
}
|
|
1719
1878
|
const won = (0, provenance_chain_1.consumeNonceExclusively)(String(approval.nonce), ledgerFile, {
|
|
1720
1879
|
ts: new Date().toISOString(), actor: approval.actor, kind: 'nonce-consumed',
|
|
1721
|
-
summary: `consumed single-use approval for: review-resolve ${
|
|
1880
|
+
summary: `consumed single-use approval for: review-resolve ${envLiftedCriticals.join(', ')}`.slice(0, 200),
|
|
1722
1881
|
inputs: [(0, provenance_chain_1.nonceFingerprint)(String(approval.nonce))], rationale: approval.rationale,
|
|
1723
1882
|
authorization: (0, provenance_chain_1.authorizationRef)(approval.actor, approval.token),
|
|
1724
1883
|
});
|
|
@@ -1727,6 +1886,13 @@ function makeRawHandlers(store) {
|
|
|
1727
1886
|
}
|
|
1728
1887
|
}
|
|
1729
1888
|
}
|
|
1889
|
+
// Deferred single-use spends — LAST, after every throwable validation including the
|
|
1890
|
+
// env-nonce block above (round-3: placed before it, a mixed batch's env-nonce refusal
|
|
1891
|
+
// burned the grant with nothing recorded — the exact harm the round-2 deferral was written
|
|
1892
|
+
// to fix, reintroduced through the env throw path). Only the append itself follows, and it
|
|
1893
|
+
// fails loudly.
|
|
1894
|
+
for (const g of grantConsumptions)
|
|
1895
|
+
(0, approval_grants_1.consumeGrantFile)(g.root, g.nonce);
|
|
1730
1896
|
// @implements A-SPEC-157 — the server's OWN observation, not anything the caller sent.
|
|
1731
1897
|
// @implements A-SPEC-160 — the ONLY caller that reads the on-disk build, so the divergence
|
|
1732
1898
|
// marker can be sealed. Append INSIDE the same lock as the guard (round-6 TOCTOU).
|
|
@@ -11,7 +11,32 @@ Object.defineProperty(exports, "HOOK_ENFORCED_TOOLS", { enumerable: true, get: f
|
|
|
11
11
|
const validate_args_1 = require("./validate-args");
|
|
12
12
|
// @implements A-SPEC-100.2
|
|
13
13
|
const store = new spec_store_1.LocalMarkdownRepository(process.env.HOLMES_SPECS ?? '.ax/specs');
|
|
14
|
-
|
|
14
|
+
// @implements A-SPEC-263.1 — the elicitation approval channel's wiring. The capability arrives at
|
|
15
|
+
// initialize, AFTER this factory runs, so it is consulted lazily at CALL time: no capability (or
|
|
16
|
+
// any transport error/timeout) folds to null, which the handlers treat as "the channel gave no
|
|
17
|
+
// answer" — the refusal that follows is byte-identical to the pre-elicitation one. Diagnostics, if
|
|
18
|
+
// ever needed, go to stderr only (stdout is the protocol channel).
|
|
19
|
+
const { buildElicitRequest, interpretElicitResult } = require('./elicit-approval');
|
|
20
|
+
const elicit = async (req) => {
|
|
21
|
+
try {
|
|
22
|
+
if (!server.getClientCapabilities()?.elicitation)
|
|
23
|
+
return null;
|
|
24
|
+
const r = await server.elicitInput(buildElicitRequest(req), { timeout: 120_000 });
|
|
25
|
+
return interpretElicitResult(r);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
const handlers = (0, handlers_1.makeHandlers)(store, {
|
|
32
|
+
elicit,
|
|
33
|
+
clientName: () => { try {
|
|
34
|
+
return server.getClientVersion()?.name ?? 'unknown';
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'unknown';
|
|
38
|
+
} },
|
|
39
|
+
});
|
|
15
40
|
// @implements A-SPEC-259 — the advertised version is the package's own, not a literal that froze at
|
|
16
41
|
// 0.1.0: a hardcoded serverInfo.version blinds any client-side drift diagnosis.
|
|
17
42
|
const PKG_VERSION = (() => {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Distributed id-preemption detection (REQ-254): two disconnected workspaces each see the same local
|
|
3
|
+
* max and issue the same spec number, and the collision is silent at create AND at push — the
|
|
4
|
+
* 2026-08-23 incident surfaced only as a rebase add/add conflict, and the bare-vs-dotted family
|
|
5
|
+
* variant (`A-SPEC-220` on one side, `A-SPEC-220.1~3` on the other) produced no git conflict at all.
|
|
6
|
+
* Prevention would demand central reservation, which the local-first principle forbids; where
|
|
7
|
+
* prevention is impossible, detection must be mechanical.
|
|
8
|
+
*
|
|
9
|
+
* The judgment key is (id, seal digest), NOT the id string (REQ-254 Constraints): files sharing an
|
|
10
|
+
* id AND an `approved_digest` are normal replication of one sealed spec — only differing digests
|
|
11
|
+
* mean two different specs are squatting one number. Unsealed drafts have no seal, so the content
|
|
12
|
+
* digest stands in: two workspaces both drafting the same number collide iff the drafts differ.
|
|
13
|
+
*
|
|
14
|
+
* PURE — list in, list out, no filesystem, no git. The I/O (walking the store, hashing bytes,
|
|
15
|
+
* reading frontmatter) belongs to the caller (doctor's collection layer), so tests exercise the
|
|
16
|
+
* judgment directly with fixture lists.
|
|
17
|
+
*/
|
|
18
|
+
export interface IdCollisionEntry {
|
|
19
|
+
/** Store-relative path, '/'-separated — reported verbatim so the finding is actionable. */
|
|
20
|
+
file: string;
|
|
21
|
+
id: string;
|
|
22
|
+
/** The seal (`approved_digest` frontmatter), absent on drafts — a SIGNAL, never the identity. */
|
|
23
|
+
approvedDigest?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The canonical spec identity: specDigest of the parsed spec (CRLF-folded, SEAL_FIELDS excluded).
|
|
26
|
+
* NOT raw file bytes (round-1: raw bytes made a CRLF checkout difference read as a collision) and
|
|
27
|
+
* NOT the seal (round-1: a stale/forged seal hid a differing-content squat).
|
|
28
|
+
*/
|
|
29
|
+
contentDigest: string;
|
|
30
|
+
}
|
|
31
|
+
export interface IdCollisionIssue {
|
|
32
|
+
kind: 'id-collision' | 'family-coexistence';
|
|
33
|
+
/** The colliding id, or the bare id of the coexisting family. */
|
|
34
|
+
id: string;
|
|
35
|
+
/** Every involved file, sorted — a pair for the classic case, more when replicas pile up. */
|
|
36
|
+
files: string[];
|
|
37
|
+
detail: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function detectIdCollisions(entries: IdCollisionEntry[]): IdCollisionIssue[];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// @implements A-SPEC-254.1
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.detectIdCollisions = detectIdCollisions;
|
|
5
|
+
// Only A-SPEC and T-SPEC may carry dot sub-numbers (the engine enforces this), so only they can
|
|
6
|
+
// have a bare/dotted family split. REQ/H-SPEC ids are structurally exempt.
|
|
7
|
+
const DOTTED = /^([AT]-SPEC-\d+)\.\d+$/;
|
|
8
|
+
function detectIdCollisions(entries) {
|
|
9
|
+
const issues = [];
|
|
10
|
+
// --- id-collision: same id, differing judgment keys ---
|
|
11
|
+
const byId = new Map();
|
|
12
|
+
for (const en of entries) {
|
|
13
|
+
const g = byId.get(en.id);
|
|
14
|
+
if (g)
|
|
15
|
+
g.push(en);
|
|
16
|
+
else
|
|
17
|
+
byId.set(en.id, [en]);
|
|
18
|
+
}
|
|
19
|
+
for (const [id, group] of byId) {
|
|
20
|
+
if (group.length < 2)
|
|
21
|
+
continue;
|
|
22
|
+
// IDENTITY IS THE CANONICAL CONTENT DIGEST, not the seal (round-1 adversarial finding): the
|
|
23
|
+
// `approved_digest` is a self-declared frontmatter string nobody verifies here, so trusting it
|
|
24
|
+
// let a post-approval edit — or a seal pasted onto a different spec — pass as "normal
|
|
25
|
+
// replication" while the bodies differed (the exact squat this module exists to expose). The
|
|
26
|
+
// caller supplies contentDigest as the canonical spec identity (specDigest: CRLF-folded,
|
|
27
|
+
// SEAL_FIELDS excluded), so a seal-stripped copy of the same spec is replication and a CRLF-only
|
|
28
|
+
// byte difference never fabricates a collision. The seal is kept as a SIGNAL: one seal value
|
|
29
|
+
// shared by differing contents is named in the detail (post-approval edit or copied seal).
|
|
30
|
+
const keys = new Set(group.map((en) => en.contentDigest));
|
|
31
|
+
if (keys.size < 2)
|
|
32
|
+
continue; // all copies carry the same spec content — replication, not a collision
|
|
33
|
+
const byKey = new Map();
|
|
34
|
+
for (const en of group) {
|
|
35
|
+
const g = byKey.get(en.contentDigest);
|
|
36
|
+
if (g)
|
|
37
|
+
g.push(en);
|
|
38
|
+
else
|
|
39
|
+
byKey.set(en.contentDigest, [en]);
|
|
40
|
+
}
|
|
41
|
+
const sealContents = new Map(); // seal value -> distinct contents claiming it
|
|
42
|
+
for (const en of group) {
|
|
43
|
+
if (!en.approvedDigest)
|
|
44
|
+
continue;
|
|
45
|
+
const s = sealContents.get(en.approvedDigest);
|
|
46
|
+
if (s)
|
|
47
|
+
s.add(en.contentDigest);
|
|
48
|
+
else
|
|
49
|
+
sealContents.set(en.approvedDigest, new Set([en.contentDigest]));
|
|
50
|
+
}
|
|
51
|
+
const sharedSeals = [...sealContents.entries()].filter(([, cs]) => cs.size > 1).map(([s]) => s).sort();
|
|
52
|
+
const parts = [...byKey.entries()].sort().map(([k, ens]) => `${k}[seal=${[...new Set(ens.map((en) => en.approvedDigest ?? '없음'))].sort().join('|')}] ← ${ens.map((en) => en.file).sort().join(', ')}`);
|
|
53
|
+
issues.push({
|
|
54
|
+
kind: 'id-collision',
|
|
55
|
+
id,
|
|
56
|
+
files: group.map((en) => en.file).sort(),
|
|
57
|
+
detail: parts.join(' / ')
|
|
58
|
+
+ (sharedSeals.length > 0 ? ` — 같은 approved_digest 를 서로 다른 내용이 공유(${sharedSeals.join(', ')}): post-approval edit 또는 봉인 복사 의심` : ''),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
// --- family-coexistence: a bare A/T-SPEC id alongside its own dot-suffix family ---
|
|
62
|
+
const ids = new Set(entries.map((en) => en.id));
|
|
63
|
+
const familyFiles = new Map(); // bare id -> dotted member files
|
|
64
|
+
for (const en of entries) {
|
|
65
|
+
const m = DOTTED.exec(en.id);
|
|
66
|
+
if (!m)
|
|
67
|
+
continue;
|
|
68
|
+
const f = familyFiles.get(m[1]);
|
|
69
|
+
if (f)
|
|
70
|
+
f.push(en.file);
|
|
71
|
+
else
|
|
72
|
+
familyFiles.set(m[1], [en.file]);
|
|
73
|
+
}
|
|
74
|
+
for (const [bare, dotted] of familyFiles) {
|
|
75
|
+
if (!ids.has(bare))
|
|
76
|
+
continue; // dotted-only families are the normal multi-slice shape
|
|
77
|
+
const bareFiles = entries.filter((en) => en.id === bare).map((en) => en.file);
|
|
78
|
+
issues.push({
|
|
79
|
+
kind: 'family-coexistence',
|
|
80
|
+
id: bare,
|
|
81
|
+
files: [...dotted, ...bareFiles].sort(),
|
|
82
|
+
detail: `bare ${bare} (${bareFiles.sort().join(', ')}) coexists with its dot-suffix family (${dotted.sort().join(', ')})`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return issues.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.kind < b.kind ? -1 : 1));
|
|
86
|
+
}
|
|
@@ -101,8 +101,12 @@ function unreadableSpecFiles(specsDir) {
|
|
|
101
101
|
if (!e.name.endsWith('.md'))
|
|
102
102
|
continue;
|
|
103
103
|
let ok = false;
|
|
104
|
+
// TYPE before READ: readFileSync's open(2) blocks forever on a FIFO with no writer (measured
|
|
105
|
+
// 2026-08-24 — it hung doctor outright). A non-regular *.md IS an unreadable spec file, so it
|
|
106
|
+
// lands in this report without ever being opened; a dangling link throws and lands here too.
|
|
104
107
|
try {
|
|
105
|
-
|
|
108
|
+
if (fs.statSync(p).isFile())
|
|
109
|
+
ok = (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8')).id !== '';
|
|
106
110
|
}
|
|
107
111
|
catch {
|
|
108
112
|
ok = false;
|
|
@@ -226,6 +230,8 @@ class LocalMarkdownRepository {
|
|
|
226
230
|
}
|
|
227
231
|
else if (e.name.endsWith('.md')) {
|
|
228
232
|
try {
|
|
233
|
+
if (!fs.statSync(p).isFile())
|
|
234
|
+
continue; // a FIFO/device would hang open(2) — never read a non-regular file
|
|
229
235
|
const spec = (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8'));
|
|
230
236
|
if (spec.id === id) {
|
|
231
237
|
result = p;
|
|
@@ -251,6 +257,8 @@ class LocalMarkdownRepository {
|
|
|
251
257
|
walk(p);
|
|
252
258
|
else if (e.name.endsWith('.md')) {
|
|
253
259
|
try {
|
|
260
|
+
if (!fs.statSync(p).isFile())
|
|
261
|
+
continue; // a FIFO/device would hang open(2) — never read a non-regular file
|
|
254
262
|
const spec = (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8'));
|
|
255
263
|
// Only include valid specs with non-empty id
|
|
256
264
|
if (spec.id) {
|
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.1
|
|
4
|
+
"version": "0.2.1",
|
|
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",
|