@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.
- package/README.md +1 -1
- package/dist/codex-home.js +72 -7
- package/dist/config.js +17 -0
- package/dist/index.js +93 -12
- package/dist/mcp.js +15 -6
- package/dist/panel3/answer.js +12 -12
- package/dist/panel3/checkout.js +522 -2
- package/dist/panel3/cli.js +49 -62
- package/dist/panel3/client.js +53 -16
- package/dist/panel3/presence.js +1 -1
- package/dist/panel3/prompt.js +473 -62
- package/dist/panel3/run.js +1206 -111
- package/dist/panel3/say.js +10 -10
- package/dist/panel3/session.js +128 -0
- package/dist/panel3/show.js +197 -23
- package/dist/panel3/spawn.js +85 -20
- package/dist/panel3/tools.js +677 -51
- package/dist/presence.js +8 -0
- package/dist/skills.js +165 -0
- package/dist/workflows.js +68 -0
- package/package.json +2 -3
package/dist/panel3/say.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ═══ AGENT PANEL v3: `
|
|
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 `
|
|
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:
|
|
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: `
|
|
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
|
-
// `
|
|
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 \`
|
|
145
|
-
+ 'or `
|
|
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, `
|
|
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:
|
|
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(`
|
|
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
|
+
}
|
package/dist/panel3/show.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ═══ AGENT PANEL v3: `
|
|
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
|
-
* `
|
|
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,
|
|
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,
|
|
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: `
|
|
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
|
-
* `
|
|
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 `
|
|
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
|
-
* `
|
|
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
|
-
|
|
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 `
|
|
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 `
|
|
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:
|
|
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
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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('
|
|
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('
|
|
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
|
-
* `
|
|
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.
|