@ctrl-spc/cs 0.7.1 → 0.7.2

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ═══ AGENT PANEL v3: `cs3 say`, the only way anything reaches the record. ═══
2
+ * ═══ AGENT PANEL v3: `cs say`, the only way anything reaches the record. ═══
3
3
  *
4
4
  * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
5
  * import it.
@@ -14,7 +14,7 @@
14
14
  * So this command's whole job is to put the card and the turn on the record and
15
15
  * return. It does not spawn, does not take, does not wait for anything to be
16
16
  * ready, and there is nothing here for a daemon to be up for. Nothing answers
17
- * the turn yet, which is why `cs3 show` says out loud that nothing is acting on
17
+ * the turn yet, which is why `cs show` says out loud that nothing is acting on
18
18
  * the card: that is the record being honest, not this command being unfinished.
19
19
  *
20
20
  * ---------------------------------------------------------------------------
@@ -46,13 +46,13 @@
46
46
  // and takes the leading comment with it, so a file whose first statement is
47
47
  // `import type` loses its v3 header in the published `dist/`.
48
48
  import { at, out, returned, signedInClient } from './client.js';
49
- const USAGE = 'usage: cs3 say [--card <id>] [--project <id or name>] "<text>"';
49
+ const USAGE = 'usage: cs say [--card <id>] [--project <id or name>] "<text>"';
50
50
  /** Long enough to recognise a request on a board, short enough to be a name. */
51
51
  const TITLE_LIMIT = 80;
52
52
  // ---------------------------------------------------------------------------
53
53
  /**
54
54
  * The arguments after `say`. A bare word that is not a flag is the message, and
55
- * a SECOND one is refused rather than joined: `cs3 say hello there` is a message
55
+ * a SECOND one is refused rather than joined: `cs say hello there` is a message
56
56
  * the shell already split, and quietly gluing it back together would be this
57
57
  * command guessing at what the user typed. Quoting is the fix and the error says
58
58
  * so.
@@ -67,7 +67,7 @@ function parseArgs(args) {
67
67
  i += 1;
68
68
  const value = args[i];
69
69
  // A FLAG IS NOT A CARD ID. Swallowing the next token whatever it is sends
70
- // `cs3 say --card --help "x"` to Postgres and surfaces "invalid input
70
+ // `cs say --card --help "x"` to Postgres and surfaces "invalid input
71
71
  // syntax for type uuid", which is a real error and the wrong one: the
72
72
  // truth is that no card was named.
73
73
  if (value === undefined || value.startsWith('--')) {
@@ -141,8 +141,8 @@ async function existingCard(client, cardId) {
141
141
  if (error)
142
142
  throw new Error(`could not read card ${cardId}: ${error.message}`);
143
143
  if (!data) {
144
- throw new Error(`no card with id ${cardId}. Run \`cs3 say "<text>"\` with no --card to start a new one, `
145
- + 'or `cs3 show` to list the cards there are.');
144
+ throw new Error(`no card with id ${cardId}. Run \`cs say "<text>"\` with no --card to start a new one, `
145
+ + 'or `cs show` to list the cards there are.');
146
146
  }
147
147
  return data;
148
148
  }
@@ -160,7 +160,7 @@ async function existingCard(client, cardId) {
160
160
  * what they typed, and having the product classify a message at send time is the
161
161
  * move ux.md spends a section forbidding: "the moment the product classifies, it
162
162
  * has to be right, and being wrong silently discards something the user said."
163
- * So it stays null, `cs3 show` says "no project yet", and that is the truth.
163
+ * So it stays null, `cs show` says "no project yet", and that is the truth.
164
164
  *
165
165
  * IT IS A LABEL, NOT A WALL. ux.md: "Project is a filter the coordinator applies,
166
166
  * never a wall it lives inside." Nothing downstream narrows a read to this
@@ -250,7 +250,7 @@ export async function say(args) {
250
250
  const reason = error instanceof Error ? error.message : String(error);
251
251
  if (isNew) {
252
252
  throw new Error(`${reason}\ncard ${card.id} was created and carries no turn. `
253
- + `Retry with: cs3 say --card ${card.id} "<text>"`);
253
+ + `Retry with: cs say --card ${card.id} "<text>"`);
254
254
  }
255
255
  throw error;
256
256
  }
@@ -258,5 +258,5 @@ export async function say(args) {
258
258
  out(`title ${card.title}`);
259
259
  out(`turn ${turn.id} ${at(turn.created_at)} ${turn.addressed_at ? `addressed ${at(turn.addressed_at)}` : 'NOT ADDRESSED'}`);
260
260
  out();
261
- out(` cs3 show ${card.id}`);
261
+ out(` cs show ${card.id}`);
262
262
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ═══ AGENT PANEL v3: `cs3 show`, the whole record in a terminal. ═══
2
+ * ═══ AGENT PANEL v3: `cs show`, the whole record in a terminal. ═══
3
3
  *
4
4
  * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
5
  * import it.
@@ -16,7 +16,7 @@
16
16
  * ═══ AN EMPTY RECORD AND A FAILED READ MUST NEVER LOOK ALIKE. ═══
17
17
  *
18
18
  * A read that comes back empty prints "No cards.". A read that FAILS throws, so
19
- * `cs3` prints the reason on stderr and exits non-zero. `returned()` in
19
+ * `cs` prints the reason on stderr and exits non-zero. `returned()` in
20
20
  * `client.ts` is the one place that distinction is made, for every v3 command,
21
21
  * and it also rejects the shape that would otherwise slip through: no error and
22
22
  * no data, which is not "nothing is there" but "the client returned something
@@ -37,6 +37,8 @@
37
37
  import { at, out, returned, signedInClient } from './client.js';
38
38
  import { coordination, elsewhereMismatch, listeningFleet, } from './presence.js';
39
39
  import { designatedCoordinator } from './coordinator.js';
40
+ import { DEFAULT_BRANCH_PATTERN, worktreeFolder } from './checkout.js';
41
+ import { existsSync } from 'node:fs';
40
42
  import { listCodebaseLocations, listCodebases } from '../codebases.js';
41
43
  /** The columns of `panel3_asks` that carry content, plus the reference that says
42
44
  * whether they are the ones to read. */
@@ -80,8 +82,8 @@ export async function withAskContent(client, asks) {
80
82
  const read = (query, what) => returned(query, 'read', what);
81
83
  const CARD_COLUMNS = 'id, project_id, title, state, created_at, archived_at';
82
84
  const TURN_COLUMNS = 'id, card_id, role, body, created_at, addressed_at, run_id';
83
- const RUN_COLUMNS = 'id, card_id, codebase_id, parent_run_id, level, state, brief, report, failed_because, activity, machine_id, '
84
- + 'pid, started_at, resumed_at, read_back_at, ended_at';
85
+ const RUN_COLUMNS = 'id, card_id, codebase_id, branch, parent_run_id, level, state, brief, report, failed_because, '
86
+ + 'activity, machine_id, pid, started_at, resumed_at, read_back_at, ended_at';
85
87
  const ASK_COLUMNS = `id, card_id, run_id, pending_run_id, answered_at, delivered_at, created_at, ${ASK_CONTENT_COLUMNS}`;
86
88
  const OUTPUT_COLUMNS = 'id, card_id, run_id, kind, ref_id, label, created_at';
87
89
  const ATTACHMENT_COLUMNS = 'id, card_id, kind, ref_id, label, created_at';
@@ -180,7 +182,7 @@ export async function loadOutputNames(client, outputs) {
180
182
  * ═══ WHAT WAS ATTACHED TO ONE CARD. ═══
181
183
  *
182
184
  * One card at a time, unlike `loadChildren`'s batch of every card on the list
183
- * view: `cs3 show` prints attachments only in the single-card view (the plan's
185
+ * view: `cs show` prints attachments only in the single-card view (the plan's
184
186
  * own line: the list view already says one thing per card, and this is
185
187
  * per-item detail), and level 1's prompt and a dispatched brief are both about
186
188
  * one card's work, never many at once. A batched loader with exactly one
@@ -190,10 +192,149 @@ export async function loadAttachments(client, cardId) {
190
192
  return read(client.from('panel3_attachments').select(ATTACHMENT_COLUMNS).eq('card_id', cardId)
191
193
  .order('created_at'), 'panel3_attachments');
192
194
  }
195
+ export async function standingRulesFor(client, runId) {
196
+ const rows = await read(client
197
+ .from('panel3_runs')
198
+ .select('codebase_id, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
199
+ .eq('id', runId), `the standing rules run ${runId} is under`);
200
+ const run = rows[0];
201
+ const projectId = run?.card?.project_id ?? null;
202
+ if (!projectId)
203
+ return [];
204
+ const scoped = client
205
+ .from('agent_instructions')
206
+ .select('title, content, codebase_id')
207
+ .eq('project_id', projectId);
208
+ return read((run.codebase_id === null
209
+ ? scoped.is('codebase_id', null)
210
+ : scoped.or(`codebase_id.is.null,codebase_id.eq.${run.codebase_id}`))
211
+ /* ═══ MOST SPECIFIC FIRST, WHICH IS THE ORDER ux.md GIVES. ═══ "A
212
+ codebase's documents beat the project's for work in that codebase", so
213
+ the codebase's are rendered above the project's. `nulls last` on a
214
+ descending-specificity sort is what puts the non-null rows first; within
215
+ each scope it is oldest-first, matching the list the person sees. */
216
+ .order('codebase_id', { nullsFirst: false })
217
+ .order('created_at'), `the standing rules on project ${projectId}`);
218
+ }
219
+ const NOTHING_STORED = {
220
+ base_branch: null, landing: null, branch_pattern: null, base_protected: null,
221
+ };
222
+ export async function storedGitRules(client, codebaseId) {
223
+ const rows = await read(client.from('codebase_git_rules')
224
+ .select('base_branch, landing, branch_pattern, base_protected')
225
+ .eq('codebase_id', codebaseId), `the git rules on codebase ${codebaseId}`);
226
+ return rows[0] ?? NOTHING_STORED;
227
+ }
228
+ /**
229
+ * ═══ THE DETECTED DEFAULT, FROM TWO INPUTS, AND IT IS NEVER WRITTEN DOWN. ═══
230
+ *
231
+ * More than one member of the organization means a pull request; otherwise
232
+ * straight onto the main branch; and a PROTECTED base branch means a pull
233
+ * request whatever the organization's size, because on a protected branch the
234
+ * merge either fails or bypasses the review, and the second is worse.
235
+ *
236
+ * ═══ UNKNOWN PROTECTION FALLS BACK TO THE MEMBER COUNT AND SAYS SO. ═══ `null`
237
+ * is not `false`. Reading it as false would be the one wrong answer available
238
+ * here, because false is the answer that skips a review.
239
+ *
240
+ * Nothing here writes a setting. Detection produces an answer for this run and a
241
+ * sentence for the screen, and `landing` stays null until a person chooses.
242
+ */
243
+ export function landingFor(stored, members) {
244
+ if (stored.landing !== null)
245
+ return { landing: stored.landing, detectedBecause: null };
246
+ if (stored.base_protected === true) {
247
+ return {
248
+ landing: 'pr',
249
+ detectedBecause: 'the main branch is protected on the remote, so a merge here would either '
250
+ + 'fail or bypass the review',
251
+ };
252
+ }
253
+ return members > 1
254
+ ? {
255
+ landing: 'pr',
256
+ detectedBecause: `this organization has ${members} members`
257
+ + (stored.base_protected === null
258
+ ? ', and nobody could check whether the main branch is protected'
259
+ : ''),
260
+ }
261
+ : {
262
+ landing: 'main',
263
+ detectedBecause: 'this organization has one member'
264
+ + (stored.base_protected === null
265
+ ? ', and nobody could check whether the main branch is protected'
266
+ : ', and the main branch is not protected'),
267
+ };
268
+ }
269
+ /**
270
+ * How many people a change to these rules reaches, which is what the detected
271
+ * ending turns on.
272
+ *
273
+ * ═══ IT TAKES THE PROJECT, IT DOES NOT LOOK ONE UP. ═══ The project is on the
274
+ * run row already at every call site, and finding it from the codebase would
275
+ * mean naming `cliv2_codebases` from inside `panel3/`, which conventions.md's
276
+ * rule 4 forbids and `panel3-isolation.contract.test.mjs` fails on. `projects`
277
+ * and `memberships` are product tables, which v3 reads under the person's own
278
+ * RLS exactly as the web app does.
279
+ */
280
+ async function orgMembers(client, projectId) {
281
+ const project = await read(client.from('projects').select('org_id').eq('id', projectId), `which organization project ${projectId} belongs to`);
282
+ const orgId = project[0]?.org_id;
283
+ /* ONE, NOT ZERO, when the project cannot be resolved: the count only ever
284
+ decides between "on your own" and "on a team", and a zero would read as
285
+ neither while behaving as the first. */
286
+ if (!orgId)
287
+ return 1;
288
+ return (await read(client.from('memberships').select('user_id').eq('org_id', orgId), `who is in organization ${orgId}`)).length;
289
+ }
290
+ export async function gitRulesFor(client, codebaseId, projectId) {
291
+ const stored = await storedGitRules(client, codebaseId);
292
+ /* THE MEMBER COUNT IS ONLY ASKED FOR WHEN IT DECIDES SOMETHING. A codebase
293
+ whose team chose an ending needs no count, and most do not. */
294
+ const members = stored.landing === null ? await orgMembers(client, projectId) : 1;
295
+ return {
296
+ ...stored,
297
+ ...landingFor(stored, members),
298
+ branchPattern: stored.branch_pattern ?? DEFAULT_BRANCH_PATTERN,
299
+ };
300
+ }
301
+ /**
302
+ * WHAT WORK IN THIS CODEBASE STARTS FROM, recorded on the CODEBASE and not on
303
+ * the card that prompted the question (story 3 scenario 4), so the same question
304
+ * is never asked twice.
305
+ *
306
+ * Upsert on `codebase_id`, which is unique: the row may not exist yet, and a
307
+ * codebase nobody has configured is a codebase with no row rather than a row of
308
+ * nulls.
309
+ */
310
+ export async function setMainBranch(client, codebaseId, branch) {
311
+ await returned(client.from('codebase_git_rules')
312
+ .upsert({ codebase_id: codebaseId, base_branch: branch }, { onConflict: 'codebase_id' })
313
+ .select('codebase_id'), 'record which branch work starts from', `codebase ${codebaseId}`);
314
+ }
315
+ /**
316
+ * AN OBSERVATION, WRITTEN WHERE THE ANSWER WAS FOUND. Refreshed opportunistically
317
+ * by the machine that holds the checkout, and never turned into a setting:
318
+ * `landing` stays null until a person chooses, so detected and chosen stay
319
+ * distinguishable.
320
+ *
321
+ * A failure is SAID, not fatal. This is a fact about the remote, not about the
322
+ * work, and refusing to run a card because a `gh` call could not be recorded
323
+ * would be the observation deciding the outcome.
324
+ */
325
+ export async function recordBaseProtection(client, codebaseId, isProtected) {
326
+ await returned(client.from('codebase_git_rules')
327
+ .upsert({
328
+ codebase_id: codebaseId,
329
+ base_protected: isProtected,
330
+ base_protected_at: new Date().toISOString(),
331
+ }, { onConflict: 'codebase_id' })
332
+ .select('codebase_id'), 'record whether the main branch is protected', `codebase ${codebaseId}`);
333
+ }
193
334
  /**
194
335
  * ═══ ONE LINE, EVERYWHERE AN ATTACHMENT IS NAMED. ═══
195
336
  *
196
- * `cs3 show`'s ATTACHMENTS section, level 1's prompt and a dispatched brief all
337
+ * `cs show`'s ATTACHMENTS section, level 1's prompt and a dispatched brief all
197
338
  * name the same fact about the same row, and ux.md's own warning about the
198
339
  * outbound rules applies here too: two copies of "how an attachment reads"
199
340
  * would drift, silently, in whichever caller nobody re-read. So there is one
@@ -202,7 +343,7 @@ export async function loadAttachments(client, cardId) {
202
343
  */
203
344
  export const attachmentLine = (a) => `${a.kind} ${a.label} id ${a.ref_id}`;
204
345
  /**
205
- * ═══ THE WHOLE ATTACHMENTS SECTION, EXACTLY AS `cs3 show` PRINTS IT, OR
346
+ * ═══ THE WHOLE ATTACHMENTS SECTION, EXACTLY AS `cs show` PRINTS IT, OR
206
347
  * NOTHING AT ALL. ═══
207
348
  *
208
349
  * Every other section on a card prints its own header and a "none" line at
@@ -215,7 +356,7 @@ export const attachmentLine = (a) => `${a.kind} ${a.label} id ${a.ref_id}`;
215
356
  * PURE AND EXPORTED for the same reason `nothingWillActOn` is: the "prints
216
357
  * nothing when there is nothing" rule is a fact about this function's return
217
358
  * value, and a test asserting it should not need a live client or a running
218
- * `cs3 show` to see it.
359
+ * `cs show` to see it.
219
360
  */
220
361
  export function attachmentsSection(attachments) {
221
362
  if (attachments.length === 0)
@@ -223,7 +364,19 @@ export function attachmentsSection(attachments) {
223
364
  return [`ATTACHMENTS ${attachments.length}`, ...attachments.map((a) => ` ${attachmentLine(a)}`)];
224
365
  }
225
366
  async function codebaseSection(client, card, attachments, runs) {
226
- const selected = attachments.filter((attachment) => attachment.kind === 'codebase');
367
+ /* ═══ WHAT THE CARD IS ATTACHED TO, AND WHAT ITS RUNS ARE ACTUALLY IN. ═══
368
+ They are not the same set: a codebase the coordinator chose is on the run
369
+ row and on no attachment, and keying this section off attachments alone
370
+ printed nothing at all for the ordinary path — including the branch and the
371
+ folder, which are the two things `cs show` exists to say here. */
372
+ const attached = attachments.filter((attachment) => attachment.kind === 'codebase');
373
+ const selected = [...new Set([
374
+ ...attached.map((attachment) => attachment.ref_id),
375
+ ...runs.map((run) => run.codebase_id).filter((id) => id !== null),
376
+ ])].map((id) => ({
377
+ ref_id: id,
378
+ label: attached.find((attachment) => attachment.ref_id === id)?.label ?? id,
379
+ }));
227
380
  if (selected.length === 0)
228
381
  return [];
229
382
  const codebases = card.project_id ? await listCodebases(client, card.project_id) : [];
@@ -252,10 +405,29 @@ async function codebaseSection(client, card, attachments, runs) {
252
405
  const located = locations.some((location) => (location.machineId === machineId && location.gitRemoteUrl === codebase.gitRemoteUrl));
253
406
  return `${located ? 'located on' : 'NOT LOCATED on'} ${machineName}`;
254
407
  });
255
- return ` ${name} ${availability.join(' · ')}`;
408
+ return ` ${name} ${availability.join(' · ')}${branchOf(runs, codebase.id)}`;
256
409
  }),
257
410
  ];
258
411
  }
412
+ /**
413
+ * ═══ THE BRANCH, AND — HERE AND ONLY HERE — THE FOLDER. ═══
414
+ *
415
+ * worktrees-8: no absolute path may reach the cloud or a hosted browser, so the
416
+ * card and the run details name the branch and stop. `cs show` runs on the
417
+ * person's own machine, which is the one surface where the path is already
418
+ * theirs, so this is where a power user reads it.
419
+ *
420
+ * The folder is printed only when it is really here: the copy is machine-local,
421
+ * and naming one on a machine that does not hold it would be an invitation to
422
+ * `cd` into nothing.
423
+ */
424
+ function branchOf(runs, codebaseId) {
425
+ const branch = runs.find((run) => run.codebase_id === codebaseId && run.branch)?.branch;
426
+ if (!branch)
427
+ return '';
428
+ const folder = worktreeFolder(codebaseId, branch);
429
+ return `\n branch ${branch}${existsSync(folder) ? ` in ${folder}` : ''}`;
430
+ }
259
431
  // ---------------------------------------------------------------------------
260
432
  // WHAT THE RECORD MEANS.
261
433
  const isLive = (run) => run.state === 'running' && !run.ended_at;
@@ -288,7 +460,7 @@ const isOutstanding = (ask) => !ask.answered_at || !ask.delivered_at;
288
460
  *
289
461
  * WHAT IT CHANGES HERE IS ONE SENTENCE, AND THE SENTENCE MATTERS: a stalled
290
462
  * question normally has a move, and this is the one case where it has none.
291
- * Printing `cs3 answer` at somebody whose answer would start nothing sends them
463
+ * Printing `cs answer` at somebody whose answer would start nothing sends them
292
464
  * to type a command that cannot do what it says.
293
465
  */
294
466
  const askWasStopped = (ask, runs) => {
@@ -382,13 +554,13 @@ function notesAboutQuestions(asks, runs) {
382
554
  const notes = [];
383
555
  for (const ask of asks.filter((a) => isStalled(a, runs))) {
384
556
  /* ═══ UNLESS ANSWERING IT WOULD DO NOTHING. ═══ A question asked by work the
385
- user stopped is never delivered again, so `cs3 answer` settles the row and
557
+ user stopped is never delivered again, so `cs answer` settles the row and
386
558
  starts nobody. Naming the command anyway would be this screen sending
387
559
  somebody to type something that cannot do what it says, which is the same
388
560
  failure as the command itself claiming an effect it did not have. */
389
561
  if (askWasStopped(ask, runs)) {
390
562
  notes.push(`question ${ask.id} was asked by work you stopped: answering it starts nothing, and `
391
- + `nothing is waiting for it. Send a message instead: cs3 say --card ${ask.card_id} `
563
+ + `nothing is waiting for it. Send a message instead: cs say --card ${ask.card_id} `
392
564
  + '"<text>".');
393
565
  continue;
394
566
  }
@@ -398,7 +570,7 @@ function notesAboutQuestions(asks, runs) {
398
570
  to. Saying only that nothing will act on it would leave them reading a
399
571
  dead end that is not one. */
400
572
  notes.push(`question ${ask.id} went to an agent that has ended without answering it or passing it on: `
401
- + `nothing will act on it until you do, with cs3 answer ${ask.id} "<text>".`);
573
+ + `nothing will act on it until you do, with cs answer ${ask.id} "<text>".`);
402
574
  }
403
575
  return notes;
404
576
  }
@@ -508,7 +680,7 @@ designation, fleet) {
508
680
  if (status !== 'here' && card.state === 'working') {
509
681
  return [
510
682
  status === 'nobody'
511
- ? 'no machine is running `cs3 run`, so nothing can pick this up: it is waiting for a '
683
+ ? 'no machine is running `cs start`, so nothing can pick this up: it is waiting for a '
512
684
  + 'machine, not being worked on. Start the CLI on a machine and it is taken from where '
513
685
  + 'it is.'
514
686
  /* ═══ `'elsewhere'`, WHICH USED TO READ AS `'nobody'` AND CLAIMED NO
@@ -582,7 +754,7 @@ designation, fleet) {
582
754
  }
583
755
  // ---------------------------------------------------------------------------
584
756
  // RENDERING. Plain text, aligned, and every id printed in full so it can be
585
- // pasted straight back into `cs3 show`. `out` and `at` come from `client.ts`,
757
+ // pasted straight back into `cs show`. `out` and `at` come from `client.ts`,
586
758
  // so two commands cannot print the same instant two different ways.
587
759
  const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
588
760
  /** A body of text under a heading, indented, with its blank lines kept. */
@@ -647,7 +819,7 @@ export function machinesSection(designation, fleet) {
647
819
  const lines = [
648
820
  `coordinator ${designation ? `${designation.machineId} ${designation.agent}` : 'none designated, any machine may coordinate'}`,
649
821
  fleet.length === 0
650
- ? 'listening none, so nothing can be picked up until a machine runs `cs3 run`'
822
+ ? 'listening none, so nothing can be picked up until a machine runs `cs start`'
651
823
  : `listening ${count(fleet.length, 'machine')}`,
652
824
  ];
653
825
  for (const m of fleet) {
@@ -711,7 +883,7 @@ async function showAllCards(client) {
711
883
  }
712
884
  }
713
885
  out();
714
- out(' cs3 show <card-id> for one card in full.');
886
+ out(' cs show <card-id> for one card in full.');
715
887
  }
716
888
  async function showCard(client, card) {
717
889
  const [children, names, designation, fleet, attachments] = await Promise.all([
@@ -833,7 +1005,7 @@ async function showCard(client, card) {
833
1005
  renderNotes(nothingWillActOn(card, turns, runs, asks, status, designation, fleet));
834
1006
  if (runs.length > 0) {
835
1007
  out();
836
- out(' cs3 show <run-id> for a run\'s brief and report.');
1008
+ out(' cs show <run-id> for a run\'s brief and report.');
837
1009
  }
838
1010
  }
839
1011
  /**
@@ -884,8 +1056,9 @@ function renderRunTree(runs, readingBack) {
884
1056
  const pad = ' '.repeat(depth + 1);
885
1057
  const liveness = isLive(run) ? 'LIVE' : run.state;
886
1058
  out(`${pad}L${run.level} ${liveness} ${run.id}`);
887
- if (run.codebase_id)
888
- out(`${pad} codebase ${run.codebase_id}`);
1059
+ if (run.codebase_id) {
1060
+ out(`${pad} codebase ${run.codebase_id}${run.branch ? ` branch ${run.branch}` : ''}`);
1061
+ }
889
1062
  out(`${pad} machine ${run.machine_id}${run.pid ? ` pid ${run.pid}` : ''}`);
890
1063
  out(`${pad} started ${at(run.started_at)}`
891
1064
  + (run.ended_at ? ` ended ${at(run.ended_at)}` : ' not ended'));
@@ -978,7 +1151,7 @@ async function showRun(client, run) {
978
1151
  }
979
1152
  // ---------------------------------------------------------------------------
980
1153
  /**
981
- * `cs3 show` with no id lists every card. With an id it shows that card in
1154
+ * `cs show` with no id lists every card. With an id it shows that card in
982
1155
  * full, or that run with its brief and report — one argument, resolved against
983
1156
  * the record, because a `--run` flag would make the caller say which kind of id
984
1157
  * they are holding when the record already knows.
@@ -243,7 +243,10 @@ function codexArgs(level, toolsUrl, platform, ownerSession) {
243
243
  '--ignore-user-config',
244
244
  '-c', 'model="gpt-5.5"',
245
245
  '-c', 'features.apps=false',
246
- '-c', 'features.multi_agent=false',
246
+ // The same closing `codex-home.ts` writes into the per-run config, and
247
+ // for the same measured reason: `features.multi_agent=false` parses and
248
+ // leaves the tool reachable.
249
+ '-c', 'agents.enabled=false',
247
250
  '-c', `mcp_servers.${SERVER}.url=${JSON.stringify(toolsUrl)}`,
248
251
  '-c', `mcp_servers.${SERVER}.default_tools_approval_mode="approve"`,
249
252
  '-c', 'windows.sandbox="unelevated"',
@@ -331,7 +334,7 @@ const MAX_OUTPUT_CHARS = 1_000_000;
331
334
  * spawn can fail hand back a message that carries one — node's own spawn error
332
335
  * reads `spawn /Users/…/claude ENOENT`, and an EACCES from the synchronous throw
333
336
  * names the file too — and both of these become a run's `failed_because`, which
334
- * `cs3 show` prints and a coordinator can repeat to the person.
337
+ * `cs show` prints and a coordinator can repeat to the person.
335
338
  *
336
339
  * `workingCopy()` above already makes this argument for the checkout and the
337
340
  * fix is the same one: the CODE is what a person acts on, it is not a path, and