codictator 0.2.0 → 0.2.2

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,17 @@
1
+ {
2
+ "rule": "core/doc-placement",
3
+ "section": "§5.10",
4
+ "expectation": "passing",
5
+ "trigger": "harness and agent dirs are placed locations",
6
+ "expectRules": [],
7
+ "files": {
8
+ ".agents/NOTES.md": "# Notes\n\nAgent notes live here.\n",
9
+ ".claude/NOTES.md": "# Notes\n\nAgent notes live here.\n",
10
+ ".github/NOTES.md": "# Notes\n\nAgent notes live here.\n",
11
+ ".cursor/NOTES.md": "# Notes\n\nAgent notes live here.\n",
12
+ ".gemini/NOTES.md": "# Notes\n\nAgent notes live here.\n",
13
+ ".opencode/NOTES.md": "# Notes\n\nAgent notes live here.\n",
14
+ ".muse/NOTES.md": "# Notes\n\nAgent notes live here.\n",
15
+ ".codictator/NOTES.md": "# Notes\n\nAgent notes live here.\n"
16
+ }
17
+ }
@@ -3,7 +3,7 @@
3
3
  // install-hooks writes the hooks and points core.hooksPath at them;
4
4
  // uninstall removes generated files with the CI warning.
5
5
  import { parseArgs } from "node:util";
6
- import { renderCustomRegenFragment } from "../features/adapters/index.js";
6
+ import { renderCustomRegenFragment, withCodictatorOwner, } from "../features/adapters/index.js";
7
7
  import { ENGINE_VERSION } from "../features/config/index.js";
8
8
  import { EXPECTED_HOOKS_PATH, HOOK_NAMES, hookInstallPlan, renderHookScript, } from "../features/integrity/index.js";
9
9
  import { err, ok, resolveRepoRoot } from "../kernel/index.js";
@@ -68,8 +68,12 @@ export async function handleRegen(req) {
68
68
  tunables: configured.tunables,
69
69
  architecture: configured.architecture,
70
70
  tools: configured.tools,
71
+ }, req.engineVersion ?? ENGINE_VERSION, root.value);
72
+ // SPEC §4.14.1: custom fragments keep the historical codictator owner.
73
+ const customOwned = withCodictatorOwner(customFiles.value);
74
+ const wrote = await writePlan(root.value, {
75
+ files: [...plan.files, ...customOwned],
71
76
  }, req.engineVersion ?? ENGINE_VERSION);
72
- const wrote = await writePlan(root.value, { files: [...plan.files, ...customFiles.value] }, req.engineVersion ?? ENGINE_VERSION);
73
77
  if (!wrote.ok) {
74
78
  return fail("CDX-E903", wrote.error.message, wrote.error.remediation, req.json);
75
79
  }
@@ -10,6 +10,8 @@ export interface GuardrailPlan {
10
10
  readonly files: ReadonlyArray<{
11
11
  path: string;
12
12
  content: string;
13
+ owner: string;
14
+ mode: "file";
13
15
  }>;
14
16
  }
15
17
  export interface PlanExtras {
@@ -18,7 +20,7 @@ export interface PlanExtras {
18
20
  readonly architecture?: unknown;
19
21
  readonly tools?: unknown;
20
22
  }
21
- export declare function guardrailPlan(harnesses: ReadonlyArray<string>, profile: string, repoName: string, existingAgents: string | undefined, extras?: PlanExtras, engineVersion?: string): GuardrailPlan;
23
+ export declare function guardrailPlan(harnesses: ReadonlyArray<string>, profile: string, repoName: string, existingAgents: string | undefined, extras?: PlanExtras, engineVersion?: string, root?: string): GuardrailPlan;
22
24
  export declare function writePlan(root: string, plan: GuardrailPlan, engineVersion?: string): Promise<Result<Array<{
23
25
  path: string;
24
26
  hash: string;
package/dist/cli/plan.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // SPEC §4.14.1 + §4.14.5: guardrail plan builders. The file list (config,
2
2
  // steering block, CI workflow, hooks, launcher) and the manifest write are
3
3
  // shared by init and regen, so regeneration stays byte-identical.
4
+ import { renderHarnessFiles } from "../features/adapters/index.js";
4
5
  import { renderWorkflow } from "../features/ci/index.js";
5
6
  import { ENGINE_VERSION } from "../features/config/index.js";
6
7
  import { buildManifest, EXPECTED_HOOKS_PATH, HOOK_NAMES, hookInstallPlan, renderHookScript, } from "../features/integrity/index.js";
@@ -65,30 +66,65 @@ function withManagedBlock(existing) {
65
66
  const after = end + MANAGED_END.length;
66
67
  return `${existing.slice(0, start)}${block}${existing.slice(after)}`;
67
68
  }
68
- export function guardrailPlan(harnesses, profile, repoName, existingAgents, extras = {}, engineVersion = ENGINE_VERSION) {
69
+ export function guardrailPlan(harnesses, profile, repoName, existingAgents, extras = {}, engineVersion = ENGINE_VERSION, root = ".") {
69
70
  const files = [];
70
71
  files.push({
71
72
  path: ".codictator/config.json",
72
73
  content: configText(harnesses, profile, extras, engineVersion),
74
+ owner: "codictator",
75
+ mode: "file",
76
+ });
77
+ files.push({
78
+ path: "AGENTS.md",
79
+ content: withManagedBlock(existingAgents),
80
+ owner: "codictator",
81
+ mode: "file",
73
82
  });
74
- files.push({ path: "AGENTS.md", content: withManagedBlock(existingAgents) });
75
83
  const workflow = renderWorkflow(repoName);
76
84
  files.push({
77
85
  path: ".github/workflows/codictator.yml",
78
86
  content: workflow.text ?? "",
87
+ owner: "codictator",
88
+ mode: "file",
79
89
  });
80
90
  for (const hook of HOOK_NAMES) {
81
91
  files.push({
82
92
  path: `${EXPECTED_HOOKS_PATH}/${hook}`,
83
93
  content: renderHookScript(hook),
94
+ owner: "codictator",
95
+ mode: "file",
84
96
  });
85
97
  }
86
98
  const plan = hookInstallPlan({});
87
99
  files.push({
88
100
  path: `${EXPECTED_HOOKS_PATH}/.gitattributes`,
89
101
  content: plan.gitattributes,
102
+ owner: "codictator",
103
+ mode: "file",
104
+ });
105
+ files.push({
106
+ path: ".codictator/bin/run.mjs",
107
+ content: LAUNCHER_TEXT,
108
+ owner: "codictator",
109
+ mode: "file",
90
110
  });
91
- files.push({ path: ".codictator/bin/run.mjs", content: LAUNCHER_TEXT });
111
+ // SPEC §4.13.1 step 2 + §4.5: every configured built-in harness owns
112
+ // its rendered files; universal renders nothing, custom:* stays with
113
+ // renderCustomHarnessFiles. Renders ignore the input, so the "." default
114
+ // keeps the init path working unchanged. SPEC §4.6.2 + core/guardrail-manifest:
115
+ // one row per path, first occurrence wins: base files go first so the
116
+ // plan's managed AGENTS.md beats any harness-rendered AGENTS.md, then
117
+ // harness files follow in sorted harness-id order, skipping paths
118
+ // already emitted (cursor/gemini-cli AGENTS.md, codex-cli/muse-code
119
+ // .agents/skills overlap), so writePlan writes each path once.
120
+ const seen = new Set(files.map((entry) => entry.path));
121
+ const sortedHarnesses = [...new Set(harnesses)].sort();
122
+ for (const artifact of renderHarnessFiles(sortedHarnesses, root)) {
123
+ if (!seen.has(artifact.path)) {
124
+ seen.add(artifact.path);
125
+ files.push({ ...artifact, mode: "file" });
126
+ }
127
+ }
92
128
  return { files };
93
129
  }
94
130
  export async function writePlan(root, plan, engineVersion = ENGINE_VERSION) {
@@ -107,12 +143,15 @@ export async function writePlan(root, plan, engineVersion = ENGINE_VERSION) {
107
143
  hash: sha256Text(normalizeText(file.content)),
108
144
  });
109
145
  }
110
- const manifest = buildManifest(engineVersion, written.map((entry) => ({
111
- path: entry.path,
112
- owner: "codictator",
113
- mode: "file",
114
- hash: entry.hash,
115
- })));
146
+ const manifest = buildManifest(engineVersion, written.map((entry, index) => {
147
+ const planned = plan.files[index];
148
+ return {
149
+ path: entry.path,
150
+ owner: planned === undefined ? "codictator" : planned.owner,
151
+ mode: planned === undefined ? "file" : planned.mode,
152
+ hash: entry.hash,
153
+ };
154
+ }));
116
155
  const saved = await ports.value.fs.write(".codictator/manifest.json", encode(`${stableStringify(manifest)}\n`));
117
156
  if (!saved.ok) {
118
157
  return err(saved.error);
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { isFixturePath, ok } from "../../../kernel/index.js";
1
+ import { globMatch, isFixturePath, ok, } from "../../../kernel/index.js";
2
2
  import { bindRule, findingFor, selectFiles, tunableStringList, } from "./binding.js";
3
3
  import { baseEnforcer, isMarkerFile, loadScopeText, parseMarkerInt, parseMarkers, } from "./health-measures.js";
4
4
  const ROOT_DOCS = new Set([
@@ -10,6 +10,15 @@ const ROOT_DOCS = new Set([
10
10
  "SECURITY.md",
11
11
  "CODE_OF_CONDUCT.md",
12
12
  ]);
13
+ const HARNESS_DIRS = [
14
+ ".agents",
15
+ ".claude",
16
+ ".github",
17
+ ".cursor",
18
+ ".gemini",
19
+ ".opencode",
20
+ ".muse",
21
+ ];
13
22
  // Doc rules judge markdown prose and the fixture measure reports. Other
14
23
  // files carry no doc signals; reading them only pays a parse for nothing
15
24
  // and invents findings from code identifiers (a doc count in `utf8Bytes`,
@@ -60,7 +69,11 @@ function placementFinding(binding, file, allowed) {
60
69
  if (/^packages\/[^/]+\/README\.md$/.test(file)) {
61
70
  return undefined;
62
71
  }
63
- if (allowed.some((glob) => file === glob)) {
72
+ if (HARNESS_DIRS.some((dir) => file.startsWith(`${dir}/`)) ||
73
+ file.startsWith(".codictator/")) {
74
+ return undefined;
75
+ }
76
+ if (allowed.some((glob) => globMatch(glob, file))) {
64
77
  return undefined;
65
78
  }
66
79
  return outside(`doc outside its placed locations: ${file}`);