@detergent-software/conductor 1.0.0-dev.1 → 1.0.0-dev.11

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 (80) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +210 -9
  3. package/build/cli.js +456 -21
  4. package/build/cli.js.map +1 -1
  5. package/build/clock.js +17 -0
  6. package/build/clock.js.map +1 -0
  7. package/build/conditions.js +5 -6
  8. package/build/conditions.js.map +1 -1
  9. package/build/config.js +92 -1
  10. package/build/config.js.map +1 -1
  11. package/build/credential.js +52 -0
  12. package/build/credential.js.map +1 -0
  13. package/build/daemon.js +96 -2
  14. package/build/daemon.js.map +1 -1
  15. package/build/events.js +106 -0
  16. package/build/events.js.map +1 -0
  17. package/build/inbox.js +385 -0
  18. package/build/inbox.js.map +1 -0
  19. package/build/manager.js +56 -12
  20. package/build/manager.js.map +1 -1
  21. package/build/mod.js +51 -0
  22. package/build/mod.js.map +1 -0
  23. package/build/note.js +71 -0
  24. package/build/note.js.map +1 -0
  25. package/build/notes.js +188 -0
  26. package/build/notes.js.map +1 -0
  27. package/build/notify.js +393 -0
  28. package/build/notify.js.map +1 -0
  29. package/build/overrides.js +82 -0
  30. package/build/overrides.js.map +1 -0
  31. package/build/paths.js +1 -0
  32. package/build/paths.js.map +1 -1
  33. package/build/profile.js +7 -0
  34. package/build/profile.js.map +1 -1
  35. package/build/pushover.js +67 -0
  36. package/build/pushover.js.map +1 -0
  37. package/build/queue.js +178 -0
  38. package/build/queue.js.map +1 -0
  39. package/build/registry.js +3 -1
  40. package/build/registry.js.map +1 -1
  41. package/build/rule-input.js +17 -0
  42. package/build/rule-input.js.map +1 -0
  43. package/build/rules-service.js +402 -0
  44. package/build/rules-service.js.map +1 -0
  45. package/build/rules.js +842 -0
  46. package/build/rules.js.map +1 -0
  47. package/build/scheduler.js +274 -0
  48. package/build/scheduler.js.map +1 -0
  49. package/build/server.js +204 -0
  50. package/build/server.js.map +1 -1
  51. package/build/spend.js +8 -0
  52. package/build/spend.js.map +1 -1
  53. package/build/startup.js +30 -0
  54. package/build/startup.js.map +1 -1
  55. package/build/store.js +271 -0
  56. package/build/store.js.map +1 -1
  57. package/build/token.js +3 -3
  58. package/build/token.js.map +1 -1
  59. package/build/upgrade.js +105 -0
  60. package/build/upgrade.js.map +1 -0
  61. package/build/wait.js +32 -32
  62. package/build/wait.js.map +1 -1
  63. package/build/watchers/index.js +1 -0
  64. package/build/watchers/index.js.map +1 -1
  65. package/build/watchers/linear-key.js +6 -45
  66. package/build/watchers/linear-key.js.map +1 -1
  67. package/build/watchers/linear-queue.js +124 -0
  68. package/build/watchers/linear-queue.js.map +1 -0
  69. package/build/watchers/linear.js +16 -7
  70. package/build/watchers/linear.js.map +1 -1
  71. package/build/why.js +78 -0
  72. package/build/why.js.map +1 -0
  73. package/mod/.claude-plugin/marketplace.json +6 -0
  74. package/mod/.claude-plugin/plugin.json +7 -0
  75. package/mod/README.md +17 -0
  76. package/mod/hooks/hooks.json +1 -0
  77. package/mod/hooks/model.ts +395 -0
  78. package/mod/hooks/register.tsx +505 -0
  79. package/mod/types/index.d.ts +57 -0
  80. package/package.json +6 -1
package/build/cli.js CHANGED
@@ -1,20 +1,35 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, realpathSync } from 'node:fs';
3
- import { createRequire } from 'node:module';
2
+ import { spawn } from 'node:child_process';
3
+ import { mkdirSync, readFileSync, realpathSync } from 'node:fs';
4
4
  import { resolve, sep } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { z } from 'zod';
8
- import { callApi, NotRunningError, readEndpoint } from "./client.js";
8
+ import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
9
+ import { loadConfig } from "./config.js";
9
10
  import { startDaemon } from "./daemon.js";
10
11
  import { ConductorError } from "./errors.js";
11
- import { readPid } from "./lock.js";
12
+ import { eventsCommand } from "./events.js";
13
+ import { inboxLines } from "./inbox.js";
14
+ import { parseJsonc } from "./jsonc.js";
15
+ import { processAlive, readPid } from "./lock.js";
12
16
  import { fleetWorkerSchema } from "./manager.js";
17
+ import { installMod, removeMod } from "./mod.js";
18
+ import { noteCommand } from "./note.js";
19
+ import { notifyLines, notifyStatusSchema, parseUntil, testResultSchema } from "./notify.js";
20
+ import { overrideViewSchema, PAUSE } from "./overrides.js";
13
21
  import { conductorPaths } from "./paths.js";
14
22
  import { registerProject } from "./registry.js";
15
- import { installStartup, removeStartup, TASK_NAME } from "./startup.js";
16
- import { openStore, projectSchema, workerSchema } from "./store.js";
23
+ import { rulesLines, rulesService, rulesTestResultSchema, rulesViewSchema, testLines } from "./rules-service.js";
24
+ import { resolveUntil } from "./rules.js";
25
+ import { outputOf, run } from "./run.js";
26
+ import { queueLines, queueViewSchema } from "./scheduler.js";
27
+ import { formatTokens } from "./spend.js";
28
+ import { installStartup, removeStartup, runStartup, startupRunsCli, TASK_NAME } from "./startup.js";
29
+ import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema } from "./store.js";
30
+ import { parseTag, upgrade } from "./upgrade.js";
17
31
  import { waitCommand } from "./wait.js";
32
+ import { whyLines, whySchema } from "./why.js";
18
33
  const USAGE = `Usage: conduct <command>
19
34
 
20
35
  serve run the daemon in this process (one per machine)
@@ -26,6 +41,37 @@ const USAGE = `Usage: conduct <command>
26
41
  [--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
27
42
  retire <worker> [--force] stop a worker, remove its worktree and session; refuses unfinished work
28
43
  attach <worker> wake a parked worker (no event text) and open it in a tab
44
+ inbox [--json] what waits on you: each open item with its commands and links
45
+ approve <item> [--text <text>]
46
+ approve a plan: post "approve" on the item as you, wake its worker;
47
+ <item> is ATK-312, or the inbox item's id as #12
48
+ revise <item> <text> ask for a revised plan: post "revise: <text>", wake its worker
49
+ done <id> mark an inbox item done (a physical action, or one you handled)
50
+ notify [--json] the notification profile, quiet hours, and each channel's state
51
+ notify profile [<name>] [--until <when>]
52
+ switch the profile now (built in: default, away, focus); --until 17:00 or
53
+ a date and time hands back to the one before it then
54
+ notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
55
+ push a sample item (default npm-otp) on the profile's channels now;
56
+ --raise puts a real item in the inbox instead (quiet hours, reminders)
57
+ queue [--json] [--fresh] what the scheduler picks next and why the rest wait (--fresh: read now)
58
+ pause [<project>] [--until <when>]
59
+ no new launches in the project (every project without one), until
60
+ 17:00, tomorrow, monday or a date and time; unpause ends it
61
+ unpause [<project>] launch again
62
+ focus <project> [--slots <n>] [--weight <n>] --until <when>
63
+ the project's slots or ordering weight, for a while
64
+ why <event id | item> [--json]
65
+ the rule behind an action, and the events that led to it
66
+ rules [--json] the rules in force (project, machine, built-in defaults), what replaces
67
+ what, and any rules file refused; machine rules are ~/.conductor/rules.json
68
+ rules test <file> [--json]
69
+ dry-run the rules on a sample, a JSON file such as
70
+ { "event": "pr.comment", "project": "atk", "fields": { "wait.kind": "merge" } }
71
+ (no event: a state rule's subject); acts on nothing
72
+ note <item> <file> post the file as a phase note on the item ("## <work skill> · <Phase>"), and
73
+ record its phase on the worker (this checkout's, or --worker <worker>);
74
+ exit 3 when nobody posted it (no conductor, or the tracker refused it)
29
75
  wait <condition> wait for an outside event: in a worker's worktree with the daemon
30
76
  running, register it and end the turn (the daemon resumes the session);
31
77
  otherwise check it here and block (exit 0 when it holds, 2 at --timeout)
@@ -35,15 +81,29 @@ const USAGE = `Usage: conduct <command>
35
81
  health <env> --version <x> | --commit <sha>
36
82
  approval [<item>] an approve / revise: comment after the item's newest Plan note
37
83
  item <item> <state> | item <item> label|note <value>
38
- time <when> such as 2026-10-10T09:00 (local)
84
+ time <when> such as 17:00 (the next one) or 2026-10-10T09:00 (local)
39
85
  <full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
40
86
  [--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
87
+ events [--since <id>] the event log after <id> (all of it without), one JSON event per line
88
+ events --follow [--since <id>]
89
+ keep following it (live without --since), reconnecting when the
90
+ conductor restarts; state lines conduct.connected / conduct.disconnected
41
91
  install-startup start the daemon at logon (Windows Task Scheduler)
42
92
  install-startup --remove remove that logon task
93
+ install-mod install the conductor mod in Claude Code (user scope) from this package:
94
+ the status line in every session, the inbox band, pane and commands in
95
+ the console (a session started with CONDUCTOR_CONSOLE=1)
96
+ install-mod --remove uninstall it
97
+ upgrade [--tag dev|latest] stop the daemon, npm install the newest version on the tag (default:
98
+ the tag the installed version came from), start the daemon again
99
+ [--allow-downgrade] install the tag's version even when it is older than the installed one
43
100
 
44
101
  A <worker> is its item (ATK-309) or its name (probe-ATK-309).
45
102
  Files live in ~/.conductor (CONDUCTOR_HOME overrides it).`;
46
103
  const STOP_WAIT_MS = 10_000;
104
+ // A restarted daemon opens its store and binds the port; under the logon task node starts cold.
105
+ const START_WAIT_MS = 30_000;
106
+ const NPM_INSTALL_TIMEOUT_MS = 5 * 60_000;
47
107
  // A launch makes a worktree (pnpm install) before the session starts.
48
108
  const LAUNCH_WAIT_MS = 15 * 60_000;
49
109
  // The daemon's worst cases, so the CLI never gives up on an operation that still runs: retire
@@ -51,6 +111,8 @@ const LAUNCH_WAIT_MS = 15 * 60_000;
51
111
  // attach waits out the settle time (up to 5 min), resumes (120 s) and opens the tab (60 s).
52
112
  const RETIRE_TIMEOUT_MS = 12 * 60_000;
53
113
  const ATTACH_TIMEOUT_MS = 9 * 60_000;
114
+ // Approve and revise ask Linear twice (the issue, then the comment), each within 20 s.
115
+ const ANSWER_TIMEOUT_MS = 60_000;
54
116
  const projectsBody = z.object({ projects: z.array(projectSchema) });
55
117
  const addBody = z.object({ created: z.boolean(), project: projectSchema });
56
118
  const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
@@ -70,6 +132,13 @@ const retireBody = z.object({
70
132
  worker: workerSchema,
71
133
  });
72
134
  const attachBody = z.object({ resumed: z.boolean(), tab: z.string().nullable(), worker: workerSchema });
135
+ const inboxBody = z.object({ items: z.array(inboxItemSchema) });
136
+ const answerBody = z.object({
137
+ comment: z.object({ id: z.string(), url: z.string().nullable() }).nullable(),
138
+ item: inboxItemSchema,
139
+ waits: z.array(waitSchema),
140
+ });
141
+ const answerRefusal = z.object({ details: z.array(z.string()).optional(), error: z.string(), posted: z.enum(['no', 'unknown']).optional() });
73
142
  async function addProject(paths, path) {
74
143
  const absolute = resolve(path);
75
144
  let result;
@@ -80,10 +149,38 @@ async function addProject(paths, path) {
80
149
  if (!(error instanceof NotRunningError))
81
150
  throw error;
82
151
  // No daemon: write the store directly. The daemon reads it when it starts.
83
- result = withStore(paths, (store) => registerProject(store, absolute));
152
+ // Its rules are checked against config.json here too, as the daemon would.
153
+ const check = rulesService({ config: loadConfig(paths.config), listProjects: () => [], paths }).checkProfile;
154
+ result = withStore(paths, (store) => registerProject(store, absolute, check));
84
155
  }
85
156
  out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
86
157
  }
158
+ // `conduct approve|revise|done`: Jason's answer to an inbox item, by id, or for approve and revise
159
+ // by the item whose plan waits.
160
+ async function answer(paths, ref, request) {
161
+ const target = request.action === 'done' ? inboxId(ref) : await planItem(paths, ref);
162
+ const { body, status } = await callApi(paths, { body: request, method: 'POST', path: `/inbox/${target}/resolve`, timeoutMs: ANSWER_TIMEOUT_MS });
163
+ if (status !== 200) {
164
+ const refusal = answerRefusal.safeParse(body).data;
165
+ const details = [...(refusal?.details ?? [])];
166
+ if (status === 502 && refusal?.posted === 'unknown')
167
+ details.push('the comment may be on the item already: look before answering again');
168
+ if (status === 404) {
169
+ if ((await openItems(paths)) === null)
170
+ throw noInbox();
171
+ throw new ConductorError(`no inbox item #${target}`, ['`conduct inbox` lists the open ones']);
172
+ }
173
+ throw new ConductorError(refusal?.error ?? `the API answered ${status}`, details);
174
+ }
175
+ const result = answerBody.parse(body);
176
+ const { item } = result;
177
+ if (request.action === 'done')
178
+ return out(`done: #${item.id} (${item.kind}${item.item ? ` ${item.item}` : ''})`);
179
+ out(`${request.action === 'approve' ? 'approved' : 'asked for a revision of'} ${item.item ?? `#${item.id}`}: ${result.comment?.url ?? result.comment?.id ?? 'comment posted'}`);
180
+ out(result.waits.length > 0
181
+ ? `its worker's approval wait resolved; the conductor resumes it`
182
+ : 'no worker waits on its approval here: the worker reads the comment when it next looks');
183
+ }
87
184
  async function attach(paths, ref) {
88
185
  const worker = await findWorker(paths, ref);
89
186
  const result = attachBody.parse(await expectOk(callApi(paths, { method: 'POST', path: `/workers/${worker.id}/attach`, timeoutMs: ATTACH_TIMEOUT_MS })));
@@ -91,6 +188,26 @@ async function attach(paths, ref) {
91
188
  if (result.tab)
92
189
  out(`no tab (${result.tab}); open it yourself: claude attach ${result.worker.handle ?? worker.name}`);
93
190
  }
191
+ // The pid of the daemon once it answers /health, for upgrade's restart.
192
+ async function awaitDaemon(paths) {
193
+ const deadline = Date.now() + START_WAIT_MS;
194
+ while (Date.now() < deadline) {
195
+ const pid = readPid(paths.pid);
196
+ if (pid !== null && (await ping(paths, pid)))
197
+ return pid;
198
+ await sleep(250);
199
+ }
200
+ throw new ConductorError(`the conductor did not answer within ${START_WAIT_MS / 1000} s`, [`see ${paths.log}`]);
201
+ }
202
+ // `conduct events`: until Ctrl+C with --follow.
203
+ async function events(paths, follow, since) {
204
+ if (since !== undefined && !/^\d+$/.test(since))
205
+ throw new ConductorError('--since takes an event id, such as --since 120');
206
+ const stop = new AbortController();
207
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK'])
208
+ process.once(signal, () => stop.abort());
209
+ await eventsCommand(paths, { follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
210
+ }
94
211
  async function expectOk(call) {
95
212
  const { body, status } = await call;
96
213
  if (status >= 200 && status < 300)
@@ -109,13 +226,22 @@ async function findWorker(paths, ref) {
109
226
  throw new ConductorError(`no worker "${ref}" in flight`, ['`conduct status` lists them']);
110
227
  throw new ConductorError(`"${ref}" names several workers`, matches.map((w) => `${w.name} in ${w.project}`));
111
228
  }
112
- // 1234 → 1.2k, 3_400_000 → 3.4M.
113
- function formatTokens(tokens) {
114
- if (tokens >= 1_000_000)
115
- return `${(tokens / 1_000_000).toFixed(1)}M`;
116
- if (tokens >= 1_000)
117
- return `${(tokens / 1_000).toFixed(1)}k`;
118
- return String(tokens);
229
+ async function inbox(paths, json) {
230
+ const items = await openItems(paths);
231
+ if (items === null)
232
+ throw noInbox();
233
+ if (json)
234
+ return out(JSON.stringify(items, null, 2));
235
+ if (items.length === 0)
236
+ return out('the inbox is empty');
237
+ inboxLines(items, new Date()).forEach((line) => out(line));
238
+ }
239
+ // An inbox id as `conduct inbox` prints it (#12), or bare (12).
240
+ function inboxId(ref) {
241
+ const id = /^#?(\d+)$/.exec(ref)?.[1];
242
+ if (id === undefined)
243
+ throw new ConductorError(`"${ref}" is not an inbox item id`, ['`conduct inbox` lists them as #<id>']);
244
+ return Number(id);
119
245
  }
120
246
  function install() {
121
247
  const cliPath = realpathSync(fileURLToPath(import.meta.url));
@@ -168,19 +294,41 @@ async function main(argv) {
168
294
  // `wait` has options of its own: its --version is a value, not this CLI's version.
169
295
  if (argv[0] === 'wait')
170
296
  return wait(argv.slice(1));
297
+ // `revise` takes free text, which option parsing would cut at a word such as --force. Every word
298
+ // after the item is the text, quoted or not: a cut-off instruction would be posted as Jason's.
299
+ if (argv[0] === 'revise') {
300
+ const [ref, ...words] = argv.slice(1);
301
+ const text = words.join(' ').trim();
302
+ if (ref === undefined || ref.startsWith('-') || !text)
303
+ throw new ConductorError('usage: conduct revise <item> "<what to change>"');
304
+ return answer(conductorPaths(), ref, { action: 'revise', text });
305
+ }
171
306
  const { positionals, values } = parseArgs({
172
307
  allowPositionals: true,
173
308
  args: argv,
174
309
  options: {
310
+ 'allow-downgrade': { type: 'boolean' },
311
+ channel: { type: 'string' },
175
312
  conductor: { type: 'string' },
313
+ follow: { type: 'boolean' },
176
314
  force: { type: 'boolean' },
315
+ fresh: { type: 'boolean' },
177
316
  help: { short: 'h', type: 'boolean' },
178
317
  json: { type: 'boolean' },
318
+ kind: { type: 'string' },
179
319
  probe: { type: 'boolean' },
180
320
  project: { type: 'string' },
181
321
  prompt: { type: 'string' },
322
+ raise: { type: 'boolean' },
182
323
  remove: { type: 'boolean' },
324
+ since: { type: 'string' },
325
+ slots: { type: 'string' },
326
+ tag: { type: 'string' },
327
+ text: { type: 'string' },
328
+ until: { type: 'string' },
183
329
  version: { short: 'v', type: 'boolean' },
330
+ weight: { type: 'string' },
331
+ worker: { type: 'string' },
184
332
  },
185
333
  });
186
334
  const paths = conductorPaths();
@@ -190,10 +338,24 @@ async function main(argv) {
190
338
  if (values.help || command === undefined || command === 'help')
191
339
  return out(USAGE);
192
340
  switch (command) {
341
+ case 'approve':
342
+ if (sub === undefined)
343
+ throw new ConductorError('usage: conduct approve <item> [--text <text>]');
344
+ return answer(paths, sub, { action: 'approve', ...(values.text?.trim() ? { text: values.text.trim() } : {}) });
193
345
  case 'attach':
194
346
  if (sub === undefined)
195
347
  throw new ConductorError('usage: conduct attach <worker>');
196
348
  return attach(paths, sub);
349
+ case 'done':
350
+ if (sub === undefined)
351
+ throw new ConductorError('usage: conduct done <inbox id>');
352
+ return answer(paths, sub, { action: 'done' });
353
+ case 'events':
354
+ return events(paths, values.follow === true, values.since);
355
+ case 'inbox':
356
+ return inbox(paths, values.json === true);
357
+ case 'install-mod':
358
+ return values.remove ? removeMod(modDeps()) : installMod(fileURLToPath(new URL('../mod', import.meta.url)), modDeps());
197
359
  case 'install-startup':
198
360
  return values.remove ? uninstall() : install();
199
361
  case 'launch':
@@ -205,12 +367,49 @@ async function main(argv) {
205
367
  project: values.project,
206
368
  prompt: values.prompt,
207
369
  });
370
+ case 'note':
371
+ if (sub === undefined || arg === undefined)
372
+ throw new ConductorError('usage: conduct note <item> <file> [--worker <worker>]');
373
+ process.exitCode = await noteCommand(paths, sub, arg, { worker: values.worker }, {
374
+ cwd: process.cwd(),
375
+ err: (line) => process.stderr.write(`${line}\n`),
376
+ out,
377
+ });
378
+ return;
379
+ case 'notify':
380
+ if (sub === undefined)
381
+ return notifyStatus(paths, values.json === true);
382
+ if (sub === 'profile')
383
+ return notifyProfile(paths, arg, values.until);
384
+ if (sub === 'test')
385
+ return notifyTest(paths, { channel: values.channel, kind: values.kind, raise: values.raise === true });
386
+ throw new ConductorError('usage: conduct notify | conduct notify profile [<name>] [--until <when>] | conduct notify test');
208
387
  case 'project':
209
388
  if (sub === 'add' && arg !== undefined)
210
389
  return addProject(paths, arg);
211
390
  if (sub === 'list')
212
391
  return listProjects(paths, values.json === true);
213
392
  throw new ConductorError('usage: conduct project add <path> | conduct project list');
393
+ case 'focus':
394
+ if (sub === undefined || values.until === undefined)
395
+ throw new ConductorError('usage: conduct focus <project> [--slots <n>] [--weight <n>] --until <when>');
396
+ return override(paths, {
397
+ action: 'focus',
398
+ project: sub,
399
+ ...(values.slots === undefined ? {} : { slots: wholeNumber('--slots', values.slots) }),
400
+ until: untilOf(values.until),
401
+ ...(values.weight === undefined ? {} : { weight: wholeNumber('--weight', values.weight) }),
402
+ });
403
+ case 'pause':
404
+ return override(paths, { action: 'pause', ...(sub === undefined ? {} : { project: sub }), ...(values.until === undefined ? {} : { until: untilOf(values.until) }) });
405
+ case 'queue':
406
+ return queue(paths, values.json === true, values.fresh === true);
407
+ case 'rules':
408
+ if (sub === undefined)
409
+ return rules(paths, values.json === true);
410
+ if (sub === 'test' && arg !== undefined)
411
+ return rulesTest(paths, arg, values.json === true);
412
+ throw new ConductorError('usage: conduct rules [--json] | conduct rules test <file> [--json]');
214
413
  case 'retire':
215
414
  if (sub === undefined)
216
415
  throw new ConductorError('usage: conduct retire <worker> [--force]');
@@ -220,18 +419,139 @@ async function main(argv) {
220
419
  case 'status':
221
420
  return status(paths, values.json === true);
222
421
  case 'stop':
223
- return stop(paths);
422
+ await stopDaemon(paths);
423
+ return;
424
+ case 'unpause':
425
+ return override(paths, { action: 'unpause', ...(sub === undefined ? {} : { project: sub }) });
426
+ case 'upgrade':
427
+ return upgradeCommand(paths, values.tag, values['allow-downgrade'] === true);
428
+ case 'why':
429
+ if (sub === undefined)
430
+ throw new ConductorError('usage: conduct why <event id | item>');
431
+ return why(paths, sub, values.json === true);
224
432
  default:
225
433
  throw new ConductorError(`unknown command "${command}"`, ['run `conduct help` for the commands']);
226
434
  }
227
435
  }
436
+ function modDeps() {
437
+ return { claude: (args) => run('claude', args, { timeoutMs: 120_000 }), out };
438
+ }
439
+ function noInbox() {
440
+ return olderDaemon('no inbox');
441
+ }
442
+ // GET /notify, or a ConductorError from a daemon older than the notifier.
443
+ async function notifyAnswer(paths, request) {
444
+ const answer = await callApi(paths, request);
445
+ if (answer.status === 404)
446
+ throw olderDaemon('no notifier');
447
+ return expectOk(Promise.resolve(answer));
448
+ }
449
+ // `conduct notify profile`: the active profile, or switch to <name>.
450
+ async function notifyProfile(paths, name, until) {
451
+ if (name === undefined) {
452
+ if (until !== undefined)
453
+ throw new ConductorError('usage: conduct notify profile <name> --until <when>');
454
+ return notifyStatus(paths, false);
455
+ }
456
+ const body = { name, ...(until === undefined ? {} : { until: parseUntil(until, new Date()).toISOString() }) };
457
+ const status = notifyStatusSchema.parse(await notifyAnswer(paths, { body, method: 'POST', path: '/notify/profile' }));
458
+ notifyLines(status).forEach((line) => out(line));
459
+ }
460
+ async function notifyStatus(paths, json) {
461
+ const status = notifyStatusSchema.parse(await notifyAnswer(paths, { method: 'GET', path: '/notify' }));
462
+ if (json)
463
+ return out(JSON.stringify(status, null, 2));
464
+ notifyLines(status).forEach((line) => out(line));
465
+ }
466
+ async function notifyTest(paths, options) {
467
+ if (options.raise && options.channel)
468
+ throw new ConductorError('--raise routes the item by the profile; it takes no --channel');
469
+ const body = { ...(options.channel ? { channel: options.channel } : {}), ...(options.kind ? { kind: options.kind } : {}), raise: options.raise };
470
+ // Pushover can take its 15 s, after a Credential Manager read.
471
+ const answer = await notifyAnswer(paths, { body, method: 'POST', path: '/notify/test', timeoutMs: 60_000 });
472
+ if (options.raise) {
473
+ const { item } = z.object({ item: inboxItemSchema }).parse(answer);
474
+ return out(`raised inbox item #${item.id} (${item.kind}); \`conduct done ${item.id}\` clears it`);
475
+ }
476
+ const result = testResultSchema.parse(answer);
477
+ if (result.channels.length === 0)
478
+ return out(`profile ${result.profile} sends a ${body.kind ?? 'npm-otp'} item nowhere`);
479
+ out(`profile ${result.profile}${result.quietNow ? ' (quiet hours now: a real item would wait for the summary)' : ''}`);
480
+ result.results.forEach((r) => out(` ${r.channel}: ${r.ok ? 'ok' : 'failed'}: ${r.detail}`));
481
+ if (result.results.some((r) => !r.ok))
482
+ process.exitCode = 1;
483
+ }
484
+ // npm is a .cmd shim on Windows, which starts through a shell alone. The only argument from input is
485
+ // the tag, which parseTag limits to dev or latest.
486
+ function npm(args, timeoutMs) {
487
+ return run('npm', args, { shell: process.platform === 'win32', timeoutMs });
488
+ }
489
+ function olderDaemon(what) {
490
+ return new ConductorError(`the running conductor has ${what}: it is older than this conduct`, [
491
+ 'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
492
+ ]);
493
+ }
494
+ // The open inbox items, or null from a daemon older than the inbox (a linked build ahead of it).
495
+ async function openItems(paths) {
496
+ const answer = await callApi(paths, { method: 'GET', path: '/inbox' });
497
+ if (answer.status === 404)
498
+ return null;
499
+ return inboxBody.parse(await expectOk(Promise.resolve(answer))).items;
500
+ }
228
501
  function out(line) {
229
502
  process.stdout.write(`${line}\n`);
230
503
  }
504
+ // `conduct pause|unpause|focus`: an override that ends on its own (unpause ends a pause).
505
+ async function override(paths, body) {
506
+ const answer = await callApi(paths, { body: { ...body, by: `conduct ${String(body.action)}` }, method: 'POST', path: '/overrides' });
507
+ if (answer.status === 404)
508
+ throw olderDaemon('no overrides');
509
+ const { overrides, set } = z.object({ overrides: z.array(overrideViewSchema), set: z.array(overrideViewSchema) }).parse(await expectOk(Promise.resolve(answer)));
510
+ const untilOfView = (o) => (o.until === null ? ' until you unpause' : ` until ${new Date(o.until).toLocaleString()}`);
511
+ for (const o of set) {
512
+ const who = o.project ?? 'every project';
513
+ if (o.kind === PAUSE)
514
+ out(o.value === 'on' ? `paused launches in ${who}${untilOfView(o)}` : `launches in ${who} are no longer paused`);
515
+ else
516
+ out(`${who}: ${o.kind.replace('focus.', '')} ${o.value}${untilOfView(o)}`);
517
+ }
518
+ // A pause of every project still holds a project unpaused on its own.
519
+ const all = overrides.find((o) => o.kind === PAUSE && o.project === null);
520
+ if (body.action === 'unpause' && body.project !== undefined && all)
521
+ out(`but all launches stay paused${untilOfView(all)}: conduct unpause ends that`);
522
+ }
523
+ // The open plan item of `ref`: the item (ATK-312) whose plan waits, or an inbox id written #12. A
524
+ // bare number is refused: it could be an issue's number, and the answer is posted as Jason's.
525
+ async function planItem(paths, ref) {
526
+ if (ref.startsWith('#'))
527
+ return inboxId(ref);
528
+ if (/^\d+$/.test(ref)) {
529
+ throw new ConductorError(`"${ref}" is ambiguous`, [`name the item, such as ATK-${ref}, or the inbox id as #${ref}`]);
530
+ }
531
+ const items = await openItems(paths);
532
+ if (items === null)
533
+ throw noInbox();
534
+ const plans = items.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.toUpperCase());
535
+ if (plans.length === 1 && plans[0])
536
+ return plans[0].id;
537
+ if (plans.length === 0)
538
+ throw new ConductorError(`no plan of ${ref} waits in the inbox`, ['`conduct inbox` lists what waits']);
539
+ throw new ConductorError(`several plans of ${ref} wait`, plans.map((p) => `#${p.id} in ${p.project ?? '?'}: pass #${p.id}`));
540
+ }
231
541
  function projectLine(p) {
232
542
  const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
233
543
  return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
234
544
  }
545
+ async function queue(paths, json, fresh) {
546
+ // A fresh read asks every project's tracker, within the shared rate budget.
547
+ const answer = await callApi(paths, { method: 'GET', path: `/queue${fresh ? '?fresh=true' : ''}`, timeoutMs: 120_000 });
548
+ if (answer.status === 404)
549
+ throw olderDaemon('no scheduler');
550
+ const view = queueViewSchema.parse(await expectOk(Promise.resolve(answer)));
551
+ if (json)
552
+ return out(JSON.stringify(view, null, 2));
553
+ queueLines(view).forEach((line) => out(line));
554
+ }
235
555
  async function retire(paths, ref, force) {
236
556
  const worker = await findWorker(paths, ref);
237
557
  const { body, status } = await callApi(paths, {
@@ -259,6 +579,48 @@ async function retire(paths, ref, force) {
259
579
  out(`retired ${worker.name}`);
260
580
  steps.forEach((s) => out(` ${s}`));
261
581
  }
582
+ // `conduct rules`: from the daemon, or straight from the files when it isn't running.
583
+ async function rules(paths, json) {
584
+ const view = await rulesAnswer(paths, { method: 'GET', path: '/rules' }, (body) => rulesViewSchema.parse(body), (service) => service.view());
585
+ if (json)
586
+ return out(JSON.stringify(view, null, 2));
587
+ rulesLines(view).forEach((line) => out(line));
588
+ }
589
+ // The daemon's answer, or, when none runs, the same thing worked out here from the files.
590
+ async function rulesAnswer(paths, request, parse, local) {
591
+ let answer;
592
+ try {
593
+ answer = await callApi(paths, request);
594
+ }
595
+ catch (error) {
596
+ if (!(error instanceof NotRunningError))
597
+ throw error;
598
+ process.stderr.write('conduct: the conductor is not running; read from the files\n');
599
+ const config = loadConfig(paths.config);
600
+ return withStore(paths, (store) => {
601
+ const service = rulesService({ config, listProjects: () => store.listProjects(), paths });
602
+ service.reload();
603
+ return local(service);
604
+ });
605
+ }
606
+ if (answer.status === 404)
607
+ throw olderDaemon('no rules engine');
608
+ return parse(await expectOk(Promise.resolve(answer)));
609
+ }
610
+ async function rulesTest(paths, file, json) {
611
+ let text;
612
+ try {
613
+ text = readFileSync(file, 'utf8');
614
+ }
615
+ catch (error) {
616
+ throw new ConductorError(`could not read ${file}`, [String(error)]);
617
+ }
618
+ const sample = parseJsonc(text, z.unknown(), file);
619
+ const result = await rulesAnswer(paths, { body: sample, method: 'POST', path: '/rules/test' }, (body) => rulesTestResultSchema.parse(body), (service) => service.test(sample));
620
+ if (json)
621
+ return out(JSON.stringify(result, null, 2));
622
+ testLines(result).forEach((line) => out(line));
623
+ }
262
624
  async function serve(paths) {
263
625
  const daemon = await startDaemon(paths, { version: version() });
264
626
  out(`conductor listening on 127.0.0.1:${daemon.port} (pid ${process.pid}, home ${paths.home})`);
@@ -290,17 +652,24 @@ async function status(paths, json) {
290
652
  return;
291
653
  }
292
654
  const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
655
+ // A linked build's CLI can be newer than the daemon still running, which has no inbox (null).
656
+ const items = await openItems(paths);
293
657
  const pid = readPid(paths.pid);
294
658
  const port = readEndpoint(paths)?.port ?? null;
295
659
  if (json)
296
- return out(JSON.stringify({ home: paths.home, pid, port, projects, running: true, workers }, null, 2));
660
+ return out(JSON.stringify({ home: paths.home, inbox: items, pid, port, projects, running: true, workers }, null, 2));
297
661
  out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
298
662
  out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
299
663
  projects.forEach((p) => out(projectLine(p)));
300
664
  out(workers.length === 0 ? 'no workers' : 'workers:');
301
665
  workers.forEach((w) => out(workerLine(w)));
666
+ if (items === null)
667
+ out('inbox: not in this daemon (restart it to run this build)');
668
+ else
669
+ out(items.length === 0 ? 'inbox: empty' : `inbox: ${items.length} open (conduct inbox)`);
302
670
  }
303
- async function stop(paths) {
671
+ // True when it stopped a daemon, false when none ran.
672
+ async function stopDaemon(paths) {
304
673
  const pid = readPid(paths.pid);
305
674
  try {
306
675
  await expectOk(callApi(paths, { method: 'POST', path: '/shutdown' }));
@@ -309,7 +678,7 @@ async function stop(paths) {
309
678
  if (!(error instanceof NotRunningError))
310
679
  throw error;
311
680
  out('the conductor is not running');
312
- return;
681
+ return false;
313
682
  }
314
683
  const deadline = Date.now() + STOP_WAIT_MS;
315
684
  while (readEndpoint(paths) !== null && Date.now() < deadline)
@@ -317,13 +686,56 @@ async function stop(paths) {
317
686
  if (readEndpoint(paths) !== null) {
318
687
  throw new ConductorError(`the conductor (pid ${pid ?? '?'}) did not stop within ${STOP_WAIT_MS / 1000} s`);
319
688
  }
689
+ // The port file goes before the process ends; a start (the logon task's especially) waits for it.
690
+ const exitDeadline = Date.now() + STOP_WAIT_MS;
691
+ while (pid !== null && processAlive(pid) && Date.now() < exitDeadline)
692
+ await sleep(100);
320
693
  out(`stopped the conductor (pid ${pid ?? '?'})`);
694
+ return true;
321
695
  }
322
696
  function uninstall() {
323
697
  out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
324
698
  }
699
+ // --until: 17:00 (the next one), tomorrow, a day such as monday, or a date and time.
700
+ function untilOf(text) {
701
+ return resolveUntil({ kind: 'at', text }, new Date()).toISOString();
702
+ }
703
+ async function upgradeCommand(paths, tag, allowDowngrade) {
704
+ const cliPath = realpathSync(fileURLToPath(import.meta.url));
705
+ await upgrade({ allowDowngrade, tag: parseTag(tag) }, {
706
+ awaitDaemon: () => awaitDaemon(paths),
707
+ cliPath,
708
+ installedVersion: version,
709
+ npmInstall: (spec) => npm(['install', '--global', spec], NPM_INSTALL_TIMEOUT_MS),
710
+ npmRoot: async () => {
711
+ const result = await npm(['root', '--global']);
712
+ if (result.code !== 0)
713
+ throw new ConductorError('could not ask npm for its global folder', [outputOf(result)]);
714
+ return realpathSync(result.stdout.trim());
715
+ },
716
+ npmView: async (spec) => {
717
+ const result = await npm(['view', spec, 'version']);
718
+ const found = result.stdout.trim();
719
+ if (result.code !== 0 || found === '')
720
+ throw new ConductorError(`npm has no ${spec}`, [outputOf(result)]);
721
+ return found;
722
+ },
723
+ out,
724
+ runTask: runStartup,
725
+ // A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
726
+ // caller's directory, which it would hold open (a worktree it may later remove, say).
727
+ serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
728
+ .on('error', () => { })
729
+ .unref(),
730
+ stopDaemon: () => stopDaemon(paths),
731
+ // The logon task's daemon uses ~/.conductor, so it can't restart one kept in CONDUCTOR_HOME.
732
+ taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
733
+ });
734
+ }
735
+ // Read from disk each time: upgrade asks again after npm has replaced the package.
325
736
  function version() {
326
- return z.object({ version: z.string() }).parse(createRequire(import.meta.url)('../package.json')).version;
737
+ const file = fileURLToPath(new URL('../package.json', import.meta.url));
738
+ return z.object({ version: z.string() }).parse(JSON.parse(readFileSync(file, 'utf8'))).version;
327
739
  }
328
740
  async function wait(argv) {
329
741
  const { positionals, values } = parseArgs({
@@ -348,6 +760,28 @@ async function wait(argv) {
348
760
  throw new ConductorError('usage: conduct wait <condition>', ['run `conduct help` for the conditions']);
349
761
  process.exitCode = await waitCommand(conductorPaths(), positionals, { ...values, timeoutMinutes: timeout }, { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out });
350
762
  }
763
+ function wholeNumber(flag, text) {
764
+ if (!/^-?\d+$/.test(text))
765
+ throw new ConductorError(`${flag} takes a whole number, not "${text}"`);
766
+ return Number(text);
767
+ }
768
+ // `conduct why <event id>`: the rule behind it and its causes. `conduct why <item>`: the same for
769
+ // each of the item's newest actions.
770
+ async function why(paths, ref, json) {
771
+ const byId = /^#?(\d+)$/.exec(ref)?.[1];
772
+ const answer = await callApi(paths, { method: 'GET', path: byId === undefined ? `/why?item=${encodeURIComponent(ref)}` : `/why/${byId}` });
773
+ if (answer.status === 404 && byId !== undefined) {
774
+ const body = errorBody.safeParse(answer.body);
775
+ throw body.success && body.data.error === 'not_found' ? new ConductorError(`no event ${byId}`) : olderDaemon('no conduct why');
776
+ }
777
+ const body = await expectOk(Promise.resolve(answer));
778
+ const whys = byId === undefined ? z.object({ items: z.array(whySchema) }).parse(body).items : [whySchema.parse(body)];
779
+ if (json)
780
+ return out(JSON.stringify(byId === undefined ? whys : whys[0], null, 2));
781
+ if (whys.length === 0)
782
+ return out(`no action on ${ref} was caused by a rule`);
783
+ whys.forEach((w, i) => [...(i > 0 ? [''] : []), ...whyLines(w)].forEach((line) => out(line)));
784
+ }
351
785
  function withStore(paths, use) {
352
786
  mkdirSync(paths.home, { recursive: true });
353
787
  const store = openStore(paths.db);
@@ -358,12 +792,13 @@ function withStore(paths, use) {
358
792
  store.close();
359
793
  }
360
794
  }
361
- // One line per worker: name, project, status and session activity, its waits, then spend.
795
+ // One line per worker: name, project, status and session activity, its phase and waits, then spend.
362
796
  function workerLine(w) {
363
797
  const activity = w.status === 'running' ? `running, ${w.activity}` : w.status;
364
798
  const waits = w.waits.map((x) => (x.resolvedAt ? `${x.condition} (resolved)` : x.condition));
365
799
  return [
366
800
  ` ${w.name} ${w.project} ${activity}`,
801
+ ...(w.phase ? [`phase ${w.phase}`] : []),
367
802
  ...(waits.length > 0 ? [`waits: ${waits.join(', ')}`] : []),
368
803
  `spend ${formatTokens(w.spend.today)} today, ${formatTokens(w.spend.total)} total`,
369
804
  ].join(' · ');