@ctrl-spc/cs 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@
8
8
  * `startPanel`'s own comment at the foot of this file carries the reasoning.
9
9
  *
10
10
  * ---------------------------------------------------------------------------
11
- * WHAT THIS IS. `cs3 say` puts a card and a user turn on the record and returns,
11
+ * WHAT THIS IS. `cs say` puts a card and a user turn on the record and returns,
12
12
  * because ux.md requires the acknowledgement never to wait for a spawn. This is
13
13
  * the other half: the loop that notices the turn, leases it, writes a run row,
14
14
  * starts a real agent, and puts what that agent said back on the card.
@@ -132,11 +132,12 @@
132
132
  // and takes the leading comment with it, so a file whose first statement is
133
133
  // `import type` loses its v3 header in the published `dist/`.
134
134
  import { out, returned, signedInClient } from './client.js';
135
- import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, presentedArtifactAnswerContext, resumePrompt, retryPrompt, } from './prompt.js';
136
- import { ASK_CONTENT_COLUMNS, attachmentLine, loadAttachments, loadOutputNames, outputOf, withAskContent, } from './show.js';
135
+ import { answerPrompt, escalationPrompt, levelOnePrompt, ownerActivationPrompt, readBackPrompt, landingOutcomeContext, presentedArtifactAnswerContext, resumePrompt, retryPrompt, standingRules, whatWasAttached, workingRules, } from './prompt.js';
136
+ import { ASK_CONTENT_COLUMNS, attachmentLine, gitRulesFor, loadAttachments, loadOutputNames, outputOf, recordBaseProtection, standingRulesFor, withAskContent, } from './show.js';
137
137
  import { forgetSecrets, redactSecrets } from './secrets.js';
138
138
  import { sayListening, stopListening } from './presence.js';
139
- import { checkoutForCodebase, hasCheckoutForCodebase } from './checkout.js';
139
+ import { designatedCoordinator } from './coordinator.js';
140
+ import { baseBranchState, checkoutForCodebase, commitCardWork, detectBaseProtection, folderIsBranch, hasCheckoutForCodebase, mergeIntoBase, releaseBaseBranch, settleCardWorktree, worktreeForCard, worktreesOnThisMachine, } from './checkout.js';
140
141
  import { harness, startAgent } from './spawn.js';
141
142
  import { establishOwnerSession, listOwnerSessionIds, OWNER_SESSION_GRACE_MS, readOwnerSession, removeOwnerSession, validSessionUuid, writeOwnerSession, } from './session.js';
142
143
  import { startToolsServer } from './tools.js';
@@ -145,7 +146,7 @@ import { getMachineIdentity, scratchDir } from '../config.js';
145
146
  import { listCodebases } from '../codebases.js';
146
147
  import { killTree, processIsAlive } from '../win-shell.js';
147
148
  import { hostname, uptime } from 'node:os';
148
- const USAGE = 'usage: cs3 run [--once]';
149
+ const USAGE = 'usage: run [--once]';
149
150
  /** How long between takes. Short, because it is the whole delay between a user
150
151
  * sending and a card showing an agent on it, and the take is one small indexed
151
152
  * read against the user's own rows. */
@@ -224,6 +225,62 @@ async function rearmedArtifactAnswer(client, askId) {
224
225
  ? null
225
226
  : artifactAnswer(ask.related_artifact_id, ask.related_artifact_revision, ask.selected_options);
226
227
  }
228
+ const LAND = 'Put this work on the main branch';
229
+ const LEAVE = 'Leave it on its branch';
230
+ export async function landingOffer(client, askId) {
231
+ const asks = await withAskContent(client, await returned(client.from('panel3_asks').select(`id, offers_landing, ${ASK_CONTENT_COLUMNS}`).eq('id', askId), 'read', `question ${askId}`));
232
+ const ask = asks[0];
233
+ if (ask === undefined || ask.offers_landing !== true)
234
+ return null;
235
+ const selected = ask.selected_options ?? [];
236
+ if (selected.length !== 1)
237
+ return 'unclear';
238
+ return selected[0] === LAND ? 'land' : selected[0] === LEAVE ? 'leave' : 'unclear';
239
+ }
240
+ /**
241
+ * ═══ THE PRODUCT PERFORMS THE LANDING, HERE, BEFORE THE AGENT THAT WILL SPEAK
242
+ * ABOUT IT STARTS. ═══
243
+ *
244
+ * Not a tool, because a tool means an agent decides whether a yes takes effect,
245
+ * and an agent that finishes the card instead leaves a person who clicked yes
246
+ * with nothing. Not the poll's sweep, because the sweep cannot tell the agent
247
+ * what it did.
248
+ *
249
+ * ═══ AND A FAILURE DOES NOT END THE RUN. ═══ The agent has to be started to
250
+ * tell the person, so what happened is returned as the sentence it will be
251
+ * handed rather than written to `failed_because`.
252
+ */
253
+ export function landCardWork(where, outcome) {
254
+ const card = where.card;
255
+ if (outcome !== 'land')
256
+ return { outcome, branch: card?.branch ?? null, base: card?.base ?? null };
257
+ if (card === null) {
258
+ return { outcome: 'refused', because: 'This card has no working copy on this machine, so there is nothing to put back.' };
259
+ }
260
+ if (card.landing !== 'main') {
261
+ return {
262
+ outcome: 'refused',
263
+ because: `Finished work in ${card.codebaseName} does not go onto ${card.base}, so nothing `
264
+ + 'was merged. The work is on its branch.',
265
+ };
266
+ }
267
+ try {
268
+ /* THE COMMIT FIRST, AND ITS ABSENCE IS NOT A FAILURE. Between the answer and
269
+ this re-arm every run on the card has ended, so the sweep may already have
270
+ committed the copy and taken it away; `workingDirectory` has just rebuilt
271
+ it from the branch, and the only difference is that there is nothing left
272
+ to commit. */
273
+ commitCardWork(card.folder, `${card.codebaseName}'s copy of this card on branch ${card.branch}`);
274
+ mergeIntoBase(card.source, card.branch, card.base, card.codebaseName);
275
+ return { outcome: 'landed', branch: card.branch, base: card.base };
276
+ }
277
+ catch (error) {
278
+ return {
279
+ outcome: 'refused',
280
+ because: error instanceof Error ? error.message : String(error),
281
+ };
282
+ }
283
+ }
227
284
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
228
285
  /**
229
286
  * ═══ NOTHING IN THIS FILE STAMPS A COLUMN FROM THIS MACHINE'S CLOCK ANY MORE,
@@ -292,7 +349,7 @@ function byCard(taken) {
292
349
  * `levelOnePrompt` for the measured failure this closes.
293
350
  *
294
351
  * THE NAME IS RESOLVED, NOT READ OFF THE RECEIPT, through the one function
295
- * `cs3 show` uses for it. `label` is written when the thing is made and the
352
+ * `cs show` uses for it. `label` is written when the thing is made and the
296
353
  * coordinator may rename the thing on the next turn; printing the label then
297
354
  * hands the agent the name of a different, real object. One owner for that,
298
355
  * because two would eventually disagree about which name a card is showing.
@@ -344,6 +401,75 @@ export const briefFor = (title, turns, produced, attachments, codebases = null)
344
401
  identity: codebase.gitRemoteUrl,
345
402
  located: hasCheckoutForCodebase(codebase),
346
403
  })) ?? null);
404
+ /**
405
+ * ═══ THE MANDATE IN FRONT OF THE PROMPT, AT EVERY ONE OF THE FIVE STARTS. ═══
406
+ *
407
+ * 19/project-context-7b, contract point 1: everything written under Agent
408
+ * instructions reaches every panel agent, at levels 1, 2 and 3, before it acts.
409
+ * Five call sites start a process (`answerCard`, `startChild`, `resumeRun`,
410
+ * `startRearmed`, `activateOwner`), and this is the one place the block and a
411
+ * prompt are put together, so the wording and the ordering cannot drift between
412
+ * them. The READ is `standingRulesFor` and the WORDS are `standingRules`; this
413
+ * is only the join.
414
+ *
415
+ * ═══ AND IT IS APPLIED TO WHAT `startAgent` IS HANDED, NEVER TO WHAT
416
+ * `recordProcess` WRITES. ═══ Contract point 2 lives or dies on that
417
+ * distinction. `panel3_runs.brief` is the immutable brief and every respawn
418
+ * replays it, so a block folded into the stored brief would be a snapshot of the
419
+ * rules as they stood at dispatch, replayed on top of a fresh copy at every
420
+ * later start — two sets of rules in one prompt, the stale one first. Keeping it
421
+ * out means the stored brief is byte-identical with and without this feature,
422
+ * which is asserted rather than assumed.
423
+ *
424
+ * ═══ NO WRAPPER AROUND THE FIVE STARTS. ═══ `startAgent` is synchronous and the
425
+ * five sites have five different endings (see `run.ts`'s own note at the level 1
426
+ * start: `endRun` with a level fork at two of them, `giveUp` at two more). The
427
+ * READ therefore sits inside each site's existing `try`, taking that site's
428
+ * ending, so a failure to read the mandate ends the run the way that path
429
+ * already ends runs and never starts a process without it (contract point 3).
430
+ * What is shared is the loader and the builder — which is where drift would
431
+ * actually happen.
432
+ */
433
+ // Exported for the same reason `briefFor` is: the seam this feature crosses is
434
+ // the string handed to `startAgent`, and a test over `toolHandler('dispatch')`
435
+ // would prove nothing about it — `caller.dispatch` is injected, so it stops
436
+ // upstream of this composer.
437
+ export const withStandingRules = (documents, prompt,
438
+ /* ═══ OPTIONAL, SO THE FOUR SITES WITH A CODEBASE GAIN IT AND NOTHING ELSE
439
+ MOVES. ═══ worktrees-8's three sentences about the card's own copy join
440
+ here rather than in the brief, for the same reason the rules do: the brief
441
+ is immutable and replayed, and the branch is not known when it is composed.
442
+ Under the rules, because a standing rule the people wrote outranks a fact
443
+ about the machine. */
444
+ whereYouAre = [],
445
+ /* ═══ WHAT THE PERSON HAS ATTACHED, RIGHT NOW, FOR THE OWNER THAT LIVES FOR
446
+ THE WHOLE CARD. ═══ attaching-after-the-fact-10. Its own parameter rather
447
+ than folded into `whereYouAre`, which is worktrees-8's sentences about the
448
+ card's copy of the codebase: what is attached is not a fact about the
449
+ machine, and `where.block` is `[]` for a level 2 owner on a record-only
450
+ card, so folding in would produce a leading blank with no separator.
451
+
452
+ ═══ ABOVE THE RULES, BECAUSE THE RULES SAY IT OUTRANKS THEM. ═══
453
+ `standingRules`' own precedence line ranks what the person attached above
454
+ the rules; rendering it underneath would put it below a block saying it
455
+ wins. Already carries its own header and its own leading blank, which this
456
+ join absorbs the same way it absorbs `whereYouAre`'s. */
457
+ whatIsAttached = []) => {
458
+ /* `whatWasAttached` begins its array with a blank, since its two other
459
+ callers join it onto lines already above it. Here it is the first thing in
460
+ the prompt, and a prompt that opens on an empty line is a prompt with a
461
+ stray blank at the top, so the leading blanks are dropped and the separator
462
+ below is this join's own. */
463
+ const attached = [...whatIsAttached];
464
+ while (attached[0] === '')
465
+ attached.shift();
466
+ const block = [
467
+ ...(attached.length === 0 ? [] : [...attached, '']),
468
+ ...standingRules(documents),
469
+ ...(whereYouAre.length === 0 ? [] : ['', ...whereYouAre]),
470
+ ];
471
+ return block.length === 0 ? prompt : [...block, '', prompt].join('\n');
472
+ };
347
473
  // ---------------------------------------------------------------------------
348
474
  // WRITES. Every one goes through the shared guard in `client.ts`, which returns
349
475
  // the rows or throws. `.select()` on each is what makes that possible: a
@@ -554,7 +680,7 @@ async function runNow(client, runId) {
554
680
  * ═══ NEVER INTO `report`, WHICH IS THE RUN'S OWN WORDS. ═══ `report` is what a
555
681
  * respawn is handed, and this build starts settled runs again — an answered
556
682
  * question does exactly that — so a reason written there would destroy the state
557
- * the next attempt was going to resume from, and `cs3 show` would print the
683
+ * the next attempt was going to resume from, and `cs show` would print the
558
684
  * daemon's sentence under a heading that says the run wrote it. One owner per
559
685
  * column: the run writes `report`, whoever ended it writes `failed_because`.
560
686
  *
@@ -615,7 +741,7 @@ async function setCardState(client, cardId, state) {
615
741
  * ═══ THERE IS NOTHING TO UNDO HERE, AND THAT IS THE POINT. ═══ The take wrote
616
742
  * the run row in the same statement that leased the turns, so this function is
617
743
  * never in the position of holding a lease it cannot honour: whatever it fails
618
- * at, the row exists, `cs3 show` can see it, and recovery can reach it. The
744
+ * at, the row exists, `cs show` can see it, and recovery can reach it. The
619
745
  * earlier version wrote the row two round trips later and had to hand the turns
620
746
  * back on failure — which covered a thrown error and not a kill, and a kill in
621
747
  * that window stranded the card forever.
@@ -653,8 +779,22 @@ async function answerCard(client, tools, machineId, cardId, turns) {
653
779
  database, whose messages name tables and columns and never a local path, so
654
780
  constraint 6 is satisfied without a level fork here. */
655
781
  let brief;
782
+ /* WHERE IT RUNS AND WHAT IT IS TOLD ABOUT THE CODEBASES, THROUGH THE SAME
783
+ SEAM AS EVERY OTHER SPAWN. A level 1 run works in an empty directory of the
784
+ user's own, and it is the fifth start site rather than a special case: the
785
+ generated git block reaches every level, and one place resolving it is what
786
+ makes that true without four copies of the read. */
787
+ let where;
788
+ /* THE MANDATE IS READ IN THE SAME WINDOW AND UNDER THE SAME RULE. A launcher
789
+ is an agent like any other and gets the project's standing rules before it
790
+ decides anything, and a read that fails must not read as "this project has
791
+ no rules" — that is a different project than the one the person is on. So it
792
+ joins the three reads above inside this ending rather than beside it. */
793
+ let rules;
656
794
  try {
657
795
  brief = briefFor(turns[0].card_title, turns, await receiptsFor(client, cardId), await attachmentsFor(client, cardId), await codebasesForRun(client, runId));
796
+ rules = await standingRulesFor(client, runId);
797
+ where = await workingDirectory(client, runId, LEVEL, false);
658
798
  }
659
799
  catch (error) {
660
800
  const why = error instanceof Error ? error.message : String(error);
@@ -670,9 +810,12 @@ async function answerCard(client, tools, machineId, cardId, turns) {
670
810
  /* AN EMPTY DIRECTORY OF THE USER'S OWN, because level 1 has no code tool to
671
811
  use a real one with, and the daemon's inherited cwd under a launchd login
672
812
  item is the filesystem root. */
673
- const started = startAgent(brief, LEVEL, tools.urlFor(runId), scratchDir());
813
+ const started = startAgent(withStandingRules(rules, brief, where.block), LEVEL, tools.urlFor(runId), where.cwd);
674
814
  out(`run ${runId} level ${LEVEL} ${started.pid ? `pid ${started.pid}` : 'no process'}`);
675
815
  try {
816
+ /* THE BRIEF, NOT WHAT THE PROCESS WAS HANDED. The rules are current at the
817
+ activation and the brief is immutable, so storing the composed string
818
+ would freeze one inside the other and every respawn would replay it. */
676
819
  await recordProcess(client, runId, started.pid, brief);
677
820
  }
678
821
  catch (error) {
@@ -720,16 +863,178 @@ async function codebaseOfRun(client, runId) {
720
863
  }
721
864
  return codebase;
722
865
  }
866
+ /**
867
+ * ═══ THE CARD'S OWN COPY, AND THE BRANCH IT IS ON, STAMPED BEFORE IT IS USED.
868
+ * ═══
869
+ *
870
+ * The card is the unit of isolation, so the branch is derived from the card and
871
+ * every run on that card resolves to the same one. A run that finds a branch
872
+ * already stamped by a sibling takes it rather than deriving again: the
873
+ * derivation is pure today and stops being pure the moment the pattern becomes
874
+ * a setting, and one card on two branches is the defect this whole slice exists
875
+ * to prevent.
876
+ *
877
+ * ═══ THE STAMP IS WRITTEN HERE, WHERE ITS FAILURE CAN STOP THE RUN. ═══ Not in
878
+ * `recordProcess`, whose failure is deliberately non-fatal at all five sites: a
879
+ * stamp allowed to silently not happen cannot carry "a resumed run returns to
880
+ * the same copy". This is before `startAgent`, so a failure to write it is a run
881
+ * that never started rather than a run whose copy nobody can find again.
882
+ */
883
+ async function cardWorktree(client, runId, codebase) {
884
+ const rows = await returned(client.from('panel3_runs')
885
+ .select('card_id, branch, base, card:panel3_cards!panel3_runs_card_id_fkey(title)')
886
+ .eq('id', runId), 'read', `which card and branch run ${runId} is on`);
887
+ const row = rows[0];
888
+ if (!row)
889
+ throw new Error(`run ${runId} is not on the record any more`);
890
+ /* THE CARD'S OWN ANSWER FIRST, from whichever of its runs has one. Asked only
891
+ when this run has none of its own, so a resumed run costs one read. The
892
+ BASE travels with the branch, in one row and one read: they were written
893
+ together and a card cut from `develop` may not be told later that it goes
894
+ back to `main` because a teammate changed the setting. */
895
+ const sibling = row.branch !== null ? null : (await returned(client.from('panel3_runs')
896
+ .select('branch, base')
897
+ .eq('card_id', row.card_id)
898
+ .not('branch', 'is', null)
899
+ .limit(1), 'read', `the branch card ${row.card_id} is working on`))[0] ?? null;
900
+ const stamped = {
901
+ branch: row.branch ?? sibling?.branch ?? null,
902
+ base: row.base ?? sibling?.base ?? null,
903
+ };
904
+ /* ═══ THE TEAM'S RULES, READ AT THE START AND NOT CARRIED. ═══ Same argument
905
+ as the standing rules beside them: a rule edited while a conversation is
906
+ running has to govern the rest of it, and the only way that is true is if
907
+ the read happens here rather than once, at dispatch. */
908
+ /* THE PROJECT COMES OFF THE RUN'S OWN CARD, which is where the codebase came
909
+ from too: the rules are the organization's, and the organization is reached
910
+ through the project rather than through the codebase row, which `panel3/`
911
+ may not name. */
912
+ const projectId = await projectOfRun(client, runId);
913
+ if (!projectId) {
914
+ /* UNREACHABLE THROUGH `codebaseOfRun`, WHICH REFUSES A RUN WITH NO PROJECT,
915
+ and said out loud rather than papered over with an empty id: the rules are
916
+ the organization's, and there is no organization without a project. */
917
+ throw new Error('This code work is not filed under a project, so its codebase has no rules.');
918
+ }
919
+ const rules = await gitRulesFor(client, codebase.id, projectId);
920
+ const { folder, branch, base, source } = worktreeForCard(codebase, row.card_id, row.card?.title ?? '', stamped, hostname(), { baseBranch: rules.base_branch, branchPattern: rules.branchPattern });
921
+ if (row.branch !== branch || row.base !== base) {
922
+ await returned(client.from('panel3_runs').update({ branch, base }).eq('id', runId).select('id'), 'record which branch this run is on', `run ${runId}`);
923
+ }
924
+ await refreshBaseProtection(client, codebase, rules, source, base);
925
+ return {
926
+ cwd: folder,
927
+ block: workingRules({
928
+ codebase: codebase.name,
929
+ branch,
930
+ base,
931
+ landing: rules.landing,
932
+ branchPattern: rules.branchPattern,
933
+ }),
934
+ /* ═══ HANDED BACK RATHER THAN DISCARDED. ═══ The landing needs the folder,
935
+ the branch, the base and the repository the copy was made from, all of
936
+ which have just been resolved here. Resolving them again at the ending
937
+ would be a second answer to one question, free to disagree with this one. */
938
+ card: { folder, branch, base, source, landing: rules.landing, codebaseName: codebase.name },
939
+ };
940
+ }
941
+ /**
942
+ * ═══ WHETHER THE BASE BRANCH IS PROTECTED, ASKED WHILE THIS MACHINE IS ALREADY
943
+ * HOLDING THE REPOSITORY OPEN. ═══
944
+ *
945
+ * The browser cannot answer it and the product stores no platform credential, so
946
+ * the machine with the checkout answers it through the developer's own `gh`.
947
+ * Asked only when the record has no answer, so a codebase whose protection is
948
+ * known costs nothing, and never turned into a SETTING: it is an observation
949
+ * that feeds the detected default, and `landing` stays null until a person
950
+ * chooses.
951
+ *
952
+ * ═══ SAID, NEVER FATAL. ═══ This is a fact about the remote, not about the
953
+ * work. A card that could not record an observation still runs.
954
+ */
955
+ async function refreshBaseProtection(client, codebase, rules, source, base) {
956
+ if (rules.base_protected !== null)
957
+ return;
958
+ try {
959
+ const observed = detectBaseProtection(source, base);
960
+ if (observed !== null)
961
+ await recordBaseProtection(client, codebase.id, observed);
962
+ }
963
+ catch (error) {
964
+ said(`could not check whether ${base} is protected: ${error instanceof Error ? error.message : String(error)}`);
965
+ }
966
+ }
967
+ /**
968
+ * ═══ WHAT THE COORDINATOR IS TOLD ABOUT EACH CODEBASE IT MAY SEND WORK TO. ═══
969
+ *
970
+ * Story 6 scenario 4: levels 1, 2 and 3 each receive the block verbatim. A level
971
+ * 1 run has no codebase of its own by design, and the block is codebase-scoped,
972
+ * so at level 1 it is rendered once PER REGISTERED CODEBASE. That is the same
973
+ * text rather than a summary of it, and it is what stops a coordinator writing a
974
+ * brief that contradicts the rules the worker it dispatches will be handed.
975
+ *
976
+ * ═══ AND IT CARRIES THE ONE QUESTION ONLY THE COORDINATOR CAN ASK. ═══ Story 3
977
+ * scenario 3: a codebase whose base branch is genuinely ambiguous has to be
978
+ * settled by a person, once, and the daemon cannot ask anybody. The coordinator
979
+ * can, it holds `ask_question` already, and it needs the fact BEFORE it
980
+ * dispatches. So the fact and the options arrive here, at the activation, beside
981
+ * the block.
982
+ *
983
+ * ═══ NOT THROUGH `briefFor`. ═══ That output is `panel3_runs.brief`, which is
984
+ * immutable and replayed at every respawn, so a coordinator resumed a week later
985
+ * would be told to ask about a branch somebody settled on Tuesday.
986
+ */
987
+ async function coordinatorGitBlock(client, runId) {
988
+ const projectId = await projectOfRun(client, runId);
989
+ /* A CARD FILED UNDER NO PROJECT HAS NO CODEBASES AND NO RULES, which is a
990
+ fact rather than a failure: level 1 answers plenty of cards that never touch
991
+ code. */
992
+ if (!projectId)
993
+ return [];
994
+ const codebases = await listCodebases(client, projectId);
995
+ if (codebases.length === 0)
996
+ return [];
997
+ const blocks = [];
998
+ for (const codebase of codebases) {
999
+ const rules = await gitRulesFor(client, codebase.id, projectId);
1000
+ /* A CODEBASE THIS MACHINE HAS NOT LOCATED ANSWERS NEITHER WAY, and is left
1001
+ out rather than guessed at: `briefFor` already tells the coordinator which
1002
+ codebases are located here, and inventing rules for one nobody can see
1003
+ would be a block about a repository this run cannot reach. */
1004
+ const state = baseBranchState(codebase, rules.base_branch);
1005
+ if (state === null)
1006
+ continue;
1007
+ blocks.push([
1008
+ ...workingRules({
1009
+ codebase: codebase.name,
1010
+ branch: null,
1011
+ base: 'settled' in state ? state.settled : null,
1012
+ landing: rules.landing,
1013
+ branchPattern: rules.branchPattern,
1014
+ }),
1015
+ ...('candidates' in state ? [
1016
+ `Nobody has said which branch finished work in ${codebase.name} should go back to, and this`,
1017
+ 'machine cannot tell. Before you send anybody into it, ask the person with `ask_question`,',
1018
+ 'once, offering these as the options and using no git word except "branch":',
1019
+ state.candidates.join(', '),
1020
+ `Then record their answer with \`set_main_branch\` for codebase id ${codebase.id}. It is`,
1021
+ 'stored on the codebase, so nobody is asked again.',
1022
+ ] : []),
1023
+ ]);
1024
+ }
1025
+ return blocks.flatMap((block, index) => (index === 0 ? block : ['', ...block]));
1026
+ }
723
1027
  /** One directory rule for every spawn whose level and ownership are known. */
724
1028
  async function workingDirectory(client, runId, level, isOwner, knownCodebase) {
725
- if (level === 1)
726
- return scratchDir();
1029
+ if (level === 1) {
1030
+ return { cwd: scratchDir(), block: await coordinatorGitBlock(client, runId), card: null };
1031
+ }
727
1032
  if (knownCodebase === null) {
728
1033
  if (level === 2 && isOwner)
729
- return scratchDir();
1034
+ return { cwd: scratchDir(), block: [], card: null };
730
1035
  throw new Error('This code work does not name a registered project codebase.');
731
1036
  }
732
- return checkoutForCodebase(knownCodebase ?? await codebaseOfRun(client, runId), hostname());
1037
+ return cardWorktree(client, runId, knownCodebase ?? await codebaseOfRun(client, runId));
733
1038
  }
734
1039
  /**
735
1040
  * ═══ ONE AGENT, STARTED UNDER ANOTHER THAT IS STILL RUNNING. ═══
@@ -777,13 +1082,18 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
777
1082
  throw new Error(why);
778
1083
  }
779
1084
  const level = row.run_level;
780
- let cwd;
1085
+ let where;
781
1086
  let prompt;
1087
+ /* READ AGAINST THE CHILD'S OWN RUN ROW, NOT THE PARENT'S. The row already
1088
+ exists (`panel3_dispatch` wrote it above) and it carries this child's
1089
+ codebase, which is what decides which codebase-scoped rules it is under. */
1090
+ let rules;
782
1091
  try {
783
- cwd = await workingDirectory(client, row.run_id, level, level === 2, codebase);
1092
+ where = await workingDirectory(client, row.run_id, level, level === 2, codebase);
784
1093
  prompt = level === 2
785
1094
  ? await initialOwnerPrompt(client, row.run_card_id, parentRunId, brief)
786
1095
  : brief;
1096
+ rules = await standingRulesFor(client, row.run_id);
787
1097
  }
788
1098
  catch (error) {
789
1099
  const why = error instanceof Error ? error.message : String(error);
@@ -792,7 +1102,7 @@ async function startChild(client, tools, machineId, parentRunId, brief, codebase
792
1102
  }
793
1103
  const processToken = row.process_token ?? undefined;
794
1104
  const isOwner = level === 2;
795
- const started = startAgent(prompt, level, tools.urlFor(row.run_id, processToken), cwd, isOwner ? { ownerId: row.run_id } : undefined);
1105
+ const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id, processToken), where.cwd, isOwner ? { ownerId: row.run_id } : undefined);
796
1106
  if (started.pid === null) {
797
1107
  /* THERE IS A ROW AND THERE IS NO PROCESS, which is the one shape the record
798
1108
  must never be left in quietly. The answer is already settled — nothing ran
@@ -1174,14 +1484,20 @@ function settle(client, tools, machineId, level, runId, cardId, started, speaksT
1174
1484
  * about the failure it was holding.
1175
1485
  */
1176
1486
  async function resumeRun(client, tools, machineId, runId, afterPid) {
1177
- /* ═══ THE SWEEP RESOLVES ITS WORKING COPY BEFORE THE CLAIM AND THE RETRY
1178
- CANNOT, AND `afterPid` IS THE WHOLE OF WHAT DECIDES IT. ═══ Said once, here.
1179
- See the header for both halves of the argument: a machine with no checkout
1180
- must not take a run it cannot start, and the level a retry needs the answer
1181
- for is not known until the claim returns. */
1182
- const sweptCwd = afterPid === null
1183
- ? checkoutForCodebase(await codebaseOfRun(client, runId), hostname())
1184
- : null;
1487
+ /* ═══ THE SWEEP CHECKS BEFORE THE CLAIM AND THE RETRY CANNOT, AND `afterPid`
1488
+ IS THE WHOLE OF WHAT DECIDES IT. ═══ Said once, here. See the header for
1489
+ both halves of the argument: a machine with no checkout must not take a run
1490
+ it cannot start, and the level a retry needs the answer for is not known
1491
+ until the claim returns.
1492
+
1493
+ ═══ IT IS THE EXISTENCE CHECK AND NOTHING MORE, WHICH IS WHAT CHANGED IN
1494
+ worktrees-8. ═══ Resolving the working copy now CREATES a branch, a folder
1495
+ and a row write, and every poll tick that loses the claim race would leave
1496
+ that debris behind for a run it never took. So the pre-claim question stays
1497
+ "does this machine have this codebase at all", which is the only question
1498
+ this position was ever asking, and the copy is made after the claim. */
1499
+ if (afterPid === null)
1500
+ checkoutForCodebase(await codebaseOfRun(client, runId), hostname());
1185
1501
  const { data, error } = await client.rpc('panel3_resume', {
1186
1502
  p_run_id: runId,
1187
1503
  p_machine_id: machineId,
@@ -1202,39 +1518,34 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1202
1518
  await giveUp(client, runId, why);
1203
1519
  throw new Error(why);
1204
1520
  }
1205
- /* NON-NULL EXACTLY WHEN THE SWEEP RESOLVED ONE, which is the rule stated at
1206
- the assignment above. Branching on the VALUE rather than testing `afterPid`
1207
- a second time is what keeps the resolution and its use from being able to
1208
- disagree: whatever the sweep resolved is what the sweep runs in. */
1209
- let cwd;
1210
- if (sweptCwd !== null) {
1211
- cwd = sweptCwd;
1521
+ /* ONE RESOLUTION FOR BOTH PATHS, AFTER THE CLAIM. It used to fork on whether
1522
+ the sweep had already resolved a folder before the claim; since the copy is
1523
+ the card's own and making it writes, both paths make it here, in the branch
1524
+ that can end the run when it cannot be made. */
1525
+ let where;
1526
+ try {
1527
+ where = await workingDirectory(client, runId, level, false);
1212
1528
  }
1213
- else {
1214
- try {
1215
- cwd = await workingDirectory(client, runId, level, false);
1216
- }
1217
- catch (error) {
1218
- /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1219
- ═══ Constraint 6, and `startRearmed`'s own handling of the same two
1220
- calls: `workingCopy()` is already careful about this and says so;
1221
- `scratchDir()` is not, because it is `mkdirSync`, whose EACCES and
1222
- ENOTDIR messages name the directory they failed on. So the machine's own
1223
- error is kept for stderr and the record is told only what is true and
1224
- shareable. */
1225
- const stderrOnly = error instanceof Error ? error.message : String(error);
1226
- const why = level === 1
1227
- ? 'this machine could not make the empty directory this runs in'
1228
- : stderrOnly;
1229
- /* ═══ AND IT ENDS THE WAY A RUN OF THIS LEVEL ENDS. ═══ It was
1230
- `panel3_give_up` outright, which was right while only a dispatched run
1231
- could reach this function and is a leak now that the retry brings level
1232
- 1 here: handing the person's message back mints a new run with its
1233
- attempts at one, which is the bound the retry is under, undone by the
1234
- one path that could not start. See `endRun`. */
1235
- await endRun(client, level, runId, claimed.run_card_id, why);
1236
- throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
1237
- }
1529
+ catch (error) {
1530
+ /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1531
+ ═══ Constraint 6, and `startRearmed`'s own handling of the same two
1532
+ calls: `worktreeForCard()` is already careful about this and says so;
1533
+ `scratchDir()` is not, because it is `mkdirSync`, whose EACCES and
1534
+ ENOTDIR messages name the directory they failed on. So the machine's own
1535
+ error is kept for stderr and the record is told only what is true and
1536
+ shareable. */
1537
+ const stderrOnly = error instanceof Error ? error.message : String(error);
1538
+ const why = level === 1
1539
+ ? 'this machine could not make the empty directory this runs in'
1540
+ : stderrOnly;
1541
+ /* ═══ AND IT ENDS THE WAY A RUN OF THIS LEVEL ENDS. ═══ It was
1542
+ `panel3_give_up` outright, which was right while only a dispatched run
1543
+ could reach this function and is a leak now that the retry brings level
1544
+ 1 here: handing the person's message back mints a new run with its
1545
+ attempts at one, which is the bound the retry is under, undone by the
1546
+ one path that could not start. See `endRun`. */
1547
+ await endRun(client, level, runId, claimed.run_card_id, why);
1548
+ throw new Error(`NO AGENT IS RUNNING: ${level === 1 ? stderrOnly : why}`);
1238
1549
  }
1239
1550
  /* WHAT IT SENT OTHERS TO DO, FROM THE RECORD. Level 3 has no `dispatch`, so it
1240
1551
  has no children to have: null says that, where an empty list would say it
@@ -1247,14 +1558,36 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1247
1558
  time: ux.md's single most expensive failure. `startRearmed` reads it the
1248
1559
  same way, for the same reason. */
1249
1560
  const children = level === 3 ? null : await childrenOf(client, runId);
1561
+ /* ═══ READ AGAIN ON EVERY START, WHICH IS THE WHOLE OF CONTRACT POINT 2. ═══
1562
+ A resumed or retried run is handed the rules AS THEY STAND NOW, not as they
1563
+ stood when it first began: a rule edited while the conversation was running
1564
+ governs the rest of it, and a rule deleted while it was running stops
1565
+ applying to it. That is only true because this read happens here rather than
1566
+ once, at the top of the run's life.
1567
+
1568
+ ═══ AND IT ENDS THE WAY THIS PATH ALREADY ENDS. ═══ The claim has already
1569
+ happened, so a throw here would leave a run reading `running` with no
1570
+ process. `endRun` with the level fork is this path's own ending (see the cwd
1571
+ branch above for why level 1 may not simply be given up on), and the reason
1572
+ is a `returned()` message naming tables and columns, which carries no local
1573
+ path and is therefore shareable. */
1574
+ let rules;
1575
+ try {
1576
+ rules = await standingRulesFor(client, runId);
1577
+ }
1578
+ catch (error) {
1579
+ const why = error instanceof Error ? error.message : String(error);
1580
+ await endRun(client, level, runId, claimed.run_card_id, why);
1581
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1582
+ }
1250
1583
  const started = startAgent(
1251
1584
  /* ═══ WHY IT DIED IS WHAT DIFFERS, AND IT IS TOLD THE TRUTH ABOUT IT. ═══
1252
1585
  `resumePrompt` opens by saying the machine went down, which is true of the
1253
1586
  sweep and false of a retry: the daemon that watched this harness exit is
1254
1587
  still running. See `retryPrompt`. */
1255
- afterPid === null
1588
+ withStandingRules(rules, afterPid === null
1256
1589
  ? resumePrompt(claimed.run_brief, claimed.run_report, children)
1257
- : retryPrompt(claimed.run_brief, claimed.run_report, children), level, tools.urlFor(runId), cwd);
1590
+ : retryPrompt(claimed.run_brief, claimed.run_report, children), where.block), level, tools.urlFor(runId), where.cwd);
1258
1591
  if (started.pid === null) {
1259
1592
  /* THE CLAIM HAPPENED AND NO PROCESS DID, which is the one shape the record
1260
1593
  must never be left in quietly. Same handling as a dispatch that could not
@@ -1302,7 +1635,7 @@ async function resumeRun(client, tools, machineId, runId, afterPid) {
1302
1635
  * what stops one question, or one set of finished workers, spawning forever. The cost is that this cannot decline afterwards: a run claimed and not
1303
1636
  * started would sit `running` with no process and its question already spent. So
1304
1637
  * every failure below ends the run with its reason, which is visible on the card
1305
- * and in `cs3 show`, rather than quietly hoping the next poll finds it.
1638
+ * and in `cs show`, rather than quietly hoping the next poll finds it.
1306
1639
  */
1307
1640
  async function startRearmed(client, tools, machineId, row) {
1308
1641
  const level = row.run_level === 1 ? 1 : row.run_level === 2 ? 2 : 3;
@@ -1311,16 +1644,16 @@ async function startRearmed(client, tools, machineId, row) {
1311
1644
  await giveUp(client, row.run_id, why);
1312
1645
  throw new Error(why);
1313
1646
  }
1314
- let cwd;
1647
+ let where;
1315
1648
  try {
1316
- cwd = await workingDirectory(client, row.run_id, level, false);
1649
+ where = await workingDirectory(client, row.run_id, level, false);
1317
1650
  }
1318
1651
  catch (error) {
1319
1652
  /* ═══ THE REASON IS WRITTEN TO THE DATABASE, SO IT MAY NOT CARRY A PATH.
1320
1653
  ═══ Constraint 6. `workingCopy()` is already careful about this and says
1321
1654
  so; `scratchDir()` is not — it is `mkdirSync`, whose EACCES and ENOTDIR
1322
1655
  messages name the directory they failed on — and `failed_because` is a
1323
- column `cs3 show` prints. So the machine's own error is kept for stderr
1656
+ column `cs show` prints. So the machine's own error is kept for stderr
1324
1657
  and the record is told only what is true and shareable. */
1325
1658
  const said = error instanceof Error ? error.message : String(error);
1326
1659
  const why = level === 1
@@ -1339,6 +1672,14 @@ async function startRearmed(client, tools, machineId, row) {
1339
1672
  const deliveredArtifact = row.ask_id !== null && row.mine
1340
1673
  ? await rearmedArtifactAnswer(client, row.ask_id)
1341
1674
  : null;
1675
+ /* ═══ THE MERGE HAPPENS HERE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT
1676
+ WILL SPEAK ABOUT IT IS STARTED. ═══ The re-arm is the last thing that runs
1677
+ before the spawn, which is why it is the only place the outcome can reach
1678
+ the prompt. See `landCardWork`. */
1679
+ const offered = row.ask_id !== null && row.mine
1680
+ ? await landingOffer(client, row.ask_id)
1681
+ : null;
1682
+ const landing = offered === null ? null : landCardWork(where, offered);
1342
1683
  /* ═══ THREE REASONS, AND THE ROW SAYS WHICH. ═══ No question is ux.md's third
1343
1684
  re-arm: everybody it sent has finished, and it is started to read them back.
1344
1685
  `children` cannot be null on that path — only a run with children is ever
@@ -1347,9 +1688,21 @@ async function startRearmed(client, tools, machineId, row) {
1347
1688
  const prompt = row.ask_id === null
1348
1689
  ? readBackPrompt(row.run_brief, row.run_report, children ?? [])
1349
1690
  : row.mine
1350
- ? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact)
1691
+ ? answerPrompt(row.run_brief, row.run_report, children, row.question ?? '', row.answer ?? '', deliveredArtifact, landing)
1351
1692
  : escalationPrompt(row.run_brief, row.run_report, children, row.ask_id, row.question ?? '');
1352
- const started = startAgent(prompt, level, tools.urlFor(row.run_id), cwd);
1693
+ /* CURRENT AT THIS ACTIVATION, exactly as on the resume path, and ended the
1694
+ same way: the re-arm's claim has already happened, so a failure here ends
1695
+ the run with its reason rather than leaving it claimed with no process. */
1696
+ let rules;
1697
+ try {
1698
+ rules = await standingRulesFor(client, row.run_id);
1699
+ }
1700
+ catch (error) {
1701
+ const why = error instanceof Error ? error.message : String(error);
1702
+ await endRun(client, level, row.run_id, row.run_card_id, why);
1703
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1704
+ }
1705
+ const started = startAgent(withStandingRules(rules, prompt, where.block), level, tools.urlFor(row.run_id), where.cwd);
1353
1706
  if (started.pid === null) {
1354
1707
  const answer = await started.answered;
1355
1708
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
@@ -1469,7 +1822,7 @@ export async function initialOwnerPrompt(client, cardId, launcherRunId, brief) {
1469
1822
  }
1470
1823
  /** Only facts delivered by this activation enter an already-resumed native
1471
1824
  * conversation. Older visible turns are already in that conversation. */
1472
- export function ownerContinuationPrompt(brief, report, events, children, delivered) {
1825
+ export function ownerContinuationPrompt(brief, report, events, children, delivered, landing = null) {
1473
1826
  const messages = events.filter((event) => event.kind === 'user' && event.needsReply);
1474
1827
  return [
1475
1828
  'CONTINUE THE CTRL+SPC CONVERSATION YOU ALREADY OWN.',
@@ -1485,6 +1838,7 @@ export function ownerContinuationPrompt(brief, report, events, children, deliver
1485
1838
  `Question ${delivered.id}: ${delivered.question}`,
1486
1839
  `Answer: ${delivered.answer ?? '(no answer text)'}`,
1487
1840
  ...presentedArtifactAnswerContext(delivered.artifactAnswer),
1841
+ ...landingOutcomeContext(landing),
1488
1842
  ] : [
1489
1843
  '',
1490
1844
  'A WORKER NEEDS YOUR DECISION',
@@ -1528,7 +1882,16 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
1528
1882
  return null;
1529
1883
  const machineHarness = harness();
1530
1884
  const resumeSessionId = resumableOwnerSessionId(candidate, machineId, machineHarness);
1531
- const cwd = await ownerDirectory(client, candidate);
1885
+ /* ═══ THE EXISTENCE CHECK BEFORE THE CLAIM, AND THE COPY AFTER IT. ═══ This
1886
+ was the whole resolution, which was right while resolving meant reading a
1887
+ folder out of a file. Since worktrees-8 it also CREATES one, and a daemon
1888
+ that lost the activation race would leave a branch and a folder behind for
1889
+ an owner it never activated. The refusal this position exists for — a
1890
+ machine that does not have this codebase must not take the activation —
1891
+ is unchanged, because it is the located checkout that answers it. */
1892
+ if (candidate.codebase_id !== null) {
1893
+ checkoutForCodebase(await codebaseOfRun(client, candidate.id), hostname());
1894
+ }
1532
1895
  const { data, error } = await client.rpc('panel3_take_owner_activation', {
1533
1896
  p_run_id: runId,
1534
1897
  p_machine_id: machineId,
@@ -1553,8 +1916,60 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
1553
1916
  mine: claimed.mine === true,
1554
1917
  artifactAnswer: currentArtifactAnswer,
1555
1918
  };
1919
+ /* ═══ AFTER THE CLAIM, SO THE ENDING IS THE ONE THIS PATH HAS. ═══ Every
1920
+ failure below the claim ends the activation with `giveUp` and its process
1921
+ token; a throw above it would merely be an activation that did not happen.
1922
+
1923
+ ═══ AND IT MATTERS MOST HERE. ═══ This is the owner, which lives for the
1924
+ whole card and whose native session is RESUMED, so it is the one agent that
1925
+ can be running while a person edits or deletes a rule. Reading at every
1926
+ activation is what makes an edit govern the rest of the conversation, and
1927
+ the block's own supersession sentence is what makes a DELETION take effect
1928
+ in a session that still holds the older copy.
1929
+
1930
+ ═══ AND IT IS BEFORE THE PROMPT SINCE worktrees-8 C1, because the prompt now
1931
+ says what the product DID with the person's answer, and the landing needs
1932
+ the card's copy. Nothing in the prompt depended on it before. */
1933
+ let rules;
1934
+ /* THE CARD'S COPY IS MADE IN THE SAME WINDOW AND UNDER THE SAME ENDING, for
1935
+ the reason the check above gives: it writes, so it happens after the claim,
1936
+ and a failure to make it is an activation that ends rather than one that
1937
+ sits `running` with no process. */
1938
+ let where;
1939
+ /* ═══ AND WHAT IS ATTACHED, READ IN THE SAME WINDOW AND UNDER THE SAME
1940
+ ENDING. ═══ attaching-after-the-fact-10: a person may attach to a card that
1941
+ is already running, and the owner's brief is immutable, so the only account
1942
+ of attachments it would otherwise get is the one frozen at dispatch. Read
1943
+ at every activation, exactly as the rules are, and for the same reason a
1944
+ failed read ends the activation rather than continuing: "nothing is
1945
+ attached" is a different card from the one the person sent.
1946
+
1947
+ THE CODEBASE LINES ARE DROPPED. `whatWasAttached` partitions those into a
1948
+ section whose own text says "its one codebase is named separately below",
1949
+ a forward reference to something `workBrief` supplies and an owner
1950
+ activation does not. This block carries the PERSON's attachments; where the
1951
+ owner is working is `where.block`'s answer. */
1952
+ let attached;
1953
+ try {
1954
+ where = await ownerDirectory(client, candidate);
1955
+ rules = await standingRulesFor(client, runId);
1956
+ attached = whatWasAttached((await attachmentsFor(client, claimed.run_card_id))
1957
+ .filter((line) => !line.startsWith('codebase ')));
1958
+ }
1959
+ catch (error) {
1960
+ const why = error instanceof Error ? error.message : String(error);
1961
+ await giveUp(client, runId, why, claimed.process_token);
1962
+ throw new Error(`NO AGENT IS RUNNING: ${why}`);
1963
+ }
1964
+ /* THE MERGE, OFF THE MARK ON THE ASK, BEFORE THE AGENT THAT WILL SPEAK ABOUT
1965
+ IT IS STARTED. The owner reaches an answer by this route as often as by the
1966
+ re-arm, which is why the artifact answer is read on both and this is too. */
1967
+ const offered = claimed.ask_id !== null && claimed.mine === true
1968
+ ? await landingOffer(client, claimed.ask_id)
1969
+ : null;
1970
+ const landing = offered === null ? null : landCardWork(where, offered);
1556
1971
  const prompt = resumeSessionId
1557
- ? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered)
1972
+ ? ownerContinuationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered, landing)
1558
1973
  : ownerActivationPrompt(claimed.run_brief, claimed.run_report, events, children, delivered !== null && !delivered.mine
1559
1974
  ? { id: delivered.id, question: delivered.question }
1560
1975
  : null, currentArtifactAnswer,
@@ -1564,8 +1979,8 @@ async function activateOwner(client, tools, machineId, runId, afterProcessToken
1564
1979
  The sentences it adds say what to do and never why, because those three
1565
1980
  are not the same event and `prompt.ts` exists to stop an agent being
1566
1981
  told an untrue reason for its own restart. */
1567
- afterPid !== null);
1568
- const started = startAgent(prompt, 2, tools.urlFor(runId, claimed.process_token), cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
1982
+ afterPid !== null, landing);
1983
+ const started = startAgent(withStandingRules(rules, prompt, where.block, attached), 2, tools.urlFor(runId, claimed.process_token), where.cwd, { ownerId: runId, ...(resumeSessionId ? { resumeSessionId } : {}) });
1569
1984
  if (started.pid === null) {
1570
1985
  const answer = await started.answered;
1571
1986
  const reason = answer.ok ? 'the process ended before it could be identified' : answer.reason;
@@ -2253,7 +2668,7 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
2253
2668
  *
2254
2669
  * ═══ ONE LOOP, TWO CALLERS, AND THAT IS THE WHOLE OF recovery-1 SLICE 4. ═══
2255
2670
  * `startPanel` below is `cs start`'s entry and hands in the session the
2256
- * installed CLI already holds. `cs3 run` is the acceptance harness's entry and
2671
+ * installed CLI already holds. `panel3/cli.js run` is the acceptance harness's entry and
2257
2672
  * signs in from the environment against its own isolated account. Neither is a
2258
2673
  * copy of the other: a second poll loop would be two things leasing the same
2259
2674
  * turns with two ideas of what recovery may reap.
@@ -2262,6 +2677,135 @@ export async function takeHandedBack(client, tools, machineId, mine, hold) {
2262
2677
  * already has one. Its presence is also what says THIS PROCESS IS NOT v3's, so
2263
2678
  * the signals belong to somebody else — see the handler block below.
2264
2679
  */
2680
+ /**
2681
+ * ═══ THE CARD IS OVER, SO ITS COPY GOES — AND THE BRANCH STAYS. ═══
2682
+ *
2683
+ * A SWEEP ON THE DAEMON'S OWN LOOP RATHER THAN A HOOK ON A RUN ENDING, and the
2684
+ * reason is that there is no place the daemon learns a card ended. The `done`
2685
+ * transition happens inside SQL, and `settle` fires per PROCESS at every level:
2686
+ * a level 3 worker exiting while its level 2 owner is still editing the same
2687
+ * tree would take the tree out from under it. So the question is asked of the
2688
+ * record, of every run on the card, and a card with anything still running is
2689
+ * passed over.
2690
+ *
2691
+ * ═══ THE FILESYSTEM IS ASKED FIRST, SO A MACHINE WITH NO COPIES ASKS NOTHING.
2692
+ * ═══ And the folders it finds are what bounds the query: only the codebases
2693
+ * this machine actually holds a copy for.
2694
+ *
2695
+ * A folder no row explains is LEFT ALONE. Nothing here deletes something it
2696
+ * cannot account for.
2697
+ */
2698
+ async function sweepFinishedWorktrees(client) {
2699
+ const held = worktreesOnThisMachine();
2700
+ if (held.length === 0)
2701
+ return;
2702
+ /* ═══ ASKED IN TWO STEPS, BECAUSE THE TWO QUESTIONS ARE NOT THE SAME ONE.
2703
+ ═══ WHICH CARD a folder belongs to is answered by the runs that carry a
2704
+ branch, which are the runs in a codebase. WHETHER THAT CARD IS STILL BEING
2705
+ WORKED is answered by ALL of its runs, and its level 1 coordinator has no
2706
+ codebase at all — so a single query filtered by codebase would call a card
2707
+ finished while the agent coordinating it was still going.
2708
+
2709
+ Neither is filtered to this machine. The FOLDERS are this machine's, and
2710
+ only a run here can be inside one, but "the card is over" is a fact about
2711
+ the card, and a card dispatched across machines has runs elsewhere. */
2712
+ const stamped = await returned(client.from('panel3_runs')
2713
+ .select('card_id, codebase_id, branch')
2714
+ .in('codebase_id', [...new Set(held.map((one) => one.codebaseId))])
2715
+ .not('branch', 'is', null), 'read', 'which cards the copies on this machine belong to');
2716
+ const cardOfFolder = new Map(held.map((one) => [one.folder, [...new Set(stamped
2717
+ .filter((row) => (row.codebase_id === one.codebaseId && row.branch !== null && folderIsBranch(one.slug, row.branch)))
2718
+ .map((row) => row.card_id))]]));
2719
+ const cards = [...new Set([...cardOfFolder.values()].flat())];
2720
+ if (cards.length === 0)
2721
+ return;
2722
+ const live = new Set((await returned(client.from('panel3_runs')
2723
+ .select('card_id')
2724
+ .in('card_id', cards)
2725
+ .is('ended_at', null), 'read', 'which of those cards still have an agent going')).map((row) => row.card_id));
2726
+ const touched = new Set();
2727
+ for (const one of held) {
2728
+ const cardIds = cardOfFolder.get(one.folder) ?? [];
2729
+ if (cardIds.length === 0 || cardIds.some((cardId) => live.has(cardId)))
2730
+ continue;
2731
+ try {
2732
+ settleCardWorktree(one.folder);
2733
+ touched.add(one.codebaseId);
2734
+ out(`cleaned card copy for ${cardIds.join(', ')}`);
2735
+ }
2736
+ catch (error) {
2737
+ /* SAID, NOT FATAL, AND NOT RETRIED HARDER. The work is committed or it is
2738
+ not; either way the copy is still there and the next poll tries again.
2739
+ Taking the daemon's loop down over cleanup would stop every card. */
2740
+ said(`could not clean up a finished card's copy: ${error instanceof Error ? error.message : String(error)}`);
2741
+ }
2742
+ }
2743
+ /* THE BASE LOCK LAST, AND ONLY WHERE SOMETHING WAS REMOVED. It is what makes
2744
+ the refusal real for every other card in that codebase, so it survives
2745
+ until none of them is left. */
2746
+ for (const codebaseId of touched) {
2747
+ try {
2748
+ releaseBaseBranch(codebaseId);
2749
+ }
2750
+ catch { /* it is held, which is safe */ }
2751
+ }
2752
+ }
2753
+ /** The last mismatch state this daemon said out loud, so a poll every two
2754
+ * seconds does not narrate the same standing fact thirty times a minute.
2755
+ *
2756
+ * ═══ SAID ONCE PER STATE, NOT ONCE PER PROCESS. ═══ The same discipline v2's
2757
+ * `roleKey` (`orchestrator.ts`) uses for its own designation lines, and for the
2758
+ * same reason: the person re-designates while the daemon is running, so
2759
+ * "matching again" is a genuinely different state that they must hear about
2760
+ * too, while the unchanged one repeated is noise that buries every useful
2761
+ * line. Null means nothing has been said yet. */
2762
+ let saidHarnessState = null;
2763
+ /**
2764
+ * ═══ THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, AND SOMEBODY HAS TO
2765
+ * SAY SO. ═══
2766
+ *
2767
+ * The take's agent filter lives in SQL and answers with zero rows, which is
2768
+ * right — a daemon may not act outside its designation — but zero rows is also
2769
+ * what "nothing is waiting" looks like, and the two are indistinguishable from
2770
+ * the outside. This is the one place they can be told apart, because it is the
2771
+ * only place that holds BOTH facts at once: the harness this process actually
2772
+ * runs, and the harness the person has designated.
2773
+ *
2774
+ * ═══ IT DOES NOT REFUSE, RETRY OR RE-READ THE HARNESS. ═══ `CTRL_SPC_V3_AGENT`
2775
+ * is fixed for the life of the process by design (`spawn.ts`: reported, never
2776
+ * chosen), and honouring a switch by silently spawning the other binary would
2777
+ * attribute a whole session of results to a harness that never ran. So the
2778
+ * daemon keeps polling, keeps taking whatever it legitimately may, and says the
2779
+ * one true sentence about why this particular work is not moving.
2780
+ *
2781
+ * ═══ AND A FAILED READ IS NOT A MISMATCH. ═══ Constraint 4, the same rule
2782
+ * `designatedCoordinator` states: a read that failed does not mean nobody is
2783
+ * designated, and it must not produce a sentence claiming to know which harness
2784
+ * was chosen. It throws, and the poll's own catch treats it as a poll that did
2785
+ * not finish — the next one is two seconds away.
2786
+ */
2787
+ async function reportHarnessMismatch(client, machineId, machineHarness) {
2788
+ const designation = await designatedCoordinator(client);
2789
+ /* Nobody designated, or somebody else's machine: neither is this daemon
2790
+ failing to honour anything. `panel3_take_turns` lets ANY machine take when
2791
+ nothing is designated, and a designation naming another machine is that
2792
+ machine's business — v2's own listener already says both of those
2793
+ (`roleLine`'s 'none' and 'other-machine'), and repeating them here would be
2794
+ two voices on one fact. */
2795
+ const mismatched = designation !== null
2796
+ && designation.machineId === machineId
2797
+ && designation.agent !== machineHarness;
2798
+ const state = mismatched ? `mismatch:${designation.agent}` : 'ok';
2799
+ if (state === saidHarnessState)
2800
+ return;
2801
+ saidHarnessState = state;
2802
+ if (!mismatched)
2803
+ return;
2804
+ said(`${designation.agent} is designated to coordinate on this machine, and this daemon runs `
2805
+ + `${machineHarness}, so it will not pick work up. Nothing will act on what you send until `
2806
+ + `this daemon is restarted with CTRL_SPC_V3_AGENT=${designation.agent}, or ${machineHarness} `
2807
+ + 'is designated in the app.');
2808
+ }
2265
2809
  export async function run(args, injected) {
2266
2810
  let once = false;
2267
2811
  for (const arg of args) {
@@ -2358,7 +2902,7 @@ export async function run(args, injected) {
2358
2902
  for (;;) {
2359
2903
  /* ═══ ONE POLL FAILING IS NOT THE DAEMON FAILING. ═══ Every read and write
2360
2904
  here throws on a network or database error, by design (constraint 7), and
2361
- until Slice 4 that threw straight out of `cs3 run` and exited the process.
2905
+ until Slice 4 that threw straight out of `panel3/cli.js run` and exited the process.
2362
2906
  Inside `cs start` that is no longer an honest outcome twice over: it would
2363
2907
  take the v2 presence heartbeat down with it, and it would leave a stranded
2364
2908
  card printing `cs start` at a person who IS running `cs start`, which is
@@ -2382,6 +2926,11 @@ export async function run(args, injected) {
2382
2926
  await killStopped(client, machineId);
2383
2927
  await reconcileOwnerSessions(client, machineId, machineHarness, new Set(inFlight.keys()));
2384
2928
  await recoverStranded(client, tools, machineId, new Set(inFlight.keys()), hold);
2929
+ /* ═══ AND THE COPIES OF CARDS THAT ARE OVER. ═══ After recovery,
2930
+ deliberately: a run this machine is about to resume is one whose card is
2931
+ not finished, and the sweep asks the record after recovery has had its
2932
+ say about what is really still running. */
2933
+ await sweepFinishedWorktrees(client);
2385
2934
  /* ═══ AND THEN WHAT SOMEBODY ELSE'S MACHINE WAS HOLDING, IF A PERSON HANDED
2386
2935
  IT BACK. ═══ AFTER the sweep, deliberately: this machine settles its own
2387
2936
  runs on local, pid-accurate evidence before it looks at anybody's, and a
@@ -2396,6 +2945,24 @@ export async function run(args, injected) {
2396
2945
  /* Every reason a conversational owner may continue is claimed together.
2397
2946
  The legacy turn and re-arm takes exclude named owners in the database. */
2398
2947
  await takeOwnerActivations(client, tools, machineId, new Set(inFlight.keys()), hold);
2948
+ /* ═══ AND IF THE DESIGNATION NAMES A HARNESS THIS DAEMON IS NOT, IT SAYS
2949
+ SO BEFORE TAKING NOTHING. ═══
2950
+ `panel3_take_turns` and `panel3_take_rearms` compare `p_agent` against
2951
+ `cliv2_orchestrator_preference` in SQL, and a mismatch is not an error
2952
+ there — it is zero rows, which is correct and which is also exactly what
2953
+ a quiet machine with no work waiting looks like. Proven on the record
2954
+ (.bugs/.resolved/20260826-daemon-ignores-the-harness-switch): with the
2955
+ designation on `codex` and this daemon on `claude`, a waiting turn was
2956
+ never claimed for over two minutes, the daemon printed nothing, and the
2957
+ card sat under `Working` with a healthy machine online.
2958
+
2959
+ `harness()` is read once at startup and cannot change under a running
2960
+ process (see `machineHarness` above), while the designation is a chip a
2961
+ person clicks in the app at any moment. So the two CAN disagree, and
2962
+ ux.md's forbidden state — "nothing will act on it and nothing on screen
2963
+ says so" — is reached the moment they do. This is the daemon's own
2964
+ screen saying so. */
2965
+ await reportHarnessMismatch(client, machineId, machineHarness);
2399
2966
  /* THE MACHINE ID GOES IN because the take writes the run row, and a run has
2400
2967
  to say where it is running: the exclusion is cross-machine and recovery is
2401
2968
  per-machine, so a row with nobody's machine on it could be neither. */