@ctrl-spc/cs 0.7.3 → 0.7.5

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.
@@ -118,10 +118,32 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
118
118
  import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
119
119
  import { z } from 'zod';
120
120
  import { returned } from './client.js';
121
+ import { readableWriteError, FIREWALL_WRITING_RULE } from '../firewall.js';
121
122
  import { rememberSecret, redactArgs } from './secrets.js';
122
123
  import { workBrief } from './prompt.js';
124
+ /* The harness this daemon spawns with, which is the process this server runs
125
+ in. It is what `panel3_runs.harness` is written from at spawn, so reading it
126
+ here is the same fact without a second round trip. */
127
+ import { harness } from './spawn.js';
123
128
  import { listCodebases } from '../codebases.js';
124
- import { readWorkflow } from '../workflows.js';
129
+ import { buildWorkflow, duplicateWorkflow, editWorkflow, readWorkflow, rewordStage } from '../workflows.js';
130
+ /* ═══ 38-panel3-steps: THE FIFTH NEUTRAL MODULE, AND A DECISION LIKE THE OTHERS.
131
+ ═══ Stages and steps are the work item's own record, shared by both
132
+ generations and read by the web's Steps section. `steps.ts` owns the rows so
133
+ nothing here names a `cliv2_` table; conventions.md's enumeration and
134
+ `panel3-isolation.contract.test.mjs` were amended with it. */
135
+ import { STEP_STATUSES, createStep, listSteps, stageById, startWorkflowOnItem, stepById, updateStep, } from '../steps.js';
136
+ /* ═══ A THIRD GENERATION-NEUTRAL MODULE, AND IT IS A DECISION. ═══
137
+ conventions.md enumerates what `panel3/` may reach outside itself and says
138
+ "and nothing else", so this line amends that enumeration rather than slipping
139
+ past it, and `panel3-isolation.contract.test.mjs` fails until the list agrees.
140
+ `screenshots.ts` names no `cliv2_` table and no v1 or v2 concept: it is a PNG
141
+ parser and a byte bound. The alternative was copying 215 lines of chunk
142
+ walking into `panel3/` to avoid one entry in a list, which is the duplication
143
+ the guide forbids, and it would have left two answers to "is this a PNG". */
144
+ import { readPngScreenshot, screenshotArtifactId, MAX_SCREENSHOT_BYTES } from '../screenshots.js';
145
+ import { lstat } from 'node:fs/promises';
146
+ import { isAbsolute } from 'node:path';
125
147
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, setMainBranch, withAskContent, } from './show.js';
126
148
  // ---------------------------------------------------------------------------
127
149
  // READING AND WRITING. Every query goes through the same guard every other v3
@@ -218,6 +240,24 @@ const secretScope = ({ runId, processToken }) => processToken === undefined ? ru
218
240
  * child text is ever inherited by what the person reads.
219
241
  */
220
242
  const PERSON_READS_LIMIT = 700;
243
+ /**
244
+ * ═══ HOW MANY WORDS MAY NAME THE WORK ON A CARD, AND WHY IT IS SIX. ═══
245
+ *
246
+ * The name is read on one line three inches wide, and the branch for the work
247
+ * is cut from the same words, so both want a name rather than a sentence. Six
248
+ * is room for one: "Fix the sign-out checklist bug" is five.
249
+ *
250
+ * ═══ AND IT REFUSES RATHER THAN CUTTING THE NAME DOWN. ═══ `say.ts` already
251
+ * owns the one rule for turning a long line into a title. A second cut here
252
+ * would be a second answer to that question inside one package, and it would be
253
+ * the answer nobody saw being given: half a name is written, the card carries
254
+ * it, and the branch is cut from it. Refusing puts it back to the one caller
255
+ * that can still say it shorter, and it comes back through `buildServer`'s
256
+ * catch as a correctable failure rather than a protocol error, which is the
257
+ * same reason `PERSON_READS_LIMIT` above is checked in a handler and not on the
258
+ * schema.
259
+ */
260
+ const WORK_NAME_WORDS = 6;
221
261
  async function asked(caller, args, askPerson) {
222
262
  const { client, runId, processToken } = caller;
223
263
  const { question, category, context, answer_mode, options, question_id, work_item_id, related_artifact_id, } = args;
@@ -434,6 +474,14 @@ function elapsed(startedAt, endedAt, resumedAt) {
434
474
  * enforces it. Both copies are reading the same authority: the bucket the
435
475
  * migration created (20260722180000_skills.sql). */
436
476
  const SKILL_BUNDLES_BUCKET = 'skill-bundles';
477
+ /** Owner-scoped and private, the one `20260924090000_panel3_images.sql` created.
478
+ * Never the org-scoped `artifacts` bucket: a `panel3_images` row is readable by
479
+ * its owner alone, and a row that private pointing at a PNG the whole org can
480
+ * read would make the privacy a claim rather than a fact. */
481
+ /* Exported because the daemon downloads a card's pictures into the run's cwd
482
+ before it builds the prompt (33 Slice 6), and a second literal is a second
483
+ place for the bucket to be renamed wrong. */
484
+ export const PANEL3_IMAGES_BUCKET = 'panel3-images';
437
485
  /**
438
486
  * ═══ A REMOVED SKILL IS NOT A MISSING ONE, AND AN AGENT TOLD OTHERWISE SAYS
439
487
  * THE WRONG THING TO THE PERSON. ═══
@@ -524,6 +572,47 @@ async function skillDocument(client, storagePath, what) {
524
572
  }
525
573
  return await data.text();
526
574
  }
575
+ /**
576
+ * ═══ WHY A FILE COULD NOT BE SHOWN, SAID WITHOUT NAMING IT. ═══
577
+ *
578
+ * `attach_image` is handed an absolute local path, and every failure below it
579
+ * carries that path in its message: `readPngScreenshot` interpolates it four
580
+ * times, and an `ENOENT` from Node restates it again in its own wording. A
581
+ * tool's thrown message is what the AGENT reads, and an agent that has just
582
+ * failed quotes its own error into a `say`, which lands in `panel3_turns.body` —
583
+ * a cloud column a browser renders. AGENTS.md's rule is that no absolute path
584
+ * reaches the cloud, so the leak has to be closed here, two hops before it.
585
+ *
586
+ * NOT BY EDITING THE MESSAGE FROM BELOW. Stripping the path out of a string that
587
+ * already contains it is a guess about wording that holds until some layer
588
+ * phrases it differently — and the layer most likely to is the operating system,
589
+ * which owes this codebase nothing. So the reason is re-derived here and the text
590
+ * from below is discarded entirely.
591
+ *
592
+ * The agent still gets an actionable sentence, which is the whole point of
593
+ * telling it anything: the four cases it can do something about are separated,
594
+ * and everything else says the one true thing left.
595
+ */
596
+ async function whyNotAPicture(localPath) {
597
+ if (!isAbsolute(localPath)) {
598
+ return 'that is not an absolute path, and this needs one.';
599
+ }
600
+ let stat;
601
+ try {
602
+ stat = await lstat(localPath);
603
+ }
604
+ catch {
605
+ return 'there is no file at that path on this machine.';
606
+ }
607
+ if (!stat.isFile())
608
+ return 'that path is a folder or a link, not a file.';
609
+ if (stat.size === 0)
610
+ return 'that file is empty.';
611
+ if (stat.size > MAX_SCREENSHOT_BYTES) {
612
+ return `that file is larger than the ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB limit.`;
613
+ }
614
+ return 'that file could not be read as a PNG. It must be a real, uncompressed-format PNG.';
615
+ }
527
616
  /**
528
617
  * ═══ THE PATH A SKILL NAMED IS NOT TRUSTED. ═══
529
618
  *
@@ -585,6 +674,37 @@ const CREDENTIAL_INSTRUCTION = 'This is a SECRET, and it is yours to USE. Make t
585
674
  + 'itself into your report, your answer, a progress line, a question, a comment, an artifact or a '
586
675
  + 'file you commit, because all of those are shared and permanent. Name the credential you used '
587
676
  + 'instead. Never refuse the work to avoid touching the value.';
677
+ /**
678
+ * The exit rules whose refusal carries a NUMBER, checked in the 1-based
679
+ * numbering the agent sent and returned in the 0-based one the RPC takes.
680
+ *
681
+ * SHARED BY `create_workflow` AND `edit_workflow`, because they are the same
682
+ * rules over the same list: `cliv2_build_workflow` and `cliv2_edit_workflow`
683
+ * both count stages from 0, a numbering neither tool ever showed the agent, so
684
+ * both refuse here in the numbering the agent used.
685
+ */
686
+ function zeroBasedExits(exits, stageCount) {
687
+ for (const exit of exits) {
688
+ const where = `exit from stage ${exit.from_stage} to stage ${exit.to_stage}`;
689
+ const inRange = (n) => n >= 1 && n <= stageCount;
690
+ if (!inRange(exit.from_stage) || !inRange(exit.to_stage)) {
691
+ throw new Error(`${where} names a stage this workflow does not have (stages are 1..${stageCount}). `
692
+ + 'Nothing was written.');
693
+ }
694
+ if (exit.to_stage > exit.from_stage) {
695
+ throw new Error(`${where} jumps forward. to_stage must be earlier than from_stage, or the same stage. `
696
+ + 'Nothing was written.');
697
+ }
698
+ if (exit.condition.trim() === '') {
699
+ throw new Error(`the exit from stage ${exit.from_stage} needs a condition. Nothing was written.`);
700
+ }
701
+ }
702
+ return exits.map((exit) => ({
703
+ from: exit.from_stage - 1,
704
+ to: exit.to_stage - 1,
705
+ condition: exit.condition.trim(),
706
+ }));
707
+ }
588
708
  // ---------------------------------------------------------------------------
589
709
  // THE TOOLS.
590
710
  //
@@ -592,6 +712,32 @@ const CREDENTIAL_INSTRUCTION = 'This is a SECRET, and it is yours to USE. Make t
592
712
  // read against the table it came from without hunting. `levels` on each entry is
593
713
  // the whole of the per-level rule; there is no second place where a level is
594
714
  // granted or taken away.
715
+ /** 38-panel3-steps: the work item must be one this card carries. The person's
716
+ * attachments are the authority on what a run may write to, exactly as
717
+ * `attach_image_artifact` reads them before keeping a picture. */
718
+ async function carriedWorkItem(caller, workItemId, consequence, attached) {
719
+ attached ??= await loadAttachments(caller.client, caller.cardId);
720
+ if (!attached.some((item) => item.kind === 'work_item' && item.ref_id === workItemId)) {
721
+ throw new Error(`NOTHING WAS WRITTEN: this card does not carry work item ${workItemId}, so ${consequence}. `
722
+ + 'get_my_brief_and_report and what was attached name the work item you are on.');
723
+ }
724
+ }
725
+ /** The stage a step write may land in: a real row, anchored to a work item this
726
+ * card carries. A stage anchored to a panel request has no Steps section to
727
+ * show it, so it is refused by name and the agent is pointed at the card. */
728
+ async function stageForWriting(caller, stageId, consequence) {
729
+ const stage = await stageById(caller.client, stageId);
730
+ if (!stage) {
731
+ throw new Error(`NOTHING WAS WRITTEN: there is no stage ${stageId}, or it is not yours. list_steps names them.`);
732
+ }
733
+ if (!stage.workItemId) {
734
+ throw new Error(`NOTHING WAS WRITTEN: stage ${stageId} belongs to a panel request rather than a work item, and `
735
+ + 'steps are a WORK ITEM\'s record. What you are doing already goes on the card: call '
736
+ + 'report_activity. If this work deserves a work item of its own, ask for one.');
737
+ }
738
+ await carriedWorkItem(caller, stage.workItemId, consequence);
739
+ return stage;
740
+ }
595
741
  const TOOLS = [
596
742
  // ── Read the record ──────────────────────────────────────────────────────
597
743
  {
@@ -837,6 +983,441 @@ const TOOLS = [
837
983
  ].join('\n');
838
984
  },
839
985
  },
986
+ {
987
+ name: 'create_workflow',
988
+ /* ═══ LEVEL 2 ALONE, FOR THE REASON `get_workflow` IS. ═══ The owner is the
989
+ agent talking to the person, so it is the one that hears a process
990
+ described and can write it down as it was described. A launcher writes one
991
+ line and exits; a worker owns one piece of somebody else's process. */
992
+ levels: [2],
993
+ description: "Write a new workflow into this organisation's library: a name, what it is about, and its "
994
+ + 'stages in order, each with a one-line description and the markdown document an agent '
995
+ + 'following it reads. Stages are numbered from 1, the way get_workflow prints them. exits '
996
+ + 'are the conditional ways back: from_stage N, if condition, go back to to_stage M, where M '
997
+ + 'is earlier than N or the same stage; a forward jump is refused and nothing is written. '
998
+ + 'Takes no organisation or project: the workflow goes into the organisation this '
999
+ + "conversation's project belongs to. It appears on /workflows at once. Never put an "
1000
+ + 'absolute local path in any field.',
1001
+ input: {
1002
+ name: z.string(),
1003
+ description: z.string().optional(),
1004
+ stages: z.array(z.object({
1005
+ name: z.string(),
1006
+ description: z.string().optional(),
1007
+ body: z.string().describe('the whole stage document, in markdown'),
1008
+ })),
1009
+ exits: z.array(z.object({
1010
+ from_stage: z.number().int(),
1011
+ to_stage: z.number().int(),
1012
+ condition: z.string(),
1013
+ })).optional(),
1014
+ },
1015
+ handler: async (caller, args) => {
1016
+ const { name, description, stages, exits } = args;
1017
+ /* ═══ CHECKED HERE IN THE NUMBERING THE AGENT SENT. ═══
1018
+ `cliv2_build_workflow` enforces every one of these itself, and its
1019
+ messages count stages from 0, a numbering this tool never showed the
1020
+ agent, which would have it correcting the wrong stage. So the rules
1021
+ whose refusal carries a NUMBER are pre-checked and worded in 1-based
1022
+ terms; everything else (the grant, "at least one stage", anything
1023
+ unforeseen) is left to the RPC and passed back as it came. */
1024
+ const workflowName = name.trim();
1025
+ if (workflowName === '')
1026
+ throw new Error('a workflow needs a name. Nothing was written.');
1027
+ /* THE RPC REFUSES THIS TOO, and its message carries no number, so it
1028
+ would ordinarily be left to it. It is checked here because the exit
1029
+ range below would otherwise read "stages are 1..0", which is not a
1030
+ range and is not the thing that is actually wrong. */
1031
+ if (stages.length === 0) {
1032
+ throw new Error('a workflow needs at least one stage. Nothing was written.');
1033
+ }
1034
+ stages.forEach((stage, i) => {
1035
+ if (stage.name.trim() === '')
1036
+ throw new Error(`stage ${i + 1} needs a name. Nothing was written.`);
1037
+ });
1038
+ const wanted = zeroBasedExits(exits ?? [], stages.length);
1039
+ /* ═══ THE ORGANISATION IS THE CONVERSATION'S, NOT THE AGENT'S. ═══ There
1040
+ is no org argument to get wrong, and a record-only card genuinely has
1041
+ none, which is said rather than guessed at. */
1042
+ const projectId = await projectOfCard(caller);
1043
+ if (projectId === null) {
1044
+ throw new Error('this conversation has no project, so there is no organisation to write the workflow into.');
1045
+ }
1046
+ const project = await only(caller.client.from('projects').select('id, org_id').eq('id', projectId).is('archived_at', null), 'read', `the project this conversation is in (${projectId})`);
1047
+ const id = await buildWorkflow(caller.client, {
1048
+ orgId: project.org_id,
1049
+ name: workflowName,
1050
+ description: description?.trim() ?? '',
1051
+ stages: stages.map((stage) => ({
1052
+ name: stage.name.trim(),
1053
+ description: stage.description?.trim() ?? '',
1054
+ /* THE BODY IS NOT TRIMMED. It is a markdown document, and its leading
1055
+ blank lines are the author's own. */
1056
+ body: stage.body,
1057
+ })),
1058
+ exits: wanted,
1059
+ fromAgent: harness(),
1060
+ });
1061
+ await receipt(caller, 'workflow', id, workflowName);
1062
+ return `Created workflow ${workflowName}, id ${id}, with ${stages.length} stages. `
1063
+ + 'It is now in the library.';
1064
+ },
1065
+ },
1066
+ {
1067
+ name: 'reword_stage',
1068
+ /* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ The owner is the
1069
+ agent hearing the person say how a stage should read. */
1070
+ levels: [2],
1071
+ description: 'Reword one stage of the workflow attached to this conversation: its name, its one-line '
1072
+ + 'description, or its whole markdown document. Name the stage by the number get_workflow '
1073
+ + 'prints. Send only the fields to change; a field left out is left as it is. A stage is one '
1074
+ + 'shared library row, so if other workflows hold it they change too, and the reply names them. '
1075
+ + 'Never put an absolute local path in any field.',
1076
+ input: {
1077
+ workflow_id: z.string(),
1078
+ stage: z.number().int().describe('the stage number, counted from 1 as get_workflow prints it'),
1079
+ name: z.string().optional(),
1080
+ description: z.string().optional(),
1081
+ body: z.string().optional().describe('the whole stage document, in markdown; replaces the current one'),
1082
+ },
1083
+ handler: async (caller, args) => {
1084
+ const { workflow_id, stage, name, description, body } = args;
1085
+ if (name === undefined && description === undefined && body === undefined) {
1086
+ throw new Error('send at least one of name, description or body. Nothing was written.');
1087
+ }
1088
+ const stageName = name?.trim();
1089
+ if (stageName === '')
1090
+ throw new Error('a stage needs a name. Nothing was written.');
1091
+ const workflow = await readWorkflow(caller.client, workflow_id);
1092
+ if (workflow.archivedAt !== null) {
1093
+ throw new Error(`workflow ${workflow.name} is archived. Nothing was written.`);
1094
+ }
1095
+ /* ═══ THE NUMBER THE AGENT SAW, RESOLVED THROUGH THE READ THAT PRINTED IT.
1096
+ ═══ No v3 tool at any level takes a stage uuid, so the numbering is
1097
+ `get_workflow`'s presentation and the mapping stays here. `stage: 0`
1098
+ indexes -1, is undefined, and is refused with the range. */
1099
+ const target = workflow.stages[stage - 1];
1100
+ if (!target) {
1101
+ throw new Error(`stage ${stage} is not a stage of ${workflow.name} (stages are 1..${workflow.stages.length}). `
1102
+ + 'Nothing was written.');
1103
+ }
1104
+ const { alsoIn } = await rewordStage(caller.client, {
1105
+ workflowId: workflow.id,
1106
+ stageId: target.id,
1107
+ name: stageName,
1108
+ /* A WHITESPACE-ONLY DESCRIPTION CLEARS IT. An empty description is a
1109
+ real state (the column defaults to one); an empty name is not. */
1110
+ description: description?.trim(),
1111
+ /* THE BODY IS NOT TRIMMED, for `create_workflow`'s reason. */
1112
+ body,
1113
+ });
1114
+ /* THE WORKFLOW IS WHAT THE PERSON OPENS, because no v3 surface shows a
1115
+ stage on its own. */
1116
+ await receiptOnce(caller, 'workflow', workflow.id, workflow.name);
1117
+ const changed = [
1118
+ name !== undefined && 'name',
1119
+ description !== undefined && 'description',
1120
+ body !== undefined && 'body',
1121
+ ].filter(Boolean).join(', ');
1122
+ return [
1123
+ `Reworded stage ${stage} ${stageName ?? target.name} of ${workflow.name} (${changed}).`,
1124
+ alsoIn.length === 0
1125
+ ? 'It is in no other live workflow.'
1126
+ : `That stage is shared, so these workflows changed too: ${alsoIn.join(', ')}.`,
1127
+ /* ═══ A RENAME DOES NOT REACH A PLAN ALREADY RUNNING. ═══
1128
+ `cliv2_start_workflow` copies the stage NAME onto the work item's plan
1129
+ and keeps only a reference for the document, so a body change flows to
1130
+ a run in flight and a name change does not. Said when the name
1131
+ actually changed; naming the runs is not done, because the read that
1132
+ exists, `cliv2_workflow_runs`, names every run of a workflow rather
1133
+ than the runs carrying this stage. */
1134
+ ...(stageName === undefined || stageName === target.name ? [] : [
1135
+ 'Work items already running a workflow that holds it keep the old stage name on their plan.',
1136
+ ]),
1137
+ ].join(' ');
1138
+ },
1139
+ },
1140
+ {
1141
+ name: 'edit_workflow',
1142
+ /* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ The owner is the
1143
+ agent hearing the person say the process has changed shape. */
1144
+ levels: [2],
1145
+ description: 'Change the shape of the workflow attached to this conversation: its name, what it is about, '
1146
+ + 'which stages it has and in what order, and the conditional ways back. stages is the WHOLE '
1147
+ + 'new list, in order: a stage left out is removed from the workflow (it stays in the library). '
1148
+ + 'Each entry is either { keep: N } to keep stage N as get_workflow prints it, or { name, '
1149
+ + 'description, body } to write a new stage. exits is the WHOLE new list of conditional ways '
1150
+ + 'back too, and may be empty: from_stage and to_stage count from 1 in the NEW list, to_stage '
1151
+ + 'must be earlier than from_stage or the same, and exits off one stage are tried in the order '
1152
+ + 'sent. name and description left out are left as they are; an empty description clears it. '
1153
+ + 'To change the wording of an '
1154
+ + 'existing stage use reword_stage. Refused, with nothing written, while any work item is '
1155
+ + 'running the workflow. Never put an absolute local path in any field.',
1156
+ input: {
1157
+ workflow_id: z.string(),
1158
+ name: z.string().optional(),
1159
+ description: z.string().optional(),
1160
+ stages: z.array(z.union([
1161
+ z.object({ keep: z.number().int().describe('the stage number as get_workflow prints it') }).strict(),
1162
+ z.object({
1163
+ name: z.string(),
1164
+ description: z.string().optional(),
1165
+ body: z.string().describe('the whole stage document, in markdown'),
1166
+ }).strict(),
1167
+ ])),
1168
+ exits: z.array(z.object({
1169
+ from_stage: z.number().int(),
1170
+ to_stage: z.number().int(),
1171
+ condition: z.string(),
1172
+ })).optional().describe('the whole new list of conditional ways back; send [] for none'),
1173
+ },
1174
+ handler: async (caller, args) => {
1175
+ const { workflow_id, name, description, stages, exits } = args;
1176
+ const workflowName = name?.trim();
1177
+ if (workflowName === '')
1178
+ throw new Error('a workflow needs a name. Nothing was written.');
1179
+ if (stages.length === 0) {
1180
+ throw new Error('a workflow needs at least one stage, and stages is its WHOLE new list. Nothing was written.');
1181
+ }
1182
+ /* ═══ AN ABSENT `exits` IS REFUSED IN WORDS, NEVER DEFAULTED TO `[]`. ═══
1183
+ The RPC REPLACES the exits from what it is sent, so treating "left out"
1184
+ as "none" would silently straighten a loop a person drew, on a call the
1185
+ agent made to add one stage. */
1186
+ if (exits === undefined) {
1187
+ throw new Error('exits is the whole new list of conditional ways back and may be empty; send [] for none. '
1188
+ + 'Nothing was written.');
1189
+ }
1190
+ const workflow = await readWorkflow(caller.client, workflow_id);
1191
+ if (workflow.archivedAt !== null) {
1192
+ throw new Error(`workflow ${workflow.name} is archived. Nothing was written.`);
1193
+ }
1194
+ /* ═══ THE NUMBER THE AGENT SAW, RESOLVED THROUGH THE READ THAT PRINTED IT,
1195
+ ═══ as `reword_stage` does it: no v3 tool at any level takes a stage
1196
+ uuid, so `{ keep: N }` is `get_workflow`'s numbering and the mapping
1197
+ stays here. */
1198
+ const kept = new Set();
1199
+ const entries = stages.map((stage, i) => {
1200
+ if ('keep' in stage) {
1201
+ const target = workflow.stages[stage.keep - 1];
1202
+ if (!target) {
1203
+ throw new Error(`keep ${stage.keep} names a stage ${workflow.name} does not have (stages are `
1204
+ + `1..${workflow.stages.length}). Nothing was written.`);
1205
+ }
1206
+ if (kept.has(stage.keep)) {
1207
+ throw new Error(`stage ${stage.keep} is listed twice. A stage may appear once. Nothing was written.`);
1208
+ }
1209
+ kept.add(stage.keep);
1210
+ return { stage_id: target.id };
1211
+ }
1212
+ if (stage.name.trim() === '') {
1213
+ throw new Error(`the new stage at position ${i + 1} needs a name. Nothing was written.`);
1214
+ }
1215
+ /* THE BODY IS NOT TRIMMED, for `create_workflow`'s reason. */
1216
+ return { name: stage.name.trim(), description: stage.description?.trim() ?? '', body: stage.body };
1217
+ });
1218
+ const count = await editWorkflow(caller.client, {
1219
+ workflowId: workflow.id,
1220
+ name: workflowName ?? null,
1221
+ /* A WHITESPACE-ONLY DESCRIPTION CLEARS IT, as `reword_stage`'s does;
1222
+ left out, it is left as it is, which is what null means to the RPC. */
1223
+ description: description?.trim() ?? null,
1224
+ stages: entries,
1225
+ exits: zeroBasedExits(exits, entries.length),
1226
+ /* THE BRANCHES GO BACK AS THEY CAME. The RPC rewrites them from what it
1227
+ is sent and nothing in v3 sets one, so sending anything else would
1228
+ delete what a person set on `/workflows`. */
1229
+ branches: workflow.branches,
1230
+ fromAgent: harness(),
1231
+ });
1232
+ /* THE LABEL IS THE NAME THAT SURVIVED THE EDIT, which is the reply's own:
1233
+ `name` is optional and is undefined whenever only the stages changed,
1234
+ so the raw argument would put an empty label on the card. */
1235
+ await receiptOnce(caller, 'workflow', workflow.id, workflowName ?? workflow.name);
1236
+ /* THE COUNT IN THE REPLY IS THE LIST'S, NOT THE RPC'S. They agree when
1237
+ the RPC did its job, and the RPC's is checked for being a number
1238
+ above; the sentence claims what was sent, so it counts what was sent. */
1239
+ return `Edited workflow ${workflowName ?? workflow.name}: it now has exactly the ${entries.length} stages you sent, `
1240
+ + `in that order, and ${exits.length === 0 ? 'no exits back to an earlier stage' : 'exactly the exits back you sent'}. `
1241
+ + `The database counts ${count}.`;
1242
+ },
1243
+ },
1244
+ {
1245
+ name: 'duplicate_workflow',
1246
+ /* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ It is the way past
1247
+ the refusal `edit_workflow` gives, so it belongs to the level that was
1248
+ refused. */
1249
+ levels: [2],
1250
+ description: 'Copy a workflow into a new one with no runs: the same stages in the same order, the same '
1251
+ + 'exits, branch conditions and ending, under a new name (the original name plus " (copy)" '
1252
+ + 'unless you give one). This is the way past a workflow edit_workflow refused because a work '
1253
+ + 'item is running it: the original keeps its runs, the copy takes your change. Never put an '
1254
+ + 'absolute local path in the name.',
1255
+ input: {
1256
+ workflow_id: z.string(),
1257
+ name: z.string().optional().describe('the name for the copy; leave out for the original name plus " (copy)"'),
1258
+ },
1259
+ handler: async (caller, args) => {
1260
+ const { workflow_id, name } = args;
1261
+ const copyName = name?.trim();
1262
+ if (copyName === '') {
1263
+ throw new Error('a workflow needs a name; leave name out to take the original name plus " (copy)". '
1264
+ + 'Nothing was written.');
1265
+ }
1266
+ /* ═══ NOTHING IS READ FIRST. ═══ The RPC refuses an archived source, one
1267
+ that is not the person's and a stageless one in its own words, which are
1268
+ the words v2 agents already see; a pre-check here would be a second
1269
+ owner of those rules saying them differently. `create_workflow`'s
1270
+ pre-checks exist only because the RPC's numbers are 0-based, and nothing
1271
+ here carries a number. */
1272
+ const id = await duplicateWorkflow(caller.client, {
1273
+ workflowId: workflow_id,
1274
+ name: copyName ?? null,
1275
+ fromAgent: harness(),
1276
+ });
1277
+ /* ═══ READ BACK, NOT GUESSED. ═══ The RPC chose the name (the default is
1278
+ its rule, not this tool's) and copied the links, so the receipt label
1279
+ and the reply say what is stored. */
1280
+ const copy = await readWorkflow(caller.client, id);
1281
+ await receipt(caller, 'workflow', copy.id, copy.name);
1282
+ return `Copied the workflow as ${copy.name}, id ${copy.id}, with ${copy.stages.length} stages`
1283
+ + `${copy.ending === 'next-in-backlog' ? ', restarting on the next backlog item' : ''}. `
1284
+ + 'It has no runs, so edit_workflow can change its shape. It links the same stage documents '
1285
+ + 'as the original, so reword_stage on either rewords both.';
1286
+ },
1287
+ },
1288
+ // ── Steps: the work item's own record of a workflow run ──────────────────
1289
+ /* ═══ 38-panel3-steps: A PANEL RUN THAT FOLLOWS A WORKFLOW WRITES THE WORK
1290
+ ITEM'S STEPS. ═══ The Steps section, its tables, its live refresh and the
1291
+ board card's progress chip all existed; what did not exist was a writer on
1292
+ this side. v2's own step tools refuse anything but a v2 session and gate on
1293
+ v2's scope contract, which a panel run never has, so these four are served
1294
+ here over the neutral module.
1295
+
1296
+ THE GATE IS THE CARD'S ATTACHMENTS, exactly as `attach_image_artifact`
1297
+ gates a picture: the person pointed this conversation at a work item, and
1298
+ that row is the authority on what a run may write to. A stage anchored to
1299
+ a panel request rather than a work item is refused outright, because the
1300
+ Steps section that would show it lives on a work item (decision 1 on the
1301
+ work item's spec). Provenance is copied from the stage by the module, never
1302
+ chosen here.
1303
+
1304
+ WHO HOLDS WHAT. `start_workflow` is the owner's, like `get_workflow`: only
1305
+ level 2 sees the attachment list and reads the process. The three that
1306
+ write and read steps are levels 2 and 3, because a worker owns one stage
1307
+ and the owner may work a record-only stage itself. Level 1 launches and
1308
+ exits and holds none of them. */
1309
+ {
1310
+ name: 'start_workflow',
1311
+ levels: [2],
1312
+ description: 'Put the attached workflow\'s stages on the attached work item, once, before you send anybody '
1313
+ + 'for the first stage. The person watches progress on the work item, so a workflow that is '
1314
+ + 'not started there is a workflow they cannot see. Call it again and nothing is written twice.',
1315
+ input: {
1316
+ workflow_id: z.string().uuid().describe('The id printed beside the workflow in what was attached.'),
1317
+ work_item_id: z.string().uuid().describe('The id printed beside the work item in what was attached.'),
1318
+ },
1319
+ handler: async (caller, args) => {
1320
+ const { workflow_id, work_item_id } = args;
1321
+ const attached = await loadAttachments(caller.client, caller.cardId);
1322
+ if (!attached.some((item) => item.kind === 'workflow' && item.ref_id === workflow_id)) {
1323
+ throw new Error(`NOTHING WAS WRITTEN: this card does not carry workflow ${workflow_id}. Use the id printed `
1324
+ + 'beside the workflow in what was attached.');
1325
+ }
1326
+ await carriedWorkItem(caller, work_item_id, 'a workflow cannot be started on it', attached);
1327
+ const workflow = await readWorkflow(caller.client, workflow_id);
1328
+ const result = await startWorkflowOnItem(caller.client, work_item_id, workflow_id);
1329
+ return result.started
1330
+ ? `The ${result.stages} stages of ${workflow.name} are on work item ${work_item_id}. Work them in `
1331
+ + 'order: send somebody for stage 1, and nobody for stage 2 until list_steps shows stage 1\'s '
1332
+ + 'steps are all done.'
1333
+ : `${workflow.name} is already on work item ${work_item_id} (${result.stages} stages); nothing was `
1334
+ + 'written twice. Call list_steps to see where it is.';
1335
+ },
1336
+ },
1337
+ {
1338
+ name: 'list_steps',
1339
+ levels: [2, 3],
1340
+ description: 'The stages on a work item, the steps recorded inside each, and UP NEXT: the one thing that '
1341
+ + 'should happen next, computed by the product and read by the person too. "decompose" means '
1342
+ + 'the named stage has no steps yet and writing them is the next action; "step" names the one '
1343
+ + 'to work; "none" means nothing is waiting. Read it before you record or send anything.',
1344
+ input: {
1345
+ work_item_id: z.string().uuid().describe('The work item, from what was attached or your brief.'),
1346
+ },
1347
+ handler: async (caller, args) => {
1348
+ const { work_item_id } = args;
1349
+ await carriedWorkItem(caller, work_item_id, 'its steps cannot be read from here');
1350
+ const spine = await listSteps(caller.client, work_item_id);
1351
+ if (spine.stages.length === 0) {
1352
+ return `Work item ${work_item_id} has no stages. Nothing has been started on it.`;
1353
+ }
1354
+ return [
1355
+ `STAGES ${spine.stages.length} on work item ${work_item_id}`,
1356
+ ...spine.stages.flatMap((stage, i) => {
1357
+ const done = stage.steps.filter((step) => step.status === 'done').length;
1358
+ const summary = stage.steps.length === 0 ? 'no steps yet' : `${done} of ${stage.steps.length} done`;
1359
+ return [
1360
+ '',
1361
+ `${i + 1}. ${stage.title} id ${stage.id} ${stage.sourceKind} · ${stage.sourceLabel} ${summary}`,
1362
+ ...stage.steps.map((step, j) => ` ${i + 1}.${j + 1} ${step.title} [${step.status}] id ${step.id}\n`
1363
+ + ` next: ${step.nextAction}`),
1364
+ ];
1365
+ }),
1366
+ '',
1367
+ 'UP NEXT',
1368
+ JSON.stringify(spine.upNext, null, 2),
1369
+ ].join('\n');
1370
+ },
1371
+ },
1372
+ {
1373
+ name: 'create_step',
1374
+ levels: [2, 3],
1375
+ description: 'Record one step inside a stage of the work item, before you do it. A step is a resumable unit '
1376
+ + 'of work: its charter says why it exists and what done looks like, its next action is the one '
1377
+ + 'literal thing to do first, and its position orders it inside the stage. Record every step '
1378
+ + 'of a stage before working the first; the person reads these as the run moves. '
1379
+ + FIREWALL_WRITING_RULE,
1380
+ input: {
1381
+ stage_id: z.string().uuid().describe('The stage this step belongs to, from list_steps.'),
1382
+ title: z.string().min(1).describe('A few words, as the person will read them in the list.'),
1383
+ charter: z.string().min(1).describe('Why the step exists and how you would know it is done.'),
1384
+ next_action: z.string().min(1).describe('The one literal thing to do first. Repo-relative paths only.'),
1385
+ position: z.number().int().describe('Order inside the stage, from 1.'),
1386
+ },
1387
+ handler: async (caller, args) => {
1388
+ const { stage_id, title, charter, next_action, position } = args;
1389
+ const stage = await stageForWriting(caller, stage_id, 'a step cannot be recorded under it');
1390
+ const { id } = await createStep(caller.client, stage, { title, charter, nextAction: next_action, position });
1391
+ return `Recorded step ${position} "${title}" under ${stage.title}, id ${id}, status pending. Call `
1392
+ + 'update_step with status in_progress when you pick it up.';
1393
+ },
1394
+ },
1395
+ {
1396
+ name: 'update_step',
1397
+ levels: [2, 3],
1398
+ description: 'Change a step you or somebody else recorded: in_progress when you pick it up, done when it is '
1399
+ + 'finished, blocked with a next_action naming what you need when you cannot go on. Rewrite '
1400
+ + 'next_action as it changes. Only done lets the next step start. A step\'s stage, position and '
1401
+ + 'source cannot change; nothing is written when nothing would change. '
1402
+ + FIREWALL_WRITING_RULE,
1403
+ input: {
1404
+ step_id: z.string().uuid().describe('The step, from list_steps or create_step.'),
1405
+ title: z.string().optional(),
1406
+ charter: z.string().optional(),
1407
+ next_action: z.string().optional(),
1408
+ status: z.enum(STEP_STATUSES).optional(),
1409
+ },
1410
+ handler: async (caller, args) => {
1411
+ const { step_id, title, charter, next_action, status } = args;
1412
+ const step = await stepById(caller.client, step_id);
1413
+ if (!step) {
1414
+ throw new Error(`NOTHING WAS WRITTEN: there is no step ${step_id}, or it is not yours. list_steps names them.`);
1415
+ }
1416
+ await stageForWriting(caller, step.stageId, 'its step cannot be changed');
1417
+ const result = await updateStep(caller.client, step, { title, charter, nextAction: next_action, status });
1418
+ return `Updated ${result.updated.join(', ')} on "${title ?? step.title}"; status is now ${status ?? step.status}.`;
1419
+ },
1420
+ },
840
1421
  {
841
1422
  name: 'get_credential',
842
1423
  /* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
@@ -1316,7 +1897,8 @@ const TOOLS = [
1316
1897
  name: 'create_work_item',
1317
1898
  levels: [1],
1318
1899
  description: 'Create a work item on the board. Put what is to be done in the description: a name alone leaves '
1319
- + 'whoever picks it up guessing.',
1900
+ + 'whoever picks it up guessing. '
1901
+ + FIREWALL_WRITING_RULE,
1320
1902
  input: {
1321
1903
  project_id: z.string(),
1322
1904
  name: z.string().min(1),
@@ -1377,7 +1959,8 @@ const TOOLS = [
1377
1959
  work items, and an agent that cannot correct the item it just made in the
1378
1960
  same turn would have to make a second one beside it. */
1379
1961
  levels: ALL,
1380
- description: 'Change a work item. Pass only what changes; anything you leave out stays as it is.',
1962
+ description: 'Change a work item. Pass only what changes; anything you leave out stays as it is. '
1963
+ + FIREWALL_WRITING_RULE,
1381
1964
  input: {
1382
1965
  work_item_id: z.string(),
1383
1966
  name: z.string().min(1).optional(),
@@ -1387,7 +1970,7 @@ const TOOLS = [
1387
1970
  sprint_id: z.string().optional(),
1388
1971
  due_date: z.string().optional().describe('YYYY-MM-DD'),
1389
1972
  },
1390
- handler: async ({ client }, args) => {
1973
+ handler: async (caller, args) => {
1391
1974
  const { work_item_id, ...rest } = args;
1392
1975
  const changes = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
1393
1976
  /* AN UPDATE WITH NOTHING IN IT IS REFUSED, not quietly treated as a
@@ -1396,7 +1979,8 @@ const TOOLS = [
1396
1979
  if (Object.keys(changes).length === 0) {
1397
1980
  throw new Error('nothing to change: pass at least one field besides work_item_id');
1398
1981
  }
1399
- const item = await only(client.from('tasks').update(changes).eq('id', work_item_id).select('id, name'), 'update', `work item ${work_item_id}`);
1982
+ const item = await only(caller.client.from('tasks').update(changes).eq('id', work_item_id).select('id, name'), 'update', `work item ${work_item_id}`);
1983
+ await receiptOnce(caller, 'work_item', item.id, item.name);
1400
1984
  return `Updated ${item.name}: ${Object.keys(changes).join(', ')}.`;
1401
1985
  },
1402
1986
  },
@@ -1406,12 +1990,15 @@ const TOOLS = [
1406
1990
  // writes down what it learned so a later stage can read it.
1407
1991
  levels: ALL,
1408
1992
  description: 'Attach a document to a work item: a plan, a spec, an analysis. Artifacts are always on a work '
1409
- + 'item, so create the work item first if there is not one yet.',
1993
+ + 'item, so create the work item first if there is not one yet. `format` defaults to md. Use '
1994
+ + 'html for an interactive mock or wireframe, svg for a diagram, json for structured data. '
1995
+ + FIREWALL_WRITING_RULE,
1410
1996
  input: {
1411
1997
  work_item_id: z.string(),
1412
1998
  title: z.string().min(1),
1413
1999
  content: z.string().min(1),
1414
2000
  type: z.enum(['plan', 'spec', 'analysis', 'diagram', 'mock', 'wireframe', 'user_story']).optional(),
2001
+ format: z.enum(['md', 'html', 'json', 'svg']).optional(),
1415
2002
  },
1416
2003
  handler: async (caller, args) => {
1417
2004
  const a = args;
@@ -1420,7 +2007,7 @@ const TOOLS = [
1420
2007
  title: a.title,
1421
2008
  content: a.content,
1422
2009
  type: a.type ?? 'plan',
1423
- format: 'md',
2010
+ format: a.format ?? 'md',
1424
2011
  // `not null`, no default, and the table's own policy requires it to be
1425
2012
  // the caller. Stated rather than left to be defaulted somewhere else.
1426
2013
  created_by: caller.userId,
@@ -1434,18 +2021,27 @@ const TOOLS = [
1434
2021
  // Everyone's, for the same reason `create_artifact` is: an object that can
1435
2022
  // be made and never corrected is an object that goes stale in the record.
1436
2023
  levels: ALL,
1437
- description: 'Replace an artifact\'s body. The whole body is replaced, so send it complete.',
1438
- input: { artifact_id: z.string(), content: z.string().min(1), title: z.string().min(1).optional() },
1439
- handler: async ({ client, userId }, args) => {
2024
+ description: 'Replace an artifact\'s body. The whole body is replaced, so send it complete. '
2025
+ + 'Pass `format` with the complete body to change how it renders. '
2026
+ + FIREWALL_WRITING_RULE,
2027
+ input: {
2028
+ artifact_id: z.string(),
2029
+ content: z.string().min(1),
2030
+ title: z.string().min(1).optional(),
2031
+ format: z.enum(['md', 'html', 'json', 'svg']).optional(),
2032
+ },
2033
+ handler: async (caller, args) => {
1440
2034
  const a = args;
1441
- const artifact = await only(client.from('artifacts')
2035
+ const artifact = await only(caller.client.from('artifacts')
1442
2036
  .update({
1443
2037
  content: a.content,
1444
2038
  ...(a.title ? { title: a.title } : {}),
2039
+ ...(a.format ? { format: a.format } : {}),
1445
2040
  updated_at: new Date().toISOString(),
1446
- updated_by: userId,
2041
+ updated_by: caller.userId,
1447
2042
  })
1448
2043
  .eq('id', a.artifact_id).select('id, title'), 'update', `artifact ${a.artifact_id}`);
2044
+ await receiptOnce(caller, 'artifact', artifact.id, artifact.title);
1449
2045
  return `Updated artifact ${artifact.title ?? artifact.id}.`;
1450
2046
  },
1451
2047
  },
@@ -1471,7 +2067,8 @@ const TOOLS = [
1471
2067
  + 'convention agreed, how a subsystem actually fits together, why an approach was rejected. '
1472
2068
  + 'It adds a document and never changes or removes one, so do not use it to correct something '
1473
2069
  + '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.',
2070
+ + 'to do — that goes in an artifact on the work item. '
2071
+ + FIREWALL_WRITING_RULE,
1475
2072
  input: {
1476
2073
  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
2074
  type: z.enum(['instructions', 'architecture', 'design', 'conventions', 'other']).describe('Which kind of reference this is, matching the five the web app offers.'),
@@ -1543,7 +2140,8 @@ const TOOLS = [
1543
2140
  + 'what you are about to do and what they will get. Use it again if what you are doing changes '
1544
2141
  + 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
1545
2142
  + '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.',
2143
+ + 'not use it for a running commentary, and do not use it to ask anything. '
2144
+ + FIREWALL_WRITING_RULE,
1547
2145
  input: {
1548
2146
  message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
1549
2147
  + 'the plan and bring it to you."'),
@@ -1565,7 +2163,7 @@ const TOOLS = [
1565
2163
  ...(processToken === undefined ? {} : { p_process_token: processToken }),
1566
2164
  });
1567
2165
  if (error)
1568
- throw new Error(`could not say that on this card: ${error.message}`);
2166
+ throw new Error(`could not say that on this card: ${readableWriteError(error.message)}`);
1569
2167
  /* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
1570
2168
  SPOKE. ═══ Three reasons collapse to one sentence because the caller
1571
2169
  cannot tell them apart and all three mean the same thing to it: this
@@ -1579,6 +2177,272 @@ const TOOLS = [
1579
2177
  return 'Said. They can read it now. Carry on.';
1580
2178
  },
1581
2179
  },
2180
+ // ── Show them a picture ──────────────────────────────────────────────────
2181
+ //
2182
+ // ═══ LEVELS 2 AND 3, AND THEY REACH THE CARD BY DIFFERENT DOORS. ═══
2183
+ //
2184
+ // A level 2 owner SPEAKS: its picture goes into a turn through `panel3_say`,
2185
+ // which refuses any run that is not the one `panel3_cards.conversation_run_id`
2186
+ // names, so the fence is enforced in SQL rather than by this list.
2187
+ //
2188
+ // A level 3 worker CANNOT speak, and `panel3_say`'s own comment says why: it
2189
+ // exists so "a dispatched worker can never reach the person through it".
2190
+ // Widening that fence to carry a picture would change what a worker is. So a
2191
+ // worker writes the `panel3_images` row with NO turn, and the panel reads a
2192
+ // card's turn-less images as conversation events of their own.
2193
+ //
2194
+ // ═══ AN IMAGE DOES NOT NEED A TURN TO EXIST, WHICH IS WHAT MAKES THAT
2195
+ // POSSIBLE. ═══ `panel3_images.turn_id` is nullable for exactly this case, and
2196
+ // the two reads partition the table rather than overlapping it: the turn embed
2197
+ // takes the rows that have a `turn_id`, the card read takes the rows that do
2198
+ // not. Neither picture can render twice, by construction.
2199
+ {
2200
+ name: 'attach_image',
2201
+ levels: [2, 3],
2202
+ description: 'Show the person a picture on the card, with one or two sentences saying what they are '
2203
+ + 'looking at. Use it when a picture is the answer and words are not: a screen you have just '
2204
+ + 'changed, a diagram, a chart, something you were asked to look at. The picture must be a PNG '
2205
+ + 'file that already exists on this machine, and you give its absolute path. It does NOT end '
2206
+ + 'your turn: keep working after it. Do not use it for a picture they '
2207
+ + 'already have, and do not use it in place of the reply you finish with.',
2208
+ input: {
2209
+ path: z.string().min(1).describe('The absolute path of a PNG file on this machine. "/Users/me/work/search-screen.png"'),
2210
+ caption: z.string().min(1).max(PERSON_READS_LIMIT).describe('What they are looking at, in their words. "Here is the search screen with the new filter '
2211
+ + 'row." One or two sentences: it is read on a card three inches wide.'),
2212
+ },
2213
+ handler: async ({ client, userId, cardId, runId, level, processToken }, args) => {
2214
+ const { path, caption } = args;
2215
+ /* ═══ THE PICTURE IS VALIDATED BEFORE ANYTHING IS WRITTEN, AND ITS
2216
+ FAILURES ARE RE-THROWN PATH-FREE. ═══ `readPngScreenshot` interpolates
2217
+ the absolute path into every one of its messages, and a tool's thrown
2218
+ message is what the agent reads. An agent may quote its own error into a
2219
+ `say`, which lands in `panel3_turns.body` — a cloud row a browser
2220
+ renders. So the reason is kept and the path is not, in the shape
2221
+ `checkout.ts` established for exactly this. */
2222
+ let picture;
2223
+ try {
2224
+ picture = await readPngScreenshot(path);
2225
+ }
2226
+ catch (err) {
2227
+ /* ═══ THE REASON IS KEPT, THE TEXT IS NOT. ═══ Every message from below
2228
+ may name the path, and not only where `readPngScreenshot` interpolated
2229
+ it: an `ENOENT` from Node restates the path in its own words, inside
2230
+ quotes, and a `replace(path, …)` over the string would miss any such
2231
+ restatement that differs by a byte. So nothing composed down there is
2232
+ forwarded. What the agent needs is which of these went wrong, and each
2233
+ of these sentences is written here. */
2234
+ throw new Error(`NOTHING WAS WRITTEN: ${await whyNotAPicture(path)} Check the path and try once more.`);
2235
+ }
2236
+ /* ═══ THE ID IS MINTED HERE, BECAUSE THE STORAGE PATH IS DERIVED FROM IT.
2237
+ ═══ `panel3_images_storage_path_shape` requires the object key to equal
2238
+ `<user>/<card>/<id>.png`, so a server-defaulted id makes the key
2239
+ uncomputable before the upload. */
2240
+ const imageId = crypto.randomUUID();
2241
+ const storagePath = `${userId}/${cardId}/${imageId}.png`;
2242
+ const { error: uploadError } = await client.storage
2243
+ .from(PANEL3_IMAGES_BUCKET)
2244
+ .upload(storagePath, picture.bytes, { contentType: 'image/png', upsert: false });
2245
+ if (uploadError) {
2246
+ throw new Error(`NOTHING WAS WRITTEN: the picture could not be stored (${uploadError.message}).`);
2247
+ }
2248
+ /* ═══ A WORKER WRITES THE PICTURE AND NOTHING ELSE. ═══ It has no turn to
2249
+ hang it on and must not acquire one: `panel3_say` would refuse it, and
2250
+ it is right to. The row's null `turn_id` is what puts the picture on the
2251
+ card as an event of its own, and the caption travels with it as the
2252
+ row's own words rather than as a message the worker did not send. */
2253
+ if (level === 3) {
2254
+ /* ═══ AND IT ASKS FIRST WHETHER IT IS STILL RUNNING, WHICH LEVEL 2 GETS
2255
+ FOR FREE AND THIS DOES NOT. ═══ `panel3_say` refuses a stopped card
2256
+ and a superseded owner, so the level 2 path below cannot write a
2257
+ picture from a run that is over. A worker has no such gate: its row
2258
+ can be stamped `stopped` by the person's Stop button while its process
2259
+ lives on for another poll, and a bare insert here would put a picture
2260
+ on the card after they asked for it to end. */
2261
+ if (!(await stillRunning(client, runId))) {
2262
+ throw new Error('NOTHING WAS WRITTEN: THIS RUN HAS ENDED, so nothing more of yours reaches the card. Stop.');
2263
+ }
2264
+ const { error } = await client.from('panel3_images').insert({
2265
+ id: imageId,
2266
+ card_id: cardId,
2267
+ turn_id: null,
2268
+ caption,
2269
+ storage_path: storagePath,
2270
+ width: picture.width,
2271
+ height: picture.height,
2272
+ size_bytes: picture.sizeBytes,
2273
+ });
2274
+ if (error) {
2275
+ throw new Error(`NOTHING WAS WRITTEN: the picture could not be recorded (${error.message}).`);
2276
+ }
2277
+ return 'Shown on the card. Carry on with your own work: this did not send anybody a message.';
2278
+ }
2279
+ /* ═══ SPEAK FIRST, THEN WRITE THE ROW. ═══ NOT the web send path's order,
2280
+ and the difference is `panel3_say`'s: it returns NULL rather than
2281
+ raising when the card is stopped or this owner has been superseded. An
2282
+ image row written before that refusal would sit on the card with a null
2283
+ `turn_id`, which is now a WORKER's picture and would be rendered as one.
2284
+ Speaking first means a refusal happens before any row exists. The upload
2285
+ still precedes both, so a refusal leaves bytes with no row, which is
2286
+ invisible and is the honest way round: the alternative is a row pointing
2287
+ at bytes that were never stored. */
2288
+ const { data: turnId, error: sayError } = await client.rpc('panel3_say', {
2289
+ p_run_id: runId,
2290
+ p_body: caption,
2291
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
2292
+ });
2293
+ if (sayError)
2294
+ throw new Error(`could not show that on this card: ${sayError.message}`);
2295
+ if (turnId === null) {
2296
+ throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
2297
+ + 'or somebody else owns it now. Do not tell anybody you showed them anything.');
2298
+ }
2299
+ const { error: rowError } = await client.from('panel3_images').insert({
2300
+ id: imageId,
2301
+ card_id: cardId,
2302
+ turn_id: turnId,
2303
+ /* ═══ NULL WHEN THERE IS A TURN, BECAUSE THE TURN IS ALREADY THE WORDS.
2304
+ ═══ Storing the caption twice would give the panel two places to read
2305
+ the same sentence from and a way for them to disagree. */
2306
+ caption: null,
2307
+ storage_path: storagePath,
2308
+ width: picture.width,
2309
+ height: picture.height,
2310
+ size_bytes: picture.sizeBytes,
2311
+ });
2312
+ /* Truthful rather than tidy: the caption is already in front of the person,
2313
+ so the failure says what they can see and what they cannot. */
2314
+ if (rowError) {
2315
+ throw new Error(`Your words reached them but the picture did not (${rowError.message}). They are looking `
2316
+ + 'at a message with nothing under it, so say what it was meant to show.');
2317
+ }
2318
+ return 'Shown. They can see the picture and read what you said about it. Carry on.';
2319
+ },
2320
+ },
2321
+ // ── Keep a picture on the work item ──────────────────────────────────────
2322
+ //
2323
+ // ═══ A SECOND TOOL, NOT FIVE INPUTS ON THE FIRST. ═══ Anchoring a picture to
2324
+ // a work item needs a title, a platform and a target that `attach_image` has
2325
+ // no use for, and `attach_image` needs a caption this has no use for. Bolting
2326
+ // them together makes five inputs, three of them conditional on each other,
2327
+ // and a schema that cannot express its own rule. `attach_screenshot` is the
2328
+ // shape followed here: every input required, one write path.
2329
+ //
2330
+ // ═══ AND THE ORG-SCOPED BUCKET IS RIGHT HERE, HAVING BEEN WRONG NEXT DOOR.
2331
+ // ═══ `attach_image` writes to the private owner-scoped `panel3-images`,
2332
+ // because a conversation is one person's. An artifact belongs to a work item
2333
+ // the whole org can see, so it goes where every other artifact goes. Two
2334
+ // visibility models, two buckets, both deliberate.
2335
+ {
2336
+ name: 'attach_image_artifact',
2337
+ levels: [2, 3],
2338
+ description: 'Keep a picture on the work item, where anybody can find it later. Use it when the picture is '
2339
+ + 'part of what the work produced rather than something you are showing in passing: a screen '
2340
+ + 'you have built, a diagram of what you changed, the state something is in. The picture must '
2341
+ + 'be a PNG file that already exists on this machine, and you give its absolute path. It is '
2342
+ + 'kept for good and cannot be replaced, so attach it when it is right. Use attach_image '
2343
+ + 'instead for something the person only needs to see now.',
2344
+ input: {
2345
+ path: z.string().min(1).describe('The absolute path of a PNG file on this machine. "/Users/me/work/search-screen.png"'),
2346
+ title: z.string().min(1).max(200).describe('What this picture is, as a person scanning the work item would want it named. '
2347
+ + '"Search results with the new filter row."'),
2348
+ work_item_id: z.string().uuid().describe('The work item to keep it on. It must be one this card carries: get_my_brief_and_report '
2349
+ + 'names it.'),
2350
+ platform: z.enum(['web', 'ios', 'android']).describe('Where the picture was taken.'),
2351
+ target: z.string().min(1).max(200).describe('What was on screen, so somebody can find their way back to it. A URL, a route, or a screen '
2352
+ + 'name. "/projects/search" or "Search results".'),
2353
+ },
2354
+ handler: async (caller, args) => {
2355
+ const { path, title, work_item_id, platform, target } = args;
2356
+ const { client, userId } = caller;
2357
+ let picture;
2358
+ try {
2359
+ picture = await readPngScreenshot(path);
2360
+ }
2361
+ catch {
2362
+ /* Path-free, for `attach_image`'s reason: a tool's thrown message is what
2363
+ the agent reads, and an agent quotes its own errors into a `say`. */
2364
+ throw new Error(`NOTHING WAS WRITTEN: ${await whyNotAPicture(path)} Check the path and try once more.`);
2365
+ }
2366
+ /* ═══ THE WORK ITEM MUST BE ONE THIS CARD CARRIES. ═══ Validated once,
2367
+ here, at the boundary. Without it an agent that names the wrong id
2368
+ writes a picture onto an unrelated work item, which nothing downstream
2369
+ would catch: the guard checks the artifact's SHAPE, and RLS lets this
2370
+ person write to any item in their org. The card's attachments are what
2371
+ the PERSON pointed this conversation at, so they are the authority on
2372
+ what this run may anchor to. */
2373
+ const attached = await loadAttachments(client, caller.cardId);
2374
+ if (!attached.some((item) => item.kind === 'work_item' && item.ref_id === work_item_id)) {
2375
+ throw new Error(`NOTHING WAS WRITTEN: this card does not carry work item ${work_item_id}, so a picture `
2376
+ + 'cannot be kept on it. get_my_brief_and_report names the work item you are on. If the '
2377
+ + 'picture belongs to the conversation rather than to an item, use attach_image.');
2378
+ }
2379
+ /* ═══ THE DATABASE DERIVES THE STORAGE PATH AND REFUSES ANY OTHER. ═══
2380
+ `app.guard_agent_artifact_insert` computes
2381
+ `<org>/<project>/<task>/<artifact id>.png` and raises on a mismatch, so
2382
+ the org and project are resolved BEFORE the upload rather than after:
2383
+ the key cannot be computed without them, and a server-defaulted artifact
2384
+ id would make it uncomputable at all. */
2385
+ const item = await only(client.from('tasks').select('id, project_id').eq('id', work_item_id).is('archived_at', null), 'read', `work item ${work_item_id}`);
2386
+ if (item.project_id === null) {
2387
+ throw new Error(`NOTHING WAS WRITTEN: work item ${work_item_id} is in no project, and a picture is kept `
2388
+ + 'under its project. Move it into one first.');
2389
+ }
2390
+ const project = await only(client.from('projects').select('id, org_id').eq('id', item.project_id).is('archived_at', null), 'read', `the project work item ${work_item_id} is in`);
2391
+ /* ═══ CONTENT-ADDRESSED, NOT MINTED. ═══ The same picture under the same
2392
+ anchor and title yields the same id, so a call interrupted between the
2393
+ upload and the insert retries onto the same key and converges instead of
2394
+ leaving one orphaned object per attempt. A random uuid would make every
2395
+ retry a second artifact of the same picture. */
2396
+ const artifactId = screenshotArtifactId(item.id, title, platform, target, picture.bytes);
2397
+ const storagePath = `${project.org_id}/${item.project_id}/${item.id}/${artifactId}.png`;
2398
+ /* ═══ EXACTLY THESE FIVE KEYS, BECAUSE THE GUARD DEMANDS EXACTLY THESE
2399
+ FIVE. ═══ It rejects a `content` object with any other key set, so this
2400
+ is not a place to add a field. */
2401
+ const content = JSON.stringify({
2402
+ platform,
2403
+ target,
2404
+ width: picture.width,
2405
+ height: picture.height,
2406
+ size_bytes: picture.sizeBytes,
2407
+ });
2408
+ const { error: uploadError } = await client.storage
2409
+ .from('artifacts')
2410
+ .upload(storagePath, picture.bytes, { contentType: 'image/png', upsert: false });
2411
+ /* A duplicate key is the converging retry above, arriving: the bytes at
2412
+ that key are this picture's, because the key is derived from them. */
2413
+ if (uploadError && !/duplicate|already exists|resource exists/i.test(uploadError.message)) {
2414
+ throw new Error(`NOTHING WAS WRITTEN: the picture could not be stored (${uploadError.message}).`);
2415
+ }
2416
+ const { error: insertError } = await client.from('artifacts').insert({
2417
+ id: artifactId,
2418
+ task_id: item.id,
2419
+ type: 'image',
2420
+ format: 'png',
2421
+ title,
2422
+ content,
2423
+ storage_path: storagePath,
2424
+ created_by: userId,
2425
+ /* ═══ BOTH NULL, AND THE GUARD IS WHY. ═══ It refuses a non-null
2426
+ `from_agent` from an `authenticated` caller, and panel3's client is
2427
+ the person's own session. `create_artifact` leaves them null for the
2428
+ same reason. */
2429
+ from_agent: null,
2430
+ agent_run_id: null,
2431
+ });
2432
+ if (insertError) {
2433
+ /* A retry that finds its own artifact already there has converged, which
2434
+ is the point of the derived id: say so rather than reporting a
2435
+ failure that would make the agent attach a second copy. */
2436
+ if (/duplicate key|already exists/i.test(insertError.message)) {
2437
+ return `Already kept on the work item as "${title}". Nothing was written twice.`;
2438
+ }
2439
+ throw new Error(`the picture could not be kept on the work item (${insertError.message}).`);
2440
+ }
2441
+ await receipt(caller, 'artifact', artifactId, title);
2442
+ return (`Kept on the work item as "${title}", and it shows on the card. It cannot be replaced, so `
2443
+ + 'attach another if this one turns out to be wrong.');
2444
+ },
2445
+ },
1582
2446
  // ── Report ───────────────────────────────────────────────────────────────
1583
2447
  {
1584
2448
  name: 'report_activity',
@@ -1617,7 +2481,8 @@ const TOOLS = [
1617
2481
  description: 'Write down what is true NOW: what you have done, what you decided and why, and what is still '
1618
2482
  + 'open. It REPLACES your last report rather than adding to it, and it is what you are handed if '
1619
2483
  + 'you are respawned, so write it as the thing you would want to read to carry on. Write it as '
1620
- + 'you go, not only at the end.',
2484
+ + 'you go, not only at the end. '
2485
+ + FIREWALL_WRITING_RULE,
1621
2486
  input: { report: z.string().min(1) },
1622
2487
  handler: async ({ client, runId, processToken }, args) => {
1623
2488
  const { report } = args;
@@ -1767,7 +2632,7 @@ const TOOLS = [
1767
2632
  ...(processToken === undefined ? {} : { p_process_token: processToken }),
1768
2633
  });
1769
2634
  if (error)
1770
- throw new Error(`could not answer question ${question_id}: ${error.message}`);
2635
+ throw new Error(`could not answer question ${question_id}: ${readableWriteError(error.message)}`);
1771
2636
  if (data === null) {
1772
2637
  /* REFUSED, AND THE THREE REASONS ARE ONE SENTENCE because the caller
1773
2638
  cannot tell them apart and all three mean the same thing to it: this
@@ -1814,12 +2679,34 @@ const TOOLS = [
1814
2679
  + 'context: what to find out or change, and in which part of the codebase.'),
1815
2680
  boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
1816
2681
  work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
2682
+ work_name: z.string().optional().describe(`A few words, ${WORK_NAME_WORDS} at most, naming the WORK this conversation is doing, such `
2683
+ + 'as "Fix the sign-out checklist bug". Pass it when the conversation has NO work item '
2684
+ + 'attached: the conversation is called this from now on, and the branch the work goes on is '
2685
+ + 'cut from it, both at the moment you send somebody. Leave it out when a work item is '
2686
+ + 'attached, because that item is already the name.'),
1817
2687
  },
1818
2688
  handler: async (caller, args) => {
1819
- const { codebase_id, responsibility, boundary, work_item_id } = args;
2689
+ const { codebase_id, responsibility, boundary, work_item_id, work_name } = args;
1820
2690
  if (caller.level === 2 && !codebase_id) {
1821
2691
  throw new Error('A worker must be attached to a registered project codebase.');
1822
2692
  }
2693
+ /* ═══ THE NAME IS CHECKED BEFORE ANYTHING IS READ, WRITTEN OR STARTED.
2694
+ ═══ It is the one argument here that changes something the person is
2695
+ already looking at, so a refusal has to leave the card exactly as it
2696
+ was and nobody running. */
2697
+ const named = work_name === undefined ? null : work_name.trim();
2698
+ if (named !== null) {
2699
+ const words = named === '' ? [] : named.split(/\s+/);
2700
+ if (words.length === 0) {
2701
+ throw new Error('NOTHING WAS WRITTEN and nobody was started: work_name is blank. Name the work in a few '
2702
+ + 'words, or leave it out and the conversation keeps the words they opened with.');
2703
+ }
2704
+ if (words.length > WORK_NAME_WORDS) {
2705
+ throw new Error(`NOTHING WAS WRITTEN and nobody was started: that is ${words.length} words, and it is `
2706
+ + `read on one line and cut into a branch name. The limit is ${WORK_NAME_WORDS}. Name `
2707
+ + 'the work, do not describe it.');
2708
+ }
2709
+ }
1823
2710
  let codebase = null;
1824
2711
  if (codebase_id) {
1825
2712
  const projectId = await projectOfCard(caller);
@@ -1845,7 +2732,21 @@ const TOOLS = [
1845
2732
  three hops from level 1 still knows which item it is working, without
1846
2733
  asking. See `workBrief`'s own doc for why the two are different things
1847
2734
  carried the same way. */
1848
- const attachments = (await loadAttachments(caller.client, caller.cardId)).map(attachmentLine);
2735
+ const attached = await loadAttachments(caller.client, caller.cardId);
2736
+ /* ═══ THE CONVERSATION IS NAMED HERE OR IT IS NEVER NAMED. ═══ Sending
2737
+ somebody resolves the working copy, and resolving it cuts the branch
2738
+ from whatever the conversation is called at that moment and stamps it
2739
+ for good. So the name is written BEFORE the call below, and there is no
2740
+ second chance further on.
2741
+
2742
+ AN ATTACHED WORK ITEM IS ALREADY THE NAME, so nothing is written over
2743
+ it: the person chose that item and the conversation was called after it
2744
+ the moment they did. */
2745
+ if (named !== null && !attached.some((attachment) => attachment.kind === 'work_item')) {
2746
+ await rows(caller.client.from('panel3_cards').update({ title: named }).eq('id', caller.cardId)
2747
+ .select('id'), 'name', `card ${caller.cardId}`);
2748
+ }
2749
+ const attachments = attached.map(attachmentLine);
1849
2750
  const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
1850
2751
  id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
1851
2752
  }), codebase, caller.processToken);
@@ -1922,8 +2823,14 @@ const TOOLS = [
1922
2823
  * that produced nothing visible. So every `create_*` above writes its own
1923
2824
  * receipt, and `record_output` stays for what these tools did not create.
1924
2825
  *
1925
- * UPDATES DELIBERATELY WRITE NOTHING. A receipt answers "what did this card
1926
- * produce", and editing something twice did not produce it twice.
2826
+ * AN EDIT RECORDS THE OBJECT TOO, ONCE. A person who asks for an artifact to be
2827
+ * rewritten wants to open the thing that changed, and a card showing nothing
2828
+ * leaves them nowhere to go; but editing something twice did not produce it
2829
+ * twice. So the four editing tools go through `receiptOnce`, which calls
2830
+ * `receipt` only when this card has no receipt for that object yet. `receipt`
2831
+ * stays the only writer, and the check is deliberately NOT inside it: every
2832
+ * create site hands it an id made a moment ago, which cannot already be there,
2833
+ * and a read for each of them would buy nothing.
1927
2834
  *
1928
2835
  * ═══ AND AN ENDED RUN DOES NOT LEAVE RECEIPTS, WHICH IS THE ONE GUARD HERE
1929
2836
  * THAT IS NOT ATOMIC. ═══
@@ -1940,18 +2847,50 @@ const TOOLS = [
1940
2847
  * is a duplicate of work another agent may redo, not an attribution to something
1941
2848
  * that never happened, which is why it does not earn an RPC of its own today.
1942
2849
  */
1943
- async function receipt({ client, runId, cardId }, kind, refId, label) {
2850
+ /**
2851
+ * Whether this run may still put something on the person's card.
2852
+ *
2853
+ * ═══ ONE OWNER, BECAUSE THERE IS ONE QUESTION. ═══ A run's process outlives its
2854
+ * row: `panel3_stop_these` stamps `state` and `ended_at` across a whole subtree
2855
+ * in one statement and the processes are killed within a poll, so between those
2856
+ * two moments a child is alive and its row says it is finished. Every write that
2857
+ * reaches the card asks this before it lands, and it is the same predicate for
2858
+ * all of them, which is why it is a function rather than a clause repeated in
2859
+ * each of them.
2860
+ */
2861
+ async function stillRunning(client, runId) {
1944
2862
  const live = await rows(client.from('panel3_runs').select('id').eq('id', runId)
1945
2863
  .in('state', STILL_WRITING).is('ended_at', null), 'check', `whether run ${runId} is still running`);
1946
- if (live.length === 0) {
1947
- /* IT NAMES WHAT WAS MADE. The product row exists — it was created before
1948
- this was reached so an error that only said "refused" would leave the
1949
- agent unable to say whether the thing is there. */
1950
- throw new Error(`the ${kind} ${refId} was created, but THIS RUN HAS ENDED so no receipt was written for it and `
1951
- + 'its card has been handed to another agent. Say what you made and stop.');
2864
+ return live.length > 0;
2865
+ }
2866
+ async function receipt({ client, runId, cardId }, kind, refId, label) {
2867
+ if (!(await stillRunning(client, runId))) {
2868
+ /* IT NAMES WHAT LANDED. The product row exists it was written before this
2869
+ was reached so an error that only said "refused" would leave the agent
2870
+ unable to say whether the thing is there. IT COVERS BOTH CALLERS: the
2871
+ creating tools and, through `receiptOnce`, the editing ones, so it must
2872
+ not tell an agent it created what it changed. */
2873
+ throw new Error(`the ${kind} ${refId} was written, but THIS RUN HAS ENDED so no receipt was written for it and `
2874
+ + 'its card has been handed to another agent. Say what you did and stop.');
1952
2875
  }
1953
2876
  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`);
1954
2877
  }
2878
+ /**
2879
+ * The same receipt, unless this card already carries one for that object.
2880
+ *
2881
+ * ═══ THIS IS NOT A RACE GUARD, AND IS NOT CLAIMED AS ONE. ═══ `panel3_outputs`
2882
+ * has no unique key over card, kind and ref, so two edits of the same object
2883
+ * landing in the same instant can both read nothing and both write. What that
2884
+ * costs is one repeated card and nothing worse, which is why it does not earn a
2885
+ * constraint or an RPC today.
2886
+ */
2887
+ async function receiptOnce(caller, kind, refId, label) {
2888
+ const already = await rows(caller.client.from('panel3_outputs').select('id')
2889
+ .eq('card_id', caller.cardId).eq('kind', kind).eq('ref_id', refId), 'check', `whether the ${kind} ${refId} is already on the card`);
2890
+ if (already.length > 0)
2891
+ return;
2892
+ await receipt(caller, kind, refId, label);
2893
+ }
1955
2894
  /** The names one level is served, in the order they are registered. Exported for
1956
2895
  * the same reason `cs show` exists: a rule nobody can print is a rule nobody
1957
2896
  * can check. */