@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@detergent-software/conductor",
3
- "version": "3.1.0-dev.4",
3
+ "version": "3.1.0-dev.5",
4
4
  "description": "Conductor: the local daemon that runs conducted projects' work loops (conduct serve) and its conduct CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- const byId = /^#(\d+)$/.exec(ref.trim());
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
- export function answerText(action: 'approve' | 'done' | 'revise', id: number, answer: ApiAnswer): string {
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
- return `not ${action === 'done' ? 'done' : `${action}d`}: ${errorOf(answer)}${unknown ? `; ${MAYBE_POSTED}` : ''}`;
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
- return `${action === 'approve' ? 'approved' : 'asked for a revision of'} ${key}: ${where}`;
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 revise run `conduct` (ANSWER_RUN_MS), by inbox id. Without the CLI, the API, which
109
- // $.http.fetch cuts off after 30 s, possibly after the comment was posted. Done doesn't ask Linear.
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 (body.action !== 'done' && (await $.fs.exists(cli))) {
118
- const failed = `not ${body.action}d`;
119
- const argv = body.action === 'revise' ? ['revise', `#${id}`, body.text] : ['approve', `#${id}`];
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
- return runText(run, {
128
- failed,
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 (body.action === 'done') throw error;
138
- return `not ${body.action}d: ${error instanceof Error ? error.message : String(error)}; ${MAYBE_POSTED}`;
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(body.action, id, result);
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) |