@holmes-lab/holmes-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
- package/dist/holmes/assoc/assoc-arm.js +187 -0
- package/dist/holmes/assoc/explore.d.ts +21 -0
- package/dist/holmes/assoc/explore.js +160 -0
- package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
- package/dist/holmes/assoc/impact-baseline.js +48 -0
- package/dist/holmes/assoc/ppr.d.ts +64 -0
- package/dist/holmes/assoc/ppr.js +110 -0
- package/dist/holmes/cli/agents.d.ts +24 -11
- package/dist/holmes/cli/agents.js +93 -17
- package/dist/holmes/cli/codex-toml.d.ts +26 -0
- package/dist/holmes/cli/codex-toml.js +282 -0
- package/dist/holmes/cli/doctor.d.ts +56 -0
- package/dist/holmes/cli/doctor.js +348 -18
- package/dist/holmes/cli/index.js +16 -2
- package/dist/holmes/cli/init.js +78 -0
- package/dist/holmes/cli/interactive-prompt.js +4 -4
- package/dist/holmes/cli/mcp-launcher.d.ts +2 -2
- package/dist/holmes/cli/semantic-key.d.ts +19 -0
- package/dist/holmes/cli/semantic-key.js +93 -0
- package/dist/holmes/config/config.d.ts +9 -0
- package/dist/holmes/config/config.js +8 -1
- package/dist/holmes/cpg/language-capability.d.ts +65 -0
- package/dist/holmes/cpg/language-capability.js +145 -0
- package/dist/holmes/cpg/language-parser-walk.js +179 -34
- package/dist/holmes/cpg/language-parser.d.ts +1 -1
- package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
- package/dist/holmes/governance/ledger-rechain.js +17 -2
- package/dist/holmes/governance/provenance-ledger.js +21 -0
- package/dist/holmes/guardrail/blind-spots.js +12 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
- package/dist/holmes/guardrail/impact-gate.js +263 -0
- package/dist/holmes/guardrail/write-target.d.ts +38 -1
- package/dist/holmes/guardrail/write-target.js +48 -4
- package/dist/holmes/hooks/adapters/antigravity.js +12 -1
- package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
- package/dist/holmes/hooks/corrupt-state-run.js +16 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
- package/dist/holmes/hooks/pre-tool-use.js +203 -11
- package/dist/holmes/hooks/stop.d.ts +65 -0
- package/dist/holmes/hooks/stop.js +200 -2
- package/dist/holmes/mcp/handlers.d.ts +88 -0
- package/dist/holmes/mcp/handlers.js +597 -16
- package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
- package/dist/holmes/mcp/maintenance-analyze.js +994 -0
- package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
- package/dist/holmes/mcp/maintenance-evidence.js +253 -0
- package/dist/holmes/mcp/tool-schemas.js +71 -0
- package/dist/holmes/project/root.js +3 -1
- package/dist/holmes/review/baseline-arm.d.ts +37 -0
- package/dist/holmes/review/baseline-arm.js +51 -0
- package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
- package/dist/holmes/review/captured-stdin-guard.js +48 -0
- package/dist/holmes/review/coherence-verify.d.ts +31 -0
- package/dist/holmes/review/coherence-verify.js +144 -0
- package/dist/holmes/review/commit-text.d.ts +50 -0
- package/dist/holmes/review/commit-text.js +76 -0
- package/dist/holmes/review/confidence-calibration.d.ts +39 -0
- package/dist/holmes/review/confidence-calibration.js +39 -0
- package/dist/holmes/review/content-baseline.d.ts +38 -0
- package/dist/holmes/review/content-baseline.js +103 -0
- package/dist/holmes/review/content-verify.d.ts +20 -0
- package/dist/holmes/review/content-verify.js +73 -0
- package/dist/holmes/review/dense-retrieval.d.ts +66 -0
- package/dist/holmes/review/dense-retrieval.js +97 -0
- package/dist/holmes/review/edge-quality.d.ts +44 -0
- package/dist/holmes/review/edge-quality.js +117 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
- package/dist/holmes/review/evaluation-metrics.js +175 -0
- package/dist/holmes/review/graph-verifier.d.ts +34 -0
- package/dist/holmes/review/graph-verifier.js +62 -0
- package/dist/holmes/review/hop-ablation.d.ts +100 -0
- package/dist/holmes/review/hop-ablation.js +89 -0
- package/dist/holmes/review/manual-baseline.d.ts +209 -0
- package/dist/holmes/review/manual-baseline.js +2846 -0
- package/dist/holmes/review/oracle-gap.d.ts +32 -0
- package/dist/holmes/review/oracle-gap.js +102 -0
- package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
- package/dist/holmes/review/point-in-time-replay.js +161 -0
- package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
- package/dist/holmes/review/rank-diagnosis.js +163 -0
- package/dist/holmes/review/replay-calibration.d.ts +62 -0
- package/dist/holmes/review/replay-calibration.js +83 -0
- package/dist/holmes/review/replay-corpus.d.ts +135 -0
- package/dist/holmes/review/replay-corpus.js +210 -0
- package/dist/holmes/review/run-replay.d.ts +260 -0
- package/dist/holmes/review/run-replay.js +729 -0
- package/dist/holmes/review/semantic-arm.d.ts +271 -0
- package/dist/holmes/review/semantic-arm.js +717 -0
- package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
- package/dist/holmes/review/semantic-retrieval.js +156 -0
- package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
- package/dist/holmes/review/spec-layer-stats.js +52 -0
- package/dist/holmes/review/temporal-prior.d.ts +33 -0
- package/dist/holmes/review/temporal-prior.js +53 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +41 -4
- package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
- package/dist/holmes/review/test-selection-breadth.js +57 -0
- package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
- package/dist/holmes/review/traceability-benchmark.js +135 -0
- package/dist/holmes/review/union-verify.d.ts +12 -0
- package/dist/holmes/review/union-verify.js +70 -0
- package/dist/holmes/rtm/graph-store.d.ts +51 -0
- package/dist/holmes/rtm/graph-store.js +122 -0
- package/dist/holmes/rtm/incremental.d.ts +25 -1
- package/dist/holmes/rtm/incremental.js +18 -1
- package/dist/holmes/rtm/localize.d.ts +28 -0
- package/dist/holmes/rtm/localize.js +272 -14
- package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
- package/dist/holmes/rtm/rtm-builder.js +265 -28
- package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
- package/dist/holmes/rtm/rtm-graph.js +194 -31
- package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
- package/dist/holmes/rtm/taint-benchmark.js +141 -0
- package/dist/holmes/rtm/test-scope.js +24 -1
- package/dist/holmes/semantic/credentials.d.ts +15 -0
- package/dist/holmes/semantic/credentials.js +134 -0
- package/dist/holmes/semantic/embedder.d.ts +44 -0
- package/dist/holmes/semantic/embedder.js +185 -0
- package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
- package/dist/holmes/semantic/hit-rerank.js +38 -0
- package/dist/holmes/semantic/tier.d.ts +37 -0
- package/dist/holmes/semantic/tier.js +54 -0
- package/dist/holmes/semantic/vector-cache.d.ts +11 -0
- package/dist/holmes/semantic/vector-cache.js +91 -0
- package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
- package/dist/holmes/spec/acceptance-quality.js +169 -0
- package/dist/holmes/spec/validator.js +33 -1
- package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
- package/dist/holmes/spec/yaml-scalar.js +43 -0
- 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
|
-
|
|
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 `\
|
|
493
|
-
const usesSecret =
|
|
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
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
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.
|