flowviant 0.125.6 → 0.125.9

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.
Files changed (73) hide show
  1. package/README.md +84 -6
  2. package/bin/cli.mjs +7 -1
  3. package/bin/lib/activityFeed.mjs +69 -0
  4. package/bin/lib/agentMergeReports.mjs +46 -0
  5. package/bin/lib/agentMergeResolution.mjs +44 -0
  6. package/bin/lib/agentPlan.mjs +6 -2
  7. package/bin/lib/claudeInput.mjs +61 -1
  8. package/bin/lib/claudePosture.mjs +21 -0
  9. package/bin/lib/claudeStream.mjs +54 -4
  10. package/bin/lib/codexContract.mjs +52 -0
  11. package/bin/lib/codexResearch.mjs +33 -5
  12. package/bin/lib/commandSpecs.mjs +8 -5
  13. package/bin/lib/config.mjs +22 -1
  14. package/bin/lib/daemonJournal.mjs +102 -0
  15. package/bin/lib/daemonLogging.mjs +22 -18
  16. package/bin/lib/fleet.mjs +16 -6
  17. package/bin/lib/fleetReports.mjs +12 -5
  18. package/bin/lib/hooks/orchestrator.mjs +3 -1
  19. package/bin/lib/logReplay.mjs +133 -0
  20. package/bin/lib/mcpInventory.mjs +2 -1
  21. package/bin/lib/mcpReasons.mjs +6 -0
  22. package/bin/lib/nativeAgentTools.mjs +20 -3
  23. package/bin/lib/observedLines.mjs +63 -0
  24. package/bin/lib/orchestrator.mjs +49 -7
  25. package/bin/lib/orchestratorNative.mjs +133 -3
  26. package/bin/lib/orchestratorPrompt.mjs +50 -4
  27. package/bin/lib/postureTools.mjs +14 -6
  28. package/bin/lib/printable.mjs +1 -1
  29. package/bin/lib/processGroup.mjs +41 -0
  30. package/bin/lib/projectTools.mjs +3 -2
  31. package/bin/lib/prompts.mjs +47 -18
  32. package/bin/lib/reportJournal.mjs +61 -0
  33. package/bin/lib/repositoryMerge.mjs +1 -1
  34. package/bin/lib/researchHandoff.mjs +144 -0
  35. package/bin/lib/researchHandoffText.mjs +3 -0
  36. package/bin/lib/researchKnowledge.mjs +60 -0
  37. package/bin/lib/researchRestoration.mjs +38 -0
  38. package/bin/lib/resumeLost.mjs +21 -1
  39. package/bin/lib/runTurn.mjs +98 -44
  40. package/bin/lib/runtimeCapabilities.mjs +7 -2
  41. package/bin/lib/runtimeClaude.mjs +30 -8
  42. package/bin/lib/runtimeCodex.mjs +54 -22
  43. package/bin/lib/runtimeEvents.mjs +17 -7
  44. package/bin/lib/runtimeOfferings.mjs +3 -1
  45. package/bin/lib/startProject.mjs +10 -0
  46. package/bin/lib/turnDiagnostics.mjs +2 -1
  47. package/bin/lib/turnOutput.mjs +63 -0
  48. package/bin/lib/turnRecord.mjs +11 -0
  49. package/bin/lib/turnSubagents.mjs +13 -3
  50. package/bin/lib/ui.mjs +2 -2
  51. package/bin/lib/update.mjs +18 -4
  52. package/bin/lib/uplinkScrub.mjs +49 -0
  53. package/bin/lib/views.mjs +49 -58
  54. package/bin/lib/wikiRunner.mjs +2 -2
  55. package/bin/lib/work.mjs +5 -5
  56. package/bin/lib/workActivity.mjs +65 -0
  57. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  58. package/bin/lib/workAgentMerges.mjs +57 -52
  59. package/bin/lib/workAgentPlans.mjs +76 -27
  60. package/bin/lib/workAgentPrecheck.mjs +1 -1
  61. package/bin/lib/workAgentTurnExecution.mjs +62 -8
  62. package/bin/lib/workAgentTurnReports.mjs +33 -3
  63. package/bin/lib/workAgentTurnShape.mjs +6 -4
  64. package/bin/lib/workAgentTurns.mjs +12 -8
  65. package/bin/lib/workAttachments.mjs +27 -5
  66. package/bin/lib/workIntake.mjs +8 -2
  67. package/bin/lib/workMergeBatch.mjs +7 -1
  68. package/bin/lib/workNarration.mjs +32 -0
  69. package/bin/lib/workPlanRecovery.mjs +50 -0
  70. package/bin/lib/workReportQueue.mjs +18 -1
  71. package/bin/lib/workSessionTurns.mjs +64 -22
  72. package/bin/lib/worktreeDiff.mjs +29 -5
  73. package/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { reportWorkOutcome } from './workActivity.mjs';
2
+ import { createTurnOutput } from './turnOutput.mjs';
1
3
  import { mergeRepositoryPlace } from './repositoryMerge.mjs';
2
4
  import { fleetRequest } from './fleetPost.mjs';
3
5
  /** Agent merge claims, pull-request approval, and branch cleanup. */
@@ -13,7 +15,9 @@ import { gitMergeIn } from './gitMergeIdentity.mjs';
13
15
  import { warn } from './ui.mjs';
14
16
  import { scrub as envScrub } from './uplinkScrub.mjs';
15
17
  import { fleetEndpoint } from './fleetWire.mjs';
16
- import { claimFleetLease, settleLease } from './fleetPost.mjs';
18
+ import { claimFleetLease } from './fleetPost.mjs';
19
+ import { createMergeReports } from './agentMergeReports.mjs';
20
+ import { gitReportedConflict, recordMergeFailure, clearMergeFailure } from './agentMergeResolution.mjs';
17
21
  import { worktreePath } from './workPlaces.mjs';
18
22
 
19
23
  /** Git puts merge conflicts on stdout, while refusals usually use stderr.
@@ -46,7 +50,7 @@ export function createWorkAgentMerges({
46
50
  agentPublished,
47
51
  }) {
48
52
  const AGENT_MERGE_CLAIM_URL = fleetEndpoint('agent-merge-claim', FLEET_URL);
49
- const AGENT_MERGE_DONE_URL = fleetEndpoint('agent-merge-done', FLEET_URL);
53
+
50
54
 
51
55
  // ── THE MERGE ──────────────────────────────────────────────────────────────
52
56
  //
@@ -63,9 +67,28 @@ export function createWorkAgentMerges({
63
67
  // The peer may hold it; doing nothing is the safe answer.
64
68
  const claimAgentMerge = (agentId) => claimFleetLease(AGENT_MERGE_CLAIM_URL, { agentId });
65
69
 
66
- // On a failure the lease lapses and the job is re-offered — a merge is
67
- // idempotent against an already-merged branch, which mergeOutward detects.
68
- const postAgentMerge = (body) => settleLease(AGENT_MERGE_DONE_URL, body, { timeoutMs: 30_000 });
70
+ const retirements = new Map();
71
+ // Remote cleanup is tail work, after acknowledgement and outside the place
72
+ // lock, for both immediate delivery and journal replay.
73
+ const retireAgentMerge = async (id) => {
74
+ const held = retirements.get(id);
75
+ if (!held) return;
76
+ retirements.delete(id);
77
+ const { body, place, publishedRef } = held;
78
+ if (!isSafePathSegment(place)) return;
79
+ clearMergeFailure(worktreePath(baseDir, place));
80
+ agentPublished.delete(place);
81
+ agentRemoteAt.delete(place);
82
+ const args = publishDeleteArgs(publishedRef);
83
+ if (args) {
84
+ try { gitNet(args, 60_000); }
85
+ catch (e) { warn(`agent ${body.agentId}: the published branch could not be deleted — ${publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''))}`); }
86
+ }
87
+ };
88
+ const reports = createMergeReports({ baseDir, delivered: async (held, settled) => {
89
+ if (held.body.ok && settled) retirements.set(String(held.body.agentId), held);
90
+ } });
91
+ const postAgentMerge = (body, meta) => reports.report(body, meta);
69
92
 
70
93
  // A merge COMMIT needs an identity the machine may not have; the probe and
71
94
  // the daemon's fallback live in gitMergeIdentity.mjs.
@@ -81,6 +104,8 @@ export function createWorkAgentMerges({
81
104
  return;
82
105
  }
83
106
  if (!(await claimAgentMerge(agentId))) return;
107
+ const activity = createTurnOutput({ work: { kind: 'merge', id: agentId, name: job.agentName || agentId } });
108
+ activity.event('Merge started', 'start');
84
109
 
85
110
  /**
86
111
  * NO EXIT PATH MAY LEAVE A CLAIMED MERGE UNREPORTED — the same belt the
@@ -111,11 +136,17 @@ export function createWorkAgentMerges({
111
136
  * landed is the case it exists for. It is recorded in `report` rather than
112
137
  * at the three success sites so a fourth one cannot forget it.
113
138
  */
114
- let landedRef = null;
115
139
  const report = async (body) => {
140
+ const wt = worktreePath(baseDir, place);
141
+ if (!job.repositoryMerge && !body.ok) {
142
+ body.conflict ??= gitReportedConflict(null, wt);
143
+ recordMergeFailure(wt, baseRef(), body.conflict);
144
+ }
145
+ // From here the journal owns retries, including a dropped 200 or a
146
+ // restart. The catch must not overwrite this measured outcome.
116
147
  reported = true;
117
- if (body?.ok === true) landedRef = job.publishedRef ?? null;
118
- await postAgentMerge({ ...body, ...(body.detail ? { detail: relayedText(String(body.detail)).slice(0, 2000) } : {}) });
148
+ reportWorkOutcome({ ...activity.identity, id: activity.identity.workId }, { outcome: body.ok ? 'merged' : 'nothing', answer: body.detail });
149
+ await postAgentMerge({ ...body, ...(body.detail ? { detail: relayedText(String(body.detail)).slice(0, 2000) } : {}) }, { place, publishedRef: job.publishedRef ?? null });
119
150
  };
120
151
 
121
152
  // A WRITER on the place, exactly as a ship is. It folds base in and pushes
@@ -160,6 +191,7 @@ export function createWorkAgentMerges({
160
191
  agentId,
161
192
  ok: false,
162
193
  detail: agentMergeFailureDetail(e),
194
+ conflict: gitReportedConflict(e, wt),
163
195
  });
164
196
  return;
165
197
  }
@@ -168,7 +200,11 @@ export function createWorkAgentMerges({
168
200
  // anything merges: a failed merge sends the agent BACK to review, which
169
201
  // is a review-entry beat like any other, and a stale reading standing
170
202
  // over a rebased branch is exactly what `precheckSha` exists to void.
171
- await runReviewEntry(agentId, wt, job.agentName);
203
+ const check = await runReviewEntry(agentId, wt, job.agentName);
204
+ if (check?.status === 'failed') {
205
+ await report({ agentId, ok: false, detail: check.text || check.output || 'The repository check failed after folding base.' });
206
+ return;
207
+ }
172
208
  }
173
209
  // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
174
210
  // a detached HEAD — so the guard below was unreachable and the throw
@@ -367,11 +403,11 @@ export function createWorkAgentMerges({
367
403
  agentId,
368
404
  ok: false,
369
405
  // WHOSE FIX IT IS (0.108.0), when the merge said: a refusal about
370
- // the person's side (`shipMerge.mjs`'s dirty checkout) queues no
371
- // resolve turn on the server. A conflict says nothing, and the
372
- // agent resolves it in its worktree as before.
406
+ // person's side must become a question during diagnosis; it is
407
+ // never permission to modify that checkout or its credentials.
373
408
  ...(e?.fix === 'person' ? { fix: 'person' } : {}),
374
409
  detail: agentMergeFailureDetail(e),
410
+ conflict: gitReportedConflict(e, wt),
375
411
  });
376
412
  return;
377
413
  }
@@ -388,62 +424,31 @@ export function createWorkAgentMerges({
388
424
  } finally {
389
425
  if (!reported) {
390
426
  // Belt over braces. A merge this daemon claimed and cannot account for
391
- // is a FAILURE, said out loud, so the agent goes back to Review with a
427
+ // is a FAILURE, said out loud, so the agent resumes diagnosis with a
392
428
  // reason instead of waiting out an expiry that blames nobody.
393
429
  await postAgentMerge({
394
430
  agentId,
395
431
  ok: false,
396
432
  detail: 'the merge did not complete — check the daemon log',
397
- }).catch(() => {});
398
- }
399
- /**
400
- * …AND THE RETIREMENT IS TAIL WORK, OUTSIDE THE PLACE LOCK — the same
401
- * placement the turn's publish argues for, for the same reason.
402
- *
403
- * `gitNet` is `execFileSync`: it blocks the whole event loop for up to its
404
- * timeout, and inside `inPlace(place, true, …)` it would hold this
405
- * agent's WRITER lock through a remote's bad day, with the merge already
406
- * settled and nothing left that the delay serves. This block is PAST the
407
- * lock: `inPlace` has returned (or thrown) before a `finally` runs.
408
- *
409
- * IN THE `finally` rather than after it, so a throw between the ok settle
410
- * and the end of the locked block cannot strand the ref. `landedRef` is
411
- * set only by a reported success, so there is nothing here to run on any
412
- * other path — and a remote that refuses the delete only warns: the merge
413
- * is the thing that matters, and a landed branch must not become a failed
414
- * approval.
415
- *
416
- * The record goes with the ref. `agentPublished` is what the SWEEP
417
- * republishes from, so leaving the entry behind would let the next sweep
418
- * push the ref straight back — an orphan no later merge job can ever
419
- * carry, and therefore one nothing can delete.
420
- */
421
- if (landedRef) {
422
- agentPublished.delete(place);
423
- agentRemoteAt.delete(place);
424
- const args = publishDeleteArgs(landedRef);
425
- if (args) {
426
- try {
427
- gitNet(args, 60_000);
428
- } catch (e) {
429
- warn(
430
- `agent ${agentId}: the published branch ${landedRef} could not be deleted — ${publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''))}`
431
- );
432
- }
433
- }
433
+ }, { place, publishedRef: job.publishedRef ?? null }).catch(() => {});
434
434
  }
435
+ await retireAgentMerge(agentId);
435
436
  }
436
437
  };
437
438
 
438
439
  const processAgentMergeJobs = (jobs) => {
440
+ void (async () => {
441
+ await reports.flush();
442
+ for (const id of [...retirements.keys()]) if (!agentMerges.has(id)) await retireAgentMerge(id);
443
+ })().catch(() => {});
439
444
  if (!Array.isArray(jobs) || jobs.length === 0) return;
440
445
  for (const job of jobs.slice(0, 2)) {
441
446
  const id = String(job?.batch?.spec?.id || job?.agentId || '');
442
- if (!id || agentMerges.has(id)) continue;
447
+ if (!id || agentMerges.has(id) || reports.pending.has(id)) continue;
443
448
  agentMerges.add(id);
444
449
  void (job.batch ? runBatch(job) : runAgentMerge(job)).finally(() => agentMerges.delete(id));
445
450
  }
446
451
  };
447
452
 
448
- return { processAgentMergeJobs, agentMerges };
453
+ return { processAgentMergeJobs, agentMerges, pendingMergeReports: reports.pending };
449
454
  }
@@ -1,4 +1,7 @@
1
+ import { planWork, reportPlanOutcome } from './workActivity.mjs';
1
2
  /** Agent plan claims, read-only planning turns, and proposal reports. */
3
+ import { PLAN_RECOVERY_SYSTEM, parsePlanDiagnosis, watchPlanAttempt } from './workPlanRecovery.mjs';
4
+ import { readClaudeAuthStatus } from './runtimeAuth.mjs';
2
5
  import { discoveryGuide } from './discoveryContract.mjs';
3
6
  import { compareRepositoryBaselines, measureRepositoryBaseline, repositoryBaselineOf } from './repositoryBaseline.mjs';
4
7
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
@@ -33,10 +36,12 @@ export function createWorkAgentPlans({
33
36
  runTurn = defaultRunTurn,
34
37
  pickRuntimeFor = defaultPickRuntimeFor,
35
38
  detectRuntimes = defaultDetectRuntimes,
39
+ authStatus = readClaudeAuthStatus,
36
40
  }) {
37
41
  const AGENT_PLAN_DONE_URL = fleetEndpoint('agent-plan-done', FLEET_URL);
38
42
  const AGENT_PLAN_ACTIVITY_URL = fleetEndpoint('agent-plan-activity', FLEET_URL);
39
43
  const AGENT_PLAN_CLAIM_URL = fleetEndpoint('agent-plan-claim', FLEET_URL);
44
+ const AGENT_PLAN_STATE_URL = fleetEndpoint('agent-plan-state', FLEET_URL);
40
45
 
41
46
  // ── AGENT PLAN JOBS: the Deploy press ──────────────────────────────────────
42
47
  //
@@ -60,13 +65,13 @@ export function createWorkAgentPlans({
60
65
  // tabs. It writes nothing, so a writer lock would only starve real work.
61
66
  const planning = new Set(); // press ids in flight on this tick
62
67
 
63
- const postAgentPlan = async (body) => {
68
+ const sendAgentPlan = async (body) => {
64
69
  // Bounded retry on 408/429/5xx, an other-4xx counted as delivered — see
65
70
  // postBestEffort's own docblock. Previously this swallowed every
66
71
  // response including ordinary 5xx and rate limits, so a transient
67
72
  // failure looked identical to a hostile refusal: nothing retried, and
68
73
  // the proposal sat unsettled until the server's own expiry.
69
- await postBestEffort(AGENT_PLAN_DONE_URL, { ...body, instance: DAEMON_INSTANCE });
74
+ return postBestEffort(AGENT_PLAN_DONE_URL, { ...body, instance: DAEMON_INSTANCE });
70
75
  };
71
76
 
72
77
  /**
@@ -90,7 +95,7 @@ export function createWorkAgentPlans({
90
95
  * turn it describes. A daemon that never calls this is a machine that has
91
96
  * not narrated, which is the only thing an absent line can mean.
92
97
  */
93
- const postAgentPlanActivity = async (planId, line) => {
98
+ const postAgentPlanActivity = async (planId, line, attempt, phase, diagnosis) => {
94
99
  const text = envScrub(String(line ?? '')).slice(0, 400);
95
100
  if (!text) return;
96
101
  try {
@@ -98,7 +103,7 @@ export function createWorkAgentPlans({
98
103
  method: 'POST',
99
104
  headers: fleetHeaders(),
100
105
  signal: AbortSignal.timeout(15_000),
101
- body: JSON.stringify({ planId, line: text }),
106
+ body: JSON.stringify({ planId, line: text, instance: DAEMON_INSTANCE, ...(attempt !== undefined ? { attempt } : {}), ...(phase ? { phase, diagnosis } : {}) }),
102
107
  });
103
108
  } catch {
104
109
  /* a readout — losing a line costs nothing */
@@ -134,7 +139,7 @@ export function createWorkAgentPlans({
134
139
  const PLAN_TURN_TIMEOUT_MS = 15 * 60_000;
135
140
 
136
141
  // The peer may hold it; doing nothing is the safe answer.
137
- const claimAgentPlan = (id) => claimFleetLease(AGENT_PLAN_CLAIM_URL, { id });
142
+ const claimAgentPlan = (id, attempt) => claimFleetLease(AGENT_PLAN_CLAIM_URL, { id, ...(attempt !== undefined ? { attempt } : {}) });
138
143
 
139
144
  /** See `runAgentTurn` — the admission reservation, released the moment the
140
145
  * planner's CLI exists. */
@@ -149,11 +154,27 @@ export function createWorkAgentPlans({
149
154
  // kill keeps (refuse cheaply, then claim) and it is because these two
150
155
  // lanes settle differently: a kill's settle does not check a lease for the
151
156
  // not-found case, and a plan's always does.
152
- if (!(await claimAgentPlan(id))) return;
157
+ if (!(await claimAgentPlan(id, job.attempt))) return;
158
+ const control = job.recoveryEnabled ? await watchPlanAttempt(AGENT_PLAN_STATE_URL, job) : null;
159
+ const report = async (body) => {
160
+ const result = { ...body, ...(job.attempt !== undefined ? { attempt: job.attempt } : {}) };
161
+ reportPlanOutcome(job, result);
162
+ // Keep the measured result while its acknowledgement is unknown. A
163
+ // network retry delivers this report, never another paid planning turn.
164
+ while (!control?.signal.aborted) {
165
+ const sent = await sendAgentPlan(result);
166
+ if (sent !== false || !job.recoveryEnabled) return;
167
+ await postAgentPlanActivity(id, 'The Plan result has not been acknowledged. Retrying delivery.', job.attempt);
168
+ await new Promise(resolve => setTimeout(resolve, 3000));
169
+ }
170
+ };
171
+ const unavailable = (reason) => report({ id, ...(job.recoveryEnabled ? { question: { question: `${reason}. Can you make that CLI available on the machine and tell me when it is ready?`, context: reason } } : { error: reason }) });
172
+ try {
173
+ if (control?.signal.aborted) return;
153
174
  if (tasks.length === 0) {
154
175
  // The press named cards the doc no longer has. A real answer, and one the
155
176
  // board can explain, rather than a turn that plans nothing.
156
- await postAgentPlan({ id, error: 'those cards are no longer on the board' });
177
+ await report({ id, error: 'those cards are no longer on the board' });
157
178
  return;
158
179
  }
159
180
 
@@ -172,23 +193,27 @@ export function createWorkAgentPlans({
172
193
  rt = pickedRuntime;
173
194
  const runtime = runtimeById(rt);
174
195
  if (!runtime) {
175
- await postAgentPlan({ id, error: `${rt} is not a CLI this machine knows` });
196
+ await unavailable(`${rt} is not a CLI this machine knows`);
176
197
  return;
177
198
  }
178
199
  const label = runtime.label;
179
200
  if (!detectRuntimes().find((r) => r.id === rt)?.installed) {
180
- await postAgentPlan({ id, error: `${label} is not installed on this machine` });
201
+ await unavailable(`${label} is not installed on this machine`);
181
202
  return;
182
203
  }
183
204
  if (!canRun(runtime, 'consult')) {
184
- await postAgentPlan({ id, error: `${label} can't run a read-only turn on this machine` });
205
+ await unavailable(`${label} can't run a read-only turn on this machine`);
185
206
  return;
186
207
  }
187
208
  } else {
188
209
  rt = pickRuntimeFor('consult');
189
210
  }
190
211
  if (!rt) {
191
- await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
212
+ await unavailable('No CLI on this machine can run a read-only turn');
213
+ return;
214
+ }
215
+ if (job.recoveryEnabled && job.recovery && rt === 'claude' && authStatus({ refresh: true }).signedIn === false) {
216
+ await report({ id, question: { question: 'Claude Code reports that it is signed out. Can you sign in on this machine and tell me when it is ready?', context: 'The CLI’s native auth status reported signed out.' } });
192
217
  return;
193
218
  }
194
219
 
@@ -204,7 +229,7 @@ export function createWorkAgentPlans({
204
229
  const now = Date.now();
205
230
  if (now - lastPlanBeat < 2_000) return;
206
231
  lastPlanBeat = now;
207
- void postAgentPlanActivity(id, line);
232
+ void postAgentPlanActivity(id, line, job.attempt);
208
233
  };
209
234
  /**
210
235
  * THE MACHINE'S OWN VOICE, for the moments only this side can see — the
@@ -219,10 +244,11 @@ export function createWorkAgentPlans({
219
244
  */
220
245
  const say = (line) => {
221
246
  lastPlanBeat = Date.now();
222
- void postAgentPlanActivity(id, line);
247
+ void postAgentPlanActivity(id, line, job.attempt);
223
248
  };
224
249
 
225
250
  let out = '';
251
+ let diagnosis = null;
226
252
  /** The cap fired: the CLI was still running when this machine stopped it. */
227
253
  let wedged = false;
228
254
  /**
@@ -234,7 +260,7 @@ export function createWorkAgentPlans({
234
260
  */
235
261
  let usage = null;
236
262
  const discovery = createToolLog(repoRoot);
237
- const spent = () => ({ ...(usage ? { usage } : {}), tools: discovery.toolLog });
263
+ const spent = () => ({ ...(usage ? { usage } : {}), tools: discovery.toolLog, ...(diagnosis ? { diagnosis: diagnosis.summary } : {}) });
238
264
  // Said BEFORE the lock is asked for, because a reader only waits when a
239
265
  // writer holds the checkout or is queued ahead of it — which is exactly
240
266
  // this condition, read one line before we join the queue.
@@ -261,20 +287,17 @@ export function createWorkAgentPlans({
261
287
  discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
262
288
  discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
263
289
  discovery.toolLog.reconciliation = compareRepositoryBaselines(discovery.toolLog.baseline, discovery.toolLog.captureBaseline);
264
- ({ out, wedged } = await runCappedTurn({
290
+ const consult = async (prompt, system) => await runCappedTurn({
265
291
  runTurn,
292
+ signal: control?.signal,
266
293
  capMs: PLAN_TURN_TIMEOUT_MS,
267
294
  workChildren,
268
295
  releaseSlot,
269
296
  transcriptCwd: repoRoot,
270
297
  turnArgs: {
271
298
  diagnosticPlanId: id,
272
- prompt: AGENT_PLAN_KICKOFF({
273
- tasks,
274
- liveAgents,
275
- agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
276
- }),
277
- system: withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation),
299
+ prompt,
300
+ system,
278
301
  knowledgeDir,
279
302
  // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
280
303
  // passed empty, so there is no control plane on this turn at all.
@@ -283,7 +306,7 @@ export function createWorkAgentPlans({
283
306
  runtime: rt,
284
307
  ...brainFor(job, rt),
285
308
  answerFromResult: true,
286
- label: c.cyan('[plan]'),
309
+ work: planWork(job),
287
310
  // The CLI's own tail, forwarded to the press. Same rule as an agent
288
311
  // turn's: throttled, overwritten rather than appended, and never
289
312
  // awaited by the turn.
@@ -300,14 +323,39 @@ export function createWorkAgentPlans({
300
323
  onSpawn: () => {
301
324
  say(`${runtimeLabel(rt)} started on this machine`);
302
325
  },
303
- }));
326
+ });
327
+ let context = AGENT_PLAN_KICKOFF({ tasks, liveAgents, agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3 });
328
+ if (job.recovery?.start) context += '\nSTART REPAIR: This is a supporting diagnosis for an already approved Start. Return exactly ONE group with the same ordered taskIds, attachment target (or absence) and CLI. Preserve the approval; do not start an unapproved sibling. Diagnose admission/pickup errors and repair planning evidence/context. Ask only a genuine person-only question in the diagnostic JSON. Approval and source revision (data): ' + JSON.stringify(job.recovery.start);
329
+ const system = withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation);
330
+ if (job.recovery) {
331
+ say(`Diagnosing the failed Plan: ${job.recovery.reason}`);
332
+ const diagnostic = await consult(context + '\nRetained recovery context (data): ' + JSON.stringify(job.recovery), withProjectContext(PLAN_RECOVERY_SYSTEM, { knowledgeDir }) + '\n' + discoveryGuide(rt, 'planning'));
333
+ if (control?.signal.aborted) return;
334
+ if (diagnostic.wedged) { wedged = true; return; }
335
+ const result = parsePlanDiagnosis(diagnostic.out);
336
+ if (result?.status === 'question') {
337
+ diagnosis = result;
338
+ return;
339
+ }
340
+ if (!result) throw new Error(`The diagnostic turn did not return a diagnosis: ${envScrub(String(diagnostic.out ?? '')).slice(0, 300) || 'no output'}`);
341
+ diagnosis = result;
342
+ await postAgentPlanActivity(id, `Retrying Plan: ${result.summary}`, job.attempt, 'retrying', result.summary);
343
+ await control?.check();
344
+ if (control?.signal.aborted) return;
345
+ }
346
+ ({ out, wedged } = await consult(context + (diagnosis ? '\nDiagnosis and corrected approach (data): ' + JSON.stringify(diagnosis) : ''), system));
304
347
  });
305
348
  } catch (e) {
306
349
  discovery.toolLog.state = 'partial';
307
- await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
350
+ await report({ id, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
308
351
  return;
309
352
  }
310
353
 
354
+ if (control?.signal.aborted) return;
355
+ if (diagnosis?.status === 'question') {
356
+ await report({ id, question: { question: diagnosis.question, context: diagnosis.context }, ...spent() });
357
+ return;
358
+ }
311
359
  if (wedged) {
312
360
  discovery.toolLog.state = 'partial';
313
361
  // NEVER LEAVE A CLAIMED PRESS UNREPORTED — the belt the merge lane wears
@@ -315,7 +363,7 @@ export function createWorkAgentPlans({
315
363
  // only side that knows: the server would have expired this press in
316
364
  // another fifteen minutes with a sentence blaming a daemon that had in
317
365
  // fact answered.
318
- await postAgentPlan({
366
+ await report({
319
367
  id,
320
368
  error: 'the planning turn ran past fifteen minutes on this machine and was stopped',
321
369
  ...spent(),
@@ -325,7 +373,7 @@ export function createWorkAgentPlans({
325
373
 
326
374
  const proposal = parseProposal(out);
327
375
  if (!proposal) {
328
- await postAgentPlan({
376
+ await report({
329
377
  id,
330
378
  // The CLI's own words when it has any — a turn that explained why it
331
379
  // could not plan is far more use than "planning failed".
@@ -336,7 +384,8 @@ export function createWorkAgentPlans({
336
384
  });
337
385
  return;
338
386
  }
339
- await postAgentPlan({ id, proposal, sessionRef: repoRoot, ...spent() });
387
+ await report({ id, proposal, sessionRef: repoRoot, ...spent() });
388
+ } finally { control?.stop(); }
340
389
  };
341
390
 
342
391
  const processAgentPlanJobs = (jobs) => {
@@ -239,7 +239,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
239
239
  // agent grading its own homework out of its own context; this is a
240
240
  // stranger reading a diff.
241
241
  answerFromResult: true,
242
- label: c.cyan('[pre-review]'),
242
+ work: { kind: 'pre-review', id: agentId, name: agentName },
243
243
  // The CLI's own count for this reading. Set, never accumulated: one
244
244
  // turn is one `result` event, and the adding-up is the server's.
245
245
  // Tagged with the CLI, so a Claude pre-review of a Codex agent is
@@ -1,3 +1,6 @@
1
+ import { agentWork } from './workActivity.mjs';
2
+ import { relayedText } from './prWorkflow.mjs';
3
+ import { measuredMergeResolution } from './agentMergeResolution.mjs';
1
4
  import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryDelivery.mjs';
2
5
  /**
3
6
  * ONE AGENT TURN, RUN: the worktree, the CLI, and what it left behind.
@@ -18,7 +21,7 @@ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryD
18
21
  * answered. `measured` is the same bargain the other way: the branch facts and
19
22
  * the spend, written here as they are learned, read by that catch.
20
23
  */
21
- import { PROJECT_ID } from './config.mjs';
24
+ import { MODEL, PROJECT_ID } from './config.mjs';
22
25
  import { setRuntimeLimit } from './daemonState.mjs';
23
26
  import { gitOrNull } from './git.mjs';
24
27
  import { turnCommits } from './turnReceipts.mjs';
@@ -49,6 +52,8 @@ import { createDeliveryReporter } from './workDelivery.mjs';
49
52
  import { createLiveMessages } from './workLiveMessages.mjs';
50
53
  import { createOrchestrator } from './orchestrator.mjs';
51
54
  import { claudeOrchestrationSafe } from './orchestratorHooks.mjs';
55
+ import { ORCHESTRATED_POSTURES, orchestratesPosture } from './orchestratorPrompt.mjs';
56
+ import { knownCodexModels } from './runtimeOfferings.mjs';
52
57
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
53
58
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
54
59
 
@@ -72,6 +77,7 @@ export function createAgentTurnExecution({
72
77
  agentChildren,
73
78
  postAgentTrace,
74
79
  postAgentActivity,
80
+ postAgentContext = () => {},
75
81
  postAgentParked,
76
82
  fetchAgentFiles,
77
83
  makeLiveMessages = createLiveMessages,
@@ -303,10 +309,12 @@ export function createAgentTurnExecution({
303
309
  }
304
310
 
305
311
  // ORCHESTRATED ONLY WHERE THE CLI CAN BE GATED (2026-10-17). v2 reaches every
306
- // job at the floor; a fenced card or a CLI below its measured floor (unknown
307
- // included) has no controller, so no marker: its notes fold on receipt.
312
+ // job at its kind's floor; an artifact card (design, model, image, deck) or
313
+ // a CLI below its measured floor (unknown included) has no controller, so
314
+ // no marker: its notes fold on receipt. Research is orchestrated too since
315
+ // 2026-10-19, with read-only children (orchestratorPrompt.mjs).
308
316
  const codexVersion = rt === 'codex' ? measuredRuntimeVersion('codex')?.version : undefined;
309
- const orchestrating = job.orchestrationVersion === 2 && posture === 'build' &&
317
+ const orchestrating = job.orchestrationVersion === 2 && orchestratesPosture(posture) &&
310
318
  (rt === 'claude' ? claudeOrchestrationSafe(measuredRuntimeVersion('claude')?.version) : rt === 'codex' && codexSubagentsSafe(codexVersion));
311
319
  /**
312
320
  * THE WHOLE STREAM, not just its latest line — see trace.mjs.
@@ -350,6 +358,10 @@ export function createAgentTurnExecution({
350
358
  * neither key.
351
359
  */
352
360
  const brain = brainFor(job, rt);
361
+ // The card's model and effort as the parent runs them, for an orchestrated
362
+ // run's children and contract: Claude always names --model (the daemon's
363
+ // pin when the card has none); Codex's unpinned default is its own.
364
+ const cardBrain = { model: brain.model ?? (rt === 'claude' ? MODEL : null), effort: brain.effort ?? null };
353
365
 
354
366
  let out = '';
355
367
  let child = null;
@@ -383,7 +395,7 @@ export function createAgentTurnExecution({
383
395
  /** THE MODEL THE TURN RAN ON, as its CLI named it (turnModel.mjs) — rides
384
396
  * every settle `usage` rides, for the same reason; null sends no key. */
385
397
  let model = null;
386
- const brief = makeBrief?.({ agentId, turnId, runtime: rt, model: () => model ?? job.model });
398
+ const brief = makeBrief?.({ agentId, turnId, workName: job.task?.title || job.agentName, runtime: rt, model: () => model ?? job.model });
387
399
  brief?.append('prompt', job.task ? { title: job.task.title, message: job.body, discussion: job.cardThread, brief: job.task.brief, criteria: job.task.criteria } : job.body);
388
400
  /**
389
401
  * THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
@@ -403,6 +415,9 @@ export function createAgentTurnExecution({
403
415
  try {
404
416
  if (orchestrating) {
405
417
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
418
+ // Codex's spawn takes only a catalog model (knownCodexModels, the
419
+ // measured models_cache.json rows the roster already reports).
420
+ children: { access: ORCHESTRATED_POSTURES[posture], ...cardBrain, ...(rt === 'codex' ? { catalog: knownCodexModels() } : {}) },
406
421
  // Whether the words reached the CLI; Codex reads its inbox from hooks.
407
422
  notify: message => liveInput ? run.live?.inject(message) === true : false,
408
423
  taken: id => run.live?.onTaken(id) });
@@ -423,8 +438,10 @@ export function createAgentTurnExecution({
423
438
  })) } : {}),
424
439
  // The kickoff and the system prompt, composed by the agent lane's
425
440
  // shape (workAgentTurnShape.mjs) from what this run measured.
441
+ researchHandoff: job.researchHandoff,
442
+ researchIdentity: { projectId: PROJECT_ID, agentId, turnId, taskId: job.task?.id ?? job.repositoryTaskId },
426
443
  prompt: agentKickoff(job, { cardFiles: files.card, messageFiles: files.message }),
427
- system: agentTurnSystem({ taskKind, runtimeLabel: runtimeById(rt)?.label, prerequisitesAccepted: job.prerequisitesAccepted === true, knowledgeDir: knowledgeDirFor(repoRoot), artifactsAccepted: getArtifactsAccepted(), toolsNote, artifactInstructions, toolInstructions: projectTools.instructions, repositoryRun, orchestrated: Boolean(orchestrator) }),
444
+ system: agentTurnSystem({ taskKind, runtimeLabel: runtimeById(rt)?.label, prerequisitesAccepted: job.prerequisitesAccepted === true, knowledgeDir: knowledgeDirFor(repoRoot), artifactsAccepted: getArtifactsAccepted(), toolsNote, artifactInstructions, toolInstructions: projectTools.instructions, repositoryRun, orchestration: orchestrator ? { posture, runtime: rt, ...cardBrain } : null }),
428
445
  agentTools: projectTools,
429
446
  writableDirs: repositoryRun?.writableDirs,
430
447
  ...((tools.envNeeded || repositoryRun?.tool || orchestrator) ? { mcpEnv: { ...tools.env, ...(repositoryRun?.tool?.env ?? {}), ...(orchestrator?.env ?? {}) } } : {}),
@@ -464,7 +481,26 @@ export function createAgentTurnExecution({
464
481
  // Present only when the container named one — see brainFor.
465
482
  ...brain,
466
483
  answerFromResult: true,
467
- label: c.cyan('[agent]'),
484
+ work: agentWork(job),
485
+ // The CLI's own context measure while the turn runs (claudeInput.mjs),
486
+ // the latest one at a time; the server clears it at turn end.
487
+ onContext: (() => {
488
+ let next = null;
489
+ let busy = false;
490
+ return (ctx) => {
491
+ next = ctx;
492
+ if (busy) return;
493
+ busy = true;
494
+ void (async () => {
495
+ while (next) {
496
+ const measured = next;
497
+ next = null;
498
+ await postAgentContext(agentId, turnId, measured);
499
+ }
500
+ busy = false;
501
+ })();
502
+ };
503
+ })(),
468
504
  // The CLI's own tail, relayed. Throttled by the same rule the tab's
469
505
  // narrator keeps: overwritten, never appended, and ignored by the
470
506
  // board past ~90 seconds.
@@ -732,7 +768,25 @@ export function createAgentTurnExecution({
732
768
  // reported as `nothing` — it did not deliver and it did not ask.
733
769
  await postAgentParked(limit, rt);
734
770
  }
735
- if (job.kind === 'merge_resolve' && repositoryRun) body.mergeResolved = allResolutionsCommitted(repositoryRun.sourcesNow());
771
+ if (job.kind === 'merge_resolve') {
772
+ const resolution = () => repositoryRun
773
+ ? allResolutionsCommitted(repositoryRun.sourcesNow())
774
+ : measuredMergeResolution(wt, before, baseRef());
775
+ body.mergeResolved = resolution();
776
+ if (body.outcome === 'delivered' && body.mergeResolved) {
777
+ const rows = repositoryRun?.sourcesNow() ?? [{ wt }];
778
+ for (const r of rows) {
779
+ const head = gitOrNull(['rev-parse', 'HEAD'], r.wt);
780
+ const check = await runReviewEntry(agentId, r.wt, job.agentName, repositoryRun ? r : undefined);
781
+ if (check?.status === 'failed') {
782
+ body.mergeResolved = false;
783
+ body.mergeDetail = relayedText(check.text || check.output || 'The repository check failed during merge recovery.').slice(0, 2000);
784
+ }
785
+ if (!head || head !== gitOrNull(['rev-parse', 'HEAD'], r.wt)) body.mergeResolved = false;
786
+ }
787
+ body.mergeResolved = body.mergeResolved && resolution();
788
+ }
789
+ }
736
790
  const reply = await postAgentTurn(body);
737
791
  // The queue just emptied. Run the project's own check and the AI
738
792
  // pre-review HERE, in the worktree we are already standing in and still