codictator 0.1.3 → 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.
@@ -0,0 +1,143 @@
1
+ // SPEC §4.19.6: one-shot adoption. Flags mirror init (§4.19.2); the run
2
+ // is the init path (or regen over an existing config) plus install, hooks
3
+ // and verify, ending at the extension handoff (§4.20.1). Every step
4
+ // delegates to the existing init, guardrail and verify handlers; no step
5
+ // logic is duplicated here.
6
+ import { ENGINE_VERSION } from "../features/config/index.js";
7
+ import { err, ok } from "../kernel/index.js";
8
+ import { configureHooksPath, handleRegen, writeHookFiles, } from "./guardrail.js";
9
+ import { parseInitFlags } from "./init.js";
10
+ import { defaultHarnesses, initPreamble, runInitSequence, } from "./init-plan.js";
11
+ import { handleVerify } from "./inspect.js";
12
+ import { setupGitignoreStep } from "./setup-gitignore.js";
13
+ import { hasEffectivePacks, SETUP_EXTENSION_ID, SETUP_EXTENSION_INSTALL, setupHandoffLines, } from "./setup-handoff.js";
14
+ import { setupInstallHere, setupInstallLayout } from "./setup-install.js";
15
+ import { fail, MIN_NODE_MAJOR, nodeMajor, nodeTooOldMessage, printHuman, printJson, readStateText, usageError, } from "./shared.js";
16
+ export const SETUP_USAGE = "usage: codictator setup [--harness <id> ...] [--profile <app|library|cli|service>] [--pack <id> ...] [--tool <role>=<command>@<pin> ...] [--repair]";
17
+ // SPEC §4.19.6 step 1: the Node floor, the human gate and the repo root.
18
+ async function setupPreflight(req) {
19
+ const version = req.nodeVersion ?? process.version;
20
+ if (nodeMajor(version) < MIN_NODE_MAJOR) {
21
+ return fail("CDX-E002", nodeTooOldMessage(version), "Install a newer Node, then retry the tool call.", req.json);
22
+ }
23
+ return initPreamble(req);
24
+ }
25
+ // SPEC §4.19.6 step 2: the adoption path. An existing config without
26
+ // --repair re-renders from config; otherwise the init path runs.
27
+ async function setupRegenPath(req, steps) {
28
+ const regen = await handleRegen({ ...req, args: [] });
29
+ if (!regen.ok) {
30
+ return regen;
31
+ }
32
+ steps.push({ step: "init", ok: true, detail: { mode: "regen" } });
33
+ printHuman("setup: guardrails regenerated from the existing config", req.quiet);
34
+ return ok(0);
35
+ }
36
+ async function setupInitPath(req, root, flags, steps) {
37
+ const engineVersion = req.engineVersion ?? ENGINE_VERSION;
38
+ const sequenced = await runInitSequence(root, req, flags, defaultHarnesses(flags), engineVersion);
39
+ if (!sequenced.ok) {
40
+ return sequenced;
41
+ }
42
+ steps.push({
43
+ step: "init",
44
+ ok: true,
45
+ detail: {
46
+ mode: flags.repair ? "repair" : "fresh",
47
+ files: sequenced.value.written.map((entry) => entry.path),
48
+ },
49
+ });
50
+ printHuman("setup: guardrails written from the init path", req.quiet);
51
+ return ok(0);
52
+ }
53
+ async function setupAdopt(req, root, flags, steps) {
54
+ const existing = await readStateText(root, ".codictator/config.json");
55
+ if (existing !== undefined && !flags.repair) {
56
+ return setupRegenPath(req, steps);
57
+ }
58
+ return setupInitPath(req, root, flags, steps);
59
+ }
60
+ // SPEC §4.19.6 step 6: hooks via the install-hooks writers.
61
+ async function setupHooksStep(req, root, steps) {
62
+ const written = await writeHookFiles(root);
63
+ if (!written.ok) {
64
+ return fail("CDX-E903", written.error.message, written.error.remediation, req.json);
65
+ }
66
+ await configureHooksPath(root, req);
67
+ steps.push({ step: "hooks", ok: true });
68
+ printHuman("setup: hooks installed", req.quiet);
69
+ return ok(0);
70
+ }
71
+ // SPEC §4.19.6 step 7: the first evaluation over the adopted repo. A
72
+ // blocking verdict stops the run with verify's own mapping (exit 1).
73
+ async function setupVerifyStep(req, steps) {
74
+ const verdict = await handleVerify({ ...req, args: [] });
75
+ if (!verdict.ok || verdict.value !== 0) {
76
+ return verdict;
77
+ }
78
+ steps.push({ step: "verify", ok: true });
79
+ return ok(0);
80
+ }
81
+ // SPEC §4.19.6 step 8 + §4.20.1: the views connect once a config exists;
82
+ // the human installs the extension with the printed command.
83
+ function setupHandoff(req, root, steps, emptyPacks) {
84
+ steps.push({
85
+ step: "handoff",
86
+ ok: true,
87
+ detail: { extension: SETUP_EXTENSION_ID },
88
+ });
89
+ if (req.json) {
90
+ printJson({
91
+ setup: true,
92
+ root,
93
+ steps,
94
+ extension: { id: SETUP_EXTENSION_ID, install: SETUP_EXTENSION_INSTALL },
95
+ });
96
+ }
97
+ else {
98
+ for (const line of setupHandoffLines(emptyPacks)) {
99
+ printHuman(line, req.quiet);
100
+ }
101
+ }
102
+ return ok(0);
103
+ }
104
+ // SPEC §4.19.6: the eight adoption steps in order; a step failure stops
105
+ // the run with that step's §8.2 mapping.
106
+ export async function handleSetup(req) {
107
+ const flags = parseInitFlags(req.args);
108
+ if (flags === undefined) {
109
+ return usageError(SETUP_USAGE);
110
+ }
111
+ const preflight = await setupPreflight(req);
112
+ if (typeof preflight !== "string") {
113
+ return preflight;
114
+ }
115
+ const root = preflight;
116
+ const steps = [{ step: "preflight", ok: true }];
117
+ const adopted = await setupAdopt(req, root, flags, steps);
118
+ if (!adopted.ok) {
119
+ return adopted;
120
+ }
121
+ const ignored = await setupGitignoreStep(req, root, steps);
122
+ if (!ignored.ok) {
123
+ return ignored;
124
+ }
125
+ const layout = await setupInstallLayout(req, root, steps);
126
+ if (!layout.ok) {
127
+ return err(layout.error);
128
+ }
129
+ const installed = await setupInstallHere(req, root, layout.value, steps);
130
+ if (!installed.ok) {
131
+ return installed;
132
+ }
133
+ const hooked = await setupHooksStep(req, root, steps);
134
+ if (!hooked.ok) {
135
+ return hooked;
136
+ }
137
+ const verified = await setupVerifyStep(req, steps);
138
+ if (!verified.ok) {
139
+ return verified;
140
+ }
141
+ const emptyPacks = !(await hasEffectivePacks(root, flags));
142
+ return setupHandoff(req, root, steps, emptyPacks);
143
+ }
@@ -0,0 +1,15 @@
1
+ export interface HarnessFile {
2
+ readonly path: string;
3
+ readonly content: string;
4
+ readonly owner: string;
5
+ }
6
+ export declare function renderHarnessFiles(harnesses: ReadonlyArray<string>, root: string): Array<HarnessFile>;
7
+ export declare function withCodictatorOwner(files: ReadonlyArray<{
8
+ path: string;
9
+ content: string;
10
+ }>): Array<{
11
+ path: string;
12
+ content: string;
13
+ owner: string;
14
+ mode: "file";
15
+ }>;
@@ -0,0 +1,85 @@
1
+ // SPEC §4.5 + §4.13.1 step 2: built-in harness file rendering for the
2
+ // guardrail plan. Each known harness id dispatches to its own adapter's
3
+ // render, so L1 enforcement ships with init and regen; the adapters stay
4
+ // the sole owners of their file shapes (§3.3).
5
+ import { claudeCodeAdapter } from "./claude-code.js";
6
+ import { codexCliAdapter } from "./codex-cli.js";
7
+ import { copilotAdapter } from "./copilot.js";
8
+ import { cursorAdapter } from "./cursor.js";
9
+ import { geminiCliAdapter } from "./gemini-cli.js";
10
+ import { museAdapter } from "./muse-code.js";
11
+ import { opencodeAdapter } from "./opencode.js";
12
+ // SPEC §4.5.8 EC-12: regen input is fixed, so unchanged config reproduces
13
+ // every harness file byte for byte. All built-in renders ignore the input;
14
+ // the cwd is still threaded through honestly for the adapters that read it.
15
+ function regenInput(harness, root) {
16
+ return {
17
+ event: {
18
+ harness,
19
+ event: "session.start",
20
+ trigger: "startup",
21
+ sessionId: "regen",
22
+ cwd: root,
23
+ },
24
+ decision: { decision: "allow", eventId: "regen" },
25
+ };
26
+ }
27
+ // SPEC §4.14.1: every generated artifact carries its adapter owner.
28
+ // Unknown non-custom ids are skipped here because config validation owns
29
+ // CDX-E301 and rejects them there; this render path never emits that error.
30
+ export function renderHarnessFiles(harnesses, root) {
31
+ const ids = [...new Set(harnesses)].sort();
32
+ const files = [];
33
+ for (const harness of ids) {
34
+ if (harness.startsWith("custom:")) {
35
+ continue;
36
+ }
37
+ const input = regenInput(harness, root);
38
+ if (harness === museAdapter.id) {
39
+ for (const artifact of museAdapter.render(input)) {
40
+ files.push({ ...artifact, owner: harness });
41
+ }
42
+ }
43
+ else if (harness === claudeCodeAdapter.id) {
44
+ for (const artifact of claudeCodeAdapter.render(input)) {
45
+ files.push({ ...artifact, owner: harness });
46
+ }
47
+ }
48
+ else if (harness === opencodeAdapter.id) {
49
+ for (const artifact of opencodeAdapter.render(input)) {
50
+ files.push({ ...artifact, owner: harness });
51
+ }
52
+ }
53
+ else if (harness === copilotAdapter.id) {
54
+ for (const artifact of copilotAdapter.render(input)) {
55
+ files.push({ ...artifact, owner: harness });
56
+ }
57
+ }
58
+ else if (harness === codexCliAdapter.id) {
59
+ for (const artifact of codexCliAdapter.render(input)) {
60
+ files.push({ ...artifact, owner: harness });
61
+ }
62
+ }
63
+ else if (harness === cursorAdapter.id) {
64
+ for (const artifact of cursorAdapter.render(input)) {
65
+ files.push({ ...artifact, owner: harness });
66
+ }
67
+ }
68
+ else if (harness === geminiCliAdapter.id) {
69
+ for (const artifact of geminiCliAdapter.render(input)) {
70
+ files.push({ ...artifact, owner: harness });
71
+ }
72
+ }
73
+ }
74
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
75
+ return files;
76
+ }
77
+ // SPEC §4.14.1: custom fragments carry no owner of their own, so they keep
78
+ // the historical codictator owner instead of inheriting a harness.
79
+ export function withCodictatorOwner(files) {
80
+ return files.map((entry) => ({
81
+ ...entry,
82
+ owner: "codictator",
83
+ mode: "file",
84
+ }));
85
+ }
@@ -10,6 +10,7 @@ import { CURSOR_CAPABILITIES, cursorAdapter, normalizeCursorToolName, parseCurso
10
10
  import { checkCustomContract, loadCustomAdapter, renderCustomHarnessFiles, renderCustomRegenFragment } from "./custom.ts";
11
11
  import { geminiCliAdapter, parseGeminiEvent, replyGemini, staticBlockGemini } from "./gemini-cli.ts";
12
12
  import { GEMINI_CAPABILITIES, normalizeGeminiToolName } from "./gemini-tables.ts";
13
+ import { renderHarnessFiles, withCodictatorOwner } from "./harness-files.ts";
13
14
  import { MUSE_CAPABILITIES, museAdapter, normalizeMuseToolName, parseMuseEvent, replyMuse, staticBlockMuse } from "./muse-code.ts";
14
15
  import { isOpenCodeVersionSupported, normalizeOpenCodeToolName, OPENCODE_VERSION_MAXIMUM_EXCLUSIVE, OPENCODE_VERSION_MINIMUM, opencodeAdapter, parseOpenCodeEvent, replyOpenCode, staticBlockOpenCode } from "./opencode.ts";
15
16
  import { OPENCODE_CAPABILITIES } from "./opencode-capabilities.ts";
@@ -17,6 +18,6 @@ import { parseUniversalEvent, replyUniversal, staticBlockUniversal, UNIVERSAL_CA
17
18
  export { hookBudgetMs } from "./hook-dispatch.ts";
18
19
  export { type HookAppend, type HookBasePorts, type HookEnforcerLoader, type HookRunRequest, runHookEvent, staticBlock, supportedHarness, } from "./runtime.ts";
19
20
  export type { Adapter, CapabilityDeclaration, Decision, FileArtifact, HookDecision, HookEvent, HookReply, HookToolCall, RenderInput, ToolKind, Trigger, };
20
- export { CLAUDE_CODE_CAPABILITIES, CODEX_CAPABILITIES, COPILOT_CAPABILITIES, COPILOT_TIMEOUT_SEC, CURSOR_CAPABILITIES, checkCustomContract, claudeCodeAdapter, codexCliAdapter, copilotAdapter, cursorAdapter, GEMINI_CAPABILITIES, geminiCliAdapter, HOOK_PAYLOAD_MAX_BYTES, isOpenCodeVersionSupported, loadCustomAdapter, MUSE_CAPABILITIES, museAdapter, normalizeClaudeCodeToolName, normalizeCodexToolName, normalizeCopilotToolName, normalizeCursorToolName, normalizeGeminiToolName, normalizeMuseToolName, normalizeOpenCodeToolName, OPENCODE_CAPABILITIES, OPENCODE_VERSION_MAXIMUM_EXCLUSIVE, OPENCODE_VERSION_MINIMUM, opencodeAdapter, parseClaudeCodeEvent, parseCodexEvent, parseCopilotEvent, parseCursorEvent, parseGeminiEvent, parseMuseEvent, parseOpenCodeEvent, parseUniversalEvent, renderCustomHarnessFiles, renderCustomRegenFragment, replyClaudeCode, replyCodex, replyCopilot, replyCursor, replyGemini, replyMuse, replyOpenCode, replyUniversal, staticBlockClaudeCode, staticBlockCodex, staticBlockCopilot, staticBlockCursor, staticBlockGemini, staticBlockMuse, staticBlockOpenCode, staticBlockUniversal, UNIVERSAL_CAPABILITIES, universalAdapter, };
21
+ export { CLAUDE_CODE_CAPABILITIES, CODEX_CAPABILITIES, COPILOT_CAPABILITIES, COPILOT_TIMEOUT_SEC, CURSOR_CAPABILITIES, checkCustomContract, claudeCodeAdapter, codexCliAdapter, copilotAdapter, cursorAdapter, GEMINI_CAPABILITIES, geminiCliAdapter, HOOK_PAYLOAD_MAX_BYTES, isOpenCodeVersionSupported, loadCustomAdapter, MUSE_CAPABILITIES, museAdapter, normalizeClaudeCodeToolName, normalizeCodexToolName, normalizeCopilotToolName, normalizeCursorToolName, normalizeGeminiToolName, normalizeMuseToolName, normalizeOpenCodeToolName, OPENCODE_CAPABILITIES, OPENCODE_VERSION_MAXIMUM_EXCLUSIVE, OPENCODE_VERSION_MINIMUM, opencodeAdapter, parseClaudeCodeEvent, parseCodexEvent, parseCopilotEvent, parseCursorEvent, parseGeminiEvent, parseMuseEvent, parseOpenCodeEvent, parseUniversalEvent, renderCustomHarnessFiles, renderCustomRegenFragment, renderHarnessFiles, replyClaudeCode, replyCodex, replyCopilot, replyCursor, replyGemini, replyMuse, replyOpenCode, replyUniversal, staticBlockClaudeCode, staticBlockCodex, staticBlockCopilot, staticBlockCursor, staticBlockGemini, staticBlockMuse, staticBlockOpenCode, staticBlockUniversal, UNIVERSAL_CAPABILITIES, universalAdapter, withCodictatorOwner, };
21
22
  export declare function parseEvent(harness: string, event: string, raw: unknown): Result<HookEvent>;
22
23
  export declare function renderReply(harness: string, decision: HookDecision): HookReply;
@@ -8,13 +8,14 @@ import { CURSOR_CAPABILITIES, cursorAdapter, normalizeCursorToolName, parseCurso
8
8
  import { checkCustomContract, loadCustomAdapter, renderCustomHarnessFiles, renderCustomRegenFragment, } from "./custom.js";
9
9
  import { geminiCliAdapter, parseGeminiEvent, replyGemini, staticBlockGemini, } from "./gemini-cli.js";
10
10
  import { GEMINI_CAPABILITIES, normalizeGeminiToolName, } from "./gemini-tables.js";
11
+ import { renderHarnessFiles, withCodictatorOwner } from "./harness-files.js";
11
12
  import { MUSE_CAPABILITIES, museAdapter, normalizeMuseToolName, parseMuseEvent, replyMuse, staticBlockMuse, } from "./muse-code.js";
12
13
  import { isOpenCodeVersionSupported, normalizeOpenCodeToolName, OPENCODE_VERSION_MAXIMUM_EXCLUSIVE, OPENCODE_VERSION_MINIMUM, opencodeAdapter, parseOpenCodeEvent, replyOpenCode, staticBlockOpenCode, } from "./opencode.js";
13
14
  import { OPENCODE_CAPABILITIES } from "./opencode-capabilities.js";
14
15
  import { parseUniversalEvent, replyUniversal, staticBlockUniversal, UNIVERSAL_CAPABILITIES, universalAdapter, } from "./universal.js";
15
16
  export { hookBudgetMs } from "./hook-dispatch.js";
16
17
  export { runHookEvent, staticBlock, supportedHarness, } from "./runtime.js";
17
- export { CLAUDE_CODE_CAPABILITIES, CODEX_CAPABILITIES, COPILOT_CAPABILITIES, COPILOT_TIMEOUT_SEC, CURSOR_CAPABILITIES, checkCustomContract, claudeCodeAdapter, codexCliAdapter, copilotAdapter, cursorAdapter, GEMINI_CAPABILITIES, geminiCliAdapter, HOOK_PAYLOAD_MAX_BYTES, isOpenCodeVersionSupported, loadCustomAdapter, MUSE_CAPABILITIES, museAdapter, normalizeClaudeCodeToolName, normalizeCodexToolName, normalizeCopilotToolName, normalizeCursorToolName, normalizeGeminiToolName, normalizeMuseToolName, normalizeOpenCodeToolName, OPENCODE_CAPABILITIES, OPENCODE_VERSION_MAXIMUM_EXCLUSIVE, OPENCODE_VERSION_MINIMUM, opencodeAdapter, parseClaudeCodeEvent, parseCodexEvent, parseCopilotEvent, parseCursorEvent, parseGeminiEvent, parseMuseEvent, parseOpenCodeEvent, parseUniversalEvent, renderCustomHarnessFiles, renderCustomRegenFragment, replyClaudeCode, replyCodex, replyCopilot, replyCursor, replyGemini, replyMuse, replyOpenCode, replyUniversal, staticBlockClaudeCode, staticBlockCodex, staticBlockCopilot, staticBlockCursor, staticBlockGemini, staticBlockMuse, staticBlockOpenCode, staticBlockUniversal, UNIVERSAL_CAPABILITIES, universalAdapter, };
18
+ export { CLAUDE_CODE_CAPABILITIES, CODEX_CAPABILITIES, COPILOT_CAPABILITIES, COPILOT_TIMEOUT_SEC, CURSOR_CAPABILITIES, checkCustomContract, claudeCodeAdapter, codexCliAdapter, copilotAdapter, cursorAdapter, GEMINI_CAPABILITIES, geminiCliAdapter, HOOK_PAYLOAD_MAX_BYTES, isOpenCodeVersionSupported, loadCustomAdapter, MUSE_CAPABILITIES, museAdapter, normalizeClaudeCodeToolName, normalizeCodexToolName, normalizeCopilotToolName, normalizeCursorToolName, normalizeGeminiToolName, normalizeMuseToolName, normalizeOpenCodeToolName, OPENCODE_CAPABILITIES, OPENCODE_VERSION_MAXIMUM_EXCLUSIVE, OPENCODE_VERSION_MINIMUM, opencodeAdapter, parseClaudeCodeEvent, parseCodexEvent, parseCopilotEvent, parseCursorEvent, parseGeminiEvent, parseMuseEvent, parseOpenCodeEvent, parseUniversalEvent, renderCustomHarnessFiles, renderCustomRegenFragment, renderHarnessFiles, replyClaudeCode, replyCodex, replyCopilot, replyCursor, replyGemini, replyMuse, replyOpenCode, replyUniversal, staticBlockClaudeCode, staticBlockCodex, staticBlockCopilot, staticBlockCursor, staticBlockGemini, staticBlockMuse, staticBlockOpenCode, staticBlockUniversal, UNIVERSAL_CAPABILITIES, universalAdapter, withCodictatorOwner, };
18
19
  // SPEC §4.5.3: native harness payload to the normalised event. Payloads
19
20
  // over the 8 MiB cap block CDX-E305 here too (§4.5 EC-11), so the parse
20
21
  // layer enforces the same limit the runtime stdin read enforces.
@@ -6,8 +6,8 @@
6
6
  // The clock arrives only through CheckContext.
7
7
  import { ok, sha256Text, stableStringify, } from "../../kernel/index.js";
8
8
  import { buildMessage } from "./message.js";
9
- import { appendFailedFinding, runEnforcer, sortFindings, } from "./run-enforcer.js";
10
- import { DEFAULT_WAIVER_STORE, matchWaivers, } from "./waivers.js";
9
+ import { appendFailedFinding, collectFindings } from "./run-enforcer.js";
10
+ import { DEFAULT_WAIVER_STORE, } from "./waivers.js";
11
11
  // SPEC §4.3.2: each engine trigger evaluates at one layer. Coarse triggers
12
12
  // map to the layer of their first scope; finer hook names arrive with the
13
13
  // adapter contract (phase 5).
@@ -52,38 +52,6 @@ function makeVerdictId(now, trigger, scope, decision, findings, waiversApplied,
52
52
  });
53
53
  return `${stamp}-${sha256Text(canonical).slice(0, 12)}`;
54
54
  }
55
- // SPEC §4.3.4: suppress findings covered by an active waiver, recording
56
- // the waiver ids. Synthetic fail-closed findings (CDX-E*) never suppress,
57
- // and an invalid floor-rule waiver blocks loudly instead of suppressing.
58
- async function applyWaivers(findings, store, now) {
59
- const kept = [];
60
- const applied = new Set();
61
- for (const finding of findings) {
62
- if (finding.code?.startsWith("CDX-E") === true) {
63
- kept.push(finding);
64
- continue;
65
- }
66
- const matched = matchWaivers(store, finding.ruleId, finding.path ?? "", now);
67
- if (!matched.ok) {
68
- kept.push(finding);
69
- kept.push({
70
- ruleId: finding.ruleId,
71
- code: matched.error.code,
72
- message: matched.error.message,
73
- remediation: matched.error.remediation,
74
- });
75
- continue;
76
- }
77
- if (matched.value.length === 0) {
78
- kept.push(finding);
79
- continue;
80
- }
81
- for (const waiver of matched.value) {
82
- applied.add(waiver.id);
83
- }
84
- }
85
- return { kept, waiversApplied: [...applied].sort() };
86
- }
87
55
  function ruleIdsOf(findings) {
88
56
  const seen = new Set();
89
57
  for (const finding of findings) {
@@ -91,22 +59,18 @@ function ruleIdsOf(findings) {
91
59
  }
92
60
  return [...seen].sort();
93
61
  }
94
- async function collectFindings(ordered, ctx, store) {
95
- const runnable = ordered.filter((enforcer) => enforcer.kinds.includes(ctx.trigger));
96
- // SPEC §4.3.3 step 2: bounded concurrency — one guard at a time keeps every
97
- // guard's event-loop share (no E902 herd); sortFindings keeps the verdict byte-identical (§4.3.6).
98
- const ran = [];
99
- for (const enforcer of runnable) {
100
- ran.push(await runEnforcer(enforcer, ctx));
101
- }
102
- return applyWaivers(sortFindings(ran.flat()), store, ctx.now);
103
- }
104
62
  function decideDecision(kept, trigger) {
105
63
  if (kept.length === 0) {
106
64
  return "allow";
107
65
  }
108
66
  return ADVISORY_TRIGGERS.includes(trigger) ? "advise" : "block";
109
67
  }
68
+ // SPEC §4.3.8 + §4.3.9 EC-19: zero effective enforcers block, except the
69
+ // never-gating L2 layer, which advises. No engine trigger maps to L2 yet
70
+ // (see TRIGGER_LAYERS), so every trigger blocks until that phase lands.
71
+ function decideZeroEnforcer(trigger) {
72
+ return TRIGGER_LAYERS[trigger] === "L2" ? "advise" : "block";
73
+ }
110
74
  function buildVerdictEvent(trigger, now, decision, kept, waiversApplied, configHash, packs, message, clockSkew) {
111
75
  return {
112
76
  kind: "verdict",
@@ -135,6 +99,28 @@ function blockedVerdict(now, trigger, scope, configHash) {
135
99
  waiversApplied: [],
136
100
  };
137
101
  }
102
+ // SPEC §4.3.3 steps 4-6: decide the verdict from the collected findings,
103
+ // build the agent message, append the verdict event before returning, and
104
+ // fail closed when the append fails (PRD §6 P5).
105
+ async function finishEvaluation(input, append) {
106
+ const decision = input.zeroEnforcers
107
+ ? decideZeroEnforcer(input.trigger)
108
+ : decideDecision(input.kept, input.trigger);
109
+ const eventId = makeVerdictId(input.now, input.trigger, input.scope, decision, input.kept, input.waiversApplied, input.configHash);
110
+ const message = buildMessage(decision, input.kept, eventId);
111
+ const event = buildVerdictEvent(input.trigger, input.now, decision, input.kept, input.waiversApplied, input.configHash, input.packs, message, input.clockSkew);
112
+ const appended = await append(event);
113
+ if (!appended.ok) {
114
+ return blockedVerdict(input.now, input.trigger, input.scope, input.configHash);
115
+ }
116
+ return {
117
+ decision,
118
+ findings: input.kept,
119
+ message,
120
+ eventId,
121
+ waiversApplied: input.waiversApplied,
122
+ };
123
+ }
138
124
  // SPEC §4.3.3: pure evaluation over registered enforcers.
139
125
  export function createEngine(enforcers, options = {}) {
140
126
  const ordered = [...enforcers].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
@@ -155,16 +141,18 @@ export function createEngine(enforcers, options = {}) {
155
141
  lastNow = now;
156
142
  }
157
143
  const ctx = { trigger, scope, now, configHash };
158
- const { kept, waiversApplied } = await collectFindings(ordered, ctx, store);
159
- const decision = decideDecision(kept, trigger);
160
- const eventId = makeVerdictId(now, trigger, scope, decision, kept, waiversApplied, configHash);
161
- const message = buildMessage(decision, kept, eventId);
162
- const event = buildVerdictEvent(trigger, now, decision, kept, waiversApplied, configHash, packs, message, clockSkew);
163
- const appended = await append(event);
164
- if (!appended.ok) {
165
- return blockedVerdict(now, trigger, scope, configHash);
166
- }
167
- return { decision, findings: kept, message, eventId, waiversApplied };
144
+ const { kept, waiversApplied, zeroEnforcers } = await collectFindings(ordered, ctx, store);
145
+ return finishEvaluation({
146
+ trigger,
147
+ scope,
148
+ now,
149
+ configHash,
150
+ packs,
151
+ kept,
152
+ waiversApplied,
153
+ zeroEnforcers,
154
+ clockSkew,
155
+ }, append);
168
156
  },
169
157
  };
170
158
  }
@@ -1,4 +1,11 @@
1
1
  import type { CheckContext, Enforcer, Finding } from "./index.ts";
2
+ import { type WaiverStore } from "./waivers.ts";
2
3
  export declare function runEnforcer(enforcer: Enforcer, ctx: CheckContext): Promise<Array<Finding>>;
3
4
  export declare function sortFindings(findings: Array<Finding>): Array<Finding>;
4
5
  export declare function appendFailedFinding(): Finding;
6
+ export declare function zeroEnforcerFinding(): Finding;
7
+ export declare function collectFindings(ordered: ReadonlyArray<Enforcer>, ctx: CheckContext, store: WaiverStore): Promise<{
8
+ kept: Array<Finding>;
9
+ waiversApplied: Array<string>;
10
+ zeroEnforcers: boolean;
11
+ }>;
@@ -1,3 +1,8 @@
1
+ import { applyWaivers } from "./waivers.js";
2
+ // SPEC §4.3.9 EC-1: the feedback-only triggers (tool.post.write and stop
3
+ // both arrive here as post-tool) allow with the event recorded when no
4
+ // enforcer runs; every other trigger with zero enforcers fails closed.
5
+ const FEEDBACK_ONLY_TRIGGERS = ["post-tool"];
1
6
  function failFinding(enforcerId, code, detail, remediation) {
2
7
  return {
3
8
  ruleId: enforcerId,
@@ -80,3 +85,33 @@ function compareFindings(a, b) {
80
85
  export function appendFailedFinding() {
81
86
  return failFinding("core/engine", "CDX-E801", "could not record its verdict event (CDX-E801)", "Free disk space and fix permissions on .codictator/state/, then retry.");
82
87
  }
88
+ // SPEC §4.3.8: zero effective enforcers fail closed with a synthetic CDX-E904
89
+ // finding. No guard ran, so the finding names the engine, not a rule.
90
+ export function zeroEnforcerFinding() {
91
+ return failFinding("core/engine", "CDX-E904", "has no effective enforcer for this trigger (CDX-E904)", "Configure a pack with a rule for this trigger, then run codictator verify.");
92
+ }
93
+ // SPEC §4.3.3 steps 1-3: run the trigger's enforcers, then apply waivers to
94
+ // the findings. Zero effective enforcers fail closed with the synthetic
95
+ // CDX-E904 finding, except the feedback-only triggers, which allow with the
96
+ // event recorded (§4.3.9 EC-1/EC-19).
97
+ export async function collectFindings(ordered, ctx, store) {
98
+ const runnable = ordered.filter((enforcer) => enforcer.kinds.includes(ctx.trigger));
99
+ if (runnable.length === 0) {
100
+ if (FEEDBACK_ONLY_TRIGGERS.includes(ctx.trigger)) {
101
+ return { kept: [], waiversApplied: [], zeroEnforcers: false };
102
+ }
103
+ return {
104
+ kept: [zeroEnforcerFinding()],
105
+ waiversApplied: [],
106
+ zeroEnforcers: true,
107
+ };
108
+ }
109
+ // SPEC §4.3.3 step 2: bounded concurrency — one guard at a time keeps every
110
+ // guard's event-loop share (no E902 herd); sortFindings keeps the verdict byte-identical (§4.3.6).
111
+ const ran = [];
112
+ for (const enforcer of runnable) {
113
+ ran.push(await runEnforcer(enforcer, ctx));
114
+ }
115
+ const { kept, waiversApplied } = await applyWaivers(sortFindings(ran.flat()), store, ctx.now);
116
+ return { kept, waiversApplied, zeroEnforcers: false };
117
+ }
@@ -1,4 +1,4 @@
1
- import type { Instant } from "../../kernel/index.ts";
1
+ import type { Finding, Instant } from "../../kernel/index.ts";
2
2
  import { type Result } from "../../kernel/index.ts";
3
3
  export interface Waiver {
4
4
  readonly id: string;
@@ -21,3 +21,7 @@ export declare const MAX_WAIVER_DAYS = 90;
21
21
  export declare const DEFAULT_WAIVER_STORE: WaiverStore;
22
22
  export declare function matchWaivers(store: WaiverStore, ruleId: string, path: string, now: Instant): Result<ReadonlyArray<Waiver>>;
23
23
  export declare function isWaived(ruleId: string, path: string, now: Instant, store?: WaiverStore): Promise<Result<boolean>>;
24
+ export declare function applyWaivers(findings: ReadonlyArray<Finding>, store: WaiverStore, now: Instant): Promise<{
25
+ kept: Array<Finding>;
26
+ waiversApplied: Array<string>;
27
+ }>;
@@ -167,3 +167,35 @@ export function isWaived(ruleId, path, now, store = DEFAULT_WAIVER_STORE) {
167
167
  }
168
168
  return Promise.resolve(ok(matched.value.length > 0));
169
169
  }
170
+ // SPEC §4.3.4: suppress findings covered by an active waiver, recording
171
+ // the waiver ids. Synthetic fail-closed findings (CDX-E*) never suppress,
172
+ // and an invalid floor-rule waiver blocks loudly instead of suppressing.
173
+ export async function applyWaivers(findings, store, now) {
174
+ const kept = [];
175
+ const applied = new Set();
176
+ for (const finding of findings) {
177
+ if (finding.code?.startsWith("CDX-E") === true) {
178
+ kept.push(finding);
179
+ continue;
180
+ }
181
+ const matched = matchWaivers(store, finding.ruleId, finding.path ?? "", now);
182
+ if (!matched.ok) {
183
+ kept.push(finding);
184
+ kept.push({
185
+ ruleId: finding.ruleId,
186
+ code: matched.error.code,
187
+ message: matched.error.message,
188
+ remediation: matched.error.remediation,
189
+ });
190
+ continue;
191
+ }
192
+ if (matched.value.length === 0) {
193
+ kept.push(finding);
194
+ continue;
195
+ }
196
+ for (const waiver of matched.value) {
197
+ applied.add(waiver.id);
198
+ }
199
+ }
200
+ return { kept, waiversApplied: [...applied].sort() };
201
+ }