@ctrl-spc/cs 0.7.1 → 0.7.3

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.
@@ -1,83 +1,70 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * ═══ AGENT PANEL v3: the `cs3` bin. ═══
3
+ * ═══ AGENT PANEL v3: the panel's commands, and the harness bin that runs them. ═══
4
4
  *
5
5
  * THIS FILE BELONGS TO AGENT PANEL v3. Nothing outside `src/panel3/` may
6
- * import it.
6
+ * import it, but for the one mount `cli-v2/src/index.ts` makes on
7
+ * `panelCommand`, named in `test/panel3-isolation.contract.test.mjs`.
7
8
  *
8
- * A SEPARATE BIN RATHER THAN A SUBCOMMAND OF `cs`, deliberately: v3 never
9
- * touches v1's command router, so retiring either generation is deleting a
10
- * directory and a line of `bin`, not unpicking a switch statement two
11
- * generations share.
9
+ * ═══ THE PERSON TYPES `cs`, AND ONLY `cs`. ═══ Lane's ruling (2026-08-24):
10
+ * nothing the product names may carry a version the person never knew about,
11
+ * and there is one CLI to launch and sign into. So `say`, `answer` and `show`
12
+ * are `cs` subcommands, reached through `panelCommand` below, and the `cs3` bin
13
+ * that used to publish them is gone. The router stays HERE rather than moving
14
+ * into `index.ts` so that v3 is still one directory to delete: `index.ts` holds
15
+ * three cases and a single import, not the panel's argument handling.
12
16
  *
13
- * ═══ AND SINCE recovery-1 SLICE 4, `run` IS NOT SOMETHING A PERSON TYPES. ═══
14
- * Lane's ruling (2026-08-21): a person launches one CLI, `cs start`, and signs
15
- * into it once. `cs start` now runs the panel's poll loop through `startPanel`,
16
- * so that is the only launch the product ever names, and `cs3 run` is off the
17
- * help text above.
18
- *
19
- * ═══ IT IS STILL HERE, AND IT IS NOT A SECOND OWNER OF THE LOOP. ═══ There is
20
- * one `run()` in `run.ts` with two callers. This one signs in from
21
- * `CTRL_SPC_V3_EMAIL` / `CTRL_SPC_V3_PASSWORD`, which is what lets
22
- * `test/panel3-acceptance.mjs` and the three walk scripts stand a daemon up
23
- * against their own isolated account and their own working copy without touching
24
- * the installed CLI's `session.json` or its presence. Deleting it would mean
25
- * rebuilding that isolation inside `cs start`, which is a launch path the tests
26
- * would then own. DO NOT PUT IT BACK IN THE HELP TEXT.
17
+ * ═══ AND `run` IS STILL NOT SOMETHING A PERSON TYPES. ═══ `cs start` runs the
18
+ * panel's poll loop through `startPanel`, so that is the only launch the product
19
+ * names. `run` is reachable only by running this file directly, which is what
20
+ * `test/panel3-acceptance.mjs` and the walk scripts do. It signs in from
21
+ * `CTRL_SPC_V3_EMAIL` / `CTRL_SPC_V3_PASSWORD`, which is what lets them stand a
22
+ * daemon up against their own isolated account and their own working copy
23
+ * without touching the installed CLI's `session.json` or its presence. There is
24
+ * one `run()` in `run.ts` with two callers, not two copies of the loop. DO NOT
25
+ * PUT IT IN `cs`.
27
26
  *
28
27
  * ═══ A FAILURE EXITS NON-ZERO AND SAYS WHY, ALWAYS. ═══ Every read in `show`
29
- * and every write in `say` either returns its rows or throws, and this is the
30
- * only handler. Nothing here converts a failure into an empty result, because a
31
- * command whose whole job is observation is worthless the first time silence can
32
- * mean either "nothing is there" or "the read failed", and a `say` that exited
33
- * zero without writing would be an acknowledgement of nothing.
28
+ * and every write in `say` either returns its rows or throws, and the two
29
+ * handlers below are the only ones. Nothing here converts a failure into an
30
+ * empty result, because a command whose whole job is observation is worthless
31
+ * the first time silence can mean either "nothing is there" or "the read
32
+ * failed", and a `say` that exited zero without writing would be an
33
+ * acknowledgement of nothing.
34
34
  */
35
+ import { realpathSync } from 'node:fs';
36
+ import { fileURLToPath } from 'node:url';
35
37
  import { answer } from './answer.js';
36
38
  import { run } from './run.js';
37
39
  import { say } from './say.js';
38
40
  import { show } from './show.js';
39
- const HELP = `cs3 — CTRL+SPC agent panel v3
40
-
41
- cs3 say "<text>" Start a card and put your message on it
42
- cs3 say --project <p> "<text>" Start it filed under that project (id or name)
43
- cs3 say --card <id> "<text>" Add a message to a card you already have
44
- cs3 answer <id> "<text>" Answer a question a card is waiting on you for
45
- cs3 show Every card
46
- cs3 show <id> One card in full, or one run with its brief and report
47
- cs3 help Show this help
48
-
49
- This machine answers cards when \`cs start\` is running. These commands are the
50
- terminal's view of the same record.
51
-
52
- Acts as CTRL_SPC_V3_EMAIL / CTRL_SPC_V3_PASSWORD, against the hosted project
53
- by default. Point it at a local stack with:
54
- CTRL_SPC_SUPABASE_URL=http://127.0.0.1:54321
55
- CTRL_SPC_SUPABASE_KEY=<the local publishable key from \`supabase status\`>
56
- `;
57
- async function main() {
58
- const command = process.argv[2];
41
+ /**
42
+ * One panel command, given the whole tail of `process.argv` from the command
43
+ * word on. `cs` routes `say`, `answer` and `show` here; running this file
44
+ * directly reaches `run` as well.
45
+ */
46
+ export async function panelCommand(argv) {
47
+ const command = argv[0];
59
48
  switch (command) {
60
49
  case 'say':
61
- return say(process.argv.slice(3));
50
+ return say(argv.slice(1));
62
51
  case 'answer':
63
- return answer(process.argv.slice(3));
52
+ return answer(argv.slice(1));
64
53
  case 'run':
65
- return run(process.argv.slice(3));
54
+ return run(argv.slice(1));
66
55
  case 'show':
67
- return show(process.argv[3]);
68
- case undefined:
69
- case 'help':
70
- case '--help':
71
- case '-h':
72
- console.log(HELP);
73
- return;
56
+ return show(argv[1]);
74
57
  default:
75
- console.error(`Unknown command: ${command}\n`);
76
- console.log(HELP);
77
- process.exitCode = 1;
58
+ throw new Error(`unknown command: ${command ?? '(none)'}`);
78
59
  }
79
60
  }
80
- main().catch((err) => {
81
- console.error(`cs3: ${err instanceof Error ? err.message : String(err)}`);
82
- process.exit(1);
83
- });
61
+ /* THE HARNESS ENTRY, AND IT MUST NOT FIRE WHEN `cs` IMPORTS `panelCommand`.
62
+ Both sides go through `realpathSync` because the installed CLI is reached
63
+ through a symlinked bin, and comparing the raw argument to the module path
64
+ would make this file two different files depending on how it was reached. */
65
+ if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
66
+ panelCommand(process.argv.slice(2)).catch((err) => {
67
+ console.error(err instanceof Error ? err.message : String(err));
68
+ process.exit(1);
69
+ });
70
+ }
@@ -16,14 +16,23 @@
16
16
  * copying it a third time.
17
17
  *
18
18
  * ---------------------------------------------------------------------------
19
- * WHY IT SIGNS IN EVERY TIME INSTEAD OF READING THE STORED SESSION.
19
+ * WHY A SESSION OF ITS OWN, AND WHY THE STORED ONE IS READ THE WAY IT IS.
20
20
  *
21
21
  * The installed CLI keeps one `session.json` on this machine and its long-lived
22
22
  * daemon holds the access token that file names. A second process that reads it
23
- * and lets the client refresh writes a ROTATED token back over the same file,
24
- * and the daemon's token stops working. So v3 takes a fresh session of its own,
25
- * in memory, and never touches the file: `persistSession: false`, and no v3
26
- * client has any storage to write a rotated token into.
23
+ * and lets the client REFRESH writes a ROTATED token back over the same file,
24
+ * and the daemon's token stops working. So when credentials are given, v3 takes
25
+ * a fresh session of its own, in memory, and never touches the file:
26
+ * `persistSession: false`, and no v3 client has any storage to write a rotated
27
+ * token into.
28
+ *
29
+ * ═══ AND WITH NO CREDENTIALS IT READS THAT FILE, BECAUSE THE PERSON TYPES
30
+ * `cs`. ═══ `say`, `answer` and `show` are `cs` subcommands, and a person
31
+ * who ran `cs login` has no v3 environment set and must not be asked to sign in
32
+ * twice. `storedSessionClient` below is that read, in the one shape that cannot
33
+ * rotate anything: the access token on a header, no auth session, no refresh
34
+ * token, nothing written back. The hazard above is about REFRESHING against
35
+ * that file, not about reading it.
27
36
  *
28
37
  * ═══ THAT IS NOW TRUE OF THE COMMANDS AND NOT OF THE PANEL, AND THE DIFFERENCE
29
38
  * IS THE WORD "PROCESS". ═══ recovery-1 Slice 4: `cs start` runs the poll
@@ -35,9 +44,9 @@
35
44
  * token. `signedInClient` must never be called from that path, because taking a
36
45
  * session of its own there is what would recreate the race indoors.
37
46
  *
38
- * So this function now serves `show`, `say`, `answer`, and `cs3 run` as the
39
- * acceptance harness's isolated entry — every one of them a process that is NOT
40
- * the installed daemon, which is exactly the set the paragraph above describes.
47
+ * So this function now serves `show`, `say`, `answer`, and the acceptance
48
+ * harness's isolated `run` entry — every one of them a process that is NOT the
49
+ * installed daemon, which is exactly the set the paragraph above describes.
41
50
  *
42
51
  * ---------------------------------------------------------------------------
43
52
  * ═══ AND THE DAEMON'S CLIENT REFRESHES, WHILE A ONE-SHOT COMMAND'S DOES NOT.
@@ -45,8 +54,8 @@
45
54
  *
46
55
  * `autoRefreshToken` was off for every v3 client, which is right for `show`,
47
56
  * `say` and `answer` — each signs in, does one thing and exits, long inside the
48
- * access token's lifetime — and WRONG for `cs3 run`, which holds one client for
49
- * as long as the daemon lives and hands that same client to every tool call an
57
+ * access token's lifetime — and WRONG for the harness's `run`, which holds one
58
+ * client for as long as the daemon lives and hands that same client to every tool call an
50
59
  * agent makes. Past the token's TTL every poll and every in-flight tool call
51
60
  * would start failing, and the card would sit working with a daemon that could
52
61
  * no longer read or write anything.
@@ -75,8 +84,9 @@
75
84
  * published (`"files": ["dist"]`, `"access": "public"`), and `dist/` is
76
85
  * committed, so a literal password here is one `npm publish` away from the
77
86
  * public registry — a harm about DISTRIBUTION that the loopback guard below,
78
- * which is about where a request is SENT, does nothing to prevent. So there is
79
- * no fallback: unset means refuse, and say where to get them.
87
+ * which is about where a request is SENT, does nothing to prevent. So no
88
+ * password is ever defaulted: unset falls through to the stored `cs login`
89
+ * session, and with neither the command refuses and says what to do.
80
90
  *
81
91
  * ---------------------------------------------------------------------------
82
92
  * ═══ THE LOOPBACK-ONLY GUARD IS GONE, AND THIS IS THE EDIT IT ASKED FOR. ═══
@@ -107,6 +117,7 @@
107
117
  */
108
118
  import { createClient } from '@supabase/supabase-js';
109
119
  import { SUPABASE_URL, SUPABASE_KEY } from '../env.js';
120
+ import { readSession } from '../config.js';
110
121
  /**
111
122
  * How a v3 client holds its session, for the two lifetimes v3 has.
112
123
  *
@@ -122,6 +133,34 @@ export const sessionOptions = (living) => ({
122
133
  the token's lifetime, and the daemon does not. */
123
134
  autoRefreshToken: living,
124
135
  });
136
+ /**
137
+ * ═══ THE PERSON SIGNED IN ONCE, WITH `cs login`, AND `cs say` MUST NOT ASK
138
+ * AGAIN. ═══
139
+ *
140
+ * `say`, `answer` and `show` are `cs` subcommands, so the person who ran
141
+ * `cs login` types them with no environment set at all. This is that session,
142
+ * and it is read in the one shape that CANNOT rotate the token the daemon is
143
+ * holding: the stored access token goes on the Authorization header, there is no
144
+ * auth session on the client, no refresh token is handed over, and nothing is
145
+ * ever written back to `session.json`. A rotation here is exactly the failure
146
+ * the header above describes, and the header cannot happen if no client in this
147
+ * process ever holds a refresh token.
148
+ *
149
+ * An expired access token surfaces as the request's own 401 through
150
+ * `returned()`, which is the truthful failure: `cs start` refreshes the file
151
+ * this reads, so a person whose machine is online has a live token.
152
+ */
153
+ function storedSessionClient() {
154
+ const stored = readSession();
155
+ if (!stored?.access_token) {
156
+ throw new Error('not signed in. Run `cs login`, or set CTRL_SPC_V3_EMAIL and CTRL_SPC_V3_PASSWORD to the '
157
+ + `account this command should act as at ${SUPABASE_URL}`);
158
+ }
159
+ return createClient(SUPABASE_URL, SUPABASE_KEY, {
160
+ auth: sessionOptions(false),
161
+ global: { headers: { Authorization: `Bearer ${stored.access_token}` } },
162
+ });
163
+ }
125
164
  /**
126
165
  * @param living whether this client outlives its access token. The daemon's
127
166
  * does; every one-shot command's does not.
@@ -129,10 +168,8 @@ export const sessionOptions = (living) => ({
129
168
  export async function signedInClient(living = false) {
130
169
  const email = process.env.CTRL_SPC_V3_EMAIL;
131
170
  const password = process.env.CTRL_SPC_V3_PASSWORD;
132
- if (!email || !password) {
133
- throw new Error('set CTRL_SPC_V3_EMAIL and CTRL_SPC_V3_PASSWORD to the account this command '
134
- + `should act as at ${SUPABASE_URL}`);
135
- }
171
+ if (!email || !password)
172
+ return storedSessionClient();
136
173
  const client = createClient(SUPABASE_URL, SUPABASE_KEY, { auth: sessionOptions(living) });
137
174
  const { data, error } = await client.auth.signInWithPassword({ email, password });
138
175
  // Truthfully, and naming both halves of what was attempted: a wrong password
@@ -9,7 +9,7 @@ import { returned } from './client.js';
9
9
  export const LISTENING_WINDOW_MS = 15_000;
10
10
  /** Is this machine still listening?
11
11
  *
12
- * THE ONE PREDICATE, shared by `cs3 show` and the panel, so the two screens
12
+ * THE ONE PREDICATE, shared by `cs show` and the panel, so the two screens
13
13
  * cannot disagree about whether anything can pick a card up.
14
14
  *
15
15
  * ═══ IT TAKES THE ROW, NOT A TIMESTAMP, BECAUSE FRESHNESS IS NO LONGER THE
@@ -228,13 +228,32 @@ const WHILE_YOU_ARE_WORKING = [
228
228
  * `whatYouSendBack` is: one wording for "what was attached", never two that
229
229
  * drift.
230
230
  */
231
- const whatWasAttached = (attachments) => {
231
+ // Exported since attaching-after-the-fact-10: the owner's activation joins this
232
+ // block live, in front of a prompt whose brief already ends with a frozen copy
233
+ // of it (see the supersession sentence below and `withStandingRules` in
234
+ // `run.ts`). No wrapper around it, because it already emits its own header and
235
+ // already returns `[]` when nothing is attached.
236
+ export const whatWasAttached = (attachments) => {
232
237
  const codebases = attachments.filter((attachment) => attachment.startsWith('codebase '));
233
238
  const personAttachments = attachments.filter((attachment) => !attachment.startsWith('codebase '));
234
239
  const personAttachmentLines = personAttachments.length === 0 ? [] : [
235
240
  '',
236
241
  'WHAT THE PERSON ATTACHED',
237
242
  'They attached these when they sent this, so what is asked belongs against them.',
243
+ /* ═══ THE SUPERSESSION SENTENCE, IN THE SHAPE `standingRules` ALREADY USES.
244
+ ═══ attaching-after-the-fact-10: a person may attach to a card that is
245
+ already running, and the owner's prompt replays an immutable brief that
246
+ ends with THIS block as it stood at dispatch. The live block is joined in
247
+ front of that prompt, so without this sentence the owner would read two
248
+ lists of attachments with the stale one last. Written once, here, rather
249
+ than at the join, so one wording exists.
250
+
251
+ AND IT IS TRUE AND INERT IN THE OTHER TWO CALLERS. `levelOnePrompt` and
252
+ `workBrief` each render this block exactly once in a prompt that holds no
253
+ other copy, so "it replaces any earlier list" is simply true there. */
254
+ 'This is the complete and current set of what the person attached to this conversation. It',
255
+ 'REPLACES any list of attachments you were given earlier, including one inside a brief below:',
256
+ 'everything below is in force, and nothing that is not below is.',
238
257
  ...personAttachments,
239
258
  /* ═══ READ BEFORE YOU DECIDE, WHICH IS THE WHOLE OF THIS SLICE. ═══ ux.md:
240
259
  "Before an agent begins working on it, it must read the description, and
@@ -319,6 +338,69 @@ const whatWasAttached = (attachments) => {
319
338
  ...personAttachmentLines,
320
339
  ];
321
340
  };
341
+ export const standingRules = (documents) => {
342
+ if (documents.length === 0)
343
+ return [];
344
+ return [
345
+ 'THE STANDING RULES ON THIS PROJECT',
346
+ 'Written by the people you are working for, in the product, and handed to you in full every',
347
+ 'time you are started. They apply to everything on this conversation unless the person said',
348
+ 'otherwise, and they are what to follow when nobody said anything. This is the complete and',
349
+ 'current set: it REPLACES any standing rules you were given earlier in this conversation, so a',
350
+ 'rule that is not below is not in force, whatever you were told before.',
351
+ 'When something else disagrees with them, the order is: what the person said on this',
352
+ 'conversation, then what they attached to it, then these rules, then the project context you',
353
+ 'can go and read, then your own judgement. A rule marked for one codebase beats a rule for the',
354
+ 'whole project when you are working in that codebase.',
355
+ 'If what you were asked for is the opposite of a rule below, do not settle it quietly either',
356
+ 'way: the person is the one who decides which they meant, and asking is how you find out.',
357
+ ...documents.flatMap((document) => [
358
+ '',
359
+ `--- ${document.title}${document.codebase_id === null ? '' : ' (this codebase only)'} ---`,
360
+ document.content,
361
+ ]),
362
+ ];
363
+ };
364
+ /** What each ending means for the agent, and all three say the same thing about
365
+ * the agent's own part in it: landing is the product's, never yours. */
366
+ const LANDING_LINES = {
367
+ pr: [
368
+ 'Finished work in this codebase lands on a branch, in a pull request that a person reviews.',
369
+ 'Landing is the product\'s job and never yours: do not merge and do not push.',
370
+ ],
371
+ main: [
372
+ 'Finished work in this codebase lands on the main branch, and the person is asked first.',
373
+ 'When the card\'s work is done, call `offer_ending` with one sentence saying what was done.',
374
+ 'The product writes the question and both answers, and it performs the merge itself if they',
375
+ 'choose to put the work back. Landing is the product\'s job and never yours: never merge and',
376
+ 'never push.',
377
+ ],
378
+ branch: [
379
+ 'Finished work in this codebase stays on its branch. Nothing is pushed and nothing is merged.',
380
+ 'Say where the work is in your answer.',
381
+ ],
382
+ };
383
+ export const workingRules = (rules) => [
384
+ `WORKING IN ${rules.codebase}`,
385
+ ...(rules.branch === null
386
+ ? [
387
+ 'Every card sent here works in a copy of its own, on a branch of its own, which the product',
388
+ `makes before any agent starts. Branches are named ${rules.branchPattern}.`,
389
+ ]
390
+ : [
391
+ `This is your card's own copy of this codebase, on branch ${rules.branch}. The product made both.`,
392
+ 'Everybody working this card shares this copy and this branch; nobody outside it does.',
393
+ 'It is a fresh checkout of tracked files only, so install and build before assuming anything',
394
+ 'is there. Never reach into another card\'s copy or branch.',
395
+ ]),
396
+ ...(rules.base === null ? [] : [
397
+ `Work starts from ${rules.base}, which was brought up to date when your copy was made.`,
398
+ `Never commit to ${rules.base}. Git itself refuses it from here, because ${rules.base} is`,
399
+ 'checked out somewhere else. If you are refused, say so with `say` and carry on: it is a rule',
400
+ 'holding, not a failure.',
401
+ ]),
402
+ ...LANDING_LINES[rules.landing],
403
+ ];
322
404
  const projectCodebases = (codebases) => {
323
405
  if (codebases === null)
324
406
  return [];
@@ -396,6 +478,16 @@ export function levelOnePrompt(cardTitle, messages, produced, attachments = [],
396
478
  'Your only question is a destination or codebase you truly cannot choose. NEVER ask the person',
397
479
  'to make a work or product decision. When one registered codebase clearly fits, launch its owner',
398
480
  'even if the work itself needs the person to choose between options; the owner asks that question.',
481
+ /* ═══ THE TWO WAYS OUT, NAMED WHATEVER THE CARD IS FILED UNDER. ═══
482
+ Both endings are a TOOL CALL, and prose is neither of them. This used to
483
+ be said only in the codebase list, so a card with no project was told to
484
+ ask and never told what asking is: measured 2 of 5 level 1 codex runs
485
+ wrote the question as an ordinary reply, which exits 0 with a message,
486
+ reads as a real answer, and lands as the run row `the launcher ended
487
+ without appointing an owner or asking a routing question`. */
488
+ 'BOTH OF THOSE ARE A TOOL CALL AND NOTHING ELSE: `dispatch` to launch the owner, `ask_question`',
489
+ 'to ask. Writing either one as your reply does neither. Nobody reads this launcher\'s prose, so a',
490
+ 'question written as text is never delivered and the conversation is left with no agent on it.',
399
491
  ...projectCodebases(codebases),
400
492
  '',
401
493
  ...WHILE_YOU_ARE_WORKING,
@@ -664,6 +756,52 @@ export const presentedArtifactAnswerContext = (artifact) => artifact === null
664
756
  'current revision with this one. If they differ, this answer does not approve the current body:',
665
757
  'present the current revision in a new approval question and stop.',
666
758
  ];
759
+ export const landingOutcomeContext = (landing) => {
760
+ if (landing === null)
761
+ return [];
762
+ const head = ['', 'WHAT THE PRODUCT DID WITH THAT ANSWER'];
763
+ if (landing.outcome === 'landed') {
764
+ return [
765
+ ...head,
766
+ `They chose to put this work back, and the product has merged ${landing.branch} onto `
767
+ + `${landing.base} on this machine. It is done and it is not yours to do.`,
768
+ 'Say so plainly in your reply, name the branch it went onto, and finish the card.',
769
+ ];
770
+ }
771
+ if (landing.outcome === 'leave') {
772
+ return [
773
+ ...head,
774
+ 'They chose to leave this work on its branch. Nothing was merged and nothing was pushed.',
775
+ `Say so, name the branch${landing.branch === null ? '' : ` (${landing.branch})`} so they can `
776
+ + 'find it, and finish the card.',
777
+ ];
778
+ }
779
+ if (landing.outcome === 'unclear') {
780
+ return [
781
+ ...head,
782
+ 'They chose neither of the two answers, so nothing was merged and the work is still on '
783
+ + `${landing.branch ?? 'its branch'}.`,
784
+ 'Read what they actually wrote and answer it. If they still want the work put back, offer the',
785
+ 'ending again with `offer_ending` rather than replying: a reply finishes the card, and an',
786
+ 'offer leaves it with them.',
787
+ ];
788
+ }
789
+ /* ═══ IT DOES NOT SAY "DO NOT FINISH THE CARD", BECAUSE THE AGENT CANNOT
790
+ OBEY THAT. ═══ The card reads done the moment the owner replies, by
791
+ `panel3_answer`, and nothing an agent writes changes it. Telling it
792
+ otherwise would be a rule that can only be broken. What it can do is say
793
+ plainly that the work did not go back and where it still is, which is what
794
+ the person needs; C3 replaces this relay with a question, and a question is
795
+ what keeps the card open. */
796
+ return [
797
+ ...head,
798
+ 'They chose to put this work back and the product could not do it:',
799
+ landing.because,
800
+ 'Tell them that in your reply, in their words: the work did NOT go back, nothing was merged,',
801
+ 'and it is still on its branch. Say what would have to happen for it to go back. Never merge',
802
+ 'anything yourself, and never write as though it landed.',
803
+ ];
804
+ };
667
805
  /**
668
806
  * ═══ WHAT A RUN IS TOLD WHEN A PROCESS OF ITS OWN ENDED BEFORE IT FINISHED.
669
807
  * ═══
@@ -701,7 +839,10 @@ export function ownerActivationPrompt(brief, report, events, children, childQues
701
839
  * may hold work no report mentions and the worker list may hold one it
702
840
  * dispatched a moment before. Never says WHICH ending it was: see
703
841
  * `LOOK_AT_THE_WORK_ITSELF`. */
704
- afterAProcessEnded = false) {
842
+ afterAProcessEnded = false,
843
+ /** What the product did with the answer this activation is delivering, when
844
+ * that answer was the product's own ending offer. See `landingOutcomeContext`. */
845
+ landing = null) {
705
846
  const history = events.map((event) => {
706
847
  if (event.kind === 'question') {
707
848
  return [
@@ -738,6 +879,7 @@ afterAProcessEnded = false) {
738
879
  'PRIVATE WORKER SNAPSHOT',
739
880
  ...(children.length === 0 ? ['You have sent nobody.'] : children),
740
881
  ...(afterAProcessEnded ? ['', LOOK_AT_THE_WORK_ITSELF, DO_NOT_SEND_SOMEBODY_TWICE] : []),
882
+ ...landingOutcomeContext(landing),
741
883
  '',
742
884
  // THE BRIEF IS LAST, and these go before it: the brief ends with how to
743
885
  // write the reply, and anything after it pushes that ending into the middle.
@@ -826,7 +968,7 @@ export function retryPrompt(brief, report, children) {
826
968
  * something the agent is better placed to see." So it is named as a judgement
827
969
  * the agent makes, in one sentence, rather than a rule about when to ask.
828
970
  */
829
- export function answerPrompt(brief, report, children, question, answer, relatedArtifact = null) {
971
+ export function answerPrompt(brief, report, children, question, answer, relatedArtifact = null, landing = null) {
830
972
  return respawnPrompt([
831
973
  'YOU STOPPED BECAUSE YOU COULD NOT GO ON WITHOUT KNOWING SOMETHING. HERE IT IS.',
832
974
  'You are the same agent, started again, and everything you did before you asked is still there.',
@@ -837,6 +979,7 @@ export function answerPrompt(brief, report, children, question, answer, relatedA
837
979
  'WHAT CAME BACK',
838
980
  answer,
839
981
  ...presentedArtifactAnswerContext(relatedArtifact),
982
+ ...landingOutcomeContext(landing),
840
983
  '',
841
984
  'If this answers less than you asked, or turns out to change more than the piece you were sent',
842
985
  'to do, ask again rather than deciding it yourself. Carrying on from a guess about something this',