@ctrl-spc/cs 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,6 +121,7 @@ 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 { readWorkflow } from '../workflows.js';
124
125
  import { ASK_CONTENT_COLUMNS, attachmentLine, loadAttachments, withAskContent, } from './show.js';
125
126
  // ---------------------------------------------------------------------------
126
127
  // READING AND WRITING. Every query goes through the same guard every other v3
@@ -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
@@ -651,6 +709,92 @@ const TOOLS = [
651
709
  ].join('\n');
652
710
  },
653
711
  },
712
+ {
713
+ name: 'get_workflow',
714
+ /* ═══ LEVEL 2 ALONE, AND THAT IS A DELIBERATE DIVERGENCE FROM `get_skill`'s
715
+ `ALL`. ═══ `get_skill`'s own comment argues level 1 needs it because "a
716
+ coordinator that cannot read the skill cannot write a responsibility that
717
+ respects it". That argument does not carry across, and the difference is
718
+ what a launcher is writing. A skill is a METHOD that applies to whatever
719
+ single responsibility level 1 writes, so a launcher ignorant of it writes
720
+ a responsibility that contradicts it. A workflow is not applied to the
721
+ launcher's responsibility: it is a SEQUENCE the OWNER decomposes, and the
722
+ launcher writes one line — see this conversation through — for the agent
723
+ that reads the process itself. `attachmentLine` already prints the
724
+ workflow's NAME in the launcher's prompt, so it can name the process in
725
+ what it writes without reading a stage of it.
726
+
727
+ AND NOT LEVEL 3 EITHER. A worker owns one piece and nothing else; handing
728
+ it the whole recipe invites it to run stages that are not its. What it
729
+ does need — its own stage's document — reaches it in its brief, because
730
+ `workBrief` tells the owner to put it there. Same narrowing, same reason,
731
+ as `get_credential` below.
732
+
733
+ AND THERE IS NO `list_workflows` BESIDE IT, for `get_skill`'s reason: the
734
+ id arrived in the brief, off the card's own attachment row, put there by
735
+ the person. A v3 agent never goes looking for a workflow. */
736
+ levels: [2],
737
+ description: 'Read the workflow attached to this conversation. A workflow is the PROCESS this work '
738
+ + 'follows: named stages in an order, each with a document to work to. Call this with the id '
739
+ + 'printed beside the workflow in what was attached, and read all of it before you decide how '
740
+ + 'this work is split.',
741
+ input: { workflow_id: z.string() },
742
+ handler: async ({ client }, args) => {
743
+ const { workflow_id } = args;
744
+ const workflow = await readWorkflow(client, workflow_id);
745
+ /* ═══ EXITS RESOLVE TO A NUMBERED STAGE, NEVER TO A RAW UUID. ═══
746
+ `to_stage_id` is a uuid and no v3 tool at any level takes one, so a raw
747
+ id would be a reference the reader cannot follow. This matters
748
+ concretely: exits are the only surviving representation of a REPEAT, and
749
+ every org is seeded with a feature loop whose exits are mostly backward.
750
+ A rendering that dropped them would present the one workflow every org
751
+ actually has as a straight line, which is a different process. */
752
+ const numberOf = new Map(workflow.stages.map((stage, i) => [stage.id, i + 1]));
753
+ const exitsOf = (stageId) => workflow.exits
754
+ .filter((exit) => exit.stageId === stageId)
755
+ .map((exit) => {
756
+ const number = numberOf.get(exit.toStageId);
757
+ const stage = workflow.stages.find((candidate) => candidate.id === exit.toStageId);
758
+ return number && stage
759
+ ? `if ${exit.condition}, go to stage ${number} ${stage.name}`
760
+ : `if ${exit.condition}, go back to an earlier stage`;
761
+ });
762
+ return [
763
+ workflow.name,
764
+ `id ${workflow.id}`,
765
+ `about ${workflow.description.trim() === '' ? 'no description' : workflow.description}`,
766
+ '',
767
+ `STAGES ${workflow.stages.length}`,
768
+ ...workflow.stages.flatMap((stage, i) => [
769
+ '',
770
+ line(`${i + 1}. ${stage.name}`, stage.description.trim() === '' ? null : stage.description),
771
+ '',
772
+ /* THE WHOLE BODY. The body IS the stage document, so truncating it is
773
+ truncating the process. An empty one is a stage nobody has written
774
+ yet, which is a real state and is said rather than hidden. */
775
+ stage.body.trim() === '' ? '(this stage has not been written yet)' : stage.body,
776
+ ...exitsOf(stage.id),
777
+ ]),
778
+ '',
779
+ /* ═══ THE PERSON'S ENDING CHOICE, SAID RATHER THAN OBEYED OR DISCARDED.
780
+ ═══ They picked it in the web app's own picker. v3 has no backlog read
781
+ at any level — `list_work_items` is newest-first with no rank — so
782
+ this cannot be done here. Rendering the raw value would invite
783
+ improvisation and rendering nothing would silently discard their
784
+ choice, which is the untruthful failure the guide forbids. The slice
785
+ that gives v3 a backlog read replaces this sentence. */
786
+ ...(workflow.ending === 'next-in-backlog'
787
+ ? [
788
+ 'This workflow is set to start again on the next backlog item. That is not available '
789
+ + 'here: stop when the last stage is done, and say so.',
790
+ '',
791
+ ]
792
+ : []),
793
+ 'This is a WORKFLOW: the process to FOLLOW for this work, stage by stage in the order '
794
+ + 'above. It is not a document to summarise, quote back or file away.',
795
+ ].join('\n');
796
+ },
797
+ },
654
798
  {
655
799
  name: 'get_credential',
656
800
  /* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
@@ -675,7 +819,7 @@ const TOOLS = [
675
819
  + 'beside the credential in what was attached. The value is yours to USE in the work you were '
676
820
  + 'sent to do; what you must not do is publish it.',
677
821
  input: { credential_id: z.string() },
678
- handler: async ({ client, runId }, args) => {
822
+ handler: async ({ client, runId, processToken }, args) => {
679
823
  const { credential_id } = args;
680
824
  const found = await rows(client.from('credentials').select('id, name, kind, username').eq('id', credential_id), 'read', `credential ${credential_id}`);
681
825
  /* ═══ ONE MESSAGE FOR THREE DIFFERENT FACTS, AND THAT IS DELIBERATE. ═══
@@ -711,7 +855,7 @@ const TOOLS = [
711
855
  BEFORE the agent can write anything containing it. ═══ See secrets.ts:
712
856
  from here on, this value cannot reach a panel3_ row through any tool
713
857
  this run calls or through the answer it ends with. */
714
- rememberSecret(runId, secret, row.name);
858
+ rememberSecret(secretScope({ runId, processToken }), secret, row.name);
715
859
  const username = typeof value.username === 'string' ? value.username : null;
716
860
  return [
717
861
  row.name,
@@ -746,13 +890,14 @@ const TOOLS = [
746
890
  handler: async ({ client }, args) => {
747
891
  const { artifact_id } = args;
748
892
  const artifact = await only(client.from('artifacts')
749
- .select('id, task_id, type, format, title, content, storage_path, created_at')
893
+ .select('id, task_id, type, format, title, content, storage_path, revision, created_at')
750
894
  .eq('id', artifact_id), 'read', `artifact ${artifact_id}`);
751
895
  return [
752
896
  `${artifact.title ?? '(untitled)'}`,
753
897
  `id ${artifact.id}`,
754
898
  `work item ${artifact.task_id}`,
755
899
  `type ${artifact.type} (${artifact.format})`,
900
+ `revision ${artifact.revision}`,
756
901
  '',
757
902
  /* A stored file and an empty body are different facts and are said
758
903
  differently. Returning '' for a PNG would read as an artifact with
@@ -1053,15 +1198,81 @@ const TOOLS = [
1053
1198
  return `Updated artifact ${artifact.title ?? artifact.id}.`;
1054
1199
  },
1055
1200
  },
1201
+ // ── Say something now ────────────────────────────────────────────────────
1202
+ //
1203
+ // ═══ LEVEL 2 ONLY, AND THE DATABASE IS WHAT ENFORCES IT. ═══ `panel3_say`
1204
+ // refuses any run that is not the one `panel3_cards.conversation_run_id`
1205
+ // names, and only a level 2 run is ever appointed, so a worker cannot reach
1206
+ // the person through this however the list below is edited. The tool is absent
1207
+ // at 1 and 3 as well, so an agent that has no business calling it is not
1208
+ // offered it and then refused.
1209
+ //
1210
+ // ═══ WHY IT EXISTS AT ALL, GIVEN `panel3_answer`. ═══ Nothing reached the
1211
+ // person until the owner's process exited, and a turn that ends by asking
1212
+ // writes no message at all. Measured 2026-08-24: "Write a plan for this item"
1213
+ // was silent for two and a half minutes and then said a question. An
1214
+ // acknowledgement cannot be an exit, because an exit is the end of the turn.
1215
+ {
1216
+ name: 'say',
1217
+ levels: [2],
1218
+ description: 'Say something to the person NOW, without stopping. Use it the moment you know what you are '
1219
+ + 'going to do, before you start doing it: they have seen only their own message, and until '
1220
+ + 'you say something the card shows them a pulse. One or two sentences in their words, saying '
1221
+ + 'what you are about to do and what they will get. Use it again if what you are doing changes '
1222
+ + 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
1223
+ + 'and does not replace the reply or the question you finish with: keep working after it. Do '
1224
+ + 'not use it for a running commentary, and do not use it to ask anything.',
1225
+ input: {
1226
+ message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
1227
+ + 'the plan and bring it to you."'),
1228
+ },
1229
+ handler: async ({ client, runId, processToken }, args) => {
1230
+ const { message } = args;
1231
+ /* ═══ THE SAME BOUND AS A QUESTION, BECAUSE IT IS THE SAME CARD. ═══
1232
+ Issue 7. Without this the bound on `ask_question` is theatre: an owner
1233
+ refused on a question can put the same paragraph in front of the same
1234
+ person through this door, and it does not even end its turn to do it. */
1235
+ if (message.length > PERSON_READS_LIMIT) {
1236
+ throw new Error(`NOTHING WAS WRITTEN: that is ${message.length} characters and they read it on a card `
1237
+ + `three inches wide. The limit is ${PERSON_READS_LIMIT}. One or two sentences saying `
1238
+ + 'what you are about to do; what you found goes in the artifact or your report.');
1239
+ }
1240
+ const { data, error } = await client.rpc('panel3_say', {
1241
+ p_run_id: runId,
1242
+ p_body: message,
1243
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1244
+ });
1245
+ if (error)
1246
+ throw new Error(`could not say that on this card: ${error.message}`);
1247
+ /* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
1248
+ SPOKE. ═══ Three reasons collapse to one sentence because the caller
1249
+ cannot tell them apart and all three mean the same thing to it: this
1250
+ conversation is not yours to speak on any more. An owner that thinks it
1251
+ acknowledged and did not is the failure this tool exists to remove,
1252
+ arrived at from the other side. */
1253
+ if (data === null) {
1254
+ throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
1255
+ + 'or somebody else owns it now. Do not tell anybody you said anything.');
1256
+ }
1257
+ return 'Said. They can read it now. Carry on.';
1258
+ },
1259
+ },
1056
1260
  // ── Report ───────────────────────────────────────────────────────────────
1057
1261
  {
1058
1262
  name: 'report_activity',
1059
1263
  levels: ALL,
1060
1264
  description: 'Say what you are doing right now, in one short line, in the words a person watching would use. '
1265
+ + 'The line says what you are FINDING OUT or what you are CHANGING, not the steps you are '
1266
+ + 'running to do it: "Checking for clock use, CI config, and running the test suite once" tells '
1267
+ + 'a reader nothing, where "Working out whether the code can tell what happened this week" tells '
1268
+ + 'them what turns on it. You do not need to have seen anybody\'s message to write one. '
1061
1269
  + 'It replaces whatever you last said. Call it when you start something that will take a while, '
1062
1270
  + '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) => {
1271
+ input: {
1272
+ activity: z.string().min(1).describe('One line, present tense, naming what you are finding out or changing. "Working out whether '
1273
+ + 'the code can tell what happened this week".'),
1274
+ },
1275
+ handler: async ({ client, runId, processToken }, args) => {
1065
1276
  const { activity } = args;
1066
1277
  /* ═══ THROUGH AN RPC, SO THE LINE AND ITS TIME ARE ONE STATEMENT ON ONE
1067
1278
  CLOCK. ═══ This was a plain table update. The card is now one list in
@@ -1070,7 +1281,11 @@ const TOOLS = [
1070
1281
  Supabase's. `panel3_report_activity` carries the same liveness
1071
1282
  predicate the update did, in the same statement, so `whileRunning` is
1072
1283
  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');
1284
+ await whileRunning(client.rpc('panel3_report_activity', {
1285
+ p_run_id: runId,
1286
+ p_activity: activity,
1287
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1288
+ }), runId, 'record what you are doing on');
1074
1289
  return 'Noted.';
1075
1290
  },
1076
1291
  },
@@ -1082,12 +1297,14 @@ const TOOLS = [
1082
1297
  + 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
1083
1298
  + 'you go, not only at the end.',
1084
1299
  input: { report: z.string().min(1) },
1085
- handler: async ({ client, runId }, args) => {
1300
+ handler: async ({ client, runId, processToken }, args) => {
1086
1301
  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');
1302
+ await whileRunning(processToken === undefined
1303
+ ? client.from('panel3_runs').update({ report }).eq('id', runId)
1304
+ .in('state', STILL_WRITING).is('ended_at', null).select('id')
1305
+ : client.rpc('panel3_write_report', {
1306
+ p_run_id: runId, p_report: report, p_process_token: processToken,
1307
+ }), runId, 'write the report on');
1091
1308
  return 'Report written.';
1092
1309
  },
1093
1310
  },
@@ -1134,6 +1351,9 @@ const TOOLS = [
1134
1351
  question carried them, and absent rather than printed as empty. */
1135
1352
  ...(a.category ? [` ${a.category}`] : []),
1136
1353
  ...(a.context ? [` why ${a.context}`] : []),
1354
+ ...(a.related_artifact_id ? [
1355
+ ` artifact ${a.related_artifact_id} presented revision ${a.related_artifact_revision ?? 'not recorded'}`,
1356
+ ] : []),
1137
1357
  ` Q ${a.question ?? '(this question could not be read)'}`,
1138
1358
  ...(a.options ?? []).map((option) => ` - ${option}`),
1139
1359
  ` A ${a.answer ?? 'not answered yet'}`,
@@ -1147,11 +1367,28 @@ const TOOLS = [
1147
1367
  decision. Level 3 still escalates, and the tool is absent there rather
1148
1368
  than present and refusing. */
1149
1369
  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.',
1370
+ description: 'Put a question to the person, and stop. A Level 1 launcher uses this only for a destination '
1371
+ + 'or codebase it truly cannot choose; it launches the owner for every work or product '
1372
+ + 'decision. A Level 2 owner MUST use this tool when work cannot continue '
1373
+ + 'until the person answers, including when they must choose between options; an ordinary '
1374
+ + 'reply is not a question path and completing the card with a question is wrong. Ask only '
1375
+ + 'what you genuinely cannot settle from the '
1376
+ + 'record, by dispatching someone to find out, or by answering it yourself. Use the shortest '
1377
+ + 'question and context the person can answer safely. Ask ONE thing, in the words they would '
1378
+ + 'use, and say what you will do with each answer. If you are putting on a question that came '
1379
+ + 'up from work you sent out, name it in question_id and write it as they need to read it: they '
1380
+ + 'have not seen any of it. For an artifact approval, name its Work Item and live artifact, '
1381
+ + 'offer approve or request changes, and present one revision at a time. THE QUESTION LINE '
1382
+ + 'ITSELF SAYS WHAT THEY ARE APPROVING, in plain words, never further down in the context: '
1383
+ + 'approving a document that describes work is not the same as approving the work, and the '
1384
+ + 'person cannot tell those apart from the title of an artifact. Write it the way these are '
1385
+ + 'written: "Plan is written. Please read it and approve or ask for changes", "Ok to write a '
1386
+ + 'spec.md and attach it to this work item?", "The work item has a detailed plan. Ok to start '
1387
+ + 'building?". A question that names the artifact and leaves the reader to work out what '
1388
+ + 'approving starts is the one this rule exists to stop. '
1389
+ + 'After this call, stop immediately. '
1390
+ + 'Do not repeat the question in '
1391
+ + 'an ordinary reply or add a message saying that you asked it.',
1155
1392
  input: {
1156
1393
  question: z.string().min(1),
1157
1394
  ...DECIDING,
@@ -1162,8 +1399,11 @@ const TOOLS = [
1162
1399
  + 'question is not about a work item — whether a second item should exist, which item a '
1163
1400
  + 'request means, or anything about the conversation itself — rather than picking the '
1164
1401
  + 'nearest one.'),
1402
+ related_artifact_id: z.string().optional().describe('The live Analysis or Plan artifact on work_item_id that this decision presents for approval '
1403
+ + 'or sends back for changes. Only the current Level 2 conversation owner may name it, and '
1404
+ + 'its current process token is required. Leave it out for every ordinary question.'),
1165
1405
  },
1166
- handler: async ({ client, runId }, args) => asked(client, runId, args, true),
1406
+ handler: async (caller, args) => asked(caller, args, true),
1167
1407
  },
1168
1408
  {
1169
1409
  name: 'escalate',
@@ -1176,7 +1416,7 @@ const TOOLS = [
1176
1416
  + 'either, this is how it goes further up: name it in question_id and write it in your own '
1177
1417
  + 'words, with what you already know added.',
1178
1418
  input: { question: z.string().min(1), ...DECIDING, question_id: PASSING_ON },
1179
- handler: async ({ client, runId }, args) => asked(client, runId, args, false),
1419
+ handler: async (caller, args) => asked(caller, args, false),
1180
1420
  },
1181
1421
  {
1182
1422
  name: 'answer_escalation',
@@ -1189,7 +1429,7 @@ const TOOLS = [
1189
1429
  + 'has to stop and deal with. The one who asked is started again with your answer, so write it '
1190
1430
  + 'to them, plainly, and say what to do rather than what you would have done.',
1191
1431
  input: { question_id: z.string(), answer: z.string().min(1) },
1192
- handler: async ({ client, runId }, args) => {
1432
+ handler: async ({ client, runId, processToken }, args) => {
1193
1433
  const { question_id, answer } = args;
1194
1434
  /* ═══ AN AGENT ANSWERS IN ITS OWN WORDS, WHATEVER SHAPE THE QUESTION WAS
1195
1435
  ASKED IN. ═══ No selection and the answer as the note, which is the
@@ -1202,6 +1442,7 @@ const TOOLS = [
1202
1442
  p_selected_options: [],
1203
1443
  p_answer_note: answer,
1204
1444
  p_by_run_id: runId,
1445
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1205
1446
  });
1206
1447
  if (error)
1207
1448
  throw new Error(`could not answer question ${question_id}: ${error.message}`);
@@ -1245,7 +1486,8 @@ const TOOLS = [
1245
1486
  + 'the same files, or send them one at a time. Do not wait for it: what it writes goes on as it '
1246
1487
  + 'wrote it, and nobody edits it on the way.',
1247
1488
  input: {
1248
- codebase_id: z.string().uuid().describe('The id of the registered project codebase this work belongs to.'),
1489
+ codebase_id: z.string().uuid().optional().describe('The id of the registered project codebase this work belongs to. A launcher may omit this '
1490
+ + 'for record-only work; an owner dispatching a worker must provide it.'),
1249
1491
  responsibility: z.string().min(1).describe('What this agent owns, in one or two sentences, complete enough to act on with no other '
1250
1492
  + 'context: what to find out or change, and in which part of the codebase.'),
1251
1493
  boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
@@ -1253,15 +1495,21 @@ const TOOLS = [
1253
1495
  },
1254
1496
  handler: async (caller, args) => {
1255
1497
  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.');
1498
+ if (caller.level === 2 && !codebase_id) {
1499
+ throw new Error('A worker must be attached to a registered project codebase.');
1260
1500
  }
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.');
1501
+ let codebase = null;
1502
+ if (codebase_id) {
1503
+ const cards = await rows(caller.client.from('panel3_cards').select('project_id').eq('id', caller.cardId), 'read', `the project for card ${caller.cardId}`);
1504
+ const projectId = cards[0]?.project_id;
1505
+ if (!projectId) {
1506
+ throw new Error('This conversation is not filed under a project, so it has no codebase to use.');
1507
+ }
1508
+ codebase = (await listCodebases(caller.client, projectId))
1509
+ .find((candidate) => candidate.id === codebase_id) ?? null;
1510
+ if (!codebase) {
1511
+ throw new Error('That codebase is not registered on this project. Read the current project codebases and choose one of them.');
1512
+ }
1265
1513
  }
1266
1514
  /* ONE LEVEL DOWN, AND THE SAME ARITHMETIC THE DATABASE DOES. This decides
1267
1515
  the words in the brief; `panel3_dispatch` decides the level on the row,
@@ -1277,11 +1525,9 @@ const TOOLS = [
1277
1525
  asking. See `workBrief`'s own doc for why the two are different things
1278
1526
  carried the same way. */
1279
1527
  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);
1528
+ const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
1529
+ id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
1530
+ }), codebase, caller.processToken);
1285
1531
  /* ═══ WHERE ITS ANSWER GOES DEPENDS ON WHICH LEVEL THIS IS, AND THAT IS
1286
1532
  KNOWN HERE RATHER THAN GUESSED. ═══ The description above cannot say it,
1287
1533
  because it is registered once for both levels that hold the tool; this
@@ -1293,8 +1539,8 @@ const TOOLS = [
1293
1539
  return (`Started, and it is working now. Its id is ${runId}, and list_child_runs will say how it is `
1294
1540
  + 'getting on. '
1295
1541
  + (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.'
1542
+ ? 'That owner now has the conversation. Your launcher work is finished: write no reply, '
1543
+ + 'ask nothing else, and exit immediately.'
1298
1544
  : 'What it writes comes back to you and to nobody else. When everybody you have sent has '
1299
1545
  + 'finished you are started again with what each of them wrote, and the one answer that '
1300
1546
  + 'covers them is yours to write, so do not wait here for it.'));
@@ -1314,10 +1560,14 @@ const TOOLS = [
1314
1560
  + 'worth finishing. It says how many agents it stopped, and it stops nothing rather than '
1315
1561
  + 'reaching outside what is yours. To stop your own work, just finish.',
1316
1562
  input: { run_id: z.string() },
1317
- handler: async ({ client, runId }, args) => {
1563
+ handler: async ({ client, runId, processToken }, args) => {
1318
1564
  const { run_id } = args;
1319
1565
  const { data, error } = await client
1320
- .rpc('panel3_stop_run', { p_by_run_id: runId, p_run_id: run_id });
1566
+ .rpc('panel3_stop_run', {
1567
+ p_by_run_id: runId,
1568
+ p_run_id: run_id,
1569
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
1570
+ });
1321
1571
  if (error)
1322
1572
  throw new Error(`could not stop run ${run_id}: ${error.message}`);
1323
1573
  const stopped = data;
@@ -1384,8 +1634,9 @@ async function receipt({ client, runId, cardId }, kind, refId, label) {
1384
1634
  /** The names one level is served, in the order they are registered. Exported for
1385
1635
  * the same reason `cs3 show` exists: a rule nobody can print is a rule nobody
1386
1636
  * can check. */
1387
- export function toolNamesForLevel(level) {
1388
- return TOOLS.filter((t) => t.levels.includes(level)).map((t) => t.name);
1637
+ export function toolNamesForLevel(level, isOwner = false) {
1638
+ return TOOLS.filter((t) => t.levels.includes(level) && !(isOwner && t.name === 'escalate'))
1639
+ .map((t) => t.name);
1389
1640
  }
1390
1641
  /**
1391
1642
  * One tool's handler, by name. Exported for the same reason `toolNamesForLevel`
@@ -1396,10 +1647,21 @@ export function toolNamesForLevel(level) {
1396
1647
  * standing up the MCP transport `buildServer` wraps it in.
1397
1648
  */
1398
1649
  export function toolHandler(name) {
1650
+ return toolNamed(name).handler;
1651
+ }
1652
+ /** One tool's registered input schema and description, by name. Exported for
1653
+ * what a handler test cannot see: whether a bound lives in Zod, where it would
1654
+ * reject before `buildServer`'s try/catch and reach every level sharing the
1655
+ * schema, or in the handler, where it comes back as a correctable failure. */
1656
+ export function toolShape(name) {
1657
+ const { description, input } = toolNamed(name);
1658
+ return { description, input };
1659
+ }
1660
+ function toolNamed(name) {
1399
1661
  const tool = TOOLS.find((t) => t.name === name);
1400
1662
  if (!tool)
1401
1663
  throw new Error(`no tool named ${name}`);
1402
- return tool.handler;
1664
+ return tool;
1403
1665
  }
1404
1666
  // ---------------------------------------------------------------------------
1405
1667
  /**
@@ -1416,7 +1678,7 @@ function buildServer(caller) {
1416
1678
  + 'hold rather than one that is missing.',
1417
1679
  });
1418
1680
  for (const tool of TOOLS) {
1419
- if (!tool.levels.includes(caller.level))
1681
+ if (!tool.levels.includes(caller.level) || (caller.isOwner && tool.name === 'escalate'))
1420
1682
  continue;
1421
1683
  server.registerTool(tool.name, { description: tool.description, inputSchema: tool.input }, (async (args) => {
1422
1684
  try {
@@ -1426,7 +1688,7 @@ function buildServer(caller) {
1426
1688
  the risky ones. This is one of the two chokepoints the rule rests
1427
1689
  on; `writeAnswer` in run.ts is the other. It is identity for the
1428
1690
  runs that read no credential, which is nearly all of them. */
1429
- const safe = redactArgs(caller.runId, args);
1691
+ const safe = redactArgs(secretScope(caller), args);
1430
1692
  return { content: [{ type: 'text', text: await tool.handler(caller, safe) }] };
1431
1693
  }
1432
1694
  catch (error) {
@@ -1447,6 +1709,18 @@ function buildServer(caller) {
1447
1709
  }
1448
1710
  return server;
1449
1711
  }
1712
+ export const toolsUrl = (port, runId, processToken) => `http://127.0.0.1:${port}/mcp/${runId}${processToken ? `/${processToken}` : ''}`;
1713
+ /** The per-request fence used by already-open MCP sessions. */
1714
+ export async function processActivationIsCurrent(client, runId, processToken) {
1715
+ const current = await rows(client.from('panel3_runs')
1716
+ .select('process_token, card:panel3_cards!panel3_runs_card_id_fkey!inner(conversation_run_id)')
1717
+ .eq('id', runId)
1718
+ .eq('process_token', processToken)
1719
+ .in('state', ['running', 'asked'])
1720
+ .is('ended_at', null)
1721
+ .eq('card.conversation_run_id', runId), 'verify', `the current process activation for run ${runId}`);
1722
+ return current.length > 0;
1723
+ }
1450
1724
  /**
1451
1725
  * Start the v3 tools server on loopback, for the signed-in user this client
1452
1726
  * carries.
@@ -1477,6 +1751,7 @@ export async function startToolsServer(client, dispatch) {
1477
1751
  /** Which run each open session belongs to, so a connection cannot change run
1478
1752
  * partway through. Bound at `initialize`, cleared with the session. */
1479
1753
  const sessionRuns = new Map();
1754
+ const sessionTokens = new Map();
1480
1755
  /** Known once the socket is bound, which is before any request can arrive. */
1481
1756
  let port = 0;
1482
1757
  const fail = (res, status, why) => {
@@ -1484,7 +1759,11 @@ export async function startToolsServer(client, dispatch) {
1484
1759
  };
1485
1760
  async function handle(req, res) {
1486
1761
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
1487
- const runId = url.pathname.startsWith('/mcp/') ? url.pathname.slice('/mcp/'.length) : null;
1762
+ const parts = url.pathname.startsWith('/mcp/')
1763
+ ? url.pathname.slice('/mcp/'.length).split('/').filter(Boolean)
1764
+ : [];
1765
+ const runId = parts[0] ?? null;
1766
+ const processToken = parts[1];
1488
1767
  if (!runId) {
1489
1768
  fail(res, 404, 'Not found. The v3 tools server serves /mcp/<run-id> and nothing else.');
1490
1769
  return;
@@ -1501,6 +1780,17 @@ export async function startToolsServer(client, dispatch) {
1501
1780
  fail(res, 400, 'Bad Request: this session belongs to a different run.');
1502
1781
  return;
1503
1782
  }
1783
+ const expected = sessionTokens.get(sessionId);
1784
+ if (expected !== processToken) {
1785
+ fail(res, 400, 'Bad Request: this session belongs to a different process activation.');
1786
+ return;
1787
+ }
1788
+ if (expected !== undefined) {
1789
+ if (!(await processActivationIsCurrent(client, runId, expected))) {
1790
+ fail(res, 403, `Run ${runId} belongs to a newer process activation.`);
1791
+ return;
1792
+ }
1793
+ }
1504
1794
  await existing.handleRequest(req, res);
1505
1795
  return;
1506
1796
  }
@@ -1517,7 +1807,9 @@ export async function startToolsServer(client, dispatch) {
1517
1807
  signed-in user's RLS, so a run belonging to somebody else is not found
1518
1808
  rather than refused — which is the same answer, told without confirming
1519
1809
  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}`);
1810
+ const runs = await rows(client.from('panel3_runs')
1811
+ .select('id, card_id, level, state, ended_at, process_token, card:panel3_cards!panel3_runs_card_id_fkey(conversation_run_id)')
1812
+ .eq('id', runId), 'read', `run ${runId}`);
1521
1813
  const run = runs[0];
1522
1814
  if (!run) {
1523
1815
  fail(res, 404, `Not found: there is no run ${runId}.`);
@@ -1535,11 +1827,21 @@ export async function startToolsServer(client, dispatch) {
1535
1827
  if (run.level !== 1 && run.level !== 2 && run.level !== 3) {
1536
1828
  throw new Error(`run ${runId} has level ${run.level}, which is not a level this product has`);
1537
1829
  }
1830
+ const isOwner = run.card?.conversation_run_id === run.id;
1831
+ if (isOwner && (!processToken || processToken !== run.process_token)) {
1832
+ fail(res, 403, `Run ${runId} belongs to a different process activation.`);
1833
+ return;
1834
+ }
1835
+ if (!isOwner && processToken !== undefined) {
1836
+ fail(res, 403, `Run ${runId} has no process activation token.`);
1837
+ return;
1838
+ }
1538
1839
  const transport = new StreamableHTTPServerTransport({
1539
1840
  sessionIdGenerator: () => crypto.randomUUID(),
1540
1841
  onsessioninitialized: (sid) => {
1541
1842
  transports.set(sid, transport);
1542
1843
  sessionRuns.set(sid, runId);
1844
+ sessionTokens.set(sid, processToken);
1543
1845
  },
1544
1846
  // The same DNS-rebinding guard v2's server carries: a page on a public
1545
1847
  // domain that re-resolves to 127.0.0.1 cannot drive this server from a
@@ -1552,10 +1854,12 @@ export async function startToolsServer(client, dispatch) {
1552
1854
  if (sid) {
1553
1855
  transports.delete(sid);
1554
1856
  sessionRuns.delete(sid);
1857
+ sessionTokens.delete(sid);
1555
1858
  }
1556
1859
  };
1557
1860
  const server = buildServer({
1558
- client, userId, runId, cardId: run.card_id, level: run.level, dispatch,
1861
+ client, userId, runId, cardId: run.card_id, level: run.level,
1862
+ processToken, isOwner, dispatch,
1559
1863
  });
1560
1864
  await server.connect(transport);
1561
1865
  await transport.handleRequest(req, res, body);
@@ -1581,11 +1885,12 @@ export async function startToolsServer(client, dispatch) {
1581
1885
  });
1582
1886
  port = http.address().port;
1583
1887
  return {
1584
- urlFor: (runId) => `http://127.0.0.1:${port}/mcp/${runId}`,
1888
+ urlFor: (runId, processToken) => toolsUrl(port, runId, processToken),
1585
1889
  async close() {
1586
1890
  await Promise.all([...transports.values()].map((t) => t.close().catch(() => { })));
1587
1891
  transports.clear();
1588
1892
  sessionRuns.clear();
1893
+ sessionTokens.clear();
1589
1894
  await new Promise((resolve, reject) => {
1590
1895
  http.close((err) => (err ? reject(err) : resolve()));
1591
1896
  });