@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.
- package/dist/agents.js +70 -5
- package/dist/autostart.js +15 -1
- package/dist/browser.js +386 -0
- package/dist/codebases.js +15 -0
- package/dist/codex-home.js +501 -0
- package/dist/companion.js +9 -12
- package/dist/config.js +23 -0
- package/dist/daemon.js +33 -3
- package/dist/env.js +42 -0
- package/dist/failure-reason.js +98 -0
- package/dist/index.js +1 -1
- package/dist/mcp.js +7627 -298
- package/dist/orchestrator.js +6011 -0
- package/dist/panel3/answer.js +166 -0
- package/dist/panel3/checkout.js +29 -0
- package/dist/panel3/cli.js +83 -0
- package/dist/panel3/client.js +181 -0
- package/dist/panel3/coordinator.js +18 -0
- package/dist/panel3/presence.js +162 -0
- package/dist/panel3/prompt.js +677 -0
- package/dist/panel3/run.js +1988 -0
- package/dist/panel3/say.js +262 -0
- package/dist/panel3/secrets.js +98 -0
- package/dist/panel3/show.js +996 -0
- package/dist/panel3/spawn.js +503 -0
- package/dist/panel3/tools.js +1601 -0
- package/dist/presence.js +178 -6
- package/dist/win-shell.js +162 -0
- package/dist/work-context.js +1484 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|