@detergent-software/conductor 3.1.0-dev.4 → 3.1.0-dev.5
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 +10 -0
- package/README.md +7 -4
- package/build/cli.js +25 -3
- package/build/cli.js.map +1 -1
- package/build/help.js +14 -4
- package/build/help.js.map +1 -1
- package/build/inbox.js +22 -4
- package/build/inbox.js.map +1 -1
- package/build/server.js +3 -2
- package/build/server.js.map +1 -1
- package/package.json +1 -1
- package/plugin/hooks/model.ts +41 -19
- package/plugin/hooks/register.tsx +50 -13
- package/plugin/skills/conductor-console/SKILL.md +3 -2
package/package.json
CHANGED
package/plugin/hooks/model.ts
CHANGED
|
@@ -303,26 +303,21 @@ export function errorOf(answer: ApiAnswer): string {
|
|
|
303
303
|
return `the conductor answered ${answer.status}`;
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
// The open scope, blocked or human Handoff of `ref` that /answer posts on (ATK-682), as findPlan
|
|
307
|
+
// finds a plan: an item key, or an inbox id as #12 (any kind; the daemon refuses the rest).
|
|
308
|
+
export function findHandoff(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
|
|
309
|
+
return findOpen(inbox, ref, ANSWERED_KINDS, { none: 'no Handoff of', several: 'several Handoffs of' });
|
|
310
|
+
}
|
|
311
|
+
|
|
306
312
|
// The open plan of `ref` (as `conduct approve` finds it): an item key, or an inbox id as #12.
|
|
307
313
|
export function findPlan(inbox: readonly ConductorInboxItem[], ref: string): Found<ConductorInboxItem> {
|
|
308
|
-
|
|
309
|
-
if (byId) {
|
|
310
|
-
const item = inbox.find((i) => i.id === Number(byId[1]));
|
|
311
|
-
return item ? { found: item } : { error: `no open inbox item ${ref}` };
|
|
312
|
-
}
|
|
313
|
-
if (/^\d+$/.test(ref.trim())) {
|
|
314
|
-
const n = ref.trim();
|
|
315
|
-
// The waiting plans whose key ends in the number, else a placeholder: the project's key isn't known here.
|
|
316
|
-
const keys = inbox.flatMap((i) => (i.kind === 'plan' && i.item?.endsWith(`-${n}`) ? [i.item] : []));
|
|
317
|
-
const item = keys.length > 0 ? keys.join(' or ') : `<KEY>-${n}`;
|
|
318
|
-
return { error: `"${ref}" is ambiguous: name the item, such as ${item}, or the inbox id as #${n}` };
|
|
319
|
-
}
|
|
320
|
-
const plans = inbox.filter((i) => i.kind === 'plan' && i.item?.toUpperCase() === ref.trim().toUpperCase());
|
|
321
|
-
if (plans.length === 1 && plans[0]) return { found: plans[0] };
|
|
322
|
-
if (plans.length === 0) return { error: `no plan of ${ref} waits in the inbox` };
|
|
323
|
-
return { error: `several plans of ${ref} wait: name one as ${plans.map((p) => `#${p.id}`).join(' or ')}` };
|
|
314
|
+
return findOpen(inbox, ref, PLAN_KINDS, { none: 'no plan of', several: 'several plans of' });
|
|
324
315
|
}
|
|
325
316
|
|
|
317
|
+
// The inbox kinds /answer offers on: those whose worker waits on an answer on the item (ATK-681).
|
|
318
|
+
export const ANSWERED_KINDS: readonly string[] = ['scope', 'blocked', 'human'];
|
|
319
|
+
const PLAN_KINDS: readonly string[] = ['plan'];
|
|
320
|
+
|
|
326
321
|
// A worker in flight by name or item; a name match wins, as `conduct attach` finds it.
|
|
327
322
|
export function findWorker(workers: readonly ConductorWorker[], ref: string): Found<ConductorWorker> {
|
|
328
323
|
const byName = workers.filter((w) => w.name === ref);
|
|
@@ -541,6 +536,30 @@ export function tokens(n: number): string {
|
|
|
541
536
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
542
537
|
}
|
|
543
538
|
|
|
539
|
+
function findOpen(
|
|
540
|
+
inbox: readonly ConductorInboxItem[],
|
|
541
|
+
ref: string,
|
|
542
|
+
kinds: readonly string[],
|
|
543
|
+
say: { none: string; several: string },
|
|
544
|
+
): Found<ConductorInboxItem> {
|
|
545
|
+
const byId = /^#(\d+)$/.exec(ref.trim());
|
|
546
|
+
if (byId) {
|
|
547
|
+
const item = inbox.find((i) => i.id === Number(byId[1]));
|
|
548
|
+
return item ? { found: item } : { error: `no open inbox item ${ref}` };
|
|
549
|
+
}
|
|
550
|
+
if (/^\d+$/.test(ref.trim())) {
|
|
551
|
+
const n = ref.trim();
|
|
552
|
+
// The waiting items whose key ends in the number, else a placeholder: the project's key isn't known here.
|
|
553
|
+
const keys = inbox.flatMap((i) => (kinds.includes(i.kind) && i.item?.endsWith(`-${n}`) ? [i.item] : []));
|
|
554
|
+
const item = keys.length > 0 ? keys.join(' or ') : `<KEY>-${n}`;
|
|
555
|
+
return { error: `"${ref}" is ambiguous: name the item, such as ${item}, or the inbox id as #${n}` };
|
|
556
|
+
}
|
|
557
|
+
const found = inbox.filter((i) => kinds.includes(i.kind) && i.item?.toUpperCase() === ref.trim().toUpperCase());
|
|
558
|
+
if (found.length === 1 && found[0]) return { found: found[0] };
|
|
559
|
+
if (found.length === 0) return { error: `${say.none} ${ref} waits in the inbox` };
|
|
560
|
+
return { error: `${say.several} ${ref} wait: name one as ${found.map((p) => `#${p.id}`).join(' or ')}` };
|
|
561
|
+
}
|
|
562
|
+
|
|
544
563
|
// `text` in exactly `cells` cells: padded, or cut with an ellipsis.
|
|
545
564
|
function fit(text: string, cells: number): string {
|
|
546
565
|
if (text.length <= cells) return text.padEnd(cells);
|
|
@@ -551,10 +570,12 @@ function fit(text: string, cells: number): string {
|
|
|
551
570
|
export const MAYBE_POSTED = 'the comment may be on the item already: look before answering again';
|
|
552
571
|
|
|
553
572
|
// POST /inbox/:id/resolve's answer as the line to show, as `conduct approve|revise|done` says it.
|
|
554
|
-
|
|
573
|
+
// `answer` is done with an answer posted on a Handoff's item (ATK-682).
|
|
574
|
+
export function answerText(action: 'answer' | 'approve' | 'done' | 'revise', id: number, answer: ApiAnswer): string {
|
|
555
575
|
if (answer.status !== 200) {
|
|
556
576
|
const unknown = answer.status === 502 && isRecord(answer.body) && answer.body.posted === 'unknown';
|
|
557
|
-
|
|
577
|
+
const not = action === 'done' ? 'done' : action === 'answer' ? 'answered' : `${action}d`;
|
|
578
|
+
return `not ${not}: ${errorOf(answer)}${unknown ? `; ${MAYBE_POSTED}` : ''}`;
|
|
558
579
|
}
|
|
559
580
|
const body = isRecord(answer.body) ? answer.body : {};
|
|
560
581
|
const key = isRecord(body.item) && typeof body.item.item === 'string' ? body.item.item : `#${id}`;
|
|
@@ -562,7 +583,8 @@ export function answerText(action: 'approve' | 'done' | 'revise', id: number, an
|
|
|
562
583
|
const comment = isRecord(body.comment) ? body.comment : {};
|
|
563
584
|
const where =
|
|
564
585
|
typeof comment.url === 'string' ? comment.url : typeof comment.id === 'string' ? comment.id : 'comment posted';
|
|
565
|
-
|
|
586
|
+
const did = { answer: 'answered', approve: 'approved', revise: 'asked for a revision of' }[action];
|
|
587
|
+
return `${did} ${key}: ${where}`;
|
|
566
588
|
}
|
|
567
589
|
|
|
568
590
|
// The item a /revise names when no plan of it waits in the inbox, which `conduct revise` then revises
|
|
@@ -6,6 +6,7 @@ import type { ConductorSnapshot } from '../types';
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
age,
|
|
9
|
+
ANSWERED_KINDS,
|
|
9
10
|
answerText,
|
|
10
11
|
type ApiAnswer,
|
|
11
12
|
approvedPlanItem,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
CONSOLE_SKILL,
|
|
16
17
|
daemonOf,
|
|
17
18
|
endpointOf,
|
|
19
|
+
findHandoff,
|
|
18
20
|
findPlan,
|
|
19
21
|
findWorker,
|
|
20
22
|
fleetColumns,
|
|
@@ -105,18 +107,25 @@ let isWindows = false;
|
|
|
105
107
|
type Endpoint = { port: number; token: string };
|
|
106
108
|
|
|
107
109
|
// The operator's answer to an inbox item, as `conduct approve|revise|done` posts it; the line to show.
|
|
108
|
-
// Approve and
|
|
109
|
-
// $.http.fetch cuts off after 30 s, possibly after the comment was
|
|
110
|
+
// Approve, revise and done with an answer (ATK-682) run `conduct` (ANSWER_RUN_MS), by inbox id.
|
|
111
|
+
// Without the CLI, the API, which $.http.fetch cuts off after 30 s, possibly after the comment was
|
|
112
|
+
// posted. A plain done doesn't ask Linear.
|
|
110
113
|
async function answer(
|
|
111
114
|
$: EngineInterface,
|
|
112
115
|
id: number,
|
|
113
|
-
body: { action: 'approve' | 'done' } | { action: 'revise'; text: string },
|
|
116
|
+
body: { action: 'approve' | 'done' } | { action: 'done'; answer: string } | { action: 'revise'; text: string },
|
|
114
117
|
): Promise<string> {
|
|
118
|
+
const action = 'answer' in body ? 'answer' : body.action;
|
|
115
119
|
try {
|
|
116
120
|
const cli = await cliPath($);
|
|
117
|
-
if (
|
|
118
|
-
const failed = `not ${
|
|
119
|
-
const argv =
|
|
121
|
+
if (action !== 'done' && (await $.fs.exists(cli))) {
|
|
122
|
+
const failed = action === 'answer' ? 'not answered' : `not ${action}d`;
|
|
123
|
+
const argv =
|
|
124
|
+
'answer' in body
|
|
125
|
+
? ['done', `#${id}`, '--answer', body.answer]
|
|
126
|
+
: body.action === 'revise'
|
|
127
|
+
? ['revise', `#${id}`, body.text]
|
|
128
|
+
: ['approve', `#${id}`];
|
|
120
129
|
let run;
|
|
121
130
|
try {
|
|
122
131
|
run = await $.process.run(['node', cli, ...argv], { ...(await cliInit($)), timeoutMs: ANSWER_RUN_MS });
|
|
@@ -124,21 +133,20 @@ async function answer(
|
|
|
124
133
|
return `${failed}: ${error instanceof Error ? error.message : String(error)}`;
|
|
125
134
|
}
|
|
126
135
|
refreshSoon($);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
ok: `${body.action === 'approve' ? 'approved' : 'asked for a revision of'} #${id}`,
|
|
130
|
-
});
|
|
136
|
+
const did = { answer: 'answered', approve: 'approved', revise: 'asked for a revision of' }[action];
|
|
137
|
+
return runText(run, { failed, ok: `${did} #${id}` });
|
|
131
138
|
}
|
|
132
139
|
const at = await endpoint($);
|
|
133
140
|
let result: ApiAnswer;
|
|
134
141
|
try {
|
|
135
142
|
result = await callApi($, 'POST', `/inbox/${id}/resolve`, body, at);
|
|
136
143
|
} catch (error) {
|
|
137
|
-
if (
|
|
138
|
-
|
|
144
|
+
if (action === 'done') throw error;
|
|
145
|
+
const not = action === 'answer' ? 'answered' : `${action}d`;
|
|
146
|
+
return `not ${not}: ${error instanceof Error ? error.message : String(error)}; ${MAYBE_POSTED}`;
|
|
139
147
|
}
|
|
140
148
|
refreshSoon($);
|
|
141
|
-
return answerText(
|
|
149
|
+
return answerText(action, id, result);
|
|
142
150
|
} catch (error) {
|
|
143
151
|
return error instanceof Error ? error.message : String(error);
|
|
144
152
|
}
|
|
@@ -511,6 +519,11 @@ async function registerCommands($: EngineInterface): Promise<void> {
|
|
|
511
519
|
description: 'Ask for a revised plan (posts revise: … as you)',
|
|
512
520
|
name: 'revise',
|
|
513
521
|
});
|
|
522
|
+
await $.command.register({
|
|
523
|
+
argumentHint: '<item | #id> <your answer>',
|
|
524
|
+
description: 'Answer a scope, blocked or human Handoff (posts it on the item as you)',
|
|
525
|
+
name: 'answer',
|
|
526
|
+
});
|
|
514
527
|
await $.command.register({
|
|
515
528
|
argumentHint: '<item | worker>',
|
|
516
529
|
description: 'Wake a parked worker and open it in a tab',
|
|
@@ -741,6 +754,22 @@ export const register: Register = (on) => {
|
|
|
741
754
|
return { text: approved === null ? plan.error : await reviseApproved($, approved, text) };
|
|
742
755
|
});
|
|
743
756
|
|
|
757
|
+
on('command.run', { command: 'answer' }, async ($, e) => {
|
|
758
|
+
// The item, then the answer as typed, as /revise takes it.
|
|
759
|
+
const typed = /^(\S+)\s+([\s\S]*\S)\s*$/.exec(e.args.trim());
|
|
760
|
+
const ref = typed?.[1] ?? '';
|
|
761
|
+
const text = typed?.[2] ?? '';
|
|
762
|
+
// A lone help flag would be `conduct done --answer --help`: the help, with nothing posted.
|
|
763
|
+
if (ref === '' || text === '' || /^(-h|--help)$/.test(text))
|
|
764
|
+
return { text: 'usage: /answer <item | #id> <your answer>' };
|
|
765
|
+
const snap = await refresh($);
|
|
766
|
+
if (snap.error !== null) return { text: snap.error };
|
|
767
|
+
const handoff = findHandoff(snap.inbox, ref);
|
|
768
|
+
return {
|
|
769
|
+
text: handoff.found ? await answer($, handoff.found.id, { action: 'done', answer: text }) : handoff.error,
|
|
770
|
+
};
|
|
771
|
+
});
|
|
772
|
+
|
|
744
773
|
on('command.run', { command: 'attach' }, async ($, e) => {
|
|
745
774
|
const ref = e.args.trim();
|
|
746
775
|
if (ref === '') return { text: 'usage: /attach <item | worker>' };
|
|
@@ -918,6 +947,14 @@ export const register: Register = (on) => {
|
|
|
918
947
|
onPress={() => $.prompt.fill({ text: `/revise ${item.item ?? ''} ` })}
|
|
919
948
|
/>
|
|
920
949
|
)}
|
|
950
|
+
{ANSWERED_KINDS.includes(item.kind) && item.item !== null && (
|
|
951
|
+
<Button
|
|
952
|
+
key={`answer-${item.id}`}
|
|
953
|
+
label='answer'
|
|
954
|
+
onPress={() => $.prompt.fill({ text: `/answer #${item.id} ` })}
|
|
955
|
+
variant='primary'
|
|
956
|
+
/>
|
|
957
|
+
)}
|
|
921
958
|
{item.kind === 'triage' && item.item !== null && (
|
|
922
959
|
<Button
|
|
923
960
|
key={`queue-${item.id}`}
|
|
@@ -5,7 +5,7 @@ description: Be the conductor console - the one always-open Claude session besid
|
|
|
5
5
|
|
|
6
6
|
# conductor-console
|
|
7
7
|
|
|
8
|
-
You are **the console**, the one Claude session that stays open beside **the daemon** (`conduct serve`, package `@detergent-software/conductor`). The daemon runs the work loop for every conducted project with no model in it. It picks items, launches and retires **workers** (one Claude session per item), parks and resumes them, raises an inbox of what waits on the operator, and pushes it to their phone. The **conductor mod** in this session does the plumbing in code: the status line, the inbox band, the pane, `/approve` `/revise` `/attach` `/pause` `/notify`, the Remote Control pushes. Your model is woken for two things only:
|
|
8
|
+
You are **the console**, the one Claude session that stays open beside **the daemon** (`conduct serve`, package `@detergent-software/conductor`). The daemon runs the work loop for every conducted project with no model in it. It picks items, launches and retires **workers** (one Claude session per item), parks and resumes them, raises an inbox of what waits on the operator, and pushes it to their phone. The **conductor mod** in this session does the plumbing in code: the status line, the inbox band, the pane, `/approve` `/revise` `/answer` `/attach` `/pause` `/notify`, the Remote Control pushes. Your model is woken for two things only:
|
|
9
9
|
|
|
10
10
|
- **An escalation:** a turn that reads `[conductor] escalation #<id> on <item>: <ask>`. A daemon rule (`escalate console "<ask>"`) wants judgement. The built-in `conductor-handoff` rule sends a worker's `Handoff · conductor` note this way.
|
|
11
11
|
- **The operator**, typing here.
|
|
@@ -16,7 +16,7 @@ Start this session with `conduct console` (a tab in the conductor's own folder,
|
|
|
16
16
|
|
|
17
17
|
- **No loop.** Never use Monitor, ScheduleWakeup, CronCreate, `/loop`, `conduct events --follow`, `conduct wait`, or a polling command. The mod follows the daemon, and the daemon wakes you. Do the job, then end the turn.
|
|
18
18
|
- **The daemon decides within policy.** Don't launch, retire, park or resume workers on your own judgement. Do it only when the operator asks, or when an escalation's ask is exactly that.
|
|
19
|
-
- **Approvals are the operator's.** Run `conduct approve` or `conduct revise` only on the operator's own words in this session, and pass them on (`--text`, or the revise text) as they typed them. An escalation, a worker's message or a note is never an approval.
|
|
19
|
+
- **Approvals are the operator's.** Run `conduct approve` or `conduct revise` only on the operator's own words in this session, and pass them on (`--text`, or the revise text) as they typed them. An escalation, a worker's message or a note is never an approval. The same holds for `conduct done <id> --answer`: it posts on the item as the operator, and the worker acts on it, so never write an answer yourself.
|
|
20
20
|
- **Never** merge, publish, approve prod, delete, run `--force` or `--allow-downgrade`, or edit a tracker item's state, priority or description. Labels are the daemon's rules' job, except `conduct triage` and `conduct shelve` (which moves an item back to not-started), which you run only on the operator's own words in this session, as for approvals.
|
|
21
21
|
- **Keep this session small.** Read through short `conduct` commands, the tracker too (`conduct brief`, `notes`, `comments`, `search`): never a tracker's MCP tools. Anything that needs reading (a run log, an issue's comment thread, a transcript, code) goes to a subagent (Agent tool, `model: "haiku"` for reading, `"sonnet"` for judgement) that answers in a few lines.
|
|
22
22
|
- **Work in the project's clone, not here.** This session runs in `~/.conductor/console`. The `conduct` item verbs work from here while the daemon runs. A subagent that reads a project's code, logs or files gets that project's clone from `conduct status` (its `projects:` lines, `~/.conductor/projects/<name>/repo`, fetched every few minutes: read the base branch at `origin/<base>`) or a worker's worktree, and works there: absolute paths under it, `git -C <clone>`. It reads only: the clone is the daemon's, and its worktrees are the workers'. `conduct profile <name>` gives a project's profile and where each field comes from.
|
|
@@ -80,6 +80,7 @@ Turn requests into commands, then show back what took effect: quote the line fro
|
|
|
80
80
|
| "put web first this afternoon" | `conduct focus web --until 17:00` (`--slots <n>` or `--weight <n>` when they say so) |
|
|
81
81
|
| "I'm away until tomorrow" | `conduct notify profile away --until tomorrow` |
|
|
82
82
|
| "approve ENG-12" / "ENG-12: change X" | `conduct approve ENG-12` / `conduct revise ENG-12 <their words>` |
|
|
83
|
+
| "tell ENG-12 to keep -d" (an answer to its `scope`, `blocked` or `human` Handoff) | `conduct done <its inbox id> --answer <their words>`: posts their words on the item, and its worker resumes once the conductor reads them. `conduct inbox` gives the id |
|
|
83
84
|
| "queue ENG-40" / "hold ENG-40" (a new item in triage) | `conduct triage ENG-40 --queue` / `conduct triage ENG-40 --hold` |
|
|
84
85
|
| "start ENG-40 now" | `conduct launch ENG-40` |
|
|
85
86
|
| "show me ENG-12" | `conduct attach ENG-12` (opens its tab) |
|