proactive-gate 0.1.2 → 0.2.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.
@@ -17,8 +17,10 @@ export declare function enabled(): Check;
17
17
  export declare function mode(options: {
18
18
  allow: string[];
19
19
  }): Check;
20
- /** A global pause until an instant. */
21
- export declare function snooze(): Check;
20
+ /** A global pause until an instant. With `defer: true` the decision carries the instant as `retryAt` instead of rejecting. */
21
+ export declare function snooze(options?: {
22
+ defer?: boolean;
23
+ }): Check;
22
24
  /** Per-type mute. */
23
25
  export declare function mute(): Check;
24
26
  /**
@@ -59,23 +61,80 @@ export declare function adaptiveTiming(options?: {
59
61
  nextGoodMoment?: (ctx: CheckContext) => Promise<Date | null> | Date | null;
60
62
  surfacesFor?: (ctx: CheckContext) => Surface[] | null;
61
63
  }): Check;
62
- /**
63
- * At most `limit` deliveries per user per local day. The check reads the
64
- * counter; gate.commit() increments it atomically and can still refuse when
65
- * two instances race, which is the only race-safe place to enforce a cap.
66
- */
67
64
  export interface BudgetCheck extends Check {
68
65
  limit: number;
66
+ consume(ctx: CheckContext): Promise<boolean>;
69
67
  }
70
- export declare function dailyBudget(options?: {
68
+ export interface BudgetOptions {
71
69
  limit?: number;
72
70
  bypassPriority?: Priority;
73
- }): BudgetCheck;
71
+ /** Fraction of the limit at which a pass carries a nearLimit note. Default 0.8. */
72
+ nearLimit?: number;
73
+ }
74
74
  export declare const budgetKey: (userId: string, now: Date, timezone?: string) => string;
75
75
  export declare const weeklyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
76
- export declare function weeklyBudget(options?: {
77
- limit?: number;
78
- bypassPriority?: Priority;
76
+ export declare const monthlyBudgetKey: (userId: string, now: Date, timezone?: string) => string;
77
+ /** At most `limit` deliveries per user per local day. */
78
+ export declare function dailyBudget(options?: BudgetOptions): BudgetCheck;
79
+ /** At most `limit` deliveries per user per local ISO week. */
80
+ export declare function weeklyBudget(options?: BudgetOptions): BudgetCheck;
81
+ /** At most `limit` deliveries per user per local calendar month. */
82
+ export declare function monthlyBudget(options?: BudgetOptions): BudgetCheck;
83
+ /**
84
+ * Horvitz's expected-utility rule with the PRISM threshold: act only when the
85
+ * caller's estimate of acceptance clears tau = cFA / (cFA + pNeed * cFN).
86
+ * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
87
+ */
88
+ export declare function utilityFloor(options: {
89
+ costFalseAlarm: number;
90
+ costMissedHelp: number;
91
+ }): Check;
92
+ /**
93
+ * Bounded deferral (Horvitz): when the user is busy, wait t* = min(bound,
94
+ * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
95
+ * staleness loss against the cost of interrupting a busy person, with the
96
+ * user becoming free at rate lambda. Never rejects; only moves deliverAt.
97
+ */
98
+ export declare function boundedDeferral(options?: {
99
+ lambda?: number;
100
+ interruptCost?: number;
101
+ staleness?: number;
102
+ boundSeconds?: number;
103
+ isBusy?: (ctx: CheckContext) => boolean;
104
+ }): Check;
105
+ /** Deliveries only inside [start, end) local time in a fixed zone or the user's. */
106
+ export declare function allowedWindow(options: {
107
+ start: string;
108
+ end: string;
109
+ timezone: string;
110
+ priorityFloor?: Priority;
111
+ id?: string;
112
+ }): Check;
113
+ /** Requires `user.consents[name]`, always or only inside a local-time window. */
114
+ export declare function requiresConsent(options: {
115
+ name: string;
116
+ when?: {
117
+ start: string;
118
+ end: string;
119
+ timezone: string;
120
+ };
121
+ id?: string;
122
+ }): Check;
123
+ /** Fixed-window rate limit keyed by user or by candidate.channel; consumed at commit. */
124
+ export declare function rateLimit(options: {
125
+ limit: number;
126
+ perSeconds: number;
127
+ keyBy?: "user" | "channel";
128
+ id?: string;
129
+ }): BudgetCheck;
130
+ /** The user wrote to the assistant within the last `withinHours`. */
131
+ export declare function recentInteraction(options: {
132
+ withinHours: number;
133
+ }): Check;
134
+ /** At most `limit` deliveries in the `withinHours` window that opened with the user's last inbound message. */
135
+ export declare function windowBudget(options: {
136
+ limit: number;
137
+ withinHours: number;
79
138
  }): BudgetCheck;
80
139
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
81
140
  export declare function defaultChecks(options?: {
@@ -2,6 +2,7 @@ import { PRIORITY_RANK } from "./types.js";
2
2
  const pass = { kind: "pass" };
3
3
  const reject = (reason) => ({ kind: "reject", reason });
4
4
  const skip = (reason) => ({ kind: "skip", reason });
5
+ const defer = (reason, retryAt) => ({ kind: "defer", reason, retryAt });
5
6
  const atLeast = (priority, floor) => PRIORITY_RANK[priority] >= PRIORITY_RANK[floor];
6
7
  const toDate = (value) => {
7
8
  if (value === null || value === undefined)
@@ -37,6 +38,7 @@ export function inWindow(minutes, start, end) {
37
38
  return false;
38
39
  return start < end ? minutes >= start && minutes < end : minutes >= start || minutes < end;
39
40
  }
41
+ const localDay = (now, timezone) => (timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10));
40
42
  /* ------------------------------------------------------------------------ */
41
43
  /* The checks, in the order LILA runs them. Compose your own order freely. */
42
44
  /* ------------------------------------------------------------------------ */
@@ -72,13 +74,16 @@ export function mode(options) {
72
74
  : pass,
73
75
  };
74
76
  }
75
- /** A global pause until an instant. */
76
- export function snooze() {
77
+ /** A global pause until an instant. With `defer: true` the decision carries the instant as `retryAt` instead of rejecting. */
78
+ export function snooze(options = {}) {
77
79
  return {
78
80
  id: "snooze",
79
81
  run: ({ user, now }) => {
80
82
  const until = toDate(user.snoozedUntil);
81
- return until && until > now ? reject(`snoozed until ${until.toISOString()}`) : pass;
83
+ if (!until || until <= now)
84
+ return pass;
85
+ const reason = `snoozed until ${until.toISOString()}`;
86
+ return options.defer ? defer(reason, until) : reject(reason);
82
87
  },
83
88
  };
84
89
  }
@@ -196,21 +201,30 @@ export function adaptiveTiming(options = {}) {
196
201
  },
197
202
  };
198
203
  }
199
- export function dailyBudget(options = {}) {
200
- const limit = options.limit ?? 5;
204
+ function budget(spec, options) {
205
+ const limit = options.limit ?? spec.defaultLimit;
206
+ const nearAt = Math.max(1, Math.ceil(limit * (options.nearLimit ?? 0.8)));
207
+ const bypass = (priority) => options.bypassPriority !== undefined && atLeast(priority, options.bypassPriority);
201
208
  return {
202
- id: "dailyBudget",
209
+ id: spec.id,
203
210
  limit,
204
- async run({ user, now, store, priority }) {
205
- if (options.bypassPriority && atLeast(priority, options.bypassPriority))
211
+ async run(ctx) {
212
+ if (bypass(ctx.priority))
206
213
  return pass;
207
- const key = budgetKey(user.id, now, user.timezone);
208
- const used = Number((await store.get(key)) ?? 0);
209
- return used < limit ? pass : reject(`daily budget of ${limit} used (${used})`);
214
+ const used = Number((await ctx.store.get(spec.keyFor(ctx))) ?? 0);
215
+ if (used >= limit)
216
+ return reject(`${spec.label} of ${limit} used (${used})`);
217
+ return used >= nearAt ? { kind: "pass", reason: `${used} of ${limit} used`, nearLimit: { used, limit } } : pass;
218
+ },
219
+ async consume(ctx) {
220
+ if (bypass(ctx.priority))
221
+ return true;
222
+ const used = await ctx.store.incr(spec.keyFor(ctx), spec.ttlSeconds);
223
+ return used <= limit;
210
224
  },
211
225
  };
212
226
  }
213
- export const budgetKey = (userId, now, timezone) => `budget:${userId}:${timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10)}`;
227
+ export const budgetKey = (userId, now, timezone) => `budget:${userId}:${localDay(now, timezone)}`;
214
228
  const isoWeekKey = (day) => {
215
229
  const date = new Date(`${day}T00:00:00Z`);
216
230
  const weekday = date.getUTCDay() || 7;
@@ -219,24 +233,135 @@ const isoWeekKey = (day) => {
219
233
  const week = Math.ceil((((date.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
220
234
  return `${date.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
221
235
  };
222
- export const weeklyBudgetKey = (userId, now, timezone) => {
223
- const day = timezone ? localClock(now, timezone).day : now.toISOString().slice(0, 10);
224
- return `weeklyBudget:${userId}:${isoWeekKey(day)}`;
225
- };
236
+ export const weeklyBudgetKey = (userId, now, timezone) => `weeklyBudget:${userId}:${isoWeekKey(localDay(now, timezone))}`;
237
+ export const monthlyBudgetKey = (userId, now, timezone) => `monthlyBudget:${userId}:${localDay(now, timezone).slice(0, 7)}`;
238
+ /** At most `limit` deliveries per user per local day. */
239
+ export function dailyBudget(options = {}) {
240
+ return budget({ id: "dailyBudget", label: "daily budget", defaultLimit: 5, keyFor: ({ user, now }) => budgetKey(user.id, now, user.timezone), ttlSeconds: 2 * DAY_SECONDS }, options);
241
+ }
242
+ /** At most `limit` deliveries per user per local ISO week. */
226
243
  export function weeklyBudget(options = {}) {
227
- const limit = options.limit ?? 20;
244
+ return budget({ id: "weeklyBudget", label: "weekly budget", defaultLimit: 20, keyFor: ({ user, now }) => weeklyBudgetKey(user.id, now, user.timezone), ttlSeconds: 8 * DAY_SECONDS }, options);
245
+ }
246
+ /** At most `limit` deliveries per user per local calendar month. */
247
+ export function monthlyBudget(options = {}) {
248
+ return budget({ id: "monthlyBudget", label: "monthly budget", defaultLimit: 60, keyFor: ({ user, now }) => monthlyBudgetKey(user.id, now, user.timezone), ttlSeconds: 32 * DAY_SECONDS }, options);
249
+ }
250
+ /* ------------------------------------------------------------------------ */
251
+ /* Optional, caller-fed checks. Off by default; the package ships no model. */
252
+ /* ------------------------------------------------------------------------ */
253
+ /**
254
+ * Horvitz's expected-utility rule with the PRISM threshold: act only when the
255
+ * caller's estimate of acceptance clears tau = cFA / (cFA + pNeed * cFN).
256
+ * `candidate.pAccept` and `candidate.pNeed` come from the caller's own model.
257
+ */
258
+ export function utilityFloor(options) {
259
+ const { costFalseAlarm: cFA, costMissedHelp: cFN } = options;
228
260
  return {
229
- id: "weeklyBudget",
230
- limit,
231
- async run({ user, now, store, priority }) {
232
- if (options.bypassPriority && atLeast(priority, options.bypassPriority))
261
+ id: "utilityFloor",
262
+ run: ({ candidate }) => {
263
+ if (typeof candidate.pAccept !== "number")
264
+ return skip("no pAccept on the candidate; utility floor cannot be evaluated");
265
+ const pNeed = typeof candidate.pNeed === "number" ? candidate.pNeed : 1;
266
+ const tau = cFA / (cFA + pNeed * cFN);
267
+ return candidate.pAccept >= tau ? pass : reject(`pAccept ${round3(candidate.pAccept)} < tau ${round3(tau)}`);
268
+ },
269
+ };
270
+ }
271
+ const round3 = (n) => Math.round(n * 1000) / 1000;
272
+ /**
273
+ * Bounded deferral (Horvitz): when the user is busy, wait t* = min(bound,
274
+ * lambda * interruptCost / (2 * staleness)), the optimum of a quadratic
275
+ * staleness loss against the cost of interrupting a busy person, with the
276
+ * user becoming free at rate lambda. Never rejects; only moves deliverAt.
277
+ */
278
+ export function boundedDeferral(options = {}) {
279
+ const lambda = options.lambda ?? 1 / 43;
280
+ const cost = options.interruptCost ?? 1;
281
+ const staleness = options.staleness ?? 0.0001;
282
+ const bound = options.boundSeconds ?? 240;
283
+ const tStar = Math.min(bound, (lambda * cost) / (2 * staleness));
284
+ return {
285
+ id: "boundedDeferral",
286
+ nonRejecting: true,
287
+ run: (ctx) => {
288
+ const busy = options.isBusy ? options.isBusy(ctx) : ctx.candidate.busy === true;
289
+ if (!busy)
233
290
  return pass;
234
- const key = weeklyBudgetKey(user.id, now, user.timezone);
235
- const used = Number((await store.get(key)) ?? 0);
236
- return used < limit ? pass : reject(`weekly budget of ${limit} used (${used})`);
291
+ const at = new Date(ctx.now.getTime() + Math.round(tStar * 1000));
292
+ return { kind: "adjust", reason: `user busy; deliver at ${at.toISOString()} (t* ${Math.round(tStar)} s)`, deliverAt: at };
237
293
  },
238
294
  };
239
295
  }
296
+ /* ------------------------------------------------------------------------ */
297
+ /* Primitives the presets compose. */
298
+ /* ------------------------------------------------------------------------ */
299
+ const zoneOf = (ctx, timezone) => (timezone === "user" ? ctx.user.timezone : timezone);
300
+ /** Deliveries only inside [start, end) local time in a fixed zone or the user's. */
301
+ export function allowedWindow(options) {
302
+ const start = parseHHMM(options.start);
303
+ const end = parseHHMM(options.end);
304
+ return {
305
+ id: options.id ?? "allowedWindow",
306
+ run: (ctx) => {
307
+ const zone = zoneOf(ctx, options.timezone);
308
+ if (!zone)
309
+ return skip("no timezone on the user; window cannot be evaluated");
310
+ if (options.priorityFloor && atLeast(ctx.priority, options.priorityFloor))
311
+ return pass;
312
+ const { minutes } = localClock(ctx.now, zone);
313
+ return inWindow(minutes, start, end) ? pass : reject(`outside the allowed window ${options.start} to ${options.end} ${zone}`);
314
+ },
315
+ };
316
+ }
317
+ /** Requires `user.consents[name]`, always or only inside a local-time window. */
318
+ export function requiresConsent(options) {
319
+ const when = options.when ? { start: parseHHMM(options.when.start), end: parseHHMM(options.when.end), timezone: options.when.timezone } : null;
320
+ return {
321
+ id: options.id ?? `consent:${options.name}`,
322
+ run: (ctx) => {
323
+ if (when) {
324
+ const zone = zoneOf(ctx, when.timezone);
325
+ if (!zone)
326
+ return skip("no timezone on the user; consent window cannot be evaluated");
327
+ if (!inWindow(localClock(ctx.now, zone).minutes, when.start, when.end))
328
+ return pass;
329
+ }
330
+ return ctx.user.consents?.[options.name] ? pass : reject(`consent "${options.name}" is missing${when ? ` (required ${options.when.start} to ${options.when.end})` : ""}`);
331
+ },
332
+ };
333
+ }
334
+ /** Fixed-window rate limit keyed by user or by candidate.channel; consumed at commit. */
335
+ export function rateLimit(options) {
336
+ const keyBy = options.keyBy ?? "user";
337
+ const keyFor = (ctx) => {
338
+ const scope = keyBy === "channel" ? ctx.candidate.channel ?? ctx.user.id : ctx.user.id;
339
+ return `rate:${keyBy}:${scope}:${options.perSeconds}:${Math.floor(ctx.now.getTime() / 1000 / options.perSeconds)}`;
340
+ };
341
+ const id = options.id ?? `rate:${options.limit}/${options.perSeconds}s`;
342
+ return budget({ id, label: `rate limit ${options.limit} per ${options.perSeconds} s`, defaultLimit: options.limit, keyFor, ttlSeconds: options.perSeconds * 2 }, { limit: options.limit, nearLimit: 1 });
343
+ }
344
+ /** The user wrote to the assistant within the last `withinHours`. */
345
+ export function recentInteraction(options) {
346
+ return {
347
+ id: "recentInteraction",
348
+ run: ({ user, now }) => {
349
+ const last = toDate(user.lastInboundAt);
350
+ if (!last)
351
+ return reject("no inbound message from the user on record");
352
+ const age = (now.getTime() - last.getTime()) / 3600000;
353
+ return age <= options.withinHours ? pass : reject(`last inbound message ${Math.floor(age)} h ago, window is ${options.withinHours} h`);
354
+ },
355
+ };
356
+ }
357
+ /** At most `limit` deliveries in the `withinHours` window that opened with the user's last inbound message. */
358
+ export function windowBudget(options) {
359
+ const keyFor = ({ user }) => {
360
+ const last = toDate(user.lastInboundAt);
361
+ return `windowBudget:${user.id}:${last ? Math.floor(last.getTime() / 1000) : "none"}`;
362
+ };
363
+ return budget({ id: "windowBudget", label: `window budget`, defaultLimit: options.limit, keyFor, ttlSeconds: options.withinHours * 3600 }, { limit: options.limit, nearLimit: 1 });
364
+ }
240
365
  /** The LILA order, as a starting point. Replace, reorder, or drop checks freely. */
241
366
  export function defaultChecks(options = {}) {
242
367
  return [
package/dist/src/cli.d.ts CHANGED
@@ -1,6 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import type { Gate } from "./gate.js";
3
- import type { Decision } from "./types.js";
3
+ import type { Decision, EvaluateInput } from "./types.js";
4
4
  export declare function loadPolicy(path?: string): Promise<Gate>;
5
5
  export declare function replay(lines: string[], gate: Gate, commit: boolean): Promise<Decision[]>;
6
6
  export declare function summarize(decisions: Decision[]): string;
7
+ interface PreToolUseEvent {
8
+ tool_name?: string;
9
+ tool_input?: {
10
+ gate?: {
11
+ user: EvaluateInput["user"];
12
+ candidate: EvaluateInput["candidate"];
13
+ now?: string;
14
+ };
15
+ };
16
+ }
17
+ /** Turns a PreToolUse event into the hook output Claude Code expects, or null when the tool does not match. */
18
+ export declare function hookDecision(event: PreToolUseEvent, gate: Gate, tool: string): Promise<string | null>;
19
+ export {};
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[]>;