@ctrl-spc/cs 0.6.0 → 0.7.0

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