@detergent-software/conductor 1.0.0-dev.2 → 1.0.0-dev.20

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 (104) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/README.md +361 -11
  3. package/build/broker-cli.js +125 -0
  4. package/build/broker-cli.js.map +1 -0
  5. package/build/broker.js +154 -0
  6. package/build/broker.js.map +1 -0
  7. package/build/cli.js +610 -44
  8. package/build/cli.js.map +1 -1
  9. package/build/clock.js +17 -0
  10. package/build/clock.js.map +1 -0
  11. package/build/conditions.js +19 -13
  12. package/build/conditions.js.map +1 -1
  13. package/build/config.js +94 -1
  14. package/build/config.js.map +1 -1
  15. package/build/console.js +99 -0
  16. package/build/console.js.map +1 -0
  17. package/build/credential.js +52 -0
  18. package/build/credential.js.map +1 -0
  19. package/build/daemon.js +153 -2
  20. package/build/daemon.js.map +1 -1
  21. package/build/events.js +106 -0
  22. package/build/events.js.map +1 -0
  23. package/build/history.js +630 -0
  24. package/build/history.js.map +1 -0
  25. package/build/inbox.js +386 -0
  26. package/build/inbox.js.map +1 -0
  27. package/build/manager.js +74 -12
  28. package/build/manager.js.map +1 -1
  29. package/build/mod.js +51 -0
  30. package/build/mod.js.map +1 -0
  31. package/build/note.js +71 -0
  32. package/build/note.js.map +1 -0
  33. package/build/notes.js +172 -0
  34. package/build/notes.js.map +1 -0
  35. package/build/notify.js +422 -0
  36. package/build/notify.js.map +1 -0
  37. package/build/overrides.js +83 -0
  38. package/build/overrides.js.map +1 -0
  39. package/build/paths.js +10 -1
  40. package/build/paths.js.map +1 -1
  41. package/build/profile.js +68 -2
  42. package/build/profile.js.map +1 -1
  43. package/build/pushover.js +67 -0
  44. package/build/pushover.js.map +1 -0
  45. package/build/queue.js +183 -0
  46. package/build/queue.js.map +1 -0
  47. package/build/registry.js +17 -10
  48. package/build/registry.js.map +1 -1
  49. package/build/rule-actions.js +444 -0
  50. package/build/rule-actions.js.map +1 -0
  51. package/build/rule-input.js +17 -0
  52. package/build/rule-input.js.map +1 -0
  53. package/build/rules-service.js +402 -0
  54. package/build/rules-service.js.map +1 -0
  55. package/build/rules.js +850 -0
  56. package/build/rules.js.map +1 -0
  57. package/build/scheduler.js +274 -0
  58. package/build/scheduler.js.map +1 -0
  59. package/build/server.js +294 -0
  60. package/build/server.js.map +1 -1
  61. package/build/spend.js +8 -0
  62. package/build/spend.js.map +1 -1
  63. package/build/store.js +452 -4
  64. package/build/store.js.map +1 -1
  65. package/build/trackers.js +12 -0
  66. package/build/trackers.js.map +1 -0
  67. package/build/upgrade.js +36 -22
  68. package/build/upgrade.js.map +1 -1
  69. package/build/wait.js +35 -41
  70. package/build/wait.js.map +1 -1
  71. package/build/watchers/ado-api.js +55 -0
  72. package/build/watchers/ado-api.js.map +1 -0
  73. package/build/watchers/ado.js +37 -49
  74. package/build/watchers/ado.js.map +1 -1
  75. package/build/watchers/boards-queue.js +120 -0
  76. package/build/watchers/boards-queue.js.map +1 -0
  77. package/build/watchers/boards-writes.js +83 -0
  78. package/build/watchers/boards-writes.js.map +1 -0
  79. package/build/watchers/boards.js +267 -0
  80. package/build/watchers/boards.js.map +1 -0
  81. package/build/watchers/github.js +5 -2
  82. package/build/watchers/github.js.map +1 -1
  83. package/build/watchers/index.js +1 -0
  84. package/build/watchers/index.js.map +1 -1
  85. package/build/watchers/linear-api.js +55 -0
  86. package/build/watchers/linear-api.js.map +1 -0
  87. package/build/watchers/linear-key.js +6 -45
  88. package/build/watchers/linear-key.js.map +1 -1
  89. package/build/watchers/linear-labels.js +43 -0
  90. package/build/watchers/linear-labels.js.map +1 -0
  91. package/build/watchers/linear-queue.js +96 -0
  92. package/build/watchers/linear-queue.js.map +1 -0
  93. package/build/watchers/linear.js +29 -18
  94. package/build/watchers/linear.js.map +1 -1
  95. package/build/why.js +78 -0
  96. package/build/why.js.map +1 -0
  97. package/mod/.claude-plugin/marketplace.json +6 -0
  98. package/mod/.claude-plugin/plugin.json +7 -0
  99. package/mod/README.md +17 -0
  100. package/mod/hooks/hooks.json +1 -0
  101. package/mod/hooks/model.ts +457 -0
  102. package/mod/hooks/register.tsx +543 -0
  103. package/mod/types/index.d.ts +57 -0
  104. package/package.json +6 -1
package/build/rules.js ADDED
@@ -0,0 +1,850 @@
1
+ import { parseLocalTime } from "./clock.js";
2
+ import { CHANNELS } from "./config.js";
3
+ import { ConductorError } from "./errors.js";
4
+ import { INBOX_KINDS } from "./store.js";
5
+ // The rules engine (design note, section 9): `when` / `do` rules in a fixed vocabulary. This
6
+ // module is pure: it reads rule text into rules, checks them against the guardrails, and picks
7
+ // the rules that fire for a subject. rules-service.ts loads the files and reloads them on save.
8
+ // What happened, as a rule names it. An event rule fires when one of its events is seen; a rule
9
+ // with none is a state rule, checked on each scheduler tick.
10
+ export const EVENT_ATOMS = [
11
+ 'inbox.raised',
12
+ 'item.closed',
13
+ 'item.unblocked',
14
+ 'note',
15
+ 'plan',
16
+ 'pr.comment',
17
+ 'queue.drained',
18
+ 'queue.pick',
19
+ 'queue.ready-to-release',
20
+ 'run.failed',
21
+ 'wait.resolved',
22
+ 'worker.exited',
23
+ 'worker.idle',
24
+ 'worker.permission',
25
+ 'worker.waiting',
26
+ ];
27
+ const DAY_NAMES = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
28
+ const TIERS = ['S', 'M', 'L'];
29
+ // A wait by the gate it holds (plan, merge, npm-otp) when it holds one, else by its condition.
30
+ export const WAIT_KINDS = ['plan', 'merge', 'npm-otp', 'approval', 'health', 'item', 'npm', 'pr', 'run', 'time'];
31
+ const UNIT_MINUTES = { d: 24 * 60, h: 60, m: 1, s: 1 / 60 };
32
+ const PRIORITIES = { high: 2, low: 4, medium: 3, none: 0, urgent: 1 };
33
+ // Every field a condition can read, by type. A field the subject doesn't have makes its
34
+ // comparison false (`conduct rules test` names it).
35
+ export const FIELDS = {
36
+ day: { type: 'day' },
37
+ 'inbox.age': { type: 'duration' },
38
+ 'inbox.kind': { type: 'enum', values: INBOX_KINDS },
39
+ 'item.area': { type: 'text' },
40
+ 'item.id': { type: 'text' },
41
+ 'item.labels': { type: 'labels' },
42
+ 'item.priority': { type: 'int' },
43
+ // The size tier of the item's newest Plan note, once it has one.
44
+ 'item.tier': { type: 'enum', values: TIERS },
45
+ 'note.kind': { type: 'text' },
46
+ 'note.phase': { type: 'text' },
47
+ 'notify.profile': { type: 'text' },
48
+ // The tier of the Plan note being posted; reading it makes the rule a `plan` event rule.
49
+ 'plan.tier': { type: 'enum', values: TIERS },
50
+ project: { type: 'text' },
51
+ 'spend.item': { type: 'tokens' },
52
+ 'spend.project': { type: 'tokens' },
53
+ 'spend.today': { type: 'tokens' },
54
+ 'spend.worker': { type: 'tokens' },
55
+ time: { type: 'clock' },
56
+ 'wait.age': { type: 'duration' },
57
+ 'wait.kind': { type: 'enum', values: WAIT_KINDS },
58
+ 'worker.phase': { type: 'text' },
59
+ 'worker.retries': { type: 'int' },
60
+ };
61
+ // The fields only one event has: a rule that reads them fires on that event, so
62
+ // `plan.tier == S` needs no `plan and`.
63
+ const IMPLIED_EVENTS = { 'note.': 'note', 'plan.': 'plan' };
64
+ // The labels a rule may add or remove. `human` is the profile's human label.
65
+ export const RULE_LABELS = ['human', 'triage', 'shipped-open'];
66
+ // Verbs that look like actions but that the conductor never takes (design note, section 14).
67
+ const FORBIDDEN = new Set([
68
+ 'approve',
69
+ 'cancel',
70
+ 'close',
71
+ 'delete',
72
+ 'deploy',
73
+ 'exec',
74
+ 'execute',
75
+ 'git',
76
+ 'merge',
77
+ 'npm',
78
+ 'promote',
79
+ 'publish',
80
+ 'push',
81
+ 'release',
82
+ 'remove',
83
+ 'rm',
84
+ 'run',
85
+ 'shell',
86
+ ]);
87
+ const NEVER = 'the conductor never merges, publishes, approves prod, deletes or runs commands';
88
+ const VERBS = 'launch, retire, park, resume, retry, pause, unpause, focus, auto-approve after, inbox, remind jason, notify, notify profile, label add|remove, flag shipped-open, escalate console, none';
89
+ // What a state rule is checked against, from the fields it reads: each open wait, each open inbox
90
+ // item, each worker, each project, or the machine once.
91
+ export const SCOPES = ['wait', 'inbox', 'worker', 'project', 'machine'];
92
+ class RuleSyntaxError extends Error {
93
+ }
94
+ // The clock fields of a subject: minutes after local midnight, and the day (0 is sunday).
95
+ export function clockFields(now) {
96
+ return { day: now.getDay(), time: now.getHours() * 60 + now.getMinutes() };
97
+ }
98
+ // Reads rules into Rules, checking each against the vocabulary and the guardrails. Throws one
99
+ // ConductorError that lists every problem, each with the rule's position and id.
100
+ export function compileRules(inputs, origin, limits) {
101
+ const problems = [];
102
+ const rules = [];
103
+ const ids = new Set();
104
+ inputs.forEach((input, index) => {
105
+ const name = `rule ${index + 1}${input.id ? ` (${input.id})` : ''}`;
106
+ try {
107
+ if (input.id !== undefined) {
108
+ if (ids.has(input.id))
109
+ throw new RuleSyntaxError(`id "${input.id}" is used twice in this file`);
110
+ ids.add(input.id);
111
+ }
112
+ rules.push(compileRule(input, index, origin, limits));
113
+ }
114
+ catch (error) {
115
+ if (!(error instanceof RuleSyntaxError))
116
+ throw error;
117
+ problems.push(`${name}: ${error.message}`);
118
+ }
119
+ });
120
+ if (problems.length > 0)
121
+ throw new ConductorError(`${origin.file ?? `${origin.source} rules`} has rules the conductor refuses`, problems);
122
+ return rules;
123
+ }
124
+ // conduct-atk's behavior written as rules (design note, section 9), so a machine or project file
125
+ // holds only the differences. Each has an id that a rule elsewhere replaces or switches off.
126
+ export function defaultRules({ dailyTokens, runawayTokens }) {
127
+ return [
128
+ { do: 'launch', id: 'launch', note: "start the queue's picks, as conduct-atk's tick did", when: 'queue.pick' },
129
+ { do: 'retire', id: 'retire-closed', note: 'retire a worker once its item is Done or Canceled', when: 'item.closed' },
130
+ { do: 'park', id: 'park-on-wait', note: 'stop a worker whose turn has ended while it waits', when: 'worker.waiting' },
131
+ { do: 'resume worker', id: 'resume-on-wait', note: 'wake a parked worker when its wait holds', when: 'wait.resolved' },
132
+ { do: 'inbox runaway', id: 'runaway', note: 'a worker past this many tokens, cache reads aside', when: `spend.worker > ${tokenLiteral(runawayTokens)}` },
133
+ { do: 'pause all launches until tomorrow', id: 'daily-cap', note: 'the daily soft cap: no new launches past it', when: `spend.today > ${tokenLiteral(dailyTokens)}` },
134
+ { do: 'inbox ready-to-release', id: 'ready-to-release', note: 'a conducted project with no open item: hand over to the release', when: 'queue.ready-to-release' },
135
+ { do: 'inbox queue-drained', id: 'queue-drained', note: 'nothing left to start or watch', when: 'queue.drained' },
136
+ {
137
+ do: 'escalate console "a worker asks the console for something: read its newest Handoff note on the item and do it within your remit"',
138
+ id: 'conductor-handoff',
139
+ note: "a worker's `Handoff · conductor` note, which went to conduct-atk's session by SendMessage before the console",
140
+ when: 'note.kind == conductor',
141
+ },
142
+ ];
143
+ }
144
+ // An action as `conduct rules` prints it.
145
+ export function describeAction(action) {
146
+ const ends = (until) => until === null ? '' : until.kind === 'at' ? ` until ${until.text}` : ` for ${formatMinutes(until.minutes)}`;
147
+ const who = (target) => target.kind === 'all' ? 'all launches' : target.kind === 'named' ? `launches in ${target.project}` : 'launches';
148
+ switch (action.verb) {
149
+ case 'auto-approve':
150
+ return `auto-approve after ${formatMinutes(action.afterMinutes)}`;
151
+ case 'escalate':
152
+ return `escalate console "${action.text}"`;
153
+ case 'focus':
154
+ return `focus ${action.project === null ? '' : `${action.project} `}${action.setting} ${action.value}${ends(action.until)}`;
155
+ case 'inbox':
156
+ return `inbox ${action.kind}${action.text === null ? '' : ` "${action.text}"`}`;
157
+ case 'label':
158
+ return `label ${action.op} ${action.label}`;
159
+ case 'launch':
160
+ case 'none':
161
+ case 'park':
162
+ case 'retire':
163
+ case 'retry':
164
+ return action.verb;
165
+ case 'notify':
166
+ return `notify ${action.channel}${action.text === null ? '' : ` "${action.text}"`}`;
167
+ case 'notify-profile':
168
+ return `notify profile ${action.profile}${ends(action.until)}`;
169
+ case 'pause':
170
+ return `pause ${who(action.target)}${ends(action.until)}`;
171
+ case 'remind':
172
+ return 'remind jason';
173
+ case 'resume':
174
+ return 'resume worker';
175
+ case 'unpause':
176
+ return `unpause ${who(action.target)}`;
177
+ }
178
+ }
179
+ // 30s, 15m, 2h, 1d as minutes; a bare 0 too.
180
+ export function durationMinutes(text) {
181
+ if (text === '0')
182
+ return 0;
183
+ const match = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(text);
184
+ const unit = match?.[2] === undefined ? undefined : UNIT_MINUTES[match[2]];
185
+ return unit === undefined ? null : Number(match?.[1]) * unit;
186
+ }
187
+ // True when the expression holds for the subject. A comparison on a field the subject lacks is
188
+ // false, and the field is added to `missing`.
189
+ export function evaluate(expr, subject, missing = []) {
190
+ switch (expr.kind) {
191
+ case 'and':
192
+ return evaluate(expr.left, subject, missing) && evaluate(expr.right, subject, missing);
193
+ case 'compare': {
194
+ const actual = subject.fields[expr.field];
195
+ if (actual === undefined || isList(actual))
196
+ return (missing.push(expr.field), false);
197
+ return compare(actual, expr.op, expr.value);
198
+ }
199
+ case 'event':
200
+ return subject.events.includes(expr.name);
201
+ case 'has': {
202
+ const actual = subject.fields[expr.field];
203
+ if (actual === undefined || !isList(actual))
204
+ return (missing.push(expr.field), false);
205
+ return actual.some((l) => l.toLowerCase() === expr.value.toLowerCase());
206
+ }
207
+ case 'in': {
208
+ const actual = subject.fields[expr.field];
209
+ if (actual === undefined || isList(actual))
210
+ return (missing.push(expr.field), false);
211
+ return expr.values.some((v) => compare(actual, '==', v));
212
+ }
213
+ case 'not':
214
+ return !evaluate(expr.expr, subject, missing);
215
+ case 'or':
216
+ return evaluate(expr.left, subject, missing) || evaluate(expr.right, subject, missing);
217
+ }
218
+ }
219
+ // A field by its name, any case; null when there's none.
220
+ export function fieldOf(text) {
221
+ const lower = text.toLowerCase();
222
+ return isFieldName(lower) ? lower : null;
223
+ }
224
+ export function isFieldName(name) {
225
+ return Object.hasOwn(FIELDS, name);
226
+ }
227
+ // A literal as a field's value: minutes for a duration, tokens for spend, minutes after midnight for
228
+ // a time, 0 (sunday) to 6 for a day, the canonical spelling for an enum. Null when it isn't one.
229
+ export function literal(field, text) {
230
+ const spec = FIELDS[field];
231
+ switch (spec.type) {
232
+ case 'clock': {
233
+ const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(text);
234
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
235
+ }
236
+ case 'day': {
237
+ const lower = text.toLowerCase();
238
+ const index = DAY_NAMES.findIndex((d) => d === lower || d.slice(0, 3) === lower);
239
+ return index === -1 ? null : index;
240
+ }
241
+ case 'duration':
242
+ return durationMinutes(text);
243
+ case 'enum':
244
+ return spec.values.find((v) => v.toLowerCase() === text.toLowerCase()) ?? null;
245
+ case 'int': {
246
+ if (field === 'item.priority' && Object.hasOwn(PRIORITIES, text.toLowerCase()))
247
+ return PRIORITIES[text.toLowerCase()] ?? null;
248
+ return /^-?\d+$/.test(text) ? Number(text) : null;
249
+ }
250
+ case 'labels':
251
+ case 'text':
252
+ return text;
253
+ case 'tokens': {
254
+ const match = /^(\d+(?:\.\d+)?)([kKmM]?)$/.exec(text);
255
+ if (!match)
256
+ return null;
257
+ const scale = match[2] === '' ? 1 : match[2]?.toLowerCase() === 'k' ? 1_000 : 1_000_000;
258
+ return Math.round(Number(match[1]) * scale);
259
+ }
260
+ }
261
+ }
262
+ export function parseAction(text, limits) {
263
+ const tokens = tokenize(text);
264
+ const words = tokens.map((t) => (t.kind === 'string' ? t.text : t.text.toLowerCase()));
265
+ const raw = tokens.map((t) => t.text);
266
+ const quoted = (i) => (tokens[i]?.kind === 'string' ? (tokens[i]?.text ?? null) : null);
267
+ const [verb = '', ...rest] = words;
268
+ const fail = (message) => {
269
+ throw new RuleSyntaxError(message);
270
+ };
271
+ const end = (i) => {
272
+ if (i < words.length)
273
+ fail(`unexpected "${raw[i] ?? ''}" in "${text}"`);
274
+ };
275
+ // `until <when>` or `for <duration>` from position i; returns the until and the next position.
276
+ const until = (i, required) => {
277
+ if (words[i] === 'until') {
278
+ // A date and a time are two words: 2026-10-12 17:00.
279
+ const pair = /^\d{4}-\d{2}-\d{2}$/.test(raw[i + 1] ?? '') && /^\d{1,2}:\d{2}$/.test(raw[i + 2] ?? '');
280
+ const when = pair ? `${raw[i + 1] ?? ''}T${(raw[i + 2] ?? '').padStart(5, '0')}` : raw[i + 1];
281
+ if (when === undefined || !untilReadable(when))
282
+ fail(`until takes 17:00, tomorrow, a day such as monday, or a date and time (in "${text}")`);
283
+ return [{ kind: 'at', text: when ?? '' }, i + (pair ? 3 : 2)];
284
+ }
285
+ if (words[i] === 'for') {
286
+ const minutes = durationMinutes(words[i + 1] ?? '');
287
+ if (minutes === null || minutes <= 0)
288
+ fail(`for takes a duration, such as 2h (in "${text}")`);
289
+ if ((minutes ?? 0) > 14 * 24 * 60)
290
+ fail(`for ${raw[i + 1] ?? ''} is over the 14-day limit of an override (in "${text}")`);
291
+ return [{ kind: 'for', minutes: minutes ?? 0 }, i + 2];
292
+ }
293
+ if (required)
294
+ fail(`"${text}" needs until <when> or for <duration>: an override always ends`);
295
+ return [null, i];
296
+ };
297
+ // `launches`, then `in <project>`, or `all launches`.
298
+ const target = (i) => {
299
+ if (words[i] === 'all' && words[i + 1] === 'launches')
300
+ return [{ kind: 'all' }, i + 2];
301
+ if (words[i] !== 'launches')
302
+ fail(`write "${verb} launches", "${verb} launches in <project>" or "${verb} all launches"`);
303
+ if (words[i + 1] === 'in') {
304
+ const project = raw[i + 2];
305
+ if (project === undefined)
306
+ fail(`"${verb} launches in" needs a project`);
307
+ return [{ kind: 'named', project: project ?? '' }, i + 3];
308
+ }
309
+ return [{ kind: 'subject' }, i + 1];
310
+ };
311
+ if (FORBIDDEN.has(verb))
312
+ fail(`"${raw[0] ?? verb}" is not in the vocabulary: ${NEVER}`);
313
+ switch (verb) {
314
+ case 'auto-approve': {
315
+ if (rest[0] !== 'after')
316
+ fail('write "auto-approve after <duration>", such as auto-approve after 15m');
317
+ const minutes = durationMinutes(rest[1] ?? '');
318
+ if (minutes === null || minutes < 1 || minutes > 24 * 60)
319
+ fail('auto-approve after takes 1m to 24h');
320
+ end(3);
321
+ return { afterMinutes: minutes ?? 0, verb: 'auto-approve' };
322
+ }
323
+ case 'escalate': {
324
+ const i = words[1] === 'console' ? 2 : 1;
325
+ const ask = quoted(i);
326
+ if (ask === null || ask.trim() === '')
327
+ fail('write escalate console "<what to look at>"');
328
+ end(i + 1);
329
+ return { text: ask ?? '', verb: 'escalate' };
330
+ }
331
+ case 'flag':
332
+ if (rest[0] !== 'shipped-open')
333
+ fail('flag takes shipped-open');
334
+ end(2);
335
+ return { label: 'shipped-open', op: 'add', verb: 'label' };
336
+ case 'focus': {
337
+ let i = 1;
338
+ let project = null;
339
+ if (words[i] !== 'weight' && words[i] !== 'slots')
340
+ project = raw[i++] ?? null;
341
+ const setting = words[i];
342
+ if (setting !== 'weight' && setting !== 'slots')
343
+ fail('write focus [<project>] weight <n> | slots <n> until <when>');
344
+ const value = Number(words[i + 1]);
345
+ if (!Number.isInteger(value))
346
+ fail(`focus ${setting ?? ''} takes a whole number`);
347
+ if (setting === 'slots' && value < 0)
348
+ fail(`focus slots takes 0 or more, not ${value}`);
349
+ if (setting === 'slots' && value > limits.slots) {
350
+ fail(`focus slots ${value} is over the limit: no project gets more than scheduler.slots (${limits.slots}) in config.json`);
351
+ }
352
+ if (setting === 'weight' && (value < -100 || value > 100))
353
+ fail(`focus weight ${value} is outside -100 to 100`);
354
+ const [ends, after] = until(i + 2, true);
355
+ end(after);
356
+ return { project, setting: setting === 'slots' ? 'slots' : 'weight', until: ends ?? { kind: 'for', minutes: 0 }, value, verb: 'focus' };
357
+ }
358
+ case 'inbox': {
359
+ const kind = INBOX_KINDS.find((k) => k === words[1]);
360
+ if (!kind)
361
+ fail(`inbox takes a kind: ${INBOX_KINDS.join(', ')}`);
362
+ const note = quoted(2);
363
+ end(note === null ? 2 : 3);
364
+ return { kind: kind ?? 'failed', text: note, verb: 'inbox' };
365
+ }
366
+ case 'label': {
367
+ const op = words[1];
368
+ const label = RULE_LABELS.find((l) => l === words[2]);
369
+ if ((op !== 'add' && op !== 'remove') || !label)
370
+ fail(`write label add|remove ${RULE_LABELS.join('|')}: rules edit those labels only`);
371
+ end(3);
372
+ return { label: label ?? 'human', op: op === 'remove' ? 'remove' : 'add', verb: 'label' };
373
+ }
374
+ case 'launch':
375
+ case 'none':
376
+ case 'park':
377
+ case 'retire':
378
+ end(1);
379
+ return { verb };
380
+ case 'notify': {
381
+ if (words[1] === 'profile') {
382
+ const profile = raw[2];
383
+ if (profile === undefined || !limits.profiles.includes(profile)) {
384
+ fail(`notify profile takes one of ${limits.profiles.join(', ')}`);
385
+ }
386
+ const [ends, after] = until(3, false);
387
+ end(after);
388
+ return { profile: profile ?? '', until: ends, verb: 'notify-profile' };
389
+ }
390
+ const channel = CHANNELS.find((c) => c === words[1]);
391
+ if (!channel)
392
+ fail(`notify takes a channel (${CHANNELS.join(', ')}) or profile <name>`);
393
+ const note = quoted(2);
394
+ end(note === null ? 2 : 3);
395
+ return { channel: channel ?? 'remote-control', text: note, verb: 'notify' };
396
+ }
397
+ case 'pause': {
398
+ const [who, after] = target(1);
399
+ const [ends, next] = until(after, false);
400
+ end(next);
401
+ return { target: who, until: ends, verb: 'pause' };
402
+ }
403
+ case 'remind':
404
+ if (rest.length > 0 && rest[0] !== 'jason')
405
+ fail('write remind jason');
406
+ end(rest.length > 0 ? 2 : 1);
407
+ return { verb: 'remind' };
408
+ case 'resume':
409
+ if (rest.length > 0 && rest[0] !== 'worker')
410
+ fail('write resume worker');
411
+ end(rest.length > 0 ? 2 : 1);
412
+ return { verb: 'resume' };
413
+ case 'retry':
414
+ if (rest.length > 0 && rest[0] !== 'once')
415
+ fail('write retry (it retries once)');
416
+ end(rest.length > 0 ? 2 : 1);
417
+ return { verb: 'retry' };
418
+ case 'unpause': {
419
+ const [who, after] = target(1);
420
+ end(after);
421
+ return { target: who, verb: 'unpause' };
422
+ }
423
+ default:
424
+ return fail(`unknown action "${raw[0] ?? ''}"; actions: ${VERBS}`);
425
+ }
426
+ }
427
+ export function parseWhen(text) {
428
+ const tokens = tokenize(text);
429
+ let next = 0;
430
+ const peek = () => tokens[next];
431
+ const where = (token) => (token ? `at column ${token.at + 1}` : 'at the end');
432
+ const keyword = (word) => {
433
+ const token = peek();
434
+ return token?.kind === 'word' && token.text.toLowerCase() === word;
435
+ };
436
+ const expect = (text) => {
437
+ const token = peek();
438
+ if (token?.text !== text)
439
+ throw new RuleSyntaxError(`expected "${text}" ${where(token)}`);
440
+ next++;
441
+ };
442
+ const or = (negated) => {
443
+ let left = and(negated);
444
+ while (keyword('or')) {
445
+ next++;
446
+ left = { kind: 'or', left, right: and(negated) };
447
+ }
448
+ return left;
449
+ };
450
+ const and = (negated) => {
451
+ let left = unary(negated);
452
+ while (keyword('and')) {
453
+ next++;
454
+ left = { kind: 'and', left, right: unary(negated) };
455
+ }
456
+ return left;
457
+ };
458
+ const unary = (negated) => {
459
+ if (keyword('not')) {
460
+ next++;
461
+ return { expr: unary(!negated), kind: 'not' };
462
+ }
463
+ if (peek()?.text === '(' && peek()?.kind === 'punct') {
464
+ next++;
465
+ const inner = or(negated);
466
+ expect(')');
467
+ return inner;
468
+ }
469
+ return atom(negated);
470
+ };
471
+ const atom = (negated) => {
472
+ const token = peek();
473
+ if (!token || (token.kind !== 'word' && token.kind !== 'string'))
474
+ throw new RuleSyntaxError(`expected a condition ${where(token)}`);
475
+ next++;
476
+ const field = token.kind === 'word' ? fieldOf(token.text) : null;
477
+ const following = peek();
478
+ const comparing = following?.kind === 'op' || (following?.kind === 'word' && ['has', 'in'].includes(following.text.toLowerCase()));
479
+ if (field && comparing)
480
+ return comparison(field);
481
+ const event = EVENT_ATOMS.find((e) => e === token.text.toLowerCase());
482
+ if (event && !comparing) {
483
+ if (negated)
484
+ throw new RuleSyntaxError(`an event can't be negated (${event}) ${where(token)}`);
485
+ return { kind: 'event', name: event };
486
+ }
487
+ // `human in item.labels`: a value, then `in` and a label field.
488
+ if (following?.kind === 'word' && following.text.toLowerCase() === 'in') {
489
+ next++;
490
+ const list = peek();
491
+ const target = list?.kind === 'word' ? fieldOf(list.text) : null;
492
+ if (target && FIELDS[target].type === 'labels') {
493
+ next++;
494
+ return { field: target, kind: 'has', value: token.text };
495
+ }
496
+ }
497
+ if (field)
498
+ throw new RuleSyntaxError(`expected ==, !=, <, <=, >, >=, in or has after ${token.text} ${where(following)}`);
499
+ throw new RuleSyntaxError(`"${token.text}" is neither a field nor an event ${where(token)}; fields: ${Object.keys(FIELDS).join(', ')}; events: ${EVENT_ATOMS.join(', ')}`);
500
+ };
501
+ const comparison = (field) => {
502
+ const spec = FIELDS[field];
503
+ const token = peek();
504
+ next++;
505
+ const op = token?.kind === 'op' ? token.text : (token?.text.toLowerCase() ?? '');
506
+ if (op === 'has') {
507
+ if (spec.type !== 'labels')
508
+ throw new RuleSyntaxError(`${field} isn't a list: "has" reads item.labels ${where(token)}`);
509
+ return { field, kind: 'has', value: word() };
510
+ }
511
+ if (spec.type === 'labels')
512
+ throw new RuleSyntaxError(`${field} is a list: write "${field} has <label>" or "<label> in ${field}" ${where(token)}`);
513
+ if (op === 'in') {
514
+ expect('[');
515
+ const values = [value(field)];
516
+ while (peek()?.text === ',') {
517
+ next++;
518
+ values.push(value(field));
519
+ }
520
+ expect(']');
521
+ return { field, kind: 'in', values };
522
+ }
523
+ const ordered = ['clock', 'duration', 'int', 'tokens'].includes(spec.type);
524
+ if (!ordered && op !== '==' && op !== '!=')
525
+ throw new RuleSyntaxError(`${field} takes ==, != or in, not ${op} ${where(token)}`);
526
+ return { field, kind: 'compare', op: toOp(op), value: value(field) };
527
+ };
528
+ const word = () => {
529
+ const token = peek();
530
+ if (!token || (token.kind !== 'word' && token.kind !== 'string'))
531
+ throw new RuleSyntaxError(`expected a value ${where(token)}`);
532
+ next++;
533
+ return token.text;
534
+ };
535
+ const value = (field) => {
536
+ const token = peek();
537
+ const text = word();
538
+ const parsed = literal(field, text);
539
+ if (parsed === null)
540
+ throw new RuleSyntaxError(`"${text}" is not a value of ${field} (${describeType(FIELDS[field])}) ${where(token)}`);
541
+ return parsed;
542
+ };
543
+ const expr = or(false);
544
+ if (next < tokens.length)
545
+ throw new RuleSyntaxError(`unexpected "${tokens[next]?.text ?? ''}" ${where(tokens[next])}`);
546
+ return expr;
547
+ }
548
+ // The time an override ends: the next 17:00, local midnight tomorrow, the next monday at 00:00, a
549
+ // date and time, or a duration from now.
550
+ export function resolveUntil(until, now) {
551
+ if (until.kind === 'for')
552
+ return new Date(now.getTime() + until.minutes * 60_000);
553
+ const lower = until.text.toLowerCase();
554
+ const day = DAY_NAMES.findIndex((d) => d === lower || d.slice(0, 3) === lower);
555
+ if (lower === 'tomorrow' || day !== -1) {
556
+ const at = new Date(now);
557
+ at.setHours(0, 0, 0, 0);
558
+ const ahead = lower === 'tomorrow' ? 1 : (day - now.getDay() + 7) % 7 || 7;
559
+ at.setDate(at.getDate() + ahead);
560
+ return at;
561
+ }
562
+ const at = parseLocalTime(until.text, now);
563
+ if (!at)
564
+ throw new ConductorError(`"${until.text}" is not a time`);
565
+ return at;
566
+ }
567
+ // What a state rule is checked against, from the fields it reads.
568
+ export function scopeOf(fields) {
569
+ const reads = (prefix) => fields.some((f) => f.startsWith(prefix));
570
+ if (reads('wait.') && reads('inbox.'))
571
+ throw new RuleSyntaxError('a state rule reads waits or inbox items, not both');
572
+ if (reads('wait.'))
573
+ return 'wait';
574
+ if (reads('inbox.'))
575
+ return 'inbox';
576
+ if (reads('worker.') || reads('item.') || fields.includes('spend.worker') || fields.includes('spend.item'))
577
+ return 'worker';
578
+ if (fields.includes('project') || fields.includes('spend.project'))
579
+ return 'project';
580
+ return 'machine';
581
+ }
582
+ // The rules that fire for a subject, in order: the subject's project's rules, then machine rules,
583
+ // then defaults. A rule with an id shadows the same id further down, matching or not. Of the rules
584
+ // that match, the first of each action (by verb, and inbox kind or label) fires; later ones are
585
+ // superseded.
586
+ export function selectRules(rules, subject) {
587
+ const ordered = [
588
+ ...rules.filter((r) => r.source === 'project' && r.project === subject.project),
589
+ ...rules.filter((r) => r.source === 'machine'),
590
+ ...rules.filter((r) => r.source === 'default'),
591
+ ];
592
+ const ids = new Map();
593
+ const taken = new Map();
594
+ const fired = [];
595
+ const trace = [];
596
+ for (const rule of ordered) {
597
+ const shadow = rule.id === null ? undefined : ids.get(rule.id);
598
+ if (rule.id !== null && shadow === undefined)
599
+ ids.set(rule.id, rule.ref);
600
+ // A `none` rule never fires itself: it only shadows.
601
+ const trigger = subject.events.length > 0 ? rule.events.some((e) => subject.events.includes(e)) : rule.events.length === 0 && rule.scope === subject.scope;
602
+ if (!trigger || rule.when === null || rule.actions.every((a) => a.verb === 'none')) {
603
+ trace.push({ missing: [], outcome: 'other trigger', over: null, ref: rule.ref });
604
+ continue;
605
+ }
606
+ if (shadow !== undefined) {
607
+ trace.push({ missing: [], outcome: 'shadowed', over: shadow, ref: rule.ref });
608
+ continue;
609
+ }
610
+ const missing = [];
611
+ if (!evaluate(rule.when, subject, missing)) {
612
+ trace.push({ missing: [...new Set(missing)], outcome: 'no match', over: null, ref: rule.ref });
613
+ continue;
614
+ }
615
+ let superseded = null;
616
+ for (const action of rule.actions) {
617
+ const key = actionKey(action);
618
+ const earlier = taken.get(key);
619
+ if (earlier !== undefined) {
620
+ superseded = earlier;
621
+ continue;
622
+ }
623
+ taken.set(key, rule.ref);
624
+ fired.push({ action, rule });
625
+ }
626
+ trace.push({ missing: [], outcome: superseded !== null && !fired.some((f) => f.rule === rule) ? 'superseded' : 'fired', over: superseded, ref: rule.ref });
627
+ }
628
+ return { fired, trace };
629
+ }
630
+ // A token count as a rule literal that reads back exactly: 2M, 750k, 1234567.
631
+ export function tokenLiteral(tokens) {
632
+ if (tokens >= 1_000_000 && tokens % 1_000_000 === 0)
633
+ return `${tokens / 1_000_000}M`;
634
+ if (tokens >= 1_000 && tokens % 1_000 === 0)
635
+ return `${tokens / 1_000}k`;
636
+ return String(tokens);
637
+ }
638
+ function actionKey(action) {
639
+ switch (action.verb) {
640
+ case 'inbox':
641
+ return `inbox:${action.kind}`;
642
+ case 'label':
643
+ return `label:${action.label}`;
644
+ case 'notify':
645
+ return `notify:${action.channel}`;
646
+ case 'pause':
647
+ case 'unpause':
648
+ return 'pause';
649
+ default:
650
+ return action.verb;
651
+ }
652
+ }
653
+ // The guardrails an action needs from its trigger: what it acts on must be there.
654
+ function checkAction(action, events, scope) {
655
+ const on = (allowed, scopes, what) => {
656
+ if (events.some((e) => allowed.includes(e)) || (scope !== null && scopes.includes(scope)))
657
+ return;
658
+ throw new RuleSyntaxError(`do: ${action.verb} acts on ${what}, which this rule's when doesn't give it`);
659
+ };
660
+ const ITEM_EVENTS = ['inbox.raised', 'item.closed', 'item.unblocked', 'note', 'plan', 'pr.comment', 'queue.pick', 'run.failed', 'wait.resolved', 'worker.exited', 'worker.idle', 'worker.permission', 'worker.waiting'];
661
+ switch (action.verb) {
662
+ case 'auto-approve':
663
+ return on(['plan'], [], 'a plan (the plan event)');
664
+ case 'label':
665
+ return on(ITEM_EVENTS, ['wait', 'inbox', 'worker'], 'an item');
666
+ case 'launch':
667
+ return on(['item.unblocked', 'queue.pick'], [], 'a queued item (queue.pick or item.unblocked); the scheduler still checks slots, Areas, blockers and quiet hours');
668
+ // The session manager parks a worker whose turn ended while it waits, and resumes it when a
669
+ // wait resolves; it asks the rules then, and at no other time.
670
+ case 'park':
671
+ return on(['worker.waiting'], [], 'a worker whose turn ended while it waits (worker.waiting)');
672
+ case 'remind':
673
+ return on(['inbox.raised', 'worker.idle', 'worker.permission'], ['wait', 'inbox'], 'an open inbox item or wait');
674
+ case 'resume':
675
+ return on(['pr.comment', 'wait.resolved'], [], 'a worker whose wait resolved (wait.resolved or pr.comment)');
676
+ case 'retire':
677
+ // Retiring removes a worktree, so only an item that is closed may cause it; it still refuses unfinished work.
678
+ return on(['item.closed'], [], 'a closed item (item.closed): the conductor retires only finished work');
679
+ case 'retry':
680
+ return on(['run.failed'], [], 'a failed run (run.failed)');
681
+ default:
682
+ return;
683
+ }
684
+ }
685
+ function compare(actual, op, expected) {
686
+ if (typeof actual === 'string' || typeof expected === 'string') {
687
+ const equal = String(actual).toLowerCase() === String(expected).toLowerCase();
688
+ return op === '==' ? equal : op === '!=' ? !equal : false;
689
+ }
690
+ switch (op) {
691
+ case '!=':
692
+ return actual !== expected;
693
+ case '<':
694
+ return actual < expected;
695
+ case '<=':
696
+ return actual <= expected;
697
+ case '==':
698
+ return actual === expected;
699
+ case '>':
700
+ return actual > expected;
701
+ case '>=':
702
+ return actual >= expected;
703
+ }
704
+ }
705
+ function compileRule(input, index, origin, limits) {
706
+ const does = typeof input.do === 'string' ? [input.do] : input.do;
707
+ let actions;
708
+ try {
709
+ actions = does.map((d) => parseAction(d, limits));
710
+ }
711
+ catch (error) {
712
+ if (error instanceof RuleSyntaxError)
713
+ throw new RuleSyntaxError(`do: ${error.message}`);
714
+ throw error;
715
+ }
716
+ const off = actions.every((a) => a.verb === 'none');
717
+ if (actions.some((a) => a.verb === 'none') && !off)
718
+ throw new RuleSyntaxError('do: "none" stands alone');
719
+ if (off && input.id === undefined)
720
+ throw new RuleSyntaxError('do: "none" switches off the rule with the same id, so it needs an id');
721
+ if (input.when === undefined && !off)
722
+ throw new RuleSyntaxError('when is missing');
723
+ let when = null;
724
+ try {
725
+ when = input.when === undefined ? null : parseWhen(input.when);
726
+ }
727
+ catch (error) {
728
+ if (error instanceof RuleSyntaxError)
729
+ throw new RuleSyntaxError(`when: ${error.message}`);
730
+ throw error;
731
+ }
732
+ const implied = when === null ? [] : fieldsOf(when).flatMap((f) => Object.entries(IMPLIED_EVENTS).flatMap(([prefix, e]) => (f.startsWith(prefix) ? [e] : [])));
733
+ const events = when === null ? [] : [...new Set([...eventsOf(when), ...implied])];
734
+ const scope = events.length > 0 || when === null ? null : scopeOf(fieldsOf(when));
735
+ if (!off)
736
+ for (const action of actions)
737
+ checkAction(action, events, scope);
738
+ const key = input.id ?? `#${index + 1}`;
739
+ return {
740
+ actions,
741
+ events,
742
+ file: origin.file,
743
+ id: input.id ?? null,
744
+ index,
745
+ note: input.note ?? null,
746
+ project: origin.project,
747
+ ref: `${origin.source === 'project' ? `project.${origin.project ?? '?'}` : origin.source}:${key}`,
748
+ scope,
749
+ source: origin.source,
750
+ text: { do: does, when: input.when ?? null },
751
+ when,
752
+ };
753
+ }
754
+ function describeType(spec) {
755
+ switch (spec.type) {
756
+ case 'clock':
757
+ return 'a local time, such as 22:00';
758
+ case 'day':
759
+ return 'a day, such as saturday';
760
+ case 'duration':
761
+ return 'a duration, such as 15m or 2h';
762
+ case 'enum':
763
+ return `one of ${spec.values.join(', ')}`;
764
+ case 'int':
765
+ return 'a whole number';
766
+ case 'labels':
767
+ return 'a label';
768
+ case 'text':
769
+ return 'a word or a "quoted string"';
770
+ case 'tokens':
771
+ return 'a token count, such as 500k or 2M';
772
+ }
773
+ }
774
+ function eventsOf(expr) {
775
+ switch (expr.kind) {
776
+ case 'and':
777
+ case 'or':
778
+ return [...eventsOf(expr.left), ...eventsOf(expr.right)];
779
+ case 'event':
780
+ return [expr.name];
781
+ case 'not':
782
+ return eventsOf(expr.expr);
783
+ default:
784
+ return [];
785
+ }
786
+ }
787
+ function fieldsOf(expr) {
788
+ switch (expr.kind) {
789
+ case 'and':
790
+ case 'or':
791
+ return [...fieldsOf(expr.left), ...fieldsOf(expr.right)];
792
+ case 'compare':
793
+ case 'has':
794
+ case 'in':
795
+ return [expr.field];
796
+ case 'event':
797
+ return [];
798
+ case 'not':
799
+ return fieldsOf(expr.expr);
800
+ }
801
+ }
802
+ function formatMinutes(minutes) {
803
+ if (minutes >= 24 * 60 && minutes % (24 * 60) === 0)
804
+ return `${minutes / (24 * 60)}d`;
805
+ if (minutes >= 60 && minutes % 60 === 0)
806
+ return `${minutes / 60}h`;
807
+ return minutes < 1 ? `${Math.round(minutes * 60)}s` : `${minutes}m`;
808
+ }
809
+ function isList(value) {
810
+ return typeof value !== 'number' && typeof value !== 'string';
811
+ }
812
+ function tokenize(text) {
813
+ const tokens = [];
814
+ const pattern = /\s+|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')|(==|!=|>=|<=|>|<)|([()[\],])|([A-Za-z0-9_.:@/+-]+)/y;
815
+ let at = 0;
816
+ while (at < text.length) {
817
+ pattern.lastIndex = at;
818
+ const match = pattern.exec(text);
819
+ if (!match)
820
+ throw new RuleSyntaxError(`unexpected "${text[at] ?? ''}" at column ${at + 1}`);
821
+ if (match[1] !== undefined)
822
+ tokens.push({ at, kind: 'string', text: match[1].slice(1, -1).replace(/\\(.)/g, '$1') });
823
+ else if (match[2] !== undefined)
824
+ tokens.push({ at, kind: 'op', text: match[2] });
825
+ else if (match[3] !== undefined)
826
+ tokens.push({ at, kind: 'punct', text: match[3] });
827
+ else if (match[4] !== undefined)
828
+ tokens.push({ at, kind: 'word', text: match[4] });
829
+ at = pattern.lastIndex;
830
+ }
831
+ return tokens;
832
+ }
833
+ function toOp(text) {
834
+ switch (text) {
835
+ case '!=':
836
+ case '<':
837
+ case '<=':
838
+ case '==':
839
+ case '>':
840
+ case '>=':
841
+ return text;
842
+ default:
843
+ throw new RuleSyntaxError(`"${text}" is not a comparison`);
844
+ }
845
+ }
846
+ function untilReadable(text) {
847
+ const lower = text.toLowerCase();
848
+ return lower === 'tomorrow' || DAY_NAMES.some((d) => d === lower || d.slice(0, 3) === lower) || parseLocalTime(text, new Date()) !== null;
849
+ }
850
+ //# sourceMappingURL=rules.js.map