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