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

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 +170 -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 +94 -16
  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 +50 -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/cli.js CHANGED
@@ -5,29 +5,93 @@ 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 { heldLocks, lockCommand, lockLine, portsCommand } from "./broker-cli.js";
8
9
  import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
9
- import { startDaemon } from "./daemon.js";
10
+ import { loadConfig } from "./config.js";
11
+ import { CONSOLE_ARGS, CONSOLE_WINDOW, consoleProject, consoleTabArgs, escalationLines, escalationSchema, handledSchema } from "./console.js";
12
+ import { logCrashes, startDaemon } from "./daemon.js";
10
13
  import { ConductorError } from "./errors.js";
14
+ import { eventsCommand } from "./events.js";
15
+ import { flagWindow, historyLines, historyReport, historySchema, parseRange, retroMarkdown } from "./history.js";
16
+ import { inboxLines } from "./inbox.js";
17
+ import { parseJsonc } from "./jsonc.js";
11
18
  import { processAlive, readPid } from "./lock.js";
12
19
  import { fleetWorkerSchema } from "./manager.js";
20
+ import { installMod, removeMod } from "./mod.js";
21
+ import { noteCommand } from "./note.js";
22
+ import { notifyLines, notifyStatusSchema, parseUntil, testResultSchema } from "./notify.js";
23
+ import { overrideViewSchema, PAUSE } from "./overrides.js";
13
24
  import { conductorPaths } from "./paths.js";
14
25
  import { registerProject } from "./registry.js";
26
+ import { rulesLines, rulesService, rulesTestResultSchema, rulesViewSchema, testLines } from "./rules-service.js";
27
+ import { resolveUntil } from "./rules.js";
15
28
  import { outputOf, run } from "./run.js";
29
+ import { queueLines, queueViewSchema } from "./scheduler.js";
30
+ import { formatTokens } from "./spend.js";
16
31
  import { installStartup, removeStartup, runStartup, startupRunsCli, TASK_NAME } from "./startup.js";
17
- import { openStore, projectSchema, workerSchema } from "./store.js";
18
- import { parseTag, upgrade } from "./upgrade.js";
32
+ import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema } from "./store.js";
33
+ import { parseTag, restartDaemon, upgrade } from "./upgrade.js";
19
34
  import { waitCommand } from "./wait.js";
35
+ import { whyLines, whySchema } from "./why.js";
20
36
  const USAGE = `Usage: conduct <command>
21
37
 
22
38
  serve run the daemon in this process (one per machine)
39
+ start start the daemon detached (the logon task, else a detached serve) and
40
+ wait until it answers; nothing when it runs already
23
41
  stop stop the running daemon
24
- status [--json] the daemon's pid, port, projects and workers
42
+ status [--json] the daemon's pid, port, projects, workers and held locks
25
43
  project add <path> register the project whose checkout holds .claude/conductor.json
26
44
  project list [--json] the registered projects
27
45
  launch <item> start a worker: its worktree, a background session, a tab
28
46
  [--project <name>] [--probe] [--prompt <text>] [--conductor <name>]
29
47
  retire <worker> [--force] stop a worker, remove its worktree and session; refuses unfinished work
30
48
  attach <worker> wake a parked worker (no event text) and open it in a tab
49
+ inbox [--json] what waits on you: each open item with its commands and links
50
+ approve <item> [--text <text>]
51
+ approve a plan: post "approve" on the item as you, wake its worker;
52
+ <item> is ATK-312, or the inbox item's id as #12
53
+ revise <item> <text> ask for a revised plan: post "revise: <text>", wake its worker
54
+ done <id> mark an inbox item done (a physical action, or one you handled)
55
+ notify [--json] the notification profile, quiet hours, and each channel's state
56
+ notify profile [<name>] [--until <when>]
57
+ switch the profile now (built in: default, away, focus); --until 17:00 or
58
+ a date and time hands back to the one before it then
59
+ notify test [--kind <kind>] [--channel remote-control|pushover] [--raise]
60
+ push a sample item (default npm-otp) on the profile's channels now;
61
+ --raise puts a real item in the inbox instead (quiet hours, reminders)
62
+ queue [--json] [--fresh] what the scheduler picks next and why the rest wait (--fresh: read now)
63
+ pause [<project>] [--until <when>]
64
+ no new launches in the project (every project without one), until
65
+ 17:00, tomorrow, monday or a date and time; unpause ends it
66
+ unpause [<project>] launch again
67
+ focus <project> [--slots <n>] [--weight <n>] --until <when>
68
+ the project's slots or ordering weight, for a while; with neither,
69
+ its items go first (weight 100)
70
+ why <event id | item> [--json]
71
+ the rule behind an action, and the events that led to it
72
+ console [--project <name>] [--here]
73
+ open the console (the Claude session the conductor mod serves) in a
74
+ Windows Terminal tab in the project's main checkout; --here runs it in
75
+ this terminal. Also how to get it back after it closed
76
+ escalations [--json] what rules escalated to the console and it hasn't handled (14 days)
77
+ escalations done <id> [--ask] <text>
78
+ the console's finding on escalation #<id>, logged; --ask also puts it
79
+ in the inbox for you (a "console" item)
80
+ history [<range>] [--since <when>] [--until <when>] [--project <name>] [--json]
81
+ what a window of the log says: shipped items, time in each phase by tier,
82
+ plan latency, waits by kind, stalls, failures, inbox, slot use, rules, spend
83
+ retro [<range>] [--project <name>]
84
+ the same as a Markdown report. A <range> is today, yesterday, a date
85
+ (2026-10-10), A..B (2026-10-10..2026-10-11), or a span (24h, the default; 7d)
86
+ rules [--json] the rules in force (project, machine, built-in defaults), what replaces
87
+ what, and any rules file refused; machine rules are ~/.conductor/rules.json
88
+ rules test <file> [--json]
89
+ dry-run the rules on a sample, a JSON file such as
90
+ { "event": "pr.comment", "project": "atk", "fields": { "wait.kind": "merge" } }
91
+ (no event: a state rule's subject); acts on nothing
92
+ note <item> <file> post the file as a phase note on the item ("## <work skill> · <Phase>"), and
93
+ record its phase on the worker (this checkout's, or --worker <worker>);
94
+ exit 3 when nobody posted it (no conductor, or the tracker refused it)
31
95
  wait <condition> wait for an outside event: in a worker's worktree with the daemon
32
96
  running, register it and end the turn (the daemon resumes the session);
33
97
  otherwise check it here and block (exit 0 when it holds, 2 at --timeout)
@@ -37,13 +101,32 @@ const USAGE = `Usage: conduct <command>
37
101
  health <env> --version <x> | --commit <sha>
38
102
  approval [<item>] an approve / revise: comment after the item's newest Plan note
39
103
  item <item> <state> | item <item> label|note <value>
40
- time <when> such as 2026-10-10T09:00 (local)
104
+ time <when> such as 17:00 (the next one) or 2026-10-10T09:00 (local)
41
105
  <full form> such as pr:ado:Emergent.AgenticToolkit#1850:merged
42
106
  [--local] [--timeout <minutes, local only; 29>] [--worker <worker>] [--item <item>]
107
+ ports [<path>] [--prefer <slot>] [--json]
108
+ the checkout's dev ports from the port broker: its project's base ports
109
+ + its slot, which no other project's worktree shares (a main checkout:
110
+ slot 0); --prefer asks for a slot first. Without <path>, every worktree's.
111
+ Exit 3 when no conductor brokered it, so the caller keeps its own ports
112
+ lock acquire|release|status <name> [--owner <id>] [--wait <minutes>]
113
+ a lock every project shares, such as chrome for verify runs; stale 30 min
114
+ after its holder last acquired it (acquire again to keep it). The owner is
115
+ this checkout's worker's item unless given; --wait polls. Prints JSON;
116
+ exit 3 when no conductor answers
117
+ events [--since <id>] the event log after <id> (all of it without), one JSON event per line
118
+ events --follow [--since <id>]
119
+ keep following it (live without --since), reconnecting when the
120
+ conductor restarts; state lines conduct.connected / conduct.disconnected
43
121
  install-startup start the daemon at logon (Windows Task Scheduler)
44
122
  install-startup --remove remove that logon task
123
+ install-mod install the conductor mod in Claude Code (user scope) from this package:
124
+ the status line in every session, the inbox band, pane and commands in
125
+ the console (a session started with CONDUCTOR_CONSOLE=1)
126
+ install-mod --remove uninstall it
45
127
  upgrade [--tag dev|latest] stop the daemon, npm install the newest version on the tag (default:
46
128
  the tag the installed version came from), start the daemon again
129
+ [--allow-downgrade] install the tag's version even when it is older than the installed one
47
130
 
48
131
  A <worker> is its item (ATK-309) or its name (probe-ATK-309).
49
132
  Files live in ~/.conductor (CONDUCTOR_HOME overrides it).`;
@@ -58,6 +141,8 @@ const LAUNCH_WAIT_MS = 15 * 60_000;
58
141
  // attach waits out the settle time (up to 5 min), resumes (120 s) and opens the tab (60 s).
59
142
  const RETIRE_TIMEOUT_MS = 12 * 60_000;
60
143
  const ATTACH_TIMEOUT_MS = 9 * 60_000;
144
+ // Approve and revise ask Linear twice (the issue, then the comment), each within 20 s.
145
+ const ANSWER_TIMEOUT_MS = 60_000;
61
146
  const projectsBody = z.object({ projects: z.array(projectSchema) });
62
147
  const addBody = z.object({ created: z.boolean(), project: projectSchema });
63
148
  const errorBody = z.object({ details: z.array(z.string()).optional(), error: z.string() });
@@ -77,6 +162,13 @@ const retireBody = z.object({
77
162
  worker: workerSchema,
78
163
  });
79
164
  const attachBody = z.object({ resumed: z.boolean(), tab: z.string().nullable(), worker: workerSchema });
165
+ const inboxBody = z.object({ items: z.array(inboxItemSchema) });
166
+ const answerBody = z.object({
167
+ comment: z.object({ id: z.string(), url: z.string().nullable() }).nullable(),
168
+ item: inboxItemSchema,
169
+ waits: z.array(waitSchema),
170
+ });
171
+ const answerRefusal = z.object({ details: z.array(z.string()).optional(), error: z.string(), posted: z.enum(['no', 'unknown']).optional() });
80
172
  async function addProject(paths, path) {
81
173
  const absolute = resolve(path);
82
174
  let result;
@@ -87,10 +179,38 @@ async function addProject(paths, path) {
87
179
  if (!(error instanceof NotRunningError))
88
180
  throw error;
89
181
  // No daemon: write the store directly. The daemon reads it when it starts.
90
- result = withStore(paths, (store) => registerProject(store, absolute));
182
+ // Its rules are checked against config.json here too, as the daemon would.
183
+ const check = rulesService({ config: loadConfig(paths.config), listProjects: () => [], paths }).checkProfile;
184
+ result = withStore(paths, (store) => registerProject(store, absolute, check));
91
185
  }
92
186
  out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
93
187
  }
188
+ // `conduct approve|revise|done`: Jason's answer to an inbox item, by id, or for approve and revise
189
+ // by the item whose plan waits.
190
+ async function answer(paths, ref, request) {
191
+ const target = request.action === 'done' ? inboxId(ref) : await planItem(paths, ref);
192
+ const { body, status } = await callApi(paths, { body: request, method: 'POST', path: `/inbox/${target}/resolve`, timeoutMs: ANSWER_TIMEOUT_MS });
193
+ if (status !== 200) {
194
+ const refusal = answerRefusal.safeParse(body).data;
195
+ const details = [...(refusal?.details ?? [])];
196
+ if (status === 502 && refusal?.posted === 'unknown')
197
+ details.push('the comment may be on the item already: look before answering again');
198
+ if (status === 404) {
199
+ if ((await openItems(paths)) === null)
200
+ throw noInbox();
201
+ throw new ConductorError(`no inbox item #${target}`, ['`conduct inbox` lists the open ones']);
202
+ }
203
+ throw new ConductorError(refusal?.error ?? `the API answered ${status}`, details);
204
+ }
205
+ const result = answerBody.parse(body);
206
+ const { item } = result;
207
+ if (request.action === 'done')
208
+ return out(`done: #${item.id} (${item.kind}${item.item ? ` ${item.item}` : ''})`);
209
+ out(`${request.action === 'approve' ? 'approved' : 'asked for a revision of'} ${item.item ?? `#${item.id}`}: ${result.comment?.url ?? result.comment?.id ?? 'comment posted'}`);
210
+ out(result.waits.length > 0
211
+ ? `its worker's approval wait resolved; the conductor resumes it`
212
+ : 'no worker waits on its approval here: the worker reads the comment when it next looks');
213
+ }
94
214
  async function attach(paths, ref) {
95
215
  const worker = await findWorker(paths, ref);
96
216
  const result = attachBody.parse(await expectOk(callApi(paths, { method: 'POST', path: `/workers/${worker.id}/attach`, timeoutMs: ATTACH_TIMEOUT_MS })));
@@ -109,6 +229,83 @@ async function awaitDaemon(paths) {
109
229
  }
110
230
  throw new ConductorError(`the conductor did not answer within ${START_WAIT_MS / 1000} s`, [`see ${paths.log}`]);
111
231
  }
232
+ // What `conduct ports` and `conduct lock` print to and run in.
233
+ function brokerIo() {
234
+ return { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out };
235
+ }
236
+ // `conduct console`: the console (design note, section 11), the Claude session the conductor mod
237
+ // serves, started with CONDUCTOR_CONSOLE=1 in the project's main checkout, where the console skill
238
+ // is installed. It keeps no state of its own, so a new one after a close loses nothing.
239
+ async function consoleCommand(paths, options) {
240
+ const project = consoleProject(await projectsNow(paths), options.project);
241
+ if (options.here) {
242
+ const child = spawn('claude', CONSOLE_ARGS, { cwd: project.root, env: { ...process.env, CONDUCTOR_CONSOLE: '1' }, stdio: 'inherit' });
243
+ // Ctrl+C belongs to the session; this process only waits for it to end.
244
+ process.on('SIGINT', () => { });
245
+ process.exitCode = await new Promise((done, fail) => {
246
+ child.on('error', (error) => fail(new ConductorError('could not start claude', [error.message])));
247
+ child.on('exit', (code) => done(code ?? 1));
248
+ });
249
+ return;
250
+ }
251
+ if (process.platform !== 'win32') {
252
+ throw new ConductorError('conduct console opens a Windows Terminal tab', ['run `conduct console --here` in a terminal of your own']);
253
+ }
254
+ // A tab starts with Windows Terminal's environment, not this one's.
255
+ if (process.env.CONDUCTOR_HOME) {
256
+ throw new ConductorError('a console tab would not see CONDUCTOR_HOME', ['run `conduct console --here` in a terminal where it is set']);
257
+ }
258
+ const args = consoleTabArgs(project, process.execPath, realpathSync(fileURLToPath(import.meta.url)));
259
+ const result = await run('wt.exe', args, { cwd: project.root, timeoutMs: 60_000 });
260
+ if (result.code !== 0)
261
+ throw new ConductorError('could not open the console tab', [outputOf(result, 500), 'run `conduct console --here` in a terminal instead']);
262
+ out(`opened the console in ${project.root} (Windows Terminal window "${CONSOLE_WINDOW}")`);
263
+ }
264
+ // How `conduct start` and upgrade's restart start the daemon detached from this process.
265
+ function daemonStarter(paths, cliPath) {
266
+ return {
267
+ awaitDaemon: () => awaitDaemon(paths),
268
+ runTask: runStartup,
269
+ // A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
270
+ // caller's directory, which it would hold open (a worktree it may later remove, say).
271
+ serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
272
+ .on('error', () => { })
273
+ .unref(),
274
+ // The logon task's daemon uses ~/.conductor, so it can't start one kept in CONDUCTOR_HOME.
275
+ taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
276
+ };
277
+ }
278
+ // `conduct escalations done`: the console reports back on an escalation.
279
+ async function escalationDone(paths, ref, finding) {
280
+ const id = /^#?(\d+)$/.exec(ref)?.[1];
281
+ if (id === undefined)
282
+ throw new ConductorError(`"${ref}" is not an escalation id`, ['`conduct escalations` lists them as #<id>']);
283
+ const answer = await callApi(paths, { body: finding, method: 'POST', path: `/console/escalations/${id}` });
284
+ if (answer.status === 404 && !errorBody.safeParse(answer.body).data?.details)
285
+ throw olderDaemon('no console escalations');
286
+ const { escalation, event, inbox: raised } = handledSchema.parse(await expectOk(Promise.resolve(answer)));
287
+ out(`handled escalation #${escalation.id}${escalation.item ? ` on ${escalation.item}` : ''} (event #${event})`);
288
+ if (raised)
289
+ out(`and raised inbox item #${raised.id} (console) for you`);
290
+ }
291
+ async function escalations(paths, json) {
292
+ const answer = await callApi(paths, { method: 'GET', path: '/console/escalations' });
293
+ if (answer.status === 404)
294
+ throw olderDaemon('no console escalations');
295
+ const { escalations: open } = z.object({ escalations: z.array(escalationSchema) }).parse(await expectOk(Promise.resolve(answer)));
296
+ if (json)
297
+ return out(JSON.stringify(open, null, 2));
298
+ escalationLines(open, new Date()).forEach((line) => out(line));
299
+ }
300
+ // `conduct events`: until Ctrl+C with --follow.
301
+ async function events(paths, follow, since) {
302
+ if (since !== undefined && !/^\d+$/.test(since))
303
+ throw new ConductorError('--since takes an event id, such as --since 120');
304
+ const stop = new AbortController();
305
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK'])
306
+ process.once(signal, () => stop.abort());
307
+ await eventsCommand(paths, { follow, since: since === undefined ? undefined : Number(since) }, { out, signal: stop.signal });
308
+ }
112
309
  async function expectOk(call) {
113
310
  const { body, status } = await call;
114
311
  if (status >= 200 && status < 300)
@@ -127,13 +324,63 @@ async function findWorker(paths, ref) {
127
324
  throw new ConductorError(`no worker "${ref}" in flight`, ['`conduct status` lists them']);
128
325
  throw new ConductorError(`"${ref}" names several workers`, matches.map((w) => `${w.name} in ${w.project}`));
129
326
  }
130
- // 1234 → 1.2k, 3_400_000 → 3.4M.
131
- function formatTokens(tokens) {
132
- if (tokens >= 1_000_000)
133
- return `${(tokens / 1_000_000).toFixed(1)}M`;
134
- if (tokens >= 1_000)
135
- return `${(tokens / 1_000).toFixed(1)}k`;
136
- return String(tokens);
327
+ // `conduct history` and `retro`: from the daemon, or straight from conductor.db when none runs or
328
+ // it is older than /history. Reading needs no daemon: the report is a query over the store.
329
+ async function history(paths, command, range, options) {
330
+ const flags = options.since !== undefined || options.until !== undefined;
331
+ if (flags && range !== undefined)
332
+ throw new ConductorError(`conduct ${command} takes a <range> or --since/--until, not both`);
333
+ const now = new Date();
334
+ const window = flags ? flagWindow(options.since, options.until, now) : parseRange(range, now);
335
+ const request = { project: options.project ?? null, since: window.since.toISOString(), until: window.until.toISOString() };
336
+ // Read-only: a daemon older than this build may be running, and a migration would strand it.
337
+ const local = () => {
338
+ try {
339
+ return withStore(paths, (store) => historyReport(store, request), { readOnly: true });
340
+ }
341
+ catch (error) {
342
+ if (error instanceof ConductorError)
343
+ throw error;
344
+ throw new ConductorError(`could not read ${paths.db}`, [
345
+ error instanceof Error ? error.message : String(error),
346
+ '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',
347
+ ]);
348
+ }
349
+ };
350
+ let report;
351
+ try {
352
+ const query = new URLSearchParams({ since: request.since, until: request.until, ...(options.project ? { project: options.project } : {}) });
353
+ const answer = await callApi(paths, { method: 'GET', path: `/history?${query.toString()}`, timeoutMs: 60_000 });
354
+ report = answer.status === 404 ? local() : historySchema.parse(await expectOk(Promise.resolve(answer)));
355
+ }
356
+ catch (error) {
357
+ if (!(error instanceof NotRunningError))
358
+ throw error;
359
+ report = local();
360
+ }
361
+ if (options.json)
362
+ return out(JSON.stringify(report, null, 2));
363
+ if (command === 'retro')
364
+ process.stdout.write(retroMarkdown(report));
365
+ else
366
+ historyLines(report).forEach((line) => out(line));
367
+ }
368
+ async function inbox(paths, json) {
369
+ const items = await openItems(paths);
370
+ if (items === null)
371
+ throw noInbox();
372
+ if (json)
373
+ return out(JSON.stringify(items, null, 2));
374
+ if (items.length === 0)
375
+ return out('the inbox is empty');
376
+ inboxLines(items, new Date()).forEach((line) => out(line));
377
+ }
378
+ // An inbox id as `conduct inbox` prints it (#12), or bare (12).
379
+ function inboxId(ref) {
380
+ const id = /^#?(\d+)$/.exec(ref)?.[1];
381
+ if (id === undefined)
382
+ throw new ConductorError(`"${ref}" is not an inbox item id`, ['`conduct inbox` lists them as #<id>']);
383
+ return Number(id);
137
384
  }
138
385
  function install() {
139
386
  const cliPath = realpathSync(fileURLToPath(import.meta.url));
@@ -167,15 +414,7 @@ async function launch(paths, item, options) {
167
414
  ]);
168
415
  }
169
416
  async function listProjects(paths, json) {
170
- let projects;
171
- try {
172
- projects = projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
173
- }
174
- catch (error) {
175
- if (!(error instanceof NotRunningError))
176
- throw error;
177
- projects = withStore(paths, (store) => store.listProjects());
178
- }
417
+ const projects = await projectsNow(paths);
179
418
  if (json)
180
419
  return out(JSON.stringify(projects, null, 2));
181
420
  if (projects.length === 0)
@@ -186,20 +425,54 @@ async function main(argv) {
186
425
  // `wait` has options of its own: its --version is a value, not this CLI's version.
187
426
  if (argv[0] === 'wait')
188
427
  return wait(argv.slice(1));
428
+ // `revise` takes free text, which option parsing would cut at a word such as --force. Every word
429
+ // after the item is the text, quoted or not: a cut-off instruction would be posted as Jason's.
430
+ if (argv[0] === 'revise') {
431
+ const [ref, ...words] = argv.slice(1);
432
+ const text = words.join(' ').trim();
433
+ if (ref === undefined || ref.startsWith('-') || !text)
434
+ throw new ConductorError('usage: conduct revise <item> "<what to change>"');
435
+ return answer(conductorPaths(), ref, { action: 'revise', text });
436
+ }
437
+ // The same for a finding: free text after the id, with --ask as a word of its own anywhere.
438
+ if (argv[0] === 'escalations' && argv[1] === 'done') {
439
+ const [ref, ...rest] = argv.slice(2);
440
+ const ask = rest.includes('--ask');
441
+ const text = rest.filter((word) => word !== '--ask').join(' ').trim();
442
+ if (ref === undefined || !text)
443
+ throw new ConductorError('usage: conduct escalations done <id> [--ask] "<what you found or did>"');
444
+ return escalationDone(conductorPaths(), ref, { ask, text });
445
+ }
189
446
  const { positionals, values } = parseArgs({
190
447
  allowPositionals: true,
191
448
  args: argv,
192
449
  options: {
450
+ 'allow-downgrade': { type: 'boolean' },
451
+ channel: { type: 'string' },
193
452
  conductor: { type: 'string' },
453
+ follow: { type: 'boolean' },
194
454
  force: { type: 'boolean' },
455
+ fresh: { type: 'boolean' },
195
456
  help: { short: 'h', type: 'boolean' },
457
+ here: { type: 'boolean' },
196
458
  json: { type: 'boolean' },
459
+ kind: { type: 'string' },
460
+ owner: { type: 'string' },
461
+ prefer: { type: 'string' },
197
462
  probe: { type: 'boolean' },
198
463
  project: { type: 'string' },
199
464
  prompt: { type: 'string' },
465
+ raise: { type: 'boolean' },
200
466
  remove: { type: 'boolean' },
467
+ since: { type: 'string' },
468
+ slots: { type: 'string' },
201
469
  tag: { type: 'string' },
470
+ text: { type: 'string' },
471
+ until: { type: 'string' },
202
472
  version: { short: 'v', type: 'boolean' },
473
+ wait: { type: 'string' },
474
+ weight: { type: 'string' },
475
+ worker: { type: 'string' },
203
476
  },
204
477
  });
205
478
  const paths = conductorPaths();
@@ -209,10 +482,33 @@ async function main(argv) {
209
482
  if (values.help || command === undefined || command === 'help')
210
483
  return out(USAGE);
211
484
  switch (command) {
485
+ case 'approve':
486
+ if (sub === undefined)
487
+ throw new ConductorError('usage: conduct approve <item> [--text <text>]');
488
+ return answer(paths, sub, { action: 'approve', ...(values.text?.trim() ? { text: values.text.trim() } : {}) });
212
489
  case 'attach':
213
490
  if (sub === undefined)
214
491
  throw new ConductorError('usage: conduct attach <worker>');
215
492
  return attach(paths, sub);
493
+ case 'console':
494
+ return consoleCommand(paths, { here: values.here === true, project: values.project });
495
+ case 'done':
496
+ if (sub === undefined)
497
+ throw new ConductorError('usage: conduct done <inbox id>');
498
+ return answer(paths, sub, { action: 'done' });
499
+ case 'escalations':
500
+ if (sub !== undefined)
501
+ throw new ConductorError('usage: conduct escalations [--json] | conduct escalations done <id> [--ask] <text>');
502
+ return escalations(paths, values.json === true);
503
+ case 'events':
504
+ return events(paths, values.follow === true, values.since);
505
+ case 'history':
506
+ case 'retro':
507
+ return history(paths, command, sub, { json: values.json === true, project: values.project, since: values.since, until: values.until });
508
+ case 'inbox':
509
+ return inbox(paths, values.json === true);
510
+ case 'install-mod':
511
+ return values.remove ? removeMod(modDeps()) : installMod(fileURLToPath(new URL('../mod', import.meta.url)), modDeps());
216
512
  case 'install-startup':
217
513
  return values.remove ? uninstall() : install();
218
514
  case 'launch':
@@ -224,41 +520,216 @@ async function main(argv) {
224
520
  project: values.project,
225
521
  prompt: values.prompt,
226
522
  });
523
+ case 'lock': {
524
+ if (sub === undefined || arg === undefined)
525
+ throw new ConductorError('usage: conduct lock <acquire|release|status> <name> [--owner <id>] [--wait <minutes>]');
526
+ const waitMinutes = values.wait === undefined ? undefined : Number(values.wait);
527
+ if (waitMinutes !== undefined && !(waitMinutes >= 0))
528
+ throw new ConductorError('--wait takes minutes, such as --wait 30');
529
+ process.exitCode = await lockCommand(paths, sub, arg, { owner: values.owner, waitMinutes }, brokerIo());
530
+ return;
531
+ }
532
+ case 'note':
533
+ if (sub === undefined || arg === undefined)
534
+ throw new ConductorError('usage: conduct note <item> <file> [--worker <worker>]');
535
+ process.exitCode = await noteCommand(paths, sub, arg, { worker: values.worker }, {
536
+ cwd: process.cwd(),
537
+ err: (line) => process.stderr.write(`${line}\n`),
538
+ out,
539
+ });
540
+ return;
541
+ case 'notify':
542
+ if (sub === undefined)
543
+ return notifyStatus(paths, values.json === true);
544
+ if (sub === 'profile')
545
+ return notifyProfile(paths, arg, values.until);
546
+ if (sub === 'test')
547
+ return notifyTest(paths, { channel: values.channel, kind: values.kind, raise: values.raise === true });
548
+ throw new ConductorError('usage: conduct notify | conduct notify profile [<name>] [--until <when>] | conduct notify test');
227
549
  case 'project':
228
550
  if (sub === 'add' && arg !== undefined)
229
551
  return addProject(paths, arg);
230
552
  if (sub === 'list')
231
553
  return listProjects(paths, values.json === true);
232
554
  throw new ConductorError('usage: conduct project add <path> | conduct project list');
555
+ case 'focus':
556
+ if (sub === undefined || values.until === undefined)
557
+ throw new ConductorError('usage: conduct focus <project> [--slots <n>] [--weight <n>] --until <when>');
558
+ return override(paths, {
559
+ action: 'focus',
560
+ project: sub,
561
+ ...(values.slots === undefined ? {} : { slots: wholeNumber('--slots', values.slots) }),
562
+ until: untilOf(values.until),
563
+ ...(values.weight === undefined ? {} : { weight: wholeNumber('--weight', values.weight) }),
564
+ });
565
+ case 'pause':
566
+ return override(paths, { action: 'pause', ...(sub === undefined ? {} : { project: sub }), ...(values.until === undefined ? {} : { until: untilOf(values.until) }) });
567
+ case 'ports':
568
+ process.exitCode = await portsCommand(paths, sub, { json: values.json === true, ...(values.prefer === undefined ? {} : { prefer: wholeNumber('--prefer', values.prefer) }) }, brokerIo());
569
+ return;
570
+ case 'queue':
571
+ return queue(paths, values.json === true, values.fresh === true);
572
+ case 'rules':
573
+ if (sub === undefined)
574
+ return rules(paths, values.json === true);
575
+ if (sub === 'test' && arg !== undefined)
576
+ return rulesTest(paths, arg, values.json === true);
577
+ throw new ConductorError('usage: conduct rules [--json] | conduct rules test <file> [--json]');
233
578
  case 'retire':
234
579
  if (sub === undefined)
235
580
  throw new ConductorError('usage: conduct retire <worker> [--force]');
236
581
  return retire(paths, sub, values.force === true);
237
582
  case 'serve':
238
583
  return serve(paths);
584
+ case 'start':
585
+ return start(paths);
239
586
  case 'status':
240
587
  return status(paths, values.json === true);
241
588
  case 'stop':
242
589
  await stopDaemon(paths);
243
590
  return;
591
+ case 'unpause':
592
+ return override(paths, { action: 'unpause', ...(sub === undefined ? {} : { project: sub }) });
244
593
  case 'upgrade':
245
- return upgradeCommand(paths, values.tag);
594
+ return upgradeCommand(paths, values.tag, values['allow-downgrade'] === true);
595
+ case 'why':
596
+ if (sub === undefined)
597
+ throw new ConductorError('usage: conduct why <event id | item>');
598
+ return why(paths, sub, values.json === true);
246
599
  default:
247
600
  throw new ConductorError(`unknown command "${command}"`, ['run `conduct help` for the commands']);
248
601
  }
249
602
  }
603
+ function modDeps() {
604
+ return { claude: (args) => run('claude', args, { timeoutMs: 120_000 }), out };
605
+ }
606
+ function noInbox() {
607
+ return olderDaemon('no inbox');
608
+ }
609
+ // GET /notify, or a ConductorError from a daemon older than the notifier.
610
+ async function notifyAnswer(paths, request) {
611
+ const answer = await callApi(paths, request);
612
+ if (answer.status === 404)
613
+ throw olderDaemon('no notifier');
614
+ return expectOk(Promise.resolve(answer));
615
+ }
616
+ // `conduct notify profile`: the active profile, or switch to <name>.
617
+ async function notifyProfile(paths, name, until) {
618
+ if (name === undefined) {
619
+ if (until !== undefined)
620
+ throw new ConductorError('usage: conduct notify profile <name> --until <when>');
621
+ return notifyStatus(paths, false);
622
+ }
623
+ const body = { name, ...(until === undefined ? {} : { until: parseUntil(until, new Date()).toISOString() }) };
624
+ const status = notifyStatusSchema.parse(await notifyAnswer(paths, { body, method: 'POST', path: '/notify/profile' }));
625
+ notifyLines(status).forEach((line) => out(line));
626
+ }
627
+ async function notifyStatus(paths, json) {
628
+ const status = notifyStatusSchema.parse(await notifyAnswer(paths, { method: 'GET', path: '/notify' }));
629
+ if (json)
630
+ return out(JSON.stringify(status, null, 2));
631
+ notifyLines(status).forEach((line) => out(line));
632
+ }
633
+ async function notifyTest(paths, options) {
634
+ if (options.raise && options.channel)
635
+ throw new ConductorError('--raise routes the item by the profile; it takes no --channel');
636
+ const body = { ...(options.channel ? { channel: options.channel } : {}), ...(options.kind ? { kind: options.kind } : {}), raise: options.raise };
637
+ // Pushover can take its 15 s, after a Credential Manager read.
638
+ const answer = await notifyAnswer(paths, { body, method: 'POST', path: '/notify/test', timeoutMs: 60_000 });
639
+ if (options.raise) {
640
+ const { item } = z.object({ item: inboxItemSchema }).parse(answer);
641
+ return out(`raised inbox item #${item.id} (${item.kind}); \`conduct done ${item.id}\` clears it`);
642
+ }
643
+ const result = testResultSchema.parse(answer);
644
+ if (result.channels.length === 0)
645
+ return out(`profile ${result.profile} sends a ${body.kind ?? 'npm-otp'} item nowhere`);
646
+ out(`profile ${result.profile}${result.quietNow ? ' (quiet hours now: a real item would wait for the summary)' : ''}`);
647
+ result.results.forEach((r) => out(` ${r.channel}: ${r.ok ? 'ok' : 'failed'}: ${r.detail}`));
648
+ if (result.results.some((r) => !r.ok))
649
+ process.exitCode = 1;
650
+ }
250
651
  // npm is a .cmd shim on Windows, which starts through a shell alone. The only argument from input is
251
652
  // the tag, which parseTag limits to dev or latest.
252
653
  function npm(args, timeoutMs) {
253
654
  return run('npm', args, { shell: process.platform === 'win32', timeoutMs });
254
655
  }
656
+ function olderDaemon(what) {
657
+ return new ConductorError(`the running conductor has ${what}: it is older than this conduct`, [
658
+ 'restart it on this build: conduct stop, then conduct serve (or conduct upgrade)',
659
+ ]);
660
+ }
661
+ // The open inbox items, or null from a daemon older than the inbox (a linked build ahead of it).
662
+ async function openItems(paths) {
663
+ const answer = await callApi(paths, { method: 'GET', path: '/inbox' });
664
+ if (answer.status === 404)
665
+ return null;
666
+ return inboxBody.parse(await expectOk(Promise.resolve(answer))).items;
667
+ }
255
668
  function out(line) {
256
669
  process.stdout.write(`${line}\n`);
257
670
  }
671
+ // `conduct pause|unpause|focus`: an override that ends on its own (unpause ends a pause).
672
+ async function override(paths, body) {
673
+ const answer = await callApi(paths, { body: { ...body, by: `conduct ${String(body.action)}` }, method: 'POST', path: '/overrides' });
674
+ if (answer.status === 404)
675
+ throw olderDaemon('no overrides');
676
+ const { overrides, set } = z.object({ overrides: z.array(overrideViewSchema), set: z.array(overrideViewSchema) }).parse(await expectOk(Promise.resolve(answer)));
677
+ const untilOfView = (o) => (o.until === null ? ' until you unpause' : ` until ${new Date(o.until).toLocaleString()}`);
678
+ for (const o of set) {
679
+ const who = o.project ?? 'every project';
680
+ if (o.kind === PAUSE)
681
+ out(o.value === 'on' ? `paused launches in ${who}${untilOfView(o)}` : `launches in ${who} are no longer paused`);
682
+ else
683
+ out(`${who}: ${o.kind.replace('focus.', '')} ${o.value}${untilOfView(o)}`);
684
+ }
685
+ // A pause of every project still holds a project unpaused on its own.
686
+ const all = overrides.find((o) => o.kind === PAUSE && o.project === null);
687
+ if (body.action === 'unpause' && body.project !== undefined && all)
688
+ out(`but all launches stay paused${untilOfView(all)}: conduct unpause ends that`);
689
+ }
690
+ // The open plan item of `ref`: the item (ATK-312) whose plan waits, or an inbox id written #12. A
691
+ // bare number is refused: it could be an issue's number, and the answer is posted as Jason's.
692
+ async function planItem(paths, ref) {
693
+ if (ref.startsWith('#'))
694
+ return inboxId(ref);
695
+ if (/^\d+$/.test(ref)) {
696
+ throw new ConductorError(`"${ref}" is ambiguous`, [`name the item, such as ATK-${ref}, or the inbox id as #${ref}`]);
697
+ }
698
+ const items = await openItems(paths);
699
+ if (items === null)
700
+ throw noInbox();
701
+ const plans = items.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.toUpperCase());
702
+ if (plans.length === 1 && plans[0])
703
+ return plans[0].id;
704
+ if (plans.length === 0)
705
+ throw new ConductorError(`no plan of ${ref} waits in the inbox`, ['`conduct inbox` lists what waits']);
706
+ throw new ConductorError(`several plans of ${ref} wait`, plans.map((p) => `#${p.id} in ${p.project ?? '?'}: pass #${p.id}`));
707
+ }
258
708
  function projectLine(p) {
259
709
  const state = p.problem ? `${p.problem}; run \`conduct project add\` again` : p.enabled ? 'enabled' : 'disabled';
260
710
  return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
261
711
  }
712
+ // The registered projects: from the daemon, or from the store when it isn't running.
713
+ async function projectsNow(paths) {
714
+ try {
715
+ return projectsBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/projects' }))).projects;
716
+ }
717
+ catch (error) {
718
+ if (!(error instanceof NotRunningError))
719
+ throw error;
720
+ return withStore(paths, (store) => store.listProjects());
721
+ }
722
+ }
723
+ async function queue(paths, json, fresh) {
724
+ // A fresh read asks every project's tracker, within the shared rate budget.
725
+ const answer = await callApi(paths, { method: 'GET', path: `/queue${fresh ? '?fresh=true' : ''}`, timeoutMs: 120_000 });
726
+ if (answer.status === 404)
727
+ throw olderDaemon('no scheduler');
728
+ const view = queueViewSchema.parse(await expectOk(Promise.resolve(answer)));
729
+ if (json)
730
+ return out(JSON.stringify(view, null, 2));
731
+ queueLines(view).forEach((line) => out(line));
732
+ }
262
733
  async function retire(paths, ref, force) {
263
734
  const worker = await findWorker(paths, ref);
264
735
  const { body, status } = await callApi(paths, {
@@ -286,23 +757,86 @@ async function retire(paths, ref, force) {
286
757
  out(`retired ${worker.name}`);
287
758
  steps.forEach((s) => out(` ${s}`));
288
759
  }
760
+ // `conduct rules`: from the daemon, or straight from the files when it isn't running.
761
+ async function rules(paths, json) {
762
+ const view = await rulesAnswer(paths, { method: 'GET', path: '/rules' }, (body) => rulesViewSchema.parse(body), (service) => service.view());
763
+ if (json)
764
+ return out(JSON.stringify(view, null, 2));
765
+ rulesLines(view).forEach((line) => out(line));
766
+ }
767
+ // The daemon's answer, or, when none runs, the same thing worked out here from the files.
768
+ async function rulesAnswer(paths, request, parse, local) {
769
+ let answer;
770
+ try {
771
+ answer = await callApi(paths, request);
772
+ }
773
+ catch (error) {
774
+ if (!(error instanceof NotRunningError))
775
+ throw error;
776
+ process.stderr.write('conduct: the conductor is not running; read from the files\n');
777
+ const config = loadConfig(paths.config);
778
+ return withStore(paths, (store) => {
779
+ const service = rulesService({ config, listProjects: () => store.listProjects(), paths });
780
+ service.reload();
781
+ return local(service);
782
+ });
783
+ }
784
+ if (answer.status === 404)
785
+ throw olderDaemon('no rules engine');
786
+ return parse(await expectOk(Promise.resolve(answer)));
787
+ }
788
+ async function rulesTest(paths, file, json) {
789
+ let text;
790
+ try {
791
+ text = readFileSync(file, 'utf8');
792
+ }
793
+ catch (error) {
794
+ throw new ConductorError(`could not read ${file}`, [String(error)]);
795
+ }
796
+ const sample = parseJsonc(text, z.unknown(), file);
797
+ const result = await rulesAnswer(paths, { body: sample, method: 'POST', path: '/rules/test' }, (body) => rulesTestResultSchema.parse(body), (service) => service.test(sample));
798
+ if (json)
799
+ return out(JSON.stringify(result, null, 2));
800
+ testLines(result).forEach((line) => out(line));
801
+ }
289
802
  async function serve(paths) {
290
803
  const daemon = await startDaemon(paths, { version: version() });
291
804
  out(`conductor listening on 127.0.0.1:${daemon.port} (pid ${process.pid}, home ${paths.home})`);
292
805
  for (const signal of ['SIGINT', 'SIGTERM', 'SIGBREAK']) {
293
806
  process.once(signal, () => void daemon.stop(signal));
294
807
  }
295
- // Under the logon task nobody sees stderr, so a crash goes to the log. The pid file it leaves
296
- // names a dead process, which the next start replaces.
297
- process.on('uncaughtException', (error) => {
298
- daemon.log.log('error', 'uncaught exception', { error: error.stack ?? String(error) });
299
- process.exit(1);
300
- });
808
+ logCrashes(daemon.log);
301
809
  await daemon.closed;
302
810
  }
303
811
  function sleep(ms) {
304
812
  return new Promise((done) => setTimeout(done, ms));
305
813
  }
814
+ // `conduct start`: the daemon, detached from this process, so it outlives the shell or the Claude
815
+ // session that started it (the console's start runs this). Nothing when one answers already.
816
+ async function start(paths) {
817
+ const pid = readPid(paths.pid);
818
+ if (pid !== null && (await ping(paths, pid)))
819
+ return out(`the conductor is running (pid ${pid})`);
820
+ // A daemon that is still starting (the logon task's, say) holds the lock: wait for it rather than
821
+ // start a second one, which would exit on that lock. A pid a reboot reused gets a start after.
822
+ if (pid !== null && processAlive(pid)) {
823
+ try {
824
+ return out(`the conductor is running (pid ${await awaitDaemon(paths)})`);
825
+ }
826
+ catch {
827
+ // Not a daemon that answers: start one, which replaces a stale lock (or says who holds it).
828
+ }
829
+ }
830
+ try {
831
+ out(`started the conductor ${await restartDaemon(daemonStarter(paths, realpathSync(fileURLToPath(import.meta.url))))}`);
832
+ }
833
+ catch (error) {
834
+ const holder = readPid(paths.pid);
835
+ if (holder === null || !processAlive(holder))
836
+ throw error;
837
+ 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}`]);
838
+ }
839
+ }
306
840
  async function status(paths, json) {
307
841
  let projects;
308
842
  try {
@@ -317,15 +851,28 @@ async function status(paths, json) {
317
851
  return;
318
852
  }
319
853
  const { workers } = fleetBody.parse(await expectOk(callApi(paths, { method: 'GET', path: '/fleet' })));
854
+ // A linked build's CLI can be newer than the daemon still running, which has no inbox (null).
855
+ const items = await openItems(paths);
856
+ // Null from a daemon older than the broker.
857
+ const locks = await heldLocks(paths);
320
858
  const pid = readPid(paths.pid);
321
859
  const port = readEndpoint(paths)?.port ?? null;
322
860
  if (json)
323
- return out(JSON.stringify({ home: paths.home, pid, port, projects, running: true, workers }, null, 2));
861
+ return out(JSON.stringify({ home: paths.home, inbox: items, locks, pid, port, projects, running: true, workers }, null, 2));
324
862
  out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
325
863
  out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
326
864
  projects.forEach((p) => out(projectLine(p)));
327
865
  out(workers.length === 0 ? 'no workers' : 'workers:');
328
866
  workers.forEach((w) => out(workerLine(w)));
867
+ if (locks === null)
868
+ out('locks: not in this daemon (restart it to run this build)');
869
+ else
870
+ out(locks.length === 0 ? 'locks: none held' : 'locks:');
871
+ locks?.forEach((l) => out(lockLine(l)));
872
+ if (items === null)
873
+ out('inbox: not in this daemon (restart it to run this build)');
874
+ else
875
+ out(items.length === 0 ? 'inbox: empty' : `inbox: ${items.length} open (conduct inbox)`);
329
876
  }
330
877
  // True when it stopped a daemon, false when none ran.
331
878
  async function stopDaemon(paths) {
@@ -355,10 +902,14 @@ async function stopDaemon(paths) {
355
902
  function uninstall() {
356
903
  out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
357
904
  }
358
- async function upgradeCommand(paths, tag) {
905
+ // --until: 17:00 (the next one), tomorrow, a day such as monday, or a date and time.
906
+ function untilOf(text) {
907
+ return resolveUntil({ kind: 'at', text }, new Date()).toISOString();
908
+ }
909
+ async function upgradeCommand(paths, tag, allowDowngrade) {
359
910
  const cliPath = realpathSync(fileURLToPath(import.meta.url));
360
- await upgrade({ tag: parseTag(tag) }, {
361
- awaitDaemon: () => awaitDaemon(paths),
911
+ await upgrade({ allowDowngrade, tag: parseTag(tag) }, {
912
+ ...daemonStarter(paths, cliPath),
362
913
  cliPath,
363
914
  installedVersion: version,
364
915
  npmInstall: (spec) => npm(['install', '--global', spec], NPM_INSTALL_TIMEOUT_MS),
@@ -376,15 +927,7 @@ async function upgradeCommand(paths, tag) {
376
927
  return found;
377
928
  },
378
929
  out,
379
- runTask: runStartup,
380
- // A failed start shows as awaitDaemon's timeout. The daemon runs in its home, not in the
381
- // caller's directory, which it would hold open (a worktree it may later remove, say).
382
- serveDetached: () => spawn(process.execPath, [cliPath, 'serve'], { cwd: paths.home, detached: true, stdio: 'ignore', windowsHide: true })
383
- .on('error', () => { })
384
- .unref(),
385
930
  stopDaemon: () => stopDaemon(paths),
386
- // The logon task's daemon uses ~/.conductor, so it can't restart one kept in CONDUCTOR_HOME.
387
- taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
388
931
  });
389
932
  }
390
933
  // Read from disk each time: upgrade asks again after npm has replaced the package.
@@ -415,9 +958,31 @@ async function wait(argv) {
415
958
  throw new ConductorError('usage: conduct wait <condition>', ['run `conduct help` for the conditions']);
416
959
  process.exitCode = await waitCommand(conductorPaths(), positionals, { ...values, timeoutMinutes: timeout }, { cwd: process.cwd(), err: (line) => process.stderr.write(`${line}\n`), out });
417
960
  }
418
- function withStore(paths, use) {
961
+ function wholeNumber(flag, text) {
962
+ if (!/^-?\d+$/.test(text))
963
+ throw new ConductorError(`${flag} takes a whole number, not "${text}"`);
964
+ return Number(text);
965
+ }
966
+ // `conduct why <event id>`: the rule behind it and its causes. `conduct why <item>`: the same for
967
+ // each of the item's newest actions.
968
+ async function why(paths, ref, json) {
969
+ const byId = /^#?(\d+)$/.exec(ref)?.[1];
970
+ const answer = await callApi(paths, { method: 'GET', path: byId === undefined ? `/why?item=${encodeURIComponent(ref)}` : `/why/${byId}` });
971
+ if (answer.status === 404 && byId !== undefined) {
972
+ const body = errorBody.safeParse(answer.body);
973
+ throw body.success && body.data.error === 'not_found' ? new ConductorError(`no event ${byId}`) : olderDaemon('no conduct why');
974
+ }
975
+ const body = await expectOk(Promise.resolve(answer));
976
+ const whys = byId === undefined ? z.object({ items: z.array(whySchema) }).parse(body).items : [whySchema.parse(body)];
977
+ if (json)
978
+ return out(JSON.stringify(byId === undefined ? whys : whys[0], null, 2));
979
+ if (whys.length === 0)
980
+ return out(`no action on ${ref} was caused by a rule`);
981
+ whys.forEach((w, i) => [...(i > 0 ? [''] : []), ...whyLines(w)].forEach((line) => out(line)));
982
+ }
983
+ function withStore(paths, use, options = {}) {
419
984
  mkdirSync(paths.home, { recursive: true });
420
- const store = openStore(paths.db);
985
+ const store = openStore(paths.db, undefined, options);
421
986
  try {
422
987
  return use(store);
423
988
  }
@@ -425,12 +990,13 @@ function withStore(paths, use) {
425
990
  store.close();
426
991
  }
427
992
  }
428
- // One line per worker: name, project, status and session activity, its waits, then spend.
993
+ // One line per worker: name, project, status and session activity, its phase and waits, then spend.
429
994
  function workerLine(w) {
430
995
  const activity = w.status === 'running' ? `running, ${w.activity}` : w.status;
431
996
  const waits = w.waits.map((x) => (x.resolvedAt ? `${x.condition} (resolved)` : x.condition));
432
997
  return [
433
998
  ` ${w.name} ${w.project} ${activity}`,
999
+ ...(w.phase ? [`phase ${w.phase}`] : []),
434
1000
  ...(waits.length > 0 ? [`waits: ${waits.join(', ')}`] : []),
435
1001
  `spend ${formatTokens(w.spend.today)} today, ${formatTokens(w.spend.total)} total`,
436
1002
  ].join(' · ');