proactive-gate 0.1.2 → 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.
package/dist/src/cli.js CHANGED
@@ -1,31 +1,65 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * proactive-gate replay <events.jsonl> [--policy <module>] [--json]
3
+ * proactive-gate replay <events.jsonl> [--policy <file>] [--json] [--commit]
4
+ * proactive-gate replay --fixtures <dir>
5
+ * proactive-gate hook --policy <file> [--tool <name>]
4
6
  *
5
- * Replays candidate messages through a gate and prints why each one was or
7
+ * replay feeds candidate messages through a gate and prints why each one was or
6
8
  * was not allowed. Each JSONL line is an EvaluateInput: { user, candidate, now? }.
7
- * The policy module must export `gate` (a Gate) or default-export one; without
8
- * --policy the default check order runs against an in-memory store.
9
+ * A policy is a JSON document (spec/schema/policy.schema.json) or an ES module
10
+ * that exports `gate`; without --policy the default check order runs against an
11
+ * in-memory store. --fixtures runs the conformance suite instead.
12
+ *
13
+ * hook reads a Claude Code PreToolUse event on stdin and prints a permission
14
+ * decision for the matching tool.
9
15
  */
10
- import { readFile } from "node:fs/promises";
16
+ import { readFile, writeFile } from "node:fs/promises";
17
+ import { existsSync } from "node:fs";
11
18
  import { pathToFileURL } from "node:url";
12
19
  import { resolve } from "node:path";
20
+ import { createRequire } from "node:module";
13
21
  import { createGate } from "./gate.js";
14
22
  import { defaultChecks } from "./checks.js";
15
- const HELP = `usage: proactive-gate replay <events.jsonl> [--policy <module.js>] [--json] [--commit]
23
+ import { loadFixtures, readSkips, runFixture } from "./conformance.js";
24
+ import { FRAMEWORKS, listText, plan } from "./init.js";
25
+ const HELP = `usage: proactive-gate init [--preset <name>] [--framework <name>] [--out <file>]
26
+ proactive-gate replay <events.jsonl> [--policy <file>] [--json] [--commit]
27
+ proactive-gate replay --fixtures <dir> [--skip <file>]
28
+ proactive-gate hook --policy <file> [--tool <name>]
29
+
30
+ init writes a policy you can read and edit, and prints the lines that wire it in.
16
31
 
17
- Replays candidates through a gate and reports what was allowed and why not.
32
+ --preset <name> a platform or legal preset to append (see --list)
33
+ --framework <name> ${FRAMEWORKS.join(", ")} (default none)
34
+ --out <file> where to write (default proactive-gate.policy.json)
35
+ --force overwrite an existing file
36
+ --list print the presets and frameworks and exit
18
37
 
19
- --policy <module> ES module exporting \`gate\` (or default) built with createGate()
38
+ replay reports what was allowed and why not.
39
+
40
+ --policy <file> policy.json (spec/schema/policy.schema.json) or an ES module
41
+ exporting \`gate\` (or default) built with createGate()
20
42
  --json one Decision per line instead of the summary table
21
- --commit also call gate.commit() for allowed decisions, so the daily
22
- budget is consumed in order, as it would be in production
43
+ --commit also call gate.commit() for allowed decisions, so budgets are
44
+ consumed in order, as they would be in production
45
+ --fixtures <dir> run the conformance fixtures under <dir> and report failures
46
+ --skip <file> fixture names to skip, one per line (default spec/skip/ts.txt)
47
+
48
+ hook reads a PreToolUse event (JSON) on stdin; when tool_name matches --tool
49
+ (default send_message) it evaluates tool_input.gate = { user, candidate, now? }
50
+ and prints a permissionDecision. Other tools print nothing.
51
+
23
52
  -h, --help this text
53
+ --version print the version
24
54
 
25
- Each line of the file is {"user": {...}, "candidate": {...}, "now": "ISO date"}.`;
55
+ Each line of an events file is {"user": {...}, "candidate": {...}, "now": "ISO date"}.`;
26
56
  export async function loadPolicy(path) {
27
57
  if (!path)
28
58
  return createGate({ checks: defaultChecks() });
59
+ if (path.endsWith(".json")) {
60
+ const policy = JSON.parse(await readFile(path, "utf8"));
61
+ return createGate({ policy });
62
+ }
29
63
  const mod = await import(pathToFileURL(resolve(path)).href);
30
64
  const gate = mod.gate ?? mod.default;
31
65
  if (!gate || typeof gate.evaluate !== "function")
@@ -50,8 +84,8 @@ export async function replay(lines, gate, commit) {
50
84
  const ok = await gate.commit(decision, input);
51
85
  if (!ok) {
52
86
  decision.allowed = false;
53
- decision.rejectedBy = "dailyBudget";
54
- decision.reason = "daily budget exhausted at commit";
87
+ decision.rejectedBy = "commit";
88
+ decision.reason = "a budget was exhausted at commit";
55
89
  }
56
90
  }
57
91
  decisions.push(decision);
@@ -60,23 +94,26 @@ export async function replay(lines, gate, commit) {
60
94
  }
61
95
  export function summarize(decisions) {
62
96
  const allowed = decisions.filter((d) => d.allowed).length;
97
+ const deferred = decisions.filter((d) => d.deferredBy).length;
63
98
  const byCheck = new Map();
64
99
  const sample = new Map();
65
100
  for (const d of decisions) {
66
- if (d.allowed || !d.rejectedBy)
101
+ const by = d.rejectedBy ?? d.deferredBy;
102
+ if (d.allowed || !by)
67
103
  continue;
68
- byCheck.set(d.rejectedBy, (byCheck.get(d.rejectedBy) ?? 0) + 1);
69
- if (!sample.has(d.rejectedBy) && d.reason)
70
- sample.set(d.rejectedBy, d.reason);
104
+ byCheck.set(by, (byCheck.get(by) ?? 0) + 1);
105
+ if (!sample.has(by) && d.reason)
106
+ sample.set(by, d.reason);
71
107
  }
72
- const deferred = decisions.filter((d) => d.allowed && d.deliverAt).length;
108
+ const later = decisions.filter((d) => d.allowed && d.deliverAt).length;
109
+ const shadowed = decisions.reduce((n, d) => n + d.shadowed.length, 0);
73
110
  const lines = [
74
- `${decisions.length} candidates · ${allowed} allowed (${pct(allowed, decisions.length)}) · ${decisions.length - allowed} rejected${deferred ? ` · ${deferred} deferred to a later moment` : ""}`,
111
+ `${decisions.length} candidates · ${allowed} allowed (${pct(allowed, decisions.length)}) · ${decisions.length - allowed - deferred} rejected${deferred ? ` · ${deferred} deferred` : ""}${later ? ` · ${later} moved to a later moment` : ""}${shadowed ? ` · ${shadowed} shadow rejections` : ""}`,
75
112
  "",
76
113
  ];
77
114
  if (byCheck.size) {
78
115
  const w = Math.max(...[...byCheck.keys()].map((k) => k.length), 5);
79
- lines.push(`${"check".padEnd(w)} ${"rejected".padStart(8)} example`);
116
+ lines.push(`${"check".padEnd(w)} ${"stopped".padStart(8)} example`);
80
117
  lines.push("-".repeat(w + 2 + 8 + 2 + 40));
81
118
  for (const [id, n] of [...byCheck.entries()].sort((a, b) => b[1] - a[1])) {
82
119
  lines.push(`${id.padEnd(w)} ${String(n).padStart(8)} ${sample.get(id) ?? ""}`);
@@ -88,19 +125,112 @@ export function summarize(decisions) {
88
125
  return lines.join("\n");
89
126
  }
90
127
  const pct = (n, total) => (total ? `${((100 * n) / total).toFixed(1)}%` : "0%");
128
+ const argValue = (argv, flag) => {
129
+ const i = argv.indexOf(flag);
130
+ return i >= 0 ? argv[i + 1] : undefined;
131
+ };
132
+ async function runFixtures(dir, skipFile) {
133
+ const fixtures = await loadFixtures(dir);
134
+ const skips = await readSkips(skipFile ?? resolve(dir, "..", "skip", "ts.txt"));
135
+ let failed = 0;
136
+ let skipped = 0;
137
+ for (const fixture of fixtures) {
138
+ const reason = skips.get(fixture.name);
139
+ if (reason !== undefined) {
140
+ skipped++;
141
+ console.log(`skip ${fixture.name} (${reason})`);
142
+ continue;
143
+ }
144
+ const failures = await runFixture(fixture);
145
+ if (failures.length) {
146
+ failed++;
147
+ console.log(`FAIL ${fixture.name}`);
148
+ for (const f of failures)
149
+ console.log(` ${f}`);
150
+ }
151
+ else {
152
+ console.log(`ok ${fixture.name}`);
153
+ }
154
+ }
155
+ console.log(`${fixtures.length - failed - skipped} passed, ${failed} failed, ${skipped} skipped`);
156
+ return failed ? 1 : 0;
157
+ }
158
+ /** Turns a PreToolUse event into the hook output Claude Code expects, or null when the tool does not match. */
159
+ export async function hookDecision(event, gate, tool) {
160
+ if (event.tool_name !== tool)
161
+ return null;
162
+ const payload = event.tool_input?.gate;
163
+ const out = (permissionDecision, permissionDecisionReason) => JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason } });
164
+ if (!payload?.user || !payload.candidate)
165
+ return out("deny", "tool_input.gate must carry { user, candidate } for proactive-gate to decide");
166
+ const decision = await gate.evaluate({ user: payload.user, candidate: payload.candidate, ...(payload.now ? { now: new Date(payload.now) } : {}) });
167
+ if (decision.allowed) {
168
+ const ok = await gate.commit(decision, { user: payload.user, candidate: payload.candidate, ...(decision.evaluatedAt ? { now: decision.evaluatedAt } : {}) });
169
+ return ok ? out("allow", `proactive-gate: allowed on ${decision.surfaces.join(",")}${decision.deliverAt ? `, deliver at ${decision.deliverAt.toISOString()}` : ""}`) : out("deny", "proactive-gate: a budget was exhausted at commit");
170
+ }
171
+ if (decision.deferredBy)
172
+ return out("deny", `proactive-gate: deferred by ${decision.deferredBy} until ${decision.retryAt?.toISOString()} (${decision.reason})`);
173
+ return out("deny", `proactive-gate: rejected by ${decision.rejectedBy} (${decision.reason})`);
174
+ }
175
+ async function readStdin() {
176
+ const chunks = [];
177
+ for await (const chunk of process.stdin)
178
+ chunks.push(chunk);
179
+ return Buffer.concat(chunks).toString("utf8");
180
+ }
181
+ const VERSION = createRequire(import.meta.url)("../../package.json").version;
91
182
  async function main(argv) {
183
+ if (argv.includes("--version")) {
184
+ console.log(VERSION);
185
+ return;
186
+ }
92
187
  if (argv.length === 0 || argv.includes("-h") || argv.includes("--help")) {
93
188
  console.log(HELP);
94
189
  return;
95
190
  }
96
191
  const [command, file] = argv;
97
- if (command !== "replay" || !file) {
192
+ if (command === "init") {
193
+ if (argv.includes("--list")) {
194
+ console.log(listText());
195
+ return;
196
+ }
197
+ const out = argValue(argv, "--out") ?? "proactive-gate.policy.json";
198
+ const presetName = argValue(argv, "--preset");
199
+ const frameworkName = argValue(argv, "--framework");
200
+ const { policy, message } = plan({
201
+ ...(presetName === undefined ? {} : { preset: presetName }),
202
+ ...(frameworkName === undefined ? {} : { framework: frameworkName }),
203
+ out,
204
+ });
205
+ if (!argv.includes("--force") && existsSync(out)) {
206
+ console.error(`${out} already exists; pass --force to overwrite it`);
207
+ process.exit(1);
208
+ }
209
+ await writeFile(out, policy);
210
+ console.log(message);
211
+ return;
212
+ }
213
+ if (command === "hook") {
214
+ const gate = await loadPolicy(argValue(argv, "--policy"));
215
+ const event = JSON.parse((await readStdin()) || "{}");
216
+ const output = await hookDecision(event, gate, argValue(argv, "--tool") ?? "send_message");
217
+ if (output)
218
+ console.log(output);
219
+ return;
220
+ }
221
+ if (command !== "replay") {
222
+ console.error(HELP);
223
+ process.exit(2);
224
+ }
225
+ const fixtures = argValue(argv, "--fixtures");
226
+ if (fixtures) {
227
+ process.exit(await runFixtures(fixtures, argValue(argv, "--skip")));
228
+ }
229
+ if (!file || file.startsWith("--")) {
98
230
  console.error(HELP);
99
231
  process.exit(2);
100
232
  }
101
- const policyIndex = argv.indexOf("--policy");
102
- const policy = policyIndex >= 0 ? argv[policyIndex + 1] : undefined;
103
- const gate = await loadPolicy(policy);
233
+ const gate = await loadPolicy(argValue(argv, "--policy"));
104
234
  const text = await readFile(file, "utf8");
105
235
  const decisions = await replay(text.split("\n"), gate, argv.includes("--commit"));
106
236
  if (argv.includes("--json")) {
@@ -0,0 +1,42 @@
1
+ import type { Candidate, Policy, UserState } from "./types.js";
2
+ export interface FixtureExpect {
3
+ allowed: boolean;
4
+ rejectedBy?: string;
5
+ deferredBy?: string;
6
+ retryAt?: string;
7
+ surfaces?: string[];
8
+ deliverAt?: string;
9
+ trace: string[];
10
+ shadowed?: string[];
11
+ nearLimit?: Array<{
12
+ check: string;
13
+ used: number;
14
+ limit: number;
15
+ }>;
16
+ reason_pattern?: string;
17
+ commit?: boolean;
18
+ store_after?: Record<string, string>;
19
+ }
20
+ export interface FixtureTest {
21
+ description: string;
22
+ input: {
23
+ user: UserState;
24
+ candidate: Candidate;
25
+ now: string;
26
+ };
27
+ commit?: boolean;
28
+ expect: FixtureExpect;
29
+ }
30
+ export interface Fixture {
31
+ spec_version: string;
32
+ since: string;
33
+ name: string;
34
+ description: string;
35
+ policy: Policy;
36
+ store_seed?: Record<string, string>;
37
+ tests: FixtureTest[];
38
+ }
39
+ export declare function loadFixtures(dir: string): Promise<Fixture[]>;
40
+ export declare function readSkips(file: string): Promise<Map<string, string>>;
41
+ /** Runs one fixture and returns the list of mismatches, empty when it conforms. */
42
+ export declare function runFixture(fixture: Fixture): Promise<string[]>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Runs the language-neutral fixtures under spec/fixtures against a gate. The
3
+ * TypeScript test suite and `proactive-gate replay --fixtures` both use it.
4
+ */
5
+ import { readdir, readFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { createGate } from "./gate.js";
8
+ import { MemoryStore } from "./stores.js";
9
+ export async function loadFixtures(dir) {
10
+ const files = [];
11
+ const walk = async (d) => {
12
+ for (const entry of await readdir(d, { withFileTypes: true })) {
13
+ const path = join(d, entry.name);
14
+ if (entry.isDirectory())
15
+ await walk(path);
16
+ else if (entry.name.endsWith(".json"))
17
+ files.push(path);
18
+ }
19
+ };
20
+ await walk(dir);
21
+ files.sort();
22
+ return Promise.all(files.map(async (f) => JSON.parse(await readFile(f, "utf8"))));
23
+ }
24
+ export async function readSkips(file) {
25
+ const skips = new Map();
26
+ let text = "";
27
+ try {
28
+ text = await readFile(file, "utf8");
29
+ }
30
+ catch {
31
+ return skips;
32
+ }
33
+ for (const line of text.split("\n")) {
34
+ const trimmed = line.trim();
35
+ if (!trimmed || trimmed.startsWith("#"))
36
+ continue;
37
+ const [name, ...reason] = trimmed.split("#");
38
+ skips.set(name.trim(), reason.join("#").trim());
39
+ }
40
+ return skips;
41
+ }
42
+ const iso = (d) => (d ? d.toISOString() : undefined);
43
+ /** Runs one fixture and returns the list of mismatches, empty when it conforms. */
44
+ export async function runFixture(fixture) {
45
+ const failures = [];
46
+ const store = new MemoryStore();
47
+ const prefix = fixture.policy.keyPrefix ?? "pg:";
48
+ for (const [key, value] of Object.entries(fixture.store_seed ?? {}))
49
+ await store.set(prefix + key, value);
50
+ const gate = createGate({ policy: fixture.policy, store });
51
+ for (const [i, t] of fixture.tests.entries()) {
52
+ const at = `${fixture.name} [${i}] ${t.description}`;
53
+ const input = { user: t.input.user, candidate: t.input.candidate, now: new Date(t.input.now) };
54
+ const decision = await gate.evaluate(input);
55
+ const e = t.expect;
56
+ const check = (field, actual, expected) => {
57
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
58
+ failures.push(`${at}: ${field} expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
59
+ };
60
+ check("allowed", decision.allowed, e.allowed);
61
+ check("trace", decision.trace.map((x) => x.id), e.trace);
62
+ check("rejectedBy", decision.rejectedBy, e.rejectedBy);
63
+ check("deferredBy", decision.deferredBy, e.deferredBy);
64
+ check("retryAt", iso(decision.retryAt), e.retryAt);
65
+ if (e.surfaces)
66
+ check("surfaces", decision.surfaces, e.surfaces);
67
+ check("deliverAt", iso(decision.deliverAt), e.deliverAt);
68
+ if (e.shadowed)
69
+ check("shadowed", decision.shadowed, e.shadowed);
70
+ if (e.nearLimit)
71
+ check("nearLimit", decision.nearLimit, e.nearLimit);
72
+ if (e.reason_pattern && !(decision.reason && new RegExp(e.reason_pattern).test(decision.reason)))
73
+ failures.push(`${at}: reason ${JSON.stringify(decision.reason)} does not match /${e.reason_pattern}/`);
74
+ if (t.commit) {
75
+ const committed = await gate.commit(decision, input);
76
+ if (e.commit !== undefined)
77
+ check("commit", committed, e.commit);
78
+ }
79
+ for (const [key, value] of Object.entries(e.store_after ?? {}))
80
+ check(`store ${key}`, await store.get(prefix + key), value);
81
+ }
82
+ return failures;
83
+ }
@@ -1,11 +1,12 @@
1
- import type { Candidate, Check, Decision, EvaluateInput, GateOptions, OutcomeEvent, UserState } from "./types.js";
1
+ import type { Candidate, Check, Decision, EvaluateInput, GateHooks, GateOptions, OutcomeEvent, Policy, Store, UserState } from "./types.js";
2
2
  export interface Gate {
3
3
  /** Run every check in order. Never throws for a check failure; see the trace. */
4
4
  evaluate(input: EvaluateInput): Promise<Decision>;
5
5
  /**
6
- * Call right before you actually send. Atomically consumes one unit of the
7
- * user's daily budget when a dailyBudget check is configured, and returns
8
- * false if the budget was exhausted by a concurrent delivery in the meantime.
6
+ * Call right before you actually send. Consumes one unit of every budget-like
7
+ * check, in order, and returns false if a unit was taken by a concurrent
8
+ * delivery in the meantime. Idempotent on decision.id: a second call returns
9
+ * the first result without consuming again.
9
10
  */
10
11
  commit(decision: Decision, input: EvaluateInput): Promise<boolean>;
11
12
  /** Tell the gate what happened after delivery, so cooldowns can learn. */
@@ -17,4 +18,11 @@ export interface Gate {
17
18
  }>;
18
19
  readonly checks: readonly Check[];
19
20
  }
20
- export declare function createGate(options: GateOptions): Gate;
21
+ /** createGate accepts explicit checks or a JSON policy (see spec/schema/policy.schema.json). */
22
+ export interface PolicyGateOptions {
23
+ policy: Policy;
24
+ store?: Store;
25
+ onDecision?: (decision: Decision) => void;
26
+ hooks?: GateHooks;
27
+ }
28
+ export declare function createGate(options: GateOptions | PolicyGateOptions): Gate;
package/dist/src/gate.js CHANGED
@@ -1,4 +1,5 @@
1
- import { budgetKey, dismissalKey, weeklyBudgetKey, DAY_SECONDS } from "./checks.js";
1
+ import { budgetKey, dismissalKey, DAY_SECONDS } from "./checks.js";
2
+ import { compilePolicy } from "./policy.js";
2
3
  import { MemoryStore } from "./stores.js";
3
4
  class PrefixedStore {
4
5
  inner;
@@ -12,32 +13,59 @@ class PrefixedStore {
12
13
  incr(key, ttl) { return this.inner.incr(this.prefix + key, ttl); }
13
14
  del(key) { return this.inner.del(this.prefix + key); }
14
15
  }
16
+ const COMMIT_TTL = 2 * DAY_SECONDS;
15
17
  export function createGate(options) {
16
- const store = new PrefixedStore(options.store ?? new MemoryStore(), options.keyPrefix ?? "pg:");
17
- const onStoreError = options.onStoreError ?? "open";
18
- const checks = [...options.checks];
19
- const budgetChecks = checks.filter((c) => c.id === "dailyBudget" || c.id === "weeklyBudget");
18
+ if ("policy" in options && "checks" in options)
19
+ throw new Error("createGate takes either checks or policy, not both");
20
+ const resolved = "policy" in options
21
+ ? { ...compilePolicy(options.policy), ...(options.store ? { store: options.store } : {}), ...(options.onDecision ? { onDecision: options.onDecision } : {}), ...(options.hooks ? { hooks: options.hooks } : {}) }
22
+ : options;
23
+ const store = new PrefixedStore(resolved.store ?? new MemoryStore(), resolved.keyPrefix ?? "pg:");
24
+ const onStoreError = resolved.onStoreError ?? "open";
25
+ const hooks = resolved.hooks ?? {};
26
+ const checks = [...resolved.checks];
27
+ let sequence = 0;
28
+ const consumers = checks.filter((c) => typeof c.consume === "function");
29
+ const callHook = async (name, ctx, check, ...rest) => {
30
+ const hook = hooks[name];
31
+ if (!hook)
32
+ return;
33
+ try {
34
+ await hook(...(ctx ? [ctx, check, ...rest] : rest));
35
+ }
36
+ catch (error) {
37
+ if (name !== "error" && ctx && check)
38
+ await callHook("error", ctx, check, error);
39
+ }
40
+ };
20
41
  const evaluate = async (input) => {
21
42
  const now = input.now ?? new Date();
22
43
  const priority = input.candidate.priority ?? "normal";
23
44
  const trace = [];
45
+ const shadowed = [];
46
+ const nearLimit = [];
24
47
  let surfaces = pickSurfaces(input.user, input.candidate);
25
48
  let deliverAt;
26
- const finish = (partial) => {
49
+ const finish = async (partial) => {
27
50
  const decision = {
51
+ id: `${input.user.id}:${input.candidate.id}:${now.toISOString()}#${++sequence}`,
28
52
  allowed: false,
29
53
  userId: input.user.id,
30
54
  candidateId: input.candidate.id,
31
55
  surfaces: [],
56
+ shadowed,
57
+ nearLimit,
32
58
  trace,
33
59
  evaluatedAt: now,
34
60
  ...partial,
35
61
  };
36
- options.onDecision?.(decision);
62
+ resolved.onDecision?.(decision);
63
+ await callHook("finally", null, null, decision);
37
64
  return decision;
38
65
  };
39
66
  for (const check of checks) {
40
67
  const ctx = { user: input.user, candidate: input.candidate, now, priority, store, surfaces };
68
+ await callHook("before", ctx, check);
41
69
  const started = performance.now();
42
70
  let outcome;
43
71
  try {
@@ -45,6 +73,7 @@ export function createGate(options) {
45
73
  }
46
74
  catch (error) {
47
75
  const message = error instanceof Error ? error.message : String(error);
76
+ await callHook("error", ctx, check, error);
48
77
  if (onStoreError === "closed") {
49
78
  trace.push({ id: check.id, outcome: "reject", reason: `check threw (${message}); failing closed`, ms: elapsed(started) });
50
79
  return finish({ rejectedBy: check.id, reason: `check "${check.id}" failed and the gate fails closed: ${message}` });
@@ -52,15 +81,30 @@ export function createGate(options) {
52
81
  trace.push({ id: check.id, outcome: "skip", reason: `check threw (${message}); failing open`, ms: elapsed(started) });
53
82
  continue;
54
83
  }
55
- if (check.nonRejecting && outcome.kind === "reject") {
56
- // A non-rejecting check that tries to reject is a bug in the check, not a decision about the user.
57
- trace.push({ id: check.id, outcome: "skip", reason: `non-rejecting check returned reject (${outcome.reason}); ignored`, ms: elapsed(started) });
84
+ const ms = elapsed(started);
85
+ await callHook("after", ctx, check, outcome, ms);
86
+ if (check.nonRejecting && (outcome.kind === "reject" || outcome.kind === "defer")) {
87
+ // A non-rejecting check that tries to stop evaluation is a bug in the check, not a decision about the user.
88
+ trace.push({ id: check.id, outcome: "skip", reason: `non-rejecting check returned ${outcome.kind} (${outcome.reason}); ignored`, ms });
58
89
  continue;
59
90
  }
60
- trace.push({ id: check.id, outcome: outcome.kind, ...(("reason" in outcome && outcome.reason) ? { reason: outcome.reason } : {}), ms: elapsed(started) });
61
- if (outcome.kind === "reject") {
62
- return finish({ rejectedBy: check.id, reason: outcome.reason });
91
+ const stops = outcome.kind === "reject" || outcome.kind === "defer";
92
+ const entry = { id: check.id, outcome: outcome.kind, ms };
93
+ if ("reason" in outcome && outcome.reason)
94
+ entry.reason = outcome.reason;
95
+ if (stops && check.shadow)
96
+ entry.shadow = true;
97
+ trace.push(entry);
98
+ if (outcome.kind === "pass" && outcome.nearLimit)
99
+ nearLimit.push({ check: check.id, ...outcome.nearLimit });
100
+ if (stops && check.shadow) {
101
+ shadowed.push(check.id);
102
+ continue;
63
103
  }
104
+ if (outcome.kind === "reject")
105
+ return finish({ rejectedBy: check.id, reason: outcome.reason });
106
+ if (outcome.kind === "defer")
107
+ return finish({ deferredBy: check.id, retryAt: outcome.retryAt, reason: outcome.reason });
64
108
  if (outcome.kind === "adjust") {
65
109
  if (outcome.deliverAt)
66
110
  deliverAt = outcome.deliverAt;
@@ -73,20 +117,25 @@ export function createGate(options) {
73
117
  const commit = async (decision, input) => {
74
118
  if (!decision.allowed)
75
119
  return false;
76
- if (!budgetChecks.length)
120
+ if (!consumers.length)
77
121
  return true;
78
- const now = input.now ?? new Date();
122
+ const now = input.now ?? decision.evaluatedAt;
123
+ const priority = input.candidate.priority ?? "normal";
124
+ const marker = `commit:${decision.id}`;
79
125
  try {
80
- for (const check of budgetChecks) {
81
- const key = check.id === "weeklyBudget"
82
- ? weeklyBudgetKey(input.user.id, now, input.user.timezone)
83
- : budgetKey(input.user.id, now, input.user.timezone);
84
- const used = await store.incr(key, check.id === "weeklyBudget" ? 8 * DAY_SECONDS : 2 * DAY_SECONDS);
85
- const limit = readLimit(check);
86
- if (limit !== undefined && used > limit)
87
- return false;
126
+ const seen = await store.get(marker);
127
+ if (seen !== null)
128
+ return seen === "1";
129
+ let ok = true;
130
+ for (const check of consumers) {
131
+ const ctx = { user: input.user, candidate: input.candidate, now, priority, store, surfaces: decision.surfaces };
132
+ if (!(await check.consume(ctx))) {
133
+ ok = false;
134
+ break;
135
+ }
88
136
  }
89
- return true;
137
+ await store.set(marker, ok ? "1" : "0", COMMIT_TTL);
138
+ return ok;
90
139
  }
91
140
  catch {
92
141
  return onStoreError === "open";
@@ -121,7 +170,3 @@ function pickSurfaces(user, candidate) {
121
170
  return wanted.filter((s) => allowed.has(s));
122
171
  }
123
172
  const elapsed = (started) => Math.round((performance.now() - started) * 1000) / 1000;
124
- /** dailyBudget() closes over its limit; expose it through a well-known property for commit(). */
125
- function readLimit(check) {
126
- return typeof check.limit === "number" ? check.limit : undefined;
127
- }
@@ -1,8 +1,11 @@
1
1
  export { createGate } from "./gate.js";
2
- export type { Gate } from "./gate.js";
2
+ export type { Gate, PolicyGateOptions } from "./gate.js";
3
+ export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
4
+ export { presets } from "./presets.js";
5
+ export type { Preset } from "./presets.js";
3
6
  export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
4
7
  export type { RedisLike } from "./stores.js";
5
8
  export * as checks from "./checks.js";
6
- export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
9
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, monthlyBudgetKey, dismissalKey } from "./checks.js";
7
10
  export { PRIORITY_RANK } from "./types.js";
8
- export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateOptions, OutcomeEvent, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
11
+ export type { Candidate, Check, CheckContext, CheckOutcome, Decision, EvaluateInput, GateHooks, GateOptions, OutcomeEvent, Policy, PolicyEntry, Priority, Store, Surface, TraceEntry, UserState, } from "./types.js";
package/dist/src/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  export { createGate } from "./gate.js";
2
+ export { compilePolicy, KNOWN_CHECKS } from "./policy.js";
3
+ export { presets } from "./presets.js";
2
4
  export { MemoryStore, RedisStore, SqliteStore } from "./stores.js";
3
5
  export * as checks from "./checks.js";
4
- export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, dismissalKey } from "./checks.js";
6
+ export { defaultChecks, localClock, inWindow, budgetKey, weeklyBudgetKey, monthlyBudgetKey, dismissalKey } from "./checks.js";
5
7
  export { PRIORITY_RANK } from "./types.js";
@@ -0,0 +1,16 @@
1
+ export declare const FRAMEWORKS: readonly ["ai-sdk", "mastra", "langchain", "openai-agents", "none"];
2
+ export type Framework = (typeof FRAMEWORKS)[number];
3
+ /** The order LILA runs, as a policy document. A preset is appended when one is named. */
4
+ export declare function buildPolicy(preset?: string): Record<string, unknown>;
5
+ export declare function snippetFor(framework: Framework, file: string): string;
6
+ export declare function presetLines(): string;
7
+ export declare function listText(): string;
8
+ /** Everything init writes and prints, as data, so the test does not need a filesystem. */
9
+ export declare function plan(options: {
10
+ preset?: string;
11
+ framework?: Framework;
12
+ out: string;
13
+ }): {
14
+ policy: string;
15
+ message: string;
16
+ };