@ctrl-spc/cs 0.7.0 → 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
  }
@@ -0,0 +1,128 @@
1
+ import { chmodSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { join } from 'node:path';
4
+ import { configDir } from '../config.js';
5
+ export const OWNER_SESSION_GRACE_MS = 30_000;
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
7
+ export const validSessionUuid = (value) => typeof value === 'string' && UUID.test(value);
8
+ export function ownerSessionsRoot() {
9
+ return join(configDir(), 'panel3-sessions');
10
+ }
11
+ export function ownerSessionPath(ownerId) {
12
+ if (!validSessionUuid(ownerId))
13
+ throw new Error('an owner session requires a valid run id');
14
+ return join(ownerSessionsRoot(), `${ownerId}.json`);
15
+ }
16
+ function validRecord(value, ownerId) {
17
+ if (!value || typeof value !== 'object')
18
+ return false;
19
+ const row = value;
20
+ return row.ownerId === ownerId
21
+ && (row.harness === 'claude' || row.harness === 'codex')
22
+ && validSessionUuid(row.nativeSessionId)
23
+ && validSessionUuid(row.processToken)
24
+ && (row.state === 'pending' || row.state === 'established')
25
+ && typeof row.updatedAt === 'string'
26
+ && Number.isFinite(new Date(row.updatedAt).getTime());
27
+ }
28
+ /** Invalid local data is never handed to a harness as a resume selector. */
29
+ export function readOwnerSession(ownerId) {
30
+ let path;
31
+ try {
32
+ path = ownerSessionPath(ownerId);
33
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
34
+ if (validRecord(parsed, ownerId))
35
+ return parsed;
36
+ }
37
+ catch {
38
+ // Missing and malformed are both absence to the caller. Malformed is removed below.
39
+ try {
40
+ path = ownerSessionPath(ownerId);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ try {
47
+ rmSync(path, { force: true });
48
+ }
49
+ catch { /* reconciliation retries */ }
50
+ return null;
51
+ }
52
+ /** One file per owner prevents two daemons updating different owners from
53
+ * losing each other's mappings. Same-directory rename makes each record whole
54
+ * or absent across a crash. */
55
+ export function writeOwnerSession(record) {
56
+ const stored = { ...record, updatedAt: record.updatedAt ?? new Date().toISOString() };
57
+ if (!validRecord(stored, stored.ownerId))
58
+ throw new Error('invalid owner session record');
59
+ const root = ownerSessionsRoot();
60
+ mkdirSync(root, { recursive: true, mode: 0o700 });
61
+ chmodSync(root, 0o700);
62
+ const path = ownerSessionPath(record.ownerId);
63
+ const temporary = join(root, `.${record.ownerId}.${randomUUID()}.tmp`);
64
+ try {
65
+ writeFileSync(temporary, `${JSON.stringify(stored)}\n`, { mode: 0o600 });
66
+ renameSync(temporary, path);
67
+ }
68
+ finally {
69
+ try {
70
+ rmSync(temporary, { force: true });
71
+ }
72
+ catch { /* a later reconciliation can remove it */ }
73
+ }
74
+ }
75
+ export function establishOwnerSession(ownerId, processToken) {
76
+ const record = readOwnerSession(ownerId);
77
+ if (!record || record.state !== 'pending' || record.processToken !== processToken)
78
+ return false;
79
+ writeOwnerSession({ ...record, state: 'established', updatedAt: new Date().toISOString() });
80
+ return true;
81
+ }
82
+ export function removeOwnerSession(ownerId) {
83
+ try {
84
+ rmSync(ownerSessionPath(ownerId), { force: true });
85
+ }
86
+ catch { /* reconciliation retries */ }
87
+ }
88
+ export function removeOwnerSessionIfToken(ownerId, processToken) {
89
+ const record = readOwnerSession(ownerId);
90
+ if (!record || record.processToken !== processToken)
91
+ return false;
92
+ removeOwnerSession(ownerId);
93
+ return true;
94
+ }
95
+ /** Names, not contents. Malformed names and temp remnants are removed here and
96
+ * never become database selectors. */
97
+ export function listOwnerSessionIds(now = Date.now()) {
98
+ let entries;
99
+ try {
100
+ entries = readdirSync(ownerSessionsRoot());
101
+ }
102
+ catch {
103
+ return [];
104
+ }
105
+ const ids = [];
106
+ for (const entry of entries) {
107
+ const match = /^([0-9a-f-]+)\.json$/i.exec(entry);
108
+ if (match && validSessionUuid(match[1])) {
109
+ ids.push(match[1]);
110
+ continue;
111
+ }
112
+ const temporary = /^\.([0-9a-f-]+)\.([0-9a-f-]+)\.tmp$/i.exec(entry);
113
+ if (temporary && validSessionUuid(temporary[1]) && validSessionUuid(temporary[2])) {
114
+ try {
115
+ if (now - statSync(join(ownerSessionsRoot(), entry)).mtimeMs < OWNER_SESSION_GRACE_MS)
116
+ continue;
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ }
122
+ try {
123
+ rmSync(join(ownerSessionsRoot(), entry), { recursive: true, force: true });
124
+ }
125
+ catch { /* retry later */ }
126
+ }
127
+ return ids;
128
+ }
@@ -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. */
@@ -61,7 +63,8 @@ export async function withAskContent(client, asks) {
61
63
  if (ids.length === 0)
62
64
  return asks;
63
65
  const decisions = await read(client.from('decisions')
64
- .select('id, question, answer, category, context, answer_mode, options, selected_options, answer_note')
66
+ .select('id, question, answer, category, context, answer_mode, options, selected_options, '
67
+ + 'answer_note, related_artifact_id, related_artifact_revision')
65
68
  .in('id', ids), 'decisions');
66
69
  /* THE CONTENT ONLY. The decision's own `id` is dropped here rather than
67
70
  spread and overwritten: the ask row's id is what every caller answers,
@@ -79,8 +82,8 @@ export async function withAskContent(client, asks) {
79
82
  const read = (query, what) => returned(query, 'read', what);
80
83
  const CARD_COLUMNS = 'id, project_id, title, state, created_at, archived_at';
81
84
  const TURN_COLUMNS = 'id, card_id, role, body, created_at, addressed_at, run_id';
82
- const RUN_COLUMNS = 'id, card_id, codebase_id, parent_run_id, level, state, brief, report, failed_because, activity, machine_id, '
83
- + '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';
84
87
  const ASK_COLUMNS = `id, card_id, run_id, pending_run_id, answered_at, delivered_at, created_at, ${ASK_CONTENT_COLUMNS}`;
85
88
  const OUTPUT_COLUMNS = 'id, card_id, run_id, kind, ref_id, label, created_at';
86
89
  const ATTACHMENT_COLUMNS = 'id, card_id, kind, ref_id, label, created_at';
@@ -179,7 +182,7 @@ export async function loadOutputNames(client, outputs) {
179
182
  * ═══ WHAT WAS ATTACHED TO ONE CARD. ═══
180
183
  *
181
184
  * One card at a time, unlike `loadChildren`'s batch of every card on the list
182
- * 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
183
186
  * own line: the list view already says one thing per card, and this is
184
187
  * per-item detail), and level 1's prompt and a dispatched brief are both about
185
188
  * one card's work, never many at once. A batched loader with exactly one
@@ -189,10 +192,149 @@ export async function loadAttachments(client, cardId) {
189
192
  return read(client.from('panel3_attachments').select(ATTACHMENT_COLUMNS).eq('card_id', cardId)
190
193
  .order('created_at'), 'panel3_attachments');
191
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
+ }
192
334
  /**
193
335
  * ═══ ONE LINE, EVERYWHERE AN ATTACHMENT IS NAMED. ═══
194
336
  *
195
- * `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
196
338
  * name the same fact about the same row, and ux.md's own warning about the
197
339
  * outbound rules applies here too: two copies of "how an attachment reads"
198
340
  * would drift, silently, in whichever caller nobody re-read. So there is one
@@ -201,7 +343,7 @@ export async function loadAttachments(client, cardId) {
201
343
  */
202
344
  export const attachmentLine = (a) => `${a.kind} ${a.label} id ${a.ref_id}`;
203
345
  /**
204
- * ═══ THE WHOLE ATTACHMENTS SECTION, EXACTLY AS `cs3 show` PRINTS IT, OR
346
+ * ═══ THE WHOLE ATTACHMENTS SECTION, EXACTLY AS `cs show` PRINTS IT, OR
205
347
  * NOTHING AT ALL. ═══
206
348
  *
207
349
  * Every other section on a card prints its own header and a "none" line at
@@ -214,7 +356,7 @@ export const attachmentLine = (a) => `${a.kind} ${a.label} id ${a.ref_id}`;
214
356
  * PURE AND EXPORTED for the same reason `nothingWillActOn` is: the "prints
215
357
  * nothing when there is nothing" rule is a fact about this function's return
216
358
  * value, and a test asserting it should not need a live client or a running
217
- * `cs3 show` to see it.
359
+ * `cs show` to see it.
218
360
  */
219
361
  export function attachmentsSection(attachments) {
220
362
  if (attachments.length === 0)
@@ -222,7 +364,19 @@ export function attachmentsSection(attachments) {
222
364
  return [`ATTACHMENTS ${attachments.length}`, ...attachments.map((a) => ` ${attachmentLine(a)}`)];
223
365
  }
224
366
  async function codebaseSection(client, card, attachments, runs) {
225
- 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
+ }));
226
380
  if (selected.length === 0)
227
381
  return [];
228
382
  const codebases = card.project_id ? await listCodebases(client, card.project_id) : [];
@@ -251,10 +405,29 @@ async function codebaseSection(client, card, attachments, runs) {
251
405
  const located = locations.some((location) => (location.machineId === machineId && location.gitRemoteUrl === codebase.gitRemoteUrl));
252
406
  return `${located ? 'located on' : 'NOT LOCATED on'} ${machineName}`;
253
407
  });
254
- return ` ${name} ${availability.join(' · ')}`;
408
+ return ` ${name} ${availability.join(' · ')}${branchOf(runs, codebase.id)}`;
255
409
  }),
256
410
  ];
257
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
+ }
258
431
  // ---------------------------------------------------------------------------
259
432
  // WHAT THE RECORD MEANS.
260
433
  const isLive = (run) => run.state === 'running' && !run.ended_at;
@@ -287,7 +460,7 @@ const isOutstanding = (ask) => !ask.answered_at || !ask.delivered_at;
287
460
  *
288
461
  * WHAT IT CHANGES HERE IS ONE SENTENCE, AND THE SENTENCE MATTERS: a stalled
289
462
  * question normally has a move, and this is the one case where it has none.
290
- * 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
291
464
  * to type a command that cannot do what it says.
292
465
  */
293
466
  const askWasStopped = (ask, runs) => {
@@ -381,13 +554,13 @@ function notesAboutQuestions(asks, runs) {
381
554
  const notes = [];
382
555
  for (const ask of asks.filter((a) => isStalled(a, runs))) {
383
556
  /* ═══ UNLESS ANSWERING IT WOULD DO NOTHING. ═══ A question asked by work the
384
- 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
385
558
  starts nobody. Naming the command anyway would be this screen sending
386
559
  somebody to type something that cannot do what it says, which is the same
387
560
  failure as the command itself claiming an effect it did not have. */
388
561
  if (askWasStopped(ask, runs)) {
389
562
  notes.push(`question ${ask.id} was asked by work you stopped: answering it starts nothing, and `
390
- + `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} `
391
564
  + '"<text>".');
392
565
  continue;
393
566
  }
@@ -397,7 +570,7 @@ function notesAboutQuestions(asks, runs) {
397
570
  to. Saying only that nothing will act on it would leave them reading a
398
571
  dead end that is not one. */
399
572
  notes.push(`question ${ask.id} went to an agent that has ended without answering it or passing it on: `
400
- + `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>".`);
401
574
  }
402
575
  return notes;
403
576
  }
@@ -507,7 +680,7 @@ designation, fleet) {
507
680
  if (status !== 'here' && card.state === 'working') {
508
681
  return [
509
682
  status === 'nobody'
510
- ? '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 '
511
684
  + 'machine, not being worked on. Start the CLI on a machine and it is taken from where '
512
685
  + 'it is.'
513
686
  /* ═══ `'elsewhere'`, WHICH USED TO READ AS `'nobody'` AND CLAIMED NO
@@ -581,7 +754,7 @@ designation, fleet) {
581
754
  }
582
755
  // ---------------------------------------------------------------------------
583
756
  // RENDERING. Plain text, aligned, and every id printed in full so it can be
584
- // 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`,
585
758
  // so two commands cannot print the same instant two different ways.
586
759
  const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
587
760
  /** A body of text under a heading, indented, with its blank lines kept. */
@@ -646,7 +819,7 @@ export function machinesSection(designation, fleet) {
646
819
  const lines = [
647
820
  `coordinator ${designation ? `${designation.machineId} ${designation.agent}` : 'none designated, any machine may coordinate'}`,
648
821
  fleet.length === 0
649
- ? '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`'
650
823
  : `listening ${count(fleet.length, 'machine')}`,
651
824
  ];
652
825
  for (const m of fleet) {
@@ -710,7 +883,7 @@ async function showAllCards(client) {
710
883
  }
711
884
  }
712
885
  out();
713
- out(' cs3 show <card-id> for one card in full.');
886
+ out(' cs show <card-id> for one card in full.');
714
887
  }
715
888
  async function showCard(client, card) {
716
889
  const [children, names, designation, fleet, attachments] = await Promise.all([
@@ -832,7 +1005,7 @@ async function showCard(client, card) {
832
1005
  renderNotes(nothingWillActOn(card, turns, runs, asks, status, designation, fleet));
833
1006
  if (runs.length > 0) {
834
1007
  out();
835
- 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.');
836
1009
  }
837
1010
  }
838
1011
  /**
@@ -883,8 +1056,9 @@ function renderRunTree(runs, readingBack) {
883
1056
  const pad = ' '.repeat(depth + 1);
884
1057
  const liveness = isLive(run) ? 'LIVE' : run.state;
885
1058
  out(`${pad}L${run.level} ${liveness} ${run.id}`);
886
- if (run.codebase_id)
887
- out(`${pad} codebase ${run.codebase_id}`);
1059
+ if (run.codebase_id) {
1060
+ out(`${pad} codebase ${run.codebase_id}${run.branch ? ` branch ${run.branch}` : ''}`);
1061
+ }
888
1062
  out(`${pad} machine ${run.machine_id}${run.pid ? ` pid ${run.pid}` : ''}`);
889
1063
  out(`${pad} started ${at(run.started_at)}`
890
1064
  + (run.ended_at ? ` ended ${at(run.ended_at)}` : ' not ended'));
@@ -977,7 +1151,7 @@ async function showRun(client, run) {
977
1151
  }
978
1152
  // ---------------------------------------------------------------------------
979
1153
  /**
980
- * `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
981
1155
  * full, or that run with its brief and report — one argument, resolved against
982
1156
  * the record, because a `--run` flag would make the caller say which kind of id
983
1157
  * they are holding when the record already knows.