@detergent-software/conductor 1.0.0-dev.8 → 1.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 (106) hide show
  1. package/CHANGELOG.md +304 -0
  2. package/README.md +277 -24
  3. package/build/brief-cli.js +68 -0
  4. package/build/brief-cli.js.map +1 -0
  5. package/build/brief.js +211 -0
  6. package/build/brief.js.map +1 -0
  7. package/build/broker-cli.js +126 -0
  8. package/build/broker-cli.js.map +1 -0
  9. package/build/broker.js +154 -0
  10. package/build/broker.js.map +1 -0
  11. package/build/cli.js +438 -132
  12. package/build/cli.js.map +1 -1
  13. package/build/clock.js +17 -0
  14. package/build/clock.js.map +1 -0
  15. package/build/conditions.js +20 -14
  16. package/build/conditions.js.map +1 -1
  17. package/build/config.js +26 -3
  18. package/build/config.js.map +1 -1
  19. package/build/console.js +176 -0
  20. package/build/console.js.map +1 -0
  21. package/build/daemon.js +136 -5
  22. package/build/daemon.js.map +1 -1
  23. package/build/events.js +2 -1
  24. package/build/events.js.map +1 -1
  25. package/build/help.js +447 -0
  26. package/build/help.js.map +1 -0
  27. package/build/history.js +637 -0
  28. package/build/history.js.map +1 -0
  29. package/build/inbox.js +117 -18
  30. package/build/inbox.js.map +1 -1
  31. package/build/manager.js +118 -43
  32. package/build/manager.js.map +1 -1
  33. package/build/notes.js +27 -43
  34. package/build/notes.js.map +1 -1
  35. package/build/notify.js +35 -14
  36. package/build/notify.js.map +1 -1
  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/queue.js +183 -0
  44. package/build/queue.js.map +1 -0
  45. package/build/registry.js +17 -10
  46. package/build/registry.js.map +1 -1
  47. package/build/rule-actions.js +444 -0
  48. package/build/rule-actions.js.map +1 -0
  49. package/build/rule-input.js +17 -0
  50. package/build/rule-input.js.map +1 -0
  51. package/build/rules-service.js +402 -0
  52. package/build/rules-service.js.map +1 -0
  53. package/build/rules.js +850 -0
  54. package/build/rules.js.map +1 -0
  55. package/build/scheduler.js +293 -0
  56. package/build/scheduler.js.map +1 -0
  57. package/build/serve.js +51 -0
  58. package/build/serve.js.map +1 -0
  59. package/build/server.js +230 -7
  60. package/build/server.js.map +1 -1
  61. package/build/spend.js +42 -0
  62. package/build/spend.js.map +1 -1
  63. package/build/store.js +266 -12
  64. package/build/store.js.map +1 -1
  65. package/build/trackers.js +15 -0
  66. package/build/trackers.js.map +1 -0
  67. package/build/upgrade.js +16 -14
  68. package/build/upgrade.js.map +1 -1
  69. package/build/wait.js +3 -9
  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 +60 -52
  74. package/build/watchers/ado.js.map +1 -1
  75. package/build/watchers/boards-brief.js +63 -0
  76. package/build/watchers/boards-brief.js.map +1 -0
  77. package/build/watchers/boards-queue.js +120 -0
  78. package/build/watchers/boards-queue.js.map +1 -0
  79. package/build/watchers/boards-writes.js +83 -0
  80. package/build/watchers/boards-writes.js.map +1 -0
  81. package/build/watchers/boards.js +271 -0
  82. package/build/watchers/boards.js.map +1 -0
  83. package/build/watchers/conflicts.js +132 -0
  84. package/build/watchers/conflicts.js.map +1 -0
  85. package/build/watchers/github.js +5 -2
  86. package/build/watchers/github.js.map +1 -1
  87. package/build/watchers/index.js +21 -7
  88. package/build/watchers/index.js.map +1 -1
  89. package/build/watchers/linear-api.js +55 -0
  90. package/build/watchers/linear-api.js.map +1 -0
  91. package/build/watchers/linear-labels.js +43 -0
  92. package/build/watchers/linear-labels.js.map +1 -0
  93. package/build/watchers/linear-queue.js +96 -0
  94. package/build/watchers/linear-queue.js.map +1 -0
  95. package/build/watchers/linear.js +18 -16
  96. package/build/watchers/linear.js.map +1 -1
  97. package/build/watchers/source.js +3 -3
  98. package/build/watchers/source.js.map +1 -1
  99. package/build/why.js +78 -0
  100. package/build/why.js.map +1 -0
  101. package/mod/.claude-plugin/plugin.json +1 -1
  102. package/mod/README.md +2 -2
  103. package/mod/hooks/model.ts +135 -8
  104. package/mod/hooks/register.tsx +219 -19
  105. package/mod/types/index.d.ts +6 -0
  106. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -5,79 +5,37 @@ 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 { briefCommand } from "./brief-cli.js";
9
+ import { heldLocks, lockCommand, lockLine, portsCommand } from "./broker-cli.js";
8
10
  import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
9
- import { startDaemon } from "./daemon.js";
11
+ import { loadConfig } from "./config.js";
12
+ import { CONSOLE_ARGS, CONSOLE_WINDOW, consoleProject, consoleTabArgs, escalationLines, escalationSchema, handledSchema } from "./console.js";
13
+ import { logCrashes, startDaemon } from "./daemon.js";
10
14
  import { ConductorError } from "./errors.js";
11
15
  import { eventsCommand } from "./events.js";
16
+ import { commandHelp, isCommand, listing, unknownCommand, usageError } from "./help.js";
17
+ import { flagWindow, historyLines, historyReport, historySchema, parseRange, retroMarkdown } from "./history.js";
12
18
  import { inboxLines } from "./inbox.js";
19
+ import { parseJsonc } from "./jsonc.js";
13
20
  import { processAlive, readPid } from "./lock.js";
14
- import { fleetWorkerSchema } from "./manager.js";
21
+ import { fleetWorkerSchema, storedFleet } from "./manager.js";
15
22
  import { installMod, removeMod } from "./mod.js";
16
23
  import { noteCommand } from "./note.js";
17
24
  import { notifyLines, notifyStatusSchema, parseUntil, testResultSchema } from "./notify.js";
25
+ import { overrideViewSchema, PAUSE } from "./overrides.js";
18
26
  import { conductorPaths } from "./paths.js";
19
27
  import { registerProject } from "./registry.js";
28
+ import { rulesLines, rulesService, rulesTestResultSchema, rulesViewSchema, testLines } from "./rules-service.js";
29
+ import { resolveUntil } from "./rules.js";
20
30
  import { outputOf, run } from "./run.js";
21
- import { formatTokens } from "./spend.js";
31
+ import { queueLines, queueViewSchema } from "./scheduler.js";
32
+ import { awaitStart, serveDetached, ServeStartError } from "./serve.js";
33
+ import { formatTokens, localDay } from "./spend.js";
22
34
  import { installStartup, removeStartup, runStartup, startupRunsCli, TASK_NAME } from "./startup.js";
23
35
  import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema } from "./store.js";
24
- import { parseTag, upgrade } from "./upgrade.js";
36
+ import { parseTag, restartDaemon, upgrade } from "./upgrade.js";
25
37
  import { waitCommand } from "./wait.js";
26
- const USAGE = `Usage: conduct <command>
27
-
28
- serve run the daemon in this process (one per machine)
29
- stop stop the running daemon
30
- status [--json] the daemon's pid, port, projects and workers
31
- project add <path> register the project whose checkout holds .claude/conductor.json
32
- project list [--json] the registered projects
33
- launch <item> start a worker: its worktree, a background session, a tab
34
- [--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
35
- retire <worker> [--force] stop a worker, remove its worktree and session; refuses unfinished work
36
- attach <worker> wake a parked worker (no event text) and open it in a tab
37
- inbox [--json] what waits on you: each open item with its commands and links
38
- approve <item> [--text <text>]
39
- approve a plan: post "approve" on the item as you, wake its worker;
40
- <item> is ATK-312, or the inbox item's id as #12
41
- revise <item> <text> ask for a revised plan: post "revise: <text>", wake its worker
42
- done <id> mark an inbox item done (a physical action, or one you handled)
43
- notify [--json] the notification profile, quiet hours, and each channel's state
44
- notify profile [<name>] [--until <when>]
45
- switch the profile now (built in: default, away, focus); --until 17:00 or
46
- a date and time hands back to the one before it then
47
- notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
48
- push a sample item (default npm-otp) on the profile's channels now;
49
- --raise puts a real item in the inbox instead (quiet hours, reminders)
50
- note <item> <file> post the file as a phase note on the item ("## <work skill> · <Phase>"), and
51
- record its phase on the worker (this checkout's, or --worker <worker>);
52
- exit 3 when nobody posted it (no conductor, or the tracker refused it)
53
- wait <condition> wait for an outside event: in a worker's worktree with the daemon
54
- running, register it and end the turn (the daemon resumes the session);
55
- otherwise check it here and block (exit 0 when it holds, 2 at --timeout)
56
- pr <n> merged|checks|comment [--repo <owner>/<repo>]
57
- run <pipeline> <run id> [done]
58
- npm <version> [--tag <dist-tag>] [--package <name>]
59
- health <env> --version <x> | --commit <sha>
60
- approval [<item>] an approve / revise: comment after the item's newest Plan note
61
- item <item> <state> | item <item> label|note <value>
62
- time <when> such as 2026-10-10T09:00 (local)
63
- <full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
64
- [--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
65
- events [--since <id>] the event log after <id> (all of it without), one JSON event per line
66
- events --follow [--since <id>]
67
- keep following it (live without --since), reconnecting when the
68
- conductor restarts; state lines conduct.connected / conduct.disconnected
69
- install-startup start the daemon at logon (Windows Task Scheduler)
70
- install-startup --remove remove that logon task
71
- install-mod install the conductor mod in Claude Code (user scope) from this package:
72
- the status line in every session, the inbox band, pane and commands in
73
- the console (a session started with CONDUCTOR_CONSOLE=1)
74
- install-mod --remove uninstall it
75
- upgrade [--tag dev|latest] stop the daemon, npm install the newest version on the tag (default:
76
- the tag the installed version came from), start the daemon again
77
- [--allow-downgrade] install the tag's version even when it is older than the installed one
78
-
79
- A <worker> is its item (ATK-309) or its name (probe-ATK-309).
80
- Files live in ~/.conductor (CONDUCTOR_HOME overrides it).`;
38
+ import { whyLines, whySchema } from "./why.js";
81
39
  const STOP_WAIT_MS = 10_000;
82
40
  // A restarted daemon opens its store and binds the port; under the logon task node starts cold.
83
41
  const START_WAIT_MS = 30_000;
@@ -127,7 +85,9 @@ async function addProject(paths, path) {
127
85
  if (!(error instanceof NotRunningError))
128
86
  throw error;
129
87
  // No daemon: write the store directly. The daemon reads it when it starts.
130
- result = withStore(paths, (store) => registerProject(store, absolute));
88
+ // Its rules are checked against config.json here too, as the daemon would.
89
+ const check = rulesService({ config: loadConfig(paths.config), listProjects: () => [], paths }).checkProfile;
90
+ result = withStore(paths, (store) => registerProject(store, absolute, check));
131
91
  }
132
92
  out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
133
93
  }
@@ -164,25 +124,94 @@ async function attach(paths, ref) {
164
124
  if (result.tab)
165
125
  out(`no tab (${result.tab}); open it yourself: claude attach ${result.worker.handle ?? worker.name}`);
166
126
  }
167
- // The pid of the daemon once it answers /health, for upgrade's restart.
168
- async function awaitDaemon(paths) {
169
- const deadline = Date.now() + START_WAIT_MS;
170
- while (Date.now() < deadline) {
171
- const pid = readPid(paths.pid);
172
- if (pid !== null && (await ping(paths, pid)))
173
- return pid;
174
- await sleep(250);
127
+ // The pid of the daemon once it answers /health, for upgrade's restart. failure: the detached
128
+ // serve's spawn error, which ends the wait early.
129
+ async function awaitDaemon(paths, failure) {
130
+ return awaitStart({
131
+ answer: async () => {
132
+ const pid = readPid(paths.pid);
133
+ return pid !== null && (await ping(paths, pid)) ? pid : null;
134
+ },
135
+ failure,
136
+ log: paths.log,
137
+ timeoutMs: START_WAIT_MS,
138
+ });
139
+ }
140
+ // What `conduct ports` and `conduct lock` print to and run in.
141
+ function brokerIo() {
142
+ return { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out };
143
+ }
144
+ // `conduct console`: the console (design note, section 11), the Claude session the conductor mod
145
+ // serves, started with CONDUCTOR_CONSOLE=1 in the project's main checkout, where the console skill
146
+ // is installed. It keeps no state of its own, so a new one after a close loses nothing.
147
+ async function consoleCommand(paths, options) {
148
+ const project = consoleProject(await projectsNow(paths), options.project);
149
+ if (options.here) {
150
+ const child = spawn('claude', CONSOLE_ARGS, { cwd: project.root, env: { ...process.env, CONDUCTOR_CONSOLE: '1' }, stdio: 'inherit' });
151
+ // Ctrl+C belongs to the session; this process only waits for it to end.
152
+ process.on('SIGINT', () => { });
153
+ process.exitCode = await new Promise((done, fail) => {
154
+ child.on('error', (error) => fail(new ConductorError('could not start claude', [error.message])));
155
+ child.on('exit', (code) => done(code ?? 1));
156
+ });
157
+ return;
158
+ }
159
+ if (process.platform !== 'win32') {
160
+ throw new ConductorError('conduct console opens a Windows Terminal tab', ['run `conduct console --here` in a terminal of your own']);
161
+ }
162
+ // A tab starts with Windows Terminal's environment, not this one's.
163
+ if (process.env.CONDUCTOR_HOME) {
164
+ throw new ConductorError('a console tab would not see CONDUCTOR_HOME', ['run `conduct console --here` in a terminal where it is set']);
175
165
  }
176
- throw new ConductorError(`the conductor did not answer within ${START_WAIT_MS / 1000} s`, [`see ${paths.log}`]);
166
+ const args = consoleTabArgs(project, process.execPath, realpathSync(fileURLToPath(import.meta.url)));
167
+ const result = await run('wt.exe', args, { cwd: project.root, timeoutMs: 60_000 });
168
+ if (result.code !== 0)
169
+ throw new ConductorError('could not open the console tab', [outputOf(result, 500), 'run `conduct console --here` in a terminal instead']);
170
+ out(`opened the console in ${project.root} (Windows Terminal window "${CONSOLE_WINDOW}")`);
171
+ }
172
+ // How `conduct start` and upgrade's restart start the daemon detached from this process.
173
+ function daemonStarter(paths, cliPath) {
174
+ let failure = () => undefined;
175
+ return {
176
+ awaitDaemon: () => awaitDaemon(paths, failure),
177
+ runTask: runStartup,
178
+ serveDetached: () => {
179
+ failure = serveDetached(paths.home, process.execPath, [cliPath, 'serve']);
180
+ },
181
+ // The logon task's daemon uses ~/.conductor, so it can't start one kept in CONDUCTOR_HOME.
182
+ taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
183
+ };
184
+ }
185
+ // `conduct escalations done`: the console reports back on an escalation.
186
+ async function escalationDone(paths, ref, finding) {
187
+ const id = /^#?(\d+)$/.exec(ref)?.[1];
188
+ if (id === undefined)
189
+ throw new ConductorError(`"${ref}" is not an escalation id`, ['`conduct escalations` lists them as #<id>']);
190
+ const answer = await callApi(paths, { body: finding, method: 'POST', path: `/console/escalations/${id}` });
191
+ if (answer.status === 404 && !errorBody.safeParse(answer.body).data?.details)
192
+ throw olderDaemon('no console escalations');
193
+ const { escalation, event, inbox: raised } = handledSchema.parse(await expectOk(Promise.resolve(answer)));
194
+ out(`handled escalation #${escalation.id}${escalation.item ? ` on ${escalation.item}` : ''} (event #${event})`);
195
+ if (raised)
196
+ out(`and raised inbox item #${raised.id} (console) for you`);
197
+ }
198
+ async function escalations(paths, json) {
199
+ const answer = await callApi(paths, { method: 'GET', path: '/console/escalations' });
200
+ if (answer.status === 404)
201
+ throw olderDaemon('no console escalations');
202
+ const { escalations: open } = z.object({ escalations: z.array(escalationSchema) }).parse(await expectOk(Promise.resolve(answer)));
203
+ if (json)
204
+ return out(JSON.stringify(open, null, 2));
205
+ escalationLines(open, new Date()).forEach((line) => out(line));
177
206
  }
178
207
  // `conduct events`: until Ctrl+C with --follow.
179
- async function events(paths, follow, since) {
208
+ async function events(paths, follow, since, isConsole) {
180
209
  if (since !== undefined && !/^\d+$/.test(since))
181
210
  throw new ConductorError('--since takes an event id, such as --since 120');
182
211
  const stop = new AbortController();
183
212
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK'])
184
213
  process.once(signal, () => stop.abort());
185
- await eventsCommand(paths, { follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
214
+ await eventsCommand(paths, { console: isConsole, follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
186
215
  }
187
216
  async function expectOk(call) {
188
217
  const { body, status } = await call;
@@ -202,6 +231,55 @@ async function findWorker(paths, ref) {
202
231
  throw new ConductorError(`no worker "${ref}" in flight`, ['`conduct status` lists them']);
203
232
  throw new ConductorError(`"${ref}" names several workers`, matches.map((w) => `${w.name} in ${w.project}`));
204
233
  }
234
+ // `conduct help <name>`; `conduct help help` is the listing.
235
+ function helpOf(name) {
236
+ if (name === 'help')
237
+ return listing();
238
+ if (!isCommand(name))
239
+ throw unknownCommand(name);
240
+ return commandHelp(name);
241
+ }
242
+ // `conduct history` and `retro`: from the daemon, or straight from conductor.db when none runs or
243
+ // it is older than /history. Reading needs no daemon: the report is a query over the store.
244
+ async function history(paths, command, range, options) {
245
+ const flags = options.since !== undefined || options.until !== undefined;
246
+ if (flags && range !== undefined)
247
+ throw new ConductorError(`conduct ${command} takes a <range> or --since/--until, not both`);
248
+ const now = new Date();
249
+ const window = flags ? flagWindow(options.since, options.until, now) : parseRange(range, now);
250
+ const request = { project: options.project ?? null, since: window.since.toISOString(), until: window.until.toISOString() };
251
+ // Read-only: a daemon older than this build may be running, and a migration would strand it.
252
+ const local = () => {
253
+ try {
254
+ return withStore(paths, (store) => historyReport(store, request), { readOnly: true });
255
+ }
256
+ catch (error) {
257
+ if (error instanceof ConductorError)
258
+ throw error;
259
+ throw new ConductorError(`could not read ${paths.db}`, [
260
+ error instanceof Error ? error.message : String(error),
261
+ 'a schema older than this conduct, or a log left by a crash: start the conductor once (conduct serve, or conduct upgrade), then try again',
262
+ ]);
263
+ }
264
+ };
265
+ let report;
266
+ try {
267
+ const query = new URLSearchParams({ since: request.since, until: request.until, ...(options.project ? { project: options.project } : {}) });
268
+ const answer = await callApi(paths, { method: 'GET', path: `/history?${query.toString()}`, timeoutMs: 60_000 });
269
+ report = answer.status === 404 ? local() : historySchema.parse(await expectOk(Promise.resolve(answer)));
270
+ }
271
+ catch (error) {
272
+ if (!(error instanceof NotRunningError))
273
+ throw error;
274
+ report = local();
275
+ }
276
+ if (options.json)
277
+ return out(JSON.stringify(report, null, 2));
278
+ if (command === 'retro')
279
+ process.stdout.write(retroMarkdown(report));
280
+ else
281
+ historyLines(report).forEach((line) => out(line));
282
+ }
205
283
  async function inbox(paths, json) {
206
284
  const items = await openItems(paths);
207
285
  if (items === null)
@@ -227,6 +305,9 @@ function install() {
227
305
  out('warning: that build is in a worktree, which goes away; run install-startup from the main checkout');
228
306
  }
229
307
  }
308
+ function isHelpFlag(word) {
309
+ return word === '--help' || word === '-h';
310
+ }
230
311
  async function launch(paths, item, options) {
231
312
  const { worker } = workerBody.parse(await expectOk(callApi(paths, { body: { item, ...options }, method: 'POST', path: '/workers' })));
232
313
  out(`launching ${worker.name} in ${worker.project} (worker ${worker.id}): worktree, then session`);
@@ -251,15 +332,7 @@ async function launch(paths, item, options) {
251
332
  ]);
252
333
  }
253
334
  async function listProjects(paths, json) {
254
- let projects;
255
- try {
256
- projects = projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
257
- }
258
- catch (error) {
259
- if (!(error instanceof NotRunningError))
260
- throw error;
261
- projects = withStore(paths, (store) => store.listProjects());
262
- }
335
+ const projects = await projectsNow(paths);
263
336
  if (json)
264
337
  return out(JSON.stringify(projects, null, 2));
265
338
  if (projects.length === 0)
@@ -269,62 +342,108 @@ async function listProjects(paths, json) {
269
342
  async function main(argv) {
270
343
  // `wait` has options of its own: its --version is a value, not this CLI's version.
271
344
  if (argv[0] === 'wait')
272
- return wait(argv.slice(1));
345
+ return argv.slice(1).some(isHelpFlag) ? out(commandHelp('wait')) : wait(argv.slice(1));
273
346
  // `revise` takes free text, which option parsing would cut at a word such as --force. Every word
274
347
  // after the item is the text, quoted or not: a cut-off instruction would be posted as Jason's.
275
348
  if (argv[0] === 'revise') {
349
+ // A lone help flag as the text is a call for help: posted, it would be a revision of "--help".
350
+ if (isHelpFlag(argv[1]) || (argv.length === 3 && isHelpFlag(argv[2])))
351
+ return out(commandHelp('revise'));
276
352
  const [ref, ...words] = argv.slice(1);
277
353
  const text = words.join(' ').trim();
278
354
  if (ref === undefined || ref.startsWith('-') || !text)
279
- throw new ConductorError('usage: conduct revise <item> "<what to change>"');
355
+ throw usageError('revise');
280
356
  return answer(conductorPaths(), ref, { action: 'revise', text });
281
357
  }
282
- const { positionals, values } = parseArgs({
358
+ // The same for a finding: free text after the id, with --ask as a word of its own anywhere.
359
+ if (argv[0] === 'escalations' && argv[1] === 'done') {
360
+ if (isHelpFlag(argv[2]) || (argv.length === 4 && isHelpFlag(argv[3])))
361
+ return out(commandHelp('escalations'));
362
+ const [ref, ...rest] = argv.slice(2);
363
+ const ask = rest.includes('--ask');
364
+ const text = rest.filter((word) => word !== '--ask').join(' ').trim();
365
+ if (ref === undefined || !text)
366
+ throw usageError('escalations');
367
+ return escalationDone(conductorPaths(), ref, { ask, text });
368
+ }
369
+ // The first word that names a command: an option's value (--project atk) comes before it at times.
370
+ const { positionals, values } = parsing(argv.find((word) => isCommand(word)), () => parseArgs({
283
371
  allowPositionals: true,
284
372
  args: argv,
285
373
  options: {
286
374
  'allow-downgrade': { type: 'boolean' },
287
375
  channel: { type: 'string' },
288
376
  conductor: { type: 'string' },
377
+ console: { type: 'boolean' },
289
378
  follow: { type: 'boolean' },
290
379
  force: { type: 'boolean' },
380
+ fresh: { type: 'boolean' },
291
381
  help: { short: 'h', type: 'boolean' },
382
+ here: { type: 'boolean' },
292
383
  json: { type: 'boolean' },
293
384
  kind: { type: 'string' },
385
+ owner: { type: 'string' },
386
+ prefer: { type: 'string' },
294
387
  probe: { type: 'boolean' },
295
388
  project: { type: 'string' },
296
389
  prompt: { type: 'string' },
297
390
  raise: { type: 'boolean' },
298
391
  remove: { type: 'boolean' },
299
392
  since: { type: 'string' },
393
+ slots: { type: 'string' },
300
394
  tag: { type: 'string' },
301
395
  text: { type: 'string' },
302
396
  until: { type: 'string' },
303
397
  version: { short: 'v', type: 'boolean' },
398
+ wait: { type: 'string' },
399
+ weight: { type: 'string' },
304
400
  worker: { type: 'string' },
305
401
  },
306
- });
402
+ }));
307
403
  const paths = conductorPaths();
308
404
  const [command, sub, arg] = positionals;
309
405
  if (values.version)
310
406
  return out(version());
311
- if (values.help || command === undefined || command === 'help')
312
- return out(USAGE);
407
+ if (command === undefined || command === 'help')
408
+ return out(sub === undefined ? listing() : helpOf(sub));
409
+ if (!isCommand(command))
410
+ throw unknownCommand(command);
411
+ if (values.help)
412
+ return out(commandHelp(command));
313
413
  switch (command) {
314
414
  case 'approve':
315
415
  if (sub === undefined)
316
- throw new ConductorError('usage: conduct approve <item> [--text <text>]');
416
+ throw usageError('approve');
317
417
  return answer(paths, sub, { action: 'approve', ...(values.text?.trim() ? { text: values.text.trim() } : {}) });
318
418
  case 'attach':
319
419
  if (sub === undefined)
320
- throw new ConductorError('usage: conduct attach <worker>');
420
+ throw usageError('attach');
321
421
  return attach(paths, sub);
422
+ case 'brief':
423
+ if (sub === undefined)
424
+ throw usageError('brief');
425
+ process.exitCode = await briefCommand(paths, sub, { fresh: values.fresh === true, json: values.json === true }, {
426
+ err: (line) => process.stderr.write(`${line}\n`),
427
+ out,
428
+ });
429
+ return;
430
+ case 'console':
431
+ return consoleCommand(paths, { here: values.here === true, project: values.project });
322
432
  case 'done':
323
433
  if (sub === undefined)
324
- throw new ConductorError('usage: conduct done <inbox id>');
434
+ throw usageError('done');
325
435
  return answer(paths, sub, { action: 'done' });
436
+ case 'escalations':
437
+ if (sub !== undefined)
438
+ throw usageError('escalations');
439
+ return escalations(paths, values.json === true);
326
440
  case 'events':
327
- return events(paths, values.follow === true, values.since);
441
+ // CONDUCTOR_CONSOLE as well: a console still running a mod from before --console (until
442
+ // /reload-plugins after an upgrade) starts this package's new build, in the console's env.
443
+ return events(paths, values.follow === true, values.since, values.console === true || process.env.CONDUCTOR_CONSOLE === '1');
444
+ case 'history':
445
+ case 'retro':
446
+ return history(paths, command, sub, { json: values.json === true, project: values.project, since: values.since, until: values.until });
328
447
  case 'inbox':
329
448
  return inbox(paths, values.json === true);
330
449
  case 'install-mod':
@@ -333,16 +452,25 @@ async function main(argv) {
333
452
  return values.remove ? uninstall() : install();
334
453
  case 'launch':
335
454
  if (sub === undefined)
336
- throw new ConductorError('usage: conduct launch <item> [--project <name>] [--probe]');
455
+ throw usageError('launch');
337
456
  return launch(paths, sub, {
338
457
  conductor: values.conductor,
339
458
  probe: values.probe,
340
459
  project: values.project,
341
460
  prompt: values.prompt,
342
461
  });
462
+ case 'lock': {
463
+ if (sub === undefined || arg === undefined)
464
+ throw usageError('lock');
465
+ const waitMinutes = values.wait === undefined ? undefined : Number(values.wait);
466
+ if (waitMinutes !== undefined && !(waitMinutes >= 0))
467
+ throw new ConductorError('--wait takes minutes, such as --wait 30');
468
+ process.exitCode = await lockCommand(paths, sub, arg, { owner: values.owner, waitMinutes }, brokerIo());
469
+ return;
470
+ }
343
471
  case 'note':
344
472
  if (sub === undefined || arg === undefined)
345
- throw new ConductorError('usage: conduct note <item> <file> [--worker <worker>]');
473
+ throw usageError('note');
346
474
  process.exitCode = await noteCommand(paths, sub, arg, { worker: values.worker }, {
347
475
  cwd: process.cwd(),
348
476
  err: (line) => process.stderr.write(`${line}\n`),
@@ -356,53 +484,85 @@ async function main(argv) {
356
484
  return notifyProfile(paths, arg, values.until);
357
485
  if (sub === 'test')
358
486
  return notifyTest(paths, { channel: values.channel, kind: values.kind, raise: values.raise === true });
359
- throw new ConductorError('usage: conduct notify | conduct notify profile [<name>] [--until <when>] | conduct notify test');
487
+ throw usageError('notify');
360
488
  case 'project':
361
489
  if (sub === 'add' && arg !== undefined)
362
490
  return addProject(paths, arg);
363
491
  if (sub === 'list')
364
492
  return listProjects(paths, values.json === true);
365
- throw new ConductorError('usage: conduct project add <path> | conduct project list');
493
+ throw usageError('project');
494
+ case 'focus':
495
+ if (sub === undefined || values.until === undefined)
496
+ throw usageError('focus');
497
+ return override(paths, {
498
+ action: 'focus',
499
+ project: sub,
500
+ ...(values.slots === undefined ? {} : { slots: wholeNumber('--slots', values.slots) }),
501
+ until: untilOf(values.until),
502
+ ...(values.weight === undefined ? {} : { weight: wholeNumber('--weight', values.weight) }),
503
+ });
504
+ case 'pause':
505
+ return override(paths, { action: 'pause', ...(sub === undefined ? {} : { project: sub }), ...(values.until === undefined ? {} : { until: untilOf(values.until) }) });
506
+ case 'ports':
507
+ process.exitCode = await portsCommand(paths, sub, { json: values.json === true, ...(values.prefer === undefined ? {} : { prefer: wholeNumber('--prefer', values.prefer) }) }, brokerIo());
508
+ return;
509
+ case 'queue':
510
+ return queue(paths, values.json === true, values.fresh === true);
511
+ case 'rules':
512
+ if (sub === undefined)
513
+ return rules(paths, values.json === true);
514
+ if (sub === 'test' && arg !== undefined)
515
+ return rulesTest(paths, arg, values.json === true);
516
+ throw usageError('rules');
366
517
  case 'retire':
367
518
  if (sub === undefined)
368
- throw new ConductorError('usage: conduct retire <worker> [--force]');
519
+ throw usageError('retire');
369
520
  return retire(paths, sub, values.force === true);
370
521
  case 'serve':
371
522
  return serve(paths);
523
+ case 'start':
524
+ return start(paths);
372
525
  case 'status':
373
526
  return status(paths, values.json === true);
374
527
  case 'stop':
375
528
  await stopDaemon(paths);
376
529
  return;
530
+ case 'unpause':
531
+ return override(paths, { action: 'unpause', ...(sub === undefined ? {} : { project: sub }) });
377
532
  case 'upgrade':
378
533
  return upgradeCommand(paths, values.tag, values['allow-downgrade'] === true);
379
- default:
380
- throw new ConductorError(`unknown command "${command}"`, ['run `conduct help` for the commands']);
534
+ case 'why':
535
+ if (sub === undefined)
536
+ throw usageError('why');
537
+ return why(paths, sub, values.json === true);
538
+ // Only when an option comes first: as the first word they parse their own arguments, above.
539
+ case 'revise':
540
+ case 'wait':
541
+ throw usageError(command, `conduct ${command} takes its arguments right after the command`);
542
+ default: {
543
+ const unhandled = command;
544
+ throw unknownCommand(String(unhandled));
545
+ }
381
546
  }
382
547
  }
383
548
  function modDeps() {
384
549
  return { claude: (args) => run('claude', args, { timeoutMs: 120_000 }), out };
385
550
  }
386
551
  function noInbox() {
387
- return new ConductorError('the running conductor has no inbox: it is older than this conduct', [
388
- 'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
389
- ]);
552
+ return olderDaemon('no inbox');
390
553
  }
391
554
  // GET /notify, or a ConductorError from a daemon older than the notifier.
392
555
  async function notifyAnswer(paths, request) {
393
556
  const answer = await callApi(paths, request);
394
- if (answer.status === 404) {
395
- throw new ConductorError('the running conductor has no notifier: it is older than this conduct', [
396
- 'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
397
- ]);
398
- }
557
+ if (answer.status === 404)
558
+ throw olderDaemon('no notifier');
399
559
  return expectOk(Promise.resolve(answer));
400
560
  }
401
561
  // `conduct notify profile`: the active profile, or switch to <name>.
402
562
  async function notifyProfile(paths, name, until) {
403
563
  if (name === undefined) {
404
564
  if (until !== undefined)
405
- throw new ConductorError('usage: conduct notify profile <name> --until <when>');
565
+ throw usageError('notify', '--until needs a profile: conduct notify profile <name> --until <when>');
406
566
  return notifyStatus(paths, false);
407
567
  }
408
568
  const body = { name, ...(until === undefined ? {} : { until: parseUntil(until, new Date()).toISOString() }) };
@@ -438,6 +598,11 @@ async function notifyTest(paths, options) {
438
598
  function npm(args, timeoutMs) {
439
599
  return run('npm', args, { shell: process.platform === 'win32', timeoutMs });
440
600
  }
601
+ function olderDaemon(what) {
602
+ return new ConductorError(`the running conductor has ${what}: it is older than this conduct`, [
603
+ 'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
604
+ ]);
605
+ }
441
606
  // The open inbox items, or null from a daemon older than the inbox (a linked build ahead of it).
442
607
  async function openItems(paths) {
443
608
  const answer = await callApi(paths, { method: 'GET', path: '/inbox' });
@@ -448,6 +613,39 @@ async function openItems(paths) {
448
613
  function out(line) {
449
614
  process.stdout.write(`${line}\n`);
450
615
  }
616
+ // `conduct pause|unpause|focus`: an override that ends on its own (unpause ends a pause).
617
+ async function override(paths, body) {
618
+ const answer = await callApi(paths, { body: { ...body, by: `conduct ${String(body.action)}` }, method: 'POST', path: '/overrides' });
619
+ if (answer.status === 404)
620
+ throw olderDaemon('no overrides');
621
+ const { overrides, set } = z.object({ overrides: z.array(overrideViewSchema), set: z.array(overrideViewSchema) }).parse(await expectOk(Promise.resolve(answer)));
622
+ const untilOfView = (o) => (o.until === null ? ' until you unpause' : ` until ${new Date(o.until).toLocaleString()}`);
623
+ for (const o of set) {
624
+ const who = o.project ?? 'every project';
625
+ if (o.kind === PAUSE)
626
+ out(o.value === 'on' ? `paused launches in ${who}${untilOfView(o)}` : `launches in ${who} are no longer paused`);
627
+ else
628
+ out(`${who}: ${o.kind.replace('focus.', '')} ${o.value}${untilOfView(o)}`);
629
+ }
630
+ // A pause of every project still holds a project unpaused on its own.
631
+ const all = overrides.find((o) => o.kind === PAUSE && o.project === null);
632
+ if (body.action === 'unpause' && body.project !== undefined && all)
633
+ out(`but all launches stay paused${untilOfView(all)}: conduct unpause ends that`);
634
+ }
635
+ // parseArgs' own errors (an unknown option, a missing value) with the command's usage, not every
636
+ // command's.
637
+ function parsing(command, parse) {
638
+ try {
639
+ return parse();
640
+ }
641
+ catch (error) {
642
+ if (!(error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS')))
643
+ throw error;
644
+ // Node's advice on `--` for a positional that starts with a dash is about the shell, not conduct.
645
+ const message = error.message.replace(/\. To specify a positional argument.*$/s, '');
646
+ throw isCommand(command) ? usageError(command, message) : new ConductorError(message, ['run `conduct help` for the commands']);
647
+ }
648
+ }
451
649
  // The open plan item of `ref`: the item (ATK-312) whose plan waits, or an inbox id written #12. A
452
650
  // bare number is refused: it could be an issue's number, and the answer is posted as Jason's.
453
651
  async function planItem(paths, ref) {
@@ -470,6 +668,27 @@ function projectLine(p) {
470
668
  const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
471
669
  return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
472
670
  }
671
+ // The registered projects: from the daemon, or from the store when it isn't running.
672
+ async function projectsNow(paths) {
673
+ try {
674
+ return projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
675
+ }
676
+ catch (error) {
677
+ if (!(error instanceof NotRunningError))
678
+ throw error;
679
+ return withStore(paths, (store) => store.listProjects());
680
+ }
681
+ }
682
+ async function queue(paths, json, fresh) {
683
+ // A fresh read asks every project's tracker, within the shared rate budget.
684
+ const answer = await callApi(paths, { method: 'GET', path: `/queue${fresh ? '?fresh=true' : ''}`, timeoutMs: 120_000 });
685
+ if (answer.status === 404)
686
+ throw olderDaemon('no scheduler');
687
+ const view = queueViewSchema.parse(await expectOk(Promise.resolve(answer)));
688
+ if (json)
689
+ return out(JSON.stringify(view, null, 2));
690
+ queueLines(view).forEach((line) => out(line));
691
+ }
473
692
  async function retire(paths, ref, force) {
474
693
  const worker = await findWorker(paths, ref);
475
694
  const { body, status } = await callApi(paths, {
@@ -497,23 +716,86 @@ async function retire(paths, ref, force) {
497
716
  out(`retired ${worker.name}`);
498
717
  steps.forEach((s) => out(` ${s}`));
499
718
  }
719
+ // `conduct rules`: from the daemon, or straight from the files when it isn't running.
720
+ async function rules(paths, json) {
721
+ const view = await rulesAnswer(paths, { method: 'GET', path: '/rules' }, (body) => rulesViewSchema.parse(body), (service) => service.view());
722
+ if (json)
723
+ return out(JSON.stringify(view, null, 2));
724
+ rulesLines(view).forEach((line) => out(line));
725
+ }
726
+ // The daemon's answer, or, when none runs, the same thing worked out here from the files.
727
+ async function rulesAnswer(paths, request, parse, local) {
728
+ let answer;
729
+ try {
730
+ answer = await callApi(paths, request);
731
+ }
732
+ catch (error) {
733
+ if (!(error instanceof NotRunningError))
734
+ throw error;
735
+ process.stderr.write('conduct: the conductor is not running; read from the files\n');
736
+ const config = loadConfig(paths.config);
737
+ return withStore(paths, (store) => {
738
+ const service = rulesService({ config, listProjects: () => store.listProjects(), paths });
739
+ service.reload();
740
+ return local(service);
741
+ });
742
+ }
743
+ if (answer.status === 404)
744
+ throw olderDaemon('no rules engine');
745
+ return parse(await expectOk(Promise.resolve(answer)));
746
+ }
747
+ async function rulesTest(paths, file, json) {
748
+ let text;
749
+ try {
750
+ text = readFileSync(file, 'utf8');
751
+ }
752
+ catch (error) {
753
+ throw new ConductorError(`could not read ${file}`, [String(error)]);
754
+ }
755
+ const sample = parseJsonc(text, z.unknown(), file);
756
+ const result = await rulesAnswer(paths, { body: sample, method: 'POST', path: '/rules/test' }, (body) => rulesTestResultSchema.parse(body), (service) => service.test(sample));
757
+ if (json)
758
+ return out(JSON.stringify(result, null, 2));
759
+ testLines(result).forEach((line) => out(line));
760
+ }
500
761
  async function serve(paths) {
501
762
  const daemon = await startDaemon(paths, { version: version() });
502
763
  out(`conductor listening on 127.0.0.1:${daemon.port} (pid ${process.pid}, home ${paths.home})`);
503
764
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
504
765
  process.once(signal, () => void daemon.stop(signal));
505
766
  }
506
- // Under the logon task nobody sees stderr, so a crash goes to the log. The pid file it leaves
507
- // names a dead process, which the next start replaces.
508
- process.on('uncaughtException', (error) => {
509
- daemon.log.log('error', 'uncaught exception', { error: error.stack ?? String(error) });
510
- process.exit(1);
511
- });
767
+ logCrashes(daemon.log);
512
768
  await daemon.closed;
513
769
  }
514
770
  function sleep(ms) {
515
771
  return new Promise((done) => setTimeout(done, ms));
516
772
  }
773
+ // `conduct start`: the daemon, detached from this process, so it outlives the shell or the Claude
774
+ // session that started it (the console's start runs this). Nothing when one answers already.
775
+ async function start(paths) {
776
+ const pid = readPid(paths.pid);
777
+ if (pid !== null && (await ping(paths, pid)))
778
+ return out(`the conductor is running (pid ${pid})`);
779
+ // A daemon that is still starting (the logon task's, say) holds the lock: wait for it rather than
780
+ // start a second one, which would exit on that lock. A pid a reboot reused gets a start after.
781
+ if (pid !== null && processAlive(pid)) {
782
+ try {
783
+ return out(`the conductor is running (pid ${await awaitDaemon(paths)})`);
784
+ }
785
+ catch {
786
+ // Not a daemon that answers: start one, which replaces a stale lock (or says who holds it).
787
+ }
788
+ }
789
+ try {
790
+ out(`started the conductor ${await restartDaemon(daemonStarter(paths, realpathSync(fileURLToPath(import.meta.url))))}`);
791
+ }
792
+ catch (error) {
793
+ const holder = readPid(paths.pid);
794
+ if (error instanceof ServeStartError || holder === null || !processAlive(holder))
795
+ throw error;
796
+ throw new ConductorError(`process ${holder} holds the conductor's lock (${paths.pid}) but doesn't answer`, ['if it is a hung conductor, stop it, then run conduct start again', `see ${paths.log}`]);
797
+ }
798
+ }
517
799
  async function status(paths, json) {
518
800
  let projects;
519
801
  try {
@@ -522,23 +804,32 @@ async function status(paths, json) {
522
804
  catch (error) {
523
805
  if (!(json && error instanceof NotRunningError))
524
806
  throw error;
525
- // Scripts and the conductor mod read the answer, so "not running" is JSON too (exit 1).
526
- out(JSON.stringify({ home: paths.home, running: false }, null, 2));
807
+ // Scripts and the conductor mod read the answer, so "not running" is JSON too (exit 1), with the
808
+ // store's workers while the daemon restarts or upgrades.
809
+ const workers = storedFleet(paths.db, localDay(new Date()));
810
+ out(JSON.stringify({ home: paths.home, running: false, ...(workers ? { workers, workersFrom: 'store' } : {}) }, null, 2));
527
811
  process.exitCode = 1;
528
812
  return;
529
813
  }
530
814
  const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
531
815
  // A linked build's CLI can be newer than the daemon still running, which has no inbox (null).
532
816
  const items = await openItems(paths);
817
+ // Null from a daemon older than the broker.
818
+ const locks = await heldLocks(paths);
533
819
  const pid = readPid(paths.pid);
534
820
  const port = readEndpoint(paths)?.port ?? null;
535
821
  if (json)
536
- return out(JSON.stringify({ home: paths.home, inbox: items, pid, port, projects, running: true, workers }, null, 2));
822
+ return out(JSON.stringify({ home: paths.home, inbox: items, locks, pid, port, projects, running: true, workers, workersFrom: 'daemon' }, null, 2));
537
823
  out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
538
824
  out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
539
825
  projects.forEach((p) => out(projectLine(p)));
540
826
  out(workers.length === 0 ? 'no workers' : 'workers:');
541
827
  workers.forEach((w) => out(workerLine(w)));
828
+ if (locks === null)
829
+ out('locks: not in this daemon (restart it to run this build)');
830
+ else
831
+ out(locks.length === 0 ? 'locks: none held' : 'locks:');
832
+ locks?.forEach((l) => out(lockLine(l)));
542
833
  if (items === null)
543
834
  out('inbox: not in this daemon (restart it to run this build)');
544
835
  else
@@ -572,10 +863,14 @@ async function stopDaemon(paths) {
572
863
  function uninstall() {
573
864
  out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
574
865
  }
866
+ // --until: 17:00 (the next one), tomorrow, a day such as monday, or a date and time.
867
+ function untilOf(text) {
868
+ return resolveUntil({ kind: 'at', text }, new Date()).toISOString();
869
+ }
575
870
  async function upgradeCommand(paths, tag, allowDowngrade) {
576
871
  const cliPath = realpathSync(fileURLToPath(import.meta.url));
577
872
  await upgrade({ allowDowngrade, tag: parseTag(tag) }, {
578
- awaitDaemon: () => awaitDaemon(paths),
873
+ ...daemonStarter(paths, cliPath),
579
874
  cliPath,
580
875
  installedVersion: version,
581
876
  npmInstall: (spec) => npm(['install', '--global', spec], NPM_INSTALL_TIMEOUT_MS),
@@ -593,15 +888,7 @@ async function upgradeCommand(paths, tag, allowDowngrade) {
593
888
  return found;
594
889
  },
595
890
  out,
596
- runTask: runStartup,
597
- // A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
598
- // caller's directory, which it would hold open (a worktree it may later remove, say).
599
- serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
600
- .on('error', () => { })
601
- .unref(),
602
891
  stopDaemon: () => stopDaemon(paths),
603
- // The logon task's daemon uses ~/.conductor, so it can't restart one kept in CONDUCTOR_HOME.
604
- taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
605
892
  });
606
893
  }
607
894
  // Read from disk each time: upgrade asks again after npm has replaced the package.
@@ -610,7 +897,7 @@ function version() {
610
897
  return z.object({ version: z.string() }).parse(JSON.parse(readFileSync(file, 'utf8'))).version;
611
898
  }
612
899
  async function wait(argv) {
613
- const { positionals, values } = parseArgs({
900
+ const { positionals, values } = parsing('wait', () => parseArgs({
614
901
  allowPositionals: true,
615
902
  args: argv,
616
903
  options: {
@@ -624,17 +911,39 @@ async function wait(argv) {
624
911
  version: { type: 'string' },
625
912
  worker: { type: 'string' },
626
913
  },
627
- });
914
+ }));
628
915
  const timeout = values.timeout === undefined ? undefined : Number(values.timeout);
629
916
  if (timeout !== undefined && !(timeout > 0))
630
917
  throw new ConductorError('--timeout takes minutes, such as --timeout 25');
631
918
  if (positionals.length === 0)
632
- throw new ConductorError('usage: conduct wait <condition>', ['run `conduct help` for the conditions']);
919
+ throw usageError('wait');
633
920
  process.exitCode = await waitCommand(conductorPaths(), positionals, { ...values, timeoutMinutes: timeout }, { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out });
634
921
  }
635
- function withStore(paths, use) {
922
+ function wholeNumber(flag, text) {
923
+ if (!/^-?\d+$/.test(text))
924
+ throw new ConductorError(`${flag} takes a whole number, not "${text}"`);
925
+ return Number(text);
926
+ }
927
+ // `conduct why <event id>`: the rule behind it and its causes. `conduct why <item>`: the same for
928
+ // each of the item's newest actions.
929
+ async function why(paths, ref, json) {
930
+ const byId = /^#?(\d+)$/.exec(ref)?.[1];
931
+ const answer = await callApi(paths, { method: 'GET', path: byId === undefined ? `/why?item=${encodeURIComponent(ref)}` : `/why/${byId}` });
932
+ if (answer.status === 404 && byId !== undefined) {
933
+ const body = errorBody.safeParse(answer.body);
934
+ throw body.success && body.data.error === 'not_found' ? new ConductorError(`no event ${byId}`) : olderDaemon('no conduct why');
935
+ }
936
+ const body = await expectOk(Promise.resolve(answer));
937
+ const whys = byId === undefined ? z.object({ items: z.array(whySchema) }).parse(body).items : [whySchema.parse(body)];
938
+ if (json)
939
+ return out(JSON.stringify(byId === undefined ? whys : whys[0], null, 2));
940
+ if (whys.length === 0)
941
+ return out(`no action on ${ref} was caused by a rule`);
942
+ whys.forEach((w, i) => [...(i > 0 ? [''] : []), ...whyLines(w)].forEach((line) => out(line)));
943
+ }
944
+ function withStore(paths, use, options = {}) {
636
945
  mkdirSync(paths.home, { recursive: true });
637
- const store = openStore(paths.db);
946
+ const store = openStore(paths.db, undefined, options);
638
947
  try {
639
948
  return use(store);
640
949
  }
@@ -657,9 +966,6 @@ main(process.argv.slice(2)).catch((error) => {
657
966
  if (error instanceof ConductorError) {
658
967
  process.stderr.write(`conduct: ${error.message}\n${error.details.map((d) => ` ${d}\n`).join('')}`);
659
968
  }
660
- else if (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS')) {
661
- process.stderr.write(`conduct: ${error.message}\n${USAGE}\n`);
662
- }
663
969
  else {
664
970
  process.stderr.write(`conduct: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`);
665
971
  }