@intutic/gate 2.9.0 → 3.0.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.
Files changed (95) hide show
  1. package/dist/actions.d.ts +39 -0
  2. package/dist/actions.d.ts.map +1 -1
  3. package/dist/actions.js +139 -0
  4. package/dist/actions.js.map +1 -1
  5. package/dist/client.d.ts +10 -0
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +19 -0
  8. package/dist/client.js.map +1 -1
  9. package/dist/decide.d.ts +269 -0
  10. package/dist/decide.d.ts.map +1 -0
  11. package/dist/decide.js +757 -0
  12. package/dist/decide.js.map +1 -0
  13. package/dist/decideApi.d.ts +24 -0
  14. package/dist/decideApi.d.ts.map +1 -0
  15. package/dist/decideApi.js +32 -0
  16. package/dist/decideApi.js.map +1 -0
  17. package/dist/decideEngine.d.ts +195 -0
  18. package/dist/decideEngine.d.ts.map +1 -0
  19. package/dist/decideEngine.js +707 -0
  20. package/dist/decideEngine.js.map +1 -0
  21. package/dist/decideEvaluate.d.ts +250 -0
  22. package/dist/decideEvaluate.d.ts.map +1 -0
  23. package/dist/decideEvaluate.js +504 -0
  24. package/dist/decideEvaluate.js.map +1 -0
  25. package/dist/decideHostGuard.d.ts +56 -0
  26. package/dist/decideHostGuard.d.ts.map +1 -0
  27. package/dist/decideHostGuard.js +194 -0
  28. package/dist/decideHostGuard.js.map +1 -0
  29. package/dist/decideLog.d.ts +287 -0
  30. package/dist/decideLog.d.ts.map +1 -0
  31. package/dist/decideLog.js +521 -0
  32. package/dist/decideLog.js.map +1 -0
  33. package/dist/decideLogRedaction.d.ts +17 -0
  34. package/dist/decideLogRedaction.d.ts.map +1 -0
  35. package/dist/decideLogRedaction.js +33 -0
  36. package/dist/decideLogRedaction.js.map +1 -0
  37. package/dist/decideRecord.d.ts +128 -0
  38. package/dist/decideRecord.d.ts.map +1 -0
  39. package/dist/decideRecord.js +254 -0
  40. package/dist/decideRecord.js.map +1 -0
  41. package/dist/decideSdk.d.ts +86 -0
  42. package/dist/decideSdk.d.ts.map +1 -0
  43. package/dist/decideSdk.js +146 -0
  44. package/dist/decideSdk.js.map +1 -0
  45. package/dist/dsh.d.ts.map +1 -1
  46. package/dist/dsh.js +6 -1
  47. package/dist/dsh.js.map +1 -1
  48. package/dist/errors.d.ts +26 -2
  49. package/dist/errors.d.ts.map +1 -1
  50. package/dist/errors.js +38 -1
  51. package/dist/errors.js.map +1 -1
  52. package/dist/gate.d.ts +85 -1
  53. package/dist/gate.d.ts.map +1 -1
  54. package/dist/gate.js +314 -18
  55. package/dist/gate.js.map +1 -1
  56. package/dist/gateRuleRecord.d.ts +244 -0
  57. package/dist/gateRuleRecord.d.ts.map +1 -0
  58. package/dist/gateRuleRecord.js +459 -0
  59. package/dist/gateRuleRecord.js.map +1 -0
  60. package/dist/harness.d.ts.map +1 -1
  61. package/dist/harness.js +22 -10
  62. package/dist/harness.js.map +1 -1
  63. package/dist/index.d.ts +14 -5
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +5 -3
  66. package/dist/index.js.map +1 -1
  67. package/dist/lanes.d.ts +94 -0
  68. package/dist/lanes.d.ts.map +1 -0
  69. package/dist/lanes.js +150 -0
  70. package/dist/lanes.js.map +1 -0
  71. package/dist/limits.d.ts +6 -0
  72. package/dist/limits.d.ts.map +1 -1
  73. package/dist/limits.js +6 -0
  74. package/dist/limits.js.map +1 -1
  75. package/dist/refusalDetail.d.ts +58 -0
  76. package/dist/refusalDetail.d.ts.map +1 -0
  77. package/dist/refusalDetail.js +121 -0
  78. package/dist/refusalDetail.js.map +1 -0
  79. package/dist/scopeMatch.d.ts +156 -0
  80. package/dist/scopeMatch.d.ts.map +1 -0
  81. package/dist/scopeMatch.js +438 -0
  82. package/dist/scopeMatch.js.map +1 -0
  83. package/dist/snapshot.d.ts +51 -3
  84. package/dist/snapshot.d.ts.map +1 -1
  85. package/dist/snapshot.js +86 -13
  86. package/dist/snapshot.js.map +1 -1
  87. package/dist/testOutcome.d.ts +164 -0
  88. package/dist/testOutcome.d.ts.map +1 -0
  89. package/dist/testOutcome.js +439 -0
  90. package/dist/testOutcome.js.map +1 -0
  91. package/dist/wrapTools.d.ts +7 -1
  92. package/dist/wrapTools.d.ts.map +1 -1
  93. package/dist/wrapTools.js +30 -2
  94. package/dist/wrapTools.js.map +1 -1
  95. package/package.json +2 -2
package/dist/decide.js ADDED
@@ -0,0 +1,757 @@
1
+ /**
2
+ * Decide rules: the grammar, the validator and the semantics.
3
+ *
4
+ * A decide rule asks a self-hosted model a few narrow `yes_no` or `choice`
5
+ * questions about a tool call or a model response, turns each answer's
6
+ * probability into yes, no or unknown through a band, and combines the
7
+ * answers with the rule's `when` expression in Kleene three-valued logic:
8
+ * true refuses, false allows, unknown escalates. It is written as one SOP
9
+ * front-matter line, `decide: {json}`, one rule per line, the way a
10
+ * `hook_rule:` line is.
11
+ *
12
+ * This module is the pure part every layer shares:
13
+ *
14
+ * - {@link parseDecideLine} and {@link validateDecideRule}: the shape and the
15
+ * eight ordered checks, each failure with one fixed reason;
16
+ * - {@link parseWhen} and {@link canonicalWhen}: the `when` grammar and its
17
+ * fully parenthesised form, `!=` and `IN` rewritten;
18
+ * - {@link decideHashInput}: the canonical JSON the rule hash is taken over,
19
+ * which leaves out `id`, `title`, `escalate` and `rollout`;
20
+ * - {@link bandValue}, {@link choiceOrders}, {@link choiceAtoms}, the Kleene
21
+ * connectives, {@link decideRuleOutcome} and {@link decideCallOutcome}.
22
+ *
23
+ * Self-contained (no imports, no module state) so `@intutic/gate` can carry
24
+ * a byte-identical copy (`packages/gate-js/src/decide.ts`). The proxy's port
25
+ * is `packages/proxy/src/decide/mod.rs` and the Python gate's is
26
+ * `intutic_clawde/gate/decide.py`; all of them run
27
+ * `fixtures/decide-vectors.json` and must give the same answer, reason text
28
+ * included. The SHA-256 is passed in ({@link decideRuleHash}), because this
29
+ * file also runs where `node:crypto` does not.
30
+ *
31
+ * @module
32
+ */
33
+ // ─── Limits ───────────────────────────────────────────────────────────
34
+ export const DECIDE_MAX_QUESTIONS = 8;
35
+ export const DECIDE_MAX_OPTIONS = 8;
36
+ export const DECIDE_MAX_RULES_PER_CALL = 4;
37
+ export const DECIDE_MAX_WHEN_CHARS = 256;
38
+ export const DECIDE_MIN_ASK_CHARS = 8;
39
+ export const DECIDE_MAX_ASK_CHARS = 300;
40
+ export const DECIDE_DEFAULT_BAND = { yes_at: 0.85, no_below: 0.15 };
41
+ /** Appended to every `choice` question's options when absent, and always asked last. */
42
+ export const NONE_OF_THESE = 'none_of_these';
43
+ // The same values as `TOOL_TOKEN_RE`, `MAX_HOOK_TOOLS` and `MAX_TITLE_CHARS`
44
+ // in `guardrailIr.ts`; a test holds them equal.
45
+ const TOOL_TOKEN = /^[A-Za-z][A-Za-z0-9_.:-]{0,63}$/;
46
+ const MAX_TOOLS = 8;
47
+ const MAX_TITLE = 80;
48
+ const RULE_ID = /^[a-z][a-z0-9_-]{0,47}$/;
49
+ const NAME = /^[a-z][a-z0-9_]{0,31}$/;
50
+ const KEYWORDS = ['AND', 'OR', 'NOT', 'IN'];
51
+ /**
52
+ * A question that asks about approval. Approval comes from the harness's ask
53
+ * or the review queue, never from a model reading text, so the validator
54
+ * refuses such a question (check 8). ASCII word boundaries and case folding,
55
+ * as JavaScript, Python's `re.ASCII` and Rust's `(?-u)` all read it.
56
+ */
57
+ export const DECIDE_APPROVAL_RE = /\b(approv\w*|authori[sz]\w*|pre-?approv\w*|consent\w*|permi(t|ssion)\w*|sign(ed)?[- ]off)\b/i;
58
+ // ─── Shape ────────────────────────────────────────────────────────────
59
+ const RULE_FIELDS = ['id', 'title', 'on', 'tools', 'questions', 'when', 'band', 'escalate', 'rollout', 'context'];
60
+ const QUESTION_FIELDS = ['type', 'ask', 'options', 'band'];
61
+ const BAND_FIELDS = ['yes_at', 'no_below'];
62
+ function isObject(v) {
63
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
64
+ }
65
+ /** An own, defined property: `in` would also see `Object.prototype`, and an `undefined` field is an absent one. */
66
+ function has(obj, key) {
67
+ return Object.prototype.hasOwnProperty.call(obj, key) && obj[key] !== undefined;
68
+ }
69
+ function own(obj, key) {
70
+ return obj && Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : undefined;
71
+ }
72
+ /** Length in code points, as Python's `len` and Rust's `chars().count()` measure it. */
73
+ function codePoints(s) {
74
+ return Array.from(s).length;
75
+ }
76
+ function unknownField(obj, allowed) {
77
+ return Object.keys(obj)
78
+ .filter((k) => !allowed.includes(k) && obj[k] !== undefined)
79
+ .sort()[0];
80
+ }
81
+ function bandShape(v, path) {
82
+ if (!isObject(v))
83
+ return `${path} must be an object`;
84
+ const extra = unknownField(v, BAND_FIELDS);
85
+ if (extra !== undefined)
86
+ return `${path}: unknown field ${extra}`;
87
+ if (!has(v, 'yes_at'))
88
+ return `${path}.yes_at is required`;
89
+ if (typeof v.yes_at !== 'number' || !(v.yes_at > 0.5 && v.yes_at < 1))
90
+ return `${path}.yes_at must be above 0.5 and below 1`;
91
+ if (!has(v, 'no_below'))
92
+ return `${path}.no_below is required`;
93
+ if (typeof v.no_below !== 'number' || !(v.no_below > 0))
94
+ return `${path}.no_below must be above 0`;
95
+ return null;
96
+ }
97
+ function questionShape(name, v) {
98
+ const path = `questions.${name}`;
99
+ if (!isObject(v))
100
+ return `${path} must be an object`;
101
+ const extra = unknownField(v, QUESTION_FIELDS);
102
+ if (extra !== undefined)
103
+ return `${path}: unknown field ${extra}`;
104
+ if (!has(v, 'type'))
105
+ return `${path}.type is required`;
106
+ if (v.type !== 'yes_no' && v.type !== 'choice')
107
+ return `${path}.type must be yes_no or choice`;
108
+ if (!has(v, 'ask'))
109
+ return `${path}.ask is required`;
110
+ const ask = v.ask;
111
+ if (typeof ask !== 'string' || codePoints(ask) < DECIDE_MIN_ASK_CHARS || codePoints(ask) > DECIDE_MAX_ASK_CHARS || /[\t\r\n]/.test(ask)) {
112
+ return `${path}.ask must be ${DECIDE_MIN_ASK_CHARS} to ${DECIDE_MAX_ASK_CHARS} characters with no tab or newline`;
113
+ }
114
+ if (v.type === 'yes_no') {
115
+ if (has(v, 'options'))
116
+ return `${path}.options applies to choice questions only`;
117
+ }
118
+ else {
119
+ if (!has(v, 'options'))
120
+ return `${path}.options is required for a choice question`;
121
+ const options = v.options;
122
+ if (!Array.isArray(options))
123
+ return `${path}.options must be a list`;
124
+ if (options.length < 2 || options.length > DECIDE_MAX_OPTIONS)
125
+ return `${path}.options lists 2 to ${DECIDE_MAX_OPTIONS} options`;
126
+ for (let i = 0; i < options.length; i++) {
127
+ const o = options[i];
128
+ if (typeof o !== 'string' || !NAME.test(o))
129
+ return `${path}.options[${i}] is not an option name`;
130
+ if (options.indexOf(o) !== i)
131
+ return `${path}.options[${i}] repeats ${o}`;
132
+ }
133
+ }
134
+ if (has(v, 'band'))
135
+ return bandShape(v.band, `${path}.band`);
136
+ return null;
137
+ }
138
+ function ruleShape(v) {
139
+ if (!isObject(v))
140
+ return 'a rule is a JSON object';
141
+ const extra = unknownField(v, RULE_FIELDS);
142
+ if (extra !== undefined)
143
+ return `unknown field ${extra}`;
144
+ if (!has(v, 'id'))
145
+ return 'id is required';
146
+ if (typeof v.id !== 'string' || !RULE_ID.test(v.id))
147
+ return 'id must be a lower-case letter, then up to 47 of a-z, 0-9, _ or -';
148
+ if (has(v, 'title')) {
149
+ const t = v.title;
150
+ if (typeof t !== 'string' || codePoints(t) < 1 || codePoints(t) > MAX_TITLE || /[\t\r\n]/.test(t)) {
151
+ return `title must be 1 to ${MAX_TITLE} characters with no tab or newline`;
152
+ }
153
+ }
154
+ if (!has(v, 'on'))
155
+ return 'on is required';
156
+ if (v.on !== 'tool_call' && v.on !== 'response')
157
+ return 'on must be tool_call or response';
158
+ if (has(v, 'tools')) {
159
+ const tools = v.tools;
160
+ if (!Array.isArray(tools))
161
+ return 'tools must be a list';
162
+ if (tools.length < 1 || tools.length > MAX_TOOLS)
163
+ return `tools names 1 to ${MAX_TOOLS} tools`;
164
+ for (let i = 0; i < tools.length; i++) {
165
+ const t = tools[i];
166
+ if (typeof t !== 'string' || !TOOL_TOKEN.test(t))
167
+ return `tools[${i}] is not a tool name`;
168
+ }
169
+ }
170
+ if (!has(v, 'questions'))
171
+ return 'questions is required';
172
+ const questions = v.questions;
173
+ if (!isObject(questions))
174
+ return 'questions must be an object';
175
+ for (const name of Object.keys(questions).sort()) {
176
+ if (!NAME.test(name))
177
+ return `questions: ${name} is not a question name`;
178
+ if (KEYWORDS.includes(name.toUpperCase()))
179
+ return `questions: ${name} is a when keyword`;
180
+ const why = questionShape(name, questions[name]);
181
+ if (why)
182
+ return why;
183
+ }
184
+ if (!has(v, 'when'))
185
+ return 'when is required';
186
+ if (typeof v.when !== 'string')
187
+ return 'when must be a string';
188
+ if (codePoints(v.when) < 1 || codePoints(v.when) > DECIDE_MAX_WHEN_CHARS)
189
+ return `when must be 1 to ${DECIDE_MAX_WHEN_CHARS} characters`;
190
+ if (has(v, 'band')) {
191
+ const why = bandShape(v.band, 'band');
192
+ if (why)
193
+ return why;
194
+ }
195
+ if (has(v, 'escalate') && v.escalate !== 'ask' && v.escalate !== 'review' && v.escalate !== 'refuse')
196
+ return 'escalate must be ask, review or refuse';
197
+ if (has(v, 'rollout') && v.rollout !== 'shadow' && v.rollout !== 'enforce')
198
+ return 'rollout must be shadow or enforce';
199
+ if (has(v, 'context')) {
200
+ const c = v.context;
201
+ if (!Array.isArray(c))
202
+ return 'context must be a list';
203
+ if (c.length > 1 || (c.length === 1 && c[0] !== 'last_user_turn'))
204
+ return 'context may list last_user_turn, once';
205
+ }
206
+ return null;
207
+ }
208
+ /** Thrown inside the parser only; {@link parseWhen} turns it into a result. */
209
+ class WhenError {
210
+ message;
211
+ column;
212
+ constructor(message, column) {
213
+ this.message = message;
214
+ this.column = column;
215
+ }
216
+ }
217
+ function isWordChar(c) {
218
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c === '_';
219
+ }
220
+ function tokenize(src) {
221
+ const cs = Array.from(src);
222
+ const out = [];
223
+ let i = 0;
224
+ while (i < cs.length) {
225
+ const c = cs[i];
226
+ const col = i + 1;
227
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') {
228
+ i++;
229
+ }
230
+ else if (c === '(' || c === ')' || c === ',') {
231
+ out.push({ t: c, v: c, col });
232
+ i++;
233
+ }
234
+ else if ((c === '=' || c === '!') && cs[i + 1] === '=') {
235
+ out.push({ t: c === '=' ? '==' : '!=', v: `${c}=`, col });
236
+ i += 2;
237
+ }
238
+ else if (isWordChar(c)) {
239
+ let j = i;
240
+ while (j < cs.length && isWordChar(cs[j]))
241
+ j++;
242
+ const w = cs.slice(i, j).join('');
243
+ if (KEYWORDS.includes(w))
244
+ out.push({ t: 'kw', v: w, col });
245
+ else if (/^[a-z][a-z0-9_]*$/.test(w))
246
+ out.push({ t: 'name', v: w, col });
247
+ else
248
+ throw new WhenError(`unknown word ${w}`, col);
249
+ i = j;
250
+ }
251
+ else {
252
+ throw new WhenError(`unexpected character ${c}`, col);
253
+ }
254
+ }
255
+ out.push({ t: 'end', v: '', col: cs.length + 1 });
256
+ return out;
257
+ }
258
+ /** Parse a `when` expression (§3.3 of the decide LLD). Never throws. Names are not checked here; {@link validateDecideRule} does that. */
259
+ export function parseWhen(src) {
260
+ try {
261
+ const toks = tokenize(src);
262
+ let k = 0;
263
+ const peek = () => toks[k];
264
+ const next = () => toks[k++];
265
+ const isKw = (w) => peek().t === 'kw' && peek().v === w;
266
+ const option = () => {
267
+ const t = next();
268
+ if (t.t !== 'name')
269
+ throw new WhenError('expected an option', t.col);
270
+ return t.v;
271
+ };
272
+ const parseOr = () => {
273
+ let a = parseAnd();
274
+ while (isKw('OR')) {
275
+ next();
276
+ a = { op: 'or', a, b: parseAnd() };
277
+ }
278
+ return a;
279
+ };
280
+ const parseAnd = () => {
281
+ let a = parseUnary();
282
+ while (isKw('AND')) {
283
+ next();
284
+ a = { op: 'and', a, b: parseUnary() };
285
+ }
286
+ return a;
287
+ };
288
+ const parseUnary = () => {
289
+ if (isKw('NOT')) {
290
+ next();
291
+ return { op: 'not', a: parseUnary() };
292
+ }
293
+ return parseAtom();
294
+ };
295
+ const parseAtom = () => {
296
+ const t = next();
297
+ if (t.t === '(') {
298
+ const e = parseOr();
299
+ const close = next();
300
+ if (close.t !== ')')
301
+ throw new WhenError('expected )', close.col);
302
+ return e;
303
+ }
304
+ if (t.t !== 'name')
305
+ throw new WhenError('expected a question name, NOT or (', t.col);
306
+ const q = t.v;
307
+ if (peek().t === '==') {
308
+ next();
309
+ return { op: 'eq', q, o: option() };
310
+ }
311
+ if (peek().t === '!=') {
312
+ next();
313
+ return { op: 'not', a: { op: 'eq', q, o: option() } };
314
+ }
315
+ if (isKw('IN')) {
316
+ next();
317
+ const open = next();
318
+ if (open.t !== '(')
319
+ throw new WhenError('expected (', open.col);
320
+ let e = { op: 'eq', q, o: option() };
321
+ for (;;) {
322
+ const sep = next();
323
+ if (sep.t === ')')
324
+ return e;
325
+ if (sep.t !== ',')
326
+ throw new WhenError('expected , or )', sep.col);
327
+ e = { op: 'or', a: e, b: { op: 'eq', q, o: option() } };
328
+ }
329
+ }
330
+ return { op: 'yes', q };
331
+ };
332
+ const expr = parseOr();
333
+ if (peek().t !== 'end')
334
+ throw new WhenError('expected AND, OR or the end', peek().col);
335
+ return { ok: true, expr };
336
+ }
337
+ catch (err) {
338
+ if (err instanceof WhenError)
339
+ return { ok: false, message: err.message, column: err.column };
340
+ throw err;
341
+ }
342
+ }
343
+ /** The canonical form: fully parenthesised, `!=` and `IN` rewritten. `a AND b != o` → `(a AND (NOT (b == o)))`. */
344
+ export function canonicalWhen(e) {
345
+ switch (e.op) {
346
+ case 'yes':
347
+ return e.q;
348
+ case 'eq':
349
+ return `(${e.q} == ${e.o})`;
350
+ case 'not':
351
+ return `(NOT ${canonicalWhen(e.a)})`;
352
+ case 'and':
353
+ return `(${canonicalWhen(e.a)} AND ${canonicalWhen(e.b)})`;
354
+ case 'or':
355
+ return `(${canonicalWhen(e.a)} OR ${canonicalWhen(e.b)})`;
356
+ }
357
+ }
358
+ /** Every atom, left to right as written. */
359
+ function atomsOf(e, out = []) {
360
+ if (e.op === 'yes' || e.op === 'eq')
361
+ out.push(e);
362
+ else if (e.op === 'not')
363
+ atomsOf(e.a, out);
364
+ else {
365
+ atomsOf(e.a, out);
366
+ atomsOf(e.b, out);
367
+ }
368
+ return out;
369
+ }
370
+ // ─── Validation ───────────────────────────────────────────────────────
371
+ /** A `choice` question's options as asked: `none_of_these` appended when absent and moved last when present. */
372
+ export function decideOptions(q) {
373
+ return [...(q.options ?? []).filter((o) => o !== NONE_OF_THESE), NONE_OF_THESE];
374
+ }
375
+ /** The band a question is judged on: its own, else the rule's, else the default. */
376
+ export function decideQuestionBand(rule, name) {
377
+ return own(rule.questions, name)?.band ?? rule.band ?? DECIDE_DEFAULT_BAND;
378
+ }
379
+ /**
380
+ * Validate an untrusted value as a decide rule (the line's JSON, without
381
+ * `kind`). Never throws. The shape comes first, then the eight checks in
382
+ * their fixed order; the first failure is the reason. Questions are visited
383
+ * in sorted name order and `when` atoms left to right, so every port reports
384
+ * the same first failure.
385
+ */
386
+ export function validateDecideRule(value) {
387
+ const shape = ruleShape(value);
388
+ if (shape)
389
+ return { ok: false, reason: `decide: ${shape}` };
390
+ const rule = value;
391
+ const names = Object.keys(rule.questions).sort();
392
+ if ((rule.on === 'tool_call') !== (rule.tools !== undefined)) {
393
+ return { ok: false, reason: 'decide: tools is required for on: tool_call and not allowed for on: response' };
394
+ }
395
+ if (rule.on !== 'tool_call' && rule.context !== undefined)
396
+ return { ok: false, reason: 'decide: context applies to on: tool_call only' };
397
+ if (names.length < 1 || names.length > DECIDE_MAX_QUESTIONS)
398
+ return { ok: false, reason: `decide: a rule asks 1 to ${DECIDE_MAX_QUESTIONS} questions` };
399
+ if (rule.band && !(rule.band.no_below < rule.band.yes_at))
400
+ return { ok: false, reason: 'decide: band: no_below must be below yes_at' };
401
+ for (const n of names) {
402
+ const b = rule.questions[n].band;
403
+ if (b && !(b.no_below < b.yes_at))
404
+ return { ok: false, reason: `decide: ${n}: no_below must be below yes_at` };
405
+ }
406
+ const parsed = parseWhen(rule.when);
407
+ if (!parsed.ok)
408
+ return { ok: false, reason: `decide: when: ${parsed.message} at column ${parsed.column}` };
409
+ const used = new Set();
410
+ for (const atom of atomsOf(parsed.expr)) {
411
+ const q = own(rule.questions, atom.q);
412
+ if (!q)
413
+ return { ok: false, reason: `decide: when: unknown question ${atom.q}` };
414
+ if (atom.op === 'yes' && q.type !== 'yes_no')
415
+ return { ok: false, reason: `decide: when: ${atom.q} is not a yes_no question` };
416
+ if (atom.op === 'eq') {
417
+ if (q.type !== 'choice')
418
+ return { ok: false, reason: `decide: when: ${atom.q} is not a choice question` };
419
+ if (!decideOptions(q).includes(atom.o))
420
+ return { ok: false, reason: `decide: when: unknown option ${atom.o} for ${atom.q}` };
421
+ }
422
+ used.add(atom.q);
423
+ }
424
+ for (const n of names) {
425
+ if (!used.has(n))
426
+ return { ok: false, reason: `decide: question ${n} is never used in when` };
427
+ }
428
+ for (const n of names) {
429
+ if (DECIDE_APPROVAL_RE.test(rule.questions[n].ask)) {
430
+ return { ok: false, reason: `decide: ${n} asks about approval; approval comes from the harness ask or the review queue, never from a question` };
431
+ }
432
+ }
433
+ return { ok: true, rule, expr: parsed.expr };
434
+ }
435
+ /** The value of one `decide:` line (the text after the key). */
436
+ export function parseDecideLine(raw) {
437
+ const text = raw.trim();
438
+ if (!text)
439
+ return { kind: 'unreadable', id: null, error: 'decide: the line is empty; write one rule per line as `decide: {json}`' };
440
+ let value;
441
+ try {
442
+ value = JSON.parse(text);
443
+ }
444
+ catch {
445
+ return { kind: 'unreadable', id: null, error: 'decide: the value is not JSON; write one rule per line as `decide: {json}`' };
446
+ }
447
+ const id = isObject(value) && has(value, 'id') && typeof value.id === 'string' && value.id !== '' ? value.id : null;
448
+ const v = validateDecideRule(value);
449
+ return v.ok ? { kind: 'rule', rule: v.rule, expr: v.expr } : { kind: 'unreadable', id, error: v.reason };
450
+ }
451
+ /** Every `decide:` line of one SOP, in order. A line reusing an earlier line's id is unreadable: ids are unique within a SOP. */
452
+ export function parseDecideLines(values) {
453
+ const seen = new Set();
454
+ return values.map((raw) => {
455
+ const d = parseDecideLine(raw);
456
+ const id = d.kind === 'rule' ? d.rule.id : d.id;
457
+ if (id === null)
458
+ return d;
459
+ if (seen.has(id))
460
+ return { kind: 'unreadable', id, error: `decide: id ${id} is used twice in this SOP` };
461
+ seen.add(id);
462
+ return d;
463
+ });
464
+ }
465
+ // ─── Identity ─────────────────────────────────────────────────────────
466
+ /** JSON with keys sorted at every level and no whitespace. Numbers as `JSON.stringify` writes them, which the other ports reproduce. */
467
+ function canonicalJson(v) {
468
+ if (Array.isArray(v))
469
+ return `[${v.map(canonicalJson).join(',')}]`;
470
+ if (isObject(v)) {
471
+ const keys = Object.keys(v).sort();
472
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(',')}}`;
473
+ }
474
+ return JSON.stringify(v);
475
+ }
476
+ function uniqueSorted(xs) {
477
+ return [...new Set(xs)].sort();
478
+ }
479
+ /**
480
+ * What the rule hash covers: `on`, `tools` (sorted), the questions with
481
+ * their bands made explicit and `none_of_these` placed, `when` in canonical
482
+ * form and `context` (sorted), as canonical JSON. `id`, `title`, `escalate`
483
+ * and `rollout` are left out, so flipping shadow to enforce keeps the rule's
484
+ * evidence, while a changed word, option or band starts it over.
485
+ * `tools` and `context` are `[]` when absent.
486
+ */
487
+ export function decideHashInput(rule) {
488
+ const parsed = parseWhen(rule.when);
489
+ if (!parsed.ok)
490
+ throw new Error(`decideHashInput: when does not parse (${parsed.message})`);
491
+ const questions = {};
492
+ for (const name of Object.keys(rule.questions)) {
493
+ const q = rule.questions[name];
494
+ const b = decideQuestionBand(rule, name);
495
+ questions[name] = {
496
+ type: q.type,
497
+ ask: q.ask,
498
+ ...(q.type === 'choice' ? { options: decideOptions(q) } : {}),
499
+ band: { yes_at: b.yes_at, no_below: b.no_below },
500
+ };
501
+ }
502
+ return canonicalJson({
503
+ on: rule.on,
504
+ tools: uniqueSorted(rule.tools ?? []),
505
+ questions,
506
+ when: canonicalWhen(parsed.expr),
507
+ context: uniqueSorted(rule.context ?? []),
508
+ });
509
+ }
510
+ /** `ruleHash`: lower-case hex SHA-256 of {@link decideHashInput}. `sha256Hex` hashes a string's UTF-8 bytes. */
511
+ export function decideRuleHash(rule, sha256Hex) {
512
+ return sha256Hex(decideHashInput(rule));
513
+ }
514
+ export function kAnd(a, b) {
515
+ return a === 'F' || b === 'F' ? 'F' : a === 'T' && b === 'T' ? 'T' : 'U';
516
+ }
517
+ export function kOr(a, b) {
518
+ return a === 'T' || b === 'T' ? 'T' : a === 'F' && b === 'F' ? 'F' : 'U';
519
+ }
520
+ export function kNot(a) {
521
+ return a === 'T' ? 'F' : a === 'F' ? 'T' : 'U';
522
+ }
523
+ /** A probability through a band: T at or above `yes_at`, F below `no_below`, U between. */
524
+ export function bandValue(p, band) {
525
+ if (p >= band.yes_at)
526
+ return 'T';
527
+ if (p < band.no_below)
528
+ return 'F';
529
+ return 'U';
530
+ }
531
+ /**
532
+ * The orders a `choice` question is asked in, as index lists over its n
533
+ * options: as written and reversed, plus, past three options, the list
534
+ * rotated by half, because reversing leaves the middle in the middle. The
535
+ * order rule of `attributeSops` in the judge cascade.
536
+ */
537
+ export function choiceOrders(n) {
538
+ const ids = Array.from({ length: n }, (_, i) => i);
539
+ const half = Math.floor(n / 2);
540
+ return [ids, [...ids].reverse(), ...(n > 3 ? [[...ids.slice(half), ...ids.slice(0, half)]] : [])];
541
+ }
542
+ /**
543
+ * Each option's atom `q == o` from the per-order probabilities (option name
544
+ * to probability, one map per order; an option a map leaves out is 0). The
545
+ * mean goes through the band; when the orders disagree by more than the
546
+ * band's width the atom is U whatever the mean. No orders at all is U.
547
+ */
548
+ export function choiceAtoms(options, perOrder, band) {
549
+ const out = {};
550
+ for (const o of options) {
551
+ const ps = perOrder.map((m) => own(m, o) ?? 0);
552
+ if (ps.length === 0) {
553
+ out[o] = { p: 0, spread: 0, value: 'U' };
554
+ continue;
555
+ }
556
+ let sum = 0;
557
+ for (const p of ps)
558
+ sum += p;
559
+ const p = sum / ps.length;
560
+ const spread = Math.max(...ps) - Math.min(...ps);
561
+ out[o] = { p, spread, value: spread > band.yes_at - band.no_below ? 'U' : bandValue(p, band) };
562
+ }
563
+ return out;
564
+ }
565
+ export function evalWhen(e, atoms) {
566
+ switch (e.op) {
567
+ case 'yes':
568
+ return own(atoms.yes, e.q) ?? 'U';
569
+ case 'eq':
570
+ return own(own(atoms.eq, e.q), e.o) ?? 'U';
571
+ case 'not':
572
+ return kNot(evalWhen(e.a, atoms));
573
+ case 'and':
574
+ return kAnd(evalWhen(e.a, atoms), evalWhen(e.b, atoms));
575
+ case 'or':
576
+ return kOr(evalWhen(e.a, atoms), evalWhen(e.b, atoms));
577
+ }
578
+ }
579
+ function logit(p) {
580
+ const q = Math.min(Math.max(p, 1e-12), 1 - 1e-12);
581
+ return Math.log(q / (1 - q));
582
+ }
583
+ /**
584
+ * s = σ(bias + Σ w_q · logit(p_q)), summed in sorted question order. A
585
+ * `choice` question enters through the averaged probability of its top
586
+ * option. Null when a weighted
587
+ * question has no answer, which the caller reads as U.
588
+ */
589
+ export function combinerScore(rule, answers, combiner) {
590
+ let x = combiner.bias;
591
+ for (const name of Object.keys(combiner.weights).sort()) {
592
+ const q = own(rule.questions, name);
593
+ const a = own(answers, name);
594
+ if (!q || !a)
595
+ return null;
596
+ let p;
597
+ if (q.type === 'yes_no') {
598
+ if (typeof a.p !== 'number')
599
+ return null;
600
+ p = a.p;
601
+ }
602
+ else {
603
+ if (!a.perOrder || a.perOrder.length === 0)
604
+ return null;
605
+ const atoms = choiceAtoms(decideOptions(q), a.perOrder, decideQuestionBand(rule, name));
606
+ p = Math.max(...Object.values(atoms).map((c) => c.p));
607
+ }
608
+ x += combiner.weights[name] * logit(p);
609
+ }
610
+ return 1 / (1 + Math.exp(-x));
611
+ }
612
+ /** The atoms of a validated rule's answers, each question through its own band. */
613
+ export function decideAtoms(rule, answers) {
614
+ const atoms = { yes: {}, eq: {} };
615
+ for (const name of Object.keys(rule.questions).sort()) {
616
+ const q = rule.questions[name];
617
+ const a = own(answers, name);
618
+ const band = decideQuestionBand(rule, name);
619
+ if (q.type === 'yes_no') {
620
+ atoms.yes[name] = a && typeof a.p === 'number' ? bandValue(a.p, band) : 'U';
621
+ }
622
+ else {
623
+ const c = choiceAtoms(decideOptions(q), a?.perOrder ?? [], band);
624
+ atoms.eq[name] = Object.fromEntries(Object.entries(c).map(([o, v]) => [o, v.value]));
625
+ }
626
+ }
627
+ return atoms;
628
+ }
629
+ /**
630
+ * One rule's outcome: T refuses (`DECIDE_REFUSED`), F allows, U escalates
631
+ * through `escalate` (default `ask`); `escalate: refuse` refuses with
632
+ * `DECIDE_ESCALATED`. `expr` is the rule's parsed `when`
633
+ * ({@link validateDecideRule} returns it).
634
+ */
635
+ export function decideRuleOutcome(rule, expr, answers, combiner) {
636
+ const atoms = decideAtoms(rule, answers);
637
+ let value;
638
+ let score = null;
639
+ if (combiner) {
640
+ score = combinerScore(rule, answers, combiner);
641
+ value = score === null ? 'U' : bandValue(score, combiner.band);
642
+ }
643
+ else {
644
+ value = evalWhen(expr, atoms);
645
+ }
646
+ if (value === 'T')
647
+ return { value, outcome: 'refuse', code: 'DECIDE_REFUSED', via: null, score, atoms };
648
+ if (value === 'F')
649
+ return { value, outcome: 'allow', code: null, via: null, score, atoms };
650
+ const escalate = rule.escalate ?? 'ask';
651
+ if (escalate === 'refuse')
652
+ return { value, outcome: 'refuse', code: 'DECIDE_ESCALATED', via: null, score, atoms };
653
+ return { value, outcome: 'escalate', code: 'DECIDE_ESCALATED', via: escalate, score, atoms };
654
+ }
655
+ /**
656
+ * The rules a tool call asks. `candidates` are in snapshot order (SOP order,
657
+ * then line order). A rule matches when it is `on: tool_call` and names the
658
+ * tool exactly.
659
+ */
660
+ export function selectDecideRules(candidates, tool) {
661
+ const matched = candidates.filter((c) => c.on === 'tool_call' && (c.tools ?? []).includes(tool));
662
+ const ordered = [...matched.filter((c) => c.effective === 'enforce'), ...matched.filter((c) => c.effective !== 'enforce')];
663
+ const beyond = ordered.slice(DECIDE_MAX_RULES_PER_CALL);
664
+ return {
665
+ evaluate: ordered.slice(0, DECIDE_MAX_RULES_PER_CALL),
666
+ skipped: beyond.filter((c) => c.effective !== 'enforce'),
667
+ unavailable: beyond.some((c) => c.effective === 'enforce') ? `more than ${DECIDE_MAX_RULES_PER_CALL} decide rules name ${tool}` : null,
668
+ };
669
+ }
670
+ /**
671
+ * The call's outcome from its rules' results, in evaluation order. Only
672
+ * enforce rules count: the first that refuses (or whose engine was
673
+ * unavailable, `GOVERNANCE_UNAVAILABLE`) refuses the call; otherwise the
674
+ * first that escalates escalates it; otherwise the call is allowed. Shadow
675
+ * results never change the call.
676
+ */
677
+ export function decideCallOutcome(selection, results) {
678
+ if (selection.unavailable)
679
+ return { outcome: 'refuse', code: 'GOVERNANCE_UNAVAILABLE', ruleRef: null, via: null, reason: selection.unavailable };
680
+ const enforcing = results.filter((r) => r.effective === 'enforce');
681
+ const refusal = enforcing.find((r) => r.outcome === 'refuse' || r.outcome === 'unavailable');
682
+ if (refusal) {
683
+ const code = refusal.outcome === 'unavailable' ? 'GOVERNANCE_UNAVAILABLE' : (refusal.code ?? 'DECIDE_REFUSED');
684
+ return { outcome: 'refuse', code, ruleRef: refusal.ref, via: null, reason: null };
685
+ }
686
+ const escalation = enforcing.find((r) => r.outcome === 'escalate');
687
+ if (escalation)
688
+ return { outcome: 'escalate', code: 'DECIDE_ESCALATED', ruleRef: escalation.ref, via: escalation.via ?? 'ask', reason: null };
689
+ return { outcome: 'allow', code: null, ruleRef: null, via: null, reason: null };
690
+ }
691
+ // ─── As source ────────────────────────────────────────────────────────
692
+ /**
693
+ * This module as JavaScript source, for the hook gates to embed: every
694
+ * constant, the parser's error class and every function above, in a scope of
695
+ * the gate's choosing. A test holds the list to the functions this file
696
+ * declares. A CommonJS build names an exported constant `exports.X` inside a
697
+ * function; the source defines each as a plain `X`, so that prefix is dropped.
698
+ */
699
+ export const DECIDE_JS_SOURCE = [
700
+ `const DECIDE_MAX_QUESTIONS = ${DECIDE_MAX_QUESTIONS};`,
701
+ `const DECIDE_MAX_OPTIONS = ${DECIDE_MAX_OPTIONS};`,
702
+ `const DECIDE_MAX_RULES_PER_CALL = ${DECIDE_MAX_RULES_PER_CALL};`,
703
+ `const DECIDE_MAX_WHEN_CHARS = ${DECIDE_MAX_WHEN_CHARS};`,
704
+ `const DECIDE_MIN_ASK_CHARS = ${DECIDE_MIN_ASK_CHARS};`,
705
+ `const DECIDE_MAX_ASK_CHARS = ${DECIDE_MAX_ASK_CHARS};`,
706
+ `const DECIDE_DEFAULT_BAND = ${JSON.stringify(DECIDE_DEFAULT_BAND)};`,
707
+ `const NONE_OF_THESE = ${JSON.stringify(NONE_OF_THESE)};`,
708
+ `const TOOL_TOKEN = ${String(TOOL_TOKEN)};`,
709
+ `const MAX_TOOLS = ${MAX_TOOLS};`,
710
+ `const MAX_TITLE = ${MAX_TITLE};`,
711
+ `const RULE_ID = ${String(RULE_ID)};`,
712
+ `const NAME = ${String(NAME)};`,
713
+ `const KEYWORDS = ${JSON.stringify(KEYWORDS)};`,
714
+ `const DECIDE_APPROVAL_RE = ${String(DECIDE_APPROVAL_RE)};`,
715
+ `const RULE_FIELDS = ${JSON.stringify(RULE_FIELDS)};`,
716
+ `const QUESTION_FIELDS = ${JSON.stringify(QUESTION_FIELDS)};`,
717
+ `const BAND_FIELDS = ${JSON.stringify(BAND_FIELDS)};`,
718
+ ...[
719
+ WhenError,
720
+ isObject,
721
+ has,
722
+ own,
723
+ codePoints,
724
+ unknownField,
725
+ bandShape,
726
+ questionShape,
727
+ ruleShape,
728
+ isWordChar,
729
+ tokenize,
730
+ parseWhen,
731
+ canonicalWhen,
732
+ atomsOf,
733
+ decideOptions,
734
+ decideQuestionBand,
735
+ validateDecideRule,
736
+ parseDecideLine,
737
+ parseDecideLines,
738
+ canonicalJson,
739
+ uniqueSorted,
740
+ decideHashInput,
741
+ decideRuleHash,
742
+ kAnd,
743
+ kOr,
744
+ kNot,
745
+ bandValue,
746
+ choiceOrders,
747
+ choiceAtoms,
748
+ evalWhen,
749
+ logit,
750
+ combinerScore,
751
+ decideAtoms,
752
+ decideRuleOutcome,
753
+ selectDecideRules,
754
+ decideCallOutcome,
755
+ ].map((fn) => fn.toString().replace(/\bexports\./g, '')),
756
+ ].join('\n');
757
+ //# sourceMappingURL=decide.js.map