@holmes-lab/holmes-kit 0.2.1 → 0.3.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 (132) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -21
  13. package/dist/holmes/cli/doctor.d.ts +56 -0
  14. package/dist/holmes/cli/doctor.js +311 -5
  15. package/dist/holmes/cli/index.js +13 -1
  16. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  17. package/dist/holmes/cli/semantic-key.js +93 -0
  18. package/dist/holmes/config/config.d.ts +9 -0
  19. package/dist/holmes/config/config.js +8 -1
  20. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  21. package/dist/holmes/cpg/language-capability.js +145 -0
  22. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  23. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  24. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  25. package/dist/holmes/governance/ledger-rechain.js +17 -2
  26. package/dist/holmes/governance/provenance-ledger.js +21 -0
  27. package/dist/holmes/guardrail/blind-spots.js +12 -1
  28. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  29. package/dist/holmes/guardrail/impact-gate.js +263 -0
  30. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  31. package/dist/holmes/guardrail/write-target.js +41 -4
  32. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  33. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  34. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  35. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  36. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  37. package/dist/holmes/hooks/stop.d.ts +65 -0
  38. package/dist/holmes/hooks/stop.js +200 -2
  39. package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
  40. package/dist/holmes/mcp/elicit-approval.js +30 -2
  41. package/dist/holmes/mcp/handlers.d.ts +88 -0
  42. package/dist/holmes/mcp/handlers.js +636 -35
  43. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  44. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  45. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  46. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  47. package/dist/holmes/mcp/server.js +13 -10
  48. package/dist/holmes/mcp/tool-schemas.js +71 -0
  49. package/dist/holmes/project/root.js +3 -1
  50. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  51. package/dist/holmes/review/baseline-arm.js +51 -0
  52. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  53. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  54. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  55. package/dist/holmes/review/coherence-verify.js +144 -0
  56. package/dist/holmes/review/commit-text.d.ts +50 -0
  57. package/dist/holmes/review/commit-text.js +76 -0
  58. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  59. package/dist/holmes/review/confidence-calibration.js +39 -0
  60. package/dist/holmes/review/content-baseline.d.ts +38 -0
  61. package/dist/holmes/review/content-baseline.js +103 -0
  62. package/dist/holmes/review/content-verify.d.ts +20 -0
  63. package/dist/holmes/review/content-verify.js +73 -0
  64. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  65. package/dist/holmes/review/dense-retrieval.js +97 -0
  66. package/dist/holmes/review/edge-quality.d.ts +44 -0
  67. package/dist/holmes/review/edge-quality.js +117 -0
  68. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  69. package/dist/holmes/review/evaluation-metrics.js +175 -0
  70. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  71. package/dist/holmes/review/graph-verifier.js +62 -0
  72. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  73. package/dist/holmes/review/hop-ablation.js +89 -0
  74. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  75. package/dist/holmes/review/manual-baseline.js +2846 -0
  76. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  77. package/dist/holmes/review/oracle-gap.js +102 -0
  78. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  79. package/dist/holmes/review/point-in-time-replay.js +161 -0
  80. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  81. package/dist/holmes/review/rank-diagnosis.js +163 -0
  82. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  83. package/dist/holmes/review/replay-calibration.js +83 -0
  84. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  85. package/dist/holmes/review/replay-corpus.js +210 -0
  86. package/dist/holmes/review/run-replay.d.ts +260 -0
  87. package/dist/holmes/review/run-replay.js +729 -0
  88. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  89. package/dist/holmes/review/semantic-arm.js +717 -0
  90. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  91. package/dist/holmes/review/semantic-retrieval.js +156 -0
  92. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  93. package/dist/holmes/review/spec-layer-stats.js +52 -0
  94. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  95. package/dist/holmes/review/temporal-prior.js +53 -0
  96. package/dist/holmes/review/test-runner.d.ts +15 -0
  97. package/dist/holmes/review/test-runner.js +41 -4
  98. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  99. package/dist/holmes/review/test-selection-breadth.js +57 -0
  100. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  101. package/dist/holmes/review/traceability-benchmark.js +135 -0
  102. package/dist/holmes/review/union-verify.d.ts +12 -0
  103. package/dist/holmes/review/union-verify.js +70 -0
  104. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  105. package/dist/holmes/rtm/graph-store.js +122 -0
  106. package/dist/holmes/rtm/incremental.d.ts +25 -1
  107. package/dist/holmes/rtm/incremental.js +18 -1
  108. package/dist/holmes/rtm/localize.d.ts +28 -0
  109. package/dist/holmes/rtm/localize.js +272 -14
  110. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  111. package/dist/holmes/rtm/rtm-builder.js +265 -28
  112. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  113. package/dist/holmes/rtm/rtm-graph.js +194 -31
  114. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  115. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  116. package/dist/holmes/rtm/test-scope.js +24 -1
  117. package/dist/holmes/semantic/credentials.d.ts +15 -0
  118. package/dist/holmes/semantic/credentials.js +134 -0
  119. package/dist/holmes/semantic/embedder.d.ts +44 -0
  120. package/dist/holmes/semantic/embedder.js +185 -0
  121. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  122. package/dist/holmes/semantic/hit-rerank.js +38 -0
  123. package/dist/holmes/semantic/tier.d.ts +37 -0
  124. package/dist/holmes/semantic/tier.js +54 -0
  125. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  126. package/dist/holmes/semantic/vector-cache.js +91 -0
  127. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  128. package/dist/holmes/spec/acceptance-quality.js +169 -0
  129. package/dist/holmes/spec/validator.js +33 -1
  130. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  131. package/dist/holmes/spec/yaml-scalar.js +43 -0
  132. package/package.json +1 -1
@@ -38,18 +38,22 @@ exports.resolveRmSignals = resolveRmSignals;
38
38
  exports.readApprovalFromEnv = readApprovalFromEnv;
39
39
  exports.dumpsEnvInCommandPosition = dumpsEnvInCommandPosition;
40
40
  exports.normalizeHookInput = normalizeHookInput;
41
+ exports.preEditEvidenceGate = preEditEvidenceGate;
41
42
  exports.decideOnGateError = decideOnGateError;
42
43
  exports.isGovernedProject = isGovernedProject;
43
44
  exports.gateErrorDecision = gateErrorDecision;
44
45
  exports.wiredSpecsDir = wiredSpecsDir;
45
46
  exports.evaluateHook = evaluateHook;
46
47
  exports.readSpecsSync = readSpecsSync;
48
+ // @implements A-SPEC-278
47
49
  // @implements A-SPEC-194, A-SPEC-195
48
50
  const registry_1 = require("../messages/registry");
49
51
  const root_1 = require("../project/root");
50
52
  const fs = __importStar(require("node:fs"));
51
53
  const path = __importStar(require("node:path"));
54
+ const os = __importStar(require("node:os"));
52
55
  const node_child_process_1 = require("node:child_process");
56
+ const config_1 = require("../config/config");
53
57
  const phase_1 = require("../guardrail/phase");
54
58
  const spec_parser_1 = require("../spec/spec-parser");
55
59
  const spec_digest_1 = require("../spec/spec-digest");
@@ -72,6 +76,7 @@ const risk_gate_1 = require("../guardrail/risk-gate");
72
76
  const approval_queue_1 = require("../governance/approval-queue");
73
77
  const approval_grants_1 = require("../governance/approval-grants");
74
78
  const approval_blockers_1 = require("../spec/approval-blockers");
79
+ const impact_gate_1 = require("../guardrail/impact-gate");
75
80
  // Resolve per-rm-target git facts (H1 git-aware guardrail): is each recursive-rm target a
76
81
  // regenerable build artifact (gitignored), version-controlled source (tracked), or escaping the
77
82
  // project? This is the I/O half — the classifier stays pure and merely consumes these signals.
@@ -275,6 +280,29 @@ function normalizeHookInput(raw) {
275
280
  * and inventing a denial there is its own failure mode. And an operator — never the session — can
276
281
  * escape via `HOLMES_GATE_BYPASS`.
277
282
  */
283
+ /**
284
+ * @implements A-SPEC-278
285
+ * Pre-edit impact evidence, wired but OFF by default.
286
+ *
287
+ * `mode: 'off'` returns null before touching anything — a repository that never opted in must behave
288
+ * exactly as it did before this gate existed, and that includes not paying for a disk read. A
289
+ * covering `code-write` approval overrides it exactly as it overrides every other gate here, and
290
+ * actions that are not code or test writes are simply not this gate's business.
291
+ *
292
+ * The evidence digest comes from the ENVIRONMENT (`HOLMES_EVIDENCE`), never from the tool payload —
293
+ * the same rule the approval channel follows, because a session naming its own justification inside
294
+ * the request it is making is the session asserting its own authority.
295
+ */
296
+ function preEditEvidenceGate(o) {
297
+ if (o.mode === 'off')
298
+ return null;
299
+ if (o.action !== 'WRITE_CODE' && o.action !== 'WRITE_TEST')
300
+ return null;
301
+ if (o.covered)
302
+ return null;
303
+ const verdict = (0, impact_gate_1.evaluateEditEvidence)((0, impact_gate_1.loadEditEvidence)(o.projectRoot, o.relPath, o.declaredDigest, o.currentHead));
304
+ return (0, impact_gate_1.preEditEvidenceDecision)(verdict, o.mode);
305
+ }
278
306
  function decideOnGateError(opts) {
279
307
  if (opts.bypass) {
280
308
  return { permissionDecision: 'allow', permissionDecisionReason: `[Holmes-Kit] 내부 오류를 HOLMES_GATE_BYPASS로 우회했습니다: ${opts.message}` };
@@ -485,12 +513,22 @@ function evaluateHook(input, specsDir, opts) {
485
513
  // shapes below are inherited unchanged, which is deliberate: they were narrowed because
486
514
  // matching the bare word denied `grep HOLMES_APPROVAL src/*.ts`, and re-widening for the new
487
515
  // names would bring that false positive back wearing a new label.
488
- const SECRET = String.raw `HOLMES_(?:LEDGER_KEY|APPROVAL|ROLE|GATE_BYPASS)`;
516
+ // @implements A-SPEC-477 the semantic key joins the family: it IS the egress consent
517
+ // (REQ-476), so reading it is harvesting and ASSIGNING it is self-granted egress, the same
518
+ // class as HOLMES_ROLE. The GEMINI_API_KEY spelling is included because that is the
519
+ // ecosystem-compatible name the resolver honors; the narrowed usage forms below keep
520
+ // `grep GEMINI_API_KEY src/…` free exactly as they do for the HOLMES names.
521
+ const SECRET = String.raw `(?:HOLMES_(?:LEDGER_KEY|APPROVAL|ROLE|GATE_BYPASS|SEMANTIC_API_KEY)|GEMINI_API_KEY|GOOGLE_API_KEY)`;
489
522
  // Which of the two harms this is. Setting a role or a bypass is not reading a secret, it is
490
523
  // self-granting authority — reporting both as "reads the environment" sends an operator to
491
524
  // hunt a leak that never happened.
492
- const GRANTS_SELF = new RegExp(String.raw `\bHOLMES_(?:ROLE|GATE_BYPASS)\b`).test(command);
493
- const usesSecret = new RegExp(String.raw `\$\{?\s*${SECRET}\b`).test(command) || // $VAR / ${VAR} — a read
525
+ const GRANTS_SELF = new RegExp(String.raw `\b(?:HOLMES_(?:ROLE|GATE_BYPASS)|HOLMES_SEMANTIC_API_KEY|GEMINI_API_KEY|GOOGLE_API_KEY)\b`).test(command);
526
+ const usesSecret =
527
+ // @implements A-SPEC-477 — the credential file is the same secret at rest; reading,
528
+ // copying or redirecting it is the harvest in file form. Path-usage only, so mentioning
529
+ // the string in a search or a commit message stays free.
530
+ /(?:\bcat\b|\bcp\b|\bless\b|\bhead\b|\btail\b|[<>])[^;&|\n]*[.\/\\]holmes[\/\\]credentials/.test(command) ||
531
+ new RegExp(String.raw `\$\{?\s*${SECRET}\b`).test(command) || // $VAR / ${VAR} — a read
494
532
  new RegExp(String.raw `\b${SECRET}\s*=`).test(command) || // assignment — a self-issued approval / forged key
495
533
  // A quoted key only counts as a LOOKUP when something indexes or calls with it —
496
534
  // `env['VAR']`, `getenv("VAR")`, `ENVIRON["VAR"]`. A quoted bare word after a space is a
@@ -530,10 +568,92 @@ function evaluateHook(input, specsDir, opts) {
530
568
  // @implements A-SPEC-191 §28 (round 13) — the rule matched only redirection and `tee`, and
531
569
  // `cp`, `mv`, `install`, `ln -f`, `dd of=`, `rsync` and `sed -i` write the same file just as
532
570
  // well. A gate that names one syntax for an act is a gate over that syntax, not over the act.
533
- const CONFIG_FILE = '(?:\\.claude\\/settings[\\w.-]*\\.json|\\.mcp\\.json|\\.env(?!\\.(?:example|sample|template|dist))(?:\\.[\\w-]+)?)';
534
- const configWrite = new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*['"]?[^\\s;|&]*${CONFIG_FILE}\\b`).test(command)
535
- || new RegExp(`\\b(?:cp|mv|install|rsync|ln|dd|truncate|chmod|chown)\\b[^;|&]*${CONFIG_FILE}\\b`).test(command)
536
- || new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*${CONFIG_FILE}\\b`).test(command);
571
+ const CODE_EXT = '(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|cc|cxx|cpp|hh|hpp|rb|php|swift)';
572
+ const CONFIG_FILE = '(?:\\.claude[\\\\/]settings[\\w.-]*\\.json|\\.mcp\\.json|\\.env(?!\\.(?:example|sample|template|dist))(?:\\.[\\w-]+)?)';
573
+ // @implements A-SPEC-448
574
+ // Paths an INTERPRETER one-liner writes. The residual both shell rules recorded, closed by
575
+ // asking for the evidence of a write rather than for an interpreter's name near a pattern.
576
+ // That earlier draft denied `node -e "console.log(cfg.env.name)"` — a property read — and was
577
+ // rightly rejected; it could not tell reading from writing. Writing leaves two marks in the
578
+ // text: a write function's NAME, or an `open(path, mode)` whose mode literal is a write mode.
579
+ // `readFileSync(p,'utf8')` has a quoted second argument and is not one, which is exactly the
580
+ // distinction the rejected draft could not make. Measured before this was written: 5/5 writes
581
+ // caught, 0/8 reads misfired.
582
+ const interpreterWritePaths = () => {
583
+ // @implements A-SPEC-451 — the entry test used to ask which FLAG carried the program, and
584
+ // `python3 -` carries it on stdin with no flag at all. A heredoc therefore walked through
585
+ // with the write evidence and the path both in plain view. The negative lookahead keeps
586
+ // `-m`, `--version` and `-v` out: only a BARE hyphen means "read the program from stdin",
587
+ // which covers heredocs and pipes alike.
588
+ const INTERP = /\b(?:node|python[0-9.]*|ruby|perl|deno|bun|php)\b/;
589
+ const ONE_LINER = /\b(?:node|python[0-9.]*|ruby|perl|deno|bun|php)\b[^\n]*?(?:-e|--eval|-c|-r)\b/;
590
+ const STDIN_PROGRAM = /\b(?:node|python[0-9.]*|ruby|perl|deno|bun|php)\b\s+-(?![\w-])/;
591
+ if (!INTERP.test(command) || !(ONE_LINER.test(command) || STDIN_PROGRAM.test(command)))
592
+ return [];
593
+ // @implements A-SPEC-450 — the evidence and the path need not sit in the same CALL.
594
+ // A-SPEC-448 required that, so `p='<path>'; open(p,'w')` passed — a variable binding, not
595
+ // obfuscation, and the idiom this repository writes its own spec bodies with. The
596
+ // spec/ledger rule never had this hole because it matches the PATH anywhere in the command.
597
+ // Write evidence is still REQUIRED, which is what keeps reads of the same path allowed.
598
+ const WRITE_EVIDENCE = new RegExp(String.raw `\b(?:writeFileSync|appendFileSync|createWriteStream|write_text|writelines|File\.write|file_put_contents)\s*\(`
599
+ + '|' + String.raw `\bopen\s*\([^)]*,\s*['"](?:[wax]\+?[bt]?|r\+[bt]?)['"]`);
600
+ if (!WRITE_EVIDENCE.test(command))
601
+ return [];
602
+ // Assembled with String.raw throughout: a plain template literal collapses `\s` to `s`,
603
+ // which silently turned the character class into "any char but s" and broke every path
604
+ // containing that letter. Caught by a test, not by reading.
605
+ // @implements A-SPEC-463 — the path sits BETWEEN quotes here, so spaces inside it are part
606
+ // of the path, not an argument boundary. `[^'"\s]*` stopped at the first space and missed
607
+ // `open('C:\Users\이름 성\a.ts','w')`; `[^'"]*` keeps the space and still cannot cross a quote.
608
+ const NOT_QUOTE = String.raw `[^'"]*`;
609
+ const QUOTED_PATH = new RegExp(String.raw `['"](` + NOT_QUOTE + String.raw `\.` + CODE_EXT + '|' + NOT_QUOTE + CONFIG_FILE + String.raw `)['"]`, 'g');
610
+ const out = [];
611
+ for (const m of command.matchAll(QUOTED_PATH))
612
+ if (m[1])
613
+ out.push(m[1]);
614
+ return out;
615
+ };
616
+ const interpreterWrites = interpreterWritePaths();
617
+ // @implements A-SPEC-446
618
+ // Scope by LOCATION, as A-SPEC-445 did for the code rule — here the approximation ran the
619
+ // other way: no path anchor at all, so a `.env` anywhere on the machine matched. Measured
620
+ // 2026-08-30: `/tmp/demo/.env` and another repository's `.env` were denied with a reason
621
+ // about THIS session's hook config, which those files cannot touch. `~/.claude` stays in
622
+ // scope on purpose — user-level settings DO change this session's wiring, so narrowing to
623
+ // "inside the project" alone would open a self-grant path.
624
+ // @implements A-SPEC-463 — Windows W-2. The extraction below approximated a path with a
625
+ // character class that excludes spaces (`[^\s...]*`) preceded by an OPTIONAL opening quote
626
+ // that no closing quote ever paired. A quoted path with a space — the Windows default home is
627
+ // `C:\Users\<이름 성>` — matched only up to the first space, never reached the extension, and
628
+ // was captured as nothing, so the write was allowed. `pathAlt` is three alternatives: a
629
+ // double- or single-quoted path (spaces kept, up to the closing quote) or the unquoted form
630
+ // (still ends at the first shell word boundary — an un-escaped space is an argument break).
631
+ // Trailing whitespace before the closing quote is dropped so `"a.ts "` — which Windows strips
632
+ // back to the real file `a.ts` — is still judged as that file. `tail` is the extension part
633
+ // (code) or the whole CONFIG_FILE pattern; both are non-capturing, so `pathAlt` owns groups.
634
+ const pathAlt = (tail) => `(?:"([^"]*${tail})\\s*"|'([^']*${tail})\\s*'|([^\\s;|&'"<>]*${tail})\\b)`;
635
+ const firstGroup = (m) => {
636
+ for (let i = 1; i < m.length; i++)
637
+ if (m[i] !== undefined)
638
+ return m[i];
639
+ return undefined;
640
+ };
641
+ const cfgCandidates = [];
642
+ const cfgCollect = (re) => {
643
+ for (const m of command.matchAll(re)) {
644
+ const g = firstGroup(m);
645
+ if (g !== undefined)
646
+ cfgCandidates.push(g);
647
+ }
648
+ };
649
+ cfgCollect(new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*${pathAlt(CONFIG_FILE)}`, 'g'));
650
+ cfgCollect(new RegExp(`\\b(?:cp|mv|install|rsync|ln|dd|truncate|chmod|chown)\\b[^;|&]*?${pathAlt(CONFIG_FILE)}`, 'g'));
651
+ cfgCollect(new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*?${pathAlt(CONFIG_FILE)}`, 'g'));
652
+ // @implements A-SPEC-447 — one containment predicate, shared with the code rule below.
653
+ const SESSION_ROOTS = [opts.projectRoot, path.join(os.homedir(), '.claude')];
654
+ const CFG_RE = new RegExp(`${CONFIG_FILE}$`);
655
+ const configWrite = [...cfgCandidates, ...interpreterWrites.filter((p) => CFG_RE.test(p))]
656
+ .some((raw) => (0, write_target_1.resolvesInside)(raw, SESSION_ROOTS));
537
657
  // Residual, stated rather than papered over: an INTERPRETER can write these files too. A first
538
658
  // draft matched `node|python|perl|ruby` anywhere near the pattern and denied
539
659
  // `node -e "console.log(cfg.env.name)"` — reading a property named `env`. A rule that cannot
@@ -546,10 +666,53 @@ function evaluateHook(input, specsDir, opts) {
546
666
  // bypasses the No-Spec-No-Code Write/Edit gate entirely. Enforced only when this repo HAS
547
667
  // governed specs (a spec-less repo has no phase gate to bypass — keeps the guardrail
548
668
  // side-effect-free on ordinary ungoverned coding). Residual: interpreter-mediated writes.
549
- const CODE_EXT = '(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|cc|cxx|cpp|hh|hpp|rb|php|swift)';
550
- const shellCodeWrite = new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*['"]?(?![/~])[\\w@.-][\\w@./-]*\\.${CODE_EXT}\\b`).test(command) ||
551
- new RegExp(`\\btouch\\s+[^;|&]*(?<![/~])\\b[\\w@.-][\\w@./-]*\\.${CODE_EXT}\\b`).test(command) ||
552
- new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*\\.${CODE_EXT}\\b`).test(command);
669
+ // @implements A-SPEC-445
670
+ // Scope comes from where the path RESOLVES, not from how it is spelled. The previous rule
671
+ // approximated "inside the project" with `(?![/~])` — "does not look absolute" — and the
672
+ // approximation WAS the bypass: measured 2026-08-30, `echo x > src/a.ts` denied while
673
+ // `echo x > <absolute project path>/src/a.ts` was allowed. Same file, same content, one
674
+ // spelling apart. The same string test also over-blocked the other way, denying
675
+ // `> ../elsewhere/a.ts`, which leaves the governed tree entirely.
676
+ // @implements A-SPEC-463 — same quoted-space fix as the config rule; `EXT_TAIL` is the
677
+ // extension part and `pathAlt` supplies the prefix and the quoting alternatives.
678
+ const EXT_TAIL = `\\.${CODE_EXT}`;
679
+ const candidates = [];
680
+ const collect = (re) => {
681
+ for (const m of command.matchAll(re)) {
682
+ const g = firstGroup(m);
683
+ if (g !== undefined)
684
+ candidates.push(g);
685
+ }
686
+ };
687
+ collect(new RegExp(`(?:>{1,2}|\\btee\\b(?:\\s+-a)?\\s)\\s*${pathAlt(EXT_TAIL)}`, 'g'));
688
+ collect(new RegExp(`\\btouch\\s+[^;|&]*?${pathAlt(EXT_TAIL)}`, 'g'));
689
+ collect(new RegExp(`\\bsed\\b[^;|&]*\\s-i\\b[^;|&]*?${pathAlt(EXT_TAIL)}`, 'g'));
690
+ // @implements A-SPEC-449 — the copy/move family. The CONFIG rule has carried it since
691
+ // A-SPEC-191 §28 ("a gate that names one syntax for an act is a gate over that syntax, not
692
+ // over the act"); the code rule never got it, and `cp /tmp/e.ts src/a.ts` walked through.
693
+ // Only the TARGET counts: `cp src/a.ts /tmp/backup.ts` reads the project and writes outside,
694
+ // which is an ordinary backup and not this gate's business.
695
+ for (const seg of command.split(/[;|&]+/)) {
696
+ if (!/\b(?:cp|mv|install|rsync|ln|dd)\b/.test(seg))
697
+ continue;
698
+ const of = seg.match(new RegExp(`\\bof=${pathAlt(EXT_TAIL)}`));
699
+ if (of) {
700
+ const g = firstGroup(of);
701
+ if (g !== undefined) {
702
+ candidates.push(g);
703
+ continue;
704
+ }
705
+ }
706
+ const all = [...seg.matchAll(new RegExp(pathAlt(EXT_TAIL), 'g'))]
707
+ .map(firstGroup).filter((x) => x !== undefined);
708
+ if (all.length >= 2)
709
+ candidates.push(all[all.length - 1]); // last path is the destination
710
+ }
711
+ // @implements A-SPEC-447 — same predicate as the config rule; it lived in two copies, which
712
+ // is how one of two gets fixed.
713
+ const CODE_RE = new RegExp(`\\.${CODE_EXT}$`);
714
+ const shellCodeWrite = [...candidates, ...interpreterWrites.filter((p) => CODE_RE.test(p))]
715
+ .some((raw) => (0, write_target_1.resolvesInside)(raw, [opts.projectRoot]));
553
716
  if (shellCodeWrite) {
554
717
  const governed = readSpecsSync(specsDir).some((s) => s.status === 'approved');
555
718
  if (governed) {
@@ -1172,6 +1335,35 @@ function evaluateHook(input, specsDir, opts) {
1172
1335
  };
1173
1336
  }
1174
1337
  }
1338
+ // @implements A-SPEC-278
1339
+ // Pre-edit impact evidence, deliberately the LAST gate on this path.
1340
+ //
1341
+ // Dogfooding 2026-08-28 caught why the position matters: sitting earlier, `warn` mode returned its
1342
+ // allow-with-reason and SHORT-CIRCUITED the gates behind it — an un-specced write that `off`
1343
+ // correctly denied became an allow the moment someone switched the advisory mode on. An advisory
1344
+ // setting that quietly disables No-Spec-No-Code is worse than no setting. Last means this gate can
1345
+ // only ever ADD a refusal, never remove one.
1346
+ //
1347
+ // OFF unless the project opted in, so it is a no-op — and costs no disk or git — for every
1348
+ // repository that has not asked for it. A covering code-write approval overrides it as it does the
1349
+ // other gates.
1350
+ const preEditMode = (0, config_1.loadConfig)(projectRoot).guardrail.preEditEvidence;
1351
+ if (preEditMode !== 'off') {
1352
+ let evidenceHead = '';
1353
+ try {
1354
+ evidenceHead = (0, node_child_process_1.execFileSync)('git', ['-C', projectRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)() }).trim();
1355
+ }
1356
+ catch { /* non-git tree: the basis degrades to "" on both sides, never to a crash */ }
1357
+ const evidenceDecision = preEditEvidenceGate({
1358
+ action, projectRoot, relPath, covered: codeWriteCovered, mode: preEditMode,
1359
+ // ENVIRONMENT only (A-SPEC-133's rule): a digest inside the payload would be the session
1360
+ // vouching for itself.
1361
+ declaredDigest: process.env.HOLMES_EVIDENCE?.trim() || null,
1362
+ currentHead: evidenceHead,
1363
+ });
1364
+ if (evidenceDecision)
1365
+ return evidenceDecision;
1366
+ }
1175
1367
  return { permissionDecision: 'allow' };
1176
1368
  }
1177
1369
  // Synchronous spec loader for hook context (fail-open: directory read errors are skipped)
@@ -44,7 +44,72 @@ export interface StopEvidence {
44
44
  * cleared standing ART-7 debt — an unreadable ledger laundering the very state it records.
45
45
  */
46
46
  findingsUnreadable?: boolean;
47
+ /**
48
+ * @implements A-SPEC-452
49
+ * Changed source files carrying NO `@implements` anchor at all — ART-1's second enforcement point.
50
+ *
51
+ * `undefined` means NO SIGNAL (not a git repository, or git failed) and must not read as clean;
52
+ * an empty array means measured and clean. The distinction is the same one A-SPEC-191 §4a drew
53
+ * for the findings ledger, and for the same reason.
54
+ *
55
+ * Deliberately WEAKER than the write-time rule, which demands the anchor on line 1: measured on
56
+ * this repository, the strict form misfires on four legitimate files whose first line is
57
+ * `'use strict'`, an eslint directive, or an import, while this form reports nothing across 114
58
+ * changed source files and still catches a file with no anchor at all.
59
+ */
60
+ unanchoredChangedSources?: string[];
61
+ /**
62
+ * @implements A-SPEC-453
63
+ * Spec ids approved in the working tree with NO `spec-approved` record in the ledger — ART-5's
64
+ * second enforcement point.
65
+ *
66
+ * The seal cannot carry this weight: `approved_digest` is a plain content hash, so anyone able to
67
+ * write the file can produce a self-consistent approval. What a forgery cannot produce is the
68
+ * RECORD of the approving act. `undefined` is no signal, as everywhere else here.
69
+ *
70
+ * Scoped to CHANGED specs by measurement: this repository holds 89 approved specs with no ledger
71
+ * entry (other machines, and the era before the ledger split). Reporting those every turn would
72
+ * make the check noise; a forgery, by definition, changes a file.
73
+ */
74
+ unrecordedApprovals?: string[];
75
+ /**
76
+ * @implements A-SPEC-455
77
+ * Tracked provenance ledgers whose COMMITTED history is no longer a prefix of the working copy.
78
+ *
79
+ * The key does not cover this: measured 2026-08-30, wiping a ledger resets single-use and
80
+ * `verify()` still returns ok, because an empty chain is trivially valid. The judgement that
81
+ * catches it already existed inside `rechainLedger` and ran from the CLI only.
82
+ */
83
+ rolledBackLedgers?: string[];
47
84
  }
85
+ /**
86
+ * @implements A-SPEC-452
87
+ * ART-1 evidence: which changed source files claim nothing.
88
+ *
89
+ * The write gate that enforces ART-1 is text-based and a two-step chain evades it — a generator
90
+ * written OUTSIDE the project (correctly allowed) and then run (its contents are not in the
91
+ * command). Both verdicts are right; together they are no gate. This is the turn-boundary backstop.
92
+ *
93
+ * git is a REFINEMENT, never a requirement: no repository means no signal, which is `undefined`,
94
+ * which produces no violation. Failing closed here would block every ungoverned scratch directory.
95
+ */
96
+ export declare function unanchoredChangedSources(root: string): string[] | undefined;
97
+ /**
98
+ * @implements A-SPEC-453
99
+ * ART-5 evidence: approvals that happened without the approving act.
100
+ *
101
+ * git is a refinement — no repository means `undefined`, which produces no violation.
102
+ */
103
+ export declare function unrecordedApprovals(root: string): string[] | undefined;
104
+ /**
105
+ * @implements A-SPEC-455
106
+ * ART-2 evidence: ledgers whose committed history was rolled back.
107
+ *
108
+ * Uses the SAME predicate the rechain command uses — `mainlineIsPrefix` — so the two can never
109
+ * drift apart. Named limit: entries appended since the last commit have nothing to compare
110
+ * against, so this catches rollback of COMMITTED history, not deletion of uncommitted history.
111
+ */
112
+ export declare function rolledBackLedgers(root: string): string[] | undefined;
48
113
  /**
49
114
  * @implements A-SPEC-175
50
115
  * The turn-boundary answer to "this project is governed but its specs are gone".
@@ -34,6 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.MAX_CONSECUTIVE_BLOCKS = void 0;
37
+ exports.unanchoredChangedSources = unanchoredChangedSources;
38
+ exports.unrecordedApprovals = unrecordedApprovals;
39
+ exports.rolledBackLedgers = rolledBackLedgers;
37
40
  exports.governanceLostPreflight = governanceLostPreflight;
38
41
  exports.evaluateStop = evaluateStop;
39
42
  exports.stopDebtAction = stopDebtAction;
@@ -57,6 +60,171 @@ const pre_tool_use_1 = require("./pre-tool-use");
57
60
  const governance_history_1 = require("../guardrail/governance-history");
58
61
  const constitution_debt_1 = require("../governance/constitution-debt");
59
62
  const root_1 = require("../project/root");
63
+ /**
64
+ * @implements A-SPEC-452
65
+ * ART-1 evidence: which changed source files claim nothing.
66
+ *
67
+ * The write gate that enforces ART-1 is text-based and a two-step chain evades it — a generator
68
+ * written OUTSIDE the project (correctly allowed) and then run (its contents are not in the
69
+ * command). Both verdicts are right; together they are no gate. This is the turn-boundary backstop.
70
+ *
71
+ * git is a REFINEMENT, never a requirement: no repository means no signal, which is `undefined`,
72
+ * which produces no violation. Failing closed here would block every ungoverned scratch directory.
73
+ */
74
+ function unanchoredChangedSources(root) {
75
+ const SOURCE = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|py|go|rs|java|kt|cs|rb|php|swift)$/;
76
+ const VENDORED = /^(?:reference|node_modules|dist|build|vendor|third_party)\//;
77
+ let raw;
78
+ try {
79
+ // `-uall`: without it git folds a wholly-untracked directory into one `?? src/` line and the
80
+ // files inside it are never seen — which is precisely where a bypassed write lands.
81
+ raw = (0, node_child_process_1.execFileSync)('git', ['status', '--porcelain', '-uall'], {
82
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
83
+ });
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ const out = [];
89
+ for (const line of raw.split('\n')) {
90
+ if (line.trim() === '')
91
+ continue;
92
+ let rel = line.slice(3).trim().replace(/^"|"$/g, '');
93
+ if (rel.includes(' -> '))
94
+ rel = rel.split(' -> ')[1]; // renames name the destination
95
+ if (!SOURCE.test(rel) || VENDORED.test(rel))
96
+ continue;
97
+ let text;
98
+ try {
99
+ text = fs.readFileSync(path.join(root, rel), 'utf8');
100
+ }
101
+ catch {
102
+ continue;
103
+ } // deleted, unreadable
104
+ if (!text.includes('@implements'))
105
+ out.push(rel);
106
+ }
107
+ return out;
108
+ }
109
+ /**
110
+ * @implements A-SPEC-453
111
+ * ART-5 evidence: approvals that happened without the approving act.
112
+ *
113
+ * git is a refinement — no repository means `undefined`, which produces no violation.
114
+ */
115
+ function unrecordedApprovals(root) {
116
+ let raw;
117
+ try {
118
+ raw = (0, node_child_process_1.execFileSync)('git', ['status', '--porcelain', '-uall'], {
119
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
120
+ });
121
+ }
122
+ catch {
123
+ return undefined;
124
+ }
125
+ const approvedIds = [];
126
+ for (const line of raw.split('\n')) {
127
+ if (line.trim() === '')
128
+ continue;
129
+ let rel = line.slice(3).trim().replace(/^"|"$/g, '');
130
+ if (rel.includes(' -> '))
131
+ rel = rel.split(' -> ')[1];
132
+ if (!rel.endsWith('.md') || !rel.replace(/\\/g, '/').includes('/specs/'))
133
+ continue;
134
+ let text;
135
+ try {
136
+ text = fs.readFileSync(path.join(root, rel), 'utf8');
137
+ }
138
+ catch {
139
+ continue;
140
+ }
141
+ const head = text.slice(0, 4000);
142
+ const id = /^id:\s*(\S+)\s*$/m.exec(head)?.[1];
143
+ const status = /^status:\s*(\S+)\s*$/m.exec(head)?.[1];
144
+ if (id !== undefined && status === 'approved')
145
+ approvedIds.push(id);
146
+ }
147
+ if (approvedIds.length === 0)
148
+ return [];
149
+ // Which ids the ledger records an approving act for. Unreadable ledger -> no signal, rather than
150
+ // a wave of false accusations.
151
+ const recorded = new Set();
152
+ const dir = path.join(root, '.ax', 'ledger');
153
+ let files;
154
+ try {
155
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
156
+ }
157
+ catch {
158
+ return undefined;
159
+ }
160
+ for (const f of files) {
161
+ let text;
162
+ try {
163
+ text = fs.readFileSync(path.join(dir, f), 'utf8');
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ for (const line of text.split('\n')) {
169
+ if (!line.includes('"spec-approved"'))
170
+ continue;
171
+ for (const m of line.matchAll(/\b(?:REQ|H-SPEC|A-SPEC|C-SPEC|T-SPEC)-[0-9.]+/g))
172
+ recorded.add(m[0]);
173
+ }
174
+ }
175
+ return approvedIds.filter((id) => !recorded.has(id));
176
+ }
177
+ /**
178
+ * @implements A-SPEC-455
179
+ * ART-2 evidence: ledgers whose committed history was rolled back.
180
+ *
181
+ * Uses the SAME predicate the rechain command uses — `mainlineIsPrefix` — so the two can never
182
+ * drift apart. Named limit: entries appended since the last commit have nothing to compare
183
+ * against, so this catches rollback of COMMITTED history, not deletion of uncommitted history.
184
+ */
185
+ function rolledBackLedgers(root) {
186
+ const git = (args) => (0, node_child_process_1.execFileSync)('git', args, {
187
+ cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: (0, root_1.cleanSubprocessEnv)(),
188
+ });
189
+ let tracked;
190
+ try {
191
+ tracked = git(['ls-files', '.ax/ledger']).split('\n')
192
+ .map((l) => l.trim()).filter((l) => /provenance[\w.-]*\.jsonl$/.test(l));
193
+ }
194
+ catch {
195
+ return undefined;
196
+ }
197
+ const parse = (text) => text.split('\n')
198
+ .map((l) => l.trim()).filter((l) => l !== '')
199
+ .map((l) => { try {
200
+ return JSON.parse(l);
201
+ }
202
+ catch {
203
+ return null;
204
+ } })
205
+ .filter((e) => e !== null);
206
+ const { mainlineIsPrefix } = require('../governance/ledger-rechain');
207
+ const out = [];
208
+ for (const rel of tracked) {
209
+ let committed;
210
+ try {
211
+ committed = git(['show', `HEAD:${rel}`]);
212
+ }
213
+ catch {
214
+ continue;
215
+ } // no HEAD, or newly added
216
+ let working;
217
+ try {
218
+ working = fs.readFileSync(path.join(root, rel), 'utf8');
219
+ }
220
+ catch {
221
+ working = '';
222
+ }
223
+ if (!mainlineIsPrefix(parse(committed), parse(working)))
224
+ out.push(rel);
225
+ }
226
+ return out;
227
+ }
60
228
  /**
61
229
  * @implements A-SPEC-175
62
230
  * The turn-boundary answer to "this project is governed but its specs are gone".
@@ -97,6 +265,27 @@ function evaluateStop(specs, evidence) {
97
265
  problems.push('[ART-7] findings 원장을 읽을 수 없습니다 — 열린 치명 발견의 존재를 확인할 수 없는 턴은 깨끗한 턴이 아닙니다 (원장 파일의 권한·형식을 복구하십시오)');
98
266
  structured.push({ article: 'ART-7', detail: 'findings 원장을 읽을 수 없습니다' });
99
267
  }
268
+ // @implements A-SPEC-452 — ART-1's second enforcement point. `undefined` is no signal and says
269
+ // nothing; an empty array is measured and clean. A file that claims nothing is a file the write
270
+ // gate never judged, which is exactly what the two-step generator bypass produces.
271
+ for (const file of evidence?.unanchoredChangedSources ?? []) {
272
+ const detail = `${file}: 변경된 소스에 @implements 앵커가 없습니다 — 이 파일이 무엇을 구현하는지 말하지 않습니다 (승인된 A-SPEC 을 만들고 앵커를 다십시오)`;
273
+ problems.push(`[ART-1] ${detail}`);
274
+ structured.push({ article: 'ART-1', detail });
275
+ }
276
+ // @implements A-SPEC-453 — ART-5's second enforcement point. The seal is forgeable; the record
277
+ // of the approving act is not.
278
+ for (const id of evidence?.unrecordedApprovals ?? []) {
279
+ const detail = `${id}: 승인 상태인데 원장에 승인 행위 기록이 없습니다 — 봉인은 내용 해시라 스스로를 증명하지 못합니다 (spec_approve 로 승인하십시오)`;
280
+ problems.push(`[ART-5] ${detail}`);
281
+ structured.push({ article: 'ART-5', detail });
282
+ }
283
+ // @implements A-SPEC-455
284
+ for (const file of evidence?.rolledBackLedgers ?? []) {
285
+ const detail = `${file}: 커밋된 원장 이력이 되돌려졌습니다 — 작업본이 커밋본을 접두로 포함하지 않습니다 (원장은 덧붙이기만 합니다; holmes-kit ledger rechain 으로 확인하십시오)`;
286
+ problems.push(`[ART-2] ${detail}`);
287
+ structured.push({ article: 'ART-2', detail });
288
+ }
100
289
  if (problems.length === 0)
101
290
  return { block: false };
102
291
  const shown = problems.slice(0, 20);
@@ -104,7 +293,10 @@ function evaluateStop(specs, evidence) {
104
293
  // @implements A-SPEC-134 — the distinct articles feed the constitution-debt state on a cap-yield.
105
294
  const articles = [...new Set(violations.map((x) => x.article)
106
295
  .concat(evidence?.provenance && !evidence.provenance.ok ? ['ART-2'] : [])
107
- .concat(evidence?.findingsUnreadable ? ['ART-7'] : []))];
296
+ .concat(evidence?.findingsUnreadable ? ['ART-7'] : [])
297
+ .concat((evidence?.unanchoredChangedSources ?? []).length > 0 ? ['ART-1'] : [])
298
+ .concat((evidence?.unrecordedApprovals ?? []).length > 0 ? ['ART-5'] : [])
299
+ .concat((evidence?.rolledBackLedgers ?? []).length > 0 ? ['ART-2'] : []))];
108
300
  return {
109
301
  block: true,
110
302
  articles,
@@ -492,7 +684,13 @@ if (require.main === module) {
492
684
  catch {
493
685
  findingsUnreadable = true;
494
686
  } // @implements A-SPEC-191 (§4a) — list() absorbs ENOENT; a throw means the ledger EXISTS and cannot be read, which must block, not launder
495
- let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable });
687
+ // @implements A-SPEC-452 collected here (I/O half) so evaluateStop stays pure.
688
+ const unanchored = unanchoredChangedSources(stopProjectRoot());
689
+ // @implements A-SPEC-453
690
+ const unrecorded = unrecordedApprovals(stopProjectRoot());
691
+ // @implements A-SPEC-455
692
+ const rolledBack = rolledBackLedgers(stopProjectRoot());
693
+ let out = evaluateStop(specs, { testCasesByAspec, provenance, executedByAspec, findings, findingsUnreadable, unanchoredChangedSources: unanchored, unrecordedApprovals: unrecorded, rolledBackLedgers: rolledBack });
496
694
  // @implements A-SPEC-247 — before deciding to re-block, ask whether every unresolved debt is
497
695
  // already queued for the owner. If so, tell the user ONCE and let the turn finish; a single
498
696
  // non-waiting violation and we block exactly as before.
@@ -26,12 +26,42 @@ export interface ElicitApprovalRequest {
26
26
  /** Human-facing one-liner: what is being approved (id, title, sealed-or-not). */
27
27
  summary: string;
28
28
  }
29
- /** The human's decision. `null` from an elicitor means "the channel gave no answer fall back". */
29
+ /** The human's decision. `null` from interpretElicitResult means "no answer in this shape". */
30
30
  export interface ElicitDecision {
31
31
  granted: boolean;
32
32
  reason?: string;
33
33
  }
34
- export type Elicitor = (req: ElicitApprovalRequest) => Promise<ElicitDecision | null>;
34
+ /**
35
+ * The three fates of an elicitation, told apart. The old `Elicitor` returned decision-or-null, and
36
+ * the wiring's `catch { return null }` erased the SDK's RequestTimeout — so an expired dialog (the
37
+ * human saw dead arrows) and an absent capability (the human saw nothing at all) wore the same
38
+ * refusal. Only `expired` earns a name in the message; `silent` stays byte-identical to the
39
+ * pre-elicitation refusal (A-SPEC-263.1's lossless-degradation promise, kept).
40
+ */
41
+ export type ElicitOutcome = {
42
+ kind: 'answered';
43
+ decision: ElicitDecision;
44
+ } | {
45
+ kind: 'expired';
46
+ waitedMs: number;
47
+ } | {
48
+ kind: 'silent';
49
+ };
50
+ export type Elicitor = (req: ElicitApprovalRequest) => Promise<ElicitOutcome>;
51
+ /** The one timeout truth: the SDK option, the dialog forewarning and `waitedMs` all derive from it. */
52
+ export declare const ELICIT_TIMEOUT_MS = 120000;
53
+ /**
54
+ * Classify an elicitInput rejection. Only the exact SDK timeout code — on a real Error — is
55
+ * `expired`; a message that merely SAYS "timed out", a near-miss code, or a code on a non-Error
56
+ * is `silent`, because the expiry face carries friendlier guidance and must not be spoofable.
57
+ */
58
+ export declare function classifyElicitError(e: unknown): ElicitOutcome;
59
+ /**
60
+ * The expiry notice that LEADS an expired refusal: what happened (the session dialog expired),
61
+ * where the request went (the approval queue), and where the decision still lives (the CLI).
62
+ * Template-owned text with no interpolated attacker data.
63
+ */
64
+ export declare function expiredNotice(waitedMs: number): string;
35
65
  /**
36
66
  * The question form. Decisions only (REQ-263 Out): the single free-text field is `reason`, and it
37
67
  * is subordinate to the decision — this channel never collects arbitrary input.