@ctrl-spc/cs 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -105,7 +105,7 @@
105
105
  * agent naming something out of its reach stops nothing and is told so.
106
106
  *
107
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
108
+ * as it is for the person's own Stop: `panel3/cli.js run` kills the processes of stopped
109
109
  * runs on its own machine, on the poll it already has. Nothing here waits for
110
110
  * that, and nothing here claims it has happened.
111
111
  */
@@ -121,7 +121,8 @@ import { returned } from './client.js';
121
121
  import { rememberSecret, redactArgs } from './secrets.js';
122
122
  import { workBrief } from './prompt.js';
123
123
  import { listCodebases } from '../codebases.js';
124
- import { ASK_CONTENT_COLUMNS, attachmentLine, loadAttachments, withAskContent, } from './show.js';
124
+ import { readWorkflow } from '../workflows.js';
125
+ import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, setMainBranch, withAskContent, } from './show.js';
125
126
  // ---------------------------------------------------------------------------
126
127
  // READING AND WRITING. Every query goes through the same guard every other v3
127
128
  // command uses, so a failed read can never reach an agent as an empty list.
@@ -176,6 +177,7 @@ const ended = (runId, verb) => `could not ${verb} run ${runId}: THIS RUN HAS END
176
177
  /** The two states a run may still be written to in, as PostgREST takes them.
177
178
  * See `whileRunning`: `asked` is a process winding up, not a run that is gone. */
178
179
  const STILL_WRITING = ['running', 'asked'];
180
+ const secretScope = ({ runId, processToken }) => processToken === undefined ? runId : `${runId}:${processToken}`;
179
181
  /**
180
182
  * ═══ ASKING AND PASSING A QUESTION ON ARE ONE ACT, SO THEY ARE ONE FUNCTION.
181
183
  * ═══
@@ -192,8 +194,61 @@ const STILL_WRITING = ['running', 'asked'];
192
194
  * on after it asked, holding a question nobody can answer while it works on top
193
195
  * of the assumption it could not make.
194
196
  */
195
- async function asked(client, runId, args, askPerson) {
196
- const { question, category, context, answer_mode, options, question_id, work_item_id } = args;
197
+ /**
198
+ * ═══ HOW MUCH A PERSON IS ASKED TO READ ON ONE CARD, AND WHY IT IS 700. ═══
199
+ *
200
+ * Issue 7. Measured, not chosen: every person-facing question this product has
201
+ * ever asked splits cleanly at this line. The five Lane read and called
202
+ * unreadable ran 959 to 1373 characters; every question nobody objected to was
203
+ * 594 or less. 700 is above the largest accepted and below the smallest
204
+ * rejected, so no question anybody has been happy with is refused by it.
205
+ *
206
+ * IT COUNTS EVERYTHING THE PERSON READS, not one field: the card renders the
207
+ * question, the context and every option together, so a bound on `context`
208
+ * alone is satisfied by moving the paragraph into `question` and the card is
209
+ * exactly as unreadable.
210
+ *
211
+ * AND IT LIVES IN THE HANDLERS, NOT IN A ZOD `.max()`. A schema rejection
212
+ * happens before the callback runs, so it never reaches `buildServer`'s
213
+ * try/catch and the agent gets a protocol error rather than a correctable tool
214
+ * failure. A throw from here comes back as `isError`, which is a refusal the
215
+ * caller can fix in the same turn. It is also why the bound reaches
216
+ * `ask_question` and `say` and NOT `escalate` or the shared `DECIDING` schema:
217
+ * an escalation is rewritten whole by whoever passes it on, so no unbounded
218
+ * child text is ever inherited by what the person reads.
219
+ */
220
+ const PERSON_READS_LIMIT = 700;
221
+ async function asked(caller, args, askPerson) {
222
+ const { client, runId, processToken } = caller;
223
+ const { question, category, context, answer_mode, options, question_id, work_item_id, related_artifact_id, } = args;
224
+ if (askPerson) {
225
+ /* ═══ REFUSED BEFORE ANYTHING IS WRITTEN, AND TOLD WHICH HALF TO CUT. ═══
226
+ Issue 6 made a question say what answering it causes, which is the half
227
+ worth keeping; what makes a question unreadable is the findings that got
228
+ carried in with it. So the refusal does not say "be brief" — it names the
229
+ total, the limit, and where the findings belong instead. */
230
+ const total = question.length + context.length
231
+ + (options ?? []).reduce((sum, option) => sum + option.length, 0);
232
+ if (total > PERSON_READS_LIMIT) {
233
+ throw new Error(`NOTHING WAS WRITTEN and nobody was asked: that question is ${total} characters and they `
234
+ + `read it on a card three inches wide. The limit is ${PERSON_READS_LIMIT}, counting the `
235
+ + 'question, the context and every option together. Cut what you found, not what each '
236
+ + 'answer causes; findings belong in the artifact or your report.');
237
+ }
238
+ }
239
+ if (related_artifact_id !== undefined
240
+ && (!askPerson || caller.level !== 2 || !caller.isOwner || processToken === undefined)) {
241
+ throw new Error('NOTHING WAS WRITTEN. An artifact approval question can be opened only by the current '
242
+ + 'Level 2 conversation owner with its current process token.');
243
+ }
244
+ if (related_artifact_id !== undefined && (!work_item_id?.trim()
245
+ || answer_mode !== 'single_select'
246
+ || options?.length !== 2
247
+ || options[0] !== 'approve'
248
+ || options[1] !== 'request changes')) {
249
+ throw new Error('NOTHING WAS WRITTEN. An artifact approval question requires its Work Item, '
250
+ + 'answer_mode single_select, and exactly these options in order: approve, request changes.');
251
+ }
197
252
  const [ask] = await rows(client.rpc('panel3_ask', {
198
253
  p_run_id: runId,
199
254
  p_question: question,
@@ -209,6 +264,9 @@ async function asked(client, runId, args, askPerson) {
209
264
  ESCALATION ANYWAY. ═══ `escalate` shares this function and passes
210
265
  nothing, so the argument is absent rather than null-and-ignored there. */
211
266
  p_work_item_id: work_item_id ?? null,
267
+ ...(related_artifact_id === undefined
268
+ ? (processToken === undefined ? {} : { p_process_token: processToken })
269
+ : { p_process_token: processToken, p_related_artifact_id: related_artifact_id }),
212
270
  }), 'put', 'your question where it can be answered');
213
271
  if (!ask) {
214
272
  /* NOTHING WAS WRITTEN, and the reasons are said together because the agent
@@ -284,6 +342,48 @@ const DECIDING = {
284
342
  + 'for free_text. A SUGGESTION, NOT A BALLOT — the person may always answer in their own words '
285
343
  + 'instead, so offer what you would act on rather than every possibility.'),
286
344
  };
345
+ // ---------------------------------------------------------------------------
346
+ // PROJECT CONTEXT (19/project-context-7b). `public.project_documents` — the
347
+ // reference a team writes in the web app: architecture, design notes, how a
348
+ // subsystem came to be the way it is. Read when the work needs it, at every
349
+ // level, and ADDED TO by the levels that do the work.
350
+ //
351
+ // ═══ IT IS NOT THE SAME TABLE AS THE STANDING RULES, AND THAT IS THE POINT.
352
+ // ═══ `public.agent_instructions` is an OBLIGATION and arrives in the prompt,
353
+ // whole, before an agent acts; no tool here reads or writes it, at any level. A
354
+ // reference an agent chooses to read and a mandate it is handed are two
355
+ // different things, and the schema keeps them apart so a tool cannot confuse
356
+ // them (see 20260919090000_agent_instructions.sql).
357
+ /**
358
+ * ═══ THE READ IS BOUNDED, AND IT SAYS WHEN IT BOUNDED SOMETHING. ═══
359
+ *
360
+ * `project_documents.content` is unbounded `text` and a project may hold any
361
+ * number of documents, so an unbounded read would put a team's whole corpus into
362
+ * the same window the mandatory standing-rules block needs — and would do it
363
+ * silently. There is no existing row or byte limit anywhere in this file to
364
+ * inherit: `mcp.ts` has one of the right shape, and conventions.md rule 2 forbids
365
+ * `panel3/` to import it.
366
+ *
367
+ * TWO BOUNDS RATHER THAN ONE, because they catch different shapes: many small
368
+ * documents, and one enormous one. Whichever bites, the response ends by saying
369
+ * how many documents it did not show and how to ask for fewer — a truncation
370
+ * nobody is told about reads as a project with nothing else written down, which
371
+ * is worse than the read failing.
372
+ */
373
+ const CONTEXT_DOCUMENT_ROWS = 50;
374
+ const CONTEXT_DOCUMENT_CHARACTERS = 60000;
375
+ /**
376
+ * Which project the caller's card is filed under, or null when it has none.
377
+ *
378
+ * `panel3_cards.project_id` IS NULLABLE and a record-only card genuinely has no
379
+ * project, so null is a fact rather than a failure and each caller words its own
380
+ * refusal. A failed READ throws through `rows`, which is a different thing again
381
+ * and must never be flattened into "no project".
382
+ */
383
+ async function projectOfCard(caller) {
384
+ const cards = await rows(caller.client.from('panel3_cards').select('project_id').eq('id', caller.cardId), 'read', `the project for card ${caller.cardId}`);
385
+ return cards[0]?.project_id ?? null;
386
+ }
287
387
  const ALL = [1, 2, 3];
288
388
  const line = (...parts) => parts.filter(Boolean).join(' ');
289
389
  const listed = (items, empty) => (items.length === 0 ? empty : items.join('\n'));
@@ -651,6 +751,92 @@ const TOOLS = [
651
751
  ].join('\n');
652
752
  },
653
753
  },
754
+ {
755
+ name: 'get_workflow',
756
+ /* ═══ LEVEL 2 ALONE, AND THAT IS A DELIBERATE DIVERGENCE FROM `get_skill`'s
757
+ `ALL`. ═══ `get_skill`'s own comment argues level 1 needs it because "a
758
+ coordinator that cannot read the skill cannot write a responsibility that
759
+ respects it". That argument does not carry across, and the difference is
760
+ what a launcher is writing. A skill is a METHOD that applies to whatever
761
+ single responsibility level 1 writes, so a launcher ignorant of it writes
762
+ a responsibility that contradicts it. A workflow is not applied to the
763
+ launcher's responsibility: it is a SEQUENCE the OWNER decomposes, and the
764
+ launcher writes one line — see this conversation through — for the agent
765
+ that reads the process itself. `attachmentLine` already prints the
766
+ workflow's NAME in the launcher's prompt, so it can name the process in
767
+ what it writes without reading a stage of it.
768
+
769
+ AND NOT LEVEL 3 EITHER. A worker owns one piece and nothing else; handing
770
+ it the whole recipe invites it to run stages that are not its. What it
771
+ does need — its own stage's document — reaches it in its brief, because
772
+ `workBrief` tells the owner to put it there. Same narrowing, same reason,
773
+ as `get_credential` below.
774
+
775
+ AND THERE IS NO `list_workflows` BESIDE IT, for `get_skill`'s reason: the
776
+ id arrived in the brief, off the card's own attachment row, put there by
777
+ the person. A v3 agent never goes looking for a workflow. */
778
+ levels: [2],
779
+ description: 'Read the workflow attached to this conversation. A workflow is the PROCESS this work '
780
+ + 'follows: named stages in an order, each with a document to work to. Call this with the id '
781
+ + 'printed beside the workflow in what was attached, and read all of it before you decide how '
782
+ + 'this work is split.',
783
+ input: { workflow_id: z.string() },
784
+ handler: async ({ client }, args) => {
785
+ const { workflow_id } = args;
786
+ const workflow = await readWorkflow(client, workflow_id);
787
+ /* ═══ EXITS RESOLVE TO A NUMBERED STAGE, NEVER TO A RAW UUID. ═══
788
+ `to_stage_id` is a uuid and no v3 tool at any level takes one, so a raw
789
+ id would be a reference the reader cannot follow. This matters
790
+ concretely: exits are the only surviving representation of a REPEAT, and
791
+ every org is seeded with a feature loop whose exits are mostly backward.
792
+ A rendering that dropped them would present the one workflow every org
793
+ actually has as a straight line, which is a different process. */
794
+ const numberOf = new Map(workflow.stages.map((stage, i) => [stage.id, i + 1]));
795
+ const exitsOf = (stageId) => workflow.exits
796
+ .filter((exit) => exit.stageId === stageId)
797
+ .map((exit) => {
798
+ const number = numberOf.get(exit.toStageId);
799
+ const stage = workflow.stages.find((candidate) => candidate.id === exit.toStageId);
800
+ return number && stage
801
+ ? `if ${exit.condition}, go to stage ${number} ${stage.name}`
802
+ : `if ${exit.condition}, go back to an earlier stage`;
803
+ });
804
+ return [
805
+ workflow.name,
806
+ `id ${workflow.id}`,
807
+ `about ${workflow.description.trim() === '' ? 'no description' : workflow.description}`,
808
+ '',
809
+ `STAGES ${workflow.stages.length}`,
810
+ ...workflow.stages.flatMap((stage, i) => [
811
+ '',
812
+ line(`${i + 1}. ${stage.name}`, stage.description.trim() === '' ? null : stage.description),
813
+ '',
814
+ /* THE WHOLE BODY. The body IS the stage document, so truncating it is
815
+ truncating the process. An empty one is a stage nobody has written
816
+ yet, which is a real state and is said rather than hidden. */
817
+ stage.body.trim() === '' ? '(this stage has not been written yet)' : stage.body,
818
+ ...exitsOf(stage.id),
819
+ ]),
820
+ '',
821
+ /* ═══ THE PERSON'S ENDING CHOICE, SAID RATHER THAN OBEYED OR DISCARDED.
822
+ ═══ They picked it in the web app's own picker. v3 has no backlog read
823
+ at any level — `list_work_items` is newest-first with no rank — so
824
+ this cannot be done here. Rendering the raw value would invite
825
+ improvisation and rendering nothing would silently discard their
826
+ choice, which is the untruthful failure the guide forbids. The slice
827
+ that gives v3 a backlog read replaces this sentence. */
828
+ ...(workflow.ending === 'next-in-backlog'
829
+ ? [
830
+ 'This workflow is set to start again on the next backlog item. That is not available '
831
+ + 'here: stop when the last stage is done, and say so.',
832
+ '',
833
+ ]
834
+ : []),
835
+ 'This is a WORKFLOW: the process to FOLLOW for this work, stage by stage in the order '
836
+ + 'above. It is not a document to summarise, quote back or file away.',
837
+ ].join('\n');
838
+ },
839
+ },
654
840
  {
655
841
  name: 'get_credential',
656
842
  /* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
@@ -675,7 +861,7 @@ const TOOLS = [
675
861
  + 'beside the credential in what was attached. The value is yours to USE in the work you were '
676
862
  + 'sent to do; what you must not do is publish it.',
677
863
  input: { credential_id: z.string() },
678
- handler: async ({ client, runId }, args) => {
864
+ handler: async ({ client, runId, processToken }, args) => {
679
865
  const { credential_id } = args;
680
866
  const found = await rows(client.from('credentials').select('id, name, kind, username').eq('id', credential_id), 'read', `credential ${credential_id}`);
681
867
  /* ═══ ONE MESSAGE FOR THREE DIFFERENT FACTS, AND THAT IS DELIBERATE. ═══
@@ -711,7 +897,7 @@ const TOOLS = [
711
897
  BEFORE the agent can write anything containing it. ═══ See secrets.ts:
712
898
  from here on, this value cannot reach a panel3_ row through any tool
713
899
  this run calls or through the answer it ends with. */
714
- rememberSecret(runId, secret, row.name);
900
+ rememberSecret(secretScope({ runId, processToken }), secret, row.name);
715
901
  const username = typeof value.username === 'string' ? value.username : null;
716
902
  return [
717
903
  row.name,
@@ -746,13 +932,14 @@ const TOOLS = [
746
932
  handler: async ({ client }, args) => {
747
933
  const { artifact_id } = args;
748
934
  const artifact = await only(client.from('artifacts')
749
- .select('id, task_id, type, format, title, content, storage_path, created_at')
935
+ .select('id, task_id, type, format, title, content, storage_path, revision, created_at')
750
936
  .eq('id', artifact_id), 'read', `artifact ${artifact_id}`);
751
937
  return [
752
938
  `${artifact.title ?? '(untitled)'}`,
753
939
  `id ${artifact.id}`,
754
940
  `work item ${artifact.task_id}`,
755
941
  `type ${artifact.type} (${artifact.format})`,
942
+ `revision ${artifact.revision}`,
756
943
  '',
757
944
  /* A stored file and an empty body are different facts and are said
758
945
  differently. Returning '' for a PNG would read as an artifact with
@@ -761,6 +948,85 @@ const TOOLS = [
761
948
  ].join('\n');
762
949
  },
763
950
  },
951
+ {
952
+ name: 'get_project_context',
953
+ /* ALL THREE, straight off ux.md's "Who gets what" table, where
954
+ `get_project_context` has always sat in the "Read the record" row. Reading
955
+ what a team has written down is not a privilege any level is denied. */
956
+ levels: ALL,
957
+ description: 'The documentation this project\'s team has written: architecture, design notes, conventions, '
958
+ + 'how a subsystem came to be the way it is. Read it when the work needs it — before you '
959
+ + 'design something, before you follow a convention you are guessing at, and before you tell '
960
+ + 'the person how this project does something. Pass a codebase id to get that codebase\'s own '
961
+ + 'documents as well as the project\'s.',
962
+ input: {
963
+ codebase_id: z.string().uuid().optional().describe('A registered project codebase, to get its OWN documents alongside the project-wide ones. '
964
+ + 'Leave it out for the project-wide documents alone.'),
965
+ },
966
+ handler: async (caller, args) => {
967
+ const { codebase_id } = args;
968
+ const projectId = await projectOfCard(caller);
969
+ if (!projectId) {
970
+ return 'This conversation is not filed under a project, so there is no project context to read.';
971
+ }
972
+ const scoped = caller.client
973
+ .from('project_documents')
974
+ .select('id, title, type, codebase_id, content')
975
+ .eq('project_id', projectId);
976
+ const documents = await rows((codebase_id === undefined
977
+ ? scoped.is('codebase_id', null)
978
+ : scoped.or(`codebase_id.is.null,codebase_id.eq.${codebase_id}`))
979
+ /* MOST SPECIFIC FIRST, the order ux.md gives for the two scopes: a
980
+ codebase's documents beat the project's for work in that codebase,
981
+ so they are read first and are the ones that survive the bound. */
982
+ .order('codebase_id', { nullsFirst: false })
983
+ .order('created_at')
984
+ /* ONE MORE THAN THE CAP, so "there are more" is something this read
985
+ OBSERVED rather than something it assumed from a full page. The
986
+ extra row is never rendered; it only makes the closing line honest,
987
+ and it is why the count below is "at least". */
988
+ .limit(CONTEXT_DOCUMENT_ROWS + 1), 'read', `the project context on project ${projectId}`);
989
+ const withinRows = documents.slice(0, CONTEXT_DOCUMENT_ROWS);
990
+ const pastRowCap = documents.length > CONTEXT_DOCUMENT_ROWS;
991
+ /* THE CHARACTER WALK. The first document is always included whatever its
992
+ size: a project whose one document is larger than the whole budget must
993
+ still get an answer, and truncating a document's BODY would hand an
994
+ agent half a convention with no sign of where it stopped. */
995
+ const shown = [];
996
+ let used = 0;
997
+ for (const document of withinRows) {
998
+ if (used + document.content.length > CONTEXT_DOCUMENT_CHARACTERS && shown.length > 0)
999
+ break;
1000
+ shown.push(document);
1001
+ used += document.content.length;
1002
+ }
1003
+ if (shown.length === 0) {
1004
+ return codebase_id === undefined
1005
+ ? 'This project has no context documents yet.'
1006
+ : 'This project and that codebase have no context documents yet.';
1007
+ }
1008
+ const cut = withinRows.length - shown.length;
1009
+ return [
1010
+ ...shown.flatMap((document) => [
1011
+ `--- ${document.title} (${document.type}${document.codebase_id === null ? ', project' : ', this codebase'}) id ${document.id} ---`,
1012
+ document.content,
1013
+ '',
1014
+ ]),
1015
+ /* ═══ SAID WHENEVER SOMETHING WAS LEFT OUT, AND NEVER OTHERWISE. ═══ A
1016
+ complete answer that ends by discussing its own limits teaches an
1017
+ agent to distrust a read that was fine. */
1018
+ ...(cut > 0 || pastRowCap
1019
+ ? [
1020
+ `NOT EVERYTHING IS ABOVE: at least ${cut + (pastRowCap ? 1 : 0)} more `
1021
+ + `${cut + (pastRowCap ? 1 : 0) === 1 ? 'document was' : 'documents were'} not shown. `
1022
+ + `This read is capped at ${CONTEXT_DOCUMENT_ROWS} documents and `
1023
+ + `${CONTEXT_DOCUMENT_CHARACTERS} characters, and the most specific documents come `
1024
+ + 'first. Ask again without a codebase, or for one codebase at a time, to narrow it.',
1025
+ ]
1026
+ : []),
1027
+ ].join('\n');
1028
+ },
1029
+ },
764
1030
  {
765
1031
  name: 'list_cards',
766
1032
  /* ═══ LEVEL 1 ONLY, AND THIS OVERRIDES plan.md's TABLE. ═══ The plan grants
@@ -887,6 +1153,136 @@ const TOOLS = [
887
1153
  // the placement tools. Editing an item as the work moves, and producing an
888
1154
  // object, are two other rows of that same table and they are NOT level 1's:
889
1155
  // see the next section, which is where they now live.
1156
+ /**
1157
+ * ═══ THE ANSWER TO THE ONE GIT QUESTION A PERSON IS EVER ASKED. ═══
1158
+ *
1159
+ * worktrees-8, story 3: a codebase whose base branch is genuinely ambiguous is
1160
+ * settled by asking the person ONCE, and the answer is stored on the CODEBASE
1161
+ * rather than on the card that prompted it, so nobody is asked twice.
1162
+ *
1163
+ * A TOOL RATHER THAN A SIDE EFFECT OF THE ANSWER, for two reasons. The answer
1164
+ * has to land on the codebase and an ask lands on a card. And the only other
1165
+ * route is teaching the questions surface to write a settings table, which
1166
+ * would couple two things that have no reason to know each other.
1167
+ *
1168
+ * LEVEL 1 ONLY. It is the coordinator that sees across cards, that holds
1169
+ * `ask_question`, and that needs the fact before it dispatches. A level 2 or 3
1170
+ * agent asking would be asking about a card, mid-work, having already started
1171
+ * in the wrong place.
1172
+ */
1173
+ {
1174
+ name: 'set_main_branch',
1175
+ levels: [1],
1176
+ description: 'Record which branch finished work in a codebase should go back to. Use this ONLY after asking '
1177
+ + 'the person and getting an answer: it is the organization\'s setting, it applies to every '
1178
+ + 'member and every future card in that codebase, and nobody is asked again. Never guess it, '
1179
+ + 'and never call this to change a branch somebody already set.',
1180
+ input: {
1181
+ codebase_id: z.string().describe('The codebase id, as the codebase list gave it to you.'),
1182
+ branch: z.string().min(1).describe('The branch name exactly as the person chose it.'),
1183
+ },
1184
+ handler: async (caller, args) => {
1185
+ const { codebase_id, branch } = args;
1186
+ /* THROUGH `show.ts`, WHICH IS THE ONE FILE UNDER panel3/ THAT MAY NAME
1187
+ THIS TABLE. conventions.md records why, and
1188
+ panel3-isolation.contract.test.mjs pins it: reads and writes alike go
1189
+ through one file, so a grep for the table finds every caller. */
1190
+ await setMainBranch(caller.client, codebase_id, branch);
1191
+ return `Work in that codebase now starts from ${branch} and goes back to it. `
1192
+ + 'Nobody will be asked again.';
1193
+ },
1194
+ },
1195
+ /**
1196
+ * ═══ THE AGENT ASKS FOR THE OFFER; THE PRODUCT WRITES IT. ═══
1197
+ *
1198
+ * worktrees-8 C1. `ask_question`'s options are typed by a model out of prose,
1199
+ * so any product rule that reads them back is a rule about transcription. This
1200
+ * tool carries ONE agent-typed string, `what_was_done`, and nothing is ever
1201
+ * compared against it: the question, both answers and the merge itself are the
1202
+ * product's, exactly as an artifact approval already is.
1203
+ *
1204
+ * ═══ IT READS STAMPS, IT DOES NOT RESOLVE ANYTHING. ═══ `branch` and `base`
1205
+ * were written by `cardWorktree` precisely so a second reader would not
1206
+ * re-implement the derivation. No git runs here.
1207
+ */
1208
+ {
1209
+ name: 'offer_ending',
1210
+ /* ═══ LEVEL 2 ONLY, AND THE DATABASE RECHECKS THAT IT IS THE OWNER. ═══
1211
+ Level 1 never holds a card's branch, and level 3 has no ending and does
1212
+ not speak to the person. */
1213
+ levels: [2],
1214
+ description: 'Offer the person the ending for this card: put the finished work onto the main branch, or '
1215
+ + 'leave it on its branch. Call this when the card\'s work is DONE and the codebase lands on '
1216
+ + 'the main branch. You do not write the question or the answers and you never merge anything: '
1217
+ + 'the product composes both, and it performs the merge itself if they choose to put the work '
1218
+ + 'back. Say in one sentence what was done, in their words. After this call, stop immediately: '
1219
+ + 'you are started again with what the product did with their answer.',
1220
+ input: {
1221
+ what_was_done: z.string().min(1).describe('One sentence, in the person\'s words, saying what this card\'s work actually changed. It is '
1222
+ + 'the only part of the offer you write.'),
1223
+ },
1224
+ handler: async (caller, args) => {
1225
+ const { what_was_done } = args;
1226
+ const { client, runId, processToken } = caller;
1227
+ if (!caller.isOwner || processToken === undefined) {
1228
+ throw new Error('NOTHING WAS WRITTEN. Only this card\'s current conversation owner can offer the ending.');
1229
+ }
1230
+ if (what_was_done.length > PERSON_READS_LIMIT) {
1231
+ throw new Error(`NOTHING WAS WRITTEN and nobody was asked: that is ${what_was_done.length} characters and `
1232
+ + `they read it on a card three inches wide. The limit is ${PERSON_READS_LIMIT}. One `
1233
+ + 'sentence saying what changed.');
1234
+ }
1235
+ const run = await only(client.from('panel3_runs')
1236
+ .select('codebase_id, branch, base, card:panel3_cards!panel3_runs_card_id_fkey(project_id)')
1237
+ .eq('id', runId), 'read', 'which branch this card\'s work is on');
1238
+ if (!run.codebase_id || !run.branch || !run.base || !run.card?.project_id) {
1239
+ throw new Error('NOTHING WAS WRITTEN. This card has no codebase, branch and base recorded, so there is no '
1240
+ + 'ending to offer. Say what you did and finish the card.');
1241
+ }
1242
+ const rules = await gitRulesFor(client, run.codebase_id, run.card.project_id);
1243
+ if (rules.landing !== 'main') {
1244
+ throw new Error('NOTHING WAS WRITTEN. Finished work in this codebase does not go onto the main branch, so '
1245
+ + 'there is no ending to offer. Say where the work is and finish the card.');
1246
+ }
1247
+ const [ask] = await rows(client.rpc('panel3_ask', {
1248
+ p_run_id: runId,
1249
+ /* THE QUESTION AND THE CONTEXT ARE COMPOSED HERE, from the two stamps,
1250
+ which is why story 4's scenario can promise the branch and the
1251
+ separation are in it. The agent's sentence is appended to the
1252
+ context and decides nothing. */
1253
+ p_question: `Put this work onto ${run.base}?`,
1254
+ p_category: 'Finished work',
1255
+ p_context: `The work on this card is finished and is on branch ${run.branch}, in a copy of its own, `
1256
+ + 'apart from anything else working in this codebase on this computer. '
1257
+ + what_was_done,
1258
+ /* ═══ PASSED AND THEN IGNORED, AND BOTH HALVES ARE DELIBERATE. ═══ The
1259
+ statement overwrites the mode and the options with the product's own
1260
+ when `p_offers_landing` is true, which is where they are enforced.
1261
+ They are named here only because the twelve-argument core carries no
1262
+ defaults, so PostgREST resolves the overload by the exact argument
1263
+ set it is given. */
1264
+ p_answer_mode: 'single_select',
1265
+ p_options: [],
1266
+ p_question_id: null,
1267
+ p_work_item_id: null,
1268
+ p_ask_person: true,
1269
+ p_process_token: processToken,
1270
+ p_related_artifact_id: null,
1271
+ p_offers_landing: true,
1272
+ }), 'put', 'the ending where it can be answered');
1273
+ if (!ask || ask.ask_id === null) {
1274
+ throw new Error(`NOTHING WAS WRITTEN and nobody was asked: run ${runId} has already ended, or a question `
1275
+ + `is already waiting on you${ask?.held_question ? ` (${ask.held_question})` : ''}. Deal `
1276
+ + 'with that first.');
1277
+ }
1278
+ return [
1279
+ `The ending is with the person now, id ${ask.ask_id}. They were asked whether this work `
1280
+ + `should go onto ${run.base}, with the product's own two answers.`,
1281
+ 'YOU HAVE STOPPED. Do not merge anything and do not finish the card: when they answer, you',
1282
+ 'are started again and told what the product did.',
1283
+ ].join('\n');
1284
+ },
1285
+ },
890
1286
  {
891
1287
  name: 'create_epic',
892
1288
  levels: [1],
@@ -1053,15 +1449,152 @@ const TOOLS = [
1053
1449
  return `Updated artifact ${artifact.title ?? artifact.id}.`;
1054
1450
  },
1055
1451
  },
1452
+ {
1453
+ name: 'add_project_context',
1454
+ /* ═══ THE LEVELS THAT DO THE WORK, AND ux.md IS AMENDED RATHER THAN ARGUED
1455
+ PAST. ═══ A context document is a PRODUCED OBJECT, not board structure, so
1456
+ it does not fall under "product structure is level 1's alone"; and the
1457
+ levels that do the work are the levels that learn something worth keeping,
1458
+ while level 1 launches and exits. ux.md's "Who gets what" table carries
1459
+ this ruling, and `panel3-tools.contract.test.mjs` pins all three lists to
1460
+ it exhaustively. */
1461
+ levels: [2, 3],
1462
+ /* ═══ ADD, AND NEVER CHANGE OR REMOVE. ═══ `project_documents` has no
1463
+ history, no version UI and no DELETE policy — hard delete is unreachable
1464
+ rather than merely un-surfaced. That was safe while no agent could write
1465
+ these rows at all. An agent UPDATE would permanently overwrite a person's
1466
+ own text with nothing to restore from, policed only by a sentence in a
1467
+ description. So the tool adds and never changes, and the name says so
1468
+ rather than the description having to. */
1469
+ description: 'Write one new document into this project\'s context — the reference the team keeps for work '
1470
+ + 'like this. Use it when the conversation SETTLES something the project should keep: a '
1471
+ + 'convention agreed, how a subsystem actually fits together, why an approach was rejected. '
1472
+ + 'It adds a document and never changes or removes one, so do not use it to correct something '
1473
+ + 'already written; say what is wrong in your report instead. Not for the work you were sent '
1474
+ + 'to do — that goes in an artifact on the work item.',
1475
+ input: {
1476
+ title: z.string().min(1).max(100).describe('What this document is, in a few words a teammate would search for. 100 characters at most.'),
1477
+ type: z.enum(['instructions', 'architecture', 'design', 'conventions', 'other']).describe('Which kind of reference this is, matching the five the web app offers.'),
1478
+ content: z.string().min(1).describe('The document itself, in markdown, complete. It is read by people and by agents on later '
1479
+ + 'work, neither of whom has seen this conversation.'),
1480
+ scope: z.enum(['project', 'codebase']).optional().describe('`codebase` files it under the codebase you are working in, for something true of that repo '
1481
+ + 'and no other. Defaults to `project`, which every codebase inherits.'),
1482
+ },
1483
+ handler: async (caller, args) => {
1484
+ const a = args;
1485
+ /* ═══ THE PROJECT COMES OFF THE RUN'S OWN CARD, NEVER OFF AN ARGUMENT.
1486
+ ═══ There is no `project_id` input, deliberately: RLS is org-scoped, so
1487
+ a card in one project could otherwise write a document into a sibling
1488
+ project in the same organization, and nothing about that would look like
1489
+ a mistake afterwards. */
1490
+ const projectId = await projectOfCard(caller);
1491
+ if (!projectId) {
1492
+ throw new Error('NOTHING WAS WRITTEN: this conversation is not filed under a project, so there is no '
1493
+ + 'project context to add to. Put what you learned in your report instead.');
1494
+ }
1495
+ let codebaseId = null;
1496
+ if (a.scope === 'codebase') {
1497
+ /* THE RUN'S OWN CODEBASE, AND NO OTHER, for the same reason the project
1498
+ is not an argument. A level 2 owner on a record-only card has none,
1499
+ and that is a refusal rather than a silent fall back to project
1500
+ scope: filing a codebase-specific note project-wide puts it in front
1501
+ of work it does not apply to. */
1502
+ const runs = await rows(caller.client.from('panel3_runs').select('codebase_id').eq('id', caller.runId), 'read', `the codebase run ${caller.runId} belongs to`);
1503
+ codebaseId = runs[0]?.codebase_id ?? null;
1504
+ if (!codebaseId) {
1505
+ throw new Error('NOTHING WAS WRITTEN: this work is not attached to a codebase, so it has no codebase '
1506
+ + 'to file a document under. Call this again with scope `project` if it holds for the '
1507
+ + 'whole project.');
1508
+ }
1509
+ }
1510
+ const document = await only(caller.client.from('project_documents').insert({
1511
+ project_id: projectId,
1512
+ codebase_id: codebaseId,
1513
+ title: a.title.trim(),
1514
+ type: a.type,
1515
+ content: a.content,
1516
+ }).select('id, title'), 'write', 'a project context document');
1517
+ return (`Written. "${document.title}" is in this project's context now, id ${document.id}`
1518
+ + `${codebaseId === null ? '' : ', filed under this codebase'}. `
1519
+ + 'The person will find it under Project settings. You cannot change or remove it, so if it '
1520
+ + 'needs correcting, say so rather than writing a second one.');
1521
+ },
1522
+ },
1523
+ // ── Say something now ────────────────────────────────────────────────────
1524
+ //
1525
+ // ═══ LEVEL 2 ONLY, AND THE DATABASE IS WHAT ENFORCES IT. ═══ `panel3_say`
1526
+ // refuses any run that is not the one `panel3_cards.conversation_run_id`
1527
+ // names, and only a level 2 run is ever appointed, so a worker cannot reach
1528
+ // the person through this however the list below is edited. The tool is absent
1529
+ // at 1 and 3 as well, so an agent that has no business calling it is not
1530
+ // offered it and then refused.
1531
+ //
1532
+ // ═══ WHY IT EXISTS AT ALL, GIVEN `panel3_answer`. ═══ Nothing reached the
1533
+ // person until the owner's process exited, and a turn that ends by asking
1534
+ // writes no message at all. Measured 2026-08-24: "Write a plan for this item"
1535
+ // was silent for two and a half minutes and then said a question. An
1536
+ // acknowledgement cannot be an exit, because an exit is the end of the turn.
1537
+ {
1538
+ name: 'say',
1539
+ levels: [2],
1540
+ description: 'Say something to the person NOW, without stopping. Use it the moment you know what you are '
1541
+ + 'going to do, before you start doing it: they have seen only their own message, and until '
1542
+ + 'you say something the card shows them a pulse. One or two sentences in their words, saying '
1543
+ + 'what you are about to do and what they will get. Use it again if what you are doing changes '
1544
+ + 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
1545
+ + 'and does not replace the reply or the question you finish with: keep working after it. Do '
1546
+ + 'not use it for a running commentary, and do not use it to ask anything.',
1547
+ input: {
1548
+ message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
1549
+ + 'the plan and bring it to you."'),
1550
+ },
1551
+ handler: async ({ client, runId, processToken }, args) => {
1552
+ const { message } = args;
1553
+ /* ═══ THE SAME BOUND AS A QUESTION, BECAUSE IT IS THE SAME CARD. ═══
1554
+ Issue 7. Without this the bound on `ask_question` is theatre: an owner
1555
+ refused on a question can put the same paragraph in front of the same
1556
+ person through this door, and it does not even end its turn to do it. */
1557
+ if (message.length > PERSON_READS_LIMIT) {
1558
+ throw new Error(`NOTHING WAS WRITTEN: that is ${message.length} characters and they read it on a card `
1559
+ + `three inches wide. The limit is ${PERSON_READS_LIMIT}. One or two sentences saying `
1560
+ + 'what you are about to do; what you found goes in the artifact or your report.');
1561
+ }
1562
+ const { data, error } = await client.rpc('panel3_say', {
1563
+ p_run_id: runId,
1564
+ p_body: message,
1565
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1566
+ });
1567
+ if (error)
1568
+ throw new Error(`could not say that on this card: ${error.message}`);
1569
+ /* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
1570
+ SPOKE. ═══ Three reasons collapse to one sentence because the caller
1571
+ cannot tell them apart and all three mean the same thing to it: this
1572
+ conversation is not yours to speak on any more. An owner that thinks it
1573
+ acknowledged and did not is the failure this tool exists to remove,
1574
+ arrived at from the other side. */
1575
+ if (data === null) {
1576
+ throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
1577
+ + 'or somebody else owns it now. Do not tell anybody you said anything.');
1578
+ }
1579
+ return 'Said. They can read it now. Carry on.';
1580
+ },
1581
+ },
1056
1582
  // ── Report ───────────────────────────────────────────────────────────────
1057
1583
  {
1058
1584
  name: 'report_activity',
1059
1585
  levels: ALL,
1060
1586
  description: 'Say what you are doing right now, in one short line, in the words a person watching would use. '
1587
+ + 'The line says what you are FINDING OUT or what you are CHANGING, not the steps you are '
1588
+ + 'running to do it: "Checking for clock use, CI config, and running the test suite once" tells '
1589
+ + 'a reader nothing, where "Working out whether the code can tell what happened this week" tells '
1590
+ + 'them what turns on it. You do not need to have seen anybody\'s message to write one. '
1061
1591
  + 'It replaces whatever you last said. Call it when you start something that will take a while, '
1062
1592
  + '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) => {
1593
+ input: {
1594
+ activity: z.string().min(1).describe('One line, present tense, naming what you are finding out or changing. "Working out whether '
1595
+ + 'the code can tell what happened this week".'),
1596
+ },
1597
+ handler: async ({ client, runId, processToken }, args) => {
1065
1598
  const { activity } = args;
1066
1599
  /* ═══ THROUGH AN RPC, SO THE LINE AND ITS TIME ARE ONE STATEMENT ON ONE
1067
1600
  CLOCK. ═══ This was a plain table update. The card is now one list in
@@ -1070,7 +1603,11 @@ const TOOLS = [
1070
1603
  Supabase's. `panel3_report_activity` carries the same liveness
1071
1604
  predicate the update did, in the same statement, so `whileRunning` is
1072
1605
  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');
1606
+ await whileRunning(client.rpc('panel3_report_activity', {
1607
+ p_run_id: runId,
1608
+ p_activity: activity,
1609
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1610
+ }), runId, 'record what you are doing on');
1074
1611
  return 'Noted.';
1075
1612
  },
1076
1613
  },
@@ -1082,12 +1619,14 @@ const TOOLS = [
1082
1619
  + 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
1083
1620
  + 'you go, not only at the end.',
1084
1621
  input: { report: z.string().min(1) },
1085
- handler: async ({ client, runId }, args) => {
1622
+ handler: async ({ client, runId, processToken }, args) => {
1086
1623
  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');
1624
+ await whileRunning(processToken === undefined
1625
+ ? client.from('panel3_runs').update({ report }).eq('id', runId)
1626
+ .in('state', STILL_WRITING).is('ended_at', null).select('id')
1627
+ : client.rpc('panel3_write_report', {
1628
+ p_run_id: runId, p_report: report, p_process_token: processToken,
1629
+ }), runId, 'write the report on');
1091
1630
  return 'Report written.';
1092
1631
  },
1093
1632
  },
@@ -1134,6 +1673,9 @@ const TOOLS = [
1134
1673
  question carried them, and absent rather than printed as empty. */
1135
1674
  ...(a.category ? [` ${a.category}`] : []),
1136
1675
  ...(a.context ? [` why ${a.context}`] : []),
1676
+ ...(a.related_artifact_id ? [
1677
+ ` artifact ${a.related_artifact_id} presented revision ${a.related_artifact_revision ?? 'not recorded'}`,
1678
+ ] : []),
1137
1679
  ` Q ${a.question ?? '(this question could not be read)'}`,
1138
1680
  ...(a.options ?? []).map((option) => ` - ${option}`),
1139
1681
  ` A ${a.answer ?? 'not answered yet'}`,
@@ -1147,11 +1689,28 @@ const TOOLS = [
1147
1689
  decision. Level 3 still escalates, and the tool is absent there rather
1148
1690
  than present and refusing. */
1149
1691
  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.',
1692
+ description: 'Put a question to the person, and stop. A Level 1 launcher uses this only for a destination '
1693
+ + 'or codebase it truly cannot choose; it launches the owner for every work or product '
1694
+ + 'decision. A Level 2 owner MUST use this tool when work cannot continue '
1695
+ + 'until the person answers, including when they must choose between options; an ordinary '
1696
+ + 'reply is not a question path and completing the card with a question is wrong. Ask only '
1697
+ + 'what you genuinely cannot settle from the '
1698
+ + 'record, by dispatching someone to find out, or by answering it yourself. Use the shortest '
1699
+ + 'question and context the person can answer safely. Ask ONE thing, in the words they would '
1700
+ + 'use, and say what you will do with each answer. If you are putting on a question that came '
1701
+ + 'up from work you sent out, name it in question_id and write it as they need to read it: they '
1702
+ + 'have not seen any of it. For an artifact approval, name its Work Item and live artifact, '
1703
+ + 'offer approve or request changes, and present one revision at a time. THE QUESTION LINE '
1704
+ + 'ITSELF SAYS WHAT THEY ARE APPROVING, in plain words, never further down in the context: '
1705
+ + 'approving a document that describes work is not the same as approving the work, and the '
1706
+ + 'person cannot tell those apart from the title of an artifact. Write it the way these are '
1707
+ + 'written: "Plan is written. Please read it and approve or ask for changes", "Ok to write a '
1708
+ + 'spec.md and attach it to this work item?", "The work item has a detailed plan. Ok to start '
1709
+ + 'building?". A question that names the artifact and leaves the reader to work out what '
1710
+ + 'approving starts is the one this rule exists to stop. '
1711
+ + 'After this call, stop immediately. '
1712
+ + 'Do not repeat the question in '
1713
+ + 'an ordinary reply or add a message saying that you asked it.',
1155
1714
  input: {
1156
1715
  question: z.string().min(1),
1157
1716
  ...DECIDING,
@@ -1162,8 +1721,11 @@ const TOOLS = [
1162
1721
  + 'question is not about a work item — whether a second item should exist, which item a '
1163
1722
  + 'request means, or anything about the conversation itself — rather than picking the '
1164
1723
  + 'nearest one.'),
1724
+ related_artifact_id: z.string().optional().describe('The live Analysis or Plan artifact on work_item_id that this decision presents for approval '
1725
+ + 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
1726
+ + 'its current process token is required. Leave it out for every ordinary question.'),
1165
1727
  },
1166
- handler: async ({ client, runId }, args) => asked(client, runId, args, true),
1728
+ handler: async (caller, args) => asked(caller, args, true),
1167
1729
  },
1168
1730
  {
1169
1731
  name: 'escalate',
@@ -1176,7 +1738,7 @@ const TOOLS = [
1176
1738
  + 'either, this is how it goes further up: name it in question_id and write it in your own '
1177
1739
  + 'words, with what you already know added.',
1178
1740
  input: { question: z.string().min(1), ...DECIDING, question_id: PASSING_ON },
1179
- handler: async ({ client, runId }, args) => asked(client, runId, args, false),
1741
+ handler: async (caller, args) => asked(caller, args, false),
1180
1742
  },
1181
1743
  {
1182
1744
  name: 'answer_escalation',
@@ -1189,7 +1751,7 @@ const TOOLS = [
1189
1751
  + 'has to stop and deal with. The one who asked is started again with your answer, so write it '
1190
1752
  + 'to them, plainly, and say what to do rather than what you would have done.',
1191
1753
  input: { question_id: z.string(), answer: z.string().min(1) },
1192
- handler: async ({ client, runId }, args) => {
1754
+ handler: async ({ client, runId, processToken }, args) => {
1193
1755
  const { question_id, answer } = args;
1194
1756
  /* ═══ AN AGENT ANSWERS IN ITS OWN WORDS, WHATEVER SHAPE THE QUESTION WAS
1195
1757
  ASKED IN. ═══ No selection and the answer as the note, which is the
@@ -1202,6 +1764,7 @@ const TOOLS = [
1202
1764
  p_selected_options: [],
1203
1765
  p_answer_note: answer,
1204
1766
  p_by_run_id: runId,
1767
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1205
1768
  });
1206
1769
  if (error)
1207
1770
  throw new Error(`could not answer question ${question_id}: ${error.message}`);
@@ -1245,7 +1808,8 @@ const TOOLS = [
1245
1808
  + 'the same files, or send them one at a time. Do not wait for it: what it writes goes on as it '
1246
1809
  + 'wrote it, and nobody edits it on the way.',
1247
1810
  input: {
1248
- codebase_id: z.string().uuid().describe('The id of the registered project codebase this work belongs to.'),
1811
+ codebase_id: z.string().uuid().optional().describe('The id of the registered project codebase this work belongs to. A launcher may omit this '
1812
+ + 'for record-only work; an owner dispatching a worker must provide it.'),
1249
1813
  responsibility: z.string().min(1).describe('What this agent owns, in one or two sentences, complete enough to act on with no other '
1250
1814
  + 'context: what to find out or change, and in which part of the codebase.'),
1251
1815
  boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
@@ -1253,15 +1817,20 @@ const TOOLS = [
1253
1817
  },
1254
1818
  handler: async (caller, args) => {
1255
1819
  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.');
1820
+ if (caller.level === 2 && !codebase_id) {
1821
+ throw new Error('A worker must be attached to a registered project codebase.');
1260
1822
  }
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.');
1823
+ let codebase = null;
1824
+ if (codebase_id) {
1825
+ const projectId = await projectOfCard(caller);
1826
+ if (!projectId) {
1827
+ throw new Error('This conversation is not filed under a project, so it has no codebase to use.');
1828
+ }
1829
+ codebase = (await listCodebases(caller.client, projectId))
1830
+ .find((candidate) => candidate.id === codebase_id) ?? null;
1831
+ if (!codebase) {
1832
+ throw new Error('That codebase is not registered on this project. Read the current project codebases and choose one of them.');
1833
+ }
1265
1834
  }
1266
1835
  /* ONE LEVEL DOWN, AND THE SAME ARITHMETIC THE DATABASE DOES. This decides
1267
1836
  the words in the brief; `panel3_dispatch` decides the level on the row,
@@ -1277,11 +1846,9 @@ const TOOLS = [
1277
1846
  asking. See `workBrief`'s own doc for why the two are different things
1278
1847
  carried the same way. */
1279
1848
  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);
1849
+ const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
1850
+ id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
1851
+ }), codebase, caller.processToken);
1285
1852
  /* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
1286
1853
  KNOWN HERE RATHER THAN GUESSED. ═══ The description above cannot say it,
1287
1854
  because it is registered once for both levels that hold the tool; this
@@ -1293,8 +1860,8 @@ const TOOLS = [
1293
1860
  return (`Started, and it is working now. Its id is ${runId}, and list_child_runs will say how it is `
1294
1861
  + 'getting on. '
1295
1862
  + (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.'
1863
+ ? 'That owner now has the conversation. Your launcher work is finished: write no reply, '
1864
+ + 'ask nothing else, and exit immediately.'
1298
1865
  : 'What it writes comes back to you and to nobody else. When everybody you have sent has '
1299
1866
  + 'finished you are started again with what each of them wrote, and the one answer that '
1300
1867
  + 'covers them is yours to write, so do not wait here for it.'));
@@ -1314,10 +1881,14 @@ const TOOLS = [
1314
1881
  + 'worth finishing. It says how many agents it stopped, and it stops nothing rather than '
1315
1882
  + 'reaching outside what is yours. To stop your own work, just finish.',
1316
1883
  input: { run_id: z.string() },
1317
- handler: async ({ client, runId }, args) => {
1884
+ handler: async ({ client, runId, processToken }, args) => {
1318
1885
  const { run_id } = args;
1319
1886
  const { data, error } = await client
1320
- .rpc('panel3_stop_run', { p_by_run_id: runId, p_run_id: run_id });
1887
+ .rpc('panel3_stop_run', {
1888
+ p_by_run_id: runId,
1889
+ p_run_id: run_id,
1890
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1891
+ });
1321
1892
  if (error)
1322
1893
  throw new Error(`could not stop run ${run_id}: ${error.message}`);
1323
1894
  const stopped = data;
@@ -1382,10 +1953,11 @@ async function receipt({ client, runId, cardId }, kind, refId, label) {
1382
1953
  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
1954
  }
1384
1955
  /** 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
1956
+ * the same reason `cs show` exists: a rule nobody can print is a rule nobody
1386
1957
  * can check. */
1387
- export function toolNamesForLevel(level) {
1388
- return TOOLS.filter((t) => t.levels.includes(level)).map((t) => t.name);
1958
+ export function toolNamesForLevel(level, isOwner = false) {
1959
+ return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
1960
+ .map((t) => t.name);
1389
1961
  }
1390
1962
  /**
1391
1963
  * One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
@@ -1396,10 +1968,21 @@ export function toolNamesForLevel(level) {
1396
1968
  * standing up the MCP transport `buildServer` wraps it in.
1397
1969
  */
1398
1970
  export function toolHandler(name) {
1971
+ return toolNamed(name).handler;
1972
+ }
1973
+ /** One tool's registered input schema and description, by name. Exported for
1974
+ * what a handler test cannot see: whether a bound lives in Zod, where it would
1975
+ * reject before `buildServer`'s try/catch and reach every level sharing the
1976
+ * schema, or in the handler, where it comes back as a correctable failure. */
1977
+ export function toolShape(name) {
1978
+ const { description, input } = toolNamed(name);
1979
+ return { description, input };
1980
+ }
1981
+ function toolNamed(name) {
1399
1982
  const tool = TOOLS.find((t) => t.name === name);
1400
1983
  if (!tool)
1401
1984
  throw new Error(`no tool named ${name}`);
1402
- return tool.handler;
1985
+ return tool;
1403
1986
  }
1404
1987
  // ---------------------------------------------------------------------------
1405
1988
  /**
@@ -1416,7 +1999,7 @@ function buildServer(caller) {
1416
1999
  + 'hold rather than one that is missing.',
1417
2000
  });
1418
2001
  for (const tool of TOOLS) {
1419
- if (!tool.levels.includes(caller.level))
2002
+ if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
1420
2003
  continue;
1421
2004
  server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
1422
2005
  try {
@@ -1426,7 +2009,7 @@ function buildServer(caller) {
1426
2009
  the risky ones. This is one of the two chokepoints the rule rests
1427
2010
  on; `writeAnswer` in run.ts is the other. It is identity for the
1428
2011
  runs that read no credential, which is nearly all of them. */
1429
- const safe = redactArgs(caller.runId, args);
2012
+ const safe = redactArgs(secretScope(caller), args);
1430
2013
  return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
1431
2014
  }
1432
2015
  catch (error) {
@@ -1447,6 +2030,18 @@ function buildServer(caller) {
1447
2030
  }
1448
2031
  return server;
1449
2032
  }
2033
+ export const toolsUrl = (port, runId, processToken) => `http://127.0.0.1:${port}/mcp/${runId}${processToken ? `/${processToken}` : ''}`;
2034
+ /** The per-request fence used by already-open MCP sessions. */
2035
+ export async function processActivationIsCurrent(client, runId, processToken) {
2036
+ const current = await rows(client.from('panel3_runs')
2037
+ .select('process_token, card:panel3_cards!panel3_runs_card_id_fkey!inner(conversation_run_id)')
2038
+ .eq('id', runId)
2039
+ .eq('process_token', processToken)
2040
+ .in('state', ['running', 'asked'])
2041
+ .is('ended_at', null)
2042
+ .eq('card.conversation_run_id', runId), 'verify', `the current process activation for run ${runId}`);
2043
+ return current.length > 0;
2044
+ }
1450
2045
  /**
1451
2046
  * Start the v3 tools server on loopback, for the signed-in user this client
1452
2047
  * carries.
@@ -1477,6 +2072,7 @@ export async function startToolsServer(client, dispatch) {
1477
2072
  /** Which run each open session belongs to, so a connection cannot change run
1478
2073
  * partway through. Bound at `initialize`, cleared with the session. */
1479
2074
  const sessionRuns = new Map();
2075
+ const sessionTokens = new Map();
1480
2076
  /** Known once the socket is bound, which is before any request can arrive. */
1481
2077
  let port = 0;
1482
2078
  const fail = (res, status, why) => {
@@ -1484,7 +2080,11 @@ export async function startToolsServer(client, dispatch) {
1484
2080
  };
1485
2081
  async function handle(req, res) {
1486
2082
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
1487
- const runId = url.pathname.startsWith('/mcp/') ? url.pathname.slice('/mcp/'.length) : null;
2083
+ const parts = url.pathname.startsWith('/mcp/')
2084
+ ? url.pathname.slice('/mcp/'.length).split('/').filter(Boolean)
2085
+ : [];
2086
+ const runId = parts[0] ?? null;
2087
+ const processToken = parts[1];
1488
2088
  if (!runId) {
1489
2089
  fail(res, 404, 'Not found. The v3 tools server serves /mcp/<run-id> and nothing else.');
1490
2090
  return;
@@ -1501,6 +2101,17 @@ export async function startToolsServer(client, dispatch) {
1501
2101
  fail(res, 400, 'Bad Request: this session belongs to a different run.');
1502
2102
  return;
1503
2103
  }
2104
+ const expected = sessionTokens.get(sessionId);
2105
+ if (expected !== processToken) {
2106
+ fail(res, 400, 'Bad Request: this session belongs to a different process activation.');
2107
+ return;
2108
+ }
2109
+ if (expected !== undefined) {
2110
+ if (!(await processActivationIsCurrent(client, runId, expected))) {
2111
+ fail(res, 403, `Run ${runId} belongs to a newer process activation.`);
2112
+ return;
2113
+ }
2114
+ }
1504
2115
  await existing.handleRequest(req, res);
1505
2116
  return;
1506
2117
  }
@@ -1517,7 +2128,9 @@ export async function startToolsServer(client, dispatch) {
1517
2128
  signed-in user's RLS, so a run belonging to somebody else is not found
1518
2129
  rather than refused — which is the same answer, told without confirming
1519
2130
  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}`);
2131
+ const runs = await rows(client.from('panel3_runs')
2132
+ .select('id, card_id, level, state, ended_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
2133
+ .eq('id', runId), 'read', `run ${runId}`);
1521
2134
  const run = runs[0];
1522
2135
  if (!run) {
1523
2136
  fail(res, 404, `Not found: there is no run ${runId}.`);
@@ -1535,11 +2148,21 @@ export async function startToolsServer(client, dispatch) {
1535
2148
  if (run.level !== 1 && run.level !== 2 && run.level !== 3) {
1536
2149
  throw new Error(`run ${runId} has level ${run.level}, which is not a level this product has`);
1537
2150
  }
2151
+ const isOwner = run.card?.conversation_run_id === run.id;
2152
+ if (isOwner && (!processToken || processToken !== run.process_token)) {
2153
+ fail(res, 403, `Run ${runId} belongs to a different process activation.`);
2154
+ return;
2155
+ }
2156
+ if (!isOwner && processToken !== undefined) {
2157
+ fail(res, 403, `Run ${runId} has no process activation token.`);
2158
+ return;
2159
+ }
1538
2160
  const transport = new StreamableHTTPServerTransport({
1539
2161
  sessionIdGenerator: () => crypto.randomUUID(),
1540
2162
  onsessioninitialized: (sid) => {
1541
2163
  transports.set(sid, transport);
1542
2164
  sessionRuns.set(sid, runId);
2165
+ sessionTokens.set(sid, processToken);
1543
2166
  },
1544
2167
  // The same DNS-rebinding guard v2's server carries: a page on a public
1545
2168
  // domain that re-resolves to 127.0.0.1 cannot drive this server from a
@@ -1552,10 +2175,12 @@ export async function startToolsServer(client, dispatch) {
1552
2175
  if (sid) {
1553
2176
  transports.delete(sid);
1554
2177
  sessionRuns.delete(sid);
2178
+ sessionTokens.delete(sid);
1555
2179
  }
1556
2180
  };
1557
2181
  const server = buildServer({
1558
- client, userId, runId, cardId: run.card_id, level: run.level, dispatch,
2182
+ client, userId, runId, cardId: run.card_id, level: run.level,
2183
+ processToken, isOwner, dispatch,
1559
2184
  });
1560
2185
  await server.connect(transport);
1561
2186
  await transport.handleRequest(req, res, body);
@@ -1581,11 +2206,12 @@ export async function startToolsServer(client, dispatch) {
1581
2206
  });
1582
2207
  port = http.address().port;
1583
2208
  return {
1584
- urlFor: (runId) => `http://127.0.0.1:${port}/mcp/${runId}`,
2209
+ urlFor: (runId, processToken) => toolsUrl(port, runId, processToken),
1585
2210
  async close() {
1586
2211
  await Promise.all([...transports.values()].map((t) => t.close().catch(() => { })));
1587
2212
  transports.clear();
1588
2213
  sessionRuns.clear();
2214
+ sessionTokens.clear();
1589
2215
  await new Promise((resolve, reject) => {
1590
2216
  http.close((err) => (err ? reject(err) : resolve()));
1591
2217
  });