flowviant 0.125.7 → 0.125.12

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 (65) hide show
  1. package/README.md +80 -1
  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 +48 -0
  6. package/bin/lib/agentPlan.mjs +14 -2
  7. package/bin/lib/agentTaskTypes.mjs +11 -7
  8. package/bin/lib/artifactContracts.mjs +8 -0
  9. package/bin/lib/artifactIteration.mjs +18 -0
  10. package/bin/lib/artifactReporter.mjs +16 -3
  11. package/bin/lib/claudePosture.mjs +21 -0
  12. package/bin/lib/claudeStream.mjs +25 -4
  13. package/bin/lib/codexResearch.mjs +35 -5
  14. package/bin/lib/commandSpecs.mjs +8 -5
  15. package/bin/lib/daemonJournal.mjs +102 -0
  16. package/bin/lib/daemonLogging.mjs +22 -18
  17. package/bin/lib/fleet.mjs +16 -6
  18. package/bin/lib/fleetReports.mjs +12 -5
  19. package/bin/lib/hooks/orchestrator.mjs +3 -1
  20. package/bin/lib/logReplay.mjs +133 -0
  21. package/bin/lib/nativeAgentTools.mjs +20 -3
  22. package/bin/lib/observedLines.mjs +63 -0
  23. package/bin/lib/orchestrator.mjs +40 -7
  24. package/bin/lib/orchestratorNative.mjs +133 -3
  25. package/bin/lib/orchestratorPrompt.mjs +51 -4
  26. package/bin/lib/postureTools.mjs +14 -6
  27. package/bin/lib/printable.mjs +1 -1
  28. package/bin/lib/prompts.mjs +122 -7
  29. package/bin/lib/repositoryDelivery.mjs +1 -1
  30. package/bin/lib/repositoryMerge.mjs +1 -1
  31. package/bin/lib/researchHandoff.mjs +144 -0
  32. package/bin/lib/researchHandoffText.mjs +3 -0
  33. package/bin/lib/researchKnowledge.mjs +60 -0
  34. package/bin/lib/researchRestoration.mjs +38 -0
  35. package/bin/lib/resultInputs.mjs +83 -0
  36. package/bin/lib/runTurn.mjs +76 -43
  37. package/bin/lib/runtimeClaude.mjs +9 -4
  38. package/bin/lib/runtimeCodex.mjs +51 -22
  39. package/bin/lib/runtimeEvents.mjs +17 -7
  40. package/bin/lib/testFindings.mjs +60 -0
  41. package/bin/lib/testFindingsContract.mjs +2 -0
  42. package/bin/lib/turnDiagnostics.mjs +2 -1
  43. package/bin/lib/turnOutput.mjs +63 -0
  44. package/bin/lib/turnRecord.mjs +11 -0
  45. package/bin/lib/turnSubagents.mjs +13 -3
  46. package/bin/lib/ui.mjs +2 -2
  47. package/bin/lib/uplinkScrub.mjs +49 -0
  48. package/bin/lib/views.mjs +49 -58
  49. package/bin/lib/wikiRunner.mjs +2 -2
  50. package/bin/lib/work.mjs +2 -2
  51. package/bin/lib/workActivity.mjs +65 -0
  52. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  53. package/bin/lib/workAgentMerges.mjs +57 -52
  54. package/bin/lib/workAgentPlans.mjs +76 -27
  55. package/bin/lib/workAgentPrecheck.mjs +1 -1
  56. package/bin/lib/workAgentTurnExecution.mjs +55 -9
  57. package/bin/lib/workAgentTurnOutcome.mjs +12 -3
  58. package/bin/lib/workAgentTurnReports.mjs +17 -3
  59. package/bin/lib/workAgentTurnShape.mjs +8 -5
  60. package/bin/lib/workAgentTurns.mjs +8 -6
  61. package/bin/lib/workIntake.mjs +8 -2
  62. package/bin/lib/workMergeBatch.mjs +7 -1
  63. package/bin/lib/workPlanRecovery.mjs +50 -0
  64. package/bin/lib/workSessionTurns.mjs +7 -2
  65. 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,9 @@
1
+ import { imageRevisionInstructions } from './artifactIteration.mjs';
2
+ import { measureTestFindings } from './testFindings.mjs';
3
+ import { fetchResultInputs } from './resultInputs.mjs';
4
+ import { agentWork } from './workActivity.mjs';
5
+ import { relayedText } from './prWorkflow.mjs';
6
+ import { measuredMergeResolution } from './agentMergeResolution.mjs';
1
7
  import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryDelivery.mjs';
2
8
  /**
3
9
  * ONE AGENT TURN, RUN: the worktree, the CLI, and what it left behind.
@@ -18,7 +24,7 @@ import { finishRepositoryDelivery, allResolutionsCommitted } from './repositoryD
18
24
  * answered. `measured` is the same bargain the other way: the branch facts and
19
25
  * the spend, written here as they are learned, read by that catch.
20
26
  */
21
- import { PROJECT_ID } from './config.mjs';
27
+ import { MODEL, PROJECT_ID } from './config.mjs';
22
28
  import { setRuntimeLimit } from './daemonState.mjs';
23
29
  import { gitOrNull } from './git.mjs';
24
30
  import { turnCommits } from './turnReceipts.mjs';
@@ -49,6 +55,8 @@ import { createDeliveryReporter } from './workDelivery.mjs';
49
55
  import { createLiveMessages } from './workLiveMessages.mjs';
50
56
  import { createOrchestrator } from './orchestrator.mjs';
51
57
  import { claudeOrchestrationSafe } from './orchestratorHooks.mjs';
58
+ import { ORCHESTRATED_POSTURES, orchestratesPosture } from './orchestratorPrompt.mjs';
59
+ import { knownCodexModels } from './runtimeOfferings.mjs';
52
60
  import { codexSubagentsSafe } from './runtimeCodex.mjs';
53
61
  import { measuredRuntimeVersion } from './runtimeDetection.mjs';
54
62
 
@@ -267,6 +275,7 @@ export function createAgentTurnExecution({
267
275
  * No key is no call: a turn without files touches nothing here, and its
268
276
  * prompt is byte-for-byte what it was.
269
277
  */
278
+ await fetchResultInputs(wt, job.resultInputs);
270
279
  const files =
271
280
  Array.isArray(job.attachments) && job.attachments.length > 0
272
281
  ? await fetchAgentFiles(wt, job.attachments)
@@ -304,10 +313,12 @@ export function createAgentTurnExecution({
304
313
  }
305
314
 
306
315
  // ORCHESTRATED ONLY WHERE THE CLI CAN BE GATED (2026-10-17). v2 reaches every
307
- // job at the floor; a fenced card or a CLI below its measured floor (unknown
308
- // included) has no controller, so no marker: its notes fold on receipt.
316
+ // job at its kind's floor; an artifact card (design, model, image, deck) or
317
+ // a CLI below its measured floor (unknown included) has no controller, so
318
+ // no marker: its notes fold on receipt. Research is orchestrated too since
319
+ // 2026-10-19, with read-only children (orchestratorPrompt.mjs).
309
320
  const codexVersion = rt === 'codex' ? measuredRuntimeVersion('codex')?.version : undefined;
310
- const orchestrating = job.orchestrationVersion === 2 && posture === 'build' &&
321
+ const orchestrating = job.orchestrationVersion === 2 && orchestratesPosture(posture) &&
311
322
  (rt === 'claude' ? claudeOrchestrationSafe(measuredRuntimeVersion('claude')?.version) : rt === 'codex' && codexSubagentsSafe(codexVersion));
312
323
  /**
313
324
  * THE WHOLE STREAM, not just its latest line — see trace.mjs.
@@ -351,6 +362,10 @@ export function createAgentTurnExecution({
351
362
  * neither key.
352
363
  */
353
364
  const brain = brainFor(job, rt);
365
+ // The card's model and effort as the parent runs them, for an orchestrated
366
+ // run's children and contract: Claude always names --model (the daemon's
367
+ // pin when the card has none); Codex's unpinned default is its own.
368
+ const cardBrain = { model: brain.model ?? (rt === 'claude' ? MODEL : null), effort: brain.effort ?? null };
354
369
 
355
370
  let out = '';
356
371
  let child = null;
@@ -384,7 +399,7 @@ export function createAgentTurnExecution({
384
399
  /** THE MODEL THE TURN RAN ON, as its CLI named it (turnModel.mjs) — rides
385
400
  * every settle `usage` rides, for the same reason; null sends no key. */
386
401
  let model = null;
387
- const brief = makeBrief?.({ agentId, turnId, runtime: rt, model: () => model ?? job.model });
402
+ const brief = makeBrief?.({ agentId, turnId, workName: job.task?.title || job.agentName, runtime: rt, model: () => model ?? job.model });
388
403
  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);
389
404
  /**
390
405
  * THE AGENT'S LAST MESSAGE, ALONE — where the final JSON object lives.
@@ -397,13 +412,20 @@ export function createAgentTurnExecution({
397
412
  /** The agent's artifact directory as it stood before this turn — the
398
413
  * tab lane's rule, in the agent's own worktree (2026-09-22). */
399
414
  const artifactsBefore = beforeArtifacts(wt);
400
- const artifactInstructions = getArtifactsAccepted()
415
+ let artifactInstructions = getArtifactsAccepted()
401
416
  ? await artifacts.prepare?.({ placeDir: wt, before: artifactsBefore, agentId }) ?? '' : '';
417
+ if (job.imageRevision) {
418
+ artifactsBefore.imageRevision = job.imageRevision;
419
+ artifactInstructions += '\n\n' + imageRevisionInstructions(job.imageRevision, job.resultInputs);
420
+ }
402
421
  let projectTools = null;
403
422
  let orchestrator = null;
404
423
  try {
405
424
  if (orchestrating) {
406
425
  orchestrator = await createOrchestrator({ turnId, runtime: rt, trace, admit,
426
+ // Codex's spawn takes only a catalog model (knownCodexModels, the
427
+ // measured models_cache.json rows the roster already reports).
428
+ children: { access: ORCHESTRATED_POSTURES[posture], ...cardBrain, ...(rt === 'codex' ? { catalog: knownCodexModels() } : {}) },
407
429
  // Whether the words reached the CLI; Codex reads its inbox from hooks.
408
430
  notify: message => liveInput ? run.live?.inject(message) === true : false,
409
431
  taken: id => run.live?.onTaken(id) });
@@ -424,8 +446,11 @@ export function createAgentTurnExecution({
424
446
  })) } : {}),
425
447
  // The kickoff and the system prompt, composed by the agent lane's
426
448
  // shape (workAgentTurnShape.mjs) from what this run measured.
449
+ researchHandoff: job.researchHandoff,
450
+ resultInputs: job.resultInputs,
451
+ researchIdentity: { projectId: PROJECT_ID, agentId, turnId, taskId: job.task?.id ?? job.repositoryTaskId },
427
452
  prompt: agentKickoff(job, { cardFiles: files.card, messageFiles: files.message }),
428
- 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) }),
453
+ system: agentTurnSystem({ taskKind, taskType: job.task?.taskType, 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 }),
429
454
  agentTools: projectTools,
430
455
  writableDirs: repositoryRun?.writableDirs,
431
456
  ...((tools.envNeeded || repositoryRun?.tool || orchestrator) ? { mcpEnv: { ...tools.env, ...(repositoryRun?.tool?.env ?? {}), ...(orchestrator?.env ?? {}) } } : {}),
@@ -465,7 +490,7 @@ export function createAgentTurnExecution({
465
490
  // Present only when the container named one — see brainFor.
466
491
  ...brain,
467
492
  answerFromResult: true,
468
- label: c.cyan('[agent]'),
493
+ work: agentWork(job),
469
494
  // The CLI's own context measure while the turn runs (claudeInput.mjs),
470
495
  // the latest one at a time; the server clears it at turn end.
471
496
  onContext: (() => {
@@ -743,6 +768,9 @@ export function createAgentTurnExecution({
743
768
  wt,
744
769
  });
745
770
  if (res) setRuntimeLimit(PROJECT_ID, rt, null);
771
+ if (body.outcome === 'delivered' && job.task?.taskType === 'tests' && res?.testFindings) {
772
+ body.testFindings = measureTestFindings(res.testFindings, repositoryRun?.sourcesNow() ?? [{ wt, baseRef: baseRef(), id: job.repos?.length === 1 ? job.repos[0] : null }], envScrub);
773
+ }
746
774
  if (limit) {
747
775
  setRuntimeLimit(PROJECT_ID, rt, limit);
748
776
  // Every agent ON THIS CLI parks, because that account is shared: one
@@ -752,7 +780,25 @@ export function createAgentTurnExecution({
752
780
  // reported as `nothing` — it did not deliver and it did not ask.
753
781
  await postAgentParked(limit, rt);
754
782
  }
755
- if (job.kind === 'merge_resolve' && repositoryRun) body.mergeResolved = allResolutionsCommitted(repositoryRun.sourcesNow());
783
+ if (job.kind === 'merge_resolve') {
784
+ const resolution = () => repositoryRun
785
+ ? allResolutionsCommitted(repositoryRun.sourcesNow())
786
+ : measuredMergeResolution(wt, before, baseRef());
787
+ body.mergeResolved = resolution();
788
+ if (body.outcome === 'delivered' && body.mergeResolved) {
789
+ const rows = repositoryRun?.sourcesNow() ?? [{ wt }];
790
+ for (const r of rows) {
791
+ const head = gitOrNull(['rev-parse', 'HEAD'], r.wt);
792
+ const check = await runReviewEntry(agentId, r.wt, job.agentName, repositoryRun ? r : undefined);
793
+ if (check?.status === 'failed') {
794
+ body.mergeResolved = false;
795
+ body.mergeDetail = relayedText(check.text || check.output || 'The repository check failed during merge recovery.').slice(0, 2000);
796
+ }
797
+ if (!head || head !== gitOrNull(['rev-parse', 'HEAD'], r.wt)) body.mergeResolved = false;
798
+ }
799
+ body.mergeResolved = body.mergeResolved && resolution();
800
+ }
801
+ }
756
802
  const reply = await postAgentTurn(body);
757
803
  // The queue just emptied. Run the project's own check and the AI
758
804
  // pre-review HERE, in the worktree we are already standing in and still
@@ -159,14 +159,23 @@ export function agentTurnSettlement({ turnId, job, kind, out, lastAnswer, usage,
159
159
  turnId,
160
160
  ...(prerequisite && !cannotRequest ? { prerequisite } : {}),
161
161
  outcome: res.outcome,
162
- answer: cannotRequest ?? envScrub(kind.posture === 'research' && researchUse
163
- ? withResearchHeader(researchUse, res.answer ?? '') : res.answer ?? ''),
162
+ answer: cannotRequest ?? envScrub((kind.posture === 'research' && researchUse
163
+ ? withResearchHeader(researchUse, res.answer ?? '') : res.answer ?? '') +
164
+ (job.task?.taskType === 'tests' && res.raised?.length ? '\n\nAdditional CLI-reported findings (not filed as fixes):\n' + res.raised.map(r => `${r.title}\n${r.brief ?? ''}`).join('\n\n') : '')),
165
+ // CLI-authored evidence travels separately from the short answer and
166
+ // measured commits/checks. Older turns simply send no report.
167
+ ...(kind.posture === 'build' && res.outcome === 'delivered' && res.verification
168
+ ? { verification: envScrub(res.verification) } : {}),
169
+ ...(kind.posture === 'build' && res.outcome === 'delivered' && res.handover
170
+ ? { handover: envScrub(res.handover) } : {}),
171
+ ...(kind.posture === 'build' && res.outcome === 'delivered' && res.automation
172
+ ? { automation: envScrub(res.automation) } : {}),
164
173
  ...(commits.length ? { commits } : {}),
165
174
  // SCRUBBED like the answer beside it: a raised card lands in the
166
175
  // project doc every member reads, and a brief quoting the `.env` the
167
176
  // agent just read ("value sk_live_… is logged in pay.ts") went out
168
177
  // verbatim while the same value in the summary was redacted.
169
- ...(res.raised?.length
178
+ ...(job.task?.taskType !== 'tests' && res.raised?.length
170
179
  ? {
171
180
  raised: res.raised.map((r) => ({
172
181
  title: envScrub(r.title).slice(0, 300),