@detergent-software/conductor 3.0.0-dev.9 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/CHANGELOG.md +475 -0
  2. package/README.md +271 -125
  3. package/build/brief-cli.js +5 -12
  4. package/build/brief-cli.js.map +1 -1
  5. package/build/brief.js +4 -3
  6. package/build/brief.js.map +1 -1
  7. package/build/broker-cli.js +41 -14
  8. package/build/broker-cli.js.map +1 -1
  9. package/build/broker.js +1 -1
  10. package/build/broker.js.map +1 -1
  11. package/build/cli.js +221 -63
  12. package/build/cli.js.map +1 -1
  13. package/build/clones.js +92 -0
  14. package/build/clones.js.map +1 -0
  15. package/build/code-host-cli.js +172 -40
  16. package/build/code-host-cli.js.map +1 -1
  17. package/build/code-host-service.js +35 -2
  18. package/build/code-host-service.js.map +1 -1
  19. package/build/code-host.js +138 -16
  20. package/build/code-host.js.map +1 -1
  21. package/build/conditions.js +33 -16
  22. package/build/conditions.js.map +1 -1
  23. package/build/config.js +58 -28
  24. package/build/config.js.map +1 -1
  25. package/build/console.js +17 -27
  26. package/build/console.js.map +1 -1
  27. package/build/credentials.js +86 -54
  28. package/build/credentials.js.map +1 -1
  29. package/build/daemon.js +53 -65
  30. package/build/daemon.js.map +1 -1
  31. package/build/file-lock.js +155 -40
  32. package/build/file-lock.js.map +1 -1
  33. package/build/follow-ups.js +2 -2
  34. package/build/follow-ups.js.map +1 -1
  35. package/build/help.js +241 -83
  36. package/build/help.js.map +1 -1
  37. package/build/history.js +10 -4
  38. package/build/history.js.map +1 -1
  39. package/build/host/credential-manager.js +109 -0
  40. package/build/host/credential-manager.js.map +1 -0
  41. package/build/host/keychain.js +44 -0
  42. package/build/host/keychain.js.map +1 -0
  43. package/build/host/launchd.js +118 -0
  44. package/build/host/launchd.js.map +1 -0
  45. package/build/host/libsecret.js +40 -0
  46. package/build/host/libsecret.js.map +1 -0
  47. package/build/host/macos-terminal.js +63 -0
  48. package/build/host/macos-terminal.js.map +1 -0
  49. package/build/host/platform.js +4 -0
  50. package/build/host/platform.js.map +1 -0
  51. package/build/host/secrets.js +46 -0
  52. package/build/host/secrets.js.map +1 -0
  53. package/build/host/shell.js +26 -0
  54. package/build/host/shell.js.map +1 -0
  55. package/build/host/startup.js +26 -0
  56. package/build/host/startup.js.map +1 -0
  57. package/build/host/systemd.js +91 -0
  58. package/build/host/systemd.js.map +1 -0
  59. package/build/host/task-scheduler.js +78 -0
  60. package/build/host/task-scheduler.js.map +1 -0
  61. package/build/host/terminal.js +46 -0
  62. package/build/host/terminal.js.map +1 -0
  63. package/build/host/tmux.js +39 -0
  64. package/build/host/tmux.js.map +1 -0
  65. package/build/host/windows-terminal.js +46 -0
  66. package/build/host/windows-terminal.js.map +1 -0
  67. package/build/in-process.js +5 -4
  68. package/build/in-process.js.map +1 -1
  69. package/build/inbox.js +47 -35
  70. package/build/inbox.js.map +1 -1
  71. package/build/init.js +509 -0
  72. package/build/init.js.map +1 -0
  73. package/build/landing.js +107 -10
  74. package/build/landing.js.map +1 -1
  75. package/build/lib/providers/ado/code-host.js +5 -1
  76. package/build/lib/providers/ado/code-host.js.map +1 -1
  77. package/build/lib/providers/ado/pipelines.js +138 -26
  78. package/build/lib/providers/ado/pipelines.js.map +1 -1
  79. package/build/lib/providers/ado/trigger.js +5 -0
  80. package/build/lib/providers/ado/trigger.js.map +1 -1
  81. package/build/lib/providers/ado/writes.js +17 -2
  82. package/build/lib/providers/ado/writes.js.map +1 -1
  83. package/build/lib/providers/github/actions.js +90 -3
  84. package/build/lib/providers/github/actions.js.map +1 -1
  85. package/build/lib/providers/github/code-host.js +2 -0
  86. package/build/lib/providers/github/code-host.js.map +1 -1
  87. package/build/lib/providers/github/writes.js +25 -2
  88. package/build/lib/providers/github/writes.js.map +1 -1
  89. package/build/lib/providers/index.js +3 -2
  90. package/build/lib/providers/index.js.map +1 -1
  91. package/build/lib/providers/npm/stage.js +14 -1
  92. package/build/lib/providers/npm/stage.js.map +1 -1
  93. package/build/lib/providers/timing.js +10 -0
  94. package/build/lib/providers/timing.js.map +1 -0
  95. package/build/lib/providers/verdicts/pr.js +47 -6
  96. package/build/lib/providers/verdicts/pr.js.map +1 -1
  97. package/build/lib/providers/verdicts/runs.js +12 -2
  98. package/build/lib/providers/verdicts/runs.js.map +1 -1
  99. package/build/logins.js +13 -6
  100. package/build/logins.js.map +1 -1
  101. package/build/manager.js +121 -55
  102. package/build/manager.js.map +1 -1
  103. package/build/note.js +5 -15
  104. package/build/note.js.map +1 -1
  105. package/build/notes.js +130 -23
  106. package/build/notes.js.map +1 -1
  107. package/build/notify.js +104 -42
  108. package/build/notify.js.map +1 -1
  109. package/build/npm-reads.js +151 -22
  110. package/build/npm-reads.js.map +1 -1
  111. package/build/ownership.js +2 -2
  112. package/build/paths.js +44 -1
  113. package/build/paths.js.map +1 -1
  114. package/build/plugin.js +4 -2
  115. package/build/plugin.js.map +1 -1
  116. package/build/profile-fields.js +153 -0
  117. package/build/profile-fields.js.map +1 -0
  118. package/build/profile-layers.js +264 -0
  119. package/build/profile-layers.js.map +1 -0
  120. package/build/profile.js +69 -49
  121. package/build/profile.js.map +1 -1
  122. package/build/project-check-cli.js +17 -19
  123. package/build/project-check-cli.js.map +1 -1
  124. package/build/project-check.js +166 -104
  125. package/build/project-check.js.map +1 -1
  126. package/build/protocol.js +5 -21
  127. package/build/protocol.js.map +1 -1
  128. package/build/pushover.js +5 -5
  129. package/build/pushover.js.map +1 -1
  130. package/build/queue.js +4 -4
  131. package/build/queue.js.map +1 -1
  132. package/build/registry.js +195 -36
  133. package/build/registry.js.map +1 -1
  134. package/build/release-kinds.js +34 -0
  135. package/build/release-kinds.js.map +1 -0
  136. package/build/rule-actions.js +8 -6
  137. package/build/rule-actions.js.map +1 -1
  138. package/build/rules-service.js +83 -44
  139. package/build/rules-service.js.map +1 -1
  140. package/build/rules.js +23 -23
  141. package/build/rules.js.map +1 -1
  142. package/build/run.js +20 -7
  143. package/build/run.js.map +1 -1
  144. package/build/runs.js +218 -68
  145. package/build/runs.js.map +1 -1
  146. package/build/scheduler.js +78 -12
  147. package/build/scheduler.js.map +1 -1
  148. package/build/search.js +2 -1
  149. package/build/search.js.map +1 -1
  150. package/build/server.js +51 -14
  151. package/build/server.js.map +1 -1
  152. package/build/spend.js +1 -1
  153. package/build/spend.js.map +1 -1
  154. package/build/store.js +79 -20
  155. package/build/store.js.map +1 -1
  156. package/build/supervise.js +1 -1
  157. package/build/supervise.js.map +1 -1
  158. package/build/timeline.js +104 -0
  159. package/build/timeline.js.map +1 -0
  160. package/build/tracker-reads-cli.js +4 -3
  161. package/build/tracker-reads-cli.js.map +1 -1
  162. package/build/trackers.js +56 -10
  163. package/build/trackers.js.map +1 -1
  164. package/build/train-cli.js +6 -5
  165. package/build/train-cli.js.map +1 -1
  166. package/build/train.js +2 -2
  167. package/build/train.js.map +1 -1
  168. package/build/upgrade.js +8 -9
  169. package/build/upgrade.js.map +1 -1
  170. package/build/wait.js +10 -10
  171. package/build/wait.js.map +1 -1
  172. package/build/watchers/ado.js +42 -19
  173. package/build/watchers/ado.js.map +1 -1
  174. package/build/watchers/github.js +18 -4
  175. package/build/watchers/github.js.map +1 -1
  176. package/build/watchers/index.js +2 -2
  177. package/build/watchers/index.js.map +1 -1
  178. package/build/watchers/linear-queue.js +47 -14
  179. package/build/watchers/linear-queue.js.map +1 -1
  180. package/build/watchers/linear-writes.js +26 -12
  181. package/build/watchers/linear-writes.js.map +1 -1
  182. package/build/watchers/linear.js +2 -2
  183. package/build/watchers/linear.js.map +1 -1
  184. package/build/watchers/source.js +5 -1
  185. package/build/watchers/source.js.map +1 -1
  186. package/build/webhook.js +80 -0
  187. package/build/webhook.js.map +1 -0
  188. package/build/why.js +1 -1
  189. package/build/why.js.map +1 -1
  190. package/build/worktrees.js +56 -93
  191. package/build/worktrees.js.map +1 -1
  192. package/package.json +61 -61
  193. package/plugin/.claude-plugin/plugin.json +1 -1
  194. package/plugin/hooks/model.ts +46 -6
  195. package/plugin/hooks/register.tsx +41 -2
  196. package/plugin/skills/conductor-console/SKILL.md +26 -27
  197. package/plugin/skills/work-item/SKILL.md +58 -54
  198. package/plugin/skills/work-item/trackers/ado-boards.md +2 -2
  199. package/plugin/skills/work-item/trackers/github-issues.md +1 -1
  200. package/plugin/skills/work-item/trackers/linear.md +2 -2
  201. package/plugin/types/index.d.ts +1 -1
  202. package/protocol/PROTOCOL.md +3 -9
  203. package/build/checkout-sync.js +0 -249
  204. package/build/checkout-sync.js.map +0 -1
  205. package/build/credential.js +0 -66
  206. package/build/credential.js.map +0 -1
  207. package/build/startup.js +0 -92
  208. package/build/startup.js.map +0 -1
  209. package/build/tab.js +0 -32
  210. package/build/tab.js.map +0 -1
package/build/cli.js CHANGED
@@ -1,22 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
- import { join, resolve, sep } from 'node:path';
4
+ import { delimiter, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { parseArgs } from 'node:util';
7
7
  import { z } from 'zod';
8
8
  import { briefCommand } from "./brief-cli.js";
9
9
  import { heldLocks, lockCommand, lockLine, portsCommand } from "./broker-cli.js";
10
+ import { trustStep } from "./claude.js";
10
11
  import { callApi, NotRunningError, ping, readEndpoint } from "./client.js";
11
- import { branchCommand, checkoutCommand, fileCommand, landCommand, modeCommand, prCreateCommand, prThreadsCommand, replyCommand, runsCommand, stateCommand, } from "./code-host-cli.js";
12
+ import { branchCommand, checkoutCommand, fileCommand, landCommand, modeCommand, prAbandonCommand, prCreateCommand, prStatusCommand, prThreadsCommand, replyCommand, runsCommand, stateCommand, timelineCommand, } from "./code-host-cli.js";
12
13
  import { configSchema, loadConfig } from "./config.js";
13
- import { claimConsole, CONSOLE_WINDOW, consoleArgs, consoleEnv, consoleTabArgs, escalationLines, escalationSchema, handledSchema, } from "./console.js";
14
+ import { claimConsole, CONSOLE_WINDOW, consoleArgs, consoleEnv, consoleTab, escalationLines, escalationSchema, handledSchema, } from "./console.js";
14
15
  import { AlreadyRunningError, logCrashes, startDaemon } from "./daemon.js";
15
16
  import { ConductorError } from "./errors.js";
16
17
  import { eventsCommand } from "./events.js";
17
18
  import { commandHelp, isCommand, listing, terminalStyle, terminalWidth, unknownCommand, usageError } from "./help.js";
18
19
  import { flagWindow, historyLines, historyReport, historySchema, parseRange, retroMarkdown, } from "./history.js";
20
+ import { startup } from "./host/startup.js";
21
+ import { terminal } from "./host/terminal.js";
19
22
  import { inboxLines } from "./inbox.js";
23
+ import { initProject, terminalAsk } from "./init.js";
20
24
  import { parseJsonc } from "./jsonc.js";
21
25
  import { npmStage } from "./lib/providers/index.js";
22
26
  import { processAlive, readPid } from "./lock.js";
@@ -29,28 +33,27 @@ import { overrideViewSchema, PAUSE } from "./overrides.js";
29
33
  import { packageInfo } from "./package-info.js";
30
34
  import { conductorPaths } from "./paths.js";
31
35
  import { refreshConductorPlugin } from "./plugin.js";
32
- import { projectCheckCommand } from "./project-check-cli.js";
36
+ import { profileLines, projectProfile } from "./profile-layers.js";
37
+ import { projectCheckCommand, projectCheckReport } from "./project-check-cli.js";
33
38
  import { noProject } from "./project-check.js";
34
39
  import { pushoverCredential, pushoverSender } from "./pushover.js";
35
- import { registerProject } from "./registry.js";
40
+ import { cloneProject, registerProject } from "./registry.js";
36
41
  import { rulesLines, rulesService, rulesTestResultSchema, rulesViewSchema, testLines, } from "./rules-service.js";
37
42
  import { resolveUntil } from "./rules.js";
38
43
  import { outputOf, run } from "./run.js";
39
44
  import { queueLines, queueViewSchema } from "./scheduler.js";
40
45
  import { awaitStart, serveDetached, ServeStartError } from "./serve.js";
41
46
  import { localDay } from "./spend.js";
42
- import { installStartup, removeStartup, runStartup, startupRunsCli, startupSupervises, TASK_NAME } from "./startup.js";
43
47
  import { answerStopRequest, dropStaleStopRequest, pendingStopRequest, STOP_POLL_MS, stopSupervisor, } from "./stop-request.js";
44
48
  import { inboxItemSchema, openStore, projectSchema, waitSchema, workerSchema, } from "./store.js";
45
49
  import { RESTART_ENV, restartInfoSchema, SERVE_LOCKED_EXIT, serveChild, supervise, SUPERVISE_POLICY, } from "./supervise.js";
46
- import { openWindowsTerminal } from "./tab.js";
47
50
  import { commentsCommand, notesCommand, searchCommand } from "./tracker-reads-cli.js";
48
51
  import { trainCommand } from "./train-cli.js";
49
52
  import { parseTag, restartDaemon, tagVersion, upgrade } from "./upgrade.js";
50
53
  import { waitCommand } from "./wait.js";
51
54
  import { whyLines, whySchema } from "./why.js";
52
55
  const STOP_WAIT_MS = 10_000;
53
- // A restarted daemon opens its store and binds the port; under the logon task node starts cold.
56
+ // A restarted daemon opens its store and binds the port; under the logon startup node starts cold.
54
57
  const START_WAIT_MS = 30_000;
55
58
  const NPM_INSTALL_TIMEOUT_MS = 5 * 60_000;
56
59
  // A launch makes a worktree (pnpm install) before the session starts.
@@ -62,6 +65,9 @@ const RETIRE_TIMEOUT_MS = 12 * 60_000;
62
65
  const ATTACH_TIMEOUT_MS = 9 * 60_000;
63
66
  // Approve and revise ask Linear twice (the issue, then the comment), each within 20 s.
64
67
  const ANSWER_TIMEOUT_MS = 60_000;
68
+ // A revise of an approved plan adds up to three brief reads (the tracker and the PR, each within
69
+ // 20 s) 2 s apart after the comment, so a slow tracker doesn't cut off an answer whose comment landed.
70
+ const REVISE_TIMEOUT_MS = 3 * 60_000;
65
71
  const projectsBody = z.object({ projects: z.array(projectSchema) });
66
72
  const addBody = z.object({ created: z.boolean(), project: projectSchema });
67
73
  const removeBody = z.object({ project: projectSchema, resolved: z.array(inboxItemSchema) });
@@ -86,23 +92,43 @@ const retireBody = z.object({
86
92
  work: workSchema.extend({ checkouts: z.record(z.string(), workSchema).optional() }).optional(),
87
93
  worker: workerSchema,
88
94
  });
89
- const attachBody = z.object({ resumed: z.boolean(), tab: z.string().nullable(), worker: workerSchema });
95
+ // hint: from ATK-529 on; an older daemon sends none.
96
+ const attachBody = z.object({
97
+ hint: z.string().nullable().default(null),
98
+ resumed: z.boolean(),
99
+ tab: z.string().nullable(),
100
+ worker: workerSchema,
101
+ });
90
102
  const inboxBody = z.object({ items: z.array(inboxItemSchema) });
91
103
  const answerBody = z.object({
92
104
  comment: z.object({ id: z.string(), url: z.string().nullable() }).nullable(),
93
105
  item: inboxItemSchema,
94
106
  waits: z.array(waitSchema),
95
107
  });
108
+ const reviseAnswer = z.object({
109
+ revise: z.object({
110
+ comment: z.object({ id: z.string(), url: z.string().nullable() }),
111
+ item: z.string(),
112
+ landed: z.boolean(),
113
+ pending: z.string().nullable(),
114
+ revised: z.boolean(),
115
+ woken: z.number(),
116
+ worker: z.boolean(),
117
+ }),
118
+ });
96
119
  const answerRefusal = z.object({
97
120
  details: z.array(z.string()).optional(),
98
121
  error: z.string(),
99
122
  posted: z.enum(['no', 'unknown']).optional(),
100
123
  });
101
- async function addProject(paths, path) {
102
- const absolute = resolve(path);
124
+ // `conduct project add <url | path> [--name <name>]`: the clone is made here, where its progress
125
+ // shows and no API timeout applies, then the daemon registers it (or the store, with none running).
126
+ // `next`: false for `conduct init`, which says what comes next itself.
127
+ async function addProject(paths, source, name, next = true) {
128
+ const made = await cloneProject(paths, source, { ...(name === undefined ? {} : { name }), say: out });
103
129
  let result;
104
130
  try {
105
- result = addBody.parse(await expectOk(callApi(paths, { body: { path: absolute }, method: 'POST', path: '/projects' })));
131
+ result = addBody.parse(await expectOk(callApi(paths, { body: { name: made.name }, method: 'POST', path: '/projects' })));
106
132
  }
107
133
  catch (error) {
108
134
  if (!(error instanceof NotRunningError))
@@ -110,14 +136,25 @@ async function addProject(paths, path) {
110
136
  // No daemon: write the store directly. The daemon reads it when it starts.
111
137
  // Its rules are checked against config.json here too, as the daemon would.
112
138
  const check = rulesService({ config: loadConfig(paths.config), listProjects: () => [], paths }).checkProfile;
113
- result = withStore(paths, (store) => registerProject(store, absolute, check));
139
+ result = withStore(paths, (store) => registerProject(store, paths, made.name, check));
114
140
  }
115
141
  out(`${result.created ? 'registered' : 'updated'} ${projectLine(result.project).trim()}`);
142
+ out(made.cloned ? `cloned ${made.url} into ${made.clone}` : `fetched ${made.clone}, the clone it has`);
143
+ out(`its machine profile, which wins over the repo's: ${result.project.profilePath}`);
144
+ // Claude Code trusts a worktree by its repository, and nobody has run Claude in a new clone yet.
145
+ if (made.cloned && next)
146
+ out(`next: ${trustStep(made.clone)}, then \`conduct project check ${made.name}\``);
147
+ return made;
116
148
  }
117
- // `conduct approve|revise|done`: Jason's answer to an inbox item, by id, or for approve and revise
118
- // by the item whose plan waits.
149
+ // `conduct approve|revise|done`: the operator's answer to an inbox item, by id, or for approve and revise
150
+ // by the item whose plan waits. A revise of an item with no plan waiting revises its approved plan.
119
151
  async function answer(paths, ref, request) {
120
152
  const target = request.action === 'done' ? inboxId(ref) : await planItem(paths, ref);
153
+ if (target === null) {
154
+ if (request.action === 'revise')
155
+ return reviseApproved(paths, ref, request.text);
156
+ throw new ConductorError(`no plan of ${ref} waits in the inbox`, ['`conduct inbox` lists what waits']);
157
+ }
121
158
  const { body, status } = await callApi(paths, {
122
159
  body: request,
123
160
  method: 'POST',
@@ -151,6 +188,8 @@ async function attach(paths, ref) {
151
188
  out(`${result.resumed ? 'woke' : 'attached to'} ${worker.name} (session ${result.worker.handle ?? '?'})`);
152
189
  if (result.tab)
153
190
  out(`no tab (${result.tab}); open it yourself: claude attach ${result.worker.handle ?? worker.name}`);
191
+ else if (result.hint)
192
+ out(`to see its tab: ${result.hint}`);
154
193
  }
155
194
  // The pid of the daemon once it answers /health, for upgrade's restart. failure: the detached
156
195
  // serve's spawn error, which ends the wait early.
@@ -208,34 +247,33 @@ async function consoleCommand(paths, options) {
208
247
  });
209
248
  return;
210
249
  }
211
- if (process.platform !== 'win32') {
212
- throw new ConductorError('conduct console opens a Windows Terminal tab', [
213
- 'run `conduct console --here` in a terminal of your own',
214
- ]);
215
- }
216
- const args = consoleTabArgs(paths.console, process.execPath, realpathSync(fileURLToPath(import.meta.url)));
250
+ const where = terminal({ choice: loadConfig(paths.config).workers.terminal });
251
+ const tab = consoleTab(paths.console, process.execPath, realpathSync(fileURLToPath(import.meta.url)), process.env);
217
252
  await claimConsole(paths, options.force);
218
- const result = await openWindowsTerminal(args, paths.console);
219
- if (result.code !== 0)
220
- throw new ConductorError('could not open the console tab', [
221
- outputOf(result, 500),
253
+ const failed = await where.open(tab);
254
+ if (failed !== null)
255
+ throw new ConductorError(`could not open the console tab in ${where.name}`, [
256
+ failed,
222
257
  'run `conduct console --here --force` in a terminal instead',
223
258
  ]);
224
- out(`opened the console in ${paths.console} (Windows Terminal window "${CONSOLE_WINDOW}")`);
259
+ const hint = where.attachHint(CONSOLE_WINDOW);
260
+ out(`opened the console in ${paths.console} (${hint === null ? `${where.name} window "${CONSOLE_WINDOW}"` : `${where.name}: ${hint}`})`);
225
261
  }
226
262
  // How `conduct start` and upgrade's restart start the daemon detached from this process.
227
263
  function daemonStarter(paths, cliPath) {
228
264
  let failure = () => undefined;
265
+ const task = startup();
229
266
  return {
230
267
  awaitDaemon: () => awaitDaemon(paths, failure),
231
- runTask: runStartup,
268
+ runTask: () => task.run(),
232
269
  serveDetached: () => {
233
270
  // --fail-fast: a first serve that can't start ends the supervisor, so the start says why at once.
234
271
  failure = serveDetached(paths.home, process.execPath, [cliPath, 'supervise', '--fail-fast']);
235
272
  },
236
- // The logon task's daemon uses ~/.conductor, so it can't start one kept in CONDUCTOR_HOME.
237
- taskRunsCli: () => !process.env.CONDUCTOR_HOME && startupRunsCli(cliPath),
238
- taskSupervises: startupSupervises,
273
+ taskName: task.name,
274
+ // The startup's daemon uses ~/.conductor, so it can't start one kept in CONDUCTOR_HOME.
275
+ taskRunsCli: async () => !process.env.CONDUCTOR_HOME && (await task.runsCli(cliPath)),
276
+ taskSupervises: () => task.supervises(),
239
277
  };
240
278
  }
241
279
  // `conduct escalations done`: the console reports back on an escalation.
@@ -360,11 +398,20 @@ function inboxId(ref) {
360
398
  throw new ConductorError(`"${ref}" is not an inbox item id`, ['`conduct inbox` lists them as #<id>']);
361
399
  return Number(id);
362
400
  }
363
- function install() {
401
+ // `conduct install-startup`: the machine's startup (host/startup.ts) runs this build's supervise.
402
+ async function install() {
364
403
  const cliPath = realpathSync(fileURLToPath(import.meta.url));
365
- installStartup({ cliPath, nodePath: process.execPath });
366
- out(`registered the "${TASK_NAME}" logon task: ${process.execPath} ${cliPath} supervise`);
367
- if (cliPath.includes(`${sep}.claude${sep}worktrees${sep}`)) {
404
+ const task = startup();
405
+ // Less what `pnpm exec` or `npm run` put first, which a worktree's removal takes away.
406
+ const path = (process.env.PATH ?? '')
407
+ .split(delimiter)
408
+ .filter((dir) => !/[\\/]node_modules[\\/]\.bin[\\/]?$/.test(dir))
409
+ .join(delimiter);
410
+ const notes = await task.install({ cliPath, nodePath: process.execPath, path });
411
+ out(`registered ${task.name}: ${process.execPath} ${cliPath} supervise`);
412
+ for (const note of notes)
413
+ out(note);
414
+ if (/[\\/](?:\.claude|\.conductor[\\/]projects[\\/][^\\/]+)[\\/]worktrees[\\/]/.test(cliPath)) {
368
415
  out('warning: that build is in a worktree, which goes away; run install-startup from the main checkout');
369
416
  }
370
417
  }
@@ -399,7 +446,7 @@ async function listProjects(paths, json) {
399
446
  if (json)
400
447
  return out(JSON.stringify(projects, null, 2));
401
448
  if (projects.length === 0)
402
- return out('no projects; add one with `conduct project add <path>`');
449
+ return out('no projects; add one with `conduct project add <url | path>`');
403
450
  projects.forEach((p) => out(projectLine(p)));
404
451
  }
405
452
  async function main(argv) {
@@ -407,7 +454,7 @@ async function main(argv) {
407
454
  if (argv[0] === 'wait')
408
455
  return argv.slice(1).some(isHelpFlag) ? help('wait') : wait(argv.slice(1));
409
456
  // `revise` takes free text, which option parsing would cut at a word such as --force. Every word
410
- // after the item is the text, quoted or not: a cut-off instruction would be posted as Jason's.
457
+ // after the item is the text, quoted or not: a cut-off instruction would be posted as the operator's.
411
458
  if (argv[0] === 'revise') {
412
459
  // A lone help flag as the text is a call for help: posted, it would be a revision of "--help".
413
460
  if (isHelpFlag(argv[1]) || (argv.length === 3 && isHelpFlag(argv[2])))
@@ -432,19 +479,22 @@ async function main(argv) {
432
479
  throw usageError('escalations');
433
480
  return escalationDone(conductorPaths(), ref, { ask, text });
434
481
  }
435
- // The first word that names a command: an option's value (--project atk) comes before it at times.
482
+ // The first word that names a command: an option's value (--project web) comes before it at times.
436
483
  const { positionals, values } = parsing(argv.find((word) => isCommand(word)), () => parseArgs({
437
484
  allowPositionals: true,
438
485
  args: argv,
439
486
  options: {
440
487
  'allow-downgrade': { type: 'boolean' },
441
488
  area: { type: 'string' },
489
+ base: { type: 'string' },
442
490
  'blocked-by': { multiple: true, type: 'string' },
443
491
  'body-file': { type: 'string' },
492
+ branch: { type: 'string' },
444
493
  channel: { type: 'string' },
445
494
  comments: { type: 'boolean' },
446
495
  conductor: { type: 'string' },
447
496
  console: { type: 'boolean' },
497
+ count: { type: 'string' },
448
498
  'dry-run': { type: 'boolean' },
449
499
  'fail-fast': { type: 'boolean' },
450
500
  files: { type: 'string' },
@@ -452,20 +502,24 @@ async function main(argv) {
452
502
  for: { type: 'string' },
453
503
  force: { type: 'boolean' },
454
504
  fresh: { type: 'boolean' },
505
+ grep: { type: 'string' },
455
506
  help: { short: 'h', type: 'boolean' },
456
507
  here: { type: 'boolean' },
457
508
  human: { type: 'boolean' },
458
509
  item: { type: 'string' },
459
510
  json: { type: 'boolean' },
511
+ key: { type: 'string' },
460
512
  kind: { type: 'string' },
461
513
  last: { type: 'boolean' },
462
514
  mode: { type: 'string' },
515
+ name: { type: 'string' },
463
516
  'no-worktree': { type: 'boolean' },
464
517
  owner: { type: 'string' },
465
518
  phase: { type: 'string' },
466
519
  probe: { type: 'boolean' },
467
520
  project: { type: 'string' },
468
521
  prompt: { type: 'string' },
522
+ query: { type: 'string' },
469
523
  raise: { type: 'boolean' },
470
524
  rearm: { type: 'boolean' },
471
525
  reason: { type: 'string' },
@@ -473,18 +527,23 @@ async function main(argv) {
473
527
  remove: { type: 'boolean' },
474
528
  repo: { type: 'string' },
475
529
  requeue: { type: 'string' },
530
+ shared: { type: 'boolean' },
476
531
  since: { type: 'string' },
477
532
  slots: { type: 'string' },
478
533
  tag: { type: 'string' },
534
+ task: { type: 'string' },
535
+ team: { type: 'string' },
479
536
  text: { type: 'string' },
480
537
  tier: { type: 'string' },
481
538
  title: { type: 'string' },
539
+ tracker: { type: 'string' },
482
540
  until: { type: 'string' },
483
541
  version: { short: 'v', type: 'boolean' },
484
542
  wait: { type: 'string' },
485
543
  'waits-release': { type: 'boolean' },
486
544
  weight: { type: 'string' },
487
545
  worker: { type: 'string' },
546
+ yes: { type: 'boolean' },
488
547
  },
489
548
  }));
490
549
  const paths = conductorPaths();
@@ -511,7 +570,7 @@ async function main(argv) {
511
570
  case 'branch':
512
571
  if (sub === undefined || arg === undefined)
513
572
  throw usageError('branch');
514
- process.exitCode = await branchCommand(sub, arg, item, verbIo());
573
+ process.exitCode = await branchCommand(paths, sub, arg, item, verbIo());
515
574
  return;
516
575
  case 'brief':
517
576
  if (sub === undefined)
@@ -525,7 +584,7 @@ async function main(argv) {
525
584
  case 'checkout':
526
585
  if (sub === undefined || arg === undefined || fourth === undefined)
527
586
  throw usageError('checkout');
528
- process.exitCode = await checkoutCommand(sub, arg, fourth, item, verbIo());
587
+ process.exitCode = await checkoutCommand(paths, sub, arg, fourth, item, verbIo());
529
588
  return;
530
589
  case 'comments': {
531
590
  if (sub === undefined || arg !== undefined)
@@ -554,10 +613,10 @@ async function main(argv) {
554
613
  // /reload-plugins after an upgrade) starts this package's new build, in the console's env.
555
614
  return events(paths, values.follow === true, values.since, values.console === true || process.env.CONDUCTOR_CONSOLE === '1');
556
615
  case 'file':
557
- if (sub !== undefined || !values.title || !values['body-file'] || !values.area)
616
+ if (sub !== undefined || !values.title || !values['body-file'])
558
617
  throw usageError('file');
559
618
  process.exitCode = await fileCommand(paths, {
560
- area: values.area,
619
+ area: values.area ?? '',
561
620
  blockedBy: values['blocked-by'] ?? [],
562
621
  bodyFile: values['body-file'],
563
622
  human: values.human === true,
@@ -587,6 +646,26 @@ async function main(argv) {
587
646
  });
588
647
  case 'inbox':
589
648
  return inbox(paths, values.json === true);
649
+ case 'init':
650
+ if (arg !== undefined)
651
+ throw usageError('init');
652
+ process.exitCode = await initProject({
653
+ add: (checkout, name) => addProject(paths, checkout, name, false),
654
+ ask: terminalAsk(),
655
+ check: (name) => projectCheckReport(paths, name, { worktree: true }, { err: (line) => process.stderr.write(`${line}\n`) }),
656
+ out,
657
+ paths,
658
+ }, sub ?? '.', {
659
+ shared: values.shared === true,
660
+ yes: values.yes === true,
661
+ ...(values.base === undefined ? {} : { base: values.base }),
662
+ ...(values.key === undefined ? {} : { key: values.key }),
663
+ ...(values.name === undefined ? {} : { name: values.name }),
664
+ ...(values.query === undefined ? {} : { query: values.query }),
665
+ ...(values.team === undefined ? {} : { team: values.team }),
666
+ ...(values.tracker === undefined ? {} : { tracker: values.tracker }),
667
+ });
668
+ return;
590
669
  case 'install-startup':
591
670
  return values.remove ? uninstall() : install();
592
671
  case 'land': {
@@ -606,6 +685,7 @@ async function main(argv) {
606
685
  ...repo,
607
686
  ...(mode === undefined ? {} : { mode }),
608
687
  ...(tier === undefined ? {} : { tier }),
688
+ ...(values.requeue === undefined ? {} : { requeue: wholeNumber('--requeue', values.requeue) }),
609
689
  }, verbIo());
610
690
  return;
611
691
  }
@@ -633,7 +713,7 @@ async function main(argv) {
633
713
  throw usageError('mode');
634
714
  if (tier !== 'S' && tier !== 'M' && tier !== 'L')
635
715
  throw new ConductorError('--tier takes S, M or L');
636
- process.exitCode = await modeCommand({ files: values.files, tier }, verbIo());
716
+ process.exitCode = await modeCommand(paths, { files: values.files, tier }, verbIo());
637
717
  return;
638
718
  }
639
719
  case 'note':
@@ -665,10 +745,20 @@ async function main(argv) {
665
745
  return notifyTest(paths, { channel: values.channel, kind: values.kind, raise: values.raise === true });
666
746
  throw usageError('notify');
667
747
  case 'pr':
748
+ if (sub === 'abandon' && arg !== undefined) {
749
+ if (values.reason === undefined)
750
+ throw usageError('pr');
751
+ process.exitCode = await prAbandonCommand(paths, { force: values.force === true, pr: wholeNumber('<pr>', arg), reason: values.reason, ...item, ...repo }, verbIo());
752
+ return;
753
+ }
668
754
  if (sub === 'threads' && arg !== undefined) {
669
755
  process.exitCode = await prThreadsCommand(paths, { pr: wholeNumber('<pr>', arg), ...repo }, verbIo());
670
756
  return;
671
757
  }
758
+ if (sub === 'status' && arg !== undefined) {
759
+ process.exitCode = await prStatusCommand(paths, { pr: wholeNumber('<pr>', arg), ...repo }, verbIo());
760
+ return;
761
+ }
672
762
  if (sub !== 'create' || values.title === undefined || values['body-file'] === undefined)
673
763
  throw usageError('pr');
674
764
  process.exitCode = await prCreateCommand(paths, { bodyFile: values['body-file'], dryRun: values['dry-run'] === true, title: values.title, ...item, ...repo }, verbIo());
@@ -682,9 +772,21 @@ async function main(argv) {
682
772
  case 'ports':
683
773
  process.exitCode = await portsCommand(paths, sub, { json: values.json === true }, brokerIo());
684
774
  return;
775
+ case 'profile': {
776
+ if (sub === undefined || arg !== undefined)
777
+ throw usageError('profile');
778
+ // Resolved here from the machine file and the clone, as the daemon resolves it.
779
+ const layered = projectProfile(paths, sub);
780
+ if (values.json === true)
781
+ return out(JSON.stringify({ profile: layered.profile, sources: layered.sources }, null, 2));
782
+ profileLines(sub, layered).forEach((line) => out(line));
783
+ return;
784
+ }
685
785
  case 'project':
686
- if (sub === 'add' && arg !== undefined)
687
- return addProject(paths, arg);
786
+ if (sub === 'add' && arg !== undefined) {
787
+ await addProject(paths, arg, values.name);
788
+ return;
789
+ }
688
790
  if (sub === 'remove' && arg !== undefined)
689
791
  return removeProject(paths, arg);
690
792
  if (sub === 'list')
@@ -725,6 +827,7 @@ async function main(argv) {
725
827
  ...item,
726
828
  ...repo,
727
829
  ...(values.for === undefined ? {} : { for: values.for }),
830
+ ...(values.grep === undefined ? {} : { grep: values.grep }),
728
831
  ...(values.requeue === undefined ? {} : { requeue: values.requeue }),
729
832
  }, verbIo());
730
833
  return;
@@ -750,6 +853,19 @@ async function main(argv) {
750
853
  case 'supervise':
751
854
  process.exitCode = await superviseCommand(paths, values['fail-fast'] === true);
752
855
  return;
856
+ case 'timeline':
857
+ if (arg !== undefined)
858
+ throw usageError('timeline');
859
+ process.exitCode = await timelineCommand(paths, {
860
+ ...repo,
861
+ ...(sub === undefined ? {} : { run: sub }),
862
+ ...(values.branch === undefined ? {} : { branch: values.branch }),
863
+ ...(values.count === undefined ? {} : { count: values.count }),
864
+ ...(values.for === undefined ? {} : { for: values.for }),
865
+ ...(values.grep === undefined ? {} : { grep: values.grep }),
866
+ ...(values.task === undefined ? {} : { task: values.task }),
867
+ }, verbIo());
868
+ return;
753
869
  case 'train':
754
870
  return trainCommand(paths, sub, { dryRun: values['dry-run'] === true }, { out });
755
871
  case 'unpause':
@@ -805,7 +921,7 @@ async function notifyTest(paths, options) {
805
921
  ...(options.kind ? { kind: options.kind } : {}),
806
922
  raise: options.raise,
807
923
  };
808
- // Pushover can take its 15 s, after a Credential Manager read.
924
+ // Pushover can take its 15 s, after a secret store read.
809
925
  const answer = await notifyAnswer(paths, { body, method: 'POST', path: '/notify/test', timeoutMs: 60_000 });
810
926
  if (options.raise) {
811
927
  const { item } = z.object({ item: inboxItemSchema }).parse(answer);
@@ -883,33 +999,35 @@ function parsing(command, parse) {
883
999
  : new ConductorError(message, ['run `conduct help` for the commands']);
884
1000
  }
885
1001
  }
886
- // The open plan item of `ref`: the item (ATK-312) whose plan waits, or an inbox id written #12. A
887
- // bare number is refused: it could be an issue's number, and the answer is posted as Jason's.
1002
+ // The open plan item of `ref`: the item (ENG-312) whose plan waits, or an inbox id written #12; null
1003
+ // when no plan of the item waits. A bare number is refused: it could be an issue's number, and the
1004
+ // answer is posted as the operator's.
888
1005
  async function planItem(paths, ref) {
889
1006
  if (ref.startsWith('#'))
890
1007
  return inboxId(ref);
1008
+ const items = await openItems(paths);
891
1009
  if (/^\d+$/.test(ref)) {
892
- throw new ConductorError(`"${ref}" is ambiguous`, [
893
- `name the item, such as ATK-${ref}, or the inbox id as #${ref}`,
894
- ]);
1010
+ // The waiting plans whose key ends in the number, else a placeholder: the project's key isn't known here.
1011
+ const keys = (items ?? []).flatMap((i) => (i.kind === 'plan' && i.item?.endsWith(`-${ref}`) ? [i.item] : []));
1012
+ const item = keys.length > 0 ? keys.join(' or ') : `<KEY>-${ref}`;
1013
+ throw new ConductorError(`"${ref}" is ambiguous`, [`name the item, such as ${item}, or the inbox id as #${ref}`]);
895
1014
  }
896
- const items = await openItems(paths);
897
1015
  if (items === null)
898
1016
  throw noInbox();
899
1017
  const plans = items.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.toUpperCase());
900
1018
  if (plans.length === 1 && plans[0])
901
1019
  return plans[0].id;
902
1020
  if (plans.length === 0)
903
- throw new ConductorError(`no plan of ${ref} waits in the inbox`, ['`conduct inbox` lists what waits']);
1021
+ return null;
904
1022
  throw new ConductorError(`several plans of ${ref} wait`, plans.map((p) => `#${p.id} in ${p.project ?? '?'}: pass #${p.id}`));
905
1023
  }
906
1024
  function projectLine(p) {
907
1025
  const state = p.problem
908
- ? `${p.problem}; the daemon reads ${p.profilePath} again when it starts or the file is saved`
1026
+ ? `${p.problem}; the daemon reads the profile again when it starts, when ~/.conductor/projects/${p.name}.json is saved, and after each fetch of the clone`
909
1027
  : p.enabled
910
1028
  ? 'enabled'
911
1029
  : 'disabled';
912
- return ` ${p.name} ${p.root} (protocol ${p.protocol}, ${state})`;
1030
+ return ` ${p.name} ${p.clone} (protocol ${p.protocol}, ${state})`;
913
1031
  }
914
1032
  // The registered projects: from the daemon, or from the store when it isn't running.
915
1033
  async function projectsNow(paths) {
@@ -954,7 +1072,7 @@ async function removeProject(paths, name) {
954
1072
  });
955
1073
  }
956
1074
  const { project, resolved } = result;
957
- out(`removed ${project.name} (${project.root})`);
1075
+ out(`removed ${project.name} (${project.clone})`);
958
1076
  for (const item of resolved)
959
1077
  out(` resolved #${item.id} (${item.kind}${item.item ? ` ${item.item}` : ''})`);
960
1078
  }
@@ -1004,6 +1122,45 @@ async function retire(paths, ref, force) {
1004
1122
  out(`retired ${worker.name}`);
1005
1123
  steps.forEach((s) => out(` ${s}`));
1006
1124
  }
1125
+ // `conduct revise <item>` with no plan of the item in the inbox: the daemon posts the revise on an
1126
+ // approved plan and wakes its worker at once, as its revise sweep would (ATK-664).
1127
+ async function reviseApproved(paths, item, text) {
1128
+ const { body, status } = await callApi(paths, {
1129
+ body: { text },
1130
+ method: 'POST',
1131
+ path: `/items/${encodeURIComponent(item)}/revise`,
1132
+ timeoutMs: REVISE_TIMEOUT_MS,
1133
+ });
1134
+ if (status !== 200) {
1135
+ const refusal = answerRefusal.safeParse(body).data;
1136
+ const details = [...(refusal?.details ?? [])];
1137
+ if (status === 502 && refusal?.posted === 'unknown')
1138
+ details.push('the comment may be on the item already: look before answering again');
1139
+ // A conductor from before ATK-664 has no such route.
1140
+ if (status === 404)
1141
+ throw new ConductorError(`no plan of ${item} waits in the inbox`, [
1142
+ '`conduct inbox` lists what waits',
1143
+ 'a conductor started before ATK-664 revises no approved plan: restart it for that',
1144
+ ]);
1145
+ throw new ConductorError(refusal?.error ?? `the API answered ${status}`, details);
1146
+ }
1147
+ const r = reviseAnswer.parse(body).revise;
1148
+ out(`asked for a revision of ${r.item}'s approved plan: ${r.comment.url ?? r.comment.id}`);
1149
+ out(reviseOutcome(r));
1150
+ }
1151
+ // What became of a revise of an approved plan after its comment, in one line.
1152
+ function reviseOutcome(r) {
1153
+ const none = 'no worker is on it: one launched on it reads the revise in its brief';
1154
+ if (r.pending !== null)
1155
+ return `nothing recorded yet (${r.pending}): ${r.worker ? 'the revise sweep records it and wakes its worker within 5 minutes' : none}`;
1156
+ if (!r.revised)
1157
+ return 'a revise of its plan was recorded already: its worker re-plans on the first one';
1158
+ if (r.woken > 0)
1159
+ return 'woke its worker to re-plan';
1160
+ if (r.landed)
1161
+ return 'its PR has merged: its worker learns of it at its next note, for a follow-up PR';
1162
+ return r.worker ? 'its worker is running: its next note is held back, and it re-plans' : none;
1163
+ }
1007
1164
  // `conduct rules`: from the daemon, or straight from the files when it isn't running.
1008
1165
  async function rules(paths, json) {
1009
1166
  const view = await rulesAnswer(paths, { method: 'GET', path: '/rules' }, (body) => rulesViewSchema.parse(body), (service) => service.view());
@@ -1084,7 +1241,7 @@ async function shutdownDaemon(paths) {
1084
1241
  if (readEndpoint(paths) !== null) {
1085
1242
  throw new ConductorError(`the conductor (pid ${pid ?? '?'}) did not stop within ${STOP_WAIT_MS / 1000} s`);
1086
1243
  }
1087
- // The port file goes before the process ends; a start (the logon task's especially) waits for it.
1244
+ // The port file goes before the process ends; a start (the logon startup's especially) waits for it.
1088
1245
  const exitDeadline = Date.now() + STOP_WAIT_MS;
1089
1246
  while (pid !== null && processAlive(pid) && Date.now() < exitDeadline)
1090
1247
  await sleep(100);
@@ -1100,7 +1257,7 @@ async function start(paths) {
1100
1257
  const pid = readPid(paths.pid);
1101
1258
  if (pid !== null && (await ping(paths, pid)))
1102
1259
  return out(`the conductor is running (pid ${pid})`);
1103
- // A daemon that is still starting (the logon task's, say) holds the lock: wait for it rather than
1260
+ // A daemon that is still starting (the logon startup's, say) holds the lock: wait for it rather than
1104
1261
  // start a second one, which would exit on that lock. A pid a reboot reused gets a start after.
1105
1262
  if (pid !== null && processAlive(pid)) {
1106
1263
  try {
@@ -1148,7 +1305,7 @@ async function status(paths, json) {
1148
1305
  if (json)
1149
1306
  return out(JSON.stringify({ home: paths.home, inbox: items, locks, pid, port, projects, running: true, workers, workersFrom: 'daemon' }, null, 2));
1150
1307
  out(`conductor running (pid ${pid ?? '?'}, port ${port ?? '?'}, home ${paths.home})`);
1151
- out(projects.length === 0 ? 'no projects; add one with `conduct project add <path>`' : 'projects:');
1308
+ out(projects.length === 0 ? 'no projects; add one with `conduct project add <url | path>`' : 'projects:');
1152
1309
  projects.forEach((p) => out(projectLine(p)));
1153
1310
  out(workers.length === 0 ? 'no workers' : 'workers:');
1154
1311
  workers.forEach((w) => out(workerLine(w)));
@@ -1186,7 +1343,7 @@ async function stopDaemon(paths) {
1186
1343
  out('the conductor is not running');
1187
1344
  return false;
1188
1345
  case 'supervisor': {
1189
- // It answers just before it ends; a start (the logon task's especially) needs it gone.
1346
+ // It answers just before it ends; a start (the logon startup's especially) needs it gone.
1190
1347
  const deadline = Date.now() + STOP_WAIT_MS;
1191
1348
  while (processAlive(stop.pid) && Date.now() < deadline)
1192
1349
  await sleep(100);
@@ -1195,7 +1352,7 @@ async function stopDaemon(paths) {
1195
1352
  }
1196
1353
  }
1197
1354
  }
1198
- // `conduct supervise`: keeps `conduct serve` running (supervise.ts, ATK-417). The logon task runs
1355
+ // `conduct supervise`: keeps `conduct serve` running (supervise.ts, ATK-417). The logon startup runs
1199
1356
  // it; `conduct start` runs it with --fail-fast. The config is read again for each push, so one fixed
1200
1357
  // while the daemon is down counts, and one that doesn't load (a reason it may be down) leaves the
1201
1358
  // defaults: no quiet hours, the default credential.
@@ -1217,7 +1374,7 @@ async function superviseCommand(paths, failFast) {
1217
1374
  rotatingLog(paths.log, config().log).log(...line);
1218
1375
  }
1219
1376
  catch {
1220
- // Nowhere else to say it: under the logon task nobody reads stderr.
1377
+ // Nowhere else to say it: under the logon startup nobody reads stderr.
1221
1378
  }
1222
1379
  },
1223
1380
  };
@@ -1293,8 +1450,9 @@ async function superviseCommand(paths, failFast) {
1293
1450
  }
1294
1451
  return code;
1295
1452
  }
1296
- function uninstall() {
1297
- out(removeStartup() ? `removed the "${TASK_NAME}" logon task` : `no "${TASK_NAME}" logon task to remove`);
1453
+ async function uninstall() {
1454
+ const task = startup();
1455
+ out((await task.remove()) ? `removed ${task.name}` : `no ${task.name} to remove`);
1298
1456
  }
1299
1457
  // --until: 17:00 (the next one), tomorrow, a day such as monday, or a date and time.
1300
1458
  function untilOf(text) {