@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.js +5 -1
  4. package/dist/holmes/cli/approve-context.d.ts +2 -0
  5. package/dist/holmes/cli/approve-context.js +180 -0
  6. package/dist/holmes/cli/approve-ref.d.ts +27 -0
  7. package/dist/holmes/cli/approve-ref.js +40 -0
  8. package/dist/holmes/cli/approve-watch.d.ts +29 -0
  9. package/dist/holmes/cli/approve-watch.js +94 -0
  10. package/dist/holmes/cli/approve.d.ts +50 -13
  11. package/dist/holmes/cli/approve.js +354 -38
  12. package/dist/holmes/cli/codex-toml.d.ts +26 -0
  13. package/dist/holmes/cli/codex-toml.js +282 -0
  14. package/dist/holmes/cli/doctor.js +206 -0
  15. package/dist/holmes/cli/gitignore-merge.d.ts +4 -0
  16. package/dist/holmes/cli/gitignore-merge.js +17 -1
  17. package/dist/holmes/cli/index.d.ts +23 -0
  18. package/dist/holmes/cli/index.js +490 -21
  19. package/dist/holmes/cli/init.js +92 -0
  20. package/dist/holmes/cli/interactive-prompt.js +4 -4
  21. package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
  22. package/dist/holmes/cli/screen-safe.d.ts +94 -0
  23. package/dist/holmes/cli/screen-safe.js +760 -0
  24. package/dist/holmes/governance/approval-queue.js +56 -4
  25. package/dist/holmes/governance/ledger-rechain.d.ts +25 -0
  26. package/dist/holmes/governance/ledger-rechain.js +95 -0
  27. package/dist/holmes/governance/provenance-chain.d.ts +33 -6
  28. package/dist/holmes/governance/provenance-chain.js +91 -16
  29. package/dist/holmes/governance/provenance-ledger.d.ts +7 -0
  30. package/dist/holmes/governance/provenance-ledger.js +10 -0
  31. package/dist/holmes/guardrail/risk-gate.d.ts +11 -1
  32. package/dist/holmes/guardrail/risk-gate.js +10 -0
  33. package/dist/holmes/guardrail/write-target.js +7 -0
  34. package/dist/holmes/mcp/elicit-approval.d.ts +67 -0
  35. package/dist/holmes/mcp/elicit-approval.js +79 -0
  36. package/dist/holmes/mcp/handlers.d.ts +7 -2
  37. package/dist/holmes/mcp/handlers.js +190 -24
  38. package/dist/holmes/mcp/server.js +26 -1
  39. package/dist/holmes/spec/id-collision.d.ts +39 -0
  40. package/dist/holmes/spec/id-collision.js +86 -0
  41. package/dist/holmes/spec/spec-store.js +9 -1
  42. 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 declare function makeHandlers(store: SpecStore): RawHandlers & {
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
- * @implements A-SPEC-156
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
- const approveResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'spec-approve', target: a.id }, new Date().toISOString());
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
- (0, baseline_1.writeBaseline)(root, baseline, (0, change_source_1.hashTree)(root, { isIgnored: (p) => (0, ignore_1.loadIgnore)(root).isIgnored(p) }));
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
- const rrResolved = resolveHandlerApproval(a.root, store, approval, { kind: 'review-resolve', target: f.id }, nowTs);
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
- (0, approval_grants_1.consumeGrantFile)(rrResolved.root, rrResolved.approval.nonce);
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
- if (liftedCriticals.length > 0) {
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) && liftedCriticals.length > 1) {
1710
- throw new HandlerRefusal(`review_record: 단일 사용 승인(nonce)은 한 건의 치명 해소만 authorize 합니다 — 이 배치는 ${liftedCriticals.length}건(${liftedCriticals.join(', ')})을 듭니다. 한 건씩 보내십시오`);
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 ${liftedCriticals.join(', ')}`.slice(0, 200),
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
- const handlers = (0, handlers_1.makeHandlers)(store);
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
- ok = (0, spec_parser_1.parseSpec)(fs.readFileSync(p, 'utf8')).id !== '';
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.18",
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",