@ctrl-spc/cs 0.6.0 → 0.7.1

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,1906 @@
1
+ /**
2
+ * ═══ AGENT PANEL v3: the tools server, and the per-level allowlist. ═══
3
+ *
4
+ * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
5
+ * import it.
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * ═══ WHY v3 SERVES ITS OWN TOOLS INSTEAD OF IMPORTING `mcp.ts`. ═══
9
+ *
10
+ * conventions.md left exactly one line unsettled — "the tools server" — and this
11
+ * is the file that settles it. `cli-v2/src/mcp.ts` holds every product tool in
12
+ * 20,143 lines. Its handler BODIES are generation-neutral, and its session
13
+ * anchoring and its permission gate are not: both branch on whether the
14
+ * connection belongs to v1 or v2, so reuse means adding a third branch to the
15
+ * one file that would then be unremovable. Finding F2 records the trade in full,
16
+ * including what it costs: two implementations of "create a work item" exist
17
+ * until v1 and v2 are retired. A temporary duplication was chosen over a
18
+ * permanent coupling.
19
+ *
20
+ * So this serves the tools plan.md's Task 5 table names, against the same
21
+ * Supabase and the same RLS, AND NO MORE. It is not a framework. There is no
22
+ * registry, no plugin surface and no second server behind an interface: one
23
+ * array of definitions, each one carrying the levels it belongs to.
24
+ *
25
+ * ---------------------------------------------------------------------------
26
+ * ═══ RULE A — THE LEVEL RIDES THE CONNECTION, NEVER AN ARGUMENT. ═══
27
+ *
28
+ * ux.md: "the run id is on the tools server URL, and the server resolves that
29
+ * run's level from the record." The URL is `/mcp/<run-id>`, the run row answers
30
+ * what level it is, and no tool below takes a `level` parameter — there is
31
+ * nothing for an agent to claim, correctly or otherwise.
32
+ *
33
+ * This is the same doctrine the product already applies to attribution:
34
+ * provenance is not a matter of agent judgement. An agent that could name its
35
+ * own level could grant itself `ask_question` by saying it was level 1, and the
36
+ * whole allowlist would be advice.
37
+ *
38
+ * IT IS BOUND ONCE, AT `initialize`, AND CHECKED ON EVERY REQUEST AFTER. The
39
+ * session's run id is remembered when the connection is made and every later
40
+ * request on that session has to arrive on the same path, so a connection cannot
41
+ * change which run it is partway through.
42
+ *
43
+ * ---------------------------------------------------------------------------
44
+ * ═══ RULE B — A TOOL A LEVEL MAY NOT USE IS ABSENT. ═══
45
+ *
46
+ * Not present and refusing. The server built for a connection registers only the
47
+ * definitions whose `levels` include that run's level, so the list an agent is
48
+ * handed at `initialize` IS the allowlist. Level 2 sees `ask_question`; level 3
49
+ * does not.
50
+ *
51
+ * The reason is about behaviour, not tidiness: an agent that can see a tool it
52
+ * may not call will spend turns discovering that, and will then report the
53
+ * refusal to the user as the product being broken. Absence is unambiguous.
54
+ *
55
+ * ---------------------------------------------------------------------------
56
+ * ═══ NO CODE TOOL IS SERVED HERE, AT ANY LEVEL. ═══
57
+ *
58
+ * Reading and editing code is the harness's own, and which levels get it is
59
+ * decided in `spawn.ts` by what is passed to `--allowedTools`. Level 1 is
60
+ * spawned without any of them (ux.md: "level 1 is never granted a code tool"),
61
+ * levels 2 and 3 with them. Serving a second, weaker copy of `Read` from here
62
+ * would give level 1 a route to the thing the rule exists to deny it.
63
+ *
64
+ * ---------------------------------------------------------------------------
65
+ * ═══ STARTING A PROCESS IS THE DAEMON'S, NOT THIS FILE'S. ═══
66
+ *
67
+ * `dispatch` is the one tool whose effect is a running program, and this server
68
+ * does not spawn it: it calls a function the daemon hands in. That is not
69
+ * indirection for its own sake. The daemon already owns everything about a
70
+ * child's life — writing its row before it starts, recording its pid, waiting
71
+ * for it, writing its answer, giving up on it when its process is gone, and
72
+ * holding it open long enough for `--once` to be honest — and a second spawn
73
+ * path here would be a second owner of all of it, each with its own idea of what
74
+ * happens when the process dies.
75
+ *
76
+ * ═══ AND IT RETURNS THE MOMENT THE PROCESS EXISTS. ═══ ux.md: "Level 1 never
77
+ * blocks waiting for it. It dispatched and moved on." A handler that awaited the
78
+ * child's answer would hold the coordinator's own run open for the length of the
79
+ * work, which is the one thing the level structure exists to prevent.
80
+ *
81
+ * ---------------------------------------------------------------------------
82
+ * ═══ ASKING IS TERMINAL, AND THE TOOLS SAY SO IN AS MANY WORDS. ═══
83
+ *
84
+ * `escalate`, `ask_question` and `answer_escalation` are three tools over one
85
+ * statement, `panel3_ask` and `panel3_answer_ask`. The rule they carry is ux.md's:
86
+ * an agent that cannot proceed writes its question and EXITS. It does not block,
87
+ * does not poll, and is not sitting there to be woken, because the harness is
88
+ * one-shot with no inbound channel — so every one of these descriptions ends by
89
+ * telling the agent to stop, and none of them promises it will be told anything.
90
+ *
91
+ * WHAT HAPPENS INSTEAD IS THAT IT IS RE-CREATED with the answer already in its
92
+ * prompt, which is the daemon's business and not something an agent has to know
93
+ * or could do anything about.
94
+ *
95
+ * ---------------------------------------------------------------------------
96
+ * ═══ STOPPING IS A RECORD WRITE HERE TOO, AND THE SCOPE IS NOT THIS FILE'S.
97
+ * ═══
98
+ *
99
+ * `stop_run` refused for the whole plumbing build, honestly, because nothing
100
+ * behind it was built. What it reaches now is `panel3_stop_run`, which stops the
101
+ * run named and everything it dispatched, and which decides FOR ITSELF whether
102
+ * this caller may: it reads the caller's own level and card off its row and
103
+ * walks the ancestry of the run it named. So the rule below is a description of
104
+ * what the record will do, not an instruction this file carries out, and an
105
+ * agent naming something out of its reach stops nothing and is told so.
106
+ *
107
+ * ═══ AND THE KILL IS STILL THE DAEMON'S. ═══ The record is the intent, exactly
108
+ * as it is for the person's own Stop: `cs3 run` kills the processes of stopped
109
+ * runs on its own machine, on the poll it already has. Nothing here waits for
110
+ * that, and nothing here claims it has happened.
111
+ */
112
+ // The runtime import comes FIRST, deliberately: `tsc` elides a type-only import
113
+ // and takes the leading comment with it, so a file whose first statement is
114
+ // `import type` loses its v3 header in the published `dist/`.
115
+ import { createServer as createHttpServer } from 'node:http';
116
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
117
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
118
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
119
+ import { z } from 'zod';
120
+ import { returned } from './client.js';
121
+ import { rememberSecret, redactArgs } from './secrets.js';
122
+ import { workBrief } from './prompt.js';
123
+ import { listCodebases } from '../codebases.js';
124
+ import { readWorkflow } from '../workflows.js';
125
+ import { ASK_CONTENT_COLUMNS, attachmentLine, loadAttachments, withAskContent, } from './show.js';
126
+ // ---------------------------------------------------------------------------
127
+ // READING AND WRITING. Every query goes through the same guard every other v3
128
+ // command uses, so a failed read can never reach an agent as an empty list.
129
+ const rows = (query, verb, subject) => returned(query, verb, subject);
130
+ /** One row that must be there, or the reason it is not. `maybeSingle()` is
131
+ * deliberately not used: it returns null both for "no such row" and for "the
132
+ * caller cannot see it", and the caller here needs to say which. */
133
+ async function only(query, verb, subject) {
134
+ const found = await rows(query, verb, subject);
135
+ if (found.length === 0)
136
+ throw new Error(`could not ${verb} ${subject}: there is no such row, or it is not yours`);
137
+ return found[0];
138
+ }
139
+ /**
140
+ * ═══ A RUN THAT HAS ENDED MUST NOT GO ON WRITING. ═══
141
+ *
142
+ * The level is resolved once, when the connection is made, and a run can end
143
+ * underneath a session that is still open: `panel3_give_up` sets `state='failed'`
144
+ * and hands the turns back the moment recovery decides the process is gone, and
145
+ * a second agent is then spawned for the same card. The orphan's socket is still
146
+ * there and its next `write_report` would overwrite the report of a run the panel
147
+ * has already settled.
148
+ *
149
+ * `panel3_answer` already carries the same guard for exactly this reason. This is
150
+ * it in the same statement as the write, wherever the write is an update: there
151
+ * is no read-then-decide window, because the predicate and the write are one
152
+ * round trip.
153
+ *
154
+ * ═══ AND THE END IS `ended_at`, NOT THE STATE, BECAUSE A RUN THAT ASKED IS
155
+ * STOPPING RATHER THAN STOPPED. ═══ `escalate` and `ask_question` put a run
156
+ * in `asked` while its process is still alive, and the last thing it does is
157
+ * write down what it got as far as — which is the whole of what comes back with
158
+ * it when the answer arrives. A guard on the state alone would refuse that
159
+ * report and lose it. `ended_at` is stamped when the process actually exits, so
160
+ * it is the fact that separates "winding up" from "gone", and it is null for a
161
+ * running run too, so nothing else is loosened.
162
+ *
163
+ * ═══ AND IT IS SAID, NOT SWALLOWED. ═══ An update that matches nothing comes
164
+ * back as an empty list, which is the shape a guard is most easily lost in. The
165
+ * connection was only established because the run existed and was the user's, so
166
+ * zero rows after this predicate means one thing and it is stated: the run has
167
+ * ended. Returning "Noted." here would have an orphan reporting progress to
168
+ * nobody and telling its agent the work landed.
169
+ */
170
+ async function whileRunning(query, runId, verb) {
171
+ const written = await rows(query, verb, `run ${runId}`);
172
+ if (written.length === 0)
173
+ throw new Error(ended(runId, verb));
174
+ }
175
+ const ended = (runId, verb) => `could not ${verb} run ${runId}: THIS RUN HAS ENDED and its card has been handed to another agent. `
176
+ + 'Nothing was written. Stop rather than carrying on.';
177
+ /** The two states a run may still be written to in, as PostgREST takes them.
178
+ * See `whileRunning`: `asked` is a process winding up, not a run that is gone. */
179
+ const STILL_WRITING = ['running', 'asked'];
180
+ const secretScope = ({ runId, processToken }) => processToken === undefined ? runId : `${runId}:${processToken}`;
181
+ /**
182
+ * ═══ ASKING AND PASSING A QUESTION ON ARE ONE ACT, SO THEY ARE ONE FUNCTION.
183
+ * ═══
184
+ *
185
+ * `escalate` and `ask_question` are two tools because ux.md makes them two — one
186
+ * asks the person and one asks whoever dispatched the run — and they are ONE
187
+ * statement because everything except that destination is the same. The tool,
188
+ * not the agent, supplies that destination, and `panel3_ask` checks it against
189
+ * the run's own level so a level cannot grant itself a route it was not served.
190
+ *
191
+ * ═══ AND THE SENTENCE THAT COMES BACK IS SHARED FOR THE SAME REASON THE BRIEF
192
+ * IS. ═══ What an agent is told about stopping decides whether it stops. Two
193
+ * copies would drift, and the drift would show up as one level in three carrying
194
+ * on after it asked, holding a question nobody can answer while it works on top
195
+ * of the assumption it could not make.
196
+ */
197
+ /**
198
+ * ═══ HOW MUCH A PERSON IS ASKED TO READ ON ONE CARD, AND WHY IT IS 700. ═══
199
+ *
200
+ * Issue 7. Measured, not chosen: every person-facing question this product has
201
+ * ever asked splits cleanly at this line. The five Lane read and called
202
+ * unreadable ran 959 to 1373 characters; every question nobody objected to was
203
+ * 594 or less. 700 is above the largest accepted and below the smallest
204
+ * rejected, so no question anybody has been happy with is refused by it.
205
+ *
206
+ * IT COUNTS EVERYTHING THE PERSON READS, not one field: the card renders the
207
+ * question, the context and every option together, so a bound on `context`
208
+ * alone is satisfied by moving the paragraph into `question` and the card is
209
+ * exactly as unreadable.
210
+ *
211
+ * AND IT LIVES IN THE HANDLERS, NOT IN A ZOD `.max()`. A schema rejection
212
+ * happens before the callback runs, so it never reaches `buildServer`'s
213
+ * try/catch and the agent gets a protocol error rather than a correctable tool
214
+ * failure. A throw from here comes back as `isError`, which is a refusal the
215
+ * caller can fix in the same turn. It is also why the bound reaches
216
+ * `ask_question` and `say` and NOT `escalate` or the shared `DECIDING` schema:
217
+ * an escalation is rewritten whole by whoever passes it on, so no unbounded
218
+ * child text is ever inherited by what the person reads.
219
+ */
220
+ const PERSON_READS_LIMIT = 700;
221
+ async function asked(caller, args, askPerson) {
222
+ const { client, runId, processToken } = caller;
223
+ const { question, category, context, answer_mode, options, question_id, work_item_id, related_artifact_id, } = args;
224
+ if (askPerson) {
225
+ /* ═══ REFUSED BEFORE ANYTHING IS WRITTEN, AND TOLD WHICH HALF TO CUT. ═══
226
+ Issue 6 made a question say what answering it causes, which is the half
227
+ worth keeping; what makes a question unreadable is the findings that got
228
+ carried in with it. So the refusal does not say "be brief" — it names the
229
+ total, the limit, and where the findings belong instead. */
230
+ const total = question.length + context.length
231
+ + (options ?? []).reduce((sum, option) => sum + option.length, 0);
232
+ if (total > PERSON_READS_LIMIT) {
233
+ throw new Error(`NOTHING WAS WRITTEN and nobody was asked: that question is ${total} characters and they `
234
+ + `read it on a card three inches wide. The limit is ${PERSON_READS_LIMIT}, counting the `
235
+ + 'question, the context and every option together. Cut what you found, not what each '
236
+ + 'answer causes; findings belong in the artifact or your report.');
237
+ }
238
+ }
239
+ if (related_artifact_id !== undefined
240
+ && (!askPerson || caller.level !== 2 || !caller.isOwner || processToken === undefined)) {
241
+ throw new Error('NOTHING WAS WRITTEN. An artifact approval question can be opened only by the current '
242
+ + 'Level 2 conversation owner with its current process token.');
243
+ }
244
+ if (related_artifact_id !== undefined && (!work_item_id?.trim()
245
+ || answer_mode !== 'single_select'
246
+ || options?.length !== 2
247
+ || options[0] !== 'approve'
248
+ || options[1] !== 'request changes')) {
249
+ throw new Error('NOTHING WAS WRITTEN. An artifact approval question requires its Work Item, '
250
+ + 'answer_mode single_select, and exactly these options in order: approve, request changes.');
251
+ }
252
+ const [ask] = await rows(client.rpc('panel3_ask', {
253
+ p_run_id: runId,
254
+ p_question: question,
255
+ p_category: category,
256
+ p_context: context,
257
+ /* Undefined, not a default: the defaults live in the function signature,
258
+ so there is one place that says what an unshaped question is. */
259
+ p_answer_mode: answer_mode,
260
+ p_options: options,
261
+ p_question_id: question_id ?? null,
262
+ p_ask_person: askPerson,
263
+ /* ═══ ONLY `ask_question` OFFERS THIS, AND THE DATABASE REFUSES IT ON AN
264
+ ESCALATION ANYWAY. ═══ `escalate` shares this function and passes
265
+ nothing, so the argument is absent rather than null-and-ignored there. */
266
+ p_work_item_id: work_item_id ?? null,
267
+ ...(related_artifact_id === undefined
268
+ ? (processToken === undefined ? {} : { p_process_token: processToken })
269
+ : { p_process_token: processToken, p_related_artifact_id: related_artifact_id }),
270
+ }), 'put', 'your question where it can be answered');
271
+ if (!ask) {
272
+ /* NOTHING WAS WRITTEN, and the reasons are said together because the agent
273
+ cannot tell them apart from where it sits and all of them mean the same
274
+ thing to it: stop. */
275
+ throw new Error(`NOTHING WAS WRITTEN and nobody was asked: run ${runId} has already ended, it has already `
276
+ + 'asked something and is stopping, or the question you named is not one that is waiting on '
277
+ + 'you. Say plainly what you could not settle, and stop.');
278
+ }
279
+ if (ask.ask_id === null) {
280
+ /* ═══ REFUSED, BECAUSE BOTH READINGS ARE PLAUSIBLE AND EITHER GUESS IS
281
+ EXPENSIVE. ═══ Merging the two loses the wording of the question this run
282
+ was handed and aims its answer at the wrong agent; inserting a second
283
+ sends a duplicate up while the original stops dead. Recoverable in this
284
+ same turn, which is why it is a refusal rather than a rule in a prompt. */
285
+ throw new Error(`NOTHING WAS WRITTEN. Question ${ask.held_question} is waiting on you, so this call is `
286
+ + 'ambiguous: pass THAT one further up by calling this again with question_id set to it, or '
287
+ + 'answer it first with answer_escalation and then ask yours.');
288
+ }
289
+ return [
290
+ ask.asked_of === null
291
+ /* ═══ AND IT SAYS WHERE THE ANSWER WILL BE KEPT, BECAUSE THAT IS WHY THE
292
+ ITEM IS WORTH NAMING. ═══ questions-2/ux.md Slice 2: a question that
293
+ names a work item IS that item's decision, so an agent that named one
294
+ can tell the person where to find it, and one that named none can see
295
+ that it did. */
296
+ ? `Your question is with the person now, id ${ask.ask_id}.`
297
+ + (work_item_id ? ` It is a decision on work item ${work_item_id}.` : '')
298
+ : `Your question is with whoever sent you, id ${ask.ask_id}.`,
299
+ 'YOU HAVE STOPPED. Nothing is waiting for a reply and there is nothing here to wait for: when',
300
+ 'there is an answer, whoever needs it is started again with it. Make sure what you have done is',
301
+ 'written down, and then stop.',
302
+ ].join('\n');
303
+ }
304
+ /**
305
+ * ═══ THE ONE ARGUMENT THAT SEPARATES PASSING A QUESTION ON FROM ASKING YOUR
306
+ * OWN. ═══
307
+ *
308
+ * Shared by `escalate` and `ask_question` because it means the same thing in
309
+ * both, and because the failure it prevents is the same failure: a run handed a
310
+ * question that then stops for something unrelated of its own used to overwrite
311
+ * the first with the second, and send the answer to whoever asked the first.
312
+ */
313
+ const PASSING_ON = z.string().optional().describe('The id of a question that is waiting on YOU, when what you are doing is passing that one '
314
+ + 'further up because you cannot settle it either. Leave it out when the question is your own.');
315
+ /**
316
+ * ═══ A QUESTION IS A DECISION, AND CARRIES WHAT A WORK ITEM'S DECISION
317
+ * CARRIES. ═══
318
+ *
319
+ * questions-2/ux.md: the person answering is being interrupted, so an agent
320
+ * that has already worked out that there are three plausible answers should say
321
+ * so, and the person should be able to settle it with one press rather than by
322
+ * typing a sentence an agent then has to parse. `app.decision_configuration_valid`
323
+ * is what refuses a badly shaped one, in the database, because it is the same
324
+ * function the work item's decisions are checked by and two validators would
325
+ * eventually disagree about what a structured question is.
326
+ *
327
+ * SHARED BY `ask_question` AND `escalate` for the same reason `PASSING_ON` is:
328
+ * a question that walks up a level is rewritten whole by the agent passing it
329
+ * on, in words the person can read without having seen any of the work, and a
330
+ * rewritten question above the options its worker offered is a new question
331
+ * with an old ballot.
332
+ */
333
+ const DECIDING = {
334
+ category: z.string().min(1).describe('What kind of decision this is, in two or three words the person would use: "Rate limiting", '
335
+ + '"Which codebase", "Scope". It is the first thing they read.'),
336
+ context: z.string().min(1).describe('WHY you are asking, for somebody who has seen none of the work. What you found, what turns on '
337
+ + 'it, and what you will do with each answer. Not a restatement of the question.'),
338
+ answer_mode: z.enum(['free_text', 'single_select', 'multi_select']).optional().describe('single_select when you have worked out the plausible answers and one of them is the decision; '
339
+ + 'multi_select when several can be chosen together; free_text only when it genuinely cannot be '
340
+ + 'reduced to a shortlist. Defaults to free_text.'),
341
+ options: z.array(z.string()).optional().describe('The answers you think are plausible: between 2 and 12, each distinct and non-blank, and none '
342
+ + 'for free_text. A SUGGESTION, NOT A BALLOT — the person may always answer in their own words '
343
+ + 'instead, so offer what you would act on rather than every possibility.'),
344
+ };
345
+ const ALL = [1, 2, 3];
346
+ const line = (...parts) => parts.filter(Boolean).join(' ');
347
+ const listed = (items, empty) => (items.length === 0 ? empty : items.join('\n'));
348
+ /** How long a run has been, or was, going — computed here rather than left to
349
+ * an agent doing date arithmetic on two timestamps. `endedAt` stops the clock
350
+ * where the run itself stopped; `null` stops it at the moment of the read, so
351
+ * the same still-running run reads a little longer on a second question later,
352
+ * which is the true fact and not a bug.
353
+ *
354
+ * ═══ AND IT MEASURES THE ATTEMPT THAT IS RUNNING NOW. ═══ `resumedAt` is when
355
+ * a run was last started again as itself, which `runProcessIsAlive` in `run.ts`
356
+ * already treats as the beginning of the attempt that exists. Measuring from
357
+ * the first attempt reports a run resumed a minute ago as hours old, and this
358
+ * string is what a coordinator reads aloud when the person asks what is
359
+ * happening. */
360
+ function elapsed(startedAt, endedAt, resumedAt) {
361
+ const ms = (endedAt ? new Date(endedAt) : new Date()).getTime()
362
+ - new Date(resumedAt ?? startedAt).getTime();
363
+ const minutes = Math.round(ms / 60000);
364
+ if (minutes < 1)
365
+ return 'under a minute';
366
+ if (minutes < 60)
367
+ return `${minutes} minute${minutes === 1 ? '' : 's'}`;
368
+ const hours = Math.floor(minutes / 60);
369
+ const rest = minutes % 60;
370
+ return `${hours} hour${hours === 1 ? '' : 's'}${rest ? ` ${rest} minute${rest === 1 ? '' : 's'}` : ''}`;
371
+ }
372
+ // ---------------------------------------------------------------------------
373
+ // ═══ A SKILL IS A ROW IN ONE PLACE AND A FILE IN ANOTHER, AND IT TAKES BOTH TO
374
+ // READ ONE. ═══
375
+ //
376
+ // `public.skills` holds the row; `public.skill_bundles` holds the org. That is
377
+ // not an arrangement worth arguing with here, it is the schema: `skills` HAS NO
378
+ // `org_id` COLUMN, and a skill is org-scoped only through its bundle. So a
379
+ // storage key cannot be built from the skill row on its own, and both tools
380
+ // below make the same two reads before they touch the bucket.
381
+ //
382
+ // THESE ARE PRODUCT TABLES v3 DOES NOT OWN, AND IT ONLY READS THEM. AGENTS.md
383
+ // permits exactly that: shared product data, through the signed-in user's own
384
+ // RLS, the same way the web app reads it. There is no write side here and there
385
+ // must never be one, for the reason v2 already wrote down: an agent that could
386
+ // rewrite the skill it is about to follow is an agent that can rewrite its own
387
+ // instructions, and the whole point of a skill is that a person decided what it
388
+ // says.
389
+ /** The private bucket every uploaded bundle tree lives in. The same string as
390
+ * `SKILL_BUNDLES_BUCKET` in `cli-v2/src/mcp.ts`, written out rather than
391
+ * imported, because v3 imports nothing from v1 or v2 and the isolation contract
392
+ * enforces it. Both copies are reading the same authority: the bucket the
393
+ * migration created (20260722180000_skills.sql). */
394
+ const SKILL_BUNDLES_BUCKET = 'skill-bundles';
395
+ /**
396
+ * ═══ A REMOVED SKILL IS NOT A MISSING ONE, AND AN AGENT TOLD OTHERWISE SAYS
397
+ * THE WRONG THING TO THE PERSON. ═══
398
+ *
399
+ * `deleted_at` could have been an `.is('deleted_at', null)` on the query, which
400
+ * is how v2 and the web both do it, and then a removed skill would come back
401
+ * through `only` as "there is no such row, or it is not yours". That is a lie
402
+ * about a row the person can still see the history of, and it sends the agent
403
+ * looking for a typo in an id that is perfectly correct.
404
+ *
405
+ * It is refused either way. What changes is that this says WHICH of the two
406
+ * happened and what the person can do about it, because only one of them has a
407
+ * fix and it is not one the agent can carry out.
408
+ *
409
+ * ═══ AND THE ATTACHMENT IS NOT WRONG FOR STILL NAMING IT. ═══ `ref_id` is by
410
+ * value with no foreign key, deliberately, so that a card's history survives the
411
+ * removal of what it pointed at. The row is right, the skill is gone, and the
412
+ * agent has to be able to tell the person exactly that.
413
+ */
414
+ const removed = (what, skillId) => new Error(`NOTHING WAS READ: ${what} was removed on the Skills page, so skill ${skillId} is not there to `
415
+ + 'follow any more. It is still attached to this conversation, because what was attached is kept '
416
+ + 'as it was, but a removed skill is not a method anybody is standing behind now. Tell the person '
417
+ + 'it has been removed and ask them to attach a current one, and do not guess at what it said.');
418
+ /**
419
+ * The skill, if it is still a skill, and everywhere in storage it reaches.
420
+ *
421
+ * ═══ THE CONVENTION IS KNOWN HERE AND NOWHERE ELSE. ═══
422
+ * `<org_id>/<bundle_id>/<relative path>`, from
423
+ * 20260723120000_skills_org_scope.sql and the web's own upload. The org comes
424
+ * from the bundle because `skills` HAS NO `org_id` COLUMN, and neither handler
425
+ * composes a key for itself: they are handed the two they can want. Two copies
426
+ * of a path convention is how the two come to disagree about where a bundle
427
+ * lives, in whichever tool nobody re-read, and spelling `${orgId}/${bundleId}`
428
+ * out again in each handler was that duplication starting.
429
+ *
430
+ * BOTH ROWS MUST BE LIVE, AND THAT IS TWO CHECKS RATHER THAN ONE. `deleted_at`
431
+ * is on both tables and archiving a whole pack does not touch the skills inside
432
+ * it, so a skill row can be perfectly live inside a bundle nobody kept. The web
433
+ * app honours both (`bundleIdsForOrg` in `web/src/lib/skills.ts`, and the
434
+ * skills query beside it), and honouring only the first would have agents
435
+ * following a pack the person retired in one go.
436
+ */
437
+ async function skillInItsBundle(client, skillId) {
438
+ const row = await only(client.from('skills')
439
+ .select('id, bundle_id, name, description, relative_path, status, status_reason, deleted_at')
440
+ .eq('id', skillId), 'read', `skill ${skillId}`);
441
+ if (row.deleted_at)
442
+ throw removed(`the skill "${row.name}"`, skillId);
443
+ const bundle = await only(client.from('skill_bundles').select('org_id, deleted_at').eq('id', row.bundle_id), 'read', `the bundle skill ${skillId} belongs to`);
444
+ if (bundle.deleted_at)
445
+ throw removed(`the pack "${row.name}" came in`, skillId);
446
+ /* The skill's OWN folder, which is usually the bundle root: importing a folder
447
+ strips the picked folder's own leading segment, so `relative_path` is
448
+ commonly just `SKILL.md` with no directory at all. The ternary is the point
449
+ rather than tidiness, because joining an empty directory emits a double
450
+ slash and 404s with the cause invisible. */
451
+ const slash = row.relative_path.lastIndexOf('/');
452
+ const dir = slash === -1 ? '' : row.relative_path.slice(0, slash);
453
+ const bundleRoot = `${bundle.org_id}/${row.bundle_id}`;
454
+ const skillRoot = dir ? `${bundleRoot}/${dir}` : bundleRoot;
455
+ return {
456
+ row,
457
+ document: `${bundleRoot}/${row.relative_path}`,
458
+ beside: (path) => `${skillRoot}/${path}`,
459
+ };
460
+ }
461
+ /**
462
+ * What is stored at one key in that bucket, or the reason there is nothing to
463
+ * hand back.
464
+ *
465
+ * ═══ A MISSING FILE THROWS, AND SAYS THE RETRY IS POINTLESS. ═══ The row and
466
+ * the file are separate systems: a skill can be listed on the Skills page and
467
+ * have lost its file, and what fixes that is a person re-importing the pack,
468
+ * which no amount of trying again from here brings any closer. So the message
469
+ * names the key it looked at and says what would actually help.
470
+ *
471
+ * AND IT NEVER RETURNS AN EMPTY DOCUMENT, which is the failure that would matter
472
+ * most: an agent handed `''` follows nothing, notices nothing, and reports the
473
+ * skill as read.
474
+ */
475
+ async function skillDocument(client, storagePath, what) {
476
+ const { data, error } = await client.storage.from(SKILL_BUNDLES_BUCKET).download(storagePath);
477
+ if (error || !data) {
478
+ throw new Error(`${what} is missing from storage at "${storagePath}"${error ? ` (${error.message})` : ''}. `
479
+ + 'Retrying will not help: the skill row and its file are stored separately, and this one lost '
480
+ + 'its file. Tell the person to re-import the skill pack on the Skills page in the web app, and '
481
+ + 'do not guess at what it would have said.');
482
+ }
483
+ return await data.text();
484
+ }
485
+ /**
486
+ * ═══ THE PATH A SKILL NAMED IS NOT TRUSTED. ═══
487
+ *
488
+ * A skill document is content a person imported, and the agent reading it passes
489
+ * whatever it says straight back through this tool. So a path that would leave
490
+ * the skill's own folder is REFUSED BY NAME rather than normalised away:
491
+ * normalising invites the question of what it normalises to, and no legitimate
492
+ * caller ever needs to go up a level.
493
+ *
494
+ * ═══ THE PERCENT-DECODED SPELLING IS CHECKED TOO, AND THAT IS THE HALF THAT
495
+ * LOOKS REDUNDANT. ═══ supabase-js interpolates the key into a URL raw and
496
+ * unencoded, and the URL parser then decodes and collapses what it finds, so
497
+ * `%2e%2e/` arrives at storage as `../` having walked past a check that read
498
+ * only the literal characters. `mcp.ts`'s own `read_skill_file` records that it
499
+ * verified exactly that, out of the skill's folder and out of the bucket
500
+ * entirely, which is why a check on one spelling is not a check.
501
+ */
502
+ function insideTheSkill(path) {
503
+ const wanted = path.trim();
504
+ let decoded = wanted;
505
+ try {
506
+ decoded = decodeURIComponent(wanted);
507
+ }
508
+ catch {
509
+ /* An escape too malformed to decode is still handed to the URL parser, so
510
+ nothing is assumed from the failure: the raw spelling is checked below
511
+ either way, and a stray `%` never becomes a reason to allow something. */
512
+ }
513
+ for (const spelling of [wanted, decoded]) {
514
+ if (spelling === '' || spelling.includes('..') || spelling.startsWith('/')) {
515
+ throw new Error(`REFUSED, and nothing was read: "${path}" is not a path inside this skill. A file beside a `
516
+ + 'skill document is named relative to that document, with no leading "/" and no "..". Read '
517
+ + 'what the skill actually named, and do not guess at a path outside it.');
518
+ }
519
+ }
520
+ return wanted;
521
+ }
522
+ /** The one refusal, for every way a credential can be out of reach. See the
523
+ * handler for why it does not say which. */
524
+ const CREDENTIAL_NOT_ACCESSIBLE = 'NOTHING WAS READ: credential not found or not accessible. Tell the person which credential you '
525
+ + 'were asked for and that you cannot reach it, and do not carry on as though you had it.';
526
+ /**
527
+ * ═══ THE SENTENCE THAT DECIDES WHETHER THE WORK GETS DONE. ═══
528
+ *
529
+ * Carried over from v2's `CREDENTIAL_INSTRUCTION` in substance word for word,
530
+ * including the last line, which reads like belt and braces and is not. `mcp.ts`
531
+ * records what happened without it: BOTH harnesses read the secret, decided that
532
+ * holding it was itself the danger, and refused the work. A product whose
533
+ * credentials cannot be used is worse than one with no credentials, because the
534
+ * person put the secret in and got a refusal back.
535
+ *
536
+ * So the shape is deliberate. USING it is named explicitly and expected. The
537
+ * single prohibition is on PUBLISHING it to what is shared and permanent — which
538
+ * in v3 is everything an agent writes, since every word goes to a hosted row.
539
+ */
540
+ const CREDENTIAL_INSTRUCTION = 'This is a SECRET, and it is yours to USE. Make the call you were asked to make with it: in a '
541
+ + 'request header, an environment variable, a command, a config file on this machine, whatever the '
542
+ + 'work needs. That is what it is for. The ONE rule is about what you PUBLISH: never put the value '
543
+ + 'itself into your report, your answer, a progress line, a question, a comment, an artifact or a '
544
+ + 'file you commit, because all of those are shared and permanent. Name the credential you used '
545
+ + 'instead. Never refuse the work to avoid touching the value.';
546
+ // ---------------------------------------------------------------------------
547
+ // THE TOOLS.
548
+ //
549
+ // One array, in plan.md's own grouping and order, so the served surface can be
550
+ // read against the table it came from without hunting. `levels` on each entry is
551
+ // the whole of the per-level rule; there is no second place where a level is
552
+ // granted or taken away.
553
+ const TOOLS = [
554
+ // ── Read the record ──────────────────────────────────────────────────────
555
+ {
556
+ name: 'list_projects',
557
+ levels: ALL,
558
+ description: 'Every CTRL+SPC project you can see, with the organization it belongs to. Start here when a '
559
+ + 'request names a project by name rather than by id.',
560
+ input: {},
561
+ handler: async ({ client }) => {
562
+ const projects = await rows(client.from('projects').select('id, name, created_at, organizations(name)').order('created_at'), 'read', 'projects');
563
+ return listed(projects.map((p) => line(p.id, p.name, `org ${p.organizations?.name ?? 'unknown'}`)), 'You can see no projects.');
564
+ },
565
+ },
566
+ {
567
+ name: 'list_work_items',
568
+ levels: ALL,
569
+ description: 'The work items (tasks) on the board, newest first. Optionally filtered to one project and one '
570
+ + 'status. Archived items and product ideas are not work items and are not listed.',
571
+ input: {
572
+ project_id: z.string().optional().describe('Only items in this project.'),
573
+ status: z.enum(['backlog', 'in_progress', 'done']).optional().describe('Only items in this status.'),
574
+ },
575
+ handler: async ({ client }, args) => {
576
+ const { project_id, status } = args;
577
+ let query = client
578
+ .from('tasks')
579
+ .select('id, name, status, project_id, epic_id, sprint_id, due_date, created_at')
580
+ .eq('is_idea', false)
581
+ .is('archived_at', null)
582
+ .order('created_at', { ascending: false });
583
+ if (project_id)
584
+ query = query.eq('project_id', project_id);
585
+ if (status)
586
+ query = query.eq('status', status);
587
+ const items = await rows(query, 'read', 'work items');
588
+ return listed(items.map((t) => line(t.id, t.status.padEnd(11), t.name, t.due_date ? `due ${t.due_date}` : null)), 'There are no work items matching that.');
589
+ },
590
+ },
591
+ {
592
+ name: 'get_work_item',
593
+ levels: ALL,
594
+ description: 'One work item in full: its description, status, placement, and the artifacts on it.',
595
+ input: { work_item_id: z.string() },
596
+ handler: async ({ client }, args) => {
597
+ const { work_item_id } = args;
598
+ const item = await only(client
599
+ .from('tasks')
600
+ .select('id, name, description, status, due_date, project_id, created_at, epics(name), sprints(name)')
601
+ .eq('id', work_item_id), 'read', `work item ${work_item_id}`);
602
+ const artifacts = await rows(client.from('artifacts').select('id, type, title, created_at').eq('task_id', work_item_id)
603
+ .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
604
+ return [
605
+ `${item.name}`,
606
+ `id ${item.id}`,
607
+ `status ${item.status}`,
608
+ `project ${item.project_id}`,
609
+ `epic ${item.epics?.name ?? 'none'}`,
610
+ `sprint ${item.sprints?.name ?? 'none'}`,
611
+ `due ${item.due_date ?? 'no date'}`,
612
+ '',
613
+ 'DESCRIPTION',
614
+ item.description.trim() === '' ? '(empty)' : item.description,
615
+ '',
616
+ `ARTIFACTS ${artifacts.length}`,
617
+ listed(artifacts.map((a) => line(a.id, a.type, a.title ?? '(untitled)')), 'none'),
618
+ ].join('\n');
619
+ },
620
+ },
621
+ {
622
+ name: 'get_skill',
623
+ /* ═══ EVERY LEVEL, AND THAT INCLUDES THE ONE WITH NO CODE TOOL. ═══ Levels 2
624
+ and 3 are the ones that follow a skill line by line, and they are both
625
+ inside `ALL`. Level 1 is in it for a different reason worth stating,
626
+ because the obvious reading is that a coordinator has no use for one:
627
+ reading what the person attached is not doing the work. A coordinator that
628
+ cannot read the skill cannot write a responsibility that respects it, and
629
+ would send somebody off against a method the person had already chosen.
630
+
631
+ AND THERE IS NO `list_skills` BESIDE IT, deliberately. v2 has one because
632
+ its agent meets a skill as a NAME in a stage document and has to go
633
+ looking. A v3 agent never searches: the id arrived in its brief, off the
634
+ card's own attachment row, put there by the person. */
635
+ levels: ALL,
636
+ description: 'Read a skill attached to this conversation. A skill is a document your team wrote to be '
637
+ + 'FOLLOWED, not reference material: call this with the id printed beside the skill in what was '
638
+ + 'attached, read all of it, and do what it says for the rest of this work.',
639
+ /* ═══ BY ID, NOT BY NAME, WHICH IS THE OPPOSITE OF v2's TOOL. ═══ v2 resolves
640
+ a name because a name is what a stage document says and what survives a
641
+ compaction, and it carries an `org_id` to disambiguate when two libraries
642
+ hold that name. Neither problem exists here: the id came off the
643
+ attachment row, so there is one row it can mean and no org argument to get
644
+ wrong. */
645
+ input: { skill_id: z.string() },
646
+ handler: async ({ client }, args) => {
647
+ const { skill_id } = args;
648
+ const { row, document: key } = await skillInItsBundle(client, skill_id);
649
+ const document = await skillDocument(client, key, `skill "${row.name}" is listed, but its document`);
650
+ return [
651
+ row.name,
652
+ `id ${row.id}`,
653
+ /* THE STATUS IS REPORTED AND NEVER FILTERED ON, which is v2's rule and
654
+ its reasoning: a `warning` is a cataloguing nit about the document's
655
+ own header, the person ticked the box and imported it anyway, and
656
+ hiding it would have an agent claim it cannot read a skill the person
657
+ is looking at on their own Skills page. */
658
+ `status ${row.status}${row.status_reason ? ` ${row.status_reason}` : ''}`,
659
+ `about ${row.description ?? 'no description'}`,
660
+ '',
661
+ 'SKILL',
662
+ /* Byte for byte as it was imported, FRONTMATTER INCLUDED. What a skill
663
+ declares about itself at the top is part of what it tells you, and
664
+ trimming it here would be this tool deciding which of the person's
665
+ words the agent gets. */
666
+ document,
667
+ '',
668
+ /* ═══ THE LAST LINE IS THE ONE THAT MAKES THE DIFFERENCE, AND IT IS
669
+ CARRIED OVER FROM v2 WORD FOR WORD IN SUBSTANCE. ═══ A document handed
670
+ over without it gets summarised back to the person and then set aside;
671
+ the same document with it gets followed. It is one sentence and it is
672
+ the whole point of the tool. */
673
+ 'This is a SKILL: a document written to be FOLLOWED for the rest of this work, exactly like a '
674
+ + 'stage document. Read all of it and do what it says. It is not reference material to '
675
+ + 'summarise, quote back or file away. Where it points at another file beside it, call '
676
+ + 'read_skill_file with this skill id and that path, and follow that too.',
677
+ ].join('\n');
678
+ },
679
+ },
680
+ {
681
+ name: 'read_skill_file',
682
+ /* ALL, for `get_skill`'s reasons: whoever may read a skill must be able to
683
+ read the file it sends them to, or the skill dead-ends at its own first
684
+ reference and the agent goes to the person for something the org already
685
+ stored. */
686
+ levels: ALL,
687
+ description: 'Read one file that lives beside a skill document, when that document points at it. The path is '
688
+ + "the one the skill named, relative to the skill's own folder. What it says is part of the "
689
+ + 'skill: follow it the same way.',
690
+ input: {
691
+ skill_id: z.string(),
692
+ path: z.string().describe("The path the skill document named, exactly as it named it, relative to the skill's own "
693
+ + 'folder.'),
694
+ },
695
+ handler: async ({ client }, args) => {
696
+ const { skill_id, path } = args;
697
+ /* REFUSED BEFORE ANYTHING IS READ, so an argument that should not exist
698
+ never becomes a request. */
699
+ const wanted = insideTheSkill(path);
700
+ const { row, beside } = await skillInItsBundle(client, skill_id);
701
+ const document = await skillDocument(client, beside(wanted), `skill "${row.name}" names "${wanted}", but that file`);
702
+ return [
703
+ `${wanted}, beside the skill ${row.name}`,
704
+ '',
705
+ document,
706
+ '',
707
+ 'This is part of the SKILL you are following. Do what it says, exactly as you do what the '
708
+ + 'skill document itself says.',
709
+ ].join('\n');
710
+ },
711
+ },
712
+ {
713
+ name: 'get_workflow',
714
+ /* ═══ LEVEL 2 ALONE, AND THAT IS A DELIBERATE DIVERGENCE FROM `get_skill`'s
715
+ `ALL`. ═══ `get_skill`'s own comment argues level 1 needs it because "a
716
+ coordinator that cannot read the skill cannot write a responsibility that
717
+ respects it". That argument does not carry across, and the difference is
718
+ what a launcher is writing. A skill is a METHOD that applies to whatever
719
+ single responsibility level 1 writes, so a launcher ignorant of it writes
720
+ a responsibility that contradicts it. A workflow is not applied to the
721
+ launcher's responsibility: it is a SEQUENCE the OWNER decomposes, and the
722
+ launcher writes one line — see this conversation through — for the agent
723
+ that reads the process itself. `attachmentLine` already prints the
724
+ workflow's NAME in the launcher's prompt, so it can name the process in
725
+ what it writes without reading a stage of it.
726
+
727
+ AND NOT LEVEL 3 EITHER. A worker owns one piece and nothing else; handing
728
+ it the whole recipe invites it to run stages that are not its. What it
729
+ does need — its own stage's document — reaches it in its brief, because
730
+ `workBrief` tells the owner to put it there. Same narrowing, same reason,
731
+ as `get_credential` below.
732
+
733
+ AND THERE IS NO `list_workflows` BESIDE IT, for `get_skill`'s reason: the
734
+ id arrived in the brief, off the card's own attachment row, put there by
735
+ the person. A v3 agent never goes looking for a workflow. */
736
+ levels: [2],
737
+ description: 'Read the workflow attached to this conversation. A workflow is the PROCESS this work '
738
+ + 'follows: named stages in an order, each with a document to work to. Call this with the id '
739
+ + 'printed beside the workflow in what was attached, and read all of it before you decide how '
740
+ + 'this work is split.',
741
+ input: { workflow_id: z.string() },
742
+ handler: async ({ client }, args) => {
743
+ const { workflow_id } = args;
744
+ const workflow = await readWorkflow(client, workflow_id);
745
+ /* ═══ EXITS RESOLVE TO A NUMBERED STAGE, NEVER TO A RAW UUID. ═══
746
+ `to_stage_id` is a uuid and no v3 tool at any level takes one, so a raw
747
+ id would be a reference the reader cannot follow. This matters
748
+ concretely: exits are the only surviving representation of a REPEAT, and
749
+ every org is seeded with a feature loop whose exits are mostly backward.
750
+ A rendering that dropped them would present the one workflow every org
751
+ actually has as a straight line, which is a different process. */
752
+ const numberOf = new Map(workflow.stages.map((stage, i) => [stage.id, i + 1]));
753
+ const exitsOf = (stageId) => workflow.exits
754
+ .filter((exit) => exit.stageId === stageId)
755
+ .map((exit) => {
756
+ const number = numberOf.get(exit.toStageId);
757
+ const stage = workflow.stages.find((candidate) => candidate.id === exit.toStageId);
758
+ return number && stage
759
+ ? `if ${exit.condition}, go to stage ${number} ${stage.name}`
760
+ : `if ${exit.condition}, go back to an earlier stage`;
761
+ });
762
+ return [
763
+ workflow.name,
764
+ `id ${workflow.id}`,
765
+ `about ${workflow.description.trim() === '' ? 'no description' : workflow.description}`,
766
+ '',
767
+ `STAGES ${workflow.stages.length}`,
768
+ ...workflow.stages.flatMap((stage, i) => [
769
+ '',
770
+ line(`${i + 1}. ${stage.name}`, stage.description.trim() === '' ? null : stage.description),
771
+ '',
772
+ /* THE WHOLE BODY. The body IS the stage document, so truncating it is
773
+ truncating the process. An empty one is a stage nobody has written
774
+ yet, which is a real state and is said rather than hidden. */
775
+ stage.body.trim() === '' ? '(this stage has not been written yet)' : stage.body,
776
+ ...exitsOf(stage.id),
777
+ ]),
778
+ '',
779
+ /* ═══ THE PERSON'S ENDING CHOICE, SAID RATHER THAN OBEYED OR DISCARDED.
780
+ ═══ They picked it in the web app's own picker. v3 has no backlog read
781
+ at any level — `list_work_items` is newest-first with no rank — so
782
+ this cannot be done here. Rendering the raw value would invite
783
+ improvisation and rendering nothing would silently discard their
784
+ choice, which is the untruthful failure the guide forbids. The slice
785
+ that gives v3 a backlog read replaces this sentence. */
786
+ ...(workflow.ending === 'next-in-backlog'
787
+ ? [
788
+ 'This workflow is set to start again on the next backlog item. That is not available '
789
+ + 'here: stop when the last stage is done, and say so.',
790
+ '',
791
+ ]
792
+ : []),
793
+ 'This is a WORKFLOW: the process to FOLLOW for this work, stage by stage in the order '
794
+ + 'above. It is not a document to summarise, quote back or file away.',
795
+ ].join('\n');
796
+ },
797
+ },
798
+ {
799
+ name: 'get_credential',
800
+ /* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
801
+ ux.md puts `get_credential` in the "Read the record" row, which is
802
+ yes/yes/yes. Lane's instruction (2026-08-19) splits it: the NAME is level
803
+ 1's, the VALUE is levels 2 and 3's, and ux.md is corrected to match.
804
+
805
+ THE REASON IT IS THE BETTER RULE, because the table will be read again by
806
+ somebody who did not hear the instruction: level 1 has no code tool, on
807
+ purpose. It never makes the call a secret is for. A secret in its context
808
+ is a secret in the coordinating run of the card, held for work it cannot
809
+ do. What a coordinator needs is the NAME, so it can write "use the Stripe
810
+ test key" into a responsibility, and the name is already in front of it —
811
+ `attachmentLine` prints it in the prompt this run was started with. So
812
+ level 1 gets nothing new here, rather than getting a narrower tool.
813
+
814
+ AND THERE IS NO `list_credentials` BESIDE IT, for `get_skill`'s reason:
815
+ the id arrived in the brief, off the card's own attachment row, put there
816
+ by the person. A v3 agent never goes looking for a credential. */
817
+ levels: [2, 3],
818
+ description: 'Read a credential attached to this conversation, and use it. Call this with the id printed '
819
+ + 'beside the credential in what was attached. The value is yours to USE in the work you were '
820
+ + 'sent to do; what you must not do is publish it.',
821
+ input: { credential_id: z.string() },
822
+ handler: async ({ client, runId, processToken }, args) => {
823
+ const { credential_id } = args;
824
+ const found = await rows(client.from('credentials').select('id, name, kind, username').eq('id', credential_id), 'read', `credential ${credential_id}`);
825
+ /* ═══ ONE MESSAGE FOR THREE DIFFERENT FACTS, AND THAT IS DELIBERATE. ═══
826
+ `get_skill` goes out of its way to say WHICH of "no such row" and "not
827
+ yours" happened, because a person can act on the difference. Here the
828
+ opposite is right, and 20260723130000_credentials.sql says why: names
829
+ and existence are themselves what the access rule protects, so a caller
830
+ that could tell "there is no such credential" from "there is one and it
831
+ is not yours" has a probe. `only()` is not used for exactly this
832
+ reason — its message names the distinction this one must not. */
833
+ if (found.length === 0)
834
+ throw new Error(CREDENTIAL_NOT_ACCESSIBLE);
835
+ const row = found[0];
836
+ /* ═══ THE ONLY READ PATH FOR THE VALUE. ═══ `credentials` has no secret
837
+ column to select: the value is in the vault and `reveal_credential` is
838
+ the DEFINER function that decrypts it, re-checking the same predicate
839
+ the select policy carries. So "if the user is permitted to use it" is
840
+ not a rule v3 implements — it is the database's, twice, and the daemon
841
+ is holding the user's own token when it asks. */
842
+ const { data, error } = await client.rpc('reveal_credential', { p_id: row.id });
843
+ if (error)
844
+ throw new Error(`could not read credential ${credential_id}: ${error.message}`);
845
+ const value = (data ?? {});
846
+ const secret = typeof value.secret === 'string' ? value.secret : '';
847
+ /* NEVER AN EMPTY SECRET. An agent handed `''` sends an empty header, gets
848
+ a 401 it cannot explain, and reports the credential as wrong. */
849
+ if (secret === '') {
850
+ throw new Error(`NOTHING WAS READ: credential "${row.name}" has no value stored against it. Retrying will `
851
+ + 'not help. Tell the person to open the Credentials page and set it again, and do not '
852
+ + 'guess at the value or carry on without it.');
853
+ }
854
+ /* ═══ RECORDED BEFORE IT IS RETURNED, so the substitution is in place
855
+ BEFORE the agent can write anything containing it. ═══ See secrets.ts:
856
+ from here on, this value cannot reach a panel3_ row through any tool
857
+ this run calls or through the answer it ends with. */
858
+ rememberSecret(secretScope({ runId, processToken }), secret, row.name);
859
+ const username = typeof value.username === 'string' ? value.username : null;
860
+ return [
861
+ row.name,
862
+ `id ${row.id}`,
863
+ `kind ${row.kind}`,
864
+ ...(username === null ? [] : [`username ${username}`]),
865
+ '',
866
+ row.kind === 'login' ? 'PASSWORD' : 'SECRET',
867
+ secret,
868
+ '',
869
+ CREDENTIAL_INSTRUCTION,
870
+ ].join('\n');
871
+ },
872
+ },
873
+ {
874
+ name: 'list_artifacts',
875
+ levels: ALL,
876
+ description: 'The live artifacts on one work item, without their bodies. Use get_artifact to read one.',
877
+ input: { work_item_id: z.string() },
878
+ handler: async ({ client }, args) => {
879
+ const { work_item_id } = args;
880
+ const artifacts = await rows(client.from('artifacts').select('id, type, format, title, created_at').eq('task_id', work_item_id)
881
+ .is('deleted_at', null).order('created_at'), 'read', `the artifacts on work item ${work_item_id}`);
882
+ return listed(artifacts.map((a) => line(a.id, a.type, a.format, a.title ?? '(untitled)')), 'That work item has no artifacts.');
883
+ },
884
+ },
885
+ {
886
+ name: 'get_artifact',
887
+ levels: ALL,
888
+ description: 'One artifact in full, including its body.',
889
+ input: { artifact_id: z.string() },
890
+ handler: async ({ client }, args) => {
891
+ const { artifact_id } = args;
892
+ const artifact = await only(client.from('artifacts')
893
+ .select('id, task_id, type, format, title, content, storage_path, revision, created_at')
894
+ .eq('id', artifact_id), 'read', `artifact ${artifact_id}`);
895
+ return [
896
+ `${artifact.title ?? '(untitled)'}`,
897
+ `id ${artifact.id}`,
898
+ `work item ${artifact.task_id}`,
899
+ `type ${artifact.type} (${artifact.format})`,
900
+ `revision ${artifact.revision}`,
901
+ '',
902
+ /* A stored file and an empty body are different facts and are said
903
+ differently. Returning '' for a PNG would read as an artifact with
904
+ nothing in it. */
905
+ artifact.content ?? `This artifact is a stored file, not text: ${artifact.storage_path}`,
906
+ ].join('\n');
907
+ },
908
+ },
909
+ {
910
+ name: 'list_cards',
911
+ /* ═══ LEVEL 1 ONLY, AND THIS OVERRIDES plan.md's TABLE. ═══ The plan grants
912
+ it to all three levels; ux.md scopes level 2 to one user ask and gives
913
+ only the coordinator the view across everything ("only level 1 sees
914
+ everything"). The spec is binding over the plan, and the ruling is Lane's.
915
+
916
+ It also matters in practice: a level 2 agent that can read every other
917
+ card is a level 2 agent that can decide its own work is less important
918
+ than something on another card, which is exactly the coordination
919
+ judgement level 1 exists to hold. */
920
+ levels: [1],
921
+ description: 'Every card on this panel, newest first, with what each is doing. This is the view across all '
922
+ + 'the work, which only the coordinator has.',
923
+ input: {},
924
+ handler: async ({ client }) => {
925
+ const cards = await rows(client.from('panel3_cards').select('id, title, state, created_at')
926
+ .is('archived_at', null).order('created_at', { ascending: false }), 'read', 'the cards');
927
+ return listed(cards.map((c) => line(c.id, c.state.padEnd(10), c.title)), 'There are no cards.');
928
+ },
929
+ },
930
+ {
931
+ name: 'list_card_agents',
932
+ /* ═══ LEVEL 1 ONLY, FOR THE SAME REASON list_cards IS. ═══ ux.md gives the
933
+ view across the work to the coordinator, and an agent that could see the
934
+ whole tree on its own card is one deciding its own priority against work
935
+ it was never sent to look at.
936
+
937
+ ═══ AND IT ANSWERS FOR A CARD THIS RUN CANNOT DISPATCH FROM. ═══
938
+ `list_child_runs` answers for the asking run's OWN children, and a fresh
939
+ coordinator taking a message mid-work has none: the agents already
940
+ running are children of the run that took the earlier turn, not of this
941
+ one. What is shared between them is the card, so this reads by card
942
+ rather than by parent — which is what lets a status question be answered
943
+ without waking anything that is still working. */
944
+ levels: [1],
945
+ description: 'Everything running or already run on this card, whoever started it, with what each one is '
946
+ + 'doing and how long it has been going. Read this to answer what is happening without starting '
947
+ + 'anything new and without disturbing whatever is already under way.',
948
+ input: {},
949
+ handler: async ({ client, cardId }) => {
950
+ const agents = await rows(client.from('panel3_runs')
951
+ // `resumed_at` is read because "how long it has been going" is about
952
+ // the attempt that is running now. See `elapsed`.
953
+ .select('id, level, parent_run_id, state, activity, started_at, resumed_at, ended_at')
954
+ .eq('card_id', cardId).order('started_at'), 'read', 'the agents on this card');
955
+ return listed(agents.map((a) => {
956
+ const live = a.state === 'running' && !a.ended_at;
957
+ return line(a.id, `L${a.level}`, a.parent_run_id ? `parent ${a.parent_run_id}` : 'no parent, dispatched by the daemon', live ? 'still running' : a.state, a.activity ?? null, live
958
+ ? `going ${elapsed(a.started_at, null, a.resumed_at)}`
959
+ : `went ${elapsed(a.started_at, a.ended_at, a.resumed_at)}`);
960
+ }), 'Nothing has run on this card.');
961
+ },
962
+ },
963
+ // ── Read my own ──────────────────────────────────────────────────────────
964
+ {
965
+ name: 'get_my_brief_and_report',
966
+ levels: ALL,
967
+ description: 'What you were sent to do, and what you have already written down about doing it. Both are also '
968
+ + 'in your prompt; this is here for when you need to re-read them mid-run.',
969
+ input: {},
970
+ handler: async ({ client, runId }) => {
971
+ const run = await only(client.from('panel3_runs').select('level, brief, report, activity').eq('id', runId), 'read', 'your own run');
972
+ return [
973
+ `You are a level ${run.level} agent.`,
974
+ '',
975
+ 'BRIEF, written when you were dispatched and never changed',
976
+ run.brief,
977
+ '',
978
+ 'REPORT, what you last wrote down',
979
+ run.report ?? 'You have not written a report yet.',
980
+ '',
981
+ `ACTIVITY ${run.activity ?? 'nothing reported'}`,
982
+ ].join('\n');
983
+ },
984
+ },
985
+ // ── Read my children ─────────────────────────────────────────────────────
986
+ //
987
+ // ABSENT AT LEVEL 3, because level 3 has no children: `dispatch` stops at
988
+ // level 2, so a level 3 agent asking what its children reported is asking
989
+ // about something that cannot exist.
990
+ {
991
+ name: 'list_child_runs',
992
+ levels: [1, 2],
993
+ description: 'The agents you dispatched, with what each is doing. READ THIS RATHER THAN REMEMBERING: what you '
994
+ + 'already dispatched is a fact in the record, so a respawn of you can see it too and will not '
995
+ + 'dispatch the same work twice.',
996
+ input: {},
997
+ handler: async ({ client, runId }) => {
998
+ const children = await rows(client.from('panel3_runs').select('id, level, state, activity, started_at, ended_at')
999
+ .eq('parent_run_id', runId).order('started_at'), 'read', 'the runs you dispatched');
1000
+ return listed(children.map((c) => line(c.id, `L${c.level}`, c.state === 'running' && !c.ended_at ? 'still running' : c.state, c.activity ?? null)), 'You have dispatched nothing.');
1001
+ },
1002
+ },
1003
+ {
1004
+ name: 'get_child_report',
1005
+ levels: [1, 2],
1006
+ description: 'What one agent you dispatched has written down about its work.',
1007
+ input: { run_id: z.string() },
1008
+ handler: async ({ client, runId }, args) => {
1009
+ const { run_id } = args;
1010
+ /* THE PARENT LINK IS THE FILTER, not a check after the read. ux.md grants
1011
+ an agent its OWN children's reports, and a run id is guessable enough
1012
+ that reading first and refusing second would be a lookup an agent could
1013
+ use to find out that some other run exists. */
1014
+ const child = await only(client.from('panel3_runs').select('id, level, state, report')
1015
+ .eq('id', run_id).eq('parent_run_id', runId), 'read', `run ${run_id} as one of yours`);
1016
+ return [
1017
+ `Run ${child.id}, level ${child.level}, ${child.state}.`,
1018
+ '',
1019
+ child.report ?? 'It has written no report.',
1020
+ ].join('\n');
1021
+ },
1022
+ },
1023
+ // ── Write structure ──────────────────────────────────────────────────────
1024
+ //
1025
+ // ═══ LEVEL 1's ALONE, AND THAT IS ux.md's SECOND RULE. ═══ "The board changes
1026
+ // because the coordinator decided it should, never because a worker created
1027
+ // something mid-refactor." A level 2 or 3 agent reports a finding; turning a
1028
+ // finding into structure is coordination.
1029
+ //
1030
+ // ═══ IT IS CREATING STRUCTURE THAT IS LEVEL 1's, NOT EVERY WRITE. ═══ ux.md's
1031
+ // "Create structure" row is `create_epic`, `create_sprint`, `create_task` and
1032
+ // the placement tools. Editing an item as the work moves, and producing an
1033
+ // object, are two other rows of that same table and they are NOT level 1's:
1034
+ // see the next section, which is where they now live.
1035
+ {
1036
+ name: 'create_epic',
1037
+ levels: [1],
1038
+ description: 'Create an epic in a project. An epic groups work items that belong to one body of work.',
1039
+ input: { project_id: z.string(), name: z.string().min(1) },
1040
+ handler: async (caller, args) => {
1041
+ const { project_id, name } = args;
1042
+ const epic = await only(caller.client.from('epics').insert({ project_id, name }).select('id, name'), 'create', `an epic called ${name}`);
1043
+ await receipt(caller, 'epic', epic.id, epic.name);
1044
+ return `Created epic ${epic.name}, id ${epic.id}.`;
1045
+ },
1046
+ },
1047
+ {
1048
+ name: 'create_sprint',
1049
+ levels: [1],
1050
+ description: 'Create a sprint in a project. Both dates are required and the start must not be after the end.',
1051
+ input: {
1052
+ project_id: z.string(),
1053
+ name: z.string().min(1),
1054
+ start_date: z.string().describe('YYYY-MM-DD'),
1055
+ end_date: z.string().describe('YYYY-MM-DD'),
1056
+ },
1057
+ handler: async (caller, args) => {
1058
+ const { project_id, name, start_date, end_date } = args;
1059
+ const sprint = await only(caller.client.from('sprints').insert({ project_id, name, start_date, end_date }).select('id, name'), 'create', `a sprint called ${name}`);
1060
+ await receipt(caller, 'sprint', sprint.id, sprint.name);
1061
+ return `Created sprint ${sprint.name}, id ${sprint.id}.`;
1062
+ },
1063
+ },
1064
+ {
1065
+ name: 'create_work_item',
1066
+ levels: [1],
1067
+ description: 'Create a work item on the board. Put what is to be done in the description: a name alone leaves '
1068
+ + 'whoever picks it up guessing.',
1069
+ input: {
1070
+ project_id: z.string(),
1071
+ name: z.string().min(1),
1072
+ description: z.string().optional(),
1073
+ epic_id: z.string().optional(),
1074
+ sprint_id: z.string().optional(),
1075
+ due_date: z.string().optional().describe('YYYY-MM-DD'),
1076
+ },
1077
+ handler: async (caller, args) => {
1078
+ const a = args;
1079
+ const item = await only(caller.client.from('tasks').insert({
1080
+ project_id: a.project_id,
1081
+ name: a.name,
1082
+ description: a.description ?? '',
1083
+ /* THE SIGNED-IN USER OWNS IT. `owner_id` is `not null` and has no
1084
+ default, and the schema's own guard refuses an owner who is not a
1085
+ member of the project's organization, so there is nothing to
1086
+ invent here: the person whose account this panel is running as is
1087
+ the owner. */
1088
+ owner_id: caller.userId,
1089
+ epic_id: a.epic_id ?? null,
1090
+ sprint_id: a.sprint_id ?? null,
1091
+ due_date: a.due_date ?? null,
1092
+ }).select('id, name'), 'create', `a work item called ${a.name}`);
1093
+ await receipt(caller, 'work_item', item.id, item.name);
1094
+ return `Created work item ${item.name}, id ${item.id}.`;
1095
+ },
1096
+ },
1097
+ // ── Do the work, and produce objects ─────────────────────────────────────
1098
+ //
1099
+ // ═══ EVERY LEVEL, AND THAT IS ux.md's FOURTH RULE. ═══ "Producing an object
1100
+ // is everyone's. A receipt, an artifact, a screenshot, a mock reaching the
1101
+ // user is not a conversation. The invariant is one conversational addressee,
1102
+ // not one source of output."
1103
+ //
1104
+ // ═══ AND IT IS LOAD-BEARING RATHER THAN TIDY. ═══ ux.md: "when an agent
1105
+ // learns something durable, it writes it down as an object... an artifact.
1106
+ // This is the existing rule that output goes into objects rather than into a
1107
+ // conversation, doing real work: it is what makes stage four's worker able to
1108
+ // know what stage two discovered, without anybody holding a transcript." A
1109
+ // level 2 or 3 agent that cannot create one has no way to leave a finding
1110
+ // where a later stage can read it, and the whole no-side-channel design rests
1111
+ // on it being able to.
1112
+ //
1113
+ // plan.md's Task 5 table grants all three of these to level 1 alone. The spec
1114
+ // is binding over the plan, which is the ruling finding F4 already made in the
1115
+ // other direction; F9 records that it was not applied here when it should have
1116
+ // been.
1117
+ {
1118
+ name: 'update_work_item',
1119
+ /* ═══ EVERY LEVEL, AND THIS OVERRIDES plan.md's TABLE. ═══ ux.md puts
1120
+ `update_task` under "Do the work", which is levels 2 and 3, and NOT under
1121
+ "Create structure", which is level 1's alone. The build had it exactly
1122
+ inverted. Rule 2 is about CREATING structure — "the board changes because
1123
+ the coordinator decided it should" — and moving an item a worker is
1124
+ already on through its own statuses is the work, not the board changing.
1125
+ LEVEL 1 KEEPS IT rather than being narrowed to ux.md's letter: it creates
1126
+ work items, and an agent that cannot correct the item it just made in the
1127
+ same turn would have to make a second one beside it. */
1128
+ levels: ALL,
1129
+ description: 'Change a work item. Pass only what changes; anything you leave out stays as it is.',
1130
+ input: {
1131
+ work_item_id: z.string(),
1132
+ name: z.string().min(1).optional(),
1133
+ description: z.string().optional(),
1134
+ status: z.enum(['backlog', 'in_progress', 'done']).optional(),
1135
+ epic_id: z.string().optional(),
1136
+ sprint_id: z.string().optional(),
1137
+ due_date: z.string().optional().describe('YYYY-MM-DD'),
1138
+ },
1139
+ handler: async ({ client }, args) => {
1140
+ const { work_item_id, ...rest } = args;
1141
+ const changes = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
1142
+ /* AN UPDATE WITH NOTHING IN IT IS REFUSED, not quietly treated as a
1143
+ success. Supabase would happily run it and return the row, and the
1144
+ agent would read "updated" having changed nothing at all. */
1145
+ if (Object.keys(changes).length === 0) {
1146
+ throw new Error('nothing to change: pass at least one field besides work_item_id');
1147
+ }
1148
+ const item = await only(client.from('tasks').update(changes).eq('id', work_item_id).select('id, name'), 'update', `work item ${work_item_id}`);
1149
+ return `Updated ${item.name}: ${Object.keys(changes).join(', ')}.`;
1150
+ },
1151
+ },
1152
+ {
1153
+ name: 'create_artifact',
1154
+ // Everyone's. See the section header: this is how a level 2 or 3 agent
1155
+ // writes down what it learned so a later stage can read it.
1156
+ levels: ALL,
1157
+ description: 'Attach a document to a work item: a plan, a spec, an analysis. Artifacts are always on a work '
1158
+ + 'item, so create the work item first if there is not one yet.',
1159
+ input: {
1160
+ work_item_id: z.string(),
1161
+ title: z.string().min(1),
1162
+ content: z.string().min(1),
1163
+ type: z.enum(['plan', 'spec', 'analysis', 'diagram', 'mock', 'wireframe', 'user_story']).optional(),
1164
+ },
1165
+ handler: async (caller, args) => {
1166
+ const a = args;
1167
+ const artifact = await only(caller.client.from('artifacts').insert({
1168
+ task_id: a.work_item_id,
1169
+ title: a.title,
1170
+ content: a.content,
1171
+ type: a.type ?? 'plan',
1172
+ format: 'md',
1173
+ // `not null`, no default, and the table's own policy requires it to be
1174
+ // the caller. Stated rather than left to be defaulted somewhere else.
1175
+ created_by: caller.userId,
1176
+ }).select('id, title'), 'create', `an artifact called ${a.title}`);
1177
+ await receipt(caller, 'artifact', artifact.id, artifact.title);
1178
+ return `Created artifact ${a.title}, id ${artifact.id}.`;
1179
+ },
1180
+ },
1181
+ {
1182
+ name: 'update_artifact',
1183
+ // Everyone's, for the same reason `create_artifact` is: an object that can
1184
+ // be made and never corrected is an object that goes stale in the record.
1185
+ levels: ALL,
1186
+ description: 'Replace an artifact\'s body. The whole body is replaced, so send it complete.',
1187
+ input: { artifact_id: z.string(), content: z.string().min(1), title: z.string().min(1).optional() },
1188
+ handler: async ({ client, userId }, args) => {
1189
+ const a = args;
1190
+ const artifact = await only(client.from('artifacts')
1191
+ .update({
1192
+ content: a.content,
1193
+ ...(a.title ? { title: a.title } : {}),
1194
+ updated_at: new Date().toISOString(),
1195
+ updated_by: userId,
1196
+ })
1197
+ .eq('id', a.artifact_id).select('id, title'), 'update', `artifact ${a.artifact_id}`);
1198
+ return `Updated artifact ${artifact.title ?? artifact.id}.`;
1199
+ },
1200
+ },
1201
+ // ── Say something now ────────────────────────────────────────────────────
1202
+ //
1203
+ // ═══ LEVEL 2 ONLY, AND THE DATABASE IS WHAT ENFORCES IT. ═══ `panel3_say`
1204
+ // refuses any run that is not the one `panel3_cards.conversation_run_id`
1205
+ // names, and only a level 2 run is ever appointed, so a worker cannot reach
1206
+ // the person through this however the list below is edited. The tool is absent
1207
+ // at 1 and 3 as well, so an agent that has no business calling it is not
1208
+ // offered it and then refused.
1209
+ //
1210
+ // ═══ WHY IT EXISTS AT ALL, GIVEN `panel3_answer`. ═══ Nothing reached the
1211
+ // person until the owner's process exited, and a turn that ends by asking
1212
+ // writes no message at all. Measured 2026-08-24: "Write a plan for this item"
1213
+ // was silent for two and a half minutes and then said a question. An
1214
+ // acknowledgement cannot be an exit, because an exit is the end of the turn.
1215
+ {
1216
+ name: 'say',
1217
+ levels: [2],
1218
+ description: 'Say something to the person NOW, without stopping. Use it the moment you know what you are '
1219
+ + 'going to do, before you start doing it: they have seen only their own message, and until '
1220
+ + 'you say something the card shows them a pulse. One or two sentences in their words, saying '
1221
+ + 'what you are about to do and what they will get. Use it again if what you are doing changes '
1222
+ + 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
1223
+ + 'and does not replace the reply or the question you finish with: keep working after it. Do '
1224
+ + 'not use it for a running commentary, and do not use it to ask anything.',
1225
+ input: {
1226
+ message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
1227
+ + 'the plan and bring it to you."'),
1228
+ },
1229
+ handler: async ({ client, runId, processToken }, args) => {
1230
+ const { message } = args;
1231
+ /* ═══ THE SAME BOUND AS A QUESTION, BECAUSE IT IS THE SAME CARD. ═══
1232
+ Issue 7. Without this the bound on `ask_question` is theatre: an owner
1233
+ refused on a question can put the same paragraph in front of the same
1234
+ person through this door, and it does not even end its turn to do it. */
1235
+ if (message.length > PERSON_READS_LIMIT) {
1236
+ throw new Error(`NOTHING WAS WRITTEN: that is ${message.length} characters and they read it on a card `
1237
+ + `three inches wide. The limit is ${PERSON_READS_LIMIT}. One or two sentences saying `
1238
+ + 'what you are about to do; what you found goes in the artifact or your report.');
1239
+ }
1240
+ const { data, error } = await client.rpc('panel3_say', {
1241
+ p_run_id: runId,
1242
+ p_body: message,
1243
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1244
+ });
1245
+ if (error)
1246
+ throw new Error(`could not say that on this card: ${error.message}`);
1247
+ /* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
1248
+ SPOKE. ═══ Three reasons collapse to one sentence because the caller
1249
+ cannot tell them apart and all three mean the same thing to it: this
1250
+ conversation is not yours to speak on any more. An owner that thinks it
1251
+ acknowledged and did not is the failure this tool exists to remove,
1252
+ arrived at from the other side. */
1253
+ if (data === null) {
1254
+ throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
1255
+ + 'or somebody else owns it now. Do not tell anybody you said anything.');
1256
+ }
1257
+ return 'Said. They can read it now. Carry on.';
1258
+ },
1259
+ },
1260
+ // ── Report ───────────────────────────────────────────────────────────────
1261
+ {
1262
+ name: 'report_activity',
1263
+ levels: ALL,
1264
+ description: 'Say what you are doing right now, in one short line, in the words a person watching would use. '
1265
+ + 'The line says what you are FINDING OUT or what you are CHANGING, not the steps you are '
1266
+ + 'running to do it: "Checking for clock use, CI config, and running the test suite once" tells '
1267
+ + 'a reader nothing, where "Working out whether the code can tell what happened this week" tells '
1268
+ + 'them what turns on it. You do not need to have seen anybody\'s message to write one. '
1269
+ + 'It replaces whatever you last said. Call it when you start something that will take a while, '
1270
+ + 'so a run that is working and a run that is wedged do not look the same.',
1271
+ input: {
1272
+ activity: z.string().min(1).describe('One line, present tense, naming what you are finding out or changing. "Working out whether '
1273
+ + 'the code can tell what happened this week".'),
1274
+ },
1275
+ handler: async ({ client, runId, processToken }, args) => {
1276
+ const { activity } = args;
1277
+ /* ═══ THROUGH AN RPC, SO THE LINE AND ITS TIME ARE ONE STATEMENT ON ONE
1278
+ CLOCK. ═══ This was a plain table update. The card is now one list in
1279
+ the order things happened, sorted against times the database wrote, and
1280
+ a stamp supplied from here would be this laptop's clock compared with
1281
+ Supabase's. `panel3_report_activity` carries the same liveness
1282
+ predicate the update did, in the same statement, so `whileRunning` is
1283
+ still what reads the refusal. */
1284
+ await whileRunning(client.rpc('panel3_report_activity', {
1285
+ p_run_id: runId,
1286
+ p_activity: activity,
1287
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1288
+ }), runId, 'record what you are doing on');
1289
+ return 'Noted.';
1290
+ },
1291
+ },
1292
+ {
1293
+ name: 'write_report',
1294
+ levels: ALL,
1295
+ description: 'Write down what is true NOW: what you have done, what you decided and why, and what is still '
1296
+ + 'open. It REPLACES your last report rather than adding to it, and it is what you are handed if '
1297
+ + 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
1298
+ + 'you go, not only at the end.',
1299
+ input: { report: z.string().min(1) },
1300
+ handler: async ({ client, runId, processToken }, args) => {
1301
+ const { report } = args;
1302
+ await whileRunning(processToken === undefined
1303
+ ? client.from('panel3_runs').update({ report }).eq('id', runId)
1304
+ .in('state', STILL_WRITING).is('ended_at', null).select('id')
1305
+ : client.rpc('panel3_write_report', {
1306
+ p_run_id: runId, p_report: report, p_process_token: processToken,
1307
+ }), runId, 'write the report on');
1308
+ return 'Report written.';
1309
+ },
1310
+ },
1311
+ {
1312
+ name: 'record_output',
1313
+ levels: ALL,
1314
+ description: 'Record that this card produced something the user should be able to open. Anything you create '
1315
+ + 'with create_epic, create_sprint, create_work_item or create_artifact is recorded for you; use '
1316
+ + 'this for anything else that should show on the card.',
1317
+ input: {
1318
+ kind: z.string().min(1).describe('What sort of thing it is: work_item, artifact, epic, sprint, commit.'),
1319
+ ref_id: z.string().describe('The id of the thing itself.'),
1320
+ label: z.string().optional().describe('What to call it on the card.'),
1321
+ },
1322
+ handler: async (caller, args) => {
1323
+ const a = args;
1324
+ await receipt(caller, a.kind, a.ref_id, a.label ?? null);
1325
+ return `Recorded ${a.kind} ${a.ref_id} on the card.`;
1326
+ },
1327
+ },
1328
+ // ── Questions ────────────────────────────────────────────────────────────
1329
+ {
1330
+ name: 'list_questions',
1331
+ /* ═══ EVERY LEVEL, BECAUSE THE QUESTION BELONGS TO THE CARD. ═══ ux.md: "the
1332
+ question and its answer are recorded against the card, so they are context
1333
+ for everything running on it rather than private to the run that asked. A
1334
+ sibling worker that would have asked the same thing reads it instead." */
1335
+ levels: ALL,
1336
+ description: 'Everything that has been asked on this piece of work and what it was answered with, whoever '
1337
+ + 'asked it. READ THIS BEFORE YOU ASK: somebody working beside you may have asked the same '
1338
+ + 'thing already, and asking again costs the person twice.',
1339
+ input: {},
1340
+ handler: async ({ client, cardId }) => {
1341
+ const asks = await withAskContent(client, await rows(client.from('panel3_asks')
1342
+ .select(`id, answered_at, pending_run_id, run_id, ${ASK_CONTENT_COLUMNS}`)
1343
+ .eq('card_id', cardId).order('created_at'), 'read', 'what has been asked here'));
1344
+ return listed(asks.map((a) => [
1345
+ `${a.id} ${a.answered_at ? 'answered' : a.pending_run_id ? 'waiting on the agent it went to' : 'waiting on the person'}`,
1346
+ /* THE WHOLE QUESTION, NOT ITS TITLE. A sibling reading only the
1347
+ question of a shortlisted one cannot tell whether its own is the
1348
+ same thing, which is the entire reason to read this before asking:
1349
+ the context says what was already worked out and the options say
1350
+ what was already ruled plausible. Null on questions asked before a
1351
+ question carried them, and absent rather than printed as empty. */
1352
+ ...(a.category ? [` ${a.category}`] : []),
1353
+ ...(a.context ? [` why ${a.context}`] : []),
1354
+ ...(a.related_artifact_id ? [
1355
+ ` artifact ${a.related_artifact_id} presented revision ${a.related_artifact_revision ?? 'not recorded'}`,
1356
+ ] : []),
1357
+ ` Q ${a.question ?? '(this question could not be read)'}`,
1358
+ ...(a.options ?? []).map((option) => ` - ${option}`),
1359
+ ` A ${a.answer ?? 'not answered yet'}`,
1360
+ ].join('\n')), 'Nothing has been asked here.');
1361
+ },
1362
+ },
1363
+ {
1364
+ name: 'ask_question',
1365
+ /* ═══ LEVELS 1 AND 2. ═══ The coordinator keeps it for a loose ask and the
1366
+ level 2 agent doing the work keeps it because it has the best view of the
1367
+ decision. Level 3 still escalates, and the tool is absent there rather
1368
+ than present and refusing. */
1369
+ levels: [1, 2],
1370
+ description: 'Put a question to the person, and stop. A Level 1 launcher uses this only for a destination '
1371
+ + 'or codebase it truly cannot choose; it launches the owner for every work or product '
1372
+ + 'decision. A Level 2 owner MUST use this tool when work cannot continue '
1373
+ + 'until the person answers, including when they must choose between options; an ordinary '
1374
+ + 'reply is not a question path and completing the card with a question is wrong. Ask only '
1375
+ + 'what you genuinely cannot settle from the '
1376
+ + 'record, by dispatching someone to find out, or by answering it yourself. Use the shortest '
1377
+ + 'question and context the person can answer safely. Ask ONE thing, in the words they would '
1378
+ + 'use, and say what you will do with each answer. If you are putting on a question that came '
1379
+ + 'up from work you sent out, name it in question_id and write it as they need to read it: they '
1380
+ + 'have not seen any of it. For an artifact approval, name its Work Item and live artifact, '
1381
+ + 'offer approve or request changes, and present one revision at a time. THE QUESTION LINE '
1382
+ + 'ITSELF SAYS WHAT THEY ARE APPROVING, in plain words, never further down in the context: '
1383
+ + 'approving a document that describes work is not the same as approving the work, and the '
1384
+ + 'person cannot tell those apart from the title of an artifact. Write it the way these are '
1385
+ + 'written: "Plan is written. Please read it and approve or ask for changes", "Ok to write a '
1386
+ + 'spec.md and attach it to this work item?", "The work item has a detailed plan. Ok to start '
1387
+ + 'building?". A question that names the artifact and leaves the reader to work out what '
1388
+ + 'approving starts is the one this rule exists to stop. '
1389
+ + 'After this call, stop immediately. '
1390
+ + 'Do not repeat the question in '
1391
+ + 'an ordinary reply or add a message saying that you asked it.',
1392
+ input: {
1393
+ question: z.string().min(1),
1394
+ ...DECIDING,
1395
+ question_id: PASSING_ON,
1396
+ work_item_id: z.string().optional().describe('The work item this question is a decision ABOUT, if it is about one. The answer is kept on '
1397
+ + 'that item, where anybody reading it later will find it, so name the item the decision '
1398
+ + 'belongs to even if it is not the one attached to this conversation. Leave it out when the '
1399
+ + 'question is not about a work item — whether a second item should exist, which item a '
1400
+ + 'request means, or anything about the conversation itself — rather than picking the '
1401
+ + 'nearest one.'),
1402
+ related_artifact_id: z.string().optional().describe('The live Analysis or Plan artifact on work_item_id that this decision presents for approval '
1403
+ + 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
1404
+ + 'its current process token is required. Leave it out for every ordinary question.'),
1405
+ },
1406
+ handler: async (caller, args) => asked(caller, args, true),
1407
+ },
1408
+ {
1409
+ name: 'escalate',
1410
+ /* n/a at level 1, which escalates to the USER through `ask_question`
1411
+ instead — there is nothing above it to ask. */
1412
+ levels: [2, 3],
1413
+ description: 'Ask whoever sent you something you cannot decide, and stop. Use it when you genuinely cannot '
1414
+ + 'go on: what you have done stays where it is, and you are started again with the answer when '
1415
+ + 'there is one. If a question came to you from somebody you sent and you cannot settle it '
1416
+ + 'either, this is how it goes further up: name it in question_id and write it in your own '
1417
+ + 'words, with what you already know added.',
1418
+ input: { question: z.string().min(1), ...DECIDING, question_id: PASSING_ON },
1419
+ handler: async (caller, args) => asked(caller, args, false),
1420
+ },
1421
+ {
1422
+ name: 'answer_escalation',
1423
+ /* ═══ ABSENT AT LEVEL 3, WHICH HAS NO CHILDREN TO BE ASKED BY. ═══ `dispatch`
1424
+ stops at level 2, so a level 3 agent answering one of its own children is
1425
+ answering something that cannot exist. */
1426
+ levels: [1, 2],
1427
+ description: 'Answer a question that came up from work you sent out, so whoever asked it can carry on. '
1428
+ + 'Answer it yourself wherever you can: every question you pass further up is one the person '
1429
+ + 'has to stop and deal with. The one who asked is started again with your answer, so write it '
1430
+ + 'to them, plainly, and say what to do rather than what you would have done.',
1431
+ input: { question_id: z.string(), answer: z.string().min(1) },
1432
+ handler: async ({ client, runId, processToken }, args) => {
1433
+ const { question_id, answer } = args;
1434
+ /* ═══ AN AGENT ANSWERS IN ITS OWN WORDS, WHATEVER SHAPE THE QUESTION WAS
1435
+ ASKED IN. ═══ No selection and the answer as the note, which is the
1436
+ product's "own words" answer: `app.canonical_decision_answer` returns
1437
+ the note verbatim for it, so this needs no case for the mode and cannot
1438
+ drift from what the person's answer composes to. */
1439
+ const { data, error } = await client
1440
+ .rpc('panel3_answer_ask', {
1441
+ p_ask_id: question_id,
1442
+ p_selected_options: [],
1443
+ p_answer_note: answer,
1444
+ p_by_run_id: runId,
1445
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1446
+ });
1447
+ if (error)
1448
+ throw new Error(`could not answer question ${question_id}: ${error.message}`);
1449
+ if (data === null) {
1450
+ /* REFUSED, AND THE THREE REASONS ARE ONE SENTENCE because the caller
1451
+ cannot tell them apart and all three mean the same thing to it: this
1452
+ is not yours to settle, and nothing was written. */
1453
+ throw new Error(`NOTHING WAS WRITTEN: question ${question_id} is not waiting on you. It has been answered `
1454
+ + 'already, or it went somewhere else, or there is no such question. Do not tell anybody it '
1455
+ + 'is settled.');
1456
+ }
1457
+ return (`Answered. Whoever asked is started again with it, so there is nothing to pass on and nobody `
1458
+ + 'to tell.');
1459
+ },
1460
+ },
1461
+ // ── Dispatch and stop ────────────────────────────────────────────────────
1462
+ {
1463
+ name: 'dispatch',
1464
+ /* ═══ ABSENT AT LEVEL 3, WHICH IS HOW "DEPTH STOPS AT THREE" IS ENFORCED.
1465
+ ═══ ux.md puts it in the mechanical column, not the judgement one, so it
1466
+ cannot be a sentence in a prompt asking a worker not to spawn. A level 3
1467
+ agent has no tool to spawn with.
1468
+
1469
+ AND THERE IS NO `level` ARGUMENT, deliberately. A child is one level below
1470
+ its parent, and `panel3_dispatch` reads the parent's own row to work that
1471
+ out, so the depth of a dispatch is arithmetic the DATABASE does rather
1472
+ than a number anything above it chooses. The same statement refuses a
1473
+ parent at level 3, which is this rule enforced a second time in the one
1474
+ place a bug in the served list above could not reach. */
1475
+ levels: [1, 2],
1476
+ /* ═══ AND WHAT IT SAYS IS TRUE AT BOTH LEVELS THAT HOLD IT. ═══ Where the
1477
+ agent's words end up differs — level 2's go to the person, level 3's go to
1478
+ whoever sent it — so that fact belongs in each level's own brief, and this
1479
+ says only what is true of a dispatch wherever it is made. A description
1480
+ that described one level's outcome would be wrong at the other exactly
1481
+ half the time, and read as authoritative both times. */
1482
+ description: 'Send an agent to read or change one named codebase, with a responsibility and a boundary. It gets a working '
1483
+ + 'copy and the two things you write here AND NOTHING ELSE — it never sees this conversation — '
1484
+ + 'so put everything it needs in the responsibility rather than making it search. People sent to '
1485
+ + 'the same codebase work in the SAME working copy at the same time, so give them pieces that do not touch '
1486
+ + 'the same files, or send them one at a time. Do not wait for it: what it writes goes on as it '
1487
+ + 'wrote it, and nobody edits it on the way.',
1488
+ input: {
1489
+ codebase_id: z.string().uuid().optional().describe('The id of the registered project codebase this work belongs to. A launcher may omit this '
1490
+ + 'for record-only work; an owner dispatching a worker must provide it.'),
1491
+ responsibility: z.string().min(1).describe('What this agent owns, in one or two sentences, complete enough to act on with no other '
1492
+ + 'context: what to find out or change, and in which part of the codebase.'),
1493
+ boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
1494
+ work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
1495
+ },
1496
+ handler: async (caller, args) => {
1497
+ const { codebase_id, responsibility, boundary, work_item_id } = args;
1498
+ if (caller.level === 2 && !codebase_id) {
1499
+ throw new Error('A worker must be attached to a registered project codebase.');
1500
+ }
1501
+ let codebase = null;
1502
+ if (codebase_id) {
1503
+ const cards = await rows(caller.client.from('panel3_cards').select('project_id').eq('id', caller.cardId), 'read', `the project for card ${caller.cardId}`);
1504
+ const projectId = cards[0]?.project_id;
1505
+ if (!projectId) {
1506
+ throw new Error('This conversation is not filed under a project, so it has no codebase to use.');
1507
+ }
1508
+ codebase = (await listCodebases(caller.client, projectId))
1509
+ .find((candidate) => candidate.id === codebase_id) ?? null;
1510
+ if (!codebase) {
1511
+ throw new Error('That codebase is not registered on this project. Read the current project codebases and choose one of them.');
1512
+ }
1513
+ }
1514
+ /* ONE LEVEL DOWN, AND THE SAME ARITHMETIC THE DATABASE DOES. This decides
1515
+ the words in the brief; `panel3_dispatch` decides the level on the row,
1516
+ from the parent it reads, and that row is what the tools server serves
1517
+ the child from. The record is authoritative and no agent is asked. */
1518
+ const childLevel = (caller.level + 1);
1519
+ /* ═══ WHAT WAS ATTACHED TO THE CARD, NOT WHAT THIS AGENT CHOSE. ═══
1520
+ `work_item_id` above is one item THIS dispatcher named for this one
1521
+ piece of work; the card's own attachments are what the PERSON pointed
1522
+ the whole conversation at, and they travel to every dispatch on it:
1523
+ level 2's workers and their own workers alike, so a level 3 agent
1524
+ three hops from level 1 still knows which item it is working, without
1525
+ asking. See `workBrief`'s own doc for why the two are different things
1526
+ carried the same way. */
1527
+ const attachments = (await loadAttachments(caller.client, caller.cardId)).map(attachmentLine);
1528
+ const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
1529
+ id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
1530
+ }), codebase, caller.processToken);
1531
+ /* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
1532
+ KNOWN HERE RATHER THAN GUESSED. ═══ The description above cannot say it,
1533
+ because it is registered once for both levels that hold the tool; this
1534
+ sentence is written for one caller whose level came off its own run row.
1535
+ Getting it wrong either way is expensive and invisible: a level 1 agent
1536
+ told the answer comes back to it waits for something that never will,
1537
+ and a level 2 agent told the worker writes to the person leaves three
1538
+ findings in the record with nobody to put them together. */
1539
+ return (`Started, and it is working now. Its id is ${runId}, and list_child_runs will say how it is `
1540
+ + 'getting on. '
1541
+ + (childLevel === 2
1542
+ ? 'That owner now has the conversation. Your launcher work is finished: write no reply, '
1543
+ + 'ask nothing else, and exit immediately.'
1544
+ : 'What it writes comes back to you and to nobody else. When everybody you have sent has '
1545
+ + 'finished you are started again with what each of them wrote, and the one answer that '
1546
+ + 'covers them is yours to write, so do not wait here for it.'));
1547
+ },
1548
+ },
1549
+ {
1550
+ name: 'stop_run',
1551
+ /* Level 1 may stop anything on its own card; level 2 only its own subtree;
1552
+ level 3 nothing, so the tool is absent there. `panel3_stop_run` enforces
1553
+ all three from the two run rows, which is why this list is the only thing
1554
+ said about scope here: a second copy of the rule in a handler is one that
1555
+ can disagree with the one that actually decides. */
1556
+ levels: [1, 2],
1557
+ description: 'Stop an agent that is running, and everything it dispatched. You may stop work you are '
1558
+ + 'responsible for: the coordinator may stop anything on its card, and an agent may stop what '
1559
+ + 'it sent out. Use it when the person says to stop, or when work you started is no longer '
1560
+ + 'worth finishing. It says how many agents it stopped, and it stops nothing rather than '
1561
+ + 'reaching outside what is yours. To stop your own work, just finish.',
1562
+ input: { run_id: z.string() },
1563
+ handler: async ({ client, runId, processToken }, args) => {
1564
+ const { run_id } = args;
1565
+ const { data, error } = await client
1566
+ .rpc('panel3_stop_run', {
1567
+ p_by_run_id: runId,
1568
+ p_run_id: run_id,
1569
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1570
+ });
1571
+ if (error)
1572
+ throw new Error(`could not stop run ${run_id}: ${error.message}`);
1573
+ const stopped = data;
1574
+ if (stopped === 0) {
1575
+ /* NOTHING WAS STOPPED, and the reasons are one sentence because the
1576
+ agent cannot tell them apart from where it sits and every one of them
1577
+ means the same thing to it: that run is still going and you did not
1578
+ stop it. Thrown rather than returned, exactly as the other refusals in
1579
+ this file are, so there is no shape in which this reads as success. */
1580
+ throw new Error(`NOTHING WAS STOPPED and run ${run_id} is untouched: it is not on your card, it is not `
1581
+ + 'yours to stop, it had already ended, it is you, or there is no such run. Say that it '
1582
+ + 'is still going rather than reporting it stopped.');
1583
+ }
1584
+ /* WHAT WAS WRITTEN, AND WHAT HAS NOT HAPPENED YET, both said. The row is
1585
+ ended the instant this returns; the process is killed by the daemon on
1586
+ its next poll, and an agent told otherwise would report the work gone
1587
+ while its output was still arriving. */
1588
+ return (`Stopped ${stopped} agent${stopped === 1 ? '' : 's'}: the one you named and everything it `
1589
+ + 'had dispatched. They are ended on the record now, and their processes are killed within '
1590
+ + 'a poll, so nothing more of theirs is coming. Nothing is waiting for you to do anything '
1591
+ + 'about that.');
1592
+ },
1593
+ },
1594
+ ];
1595
+ /**
1596
+ * ═══ THE RECEIPT IS WRITTEN BY THE TOOL THAT MADE THE THING. ═══
1597
+ *
1598
+ * ux.md puts attribution in the mechanical column — "attribution is never agent
1599
+ * judgement" — and `record_output` alone would put it squarely in the other one:
1600
+ * an agent that created an epic and then forgot the second call leaves a card
1601
+ * that produced nothing visible. So every `create_*` above writes its own
1602
+ * receipt, and `record_output` stays for what these tools did not create.
1603
+ *
1604
+ * UPDATES DELIBERATELY WRITE NOTHING. A receipt answers "what did this card
1605
+ * produce", and editing something twice did not produce it twice.
1606
+ *
1607
+ * ═══ AND AN ENDED RUN DOES NOT LEAVE RECEIPTS, WHICH IS THE ONE GUARD HERE
1608
+ * THAT IS NOT ATOMIC. ═══
1609
+ *
1610
+ * `report_activity` and `write_report` carry the liveness predicate in the same
1611
+ * statement as their write. An INSERT into `panel3_outputs` cannot: the predicate
1612
+ * is on a different table, and PostgREST has nowhere to put it. So this reads
1613
+ * first and inserts second, and the window between them is stated rather than
1614
+ * hidden.
1615
+ *
1616
+ * WHAT THE WINDOW COSTS IS SMALL AND IS NOT A FALSE CLAIM. A receipt written in
1617
+ * it points at an object that genuinely exists and was genuinely made by that
1618
+ * run; what is wrong is only that the run had just been given up on. The receipt
1619
+ * is a duplicate of work another agent may redo, not an attribution to something
1620
+ * that never happened, which is why it does not earn an RPC of its own today.
1621
+ */
1622
+ async function receipt({ client, runId, cardId }, kind, refId, label) {
1623
+ const live = await rows(client.from('panel3_runs').select('id').eq('id', runId)
1624
+ .in('state', STILL_WRITING).is('ended_at', null), 'check', `whether run ${runId} is still running`);
1625
+ if (live.length === 0) {
1626
+ /* IT NAMES WHAT WAS MADE. The product row exists — it was created before
1627
+ this was reached — so an error that only said "refused" would leave the
1628
+ agent unable to say whether the thing is there. */
1629
+ throw new Error(`the ${kind} ${refId} was created, but THIS RUN HAS ENDED so no receipt was written for it and `
1630
+ + 'its card has been handed to another agent. Say what you made and stop.');
1631
+ }
1632
+ await only(client.from('panel3_outputs').insert({ card_id: cardId, run_id: runId, kind, ref_id: refId, label }).select('id'), 'record', `the ${kind} on the card`);
1633
+ }
1634
+ /** The names one level is served, in the order they are registered. Exported for
1635
+ * the same reason `cs3 show` exists: a rule nobody can print is a rule nobody
1636
+ * can check. */
1637
+ export function toolNamesForLevel(level, isOwner = false) {
1638
+ return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
1639
+ .map((t) => t.name);
1640
+ }
1641
+ /**
1642
+ * One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
1643
+ * is: a test that only calls `workBrief` with a hand-built array proves the
1644
+ * formatter, not the wiring inside `dispatch` that reads a card's own
1645
+ * attachments and threads them in. This is the seam that lets a test call the
1646
+ * real handler with a fake `Caller` and see what it actually queried, without
1647
+ * standing up the MCP transport `buildServer` wraps it in.
1648
+ */
1649
+ export function toolHandler(name) {
1650
+ return toolNamed(name).handler;
1651
+ }
1652
+ /** One tool's registered input schema and description, by name. Exported for
1653
+ * what a handler test cannot see: whether a bound lives in Zod, where it would
1654
+ * reject before `buildServer`'s try/catch and reach every level sharing the
1655
+ * schema, or in the handler, where it comes back as a correctable failure. */
1656
+ export function toolShape(name) {
1657
+ const { description, input } = toolNamed(name);
1658
+ return { description, input };
1659
+ }
1660
+ function toolNamed(name) {
1661
+ const tool = TOOLS.find((t) => t.name === name);
1662
+ if (!tool)
1663
+ throw new Error(`no tool named ${name}`);
1664
+ return tool;
1665
+ }
1666
+ // ---------------------------------------------------------------------------
1667
+ /**
1668
+ * The server for ONE connection, carrying only that run's level's tools.
1669
+ *
1670
+ * Built per connection rather than once, which is what makes rule B structural:
1671
+ * the tool list is assembled from the level, so there is no shared server whose
1672
+ * list would have to be filtered on the way out.
1673
+ */
1674
+ function buildServer(caller) {
1675
+ const server = new McpServer({ name: 'ctrl-spc', version: '3.0.0' }, {
1676
+ instructions: `You are a level ${caller.level} agent in CTRL+SPC. The tools you have are the tools your level `
1677
+ + 'has: there are no others to ask for, and a tool you cannot see is one your level does not '
1678
+ + 'hold rather than one that is missing.',
1679
+ });
1680
+ for (const tool of TOOLS) {
1681
+ if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
1682
+ continue;
1683
+ server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
1684
+ try {
1685
+ /* ═══ NOTHING THIS RUN READ GOES BACK OUT THROUGH A TOOL. ═══ Every
1686
+ argument of every tool, redacted before the handler sees it: see
1687
+ `redactArgs` for why the set is "all of them" rather than a list of
1688
+ the risky ones. This is one of the two chokepoints the rule rests
1689
+ on; `writeAnswer` in run.ts is the other. It is identity for the
1690
+ runs that read no credential, which is nearly all of them. */
1691
+ const safe = redactArgs(secretScope(caller), args);
1692
+ return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
1693
+ }
1694
+ catch (error) {
1695
+ /* ═══ A FAILURE REACHES THE AGENT AS A FAILURE. ═══ Constraint 7, at
1696
+ the one boundary where breaking it is invisible: an MCP tool that
1697
+ returned its error as ordinary content would have the agent read a
1698
+ refusal as a result, and report to the user that the work was done.
1699
+ `isError` is what makes the harness treat it as the failure it is. */
1700
+ return {
1701
+ isError: true,
1702
+ content: [{
1703
+ type: 'text',
1704
+ text: error instanceof Error ? error.message : String(error),
1705
+ }],
1706
+ };
1707
+ }
1708
+ }));
1709
+ }
1710
+ return server;
1711
+ }
1712
+ export const toolsUrl = (port, runId, processToken) => `http://127.0.0.1:${port}/mcp/${runId}${processToken ? `/${processToken}` : ''}`;
1713
+ /** The per-request fence used by already-open MCP sessions. */
1714
+ export async function processActivationIsCurrent(client, runId, processToken) {
1715
+ const current = await rows(client.from('panel3_runs')
1716
+ .select('process_token, card:panel3_cards!panel3_runs_card_id_fkey!inner(conversation_run_id)')
1717
+ .eq('id', runId)
1718
+ .eq('process_token', processToken)
1719
+ .in('state', ['running', 'asked'])
1720
+ .is('ended_at', null)
1721
+ .eq('card.conversation_run_id', runId), 'verify', `the current process activation for run ${runId}`);
1722
+ return current.length > 0;
1723
+ }
1724
+ /**
1725
+ * Start the v3 tools server on loopback, for the signed-in user this client
1726
+ * carries.
1727
+ *
1728
+ * ═══ AN EPHEMERAL PORT, NOT A FIXED ONE. ═══ The daemon is the only thing that
1729
+ * needs to know it, and it learns it here and puts it in each spawn's config, so
1730
+ * a fixed port would buy nothing and cost a collision with whatever else on this
1731
+ * machine is listening — including v1's and v2's own tools servers, and a second
1732
+ * v3 daemon, which is a configuration this build's lease is proved with.
1733
+ *
1734
+ * ═══ THE RUN ID IS THE CREDENTIAL. ═══ v2's server carries a per-install token
1735
+ * on the URL because it serves one long-lived connection surface that anything
1736
+ * on the machine could find. This one is different in kind: the URL names ONE
1737
+ * run, it is minted by the take, it is a v4 uuid, and it is handed to exactly
1738
+ * one child process. A second token beside it would be a second secret guarding
1739
+ * the same door. What it is NOT is a substitute for RLS: every read and write
1740
+ * below goes through the signed-in user's own token, so guessing a run id would
1741
+ * still only ever reach that user's own record.
1742
+ */
1743
+ export async function startToolsServer(client, dispatch) {
1744
+ const { data, error } = await client.auth.getUser();
1745
+ if (error)
1746
+ throw new Error(`could not start the tools server: ${error.message}`);
1747
+ if (!data.user)
1748
+ throw new Error('could not start the tools server: the client is not signed in');
1749
+ const userId = data.user.id;
1750
+ const transports = new Map();
1751
+ /** Which run each open session belongs to, so a connection cannot change run
1752
+ * partway through. Bound at `initialize`, cleared with the session. */
1753
+ const sessionRuns = new Map();
1754
+ const sessionTokens = new Map();
1755
+ /** Known once the socket is bound, which is before any request can arrive. */
1756
+ let port = 0;
1757
+ const fail = (res, status, why) => {
1758
+ res.writeHead(status, { 'Content-Type': 'text/plain' }).end(why);
1759
+ };
1760
+ async function handle(req, res) {
1761
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
1762
+ const parts = url.pathname.startsWith('/mcp/')
1763
+ ? url.pathname.slice('/mcp/'.length).split('/').filter(Boolean)
1764
+ : [];
1765
+ const runId = parts[0] ?? null;
1766
+ const processToken = parts[1];
1767
+ if (!runId) {
1768
+ fail(res, 404, 'Not found. The v3 tools server serves /mcp/<run-id> and nothing else.');
1769
+ return;
1770
+ }
1771
+ const sessionHeader = req.headers['mcp-session-id'];
1772
+ const sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined;
1773
+ const existing = sessionId ? transports.get(sessionId) : undefined;
1774
+ if (sessionId && existing) {
1775
+ /* ═══ THE RUN A SESSION BELONGS TO NEVER CHANGES. ═══ Rule A is only worth
1776
+ anything if it holds for every request, not just the first: a session
1777
+ that could arrive on a different run's path would be an agent choosing
1778
+ its own level after the fact. */
1779
+ if (sessionRuns.get(sessionId) !== runId) {
1780
+ fail(res, 400, 'Bad Request: this session belongs to a different run.');
1781
+ return;
1782
+ }
1783
+ const expected = sessionTokens.get(sessionId);
1784
+ if (expected !== processToken) {
1785
+ fail(res, 400, 'Bad Request: this session belongs to a different process activation.');
1786
+ return;
1787
+ }
1788
+ if (expected !== undefined) {
1789
+ if (!(await processActivationIsCurrent(client, runId, expected))) {
1790
+ fail(res, 403, `Run ${runId} belongs to a newer process activation.`);
1791
+ return;
1792
+ }
1793
+ }
1794
+ await existing.handleRequest(req, res);
1795
+ return;
1796
+ }
1797
+ if (req.method !== 'POST') {
1798
+ fail(res, 400, 'Bad Request: no valid session id.');
1799
+ return;
1800
+ }
1801
+ const body = await readJson(req);
1802
+ if (!isInitializeRequest(body)) {
1803
+ fail(res, 400, 'Bad Request: no valid session id.');
1804
+ return;
1805
+ }
1806
+ /* ═══ THE LEVEL, FROM THE RECORD, KEYED BY THE URL. ═══ Read through the
1807
+ signed-in user's RLS, so a run belonging to somebody else is not found
1808
+ rather than refused — which is the same answer, told without confirming
1809
+ the row exists. */
1810
+ const runs = await rows(client.from('panel3_runs')
1811
+ .select('id, card_id, level, state, ended_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
1812
+ .eq('id', runId), 'read', `run ${runId}`);
1813
+ const run = runs[0];
1814
+ if (!run) {
1815
+ fail(res, 404, `Not found: there is no run ${runId}.`);
1816
+ return;
1817
+ }
1818
+ if (!STILL_WRITING.includes(run.state) || run.ended_at) {
1819
+ /* A run that has ended has no tools. Serving it would let a process that
1820
+ outlived its own row keep writing to the record under a run the panel
1821
+ has already settled. `asked` is served for the reason `whileRunning`
1822
+ gives: it is a process winding up, and what it writes down last is the
1823
+ whole of what comes back with it. */
1824
+ fail(res, 403, `Run ${runId} is ${run.state} and is no longer served.`);
1825
+ return;
1826
+ }
1827
+ if (run.level !== 1 && run.level !== 2 && run.level !== 3) {
1828
+ throw new Error(`run ${runId} has level ${run.level}, which is not a level this product has`);
1829
+ }
1830
+ const isOwner = run.card?.conversation_run_id === run.id;
1831
+ if (isOwner && (!processToken || processToken !== run.process_token)) {
1832
+ fail(res, 403, `Run ${runId} belongs to a different process activation.`);
1833
+ return;
1834
+ }
1835
+ if (!isOwner && processToken !== undefined) {
1836
+ fail(res, 403, `Run ${runId} has no process activation token.`);
1837
+ return;
1838
+ }
1839
+ const transport = new StreamableHTTPServerTransport({
1840
+ sessionIdGenerator: () => crypto.randomUUID(),
1841
+ onsessioninitialized: (sid) => {
1842
+ transports.set(sid, transport);
1843
+ sessionRuns.set(sid, runId);
1844
+ sessionTokens.set(sid, processToken);
1845
+ },
1846
+ // The same DNS-rebinding guard v2's server carries: a page on a public
1847
+ // domain that re-resolves to 127.0.0.1 cannot drive this server from a
1848
+ // browser, because the Host header will not be one we bound.
1849
+ enableDnsRebindingProtection: true,
1850
+ allowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
1851
+ });
1852
+ transport.onclose = () => {
1853
+ const sid = transport.sessionId;
1854
+ if (sid) {
1855
+ transports.delete(sid);
1856
+ sessionRuns.delete(sid);
1857
+ sessionTokens.delete(sid);
1858
+ }
1859
+ };
1860
+ const server = buildServer({
1861
+ client, userId, runId, cardId: run.card_id, level: run.level,
1862
+ processToken, isOwner, dispatch,
1863
+ });
1864
+ await server.connect(transport);
1865
+ await transport.handleRequest(req, res, body);
1866
+ }
1867
+ const http = createHttpServer((req, res) => {
1868
+ void handle(req, res).catch((err) => {
1869
+ // Said, not swallowed. A tools server that answered 200 to a request it
1870
+ // could not serve would have the agent read an empty result as an answer.
1871
+ if (!res.headersSent) {
1872
+ fail(res, 500, err instanceof Error ? err.message : String(err));
1873
+ }
1874
+ else
1875
+ res.end();
1876
+ });
1877
+ });
1878
+ await new Promise((resolve, reject) => {
1879
+ const onError = (err) => { http.off('listening', onListening); reject(err); };
1880
+ const onListening = () => { http.off('error', onError); resolve(); };
1881
+ http.once('error', onError);
1882
+ http.once('listening', onListening);
1883
+ // Loopback only, never all interfaces, and port 0 for "whatever is free".
1884
+ http.listen(0, '127.0.0.1');
1885
+ });
1886
+ port = http.address().port;
1887
+ return {
1888
+ urlFor: (runId, processToken) => toolsUrl(port, runId, processToken),
1889
+ async close() {
1890
+ await Promise.all([...transports.values()].map((t) => t.close().catch(() => { })));
1891
+ transports.clear();
1892
+ sessionRuns.clear();
1893
+ sessionTokens.clear();
1894
+ await new Promise((resolve, reject) => {
1895
+ http.close((err) => (err ? reject(err) : resolve()));
1896
+ });
1897
+ },
1898
+ };
1899
+ }
1900
+ async function readJson(req) {
1901
+ const chunks = [];
1902
+ for await (const chunk of req)
1903
+ chunks.push(chunk);
1904
+ const raw = Buffer.concat(chunks).toString('utf8');
1905
+ return raw ? JSON.parse(raw) : undefined;
1906
+ }