flowviant 0.116.0 → 0.124.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 (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -1,13 +1,14 @@
1
1
  /** Session branch shipping and settlement. */
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { git, baseBranchName, isSafePathSegment } from './git.mjs';
4
+ import { git, baseBranchName, isSafePathSegment, sessionBranch, stripUrlUserinfo } from './git.mjs';
5
5
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
6
6
  import { gitMergeIn } from './gitMergeIdentity.mjs';
7
7
  import { c, note, ok, warn } from './ui.mjs';
8
8
  import { scrub as envScrub } from './uplinkScrub.mjs';
9
9
  import { REPO_PLACE } from './workPlaces.mjs';
10
10
  import { turnLockedByLivePid } from './turnLock.mjs';
11
+ import { printable } from './printable.mjs';
11
12
 
12
13
  export function createWorkShipper({
13
14
  inPlace,
@@ -17,6 +18,8 @@ export function createWorkShipper({
17
18
  baseDir,
18
19
  gitNet,
19
20
  baseRef,
21
+ placeDir,
22
+ branchAt,
20
23
  placeWtFor,
21
24
  sessionMetaPath,
22
25
  shipping,
@@ -55,6 +58,11 @@ export function createWorkShipper({
55
58
  // delivers it; re-running the ship would misread its own success.
56
59
  if (pendingShipReports.has(job.sessionId)) continue;
57
60
  shipping.add(job.sessionId);
61
+ // A TAB'S NAME, which its person wrote, printed on this console and put
62
+ // in the merge commit's subject — scrubbed here, where the job is read,
63
+ // so no line below can print an escape sequence (2026-10-10, the audit's
64
+ // F097; see printable.mjs). The id is checked as a path segment below.
65
+ const sessionName = printable(job.sessionName);
58
66
  // A WRITER, keyed by PLACE: ship folds and merges with git in this
59
67
  // directory, and no CLI running here can coordinate with that. Keyed by
60
68
  // place rather than by session id, which is what this used to do while
@@ -74,7 +82,7 @@ export function createWorkShipper({
74
82
  await done({ ok: false, error: 'invalid session id' });
75
83
  return;
76
84
  }
77
- note(`${c.cyan('ship')} ${c.dim(`— "${job.sessionName || job.sessionId}"`)}`);
85
+ note(`${c.cyan('ship')} ${c.dim(`— "${sessionName || job.sessionId}"`)}`);
78
86
  /**
79
87
  * WHAT IS ACTUALLY CHECKED OUT — not what we named it at birth.
80
88
  *
@@ -99,20 +107,20 @@ export function createWorkShipper({
99
107
  *
100
108
  * A directory that is gone (a retired or closed tab) cannot be asked,
101
109
  * so the recorded name is the fallback — the one case where the name
102
- * is the only thing there is.
110
+ * is the only thing there is. The recorded name is the branch the
111
+ * PLACE was cut on, `session/<place>`: a person's `u-` place lives on
112
+ * `session/u-<id>`, and falling back to `session/<this tab's id>`
113
+ * answered "nothing to ship" over its committed work (audit
114
+ * 2026-10-10, F168). The checkout always exists, so its fallback is
115
+ * never read and keeps the tab's own name.
103
116
  */
104
117
  // The session's PLACE — the directory it actually works in.
105
118
  const shipPlace = placeOf(job.sessionId);
106
- const wt = shipPlace === REPO_PLACE ? repoRoot : join(baseDir, 'sessions', shipPlace);
107
- let branch = `session/${job.sessionId}`;
108
- let detached = false;
109
- if (existsSync(wt)) {
110
- try {
111
- branch = git(['symbolic-ref', '--short', 'HEAD'], wt);
112
- } catch {
113
- detached = true;
114
- }
115
- }
119
+ const wt = placeDir(job.sessionId);
120
+ const { branch, detached } = branchAt(
121
+ wt,
122
+ sessionBranch(shipPlace === REPO_PLACE ? job.sessionId : shipPlace)
123
+ );
116
124
  // THE ONE REFUSAL LEFT, and it is not policy. A detached HEAD names
117
125
  // no branch, so there is nothing to merge and nothing to record —
118
126
  // that is an ambiguity in git, not a rule of ours.
@@ -177,13 +185,19 @@ export function createWorkShipper({
177
185
  tip,
178
186
  count,
179
187
  branch,
180
- label: job.sessionName || job.sessionId.slice(0, 8),
188
+ label: sessionName || job.sessionId.slice(0, 8),
181
189
  git,
182
190
  gitMerge,
183
191
  repoRoot,
184
192
  tmpDir: join(baseDir, 'ship', job.sessionId),
185
193
  baseRef,
186
- workingTree: placeWtFor(shipPlace)?.wt ?? null,
194
+ // A QUERY, never `placeWtFor`: that one re-cuts a missing
195
+ // worktree, and on the ENDED path the directory is retired on
196
+ // purpose — the branch is the session now. Re-attaching it here
197
+ // resurrected a closed tab's directory with its branch checked
198
+ // out, which then blocked the merged-branch sweep (audit
199
+ // 2026-10-10, F160). The live path below opened it already.
200
+ workingTree: existsSync(wt) ? wt : null,
187
201
  warn,
188
202
  });
189
203
  // Idempotency: base already contains the branch tip. A re-offered
@@ -245,10 +259,14 @@ export function createWorkShipper({
245
259
  'conflicts with main — the tab is closed, so open a new session from this branch to resolve them, then ship again',
246
260
  });
247
261
  } else {
262
+ // Userinfo stripped as well as scrubbed (2026-10-10, F153): the
263
+ // outward merge pushes to `origin`, and a push names the remote.
248
264
  const line = envScrub(
249
- String(detail)
250
- .split('\n')
251
- .find((l) => l.trim()) ?? 'git merge failed'
265
+ stripUrlUserinfo(
266
+ String(detail)
267
+ .split('\n')
268
+ .find((l) => l.trim()) ?? 'git merge failed'
269
+ )
252
270
  );
253
271
  await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
254
272
  }
@@ -273,7 +291,7 @@ export function createWorkShipper({
273
291
  // path, and the job re-offers next poll.
274
292
  if (turnLockedByLivePid(sessionMetaPath(dir.wt, 'flowviant-turn.lock'))) {
275
293
  warn(
276
- `a turn is still running in "${job.sessionName || job.sessionId}" — ship waits for it`
294
+ `a turn is still running in "${sessionName || job.sessionId}" — ship waits for it`
277
295
  );
278
296
  deferred = true;
279
297
  return;
@@ -313,9 +331,11 @@ export function createWorkShipper({
313
331
  // An honest error beats a fabricated conflict — the human can
314
332
  // only fix what they are told about.
315
333
  const line = envScrub(
316
- String(detail)
317
- .split('\n')
318
- .find((l) => l.trim()) ?? 'git merge failed'
334
+ stripUrlUserinfo(
335
+ String(detail)
336
+ .split('\n')
337
+ .find((l) => l.trim()) ?? 'git merge failed'
338
+ )
319
339
  );
320
340
  await done({ ok: false, error: `the merge failed: ${line.slice(0, 300)}` });
321
341
  }
@@ -347,7 +367,7 @@ export function createWorkShipper({
347
367
  warn(`ship failed: ${e?.message ?? e}`);
348
368
  await done({
349
369
  ok: false,
350
- error: envScrub(String(e?.message ?? 'the merge failed')).slice(0, 500),
370
+ error: envScrub(stripUrlUserinfo(e?.message ?? 'the merge failed')).slice(0, 500),
351
371
  });
352
372
  } finally {
353
373
  if (!settled && !deferred) {
@@ -8,7 +8,7 @@
8
8
  * the stream. Tested directly (workToolLog.test.mjs).
9
9
  */
10
10
  import { toolEventOf } from './runtimeEvents.mjs';
11
- import { scrub as envScrub } from './uplinkScrub.mjs';
11
+ import { scrubCut, scrub as envScrub } from './uplinkScrub.mjs';
12
12
 
13
13
  /**
14
14
  * THE TURN'S TOOL LOG — the structured relay behind the transcript's
@@ -30,14 +30,74 @@ import { scrub as envScrub } from './uplinkScrub.mjs';
30
30
  * because it is current state rather than scrollback.
31
31
  */
32
32
  export function createToolLog(cwd) {
33
- const toolLog = { ev: [], dropped: 0 };
34
- const pushToolEvent = (name, input) => {
33
+ const toolLog = { ev: [], dropped: 0, state: 'unknown' };
34
+ // IDs are correlation keys, never model-authored citations. Keep only bounded
35
+ // metadata; output supplies a fixed category and allowlisted reader diagnostic.
36
+ // Arbitrary output and file contents are discarded.
37
+ const correlated = new Map();
38
+ const refresh = () => {
39
+ toolLog.state = toolLog.state === 'partial' || toolLog.dropped || toolLog.recoveryDropped ? 'partial' :
40
+ toolLog.ev.length && toolLog.ev.every(e => e.outcome && e.outcome !== 'unknown') ? 'complete' : 'unknown';
41
+ const retained = new Set([...toolLog.ev, toolLog.firstFailure, ...(toolLog.recovery ?? [])]);
42
+ for (const [id, row] of correlated) if (!retained.has(row)) correlated.delete(id);
43
+ };
44
+ const observe = (e, meta) => {
45
+ const code = Number.isSafeInteger(meta.exitCode) ? meta.exitCode : undefined;
46
+ const failed = meta.isError === true || (code !== undefined && code !== 0);
47
+ e.outcome = !meta.result ? 'unknown' : failed ? 'failed' :
48
+ meta.runtime === 'codex' && code === undefined ? 'unknown' : 'success';
49
+ if (code !== undefined) e.exitCode = code;
50
+ if (failed) {
51
+ const output = scrubCut(meta.output ?? '', 16_384);
52
+ const guardAt = output.indexOf('flowviant: this turn is read-only, so ');
53
+ const guard = guardAt >= 0 ? output.slice(guardAt).split('\n', 1)[0] : undefined;
54
+ const diagnostic = guard ?? output.split('\n', 80).find(line => /^(?:[\w./ -]{1,160}: ){1,2}(?:No such file or directory|Permission denied|command not found)\.?$/.test(line) || (e.t === 'read' && /^(?:Error: )?(?:File does not exist|File not found|Permission denied)\.?(?: Note: your current working directory is [^\n]{1,400}\.)?$/.test(line)));
55
+ if (diagnostic) {
56
+ e.errorMessage = diagnostic.slice(0, 500);
57
+ if (diagnostic.length > 500) { e.errorTruncated = true; toolLog.state = 'partial'; }
58
+ }
59
+ e.errorCategory = guard && /a pipe|a glob|a redirect|a second command|an expansion|command substitution|brace expansion|a subshell/.test(guard) ? 'syntax_refusal' : /permission|denied|approval|not allowed|refus/i.test(output) ? 'denied' :
60
+ /command not found|not recognized|executable.*not found/i.test(output) ? 'unavailable_tool' :
61
+ /no such file|not found|does not exist|cannot find/i.test(output) ? 'missing_path' : 'tool_error';
62
+ if (!toolLog.firstFailure) {
63
+ toolLog.firstFailure = { ...e };
64
+ toolLog.recovery = [];
65
+ }
66
+ }
67
+ };
68
+ const pushToolEvent = (name, input, meta = {}) => {
69
+ const id = typeof meta.id === 'string' && meta.id && meta.id.length <= 160 ? meta.id : undefined;
70
+ const existing = id && correlated.get(id);
71
+ if (existing) {
72
+ if (meta.result) observe(existing, meta);
73
+ refresh();
74
+ return existing;
75
+ }
76
+ if (!name) {
77
+ toolLog.dropped = Math.min(1_000_000, toolLog.dropped + 1);
78
+ refresh();
79
+ return;
80
+ }
35
81
  // envScrub rides INTO the builder, which scrubs over a bounded
36
82
  // window BEFORE its caps — scrubbing after the cut both leaked a
37
83
  // boundary-straddling secret's prefix and grew a capped field
38
84
  // past the server's limits (review, 2026-09-01).
39
85
  const e = toolEventOf(name, input, cwd, envScrub);
40
86
  if (!e) return;
87
+ if (e.t !== 'plan') {
88
+ if (id) { e.id = scrubCut(id, 160); correlated.set(id, e); }
89
+ if (name === 'Read') for (const key of ['offset', 'limit']) {
90
+ if (Number.isSafeInteger(input?.[key]) && input[key] >= 0) e[key] = input[key];
91
+ }
92
+ if (toolLog.firstFailure) {
93
+ toolLog.recovery.push(e);
94
+ if (toolLog.recovery.length > 12) {
95
+ toolLog.recovery.shift();
96
+ toolLog.recoveryDropped = Math.min(1_000_000, (toolLog.recoveryDropped ?? 0) + 1);
97
+ }
98
+ }
99
+ observe(e, meta);
100
+ }
41
101
  if (e.t === 'plan') {
42
102
  const i = toolLog.ev.findIndex((x) => x.t === 'plan');
43
103
  if (i >= 0) toolLog.ev.splice(i, 1);
@@ -46,6 +106,11 @@ export function createToolLog(cwd) {
46
106
  const last = toolLog.ev[toolLog.ev.length - 1];
47
107
  const sameKey =
48
108
  last &&
109
+ !id && !last.id &&
110
+ last.outcome === e.outcome &&
111
+ last.exitCode === e.exitCode &&
112
+ last.errorCategory === e.errorCategory &&
113
+ last.offset === e.offset && last.limit === e.limit &&
49
114
  last.t === e.t &&
50
115
  last.p === e.p &&
51
116
  last.q === e.q &&
@@ -71,6 +136,8 @@ export function createToolLog(cwd) {
71
136
  const [shed] = toolLog.ev.splice(i, 1);
72
137
  toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
73
138
  }
139
+ refresh();
140
+ return e;
74
141
  };
75
142
  return { toolLog, pushToolEvent };
76
143
  }
@@ -0,0 +1,165 @@
1
+ import { discoveryGuide } from './discoveryContract.mjs';
2
+ /**
3
+ * WHAT A TERMINAL TAB'S TURN IS — plain, plan, capture or build — decided
4
+ * once, as data (audit 2026-10-10, F159). Pure: no I/O, no state.
5
+ *
6
+ * What a turn mounts and says is ONE decision over the runtime, the job's
7
+ * plan and capture switches and whether it adopts, and it was spelled as six
8
+ * parallel ternaries through the lane's lifecycle — the MCP mount, the
9
+ * kickoff, the profile, the system prompt, its artifacts paragraph, the
10
+ * display — plus runtime ids hard-coded where the RUNTIMES row should answer. Missing one of them gave an
11
+ * incoherent turn (a mounted MCP under a plain prompt, a plan profile beside
12
+ * the build contract), and nothing could show it short of a run. So the lane
13
+ * (workSessionTurns.mjs) asks `sessionTurnShape` once, the moment its runtime
14
+ * resolves, settles its refusal in words if it has one, and reads every
15
+ * other answer off it; the prompt pair is composed here too.
16
+ *
17
+ * The runtime facts come off the row (runtime{Claude,Codex,Antigravity}.mjs):
18
+ * `tab` ('plain' runs no MCP), `planTurns` (the CLI can spell plan mode) and
19
+ * `adopt` ('fork' | 'move' | null). The LIFECYCLE stays the lane's — its
20
+ * refusal order is the contract — and only this composition moved.
21
+ */
22
+ import { runtimeById } from './runtimes.mjs';
23
+ import {
24
+ SYSTEM_WORK,
25
+ SYSTEM_CAPTURE,
26
+ SYSTEM_WORK_PLAIN,
27
+ WORK_TURN_KICKOFF,
28
+ CAPTURE_TURN_KICKOFF,
29
+ WORK_TURN_KICKOFF_PLAIN,
30
+ withProjectContext,
31
+ } from './prompts.mjs';
32
+
33
+ /**
34
+ * THE ONE SENTENCE A PLAN TURN'S SYSTEM PROMPT GAINS (0.97.0), composed here
35
+ * rather than in prompts.mjs because it is a MODIFIER on whichever contract
36
+ * the tab runs, not a contract of its own.
37
+ *
38
+ * The enforcement is not this sentence — it is `--permission-mode plan`, under
39
+ * which the CLI itself refuses every write (claudePosture.mjs, PLAN_MODE_PERM). What
40
+ * the sentence buys is the ANSWER: the plain contract above it says "edit
41
+ * freely, commit", and a turn told only that would spend itself discovering
42
+ * refusals. It also says not to reach for `ExitPlanMode` (measured: disabled
43
+ * under `-p`, and the probe turn then asked the person to leave plan mode
44
+ * themselves) — leaving plan mode is the person's switch in the tab.
45
+ */
46
+ export const PLAN_TURN_SENTENCE =
47
+ '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.';
48
+
49
+ /**
50
+ * The turn's shape. `runtime` is the resolved RUNTIMES id; `job` is the
51
+ * roster's job, whose `planMode` and `capture` switches count only when
52
+ * exactly `true` (the job is handed whole rather than its switches spelled
53
+ * as keys: `planMode` is also a retired runTurn switch, and a lane that
54
+ * spells it is caught by turnProfile's ban); `adopting` is the lane's
55
+ * verdict that this first turn adopts a terminal session.
56
+ *
57
+ * Returns `{ refusal?, plain, plan, capture, kickoff, systemBase,
58
+ * planSentence, artifacts, profile, mountMcp, display }`:
59
+ * refusal — the sentence the turn settles in, unrun, when it has one;
60
+ * kickoff /
61
+ * systemBase — which contract the prompt pair runs: 'plain' | 'capture' | 'work';
62
+ * planSentence — PLAN_TURN_SENTENCE is appended to the system prompt;
63
+ * artifacts — this turn's contract may carry the artifacts paragraph
64
+ * (`sessionTurnSystem` adds it only while the server takes
65
+ * one, as the lane reads it at spawn);
66
+ * profile — the permission profile: 'build' | 'plan' | 'plan-mode';
67
+ * mountMcp — the session's MCP config is mounted;
68
+ * display — the CLI keeps its environment's display.
69
+ */
70
+ export function sessionTurnShape({ runtime, adopting = false, job = null }) {
71
+ const row = runtimeById(runtime);
72
+ // A PLAIN tab (agy) mounts no MCP: no credential to mint, no config to
73
+ // write. The trade is stated in SYSTEM_WORK_PLAIN — no cards, no streaming
74
+ // — and the honesty survives on the existing rails: the answer lands via
75
+ // work-turn-done, the rail says "no card yet", and ship-time reconciliation
76
+ // books every branch commit.
77
+ const plain = row?.tab === 'plain';
78
+ // A CAPTURE chat (the board's New task conversation): its own system
79
+ // prompt, its own kickoff, and the scratch planner's READ-ONLY permission
80
+ // profile — the prompt says stage-never-file and the profile is what makes
81
+ // "read-only" true rather than asserted. Server-flagged per job; a server
82
+ // too old to flag it simply runs an ordinary tab, which the web's version
83
+ // floor prevents ever being offered.
84
+ const isCapture = job?.capture === true;
85
+ /**
86
+ * A PLAN TURN (0.97.0): the tab's switch, off the job. Only on a CLI whose
87
+ * row can spell it (`planTurns` — Claude Code's `--permission-mode plan`); a
88
+ * codex or agy turn is refused in words rather than run as a build turn
89
+ * wearing the word. The server never sends the key for a non-Claude tab;
90
+ * this is the machine's own half. Never on a capture chat, which is
91
+ * read-only by its own profile already.
92
+ *
93
+ * A PLAN TURN RUNS PLAIN TOO: measured on 2.1.281, plan mode refuses every
94
+ * `mcp__flowviant` call ("Cannot call … while in plan mode") unless the
95
+ * tool is annotated read-only, and none of the session tools are — so
96
+ * mounting the control plane would hand the turn a list of tools it can
97
+ * only fail at. The lane still mints the credential: its lease answers
98
+ * (held elsewhere, gone) are about the SESSION, and a plan turn needs them
99
+ * as much as any other.
100
+ */
101
+ const plan = job?.planMode === true && !isCapture;
102
+ // An adopt id names a conversation in ITS OWN CLI's store: claude forks it
103
+ // (--resume --fork-session), agy moves it (--conversation). A CLI whose row
104
+ // declares no adoption (Codex: no adoptable store yet) is refused here —
105
+ // its args builder backstops this with a loud throw, but a sentence beats
106
+ // a stack. Checked BEFORE plan mode: the order the lane always settled in.
107
+ const refusal =
108
+ adopting && !row?.adopt
109
+ ? 'adopting this terminal session needs its own CLI on the machine — install it, then try again'
110
+ : plan && !row?.planTurns
111
+ ? `plan mode runs on Claude Code only, and this tab is ${runtime} — turn plan off in the tab, then send the message again`
112
+ : null;
113
+ // ONE CONTRACT for the prompt pair: a plain tab and a plan turn run the
114
+ // plain one (no Flowviant tools to name, one complete report instead of a
115
+ // stream), a capture chat its own, every other tab the work contract.
116
+ const contract = plain || plan ? 'plain' : isCapture ? 'capture' : 'work';
117
+ return {
118
+ ...(refusal ? { refusal } : {}),
119
+ plain,
120
+ plan,
121
+ capture: isCapture,
122
+ kickoff: contract,
123
+ systemBase: contract,
124
+ planSentence: plan,
125
+ // ARTIFACTS (0.94.0): every tab but the read-only capture chat — and not
126
+ // a plan turn, which cannot write one.
127
+ artifacts: !isCapture && !plan,
128
+ // ONE PROFILE (turnProfile.mjs): the capture chat's read-only `plan`
129
+ // list; or a plan turn's `plan-mode` — `--permission-mode plan` INSTEAD
130
+ // of the build posture, never beside `--dangerously-skip-permissions`,
131
+ // which silently wins (claudePosture.mjs, PLAN_MODE_PERM); else the build
132
+ // posture.
133
+ profile: isCapture ? 'plan' : plan ? 'plan-mode' : 'build',
134
+ mountMcp: !plain && !plan,
135
+ // A PERSON AT THE KEYBOARD MAY ASK FOR A WINDOW (2026-09-29): a Terminal
136
+ // tab keeps its environment's display. The capture chat does not — it
137
+ // only reads and stages cards, and the person talking to it may be on a
138
+ // phone (noWindowEnv.mjs).
139
+ display: !isCapture,
140
+ };
141
+ }
142
+
143
+ const KICKOFFS = { plain: WORK_TURN_KICKOFF_PLAIN, capture: CAPTURE_TURN_KICKOFF, work: WORK_TURN_KICKOFF };
144
+ const SYSTEMS = { plain: SYSTEM_WORK_PLAIN, capture: SYSTEM_CAPTURE, work: SYSTEM_WORK };
145
+
146
+ /** The turn's opening message under its contract. The plain kickoff names no
147
+ * session id (WORK_TURN_KICKOFF_PLAIN takes none) — it has no tools to pass
148
+ * one to. */
149
+ export const sessionTurnKickoff = (shape, { sessionId, sessionName, message, askedByName, interrupted = false }) =>
150
+ KICKOFFS[shape.kickoff]({ sessionId, sessionName, message, askedByName, interrupted });
151
+
152
+ /**
153
+ * The turn's system prompt: its contract with the project's knowledge
154
+ * paragraph, the artifacts paragraph only where the shape allows one AND the
155
+ * server can show one (`artifactsAccepted`, read by the lane at spawn), and a
156
+ * plan turn's sentence appended.
157
+ */
158
+ export const sessionTurnSystem = (shape, { knowledgeDir, artifactsAccepted, runtime }) => {
159
+ const contract = withProjectContext(SYSTEMS[shape.systemBase], {
160
+ knowledgeDir,
161
+ artifacts: shape.artifacts && artifactsAccepted,
162
+ });
163
+ const discovery = shape.systemBase === 'capture' && runtime ? `\n\n${discoveryGuide(runtime, 'capture')}` : '';
164
+ return shape.planSentence ? `${contract}\n\n${PLAN_TURN_SENTENCE}` : contract + discovery;
165
+ };
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "flowviant",
3
+ "version": "0.124.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "flowviant",
9
+ "version": "0.124.0",
10
+ "license": "MIT",
11
+ "dependencies": {
12
+ "libsodium-wrappers-sumo": "^0.8.4",
13
+ "ws": "^8.18.0"
14
+ },
15
+ "bin": {
16
+ "flowviant": "bin/cli.mjs"
17
+ },
18
+ "devDependencies": {
19
+ "three": "0.180.0"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ }
24
+ },
25
+ "node_modules/libsodium-sumo": {
26
+ "version": "0.8.4",
27
+ "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.4.tgz",
28
+ "integrity": "sha512-TMtHShQfVVsaxDygyapvUC3o7YsPgXa/hRWeIgzyFz6w5k/1hirGptCxp1U7XwW3rCskaTTYKgV10v86UiGgNw==",
29
+ "license": "ISC"
30
+ },
31
+ "node_modules/libsodium-wrappers-sumo": {
32
+ "version": "0.8.4",
33
+ "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.4.tgz",
34
+ "integrity": "sha512-ql7hcgulKZ3ekfa2DGAogcCKsWU0diA/0nArz1CFzh93WQdb46/Kj18ka/Hifq6uA3Ush34Pc6vU/6HXeRwUkg==",
35
+ "license": "ISC",
36
+ "dependencies": {
37
+ "libsodium-sumo": "^0.8.0"
38
+ }
39
+ },
40
+ "node_modules/three": {
41
+ "version": "0.180.0",
42
+ "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
43
+ "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
44
+ "dev": true,
45
+ "license": "MIT"
46
+ },
47
+ "node_modules/ws": {
48
+ "version": "8.21.1",
49
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
50
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
51
+ "license": "MIT",
52
+ "engines": {
53
+ "node": ">=10.0.0"
54
+ },
55
+ "peerDependencies": {
56
+ "bufferutil": "^4.0.1",
57
+ "utf-8-validate": ">=5.0.2"
58
+ },
59
+ "peerDependenciesMeta": {
60
+ "bufferutil": {
61
+ "optional": true
62
+ },
63
+ "utf-8-validate": {
64
+ "optional": true
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.116.0",
3
+ "version": "0.124.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "bin",
11
11
  "!bin/lib/*.test.mjs",
12
12
  "README.md",
13
- "LICENSE"
13
+ "LICENSE",
14
+ "npm-shrinkwrap.json"
14
15
  ],
15
16
  "engines": {
16
17
  "node": ">=20"
@@ -34,10 +35,14 @@
34
35
  "homepage": "https://flowviant.com",
35
36
  "repository": {
36
37
  "type": "git",
37
- "url": "git+https://github.com/flowviant/cli.git"
38
+ "url": "git+https://github.com/Flowviant/flowviant.git",
39
+ "directory": "cli"
38
40
  },
39
41
  "license": "MIT",
40
42
  "bugs": {
41
- "url": "https://github.com/flowviant/cli/issues"
43
+ "url": "https://github.com/Flowviant/flowviant/issues"
44
+ },
45
+ "devDependencies": {
46
+ "three": "0.180.0"
42
47
  }
43
48
  }
@@ -1,120 +0,0 @@
1
- /**
2
- * TWO ROSTER JOB LANES THAT ACT ON THIS CHECKOUT AND SETTLE BACK: patch
3
- * reverts and task-restart cleanups.
4
- *
5
- * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Each lane dedups its
6
- * in-flight job ids, does its work fire-and-forget, and ALWAYS reports the job
7
- * done — the roster re-serves a job until it is reported, so a lane that could
8
- * wedge on a failure would redo it every poll. The loop in fleet.mjs only
9
- * hands each lane its roster array.
10
- *
11
- * Factories rather than module state because each lane is bound to the
12
- * checkout this daemon serves (and cleanup to the live base ref, read at call
13
- * time — the roster can move it mid-run).
14
- */
15
-
16
- import { execFileSync } from 'node:child_process';
17
- import { FLEET_URL } from './config.mjs';
18
- import { gitNetAsync, originSlug, isValidPrUrl, isValidBranch } from './git.mjs';
19
- import { c, note, ok, warn } from './ui.mjs';
20
- import { revertPatch, withPatchLock } from './patchRevert.mjs';
21
- import { fleetEndpoint } from './fleetWire.mjs';
22
- import { postToFleet } from './fleetPost.mjs';
23
-
24
- export function createPatchRevertLane({ repoRoot }) {
25
- // Patch reverts: a patch landed straight in this checkout, and a human took it
26
- // back. The commits are HERE, not on the server, so the reverse-apply happens
27
- // here too — a revert, never a reset, because the owner has almost certainly
28
- // worked on top by now. Serialised through the same lock as applies.
29
- const PATCH_REVERT_DONE_URL = fleetEndpoint('patch-revert-done', FLEET_URL);
30
- const reverting = new Set();
31
- const processPatchRevertJobs = (jobs) => {
32
- for (const job of jobs ?? []) {
33
- if (!job || typeof job.id !== 'string' || !Array.isArray(job.shas)) continue;
34
- if (reverting.has(job.id)) continue;
35
- reverting.add(job.id);
36
- (async () => {
37
- try {
38
- note(`${c.cyan('revert')} ${c.dim(`— ${job.title}`)}`);
39
- const res = await withPatchLock(() =>
40
- Promise.resolve(revertPatch({ repoRoot, shas: job.shas }))
41
- );
42
- if (res.ok) ok(`${c.dim('reverted')} ${job.title}`);
43
- else warn(`revert failed for "${job.title}": ${res.error}`);
44
- // ALWAYS report, success or not. Without this the flag stays set, the
45
- // roster re-serves the job every poll, and each pass reverts the
46
- // revert — the change flapping in and out of the owner's tree forever.
47
- await postToFleet(PATCH_REVERT_DONE_URL, {
48
- taskId: job.id,
49
- ok: res.ok,
50
- error: res.ok ? undefined : String(res.error ?? 'revert failed'),
51
- });
52
- } finally {
53
- reverting.delete(job.id);
54
- }
55
- })();
56
- }
57
- };
58
- return processPatchRevertJobs;
59
- }
60
-
61
- export function createCleanupLane({ repoRoot, getBaseRef }) {
62
- // Cleanup jobs (task restarts): close the abandoned PR + delete its remote
63
- // branch on the user's own gh, so a restart doesn't litter the repo.
64
- const CLEANUP_DONE_URL = fleetEndpoint('cleanup-done', FLEET_URL);
65
- const cleaning = new Set();
66
- const processCleanupJobs = (jobs) => {
67
- for (const job of jobs ?? []) {
68
- if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
69
- if (cleaning.has(job.id)) continue;
70
- cleaning.add(job.id);
71
- (async () => {
72
- try {
73
- note(`${c.cyan('cleanup')} ${c.dim(`— ${job.title} (restarted)`)}`);
74
- // Same guards as merge: only close a PR in THIS repo, only delete a
75
- // well-formed non-base branch. A bad server must not close a stranger's
76
- // PR or delete `main` (`--delete` with `main`) via a cleanup job.
77
- if (job.prUrl && isValidPrUrl(job.prUrl, originSlug(repoRoot))) {
78
- try {
79
- execFileSync(
80
- 'gh',
81
- [
82
- 'pr',
83
- 'close',
84
- job.prUrl,
85
- '--comment',
86
- 'Task restarted in Flowviant — this attempt was discarded.',
87
- '--delete-branch',
88
- ],
89
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
90
- );
91
- } catch (e) {
92
- // Already closed/merged/missing = fine; anything else we still
93
- // report done — a restart must never wedge on stale remotes.
94
- const err = e.stderr?.toString?.() || e.message || '';
95
- warn(`cleanup for "${job.title}": ${err.split('\n')[0] || 'gh pr close failed'}`);
96
- }
97
- } else if (job.branch && isValidBranch(job.branch, repoRoot, getBaseRef())) {
98
- try {
99
- // Explicit refspec form so a leading '-' can't be a git flag.
100
- // NETWORK call, so timed and non-interactive (2026-09-24, the
101
- // audit): a bare execFileSync here had no timeout and could
102
- // prompt on /dev/tty, freezing every poll and lease on the
103
- // machine behind this cleanup's own async loop.
104
- await gitNetAsync(['push', 'origin', `:refs/heads/${job.branch}`], repoRoot);
105
- } catch {
106
- /* branch already gone — fine */
107
- }
108
- } else if (job.prUrl || job.branch) {
109
- warn(`cleanup REFUSED for "${job.title}": untrusted PR/branch value`);
110
- }
111
- await postToFleet(CLEANUP_DONE_URL, { taskId: job.id });
112
- ok(`${c.cyan('cleaned')} ${c.dim(`— ${job.title}`)}`);
113
- } finally {
114
- cleaning.delete(job.id);
115
- }
116
- })();
117
- }
118
- };
119
- return processCleanupJobs;
120
- }