@onepointfour-packs/flaredeck-agent 0.2.37 → 0.2.38

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.
package/dist/serve.js CHANGED
@@ -1,4 +1,6 @@
1
+ import { EventEmitter } from 'node:events';
1
2
  import { createRequire } from 'node:module';
3
+ import { ensureTmuxScrollback } from './tmux.js';
2
4
  import { START_SCRIPT as START_SCRIPT_NAME, browserBrief, crossWired, environmentBrief, notStartedBrief, startEnvironment, stopEnvironment, stopLeftoverEnvironments } from './environment.js';
3
5
  import { GIT_TOOL_NAMES, githubTools, publishBranch } from './github.js';
4
6
  import { freshGitHubToken } from './githubauth.js';
@@ -24,6 +26,7 @@ import { managedPython, usingManagedPython } from './engine-antigravity.js';
24
26
  import { mkdirSync } from 'node:fs';
25
27
  import { join } from 'node:path';
26
28
  import { configDir, saveConfig } from './config.js';
29
+ import { boardArg, onMyBoards, outstandingCards } from './boards.js';
27
30
  import { browserTools } from './browser.js';
28
31
  import { watchForDesperation } from './desperation.js';
29
32
  import { note, recallWorktree, rememberWorktree, resumeBrief } from './journal.js';
@@ -173,7 +176,11 @@ export async function serve(opts) {
173
176
  if (!['message', 'commented', 'ask'].includes(event.event))
174
177
  return null;
175
178
  const text = `${event.line ?? ''} ${event.message ?? ''}`;
176
- const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])];
179
+ // Prose a person typed, so there is no field to read and a pattern is the only way. The
180
+ // bound was ten characters before the dash, which silently excluded real boards —
181
+ // DOCUMENTREPOSITOR-0022 is seventeen. Where a TOOL answers, the code is read from the
182
+ // fields it prints instead; see outstandingCards.
183
+ const codes = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,23}-\d{3,}\b/g) ?? [])];
177
184
  for (const code of codes) {
178
185
  // What the harness wrote down when it made the worktree, first: the directory is named from
179
186
  // the card's internal id and the branch from whatever the agent chose, so neither can be
@@ -228,6 +235,8 @@ export async function serve(opts) {
228
235
  if (await releaseSharedPushGuard(c.path).catch(() => false))
229
236
  log('tool', `restored push on ${c.path} (a worktree's push guard had leaked into it)`);
230
237
  }
238
+ // Inside tmux, the history this pane prints is only reachable with tmux's mouse mode on.
239
+ void ensureTmuxScrollback((t) => log('tool', t)).catch(() => { });
231
240
  // A restart begins with no environments: the last run's are nobody's now (see the function).
232
241
  for (const path of new Set([config.repo, ...inScope.map((c) => c.path)])) {
233
242
  void stopLeftoverEnvironments(path, (t) => log('tool', t)).catch(() => { });
@@ -336,13 +345,22 @@ export async function serve(opts) {
336
345
  const reader = opts.me ?? opts.deck ?? null;
337
346
  const answeredHere = new Map();
338
347
  let refreshing = null;
348
+ /** This agent's own id, and (on a lead) the agents that report to it — whose requests it sees. */
349
+ let selfId = '';
350
+ let teamIds = new Set();
339
351
  const refreshApprovals = () => {
340
352
  if (!reader)
341
353
  return Promise.resolve();
342
354
  if (refreshing)
343
355
  return refreshing;
344
356
  refreshing = reader.pendingApprovals().then((all) => {
345
- const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST);
357
+ // Its own requests, and on a lead its team's. Every pending request in the workspace used
358
+ // to be listed on every pane, so CASE showed HAL's request as if it were CASE's business.
359
+ // Until this agent knows who it is (the team read failed), nothing is hidden.
360
+ const ours = (a) => !selfId
361
+ || a.sender_key === selfId
362
+ || (isLead && (a.review?.by === selfId || (!!a.sender_key && teamIds.has(a.sender_key))));
363
+ const mine = all.filter((a) => a.tool_name === SCRIPT_REQUEST && ours(a));
346
364
  state.approvals = mine.map((a) => {
347
365
  const lines = (a.input_preview || '').split('\n');
348
366
  const preview = lines.find((l) => l.startsWith('-- RAN') || /would_touch/.test(l))?.replace(/^-- /, '').replace(/^harness preview.*?:\s*/, '');
@@ -457,29 +475,58 @@ export async function serve(opts) {
457
475
  return null;
458
476
  return flagCard({ card: cur.card, board: cur.board, code: cur.code }, reason, kind);
459
477
  },
460
- unflag: async () => {
461
- const cur = onCard;
462
- if (!cur)
463
- return;
478
+ unflag: async (cur) => {
464
479
  parked.delete(cur.card);
465
480
  await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'clear-needs-human', {
466
481
  card_id: cur.code || cur.card,
467
482
  ...(cur.board ? { board_id: cur.board } : {}),
468
483
  }).catch((e) => log('warn', `could not clear needs-human: ${e instanceof Error ? e.message : String(e)}`));
469
484
  },
470
- }, () => leadId, onAsk)
485
+ }, () => leadId, onAsk, (card, text) => resumeParked(card, text))
471
486
  : undefined;
472
487
  // Answering from this keyboard: the person's own login decides, through the same endpoint the
473
488
  // phone uses, so the agent waiting on it sees the verdict as any other.
474
- const decide = opts.me
489
+ /**
490
+ * The person answering from this pane. Signed in once per machine, from the pane when nobody
491
+ * has: press a/d and it asks. Never the agent's own login — an approval is a person's decision,
492
+ * and the agent has a shell on this box and its own credentials in it.
493
+ */
494
+ let me = opts.me;
495
+ state.person = !!me;
496
+ const startSignIn = () => {
497
+ if (signingIn) {
498
+ log('event', 'still waiting for the sign-in — paste the address your browser ended on');
499
+ return;
500
+ }
501
+ if (!opts.signInMe) {
502
+ log('warn', 'nobody is signed in here to answer with — run: flaredeck-agent login --me');
503
+ return;
504
+ }
505
+ signingIn = true;
506
+ log('event', 'Sign in as YOURSELF to answer from this pane (once per machine). Open the address below, sign in, then paste the address your browser ends up on into the box here and press Enter.');
507
+ opts.signInMe((t) => { if (t.trim())
508
+ log('event', t); }, signInReader)
509
+ .then((client) => {
510
+ me = client ?? undefined;
511
+ state.person = !!me;
512
+ log(me ? 'event' : 'warn', me ? 'signed in — a approves, d denies, from this pane' : 'signed in, but that login has no workspace here');
513
+ })
514
+ .catch((e) => log('warn', `sign-in did not finish: ${e instanceof Error ? e.message : String(e)}`))
515
+ .finally(() => { signingIn = false; });
516
+ };
517
+ const decide = opts.signInMe || opts.me
475
518
  ? (requestId, verdict) => {
519
+ if (!me) {
520
+ startSignIn();
521
+ return;
522
+ }
476
523
  const row = (state.approvals ?? []).find((a) => a.requestId === requestId);
477
524
  if (!row || row.stage !== 'ready')
478
525
  return;
479
526
  answeredHere.set(requestId, verdict);
480
527
  row.stage = 'answered';
481
528
  row.verdict = verdict;
482
- void opts.me.decide(row.commentId, verdict)
529
+ void me.decide(row.commentId, verdict)
483
530
  .then(() => log('you', `${verdict === 'allow' ? 'approved' : 'denied'} ${row.name} (${row.agent}, ${row.card}) from here`))
484
531
  .catch((e) => { answeredHere.delete(requestId); log('warn', `could not record that: ${e instanceof Error ? e.message : String(e)}`); void refreshApprovals(); });
485
532
  }
@@ -563,6 +610,8 @@ export async function serve(opts) {
563
610
  if (leadId)
564
611
  log('tool', `your lead is ${leadName} — ${leadName} verifies your work before it goes anywhere`);
565
612
  isLead = team.you.role === 'lead';
613
+ selfId = team.you.agentId;
614
+ teamIds = new Set(team.agents.filter((a) => a.reportsTo === team.you.agentId).map((a) => a.agentId));
566
615
  if (team.you.role === 'lead') {
567
616
  teamSet = {
568
617
  // After a review the list changes — an endorsed request is now answerable, from THIS
@@ -587,11 +636,34 @@ export async function serve(opts) {
587
636
  * Polled rather than pushed, because nothing streams another agent's status — and slowly,
588
637
  * since agents check in every five minutes, so a faster poll would show the same row again.
589
638
  */
639
+ /**
640
+ * An idle worker is the lead's problem, and the lead already knows within a minute.
641
+ *
642
+ * It polls its team every sixty seconds to draw the pane, so "CASE idle 4m ago" is sitting on
643
+ * its screen — and it did nothing with it. Dispatch waited for the hourly sweep, or for the
644
+ * worker to say it was free, which for a while it never did. Eleven cards waited in Ready For
645
+ * Dev while two agents sat idle and the lead reported "waiting for work".
646
+ *
647
+ * The poll is already happening, so noticing costs nothing. Rate-limited because an idle
648
+ * worker with genuinely nothing to give it stays idle, and a lead asked about it every minute
649
+ * learns to say "nothing" without looking.
650
+ */
651
+ const DISPATCH_AGAIN_MS = 10 * 60 * 1000;
652
+ let lastDispatch = 0;
653
+ const workersAreIdle = (team) => myTeam(team).mine.filter((a) => !a.card && a.state !== 'stopped' && !a.stale).map((a) => a.name);
590
654
  if (leadOfTeam) {
591
655
  const teamTimer = setInterval(() => {
592
656
  void (async () => {
593
657
  try {
594
- showTeam(await fetchTeam(opts.baseUrl, await opts.auth()));
658
+ const team = await fetchTeam(opts.baseUrl, await opts.auth());
659
+ showTeam(team);
660
+ const free = workersAreIdle(team);
661
+ if (free.length && !working && !queue.length && Date.now() - lastDispatch > DISPATCH_AGAIN_MS) {
662
+ lastDispatch = Date.now();
663
+ log('event', `${free.join(', ')} ${free.length === 1 ? 'is' : 'are'} idle — looking for work to hand over`);
664
+ if (enqueue({ id: `dispatch-${Date.now()}`, event: 'sweep', line: `${free.join(', ')} have nothing to do` }))
665
+ void drain();
666
+ }
595
667
  }
596
668
  catch {
597
669
  // A team the lead briefly cannot see is not worth a line in the log every minute; the
@@ -623,12 +695,40 @@ export async function serve(opts) {
623
695
  if (engine.name !== 'claude')
624
696
  log('event', `${engine.name} is answering on this agent`);
625
697
  let liveRun = null;
698
+ /**
699
+ * This turn's browser, so it can be closed when the turn ends.
700
+ *
701
+ * A fresh tool set is built per turn, so without this the previous turn's Chromium was both
702
+ * unreachable and still running — a leaked browser tree for every turn that looked at
703
+ * anything, on a process that stays up for days.
704
+ */
705
+ let turnEyes = null;
626
706
  /**
627
707
  * Typed with nothing running: a question for the agent, answered now. It goes through the same
628
708
  * queue as a card event so it is serialised with real work, but it is local — never acknowledged
629
709
  * to the server, never a card — and it opens one long-lived session of its own so a follow-up
630
710
  * question remembers the last one.
631
711
  */
712
+ /**
713
+ * A parked card's request was answered: put the card back at the FRONT of the queue, resuming
714
+ * its own session in its own worktree, with what happened. The turn on whatever card is running
715
+ * now finishes first.
716
+ */
717
+ const resumeParked = (card, text) => {
718
+ const event = {
719
+ id: `run-${card.card}-${Date.now()}`,
720
+ event: 'run-answered',
721
+ line: text,
722
+ message: text,
723
+ cardKey: card.card,
724
+ ...(card.board ? { boardKey: card.board } : {}),
725
+ ...(card.code ? { cardCode: card.code } : {}),
726
+ };
727
+ log('event', `${card.code || card.card}: request answered — back at the front of the queue`);
728
+ if (enqueue(event, true)) {
729
+ void drain();
730
+ }
731
+ };
632
732
  const ask = (text) => {
633
733
  const event = { id: `ask-${Date.now()}`, event: 'ask', line: text, message: text };
634
734
  if (enqueue(event)) {
@@ -643,6 +743,9 @@ export async function serve(opts) {
643
743
  * blocking call here would freeze the pane and the agent's own event stream for as long as the
644
744
  * command took. The agent never sees it; say on the card or in a steer if it should know.
645
745
  */
746
+ /** A person signing in from the pane: lines typed meanwhile go to the sign-in, not the agent. */
747
+ let signingIn = false;
748
+ const signInReader = Object.assign(new EventEmitter(), { prompt() { }, setPrompt() { }, close() { } });
646
749
  /** The checkout the current turn is working in ('' for one that has none). */
647
750
  let turnCwd = '';
648
751
  /** Worktrees whose environment comes down unless a turn returns to them first. */
@@ -690,6 +793,11 @@ export async function serve(opts) {
690
793
  });
691
794
  };
692
795
  const steer = (text) => {
796
+ // Mid sign-in, the next line is the redirect address — for the sign-in, never the agent.
797
+ if (signingIn) {
798
+ signInReader.emit('line', text);
799
+ return;
800
+ }
693
801
  if (text.startsWith('!')) {
694
802
  const command = text.slice(1).trim();
695
803
  if (command)
@@ -760,6 +868,11 @@ export async function serve(opts) {
760
868
  const pending = new Set();
761
869
  /** Keep the pushed status in step with the queue — it is what a lead's pane reads. */
762
870
  const syncQueue = () => status.setQueue(queue.length, queue[0]?.cardCode || queue[0]?.cardKey || '');
871
+ /**
872
+ * The boards this agent works, by key. Empty means all of them, which is what every install
873
+ * did before this existed.
874
+ */
875
+ const myBoards = new Set((config.boards ?? []).map((b) => b.key));
763
876
  const enqueue = (event, front = false) => {
764
877
  if (pending.has(event.id))
765
878
  return false;
@@ -785,6 +898,8 @@ export async function serve(opts) {
785
898
  const wantsIntake = (event) => !!event.cardKey
786
899
  && event.event !== 'review'
787
900
  && event.event !== 'ask'
901
+ // A parked card coming back is the same work resuming, not a new card to brief.
902
+ && event.event !== 'run-answered'
788
903
  && !(event.event === 'commented' && event.cardDone && !event.mentioned)
789
904
  // Someone else's card is not worth reading ahead. `drain` already declines to WORK these —
790
905
  // a card this agent once touched keeps sending it every move and comment made by whoever
@@ -909,10 +1024,19 @@ export async function serve(opts) {
909
1024
  return;
910
1025
  let mine = [];
911
1026
  try {
912
- const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', {});
1027
+ // Scoped, like every other account-wide question. This one is asked by the HARNESS rather
1028
+ // than by the model, so the proxy that narrows the model's calls never saw it — and a
1029
+ // board-scoped agent went straight back to picking up another team's cards: KD-1361, then
1030
+ // KD-1362, from a board nobody had ticked. The card then 404s, because it is not on a
1031
+ // board this agent works.
1032
+ const out = await callFlaredeckTool(config.mcpUrl, await opts.auth(), 'get-outstanding', { ...(boardArg([...myBoards]) !== undefined ? { board: boardArg([...myBoards]) } : {}) });
913
1033
  const text = typeof out === 'string' ? out : JSON.stringify(out);
914
- // The codes it names, in the order it names them: priority order, which is the board's.
915
- mine = [...new Set(text.match(/\b[A-Z][A-Z0-9]{1,9}-\d{3,}\b/g) ?? [])].map((code) => ({ code }));
1034
+ // Read from the fields the answer prints, and filtered here as well as asked for — the
1035
+ // server is deployed separately from this, so the scope cannot depend on its version.
1036
+ // In the order it names them: priority order, which is the board's.
1037
+ mine = outstandingCards(text)
1038
+ .filter((c) => onMyBoards(myBoards, c))
1039
+ .map((c) => ({ code: c.code }));
916
1040
  }
917
1041
  catch {
918
1042
  return; // unreachable board is the stream's problem to report, not this timer's
@@ -1028,7 +1152,7 @@ export async function serve(opts) {
1028
1152
  status.finishCard(didWork);
1029
1153
  void report();
1030
1154
  pending.delete(event.id);
1031
- if (event.event === 'ask' || event.event === 'sweep') { /* local: nothing to acknowledge, nothing to retry */ }
1155
+ if (event.event === 'ask' || event.event === 'sweep' || event.event === 'run-answered') { /* local: nothing to acknowledge, nothing to retry */ }
1032
1156
  else if (didWork)
1033
1157
  await stream.ack([event.id]);
1034
1158
  else {
@@ -1049,6 +1173,13 @@ export async function serve(opts) {
1049
1173
  // soon — and then taken down, so a day of cards does not leave a day of servers running.
1050
1174
  if (turnCwd && turnCwd !== config.repo)
1051
1175
  scheduleEnvironmentStop(turnCwd);
1176
+ // The environment is kept warm; the browser is not. It holds a Chromium tree, the next
1177
+ // turn builds its own, and a page left open is not something a later turn can reach.
1178
+ if (turnEyes) {
1179
+ const e = turnEyes;
1180
+ turnEyes = null;
1181
+ await e.close().catch(() => { });
1182
+ }
1052
1183
  current = '';
1053
1184
  delete state.plan;
1054
1185
  state.working = '';
@@ -1062,9 +1193,15 @@ export async function serve(opts) {
1062
1193
  finally {
1063
1194
  working = false;
1064
1195
  }
1065
- // The queue is empty and nothing is running: this agent has nothing to do. Tell its lead,
1066
- // once, rather than leaving them to notice.
1067
- if (!queue.length && !live.size)
1196
+ // An empty queue at the end of a drain means nothing is running and nothing is waiting: this
1197
+ // agent has nothing to do. Tell its lead, once, rather than leaving them to notice.
1198
+ //
1199
+ // This used to require `!live.size` as well, which reads like "nothing running" and is not.
1200
+ // `live` holds sessions kept WARM for fifteen minutes so a follow-up on the same card costs
1201
+ // a resume instead of a re-orientation — a cache, not work. So every agent went quiet for
1202
+ // fifteen minutes after finishing anything, which is exactly the window in which it most
1203
+ // needs a new card. Two sat idle and silent while eleven cards waited.
1204
+ if (!queue.length)
1068
1205
  void offerToLead();
1069
1206
  }
1070
1207
  /**
@@ -1416,7 +1553,7 @@ export async function serve(opts) {
1416
1553
  tag = code;
1417
1554
  state.working = code;
1418
1555
  };
1419
- const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar);
1556
+ const flaredeck = flaredeckProxy(config.mcpServerName, config.mcpUrl, opts.auth, check.discovered, (w) => log('warn', w), finishGate, noteCard, keepTool, savedSoFar, [...myBoards]);
1420
1557
  const held = live.get(key);
1421
1558
  if (held?.timer)
1422
1559
  clearTimeout(held.timer);
@@ -1500,6 +1637,20 @@ export async function serve(opts) {
1500
1637
  // The guard is built once at startup; what this agent can reach changes per card. Handing it
1501
1638
  // the live picture is the difference between judging "$DATABASE_URL" as a scratch database
1502
1639
  // and judging it as a live one.
1640
+ // Deployed addresses the board declared for this card's repository — the dev or UAT site. A
1641
+ // card is usually about code that is already live somewhere, and an agent that can only see
1642
+ // what it started locally cannot tell its own change from how the thing already behaved.
1643
+ const declaredSites = where.kind === 'repo' ? where.sites ?? {} : {};
1644
+ const eyes = forACard
1645
+ ? turnEyes = browserTools({
1646
+ repo: config.repo,
1647
+ endpoints: () => env.endpoints,
1648
+ sites: () => declaredSites,
1649
+ contract: () => env.contract,
1650
+ cwd,
1651
+ onNote: (t) => log('tool', t),
1652
+ })
1653
+ : null;
1503
1654
  reachable = { cwd, endpoints: env.endpoints };
1504
1655
  if (forACard && !needsRunning)
1505
1656
  log('tool', 'environment not started — this card does not look like it needs one');
@@ -1535,19 +1686,12 @@ export async function serve(opts) {
1535
1686
  ...(prepared?.size ? { size: prepared.size } : {}),
1536
1687
  tools: {
1537
1688
  ...(job ? { git: { server: gitServer, toolNames: GIT_TOOL_NAMES } } : {}),
1538
- // Eyes, when there is something running to look at. A card with no environment gets no
1539
- // browser tools and is told nothing about them.
1540
- ...(env.ran && Object.keys(env.endpoints).length
1541
- ? {
1542
- browser: browserTools({
1543
- repo: config.repo,
1544
- endpoints: () => env.endpoints,
1545
- contract: () => env.contract,
1546
- cwd,
1547
- onNote: (t) => log('tool', t),
1548
- }),
1549
- }
1550
- : {}),
1689
+ // Eyes, for any card. They used to be mounted only when the harness had itself started
1690
+ // something, which left an agent working alone unable to get them: no start script meant
1691
+ // no browser AND no mention of one, so it could not know that writing the script it is
1692
+ // perfectly able to write was the thing standing between it and seeing its own change.
1693
+ // The endpoints are read through a getter, so something the agent starts mid-turn counts.
1694
+ ...(eyes ? { browser: eyes } : {}),
1551
1695
  team: teamSet,
1552
1696
  flaredeck,
1553
1697
  runs: runsSet,
@@ -1568,7 +1712,7 @@ export async function serve(opts) {
1568
1712
  freeWorkerBrief(event) +
1569
1713
  // Only tell an agent about the environment when the card actually needs one running.
1570
1714
  environmentBrief(env, cwd, needsRunning) + notStartedBrief(forACard && !needsRunning, cwd) +
1571
- browserBrief(env.ran && Object.keys(env.endpoints).length > 0, env.endpoints) +
1715
+ browserBrief(env.endpoints, declaredSites, forACard) +
1572
1716
  (wrongEnv.length
1573
1717
  ? `\n\nSTOP — the environment that started is not yours:\n${wrongEnv.map((w) => ` · ${w}`).join('\n')}\n\n` +
1574
1718
  `Anything you test against it tells you about another card's code, and it will answer ` +
@@ -1832,11 +1976,26 @@ export async function serve(opts) {
1832
1976
  beat();
1833
1977
  continue;
1834
1978
  }
1979
+ // Another board's work. Acknowledged so it does not come round again, and never read: no
1980
+ // intake, no Jev call, no turn. One line, so it is visible that scope is doing something
1981
+ // rather than that the agent has gone deaf.
1982
+ if (!onMyBoards(myBoards, event)) {
1983
+ log('tool', `not this agent's board (${event.boardName || event.boardKey}) — ${event.line}`);
1984
+ await stream.ack([event.id]);
1985
+ beat();
1986
+ continue;
1987
+ }
1835
1988
  // A review request for this lead: the model handles it as a turn, but the list can show
1836
1989
  // the row straight away.
1837
1990
  if (event.event === 'review')
1838
1991
  void refreshApprovals();
1839
- if (enqueue(event)) {
1992
+ // A review jumps the queue. It has a deadline and a worker blocked on it; behind a backlog
1993
+ // it waited for every card ahead of it. HAL's request sat behind seven events on a freshly
1994
+ // restarted JARVIS, listed in the pane as "with lead" and never looked at, while the hour
1995
+ // ran down. Front, not interrupt: the turn already running finishes first.
1996
+ if (enqueue(event, event.event === 'review')) {
1997
+ if (event.event === 'review')
1998
+ log('event', `review waiting — taken next: ${event.line}`);
1840
1999
  prepareAhead(event);
1841
2000
  void drain();
1842
2001
  }
@@ -1900,6 +2059,16 @@ function prompt(event, isLead = false) {
1900
2059
  'assign-card, newest bugs first. One each — an agent already on a card is refused a',
1901
2060
  'second, and it messages you the moment it is free.',
1902
2061
  '',
2062
+ 'Then look at the balance. list_agents shows what each one holds and how many are',
2063
+ 'queued behind it. Where one worker has several cards waiting and another has none,',
2064
+ 'move a card across with assign-card: a card queued behind a busy agent is not being',
2065
+ 'worked, and the idle one could finish it today.',
2066
+ '',
2067
+ 'Each agent is shown with the repository it has checked out. Move a card only to an',
2068
+ 'agent that has the repository the card is for — on a board where everyone works the',
2069
+ 'same one they are interchangeable, and on a board with a ui agent and an api agent',
2070
+ 'they are not.',
2071
+ '',
1903
2072
  ]
1904
2073
  : []),
1905
2074
  'If it returns nothing, say so in one line and stop. That is a perfectly good outcome and',
@@ -1910,11 +2079,13 @@ function prompt(event, isLead = false) {
1910
2079
  '',
1911
2080
  '- A card for one of the agents who report to you: assign it to the right one. That dispatch',
1912
2081
  ' IS the work, and this turn is done.',
1913
- '- A card that is yours and NOT already in progress: move it to the board\'s in-progress lane',
1914
- ' with update-card-status, and stop. Do not read it, do not start the work, do not comment',
1915
- ' on it. Moving it hands it back to you as a card of its own — with a checkout of your own,',
1916
- ' on its own branch — which is the only place the work can actually be done and published',
1917
- ' from. Working it here would edit the shared checkout and could not be pushed anywhere.',
2082
+ '- A card that is yours and NOT already in progress: hand it to a worker if you have one free,',
2083
+ ' with assign-card. You run one turn at a time, and a card takes minutes — for all of them you',
2084
+ ' would be unable to assign anything or answer anyone, which is the one job only you can do.',
2085
+ ' With nobody free, move it to the board\'s in-progress lane with update-card-status and stop:',
2086
+ ' that hands it back to you as a card of its own, with a checkout and a branch, which is the',
2087
+ ' only place work can be done and published from. Either way, stop here — reading it, starting',
2088
+ ' it or commenting on it in this turn all edit the shared checkout, which publishes nowhere.',
1918
2089
  '- A card already in progress: say which one in a line, and stop. The harness picks those up',
1919
2090
  ' by itself, shortly, with a checkout of its own — you do not need to do anything for it.',
1920
2091
  '',