@ctrl-spc/cs 0.7.2 → 0.7.4

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,26 @@ 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
+ /* ═══ A THIRD GENERATION-NEUTRAL MODULE, AND IT IS A DECISION. ═══
131
+ conventions.md enumerates what `panel3/` may reach outside itself and says
132
+ "and nothing else", so this line amends that enumeration rather than slipping
133
+ past it, and `panel3-isolation.contract.test.mjs` fails until the list agrees.
134
+ `screenshots.ts` names no `cliv2_` table and no v1 or v2 concept: it is a PNG
135
+ parser and a byte bound. The alternative was copying 215 lines of chunk
136
+ walking into `panel3/` to avoid one entry in a list, which is the duplication
137
+ the guide forbids, and it would have left two answers to "is this a PNG". */
138
+ import { readPngScreenshot, screenshotArtifactId, MAX_SCREENSHOT_BYTES } from '../screenshots.js';
139
+ import { lstat } from 'node:fs/promises';
140
+ import { isAbsolute } from 'node:path';
125
141
  import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, setMainBranch, withAskContent, } from './show.js';
126
142
  // ---------------------------------------------------------------------------
127
143
  // READING AND WRITING. Every query goes through the same guard every other v3
@@ -218,6 +234,24 @@ const secretScope = ({ runId, processToken }) => processToken === undefined ? ru
218
234
  * child text is ever inherited by what the person reads.
219
235
  */
220
236
  const PERSON_READS_LIMIT = 700;
237
+ /**
238
+ * ═══ HOW MANY WORDS MAY NAME THE WORK ON A CARD, AND WHY IT IS SIX. ═══
239
+ *
240
+ * The name is read on one line three inches wide, and the branch for the work
241
+ * is cut from the same words, so both want a name rather than a sentence. Six
242
+ * is room for one: "Fix the sign-out checklist bug" is five.
243
+ *
244
+ * ═══ AND IT REFUSES RATHER THAN CUTTING THE NAME DOWN. ═══ `say.ts` already
245
+ * owns the one rule for turning a long line into a title. A second cut here
246
+ * would be a second answer to that question inside one package, and it would be
247
+ * the answer nobody saw being given: half a name is written, the card carries
248
+ * it, and the branch is cut from it. Refusing puts it back to the one caller
249
+ * that can still say it shorter, and it comes back through `buildServer`'s
250
+ * catch as a correctable failure rather than a protocol error, which is the
251
+ * same reason `PERSON_READS_LIMIT` above is checked in a handler and not on the
252
+ * schema.
253
+ */
254
+ const WORK_NAME_WORDS = 6;
221
255
  async function asked(caller, args, askPerson) {
222
256
  const { client, runId, processToken } = caller;
223
257
  const { question, category, context, answer_mode, options, question_id, work_item_id, related_artifact_id, } = args;
@@ -434,6 +468,14 @@ function elapsed(startedAt, endedAt, resumedAt) {
434
468
  * enforces it. Both copies are reading the same authority: the bucket the
435
469
  * migration created (20260722180000_skills.sql). */
436
470
  const SKILL_BUNDLES_BUCKET = 'skill-bundles';
471
+ /** Owner-scoped and private, the one `20260924090000_panel3_images.sql` created.
472
+ * Never the org-scoped `artifacts` bucket: a `panel3_images` row is readable by
473
+ * its owner alone, and a row that private pointing at a PNG the whole org can
474
+ * read would make the privacy a claim rather than a fact. */
475
+ /* Exported because the daemon downloads a card's pictures into the run's cwd
476
+ before it builds the prompt (33 Slice 6), and a second literal is a second
477
+ place for the bucket to be renamed wrong. */
478
+ export const PANEL3_IMAGES_BUCKET = 'panel3-images';
437
479
  /**
438
480
  * ═══ A REMOVED SKILL IS NOT A MISSING ONE, AND AN AGENT TOLD OTHERWISE SAYS
439
481
  * THE WRONG THING TO THE PERSON. ═══
@@ -524,6 +566,47 @@ async function skillDocument(client, storagePath, what) {
524
566
  }
525
567
  return await data.text();
526
568
  }
569
+ /**
570
+ * ═══ WHY A FILE COULD NOT BE SHOWN, SAID WITHOUT NAMING IT. ═══
571
+ *
572
+ * `attach_image` is handed an absolute local path, and every failure below it
573
+ * carries that path in its message: `readPngScreenshot` interpolates it four
574
+ * times, and an `ENOENT` from Node restates it again in its own wording. A
575
+ * tool's thrown message is what the AGENT reads, and an agent that has just
576
+ * failed quotes its own error into a `say`, which lands in `panel3_turns.body` —
577
+ * a cloud column a browser renders. AGENTS.md's rule is that no absolute path
578
+ * reaches the cloud, so the leak has to be closed here, two hops before it.
579
+ *
580
+ * NOT BY EDITING THE MESSAGE FROM BELOW. Stripping the path out of a string that
581
+ * already contains it is a guess about wording that holds until some layer
582
+ * phrases it differently — and the layer most likely to is the operating system,
583
+ * which owes this codebase nothing. So the reason is re-derived here and the text
584
+ * from below is discarded entirely.
585
+ *
586
+ * The agent still gets an actionable sentence, which is the whole point of
587
+ * telling it anything: the four cases it can do something about are separated,
588
+ * and everything else says the one true thing left.
589
+ */
590
+ async function whyNotAPicture(localPath) {
591
+ if (!isAbsolute(localPath)) {
592
+ return 'that is not an absolute path, and this needs one.';
593
+ }
594
+ let stat;
595
+ try {
596
+ stat = await lstat(localPath);
597
+ }
598
+ catch {
599
+ return 'there is no file at that path on this machine.';
600
+ }
601
+ if (!stat.isFile())
602
+ return 'that path is a folder or a link, not a file.';
603
+ if (stat.size === 0)
604
+ return 'that file is empty.';
605
+ if (stat.size > MAX_SCREENSHOT_BYTES) {
606
+ return `that file is larger than the ${MAX_SCREENSHOT_BYTES / (1024 * 1024)} MB limit.`;
607
+ }
608
+ return 'that file could not be read as a PNG. It must be a real, uncompressed-format PNG.';
609
+ }
527
610
  /**
528
611
  * ═══ THE PATH A SKILL NAMED IS NOT TRUSTED. ═══
529
612
  *
@@ -585,6 +668,37 @@ const CREDENTIAL_INSTRUCTION = 'This is a SECRET, and it is yours to USE. Make t
585
668
  + 'itself into your report, your answer, a progress line, a question, a comment, an artifact or a '
586
669
  + 'file you commit, because all of those are shared and permanent. Name the credential you used '
587
670
  + 'instead. Never refuse the work to avoid touching the value.';
671
+ /**
672
+ * The exit rules whose refusal carries a NUMBER, checked in the 1-based
673
+ * numbering the agent sent and returned in the 0-based one the RPC takes.
674
+ *
675
+ * SHARED BY `create_workflow` AND `edit_workflow`, because they are the same
676
+ * rules over the same list: `cliv2_build_workflow` and `cliv2_edit_workflow`
677
+ * both count stages from 0, a numbering neither tool ever showed the agent, so
678
+ * both refuse here in the numbering the agent used.
679
+ */
680
+ function zeroBasedExits(exits, stageCount) {
681
+ for (const exit of exits) {
682
+ const where = `exit from stage ${exit.from_stage} to stage ${exit.to_stage}`;
683
+ const inRange = (n) => n >= 1 && n <= stageCount;
684
+ if (!inRange(exit.from_stage) || !inRange(exit.to_stage)) {
685
+ throw new Error(`${where} names a stage this workflow does not have (stages are 1..${stageCount}). `
686
+ + 'Nothing was written.');
687
+ }
688
+ if (exit.to_stage > exit.from_stage) {
689
+ throw new Error(`${where} jumps forward. to_stage must be earlier than from_stage, or the same stage. `
690
+ + 'Nothing was written.');
691
+ }
692
+ if (exit.condition.trim() === '') {
693
+ throw new Error(`the exit from stage ${exit.from_stage} needs a condition. Nothing was written.`);
694
+ }
695
+ }
696
+ return exits.map((exit) => ({
697
+ from: exit.from_stage - 1,
698
+ to: exit.to_stage - 1,
699
+ condition: exit.condition.trim(),
700
+ }));
701
+ }
588
702
  // ---------------------------------------------------------------------------
589
703
  // THE TOOLS.
590
704
  //
@@ -837,6 +951,308 @@ const TOOLS = [
837
951
  ].join('\n');
838
952
  },
839
953
  },
954
+ {
955
+ name: 'create_workflow',
956
+ /* ═══ LEVEL 2 ALONE, FOR THE REASON `get_workflow` IS. ═══ The owner is the
957
+ agent talking to the person, so it is the one that hears a process
958
+ described and can write it down as it was described. A launcher writes one
959
+ line and exits; a worker owns one piece of somebody else's process. */
960
+ levels: [2],
961
+ description: "Write a new workflow into this organisation's library: a name, what it is about, and its "
962
+ + 'stages in order, each with a one-line description and the markdown document an agent '
963
+ + 'following it reads. Stages are numbered from 1, the way get_workflow prints them. exits '
964
+ + 'are the conditional ways back: from_stage N, if condition, go back to to_stage M, where M '
965
+ + 'is earlier than N or the same stage; a forward jump is refused and nothing is written. '
966
+ + 'Takes no organisation or project: the workflow goes into the organisation this '
967
+ + "conversation's project belongs to. It appears on /workflows at once. Never put an "
968
+ + 'absolute local path in any field.',
969
+ input: {
970
+ name: z.string(),
971
+ description: z.string().optional(),
972
+ stages: z.array(z.object({
973
+ name: z.string(),
974
+ description: z.string().optional(),
975
+ body: z.string().describe('the whole stage document, in markdown'),
976
+ })),
977
+ exits: z.array(z.object({
978
+ from_stage: z.number().int(),
979
+ to_stage: z.number().int(),
980
+ condition: z.string(),
981
+ })).optional(),
982
+ },
983
+ handler: async (caller, args) => {
984
+ const { name, description, stages, exits } = args;
985
+ /* ═══ CHECKED HERE IN THE NUMBERING THE AGENT SENT. ═══
986
+ `cliv2_build_workflow` enforces every one of these itself, and its
987
+ messages count stages from 0, a numbering this tool never showed the
988
+ agent, which would have it correcting the wrong stage. So the rules
989
+ whose refusal carries a NUMBER are pre-checked and worded in 1-based
990
+ terms; everything else (the grant, "at least one stage", anything
991
+ unforeseen) is left to the RPC and passed back as it came. */
992
+ const workflowName = name.trim();
993
+ if (workflowName === '')
994
+ throw new Error('a workflow needs a name. Nothing was written.');
995
+ /* THE RPC REFUSES THIS TOO, and its message carries no number, so it
996
+ would ordinarily be left to it. It is checked here because the exit
997
+ range below would otherwise read "stages are 1..0", which is not a
998
+ range and is not the thing that is actually wrong. */
999
+ if (stages.length === 0) {
1000
+ throw new Error('a workflow needs at least one stage. Nothing was written.');
1001
+ }
1002
+ stages.forEach((stage, i) => {
1003
+ if (stage.name.trim() === '')
1004
+ throw new Error(`stage ${i + 1} needs a name. Nothing was written.`);
1005
+ });
1006
+ const wanted = zeroBasedExits(exits ?? [], stages.length);
1007
+ /* ═══ THE ORGANISATION IS THE CONVERSATION'S, NOT THE AGENT'S. ═══ There
1008
+ is no org argument to get wrong, and a record-only card genuinely has
1009
+ none, which is said rather than guessed at. */
1010
+ const projectId = await projectOfCard(caller);
1011
+ if (projectId === null) {
1012
+ throw new Error('this conversation has no project, so there is no organisation to write the workflow into.');
1013
+ }
1014
+ 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})`);
1015
+ const id = await buildWorkflow(caller.client, {
1016
+ orgId: project.org_id,
1017
+ name: workflowName,
1018
+ description: description?.trim() ?? '',
1019
+ stages: stages.map((stage) => ({
1020
+ name: stage.name.trim(),
1021
+ description: stage.description?.trim() ?? '',
1022
+ /* THE BODY IS NOT TRIMMED. It is a markdown document, and its leading
1023
+ blank lines are the author's own. */
1024
+ body: stage.body,
1025
+ })),
1026
+ exits: wanted,
1027
+ fromAgent: harness(),
1028
+ });
1029
+ await receipt(caller, 'workflow', id, workflowName);
1030
+ return `Created workflow ${workflowName}, id ${id}, with ${stages.length} stages. `
1031
+ + 'It is now in the library.';
1032
+ },
1033
+ },
1034
+ {
1035
+ name: 'reword_stage',
1036
+ /* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ The owner is the
1037
+ agent hearing the person say how a stage should read. */
1038
+ levels: [2],
1039
+ description: 'Reword one stage of the workflow attached to this conversation: its name, its one-line '
1040
+ + 'description, or its whole markdown document. Name the stage by the number get_workflow '
1041
+ + 'prints. Send only the fields to change; a field left out is left as it is. A stage is one '
1042
+ + 'shared library row, so if other workflows hold it they change too, and the reply names them. '
1043
+ + 'Never put an absolute local path in any field.',
1044
+ input: {
1045
+ workflow_id: z.string(),
1046
+ stage: z.number().int().describe('the stage number, counted from 1 as get_workflow prints it'),
1047
+ name: z.string().optional(),
1048
+ description: z.string().optional(),
1049
+ body: z.string().optional().describe('the whole stage document, in markdown; replaces the current one'),
1050
+ },
1051
+ handler: async (caller, args) => {
1052
+ const { workflow_id, stage, name, description, body } = args;
1053
+ if (name === undefined && description === undefined && body === undefined) {
1054
+ throw new Error('send at least one of name, description or body. Nothing was written.');
1055
+ }
1056
+ const stageName = name?.trim();
1057
+ if (stageName === '')
1058
+ throw new Error('a stage needs a name. Nothing was written.');
1059
+ const workflow = await readWorkflow(caller.client, workflow_id);
1060
+ if (workflow.archivedAt !== null) {
1061
+ throw new Error(`workflow ${workflow.name} is archived. Nothing was written.`);
1062
+ }
1063
+ /* ═══ THE NUMBER THE AGENT SAW, RESOLVED THROUGH THE READ THAT PRINTED IT.
1064
+ ═══ No v3 tool at any level takes a stage uuid, so the numbering is
1065
+ `get_workflow`'s presentation and the mapping stays here. `stage: 0`
1066
+ indexes -1, is undefined, and is refused with the range. */
1067
+ const target = workflow.stages[stage - 1];
1068
+ if (!target) {
1069
+ throw new Error(`stage ${stage} is not a stage of ${workflow.name} (stages are 1..${workflow.stages.length}). `
1070
+ + 'Nothing was written.');
1071
+ }
1072
+ const { alsoIn } = await rewordStage(caller.client, {
1073
+ workflowId: workflow.id,
1074
+ stageId: target.id,
1075
+ name: stageName,
1076
+ /* A WHITESPACE-ONLY DESCRIPTION CLEARS IT. An empty description is a
1077
+ real state (the column defaults to one); an empty name is not. */
1078
+ description: description?.trim(),
1079
+ /* THE BODY IS NOT TRIMMED, for `create_workflow`'s reason. */
1080
+ body,
1081
+ });
1082
+ /* THE WORKFLOW IS WHAT THE PERSON OPENS, because no v3 surface shows a
1083
+ stage on its own. */
1084
+ await receiptOnce(caller, 'workflow', workflow.id, workflow.name);
1085
+ const changed = [
1086
+ name !== undefined && 'name',
1087
+ description !== undefined && 'description',
1088
+ body !== undefined && 'body',
1089
+ ].filter(Boolean).join(', ');
1090
+ return [
1091
+ `Reworded stage ${stage} ${stageName ?? target.name} of ${workflow.name} (${changed}).`,
1092
+ alsoIn.length === 0
1093
+ ? 'It is in no other live workflow.'
1094
+ : `That stage is shared, so these workflows changed too: ${alsoIn.join(', ')}.`,
1095
+ /* ═══ A RENAME DOES NOT REACH A PLAN ALREADY RUNNING. ═══
1096
+ `cliv2_start_workflow` copies the stage NAME onto the work item's plan
1097
+ and keeps only a reference for the document, so a body change flows to
1098
+ a run in flight and a name change does not. Said when the name
1099
+ actually changed; naming the runs is not done, because the read that
1100
+ exists, `cliv2_workflow_runs`, names every run of a workflow rather
1101
+ than the runs carrying this stage. */
1102
+ ...(stageName === undefined || stageName === target.name ? [] : [
1103
+ 'Work items already running a workflow that holds it keep the old stage name on their plan.',
1104
+ ]),
1105
+ ].join(' ');
1106
+ },
1107
+ },
1108
+ {
1109
+ name: 'edit_workflow',
1110
+ /* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ The owner is the
1111
+ agent hearing the person say the process has changed shape. */
1112
+ levels: [2],
1113
+ description: 'Change the shape of the workflow attached to this conversation: its name, what it is about, '
1114
+ + 'which stages it has and in what order, and the conditional ways back. stages is the WHOLE '
1115
+ + 'new list, in order: a stage left out is removed from the workflow (it stays in the library). '
1116
+ + 'Each entry is either { keep: N } to keep stage N as get_workflow prints it, or { name, '
1117
+ + 'description, body } to write a new stage. exits is the WHOLE new list of conditional ways '
1118
+ + 'back too, and may be empty: from_stage and to_stage count from 1 in the NEW list, to_stage '
1119
+ + 'must be earlier than from_stage or the same, and exits off one stage are tried in the order '
1120
+ + 'sent. name and description left out are left as they are; an empty description clears it. '
1121
+ + 'To change the wording of an '
1122
+ + 'existing stage use reword_stage. Refused, with nothing written, while any work item is '
1123
+ + 'running the workflow. Never put an absolute local path in any field.',
1124
+ input: {
1125
+ workflow_id: z.string(),
1126
+ name: z.string().optional(),
1127
+ description: z.string().optional(),
1128
+ stages: z.array(z.union([
1129
+ z.object({ keep: z.number().int().describe('the stage number as get_workflow prints it') }).strict(),
1130
+ z.object({
1131
+ name: z.string(),
1132
+ description: z.string().optional(),
1133
+ body: z.string().describe('the whole stage document, in markdown'),
1134
+ }).strict(),
1135
+ ])),
1136
+ exits: z.array(z.object({
1137
+ from_stage: z.number().int(),
1138
+ to_stage: z.number().int(),
1139
+ condition: z.string(),
1140
+ })).optional().describe('the whole new list of conditional ways back; send [] for none'),
1141
+ },
1142
+ handler: async (caller, args) => {
1143
+ const { workflow_id, name, description, stages, exits } = args;
1144
+ const workflowName = name?.trim();
1145
+ if (workflowName === '')
1146
+ throw new Error('a workflow needs a name. Nothing was written.');
1147
+ if (stages.length === 0) {
1148
+ throw new Error('a workflow needs at least one stage, and stages is its WHOLE new list. Nothing was written.');
1149
+ }
1150
+ /* ═══ AN ABSENT `exits` IS REFUSED IN WORDS, NEVER DEFAULTED TO `[]`. ═══
1151
+ The RPC REPLACES the exits from what it is sent, so treating "left out"
1152
+ as "none" would silently straighten a loop a person drew, on a call the
1153
+ agent made to add one stage. */
1154
+ if (exits === undefined) {
1155
+ throw new Error('exits is the whole new list of conditional ways back and may be empty; send [] for none. '
1156
+ + 'Nothing was written.');
1157
+ }
1158
+ const workflow = await readWorkflow(caller.client, workflow_id);
1159
+ if (workflow.archivedAt !== null) {
1160
+ throw new Error(`workflow ${workflow.name} is archived. Nothing was written.`);
1161
+ }
1162
+ /* ═══ THE NUMBER THE AGENT SAW, RESOLVED THROUGH THE READ THAT PRINTED IT,
1163
+ ═══ as `reword_stage` does it: no v3 tool at any level takes a stage
1164
+ uuid, so `{ keep: N }` is `get_workflow`'s numbering and the mapping
1165
+ stays here. */
1166
+ const kept = new Set();
1167
+ const entries = stages.map((stage, i) => {
1168
+ if ('keep' in stage) {
1169
+ const target = workflow.stages[stage.keep - 1];
1170
+ if (!target) {
1171
+ throw new Error(`keep ${stage.keep} names a stage ${workflow.name} does not have (stages are `
1172
+ + `1..${workflow.stages.length}). Nothing was written.`);
1173
+ }
1174
+ if (kept.has(stage.keep)) {
1175
+ throw new Error(`stage ${stage.keep} is listed twice. A stage may appear once. Nothing was written.`);
1176
+ }
1177
+ kept.add(stage.keep);
1178
+ return { stage_id: target.id };
1179
+ }
1180
+ if (stage.name.trim() === '') {
1181
+ throw new Error(`the new stage at position ${i + 1} needs a name. Nothing was written.`);
1182
+ }
1183
+ /* THE BODY IS NOT TRIMMED, for `create_workflow`'s reason. */
1184
+ return { name: stage.name.trim(), description: stage.description?.trim() ?? '', body: stage.body };
1185
+ });
1186
+ const count = await editWorkflow(caller.client, {
1187
+ workflowId: workflow.id,
1188
+ name: workflowName ?? null,
1189
+ /* A WHITESPACE-ONLY DESCRIPTION CLEARS IT, as `reword_stage`'s does;
1190
+ left out, it is left as it is, which is what null means to the RPC. */
1191
+ description: description?.trim() ?? null,
1192
+ stages: entries,
1193
+ exits: zeroBasedExits(exits, entries.length),
1194
+ /* THE BRANCHES GO BACK AS THEY CAME. The RPC rewrites them from what it
1195
+ is sent and nothing in v3 sets one, so sending anything else would
1196
+ delete what a person set on `/workflows`. */
1197
+ branches: workflow.branches,
1198
+ fromAgent: harness(),
1199
+ });
1200
+ /* THE LABEL IS THE NAME THAT SURVIVED THE EDIT, which is the reply's own:
1201
+ `name` is optional and is undefined whenever only the stages changed,
1202
+ so the raw argument would put an empty label on the card. */
1203
+ await receiptOnce(caller, 'workflow', workflow.id, workflowName ?? workflow.name);
1204
+ /* THE COUNT IN THE REPLY IS THE LIST'S, NOT THE RPC'S. They agree when
1205
+ the RPC did its job, and the RPC's is checked for being a number
1206
+ above; the sentence claims what was sent, so it counts what was sent. */
1207
+ return `Edited workflow ${workflowName ?? workflow.name}: it now has exactly the ${entries.length} stages you sent, `
1208
+ + `in that order, and ${exits.length === 0 ? 'no exits back to an earlier stage' : 'exactly the exits back you sent'}. `
1209
+ + `The database counts ${count}.`;
1210
+ },
1211
+ },
1212
+ {
1213
+ name: 'duplicate_workflow',
1214
+ /* ═══ LEVEL 2 ALONE, FOR `create_workflow`'s REASON. ═══ It is the way past
1215
+ the refusal `edit_workflow` gives, so it belongs to the level that was
1216
+ refused. */
1217
+ levels: [2],
1218
+ description: 'Copy a workflow into a new one with no runs: the same stages in the same order, the same '
1219
+ + 'exits, branch conditions and ending, under a new name (the original name plus " (copy)" '
1220
+ + 'unless you give one). This is the way past a workflow edit_workflow refused because a work '
1221
+ + 'item is running it: the original keeps its runs, the copy takes your change. Never put an '
1222
+ + 'absolute local path in the name.',
1223
+ input: {
1224
+ workflow_id: z.string(),
1225
+ name: z.string().optional().describe('the name for the copy; leave out for the original name plus " (copy)"'),
1226
+ },
1227
+ handler: async (caller, args) => {
1228
+ const { workflow_id, name } = args;
1229
+ const copyName = name?.trim();
1230
+ if (copyName === '') {
1231
+ throw new Error('a workflow needs a name; leave name out to take the original name plus " (copy)". '
1232
+ + 'Nothing was written.');
1233
+ }
1234
+ /* ═══ NOTHING IS READ FIRST. ═══ The RPC refuses an archived source, one
1235
+ that is not the person's and a stageless one in its own words, which are
1236
+ the words v2 agents already see; a pre-check here would be a second
1237
+ owner of those rules saying them differently. `create_workflow`'s
1238
+ pre-checks exist only because the RPC's numbers are 0-based, and nothing
1239
+ here carries a number. */
1240
+ const id = await duplicateWorkflow(caller.client, {
1241
+ workflowId: workflow_id,
1242
+ name: copyName ?? null,
1243
+ fromAgent: harness(),
1244
+ });
1245
+ /* ═══ READ BACK, NOT GUESSED. ═══ The RPC chose the name (the default is
1246
+ its rule, not this tool's) and copied the links, so the receipt label
1247
+ and the reply say what is stored. */
1248
+ const copy = await readWorkflow(caller.client, id);
1249
+ await receipt(caller, 'workflow', copy.id, copy.name);
1250
+ return `Copied the workflow as ${copy.name}, id ${copy.id}, with ${copy.stages.length} stages`
1251
+ + `${copy.ending === 'next-in-backlog' ? ', restarting on the next backlog item' : ''}. `
1252
+ + 'It has no runs, so edit_workflow can change its shape. It links the same stage documents '
1253
+ + 'as the original, so reword_stage on either rewords both.';
1254
+ },
1255
+ },
840
1256
  {
841
1257
  name: 'get_credential',
842
1258
  /* ═══ LEVELS 2 AND 3, AND THAT IS A CORRECTION TO ux.md's OWN TABLE. ═══
@@ -1316,7 +1732,8 @@ const TOOLS = [
1316
1732
  name: 'create_work_item',
1317
1733
  levels: [1],
1318
1734
  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.',
1735
+ + 'whoever picks it up guessing. '
1736
+ + FIREWALL_WRITING_RULE,
1320
1737
  input: {
1321
1738
  project_id: z.string(),
1322
1739
  name: z.string().min(1),
@@ -1377,7 +1794,8 @@ const TOOLS = [
1377
1794
  work items, and an agent that cannot correct the item it just made in the
1378
1795
  same turn would have to make a second one beside it. */
1379
1796
  levels: ALL,
1380
- description: 'Change a work item. Pass only what changes; anything you leave out stays as it is.',
1797
+ description: 'Change a work item. Pass only what changes; anything you leave out stays as it is. '
1798
+ + FIREWALL_WRITING_RULE,
1381
1799
  input: {
1382
1800
  work_item_id: z.string(),
1383
1801
  name: z.string().min(1).optional(),
@@ -1387,7 +1805,7 @@ const TOOLS = [
1387
1805
  sprint_id: z.string().optional(),
1388
1806
  due_date: z.string().optional().describe('YYYY-MM-DD'),
1389
1807
  },
1390
- handler: async ({ client }, args) => {
1808
+ handler: async (caller, args) => {
1391
1809
  const { work_item_id, ...rest } = args;
1392
1810
  const changes = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
1393
1811
  /* AN UPDATE WITH NOTHING IN IT IS REFUSED, not quietly treated as a
@@ -1396,7 +1814,8 @@ const TOOLS = [
1396
1814
  if (Object.keys(changes).length === 0) {
1397
1815
  throw new Error('nothing to change: pass at least one field besides work_item_id');
1398
1816
  }
1399
- const item = await only(client.from('tasks').update(changes).eq('id', work_item_id).select('id, name'), 'update', `work item ${work_item_id}`);
1817
+ const item = await only(caller.client.from('tasks').update(changes).eq('id', work_item_id).select('id, name'), 'update', `work item ${work_item_id}`);
1818
+ await receiptOnce(caller, 'work_item', item.id, item.name);
1400
1819
  return `Updated ${item.name}: ${Object.keys(changes).join(', ')}.`;
1401
1820
  },
1402
1821
  },
@@ -1406,12 +1825,15 @@ const TOOLS = [
1406
1825
  // writes down what it learned so a later stage can read it.
1407
1826
  levels: ALL,
1408
1827
  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.',
1828
+ + 'item, so create the work item first if there is not one yet. `format` defaults to md. Use '
1829
+ + 'html for an interactive mock or wireframe, svg for a diagram, json for structured data. '
1830
+ + FIREWALL_WRITING_RULE,
1410
1831
  input: {
1411
1832
  work_item_id: z.string(),
1412
1833
  title: z.string().min(1),
1413
1834
  content: z.string().min(1),
1414
1835
  type: z.enum(['plan', 'spec', 'analysis', 'diagram', 'mock', 'wireframe', 'user_story']).optional(),
1836
+ format: z.enum(['md', 'html', 'json', 'svg']).optional(),
1415
1837
  },
1416
1838
  handler: async (caller, args) => {
1417
1839
  const a = args;
@@ -1420,7 +1842,7 @@ const TOOLS = [
1420
1842
  title: a.title,
1421
1843
  content: a.content,
1422
1844
  type: a.type ?? 'plan',
1423
- format: 'md',
1845
+ format: a.format ?? 'md',
1424
1846
  // `not null`, no default, and the table's own policy requires it to be
1425
1847
  // the caller. Stated rather than left to be defaulted somewhere else.
1426
1848
  created_by: caller.userId,
@@ -1434,18 +1856,27 @@ const TOOLS = [
1434
1856
  // Everyone's, for the same reason `create_artifact` is: an object that can
1435
1857
  // be made and never corrected is an object that goes stale in the record.
1436
1858
  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) => {
1859
+ description: 'Replace an artifact\'s body. The whole body is replaced, so send it complete. '
1860
+ + 'Pass `format` with the complete body to change how it renders. '
1861
+ + FIREWALL_WRITING_RULE,
1862
+ input: {
1863
+ artifact_id: z.string(),
1864
+ content: z.string().min(1),
1865
+ title: z.string().min(1).optional(),
1866
+ format: z.enum(['md', 'html', 'json', 'svg']).optional(),
1867
+ },
1868
+ handler: async (caller, args) => {
1440
1869
  const a = args;
1441
- const artifact = await only(client.from('artifacts')
1870
+ const artifact = await only(caller.client.from('artifacts')
1442
1871
  .update({
1443
1872
  content: a.content,
1444
1873
  ...(a.title ? { title: a.title } : {}),
1874
+ ...(a.format ? { format: a.format } : {}),
1445
1875
  updated_at: new Date().toISOString(),
1446
- updated_by: userId,
1876
+ updated_by: caller.userId,
1447
1877
  })
1448
1878
  .eq('id', a.artifact_id).select('id, title'), 'update', `artifact ${a.artifact_id}`);
1879
+ await receiptOnce(caller, 'artifact', artifact.id, artifact.title);
1449
1880
  return `Updated artifact ${artifact.title ?? artifact.id}.`;
1450
1881
  },
1451
1882
  },
@@ -1471,7 +1902,8 @@ const TOOLS = [
1471
1902
  + 'convention agreed, how a subsystem actually fits together, why an approach was rejected. '
1472
1903
  + 'It adds a document and never changes or removes one, so do not use it to correct something '
1473
1904
  + '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.',
1905
+ + 'to do — that goes in an artifact on the work item. '
1906
+ + FIREWALL_WRITING_RULE,
1475
1907
  input: {
1476
1908
  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
1909
  type: z.enum(['instructions', 'architecture', 'design', 'conventions', 'other']).describe('Which kind of reference this is, matching the five the web app offers.'),
@@ -1543,7 +1975,8 @@ const TOOLS = [
1543
1975
  + 'what you are about to do and what they will get. Use it again if what you are doing changes '
1544
1976
  + 'in a way they would want to know about. It does NOT end your turn, does not answer them, '
1545
1977
  + '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.',
1978
+ + 'not use it for a running commentary, and do not use it to ask anything. '
1979
+ + FIREWALL_WRITING_RULE,
1547
1980
  input: {
1548
1981
  message: z.string().min(1).describe('What they should read, in their words. "Reading the search screen first, then I\'ll write '
1549
1982
  + 'the plan and bring it to you."'),
@@ -1565,7 +1998,7 @@ const TOOLS = [
1565
1998
  ...(processToken === undefined ? {} : { p_process_token: processToken }),
1566
1999
  });
1567
2000
  if (error)
1568
- throw new Error(`could not say that on this card: ${error.message}`);
2001
+ throw new Error(`could not say that on this card: ${readableWriteError(error.message)}`);
1569
2002
  /* ═══ REFUSED, AND THE AGENT IS TOLD SO RATHER THAN LEFT BELIEVING IT
1570
2003
  SPOKE. ═══ Three reasons collapse to one sentence because the caller
1571
2004
  cannot tell them apart and all three mean the same thing to it: this
@@ -1579,6 +2012,272 @@ const TOOLS = [
1579
2012
  return 'Said. They can read it now. Carry on.';
1580
2013
  },
1581
2014
  },
2015
+ // ── Show them a picture ──────────────────────────────────────────────────
2016
+ //
2017
+ // ═══ LEVELS 2 AND 3, AND THEY REACH THE CARD BY DIFFERENT DOORS. ═══
2018
+ //
2019
+ // A level 2 owner SPEAKS: its picture goes into a turn through `panel3_say`,
2020
+ // which refuses any run that is not the one `panel3_cards.conversation_run_id`
2021
+ // names, so the fence is enforced in SQL rather than by this list.
2022
+ //
2023
+ // A level 3 worker CANNOT speak, and `panel3_say`'s own comment says why: it
2024
+ // exists so "a dispatched worker can never reach the person through it".
2025
+ // Widening that fence to carry a picture would change what a worker is. So a
2026
+ // worker writes the `panel3_images` row with NO turn, and the panel reads a
2027
+ // card's turn-less images as conversation events of their own.
2028
+ //
2029
+ // ═══ AN IMAGE DOES NOT NEED A TURN TO EXIST, WHICH IS WHAT MAKES THAT
2030
+ // POSSIBLE. ═══ `panel3_images.turn_id` is nullable for exactly this case, and
2031
+ // the two reads partition the table rather than overlapping it: the turn embed
2032
+ // takes the rows that have a `turn_id`, the card read takes the rows that do
2033
+ // not. Neither picture can render twice, by construction.
2034
+ {
2035
+ name: 'attach_image',
2036
+ levels: [2, 3],
2037
+ description: 'Show the person a picture on the card, with one or two sentences saying what they are '
2038
+ + 'looking at. Use it when a picture is the answer and words are not: a screen you have just '
2039
+ + 'changed, a diagram, a chart, something you were asked to look at. The picture must be a PNG '
2040
+ + 'file that already exists on this machine, and you give its absolute path. It does NOT end '
2041
+ + 'your turn: keep working after it. Do not use it for a picture they '
2042
+ + 'already have, and do not use it in place of the reply you finish with.',
2043
+ input: {
2044
+ path: z.string().min(1).describe('The absolute path of a PNG file on this machine. "/Users/me/work/search-screen.png"'),
2045
+ 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 '
2046
+ + 'row." One or two sentences: it is read on a card three inches wide.'),
2047
+ },
2048
+ handler: async ({ client, userId, cardId, runId, level, processToken }, args) => {
2049
+ const { path, caption } = args;
2050
+ /* ═══ THE PICTURE IS VALIDATED BEFORE ANYTHING IS WRITTEN, AND ITS
2051
+ FAILURES ARE RE-THROWN PATH-FREE. ═══ `readPngScreenshot` interpolates
2052
+ the absolute path into every one of its messages, and a tool's thrown
2053
+ message is what the agent reads. An agent may quote its own error into a
2054
+ `say`, which lands in `panel3_turns.body` — a cloud row a browser
2055
+ renders. So the reason is kept and the path is not, in the shape
2056
+ `checkout.ts` established for exactly this. */
2057
+ let picture;
2058
+ try {
2059
+ picture = await readPngScreenshot(path);
2060
+ }
2061
+ catch (err) {
2062
+ /* ═══ THE REASON IS KEPT, THE TEXT IS NOT. ═══ Every message from below
2063
+ may name the path, and not only where `readPngScreenshot` interpolated
2064
+ it: an `ENOENT` from Node restates the path in its own words, inside
2065
+ quotes, and a `replace(path, …)` over the string would miss any such
2066
+ restatement that differs by a byte. So nothing composed down there is
2067
+ forwarded. What the agent needs is which of these went wrong, and each
2068
+ of these sentences is written here. */
2069
+ throw new Error(`NOTHING WAS WRITTEN: ${await whyNotAPicture(path)} Check the path and try once more.`);
2070
+ }
2071
+ /* ═══ THE ID IS MINTED HERE, BECAUSE THE STORAGE PATH IS DERIVED FROM IT.
2072
+ ═══ `panel3_images_storage_path_shape` requires the object key to equal
2073
+ `<user>/<card>/<id>.png`, so a server-defaulted id makes the key
2074
+ uncomputable before the upload. */
2075
+ const imageId = crypto.randomUUID();
2076
+ const storagePath = `${userId}/${cardId}/${imageId}.png`;
2077
+ const { error: uploadError } = await client.storage
2078
+ .from(PANEL3_IMAGES_BUCKET)
2079
+ .upload(storagePath, picture.bytes, { contentType: 'image/png', upsert: false });
2080
+ if (uploadError) {
2081
+ throw new Error(`NOTHING WAS WRITTEN: the picture could not be stored (${uploadError.message}).`);
2082
+ }
2083
+ /* ═══ A WORKER WRITES THE PICTURE AND NOTHING ELSE. ═══ It has no turn to
2084
+ hang it on and must not acquire one: `panel3_say` would refuse it, and
2085
+ it is right to. The row's null `turn_id` is what puts the picture on the
2086
+ card as an event of its own, and the caption travels with it as the
2087
+ row's own words rather than as a message the worker did not send. */
2088
+ if (level === 3) {
2089
+ /* ═══ AND IT ASKS FIRST WHETHER IT IS STILL RUNNING, WHICH LEVEL 2 GETS
2090
+ FOR FREE AND THIS DOES NOT. ═══ `panel3_say` refuses a stopped card
2091
+ and a superseded owner, so the level 2 path below cannot write a
2092
+ picture from a run that is over. A worker has no such gate: its row
2093
+ can be stamped `stopped` by the person's Stop button while its process
2094
+ lives on for another poll, and a bare insert here would put a picture
2095
+ on the card after they asked for it to end. */
2096
+ if (!(await stillRunning(client, runId))) {
2097
+ throw new Error('NOTHING WAS WRITTEN: THIS RUN HAS ENDED, so nothing more of yours reaches the card. Stop.');
2098
+ }
2099
+ const { error } = await client.from('panel3_images').insert({
2100
+ id: imageId,
2101
+ card_id: cardId,
2102
+ turn_id: null,
2103
+ caption,
2104
+ storage_path: storagePath,
2105
+ width: picture.width,
2106
+ height: picture.height,
2107
+ size_bytes: picture.sizeBytes,
2108
+ });
2109
+ if (error) {
2110
+ throw new Error(`NOTHING WAS WRITTEN: the picture could not be recorded (${error.message}).`);
2111
+ }
2112
+ return 'Shown on the card. Carry on with your own work: this did not send anybody a message.';
2113
+ }
2114
+ /* ═══ SPEAK FIRST, THEN WRITE THE ROW. ═══ NOT the web send path's order,
2115
+ and the difference is `panel3_say`'s: it returns NULL rather than
2116
+ raising when the card is stopped or this owner has been superseded. An
2117
+ image row written before that refusal would sit on the card with a null
2118
+ `turn_id`, which is now a WORKER's picture and would be rendered as one.
2119
+ Speaking first means a refusal happens before any row exists. The upload
2120
+ still precedes both, so a refusal leaves bytes with no row, which is
2121
+ invisible and is the honest way round: the alternative is a row pointing
2122
+ at bytes that were never stored. */
2123
+ const { data: turnId, error: sayError } = await client.rpc('panel3_say', {
2124
+ p_run_id: runId,
2125
+ p_body: caption,
2126
+ ...(processToken === undefined ? {} : { p_process_token: processToken }),
2127
+ });
2128
+ if (sayError)
2129
+ throw new Error(`could not show that on this card: ${sayError.message}`);
2130
+ if (turnId === null) {
2131
+ throw new Error('NOTHING WAS WRITTEN: this conversation is not yours to speak on. It has been stopped, '
2132
+ + 'or somebody else owns it now. Do not tell anybody you showed them anything.');
2133
+ }
2134
+ const { error: rowError } = await client.from('panel3_images').insert({
2135
+ id: imageId,
2136
+ card_id: cardId,
2137
+ turn_id: turnId,
2138
+ /* ═══ NULL WHEN THERE IS A TURN, BECAUSE THE TURN IS ALREADY THE WORDS.
2139
+ ═══ Storing the caption twice would give the panel two places to read
2140
+ the same sentence from and a way for them to disagree. */
2141
+ caption: null,
2142
+ storage_path: storagePath,
2143
+ width: picture.width,
2144
+ height: picture.height,
2145
+ size_bytes: picture.sizeBytes,
2146
+ });
2147
+ /* Truthful rather than tidy: the caption is already in front of the person,
2148
+ so the failure says what they can see and what they cannot. */
2149
+ if (rowError) {
2150
+ throw new Error(`Your words reached them but the picture did not (${rowError.message}). They are looking `
2151
+ + 'at a message with nothing under it, so say what it was meant to show.');
2152
+ }
2153
+ return 'Shown. They can see the picture and read what you said about it. Carry on.';
2154
+ },
2155
+ },
2156
+ // ── Keep a picture on the work item ──────────────────────────────────────
2157
+ //
2158
+ // ═══ A SECOND TOOL, NOT FIVE INPUTS ON THE FIRST. ═══ Anchoring a picture to
2159
+ // a work item needs a title, a platform and a target that `attach_image` has
2160
+ // no use for, and `attach_image` needs a caption this has no use for. Bolting
2161
+ // them together makes five inputs, three of them conditional on each other,
2162
+ // and a schema that cannot express its own rule. `attach_screenshot` is the
2163
+ // shape followed here: every input required, one write path.
2164
+ //
2165
+ // ═══ AND THE ORG-SCOPED BUCKET IS RIGHT HERE, HAVING BEEN WRONG NEXT DOOR.
2166
+ // ═══ `attach_image` writes to the private owner-scoped `panel3-images`,
2167
+ // because a conversation is one person's. An artifact belongs to a work item
2168
+ // the whole org can see, so it goes where every other artifact goes. Two
2169
+ // visibility models, two buckets, both deliberate.
2170
+ {
2171
+ name: 'attach_image_artifact',
2172
+ levels: [2, 3],
2173
+ description: 'Keep a picture on the work item, where anybody can find it later. Use it when the picture is '
2174
+ + 'part of what the work produced rather than something you are showing in passing: a screen '
2175
+ + 'you have built, a diagram of what you changed, the state something is in. The picture must '
2176
+ + 'be a PNG file that already exists on this machine, and you give its absolute path. It is '
2177
+ + 'kept for good and cannot be replaced, so attach it when it is right. Use attach_image '
2178
+ + 'instead for something the person only needs to see now.',
2179
+ input: {
2180
+ path: z.string().min(1).describe('The absolute path of a PNG file on this machine. "/Users/me/work/search-screen.png"'),
2181
+ title: z.string().min(1).max(200).describe('What this picture is, as a person scanning the work item would want it named. '
2182
+ + '"Search results with the new filter row."'),
2183
+ 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 '
2184
+ + 'names it.'),
2185
+ platform: z.enum(['web', 'ios', 'android']).describe('Where the picture was taken.'),
2186
+ 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 '
2187
+ + 'name. "/projects/search" or "Search results".'),
2188
+ },
2189
+ handler: async (caller, args) => {
2190
+ const { path, title, work_item_id, platform, target } = args;
2191
+ const { client, userId } = caller;
2192
+ let picture;
2193
+ try {
2194
+ picture = await readPngScreenshot(path);
2195
+ }
2196
+ catch {
2197
+ /* Path-free, for `attach_image`'s reason: a tool's thrown message is what
2198
+ the agent reads, and an agent quotes its own errors into a `say`. */
2199
+ throw new Error(`NOTHING WAS WRITTEN: ${await whyNotAPicture(path)} Check the path and try once more.`);
2200
+ }
2201
+ /* ═══ THE WORK ITEM MUST BE ONE THIS CARD CARRIES. ═══ Validated once,
2202
+ here, at the boundary. Without it an agent that names the wrong id
2203
+ writes a picture onto an unrelated work item, which nothing downstream
2204
+ would catch: the guard checks the artifact's SHAPE, and RLS lets this
2205
+ person write to any item in their org. The card's attachments are what
2206
+ the PERSON pointed this conversation at, so they are the authority on
2207
+ what this run may anchor to. */
2208
+ const attached = await loadAttachments(client, caller.cardId);
2209
+ if (!attached.some((item) => item.kind === 'work_item' && item.ref_id === work_item_id)) {
2210
+ throw new Error(`NOTHING WAS WRITTEN: this card does not carry work item ${work_item_id}, so a picture `
2211
+ + 'cannot be kept on it. get_my_brief_and_report names the work item you are on. If the '
2212
+ + 'picture belongs to the conversation rather than to an item, use attach_image.');
2213
+ }
2214
+ /* ═══ THE DATABASE DERIVES THE STORAGE PATH AND REFUSES ANY OTHER. ═══
2215
+ `app.guard_agent_artifact_insert` computes
2216
+ `<org>/<project>/<task>/<artifact id>.png` and raises on a mismatch, so
2217
+ the org and project are resolved BEFORE the upload rather than after:
2218
+ the key cannot be computed without them, and a server-defaulted artifact
2219
+ id would make it uncomputable at all. */
2220
+ 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}`);
2221
+ if (item.project_id === null) {
2222
+ throw new Error(`NOTHING WAS WRITTEN: work item ${work_item_id} is in no project, and a picture is kept `
2223
+ + 'under its project. Move it into one first.');
2224
+ }
2225
+ 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`);
2226
+ /* ═══ CONTENT-ADDRESSED, NOT MINTED. ═══ The same picture under the same
2227
+ anchor and title yields the same id, so a call interrupted between the
2228
+ upload and the insert retries onto the same key and converges instead of
2229
+ leaving one orphaned object per attempt. A random uuid would make every
2230
+ retry a second artifact of the same picture. */
2231
+ const artifactId = screenshotArtifactId(item.id, title, platform, target, picture.bytes);
2232
+ const storagePath = `${project.org_id}/${item.project_id}/${item.id}/${artifactId}.png`;
2233
+ /* ═══ EXACTLY THESE FIVE KEYS, BECAUSE THE GUARD DEMANDS EXACTLY THESE
2234
+ FIVE. ═══ It rejects a `content` object with any other key set, so this
2235
+ is not a place to add a field. */
2236
+ const content = JSON.stringify({
2237
+ platform,
2238
+ target,
2239
+ width: picture.width,
2240
+ height: picture.height,
2241
+ size_bytes: picture.sizeBytes,
2242
+ });
2243
+ const { error: uploadError } = await client.storage
2244
+ .from('artifacts')
2245
+ .upload(storagePath, picture.bytes, { contentType: 'image/png', upsert: false });
2246
+ /* A duplicate key is the converging retry above, arriving: the bytes at
2247
+ that key are this picture's, because the key is derived from them. */
2248
+ if (uploadError && !/duplicate|already exists|resource exists/i.test(uploadError.message)) {
2249
+ throw new Error(`NOTHING WAS WRITTEN: the picture could not be stored (${uploadError.message}).`);
2250
+ }
2251
+ const { error: insertError } = await client.from('artifacts').insert({
2252
+ id: artifactId,
2253
+ task_id: item.id,
2254
+ type: 'image',
2255
+ format: 'png',
2256
+ title,
2257
+ content,
2258
+ storage_path: storagePath,
2259
+ created_by: userId,
2260
+ /* ═══ BOTH NULL, AND THE GUARD IS WHY. ═══ It refuses a non-null
2261
+ `from_agent` from an `authenticated` caller, and panel3's client is
2262
+ the person's own session. `create_artifact` leaves them null for the
2263
+ same reason. */
2264
+ from_agent: null,
2265
+ agent_run_id: null,
2266
+ });
2267
+ if (insertError) {
2268
+ /* A retry that finds its own artifact already there has converged, which
2269
+ is the point of the derived id: say so rather than reporting a
2270
+ failure that would make the agent attach a second copy. */
2271
+ if (/duplicate key|already exists/i.test(insertError.message)) {
2272
+ return `Already kept on the work item as "${title}". Nothing was written twice.`;
2273
+ }
2274
+ throw new Error(`the picture could not be kept on the work item (${insertError.message}).`);
2275
+ }
2276
+ await receipt(caller, 'artifact', artifactId, title);
2277
+ return (`Kept on the work item as "${title}", and it shows on the card. It cannot be replaced, so `
2278
+ + 'attach another if this one turns out to be wrong.');
2279
+ },
2280
+ },
1582
2281
  // ── Report ───────────────────────────────────────────────────────────────
1583
2282
  {
1584
2283
  name: 'report_activity',
@@ -1617,7 +2316,8 @@ const TOOLS = [
1617
2316
  description: 'Write down what is true NOW: what you have done, what you decided and why, and what is still '
1618
2317
  + 'open. It REPLACES your last report rather than adding to it, and it is what you are handed if '
1619
2318
  + '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.',
2319
+ + 'you go, not only at the end. '
2320
+ + FIREWALL_WRITING_RULE,
1621
2321
  input: { report: z.string().min(1) },
1622
2322
  handler: async ({ client, runId, processToken }, args) => {
1623
2323
  const { report } = args;
@@ -1767,7 +2467,7 @@ const TOOLS = [
1767
2467
  ...(processToken === undefined ? {} : { p_process_token: processToken }),
1768
2468
  });
1769
2469
  if (error)
1770
- throw new Error(`could not answer question ${question_id}: ${error.message}`);
2470
+ throw new Error(`could not answer question ${question_id}: ${readableWriteError(error.message)}`);
1771
2471
  if (data === null) {
1772
2472
  /* REFUSED, AND THE THREE REASONS ARE ONE SENTENCE because the caller
1773
2473
  cannot tell them apart and all three mean the same thing to it: this
@@ -1814,12 +2514,34 @@ const TOOLS = [
1814
2514
  + 'context: what to find out or change, and in which part of the codebase.'),
1815
2515
  boundary: z.string().min(1).describe('What it must not touch, and where its work stops.'),
1816
2516
  work_item_id: z.string().optional().describe('The work item it is working, if there is one.'),
2517
+ work_name: z.string().optional().describe(`A few words, ${WORK_NAME_WORDS} at most, naming the WORK this conversation is doing, such `
2518
+ + 'as "Fix the sign-out checklist bug". Pass it when the conversation has NO work item '
2519
+ + 'attached: the conversation is called this from now on, and the branch the work goes on is '
2520
+ + 'cut from it, both at the moment you send somebody. Leave it out when a work item is '
2521
+ + 'attached, because that item is already the name.'),
1817
2522
  },
1818
2523
  handler: async (caller, args) => {
1819
- const { codebase_id, responsibility, boundary, work_item_id } = args;
2524
+ const { codebase_id, responsibility, boundary, work_item_id, work_name } = args;
1820
2525
  if (caller.level === 2 && !codebase_id) {
1821
2526
  throw new Error('A worker must be attached to a registered project codebase.');
1822
2527
  }
2528
+ /* ═══ THE NAME IS CHECKED BEFORE ANYTHING IS READ, WRITTEN OR STARTED.
2529
+ ═══ It is the one argument here that changes something the person is
2530
+ already looking at, so a refusal has to leave the card exactly as it
2531
+ was and nobody running. */
2532
+ const named = work_name === undefined ? null : work_name.trim();
2533
+ if (named !== null) {
2534
+ const words = named === '' ? [] : named.split(/\s+/);
2535
+ if (words.length === 0) {
2536
+ throw new Error('NOTHING WAS WRITTEN and nobody was started: work_name is blank. Name the work in a few '
2537
+ + 'words, or leave it out and the conversation keeps the words they opened with.');
2538
+ }
2539
+ if (words.length > WORK_NAME_WORDS) {
2540
+ throw new Error(`NOTHING WAS WRITTEN and nobody was started: that is ${words.length} words, and it is `
2541
+ + `read on one line and cut into a branch name. The limit is ${WORK_NAME_WORDS}. Name `
2542
+ + 'the work, do not describe it.');
2543
+ }
2544
+ }
1823
2545
  let codebase = null;
1824
2546
  if (codebase_id) {
1825
2547
  const projectId = await projectOfCard(caller);
@@ -1845,7 +2567,21 @@ const TOOLS = [
1845
2567
  three hops from level 1 still knows which item it is working, without
1846
2568
  asking. See `workBrief`'s own doc for why the two are different things
1847
2569
  carried the same way. */
1848
- const attachments = (await loadAttachments(caller.client, caller.cardId)).map(attachmentLine);
2570
+ const attached = await loadAttachments(caller.client, caller.cardId);
2571
+ /* ═══ THE CONVERSATION IS NAMED HERE OR IT IS NEVER NAMED. ═══ Sending
2572
+ somebody resolves the working copy, and resolving it cuts the branch
2573
+ from whatever the conversation is called at that moment and stamps it
2574
+ for good. So the name is written BEFORE the call below, and there is no
2575
+ second chance further on.
2576
+
2577
+ AN ATTACHED WORK ITEM IS ALREADY THE NAME, so nothing is written over
2578
+ it: the person chose that item and the conversation was called after it
2579
+ the moment they did. */
2580
+ if (named !== null && !attached.some((attachment) => attachment.kind === 'work_item')) {
2581
+ await rows(caller.client.from('panel3_cards').update({ title: named }).eq('id', caller.cardId)
2582
+ .select('id'), 'name', `card ${caller.cardId}`);
2583
+ }
2584
+ const attachments = attached.map(attachmentLine);
1849
2585
  const { runId } = await caller.dispatch(caller.runId, workBrief(childLevel, responsibility, boundary, work_item_id, attachments, codebase === null ? undefined : {
1850
2586
  id: codebase.id, name: codebase.name, identity: codebase.gitRemoteUrl,
1851
2587
  }), codebase, caller.processToken);
@@ -1922,8 +2658,14 @@ const TOOLS = [
1922
2658
  * that produced nothing visible. So every `create_*` above writes its own
1923
2659
  * receipt, and `record_output` stays for what these tools did not create.
1924
2660
  *
1925
- * UPDATES DELIBERATELY WRITE NOTHING. A receipt answers "what did this card
1926
- * produce", and editing something twice did not produce it twice.
2661
+ * AN EDIT RECORDS THE OBJECT TOO, ONCE. A person who asks for an artifact to be
2662
+ * rewritten wants to open the thing that changed, and a card showing nothing
2663
+ * leaves them nowhere to go; but editing something twice did not produce it
2664
+ * twice. So the four editing tools go through `receiptOnce`, which calls
2665
+ * `receipt` only when this card has no receipt for that object yet. `receipt`
2666
+ * stays the only writer, and the check is deliberately NOT inside it: every
2667
+ * create site hands it an id made a moment ago, which cannot already be there,
2668
+ * and a read for each of them would buy nothing.
1927
2669
  *
1928
2670
  * ═══ AND AN ENDED RUN DOES NOT LEAVE RECEIPTS, WHICH IS THE ONE GUARD HERE
1929
2671
  * THAT IS NOT ATOMIC. ═══
@@ -1940,18 +2682,50 @@ const TOOLS = [
1940
2682
  * is a duplicate of work another agent may redo, not an attribution to something
1941
2683
  * that never happened, which is why it does not earn an RPC of its own today.
1942
2684
  */
1943
- async function receipt({ client, runId, cardId }, kind, refId, label) {
2685
+ /**
2686
+ * Whether this run may still put something on the person's card.
2687
+ *
2688
+ * ═══ ONE OWNER, BECAUSE THERE IS ONE QUESTION. ═══ A run's process outlives its
2689
+ * row: `panel3_stop_these` stamps `state` and `ended_at` across a whole subtree
2690
+ * in one statement and the processes are killed within a poll, so between those
2691
+ * two moments a child is alive and its row says it is finished. Every write that
2692
+ * reaches the card asks this before it lands, and it is the same predicate for
2693
+ * all of them, which is why it is a function rather than a clause repeated in
2694
+ * each of them.
2695
+ */
2696
+ async function stillRunning(client, runId) {
1944
2697
  const live = await rows(client.from('panel3_runs').select('id').eq('id', runId)
1945
2698
  .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.');
2699
+ return live.length > 0;
2700
+ }
2701
+ async function receipt({ client, runId, cardId }, kind, refId, label) {
2702
+ if (!(await stillRunning(client, runId))) {
2703
+ /* IT NAMES WHAT LANDED. The product row exists it was written before this
2704
+ was reached so an error that only said "refused" would leave the agent
2705
+ unable to say whether the thing is there. IT COVERS BOTH CALLERS: the
2706
+ creating tools and, through `receiptOnce`, the editing ones, so it must
2707
+ not tell an agent it created what it changed. */
2708
+ throw new Error(`the ${kind} ${refId} was written, but THIS RUN HAS ENDED so no receipt was written for it and `
2709
+ + 'its card has been handed to another agent. Say what you did and stop.');
1952
2710
  }
1953
2711
  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
2712
  }
2713
+ /**
2714
+ * The same receipt, unless this card already carries one for that object.
2715
+ *
2716
+ * ═══ THIS IS NOT A RACE GUARD, AND IS NOT CLAIMED AS ONE. ═══ `panel3_outputs`
2717
+ * has no unique key over card, kind and ref, so two edits of the same object
2718
+ * landing in the same instant can both read nothing and both write. What that
2719
+ * costs is one repeated card and nothing worse, which is why it does not earn a
2720
+ * constraint or an RPC today.
2721
+ */
2722
+ async function receiptOnce(caller, kind, refId, label) {
2723
+ const already = await rows(caller.client.from('panel3_outputs').select('id')
2724
+ .eq('card_id', caller.cardId).eq('kind', kind).eq('ref_id', refId), 'check', `whether the ${kind} ${refId} is already on the card`);
2725
+ if (already.length > 0)
2726
+ return;
2727
+ await receipt(caller, kind, refId, label);
2728
+ }
1955
2729
  /** The names one level is served, in the order they are registered. Exported for
1956
2730
  * the same reason `cs show` exists: a rule nobody can print is a rule nobody
1957
2731
  * can check. */