@ctrl-spc/cs 0.5.0 → 0.7.0

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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * ═══ AGENT PANEL v3: `cs3 say`, the only way anything reaches the record. ═══
3
+ *
4
+ * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
+ * import it.
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * ═══ THE PRODUCT WRITES THE CARD, WITH NO AGENT ANYWHERE. ═══
9
+ *
10
+ * ux.md: "The card IS the acknowledgement. It appears on send... written by the
11
+ * product on send, before any agent exists. An acknowledgement that waits for a
12
+ * spawn is not an acknowledgement."
13
+ *
14
+ * So this command's whole job is to put the card and the turn on the record and
15
+ * return. It does not spawn, does not take, does not wait for anything to be
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
18
+ * the card: that is the record being honest, not this command being unfinished.
19
+ *
20
+ * ---------------------------------------------------------------------------
21
+ * WHY A TITLE IS DERIVED HERE RATHER THAN ASKED FOR.
22
+ *
23
+ * `panel3_cards.title` is `not null` and non-blank, because a card with no name
24
+ * cannot be read on a board. The user typed one thing, so the first line of it
25
+ * is the card's name. Nothing else exists yet that could name it better, and a
26
+ * second `--title` flag would make the user say twice what they said once.
27
+ *
28
+ * ---------------------------------------------------------------------------
29
+ * ═══ SEND ORDER IS MEANING, SO A TURN IS ONLY EVER APPENDED. ═══
30
+ *
31
+ * ux.md: "'Approve that' followed by 'actually do it differently' reads as a
32
+ * sequence and is answerable. Unordered, it is two contradictory demands." Every
33
+ * send is a new row stamped with the time it arrived, and nothing here edits or
34
+ * replaces one, so the order the user sent in is the order the record holds.
35
+ *
36
+ * ---------------------------------------------------------------------------
37
+ * ═══ `--card` NAMING A CARD THAT IS NOT THERE FAILS, AND NEVER CREATES ONE. ═══
38
+ *
39
+ * The two behaviours are different requests: `say` starts a request, `say
40
+ * --card` continues one. Falling back from the second to the first would put the
41
+ * user's message on a brand new card while they believed they had answered on
42
+ * the old one, which is the fenced-off answer ux.md spends a section on, arrived
43
+ * at by a different route.
44
+ */
45
+ // The runtime import comes FIRST, deliberately: `tsc` elides a type-only import
46
+ // and takes the leading comment with it, so a file whose first statement is
47
+ // `import type` loses its v3 header in the published `dist/`.
48
+ import { at, out, returned, signedInClient } from './client.js';
49
+ const USAGE = 'usage: cs3 say [--card <id>] [--project <id or name>] "<text>"';
50
+ /** Long enough to recognise a request on a board, short enough to be a name. */
51
+ const TITLE_LIMIT = 80;
52
+ // ---------------------------------------------------------------------------
53
+ /**
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
56
+ * the shell already split, and quietly gluing it back together would be this
57
+ * command guessing at what the user typed. Quoting is the fix and the error says
58
+ * so.
59
+ */
60
+ function parseArgs(args) {
61
+ let cardId = null;
62
+ let project = null;
63
+ const words = [];
64
+ for (let i = 0; i < args.length; i += 1) {
65
+ const arg = args[i];
66
+ if (arg === '--card') {
67
+ i += 1;
68
+ const value = args[i];
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
71
+ // syntax for type uuid", which is a real error and the wrong one: the
72
+ // truth is that no card was named.
73
+ if (value === undefined || value.startsWith('--')) {
74
+ throw new Error(`--card needs a card id. ${USAGE}`);
75
+ }
76
+ cardId = value;
77
+ }
78
+ else if (arg === '--project') {
79
+ i += 1;
80
+ const value = args[i];
81
+ // Same reasoning as `--card` above, and it matters more here: a swallowed
82
+ // flag would be looked up as a project NAME and refused as "no project
83
+ // called --card", which blames the wrong argument.
84
+ if (value === undefined || value.startsWith('--')) {
85
+ throw new Error(`--project needs a project id or name. ${USAGE}`);
86
+ }
87
+ project = value;
88
+ }
89
+ else if (arg.startsWith('--')) {
90
+ throw new Error(`unknown option ${arg}. ${USAGE}`);
91
+ }
92
+ else {
93
+ words.push(arg);
94
+ }
95
+ }
96
+ /* ═══ NAMING A PROJECT FOR A CARD THAT ALREADY EXISTS IS REFUSED, NOT
97
+ IGNORED. ═══ A card is filed once, when it is created; `--card` continues a
98
+ request that is already filed somewhere. Accepting both and silently using
99
+ neither would have somebody believe they had moved a card. Moving one is a
100
+ different act and this build does not have it. */
101
+ if (cardId && project) {
102
+ throw new Error('a card is already filed under its project, so --project cannot be given with --card. '
103
+ + `${USAGE}`);
104
+ }
105
+ if (words.length === 0)
106
+ throw new Error(`say what? ${USAGE}`);
107
+ if (words.length > 1) {
108
+ throw new Error(`quote the message as one argument, got ${words.length}. ${USAGE}`);
109
+ }
110
+ const message = words[0];
111
+ // A blank message would produce a blank title, which the table rejects, and a
112
+ // turn nobody could read. Refused here so the reason names the message rather
113
+ // than a check constraint.
114
+ if (message.trim() === '')
115
+ throw new Error(`the message is empty. ${USAGE}`);
116
+ return { cardId, project, message };
117
+ }
118
+ /** The first line of what the user typed, as the card's name. */
119
+ function titleFrom(message) {
120
+ const line = message.trim().split('\n')[0].replace(/\s+/g, ' ').trim();
121
+ if (line.length <= TITLE_LIMIT)
122
+ return line;
123
+ return `${line.slice(0, TITLE_LIMIT - 3).trimEnd()}...`;
124
+ }
125
+ /**
126
+ * The card `--card` names, or a truthful refusal. `maybeSingle` distinguishes an
127
+ * absent card from a failed read: it returns null data with no error only when
128
+ * the row genuinely is not there, and a read that could not run still errors.
129
+ *
130
+ * ═══ AND THAT IS WHY THIS ONE DOES NOT GO THROUGH `returned()`. ═══ The shared
131
+ * guard treats "no error and no data" as a result it does not understand, which
132
+ * is right for every other call here and wrong for exactly this one: here it is
133
+ * a fact about the record, and it has its own sentence saying what to do next.
134
+ */
135
+ async function existingCard(client, cardId) {
136
+ const { data, error } = await client
137
+ .from('panel3_cards')
138
+ .select('id, title')
139
+ .eq('id', cardId)
140
+ .maybeSingle();
141
+ if (error)
142
+ throw new Error(`could not read card ${cardId}: ${error.message}`);
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.');
146
+ }
147
+ return data;
148
+ }
149
+ /**
150
+ * ═══ THE CARD'S PROJECT, WHEN THERE IS ONLY ONE IT COULD BE. ═══
151
+ *
152
+ * `panel3_cards.project_id` existed from Task 1 and nothing wrote it, so every
153
+ * card read "no project yet" forever. A column nothing ever writes is not a
154
+ * nullable column, it is a dead one, and a card that cannot say which project it
155
+ * belongs to is a card the board cannot file.
156
+ *
157
+ * WHAT CAN HONESTLY BE KNOWN AT SEND TIME IS EXACTLY THIS. If the user has one
158
+ * project, every request they make is about it, and that is a fact about the
159
+ * record rather than a reading of the words. With two or more, the only clue is
160
+ * what they typed, and having the product classify a message at send time is the
161
+ * move ux.md spends a section forbidding: "the moment the product classifies, it
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.
164
+ *
165
+ * IT IS A LABEL, NOT A WALL. ux.md: "Project is a filter the coordinator applies,
166
+ * never a wall it lives inside." Nothing downstream narrows a read to this
167
+ * project; level 1 sees everything the user has, whatever one card is filed
168
+ * under.
169
+ *
170
+ * `limit(2)` because the question is one-or-more-than-one, and a user with two
171
+ * hundred projects should not have their acknowledgement wait on a list nobody
172
+ * reads.
173
+ *
174
+ * ═══ AND ARCHIVED PROJECTS ARE NOT PROJECTS IT COULD BE. ═══ Without the
175
+ * filter, a user with one live project and one they archived last year gets two
176
+ * rows, so the card silently loses the fact this whole function exists to keep —
177
+ * and it loses it precisely for the user who has been here long enough to have
178
+ * archived something. The rule is "the only project it could be", and an
179
+ * archived one is not one it could be.
180
+ */
181
+ async function onlyProject(client) {
182
+ const projects = await returned(client.from('projects').select('id').is('archived_at', null).limit(2), 'read', 'your projects');
183
+ return projects.length === 1 ? projects[0].id : null;
184
+ }
185
+ /**
186
+ * ═══ THE PROJECT THE PERSON NAMED, BY ID OR BY NAME. ═══
187
+ *
188
+ * A person at a terminal has the name in front of them and the id nowhere, so
189
+ * refusing anything but a uuid would make the flag unusable by the only people
190
+ * who can type it. Both are matched against the SHARED `projects` table through
191
+ * their own RLS, which is the read AGENTS.md allows and the web app makes on
192
+ * every page.
193
+ *
194
+ * ═══ AN UNKNOWN NAME AND AN AMBIGUOUS ONE BOTH REFUSE THE SEND. ═══ Filing the
195
+ * card somewhere else, or nowhere, would be answering a different request from
196
+ * the one that was typed: the project decides which checkout the work happens
197
+ * in. The names are listed back, because the person is one keystroke from the
198
+ * right one.
199
+ */
200
+ async function namedProject(client, named) {
201
+ const projects = await returned(client.from('projects').select('id, name').is('archived_at', null), 'read', 'your projects');
202
+ const wanted = named.trim().toLowerCase();
203
+ const matches = projects.filter((p) => p.id === named || (p.name ?? '').trim().toLowerCase() === wanted);
204
+ if (matches.length === 1)
205
+ return matches[0].id;
206
+ const known = projects.map((p) => p.name).sort().join(', ') || 'none';
207
+ if (matches.length === 0) {
208
+ throw new Error(`no project of yours is called ${named}. Yours are: ${known}`);
209
+ }
210
+ throw new Error(`${matches.length} of your projects are called ${named}, so name it by id instead. `
211
+ + `Yours are: ${known}`);
212
+ }
213
+ /** A new card, written by the product on send, filed under the project the
214
+ * person named, or under their project when there is only one it could be.
215
+ * Read first, write once: a failed read refuses the send outright rather than
216
+ * writing a card that quietly lost a fact it could have had. */
217
+ async function newCard(client, message, project) {
218
+ const projectId = project ? await namedProject(client, project) : await onlyProject(client);
219
+ return returned(client
220
+ .from('panel3_cards')
221
+ .insert({ title: titleFrom(message), project_id: projectId })
222
+ .select('id, title')
223
+ .single(), 'create', 'the card');
224
+ }
225
+ /** The user's words, appended. `role` is always `user` here: this command is the
226
+ * user's, and the agent side of the table belongs to the task that spawns one. */
227
+ async function addTurn(client, cardId, message) {
228
+ return returned(client
229
+ .from('panel3_turns')
230
+ .insert({ card_id: cardId, role: 'user', body: message })
231
+ .select('id, created_at, addressed_at')
232
+ .single(), 'add', `the turn to card ${cardId}`);
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ export async function say(args) {
236
+ const { cardId, project, message } = parseArgs(args);
237
+ const client = await signedInClient();
238
+ const card = cardId
239
+ ? await existingCard(client, cardId)
240
+ : await newCard(client, message, project);
241
+ const isNew = !cardId;
242
+ let turn;
243
+ try {
244
+ turn = await addTurn(client, card.id, message);
245
+ }
246
+ catch (error) {
247
+ // The card is on the record and the message is not. Said out loud, with the
248
+ // id, because the alternative is a user who believes nothing was written
249
+ // and a nameless card sitting on their board.
250
+ const reason = error instanceof Error ? error.message : String(error);
251
+ if (isNew) {
252
+ throw new Error(`${reason}\ncard ${card.id} was created and carries no turn. `
253
+ + `Retry with: cs3 say --card ${card.id} "<text>"`);
254
+ }
255
+ throw error;
256
+ }
257
+ out(`card ${card.id}${isNew ? ' NEW' : ''}`);
258
+ out(`title ${card.title}`);
259
+ out(`turn ${turn.id} ${at(turn.created_at)} ${turn.addressed_at ? `addressed ${at(turn.addressed_at)}` : 'NOT ADDRESSED'}`);
260
+ out();
261
+ out(` cs3 show ${card.id}`);
262
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * ═══ WHAT A RUN READ, AND WHERE IT IS NOT ALLOWED TO GO. ═══
3
+ *
4
+ * A credential is attached so an agent can USE it: put it in a header, an
5
+ * environment variable, a command. That happens inside the agent's own harness
6
+ * and is none of this file's business. What IS this file's business is
7
+ * everything the agent then WRITES, because in v3 every word an agent writes is
8
+ * a row in a hosted database and a line on a card somebody's colleague can open.
9
+ *
10
+ * The rule is v2's, and v2 states it in `mcp.ts`: a credential value must never
11
+ * reach a CLOUD ROW or the PANEL. v3 breaks that rule by default and would have
12
+ * shipped breaking it, because there is no v3 write that is not a cloud row.
13
+ *
14
+ * ═══ TWO HALVES, AND ONLY ONE OF THEM IS THIS FILE. ═══ The other half is the
15
+ * instruction `get_credential` returns, which tells the agent not to publish the
16
+ * value. This half is what makes the rule true when the instruction is not
17
+ * followed, which — v2 recorded it happening in both harnesses — is a thing that
18
+ * happens. Neither half replaces the other: an instruction alone is a hope, and
19
+ * a substitution alone would leave the agent believing it had published
20
+ * something it had not.
21
+ *
22
+ * ═══ WHY A COPY OF v2's, AND NOT AN IMPORT. ═══ `panel3/` imports nothing from
23
+ * v1 or v2, and `panel3-isolation.contract.test.mjs` enforces it. Both are
24
+ * implementing the same three lines of the same rule.
25
+ */
26
+ /** run id -> (value -> the credential's STORED name). */
27
+ const secretsByRun = new Map();
28
+ /** Record a value this run was given, so anything it writes can be checked
29
+ * against it. An empty value is not recorded: substituting `''` would replace
30
+ * every gap in every string. */
31
+ export function rememberSecret(runId, value, name) {
32
+ if (!runId || !value)
33
+ return;
34
+ const forRun = secretsByRun.get(runId) ?? new Map();
35
+ forRun.set(value, name);
36
+ secretsByRun.set(runId, forRun);
37
+ }
38
+ /**
39
+ * Every value this run read, substituted out of text on its way to a row.
40
+ *
41
+ * LITERAL, NEVER A RegExp: a real password contains `.`, `+`, `(`, so a pattern
42
+ * built from one would be malformed or would over-match. `String.replaceAll`
43
+ * with a STRING needle has no such problem — but its REPLACEMENT still honours
44
+ * `$&`, `` $` `` and `$'`, so a credential named "Ops $& token" would corrupt
45
+ * what it replaced. Hence the `$$` escape on the NAME.
46
+ *
47
+ * LONGEST FIRST, so a secret that contains another cannot leave a fragment of
48
+ * the longer one behind after the shorter one has been replaced.
49
+ *
50
+ * IDENTITY WHEN THE RUN READ NOTHING, which is nearly every run: the text comes
51
+ * back byte for byte, so nothing else in v3 has to reason about whether this
52
+ * touched it.
53
+ */
54
+ export function redactSecrets(runId, text) {
55
+ if (!runId || !text)
56
+ return text;
57
+ const forRun = secretsByRun.get(runId);
58
+ if (!forRun || forRun.size === 0)
59
+ return text;
60
+ let out = text;
61
+ for (const value of [...forRun.keys()].sort((a, b) => b.length - a.length)) {
62
+ if (!out.includes(value))
63
+ continue;
64
+ out = out.replaceAll(value, `[redacted: ${(forRun.get(value) ?? 'a credential').replaceAll('$', '$$$$')}]`);
65
+ }
66
+ return out;
67
+ }
68
+ /** Drop everything this run read. Called from the run's own completion path,
69
+ * which runs on every ending, so a daemon that stays up for days does not keep
70
+ * a secret in memory for a run that ended on Tuesday. */
71
+ export function forgetSecrets(runId) {
72
+ secretsByRun.delete(runId);
73
+ }
74
+ /**
75
+ * Every string inside a tool call's arguments, redacted, structure untouched.
76
+ *
77
+ * ═══ WHY EVERY ARGUMENT OF EVERY TOOL, AND NOT A LIST OF THE RISKY ONES. ═══
78
+ * No v3 tool has any legitimate reason to be HANDED a secret. They write the
79
+ * record: a report, an activity line, a question, an answer, a responsibility
80
+ * for somebody being dispatched, an artifact, a comment. The thing a secret is
81
+ * FOR — the request that needs it — happens in the harness's own shell, which
82
+ * does not come through here at all. So the safe set is "all of them", and a
83
+ * list of risky ones would be a list to keep up to date every time a tool is
84
+ * added, which is the maintenance nobody does.
85
+ *
86
+ * RECURSIVE, because `dispatch` and the artifact tools take nested objects and
87
+ * arrays, and a secret does not become safe by being one level down.
88
+ */
89
+ export function redactArgs(runId, args) {
90
+ if (typeof args === 'string')
91
+ return redactSecrets(runId, args);
92
+ if (Array.isArray(args))
93
+ return args.map((item) => redactArgs(runId, item));
94
+ if (args && typeof args === 'object') {
95
+ return Object.fromEntries(Object.entries(args).map(([k, v]) => [k, redactArgs(runId, v)]));
96
+ }
97
+ return args;
98
+ }