flowviant 0.115.0 → 0.117.0

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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +99 -145
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. package/bin/lib/patchRevert.mjs +0 -60
@@ -12,53 +12,31 @@
12
12
  * the credential (workSessionTokens.mjs), the runtime pin (workSessionRuntime
13
13
  * .mjs), the adopt carry (workAdoptCarry.mjs), attachments
14
14
  * (workAttachments.mjs), the live line (workNarration.mjs), the tool log
15
- * (workToolLog.mjs) and the command audit (workCommandAudit.mjs).
15
+ * (workToolLog.mjs), the command audit (workCommandAudit.mjs) and — pure —
16
+ * what the turn IS, plain, plan, capture or build, with its prompt pair
17
+ * (workTurnShape.mjs, audit 2026-10-10 F159).
16
18
  */
17
19
  import { existsSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
- import { git, isSafePathSegment } from './git.mjs';
20
+ import { git } from './git.mjs';
19
21
  import { processStartTime } from './procRegistry.mjs';
20
22
  import { c, note, ok, warn } from './ui.mjs';
21
23
  import { mcpFor } from './claude.mjs';
22
24
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
23
25
  import { addUsage } from './turnUsage.mjs';
24
- import {
25
- SYSTEM_WORK,
26
- SYSTEM_CAPTURE,
27
- WORK_TURN_KICKOFF,
28
- CAPTURE_TURN_KICKOFF,
29
- SYSTEM_WORK_PLAIN,
30
- WORK_TURN_KICKOFF_PLAIN,
31
- withProjectContext,
32
- } from './prompts.mjs';
33
26
  import { knowledgeDirFor } from './knowledgeFiles.mjs';
34
27
  import { scrub as envScrub } from './uplinkScrub.mjs';
35
- import { recordSkills, recordMcpServers } from './runtimeCapabilities.mjs';
36
28
  import { isTerminalSessionLive } from './claudeSessions.mjs';
37
29
  import { isAgyConversationLive } from './agySessions.mjs';
30
+ import { adoptFence } from './localSessions.mjs';
38
31
  import { resumeRetriesFresh } from './resumeLost.mjs';
39
32
  import { turnLockedByLivePid } from './turnLock.mjs';
33
+ import { printable } from './printable.mjs';
40
34
  import { brainFor } from './workBrain.mjs';
41
- import { REPO_PLACE } from './workPlaces.mjs';
42
- import { CODEX_THREAD_RE, AGY_CONV_RE } from './workSessionRuntime.mjs';
35
+ import { AGY_CONV_RE, CLAUDE_SESSION_RE, CODEX_THREAD_RE, RESUME_MARKERS } from './conversationIds.mjs';
36
+ import { sessionTurnKickoff, sessionTurnShape, sessionTurnSystem } from './workTurnShape.mjs';
43
37
  import { createToolLog } from './workToolLog.mjs';
44
38
  import { createCommandAudit } from './workCommandAudit.mjs';
45
- import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, withRelay } from './codexRelay.mjs';
46
-
47
- /**
48
- * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
49
- * rather than in prompts.mjs because it is a MODIFIER on whichever contract
50
- * the tab runs, not a contract of its own.
51
- *
52
- * The enforcement is not this sentence — it is `--permission-mode plan`, under
53
- * which the CLI itself refuses every write (claudePosture.mjs, PLAN_MODE_PERM). What
54
- * the sentence buys is the ANSWER: the plain contract above it says "edit
55
- * freely, commit", and a turn told only that would spend itself discovering
56
- * refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
57
- * under `-p`, and the probe turn then asked the person to leave plan mode
58
- * themselves) — leaving plan mode is the person's switch in the tab.
59
- */
60
- export const PLAN_TURN_SENTENCE =
61
- 'THIS IS A PLANNING TURN: the person switched this tab to plan mode, so read what you need, decide, and answer with the plan itself as your reply — change nothing (the CLI refuses every write this turn, whatever the mechanics above say about editing, committing or artifacts), and do not try to leave plan mode; they switch it off in the tab when they want the plan carried out.';
39
+ import { APPROVAL_REFUSED, codexApprovalSentence, codexLoginFailure, codexLoginSentence, SESSION_ANSWER_MAX, withRelay } from './codexRelay.mjs';
62
40
 
63
41
  /**
64
42
  * HOW LONG, AND HOW MANY, SETTLED TURNS THE LANE REMEMBERS (2026-09-29) — see
@@ -77,7 +55,7 @@ export function createWorkSessionTurns({
77
55
  inPlace,
78
56
  workChildren,
79
57
  workAttempts,
80
- sessionPlaces,
58
+ notePlace,
81
59
  placeOf,
82
60
  placeWtFor,
83
61
  sessionMetaPath,
@@ -191,17 +169,21 @@ export function createWorkSessionTurns({
191
169
  workAnswering.add(job.id);
192
170
  const place = job.place || job.sessionId;
193
171
  /**
194
- * VALIDATED AT THE TRUST BOUNDARY, exactly as `learnPlaces` validates
195
- * the roster's map — this is the OTHER writer of `sessionPlaces`, in the
196
- * same reconcile tick, BEFORE the preview jobs and the sweep read it. An
172
+ * VALIDATED AT THE TRUST BOUNDARY, through the same door `learnPlaces`
173
+ * uses for the roster's map (`notePlace`, workPlaces.mjs) — in the same
174
+ * reconcile tick, BEFORE the preview jobs and the sweep read it. An
197
175
  * unchecked value stored here reaches every `placeDir` consumer: the
198
176
  * turn is spawned in it, `burstListeners` measures it, and a share would
199
177
  * publicly tunnel a directory OUTSIDE the checkout — the exact traversal
200
178
  * the preview feature's port attribution exists to prevent. Settled out
201
179
  * loud rather than skipped, because a silently dropped turn strands the
202
180
  * tab for the server's whole expiry window.
181
+ *
182
+ * Stored on success: remembered for every other beat — the sweep, ship,
183
+ * the preview re-check — so they all ask the same directory this turn
184
+ * runs in.
203
185
  */
204
- if (place !== REPO_PLACE && !isSafePathSegment(place)) {
186
+ if (!notePlace(job.sessionId, place)) {
205
187
  void settleTurn(job.id, {
206
188
  ok: false,
207
189
  answer:
@@ -210,9 +192,6 @@ export function createWorkSessionTurns({
210
192
  releaseSlot();
211
193
  continue;
212
194
  }
213
- // Remembered for every other beat — the sweep, ship, the preview
214
- // re-check — so they all ask the same directory this turn runs in.
215
- sessionPlaces.set(job.sessionId, place);
216
195
  // A READER: other turns in this place run alongside it. See `inPlace`.
217
196
  inPlace(place, false, async () => {
218
197
  /** This turn's artifact snapshot — set once the place is resolved and
@@ -240,6 +219,20 @@ export function createWorkSessionTurns({
240
219
  * out here beside `usage`, and spread wherever it is. Last write wins
241
220
  * across a fresh retry: that spawn is the one that answered. */
242
221
  let model = null;
222
+ /**
223
+ * WHAT THIS TURN SPENT AND DID, as every settle after a CLI may have
224
+ * run reports it (audit 2026-10-10, F164): its tool log when it has
225
+ * rows, its usage and its model, each only when measured. ONE
226
+ * null-safe spread for the empty adoption, the answer and the catch —
227
+ * the catch's own copy is where the 2026-09-26 ReferenceError lived,
228
+ * and a field added to one copy and not the others drops exactly when
229
+ * a turn fails.
230
+ */
231
+ const spent = () => ({
232
+ ...(toolLog?.ev?.length > 0 ? { tools: toolLog } : {}),
233
+ ...(usage ? { usage } : {}),
234
+ ...(model ? { model } : {}),
235
+ });
243
236
  /** The `startingIn` key this turn claimed — released in the outer
244
237
  * `finally`, and only by the turn that took it. */
245
238
  let startClaim = null;
@@ -254,8 +247,11 @@ export function createWorkSessionTurns({
254
247
  });
255
248
  return;
256
249
  }
250
+ // A person's name and their tab's, both member-written, printed on
251
+ // the operator's console: scrubbed (2026-10-10, the audit's F097;
252
+ // see printable.mjs).
257
253
  note(
258
- `${c.cyan('tab')} ${c.dim(`— ${job.askedByName || 'the owner'} in "${job.sessionName || 'a session'}"`)}`
254
+ `${c.cyan('tab')} ${c.dim(`— ${printable(job.askedByName) || 'the owner'} in "${printable(job.sessionName) || 'a session'}"`)}`
259
255
  );
260
256
  // ── ADOPTION: a tab born from a TERMINAL session ────────────────
261
257
  // The server sends `adopt {id, cwd}` only while the session has no
@@ -297,28 +293,17 @@ export function createWorkSessionTurns({
297
293
  // Inside the repo, outside the daemon's own worktrees: an adopt
298
294
  // source is a HUMAN's checkout, and one of our directories showing
299
295
  // up here means a stale or confused offer, not a session to fork.
300
- const under = (p, root) =>
301
- p === root || p.startsWith(root.endsWith('/') ? root : `${root}/`);
302
- let realRoot = repoRoot;
303
- let realBase = baseDir;
304
- try {
305
- realRoot = realpathSync(repoRoot);
306
- } catch {
307
- /* keep the literal path */
308
- }
309
- try {
310
- realBase = realpathSync(baseDir);
311
- } catch {
312
- /* keep the literal path */
313
- }
314
- if (!under(srcCwd, realRoot)) {
296
+ // The SAME fence the offer was made through (`adoptFence`,
297
+ // localSessions.mjs), so the two doors cannot disagree.
298
+ const fenced = adoptFence({ repoRoot, excludeDirs: [baseDir] }).check(srcCwd);
299
+ if (fenced === 'outside') {
315
300
  await settleTurn(job.id, {
316
301
  ok: false,
317
302
  answer: "the terminal session's directory is outside this project's repository",
318
303
  });
319
304
  return;
320
305
  }
321
- if (under(srcCwd, realBase)) {
306
+ if (fenced === 'ours') {
322
307
  await settleTurn(job.id, {
323
308
  ok: false,
324
309
  answer:
@@ -381,7 +366,7 @@ export function createWorkSessionTurns({
381
366
  const startKey = lockPath || dir.wt;
382
367
  if (startingIn.has(startKey) || turnLockedByLivePid(lockPath)) {
383
368
  warn(
384
- `a turn is already running in "${job.sessionName || job.sessionId}" — waiting for it to finish`
369
+ `a turn is already running in "${printable(job.sessionName) || job.sessionId}" — waiting for it to finish`
385
370
  );
386
371
  return;
387
372
  }
@@ -432,43 +417,23 @@ export function createWorkSessionTurns({
432
417
  });
433
418
  return;
434
419
  }
435
- if (adopting && rt.id !== 'claude' && rt.id !== 'antigravity') {
436
- // An adopt id names a conversation in ITS OWN CLI's store: claude
437
- // forks it (--resume --fork-session), agy moves it
438
- // (--conversation). Codex has no adoptable store yet, and its
439
- // args builder backstops this with a loud throw — but a sentence
440
- // here beats a stack there.
441
- await settleTurn(job.id, {
442
- ok: false,
443
- answer:
444
- 'adopting this terminal session needs its own CLI on the machine — install it, then try again',
445
- });
446
- return;
447
- }
448
420
  /**
449
- * A PLAN TURN (0.97.0): the tab's switch, off the job. CLAUDE ONLY —
450
- * `--permission-mode plan` is Claude Code's, and a codex or agy turn
451
- * has no way to spell it, so it is refused HERE in words rather than
452
- * run as a build turn wearing the word. The server never sends the
453
- * key for a non-Claude tab; this is the machine's own half. Never on
454
- * a capture chat, which is read-only by its own profile already.
421
+ * WHAT THIS TURN IS — plain, plan, capture or build — decided ONCE,
422
+ * off the runtime's row and the job's own switches
423
+ * (workTurnShape.mjs, audit 2026-10-10 F159); everything below
424
+ * reads it instead of re-deciding. Its refusal (an adoption this
425
+ * CLI cannot take, then plan mode on a CLI that cannot spell it)
426
+ * settles here, in its words, in the order the lane always said
427
+ * them.
455
428
  */
456
- const planTurn = job.planMode === true && job.capture !== true;
457
- if (planTurn && rt.id !== 'claude') {
458
- await settleTurn(job.id, {
459
- ok: false,
460
- answer: `plan mode runs on Claude Code only, and this tab is ${rt.id} — turn plan off in the tab, then send the message again`,
461
- });
429
+ const shape = sessionTurnShape({ runtime: rt.id, adopting, job });
430
+ if (shape.refusal) {
431
+ await settleTurn(job.id, { ok: false, answer: shape.refusal });
462
432
  return;
463
433
  }
464
- // A PLAIN tab (agy) mounts no MCP: no credential to mint, no config
465
- // to write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no
466
- // streaming — and the honesty survives on the existing rails: the
467
- // answer lands via work-turn-done, the rail says "no card yet", and
468
- // ship-time reconciliation books every branch commit.
469
- const plainTab = rt.id === 'antigravity';
434
+ // A PLAIN tab (agy) mounts no MCP: no credential to mint.
470
435
  let mint = null;
471
- if (!plainTab) {
436
+ if (!shape.plain) {
472
437
  mint = await mintWorkToken(job.sessionId);
473
438
  if (!mint) mint = await mintWorkToken(job.sessionId, true); // one transient blip ≠ a dead turn
474
439
  // Another daemon on this credential holds the session. Return
@@ -528,11 +493,11 @@ export function createWorkSessionTurns({
528
493
  */
529
494
  let claudeResumeId = null;
530
495
  if (rt.id === 'claude') {
531
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
496
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
532
497
  if (convMarker && existsSync(convMarker)) {
533
498
  try {
534
499
  const v = readFileSync(convMarker, 'utf8').trim();
535
- if (/^[A-Za-z0-9_-]{8,64}$/.test(v)) claudeResumeId = v;
500
+ if (CLAUDE_SESSION_RE.test(v)) claudeResumeId = v;
536
501
  } catch {
537
502
  /* unreadable marker — run fresh */
538
503
  }
@@ -540,7 +505,7 @@ export function createWorkSessionTurns({
540
505
  }
541
506
  let codexResumeId = null;
542
507
  if (rt.id === 'codex') {
543
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
508
+ const threadMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId);
544
509
  if (threadMarker && existsSync(threadMarker)) {
545
510
  try {
546
511
  const v = readFileSync(threadMarker, 'utf8').trim();
@@ -558,7 +523,7 @@ export function createWorkSessionTurns({
558
523
  // dispatch sharing the machine could overwrite that between turns.
559
524
  let agyConvId = null;
560
525
  if (rt.id === 'antigravity' && !adopting) {
561
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
526
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.antigravity, job.sessionId);
562
527
  if (convMarker && existsSync(convMarker)) {
563
528
  try {
564
529
  const v = readFileSync(convMarker, 'utf8').trim();
@@ -605,17 +570,13 @@ export function createWorkSessionTurns({
605
570
  const adoptNote = adopting
606
571
  ? '[ADOPTED SESSION — this conversation was brought in from a terminal. Begin your reply with a 2-3 sentence recap of where it left off and what state carried over, then answer the message.]'
607
572
  : '';
608
- // A PLAN TURN RUNS PLAIN TOO (0.97.0): measured on 2.1.281, plan mode
609
- // refuses every `mcp__flowviant` call ("Cannot call … while in plan
610
- // mode") unless the tool is annotated read-only, and none of the
611
- // session tools are — so mounting the control plane would hand the
612
- // turn a list of tools it can only fail at. The credential is still
613
- // minted above: its lease answers (held elsewhere, gone) are about
614
- // the SESSION, and a plan turn needs them as much as any other.
615
- const mcp =
616
- plainTab || planTurn
617
- ? { args: [], env: null, dir: null }
618
- : mcpFor(rt.id, mint.token, getMcpUrl());
573
+ // Mounted only where the shape says (a plain tab and a plan turn run
574
+ // without one — see workTurnShape.mjs). A plan turn's credential is
575
+ // still minted above: its lease answers (held elsewhere, gone) are
576
+ // about the SESSION, and a plan turn needs them as much as any other.
577
+ const mcp = shape.mountMcp
578
+ ? mcpFor(rt.id, mint.token, getMcpUrl())
579
+ : { args: [], env: null, dir: null };
619
580
  // The tab's model/effort, if it named any. Spread into turnArgs so
620
581
  // BOTH runTurn calls below carry it — the retry is the same turn on
621
582
  // the same brain, not a quieter second opinion.
@@ -661,42 +622,20 @@ export function createWorkSessionTurns({
661
622
  const message = [job.body, filesNote, adoptNote, carryNote]
662
623
  .filter(Boolean)
663
624
  .join('\n\n');
664
- // A CAPTURE chat (the board's New task conversation): its own
665
- // system prompt, its own kickoff, and the scratch planner's
666
- // READ-ONLY permission profile — the prompt says stage-never-file
667
- // and the profile is what makes "read-only" true rather than
668
- // asserted. Server-flagged per job; a server too old to flag it
669
- // simply runs an ordinary tab, which the web's version floor
670
- // prevents ever being offered.
671
- const captureTab = job.capture === true;
672
625
  const turnArgs = {
673
- // A plain tab has no tools to name and no session id to pass —
674
- // its kickoff asks for one complete report instead of a stream.
675
- prompt: plainTab || planTurn
676
- ? WORK_TURN_KICKOFF_PLAIN({
677
- sessionName: job.sessionName,
678
- message,
679
- askedByName: job.askedByName,
680
- })
681
- : captureTab
682
- ? CAPTURE_TURN_KICKOFF({
683
- sessionId: job.sessionId,
684
- sessionName: job.sessionName,
685
- message,
686
- askedByName: job.askedByName,
687
- })
688
- : WORK_TURN_KICKOFF({
689
- sessionId: job.sessionId,
690
- sessionName: job.sessionName,
691
- message,
692
- askedByName: job.askedByName,
693
- }),
694
- // ONE PROFILE (turnProfile.mjs): the capture chat's read-only
695
- // `plan` list; or a plan turn's `plan-mode` — `--permission-mode
696
- // plan` INSTEAD of the build posture, never beside
697
- // `--dangerously-skip-permissions`, which silently wins
698
- // (claudePosture.mjs, PLAN_MODE_PERM); else the build posture.
699
- profile: captureTab ? 'plan' : planTurn ? 'plan-mode' : 'build',
626
+ // The kickoff under the turn's contract: a plain tab (or a plan
627
+ // turn) has no tools to name and no session id to pass, and asks
628
+ // for one complete report instead of a stream; a capture chat
629
+ // restates its posture.
630
+ prompt: sessionTurnKickoff(shape, {
631
+ sessionId: job.sessionId,
632
+ sessionName: job.sessionName,
633
+ message,
634
+ askedByName: job.askedByName,
635
+ }),
636
+ // ONE PROFILE (turnProfile.mjs), the shape's: `plan` for the
637
+ // capture chat, `plan-mode` for a plan turn, else `build`.
638
+ profile: shape.profile,
700
639
  // The adopt turn resumes the TERMINAL conversation by forking it
701
640
  // into this cwd (claude: --resume <id> --fork-session). After it
702
641
  // speaks once, the fork lives natively here and turn 2+ is the
@@ -709,18 +648,14 @@ export function createWorkSessionTurns({
709
648
  ...(() => {
710
649
  const knowledgeDir = knowledgeDirFor(repoRoot);
711
650
  return {
712
- // A PLAN TURN (0.97.0) runs the PLAIN contract — it has no
713
- // Flowviant tools, see `mcp` above — with no artifacts
714
- // paragraph (plan mode cannot write one) and the planning
715
- // sentence appended. Every other tab is untouched.
716
- system: planTurn
717
- ? `${withProjectContext(SYSTEM_WORK_PLAIN, { knowledgeDir, artifacts: false })}\n\n${PLAN_TURN_SENTENCE}`
718
- : withProjectContext(
719
- plainTab ? SYSTEM_WORK_PLAIN : captureTab ? SYSTEM_CAPTURE : SYSTEM_WORK,
720
- // ARTIFACTS (0.94.0): every tab but the read-only capture
721
- // chat, and only while the server can show one.
722
- { knowledgeDir, artifacts: !captureTab && getArtifactsAccepted() }
723
- ),
651
+ // The shape's contract (a plan turn: the PLAIN one, no
652
+ // artifacts paragraph, the planning sentence appended). The
653
+ // artifacts paragraph also waits on the server — ARTIFACTS
654
+ // (0.94.0): only while it can show one, read here at spawn.
655
+ system: sessionTurnSystem(shape, {
656
+ knowledgeDir,
657
+ artifactsAccepted: getArtifactsAccepted(),
658
+ }),
724
659
  // The directory is OUTSIDE a worktree's cwd; Claude Code is
725
660
  // told it may read there (`--add-dir`) rather than left to
726
661
  // refuse a read-only capture turn a path it was just handed.
@@ -729,12 +664,11 @@ export function createWorkSessionTurns({
729
664
  })(),
730
665
  // Present only when the tab named one — see brainFor.
731
666
  ...brain,
732
- // The tab watches the CLI work. Claude needs the flag to speak
733
- // events at all (codex and agy always do); `answerFromResult`
734
- // keeps `out` — which IS the reply posted to the transcript — to
735
- // the final result, so streamed prose is narrated once and
736
- // posted once. Every line goes to the narrator above, throttled.
737
- streamJson: true,
667
+ // The tab watches the CLI work (every CLI speaks events);
668
+ // `answerFromResult` keeps `out` — which IS the reply posted to
669
+ // the transcript — to the final result, so streamed prose is
670
+ // narrated once and posted once. Every line goes to the narrator
671
+ // above, throttled.
738
672
  answerFromResult: true,
739
673
  onActivity: (a) => {
740
674
  narrator.line(a?.label);
@@ -756,16 +690,12 @@ export function createWorkSessionTurns({
756
690
  onModel: (m) => {
757
691
  model = m;
758
692
  },
759
- // What this CLI says it can be asked for by name. Harvested off
760
- // the init event the stream already carries — no probe, no scan,
761
- // no extra spawn — and reported on the next roster poll so the
762
- // composer can autocomplete a `/`. See runtimeCapabilities.mjs for why it is
763
- // learned from a turn rather than looked up.
693
+ // What this CLI says it can be asked for by name, and which of
694
+ // its connectors need a sign-in, are harvested off this init by
695
+ // runTurn itself (`initHarvestFor`, runtimeCapabilities.mjs) —
696
+ // a tab's build turn is the machine's measurement; its fenced
697
+ // capture chat is not.
764
698
  onInit: (i) => {
765
- recordSkills(i.skills);
766
- // The CLI's mounted MCP servers and connectors (0.97.0) — the
767
- // same free fact off the same event; see recordMcpServers.
768
- recordMcpServers(i.mcpServers);
769
699
  // The conversation this turn is actually speaking under. Held
770
700
  // and persisted after the turn ends, so the NEXT one resumes
771
701
  // this exact thread rather than whatever the directory saw
@@ -777,11 +707,9 @@ export function createWorkSessionTurns({
777
707
  cwd: dir.wt,
778
708
  mcpArgs: mcp.args,
779
709
  mcpEnv: mcp.env,
780
- // A PERSON AT THE KEYBOARD MAY ASK FOR A WINDOW (2026-09-29): a
781
- // Terminal tab keeps its environment's display. The capture chat
782
- // does not — it only reads and stages cards, and the person
783
- // talking to it may be on a phone (noWindowEnv.mjs).
784
- display: !captureTab,
710
+ // A Terminal tab keeps its environment's display; the capture
711
+ // chat does not (workTurnShape.mjs).
712
+ display: shape.display,
785
713
  runtime: rt.id,
786
714
  label: c.cyan('[tab]'),
787
715
  // Only codex announces one (thread.started); held here so the id
@@ -888,9 +816,9 @@ export function createWorkSessionTurns({
888
816
  if (
889
817
  rt.id === 'claude' &&
890
818
  seenClaudeSession &&
891
- /^[A-Za-z0-9_-]{8,64}$/.test(seenClaudeSession)
819
+ CLAUDE_SESSION_RE.test(seenClaudeSession)
892
820
  ) {
893
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-claude-session', job.sessionId);
821
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.claude, job.sessionId);
894
822
  if (convMarker) {
895
823
  try {
896
824
  writeFileSync(convMarker, seenClaudeSession);
@@ -900,7 +828,7 @@ export function createWorkSessionTurns({
900
828
  }
901
829
  }
902
830
  if (rt.id === 'codex' && seenThreadId && CODEX_THREAD_RE.test(seenThreadId)) {
903
- const threadMarker = sessionMetaPath(dir.wt, 'flowviant-codex-thread', job.sessionId);
831
+ const threadMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.codex, job.sessionId);
904
832
  if (threadMarker) {
905
833
  try {
906
834
  writeFileSync(threadMarker, seenThreadId);
@@ -923,11 +851,9 @@ export function createWorkSessionTurns({
923
851
  ok: false,
924
852
  answer: "Couldn't resume the terminal session — it may have been removed.",
925
853
  // Whatever it DID before coming back empty is exactly the
926
- // question a failed turn's log answers.
927
- ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
928
- // …and an empty fork still spent what it spent.
929
- ...(usage ? { usage } : {}),
930
- ...(model ? { model } : {}),
854
+ // question a failed turn's log answers — and an empty fork
855
+ // still spent what it spent.
856
+ ...spent(),
931
857
  });
932
858
  warn('adopt turn produced no output — settled as failed');
933
859
  return;
@@ -938,7 +864,7 @@ export function createWorkSessionTurns({
938
864
  // cwd registry. From here on the marker is the tab's identity and
939
865
  // the registry is never trusted again.
940
866
  if (rt.id === 'antigravity' && answer.length > 0) {
941
- const convMarker = sessionMetaPath(dir.wt, 'flowviant-agy-conversation', job.sessionId);
867
+ const convMarker = sessionMetaPath(dir.wt, RESUME_MARKERS.antigravity, job.sessionId);
942
868
  if (convMarker && !existsSync(convMarker)) {
943
869
  const learned = adopting ? job.adopt.id : agyRegistryLookup(dir.wt);
944
870
  if (learned && AGY_CONV_RE.test(learned)) {
@@ -980,7 +906,7 @@ export function createWorkSessionTurns({
980
906
  ? refusedLine
981
907
  ? withRelay(refusedLine, answer)
982
908
  : // Scrub: a reply can quote config or env-adjacent code.
983
- envScrub(answer).slice(0, 16000)
909
+ envScrub(answer).slice(0, SESSION_ANSWER_MAX)
984
910
  : (refusedLine ?? noWords),
985
911
  // Only a turn that actually SPOKE proves a conversation lives
986
912
  // here. Recording the path unconditionally is how a crashed first
@@ -989,9 +915,7 @@ export function createWorkSessionTurns({
989
915
  // The turn's tool log, in final form — the durable copy that lands
990
916
  // on the settled message (the live copy on the record is cleared
991
917
  // at settle). Already scrubbed at collection.
992
- ...(toolLog.ev.length > 0 ? { tools: toolLog } : {}),
993
- ...(usage ? { usage } : {}),
994
- ...(model ? { model } : {}),
918
+ ...spent(),
995
919
  });
996
920
  if (spoke) ok(`${c.cyan('tab')} ${c.dim('— replied in the session')}`);
997
921
  else if (loginFailure) warn('codex answered 401 for this session turn — settled as failed, in words');
@@ -1004,11 +928,9 @@ export function createWorkSessionTurns({
1004
928
  // synced secret.
1005
929
  answer: envScrub(String(e?.message ?? 'the session turn failed')).slice(0, 2000),
1006
930
  // "What did it do before it failed" is exactly the question a
1007
- // crashed turn's log answers — same spread as the main settle.
1008
- ...(toolLog?.ev.length > 0 ? { tools: toolLog } : {}),
1009
- // A turn that threw after its CLI ran still spent what it spent.
1010
- ...(usage ? { usage } : {}),
1011
- ...(model ? { model } : {}),
931
+ // crashed turn's log answers, and a turn that threw after its CLI
932
+ // ran still spent what it spent — the main settle's own spread.
933
+ ...spent(),
1012
934
  });
1013
935
  warn(`session turn failed: ${e?.message ?? e}`);
1014
936
  } finally {