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
package/bin/lib/views.mjs CHANGED
@@ -7,32 +7,37 @@
7
7
  * status line for a server that did not answer is left out, never guessed.
8
8
  */
9
9
 
10
- import { execFileSync, spawn } from 'node:child_process';
10
+ import { spawn } from 'node:child_process';
11
11
  import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
12
- import { FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
12
+ import { StringDecoder } from 'node:string_decoder';
13
+ import { APP_URL, FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
13
14
  import { detectRepoRoot, listStoredProjects, projectLabel, resolveStoredCredential } from './credentials.mjs';
14
15
  import { desktopStatusRemote, installChannel } from './desktopContract.mjs';
15
16
  import { daemonLogPath } from './daemonState.mjs';
17
+ import { git as runGit } from './git.mjs';
16
18
  import { daemonRunningFor } from './instance.mjs';
17
19
  import { agoFrom } from './machineListing.mjs';
20
+ import { printable, printableText } from './printable.mjs';
18
21
  import { detectRuntimes } from './runtimeDetection.mjs';
19
22
  import { c } from './ui.mjs';
20
23
  import { versionBelow } from './versionOrder.mjs';
21
24
 
22
- const APP_URL = process.env.FLOWVIANT_APP_URL || 'https://app.flowviant.com';
23
-
24
25
  // "Behind" in these views is versionBelow (versionOrder.mjs): an unreadable
25
26
  // version is never marked older — a read-only view states nothing it cannot
26
27
  // measure.
27
28
 
28
29
  function agentLine(agent, now) {
29
- if (agent.asks) return `${agent.name} · ${c.yellow('asks you a question')}`;
30
+ // A member names an agent; this prints it to a terminal. Scrubbed like every
31
+ // other server string this daemon prints (2026-10-10, the audit's F151;
32
+ // see printable.mjs).
33
+ const name = printable(agent.name);
34
+ if (agent.asks) return `${name} · ${c.yellow('asks you a question')}`;
30
35
  const since = agent.status === 'working' && !agent.parked && agent.since ? Date.parse(agent.since) : NaN;
31
36
  const minutes = Number.isFinite(since) ? Math.max(0, Math.floor((now - since) / 60_000)) : null;
32
37
  const took = minutes == null ? '' : ` ${minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`}`;
33
38
  const word = agent.parked ? 'parked' : agent.status;
34
39
  const cards = `${agent.delivered} of ${agent.total} ${agent.total === 1 ? 'card' : 'cards'}${agent.delivered ? ' delivered' : ''}`;
35
- return `${agent.name} · ${word === 'working' ? c.blue(`working${took}`) : word} · ${cards}`;
40
+ return `${name} · ${word === 'working' ? c.blue(`working${took}`) : word} · ${cards}`;
36
41
  }
37
42
 
38
43
  /** `flowviant status`, rendered from the same document `status --json --remote` prints. */
@@ -51,7 +56,7 @@ export function renderStatus(status, { now = Date.now() } = {}) {
51
56
  lines.push(label('here', `not running — run ${c.cyan('flowviant')} in that repo`));
52
57
  } else if (project.running === true) {
53
58
  const serves = role === 'serving' ? c.green('serving')
54
- : role === 'inactive' ? `inactive${remote?.servingBoxName ? ` — ${remote.servingBoxName} serves this project` : ''}`
59
+ : role === 'inactive' ? `inactive${remote?.servingBoxName ? ` — ${printable(remote.servingBoxName)} serves this project` : ''}`
55
60
  : project.holder === 'serving' ? c.green('serving') : null;
56
61
  lines.push(label('here', serves ? `running · ${serves}` : 'running'));
57
62
  }
@@ -61,7 +66,9 @@ export function renderStatus(status, { now = Date.now() } = {}) {
61
66
  if (agents && agents.length > 0) {
62
67
  agents.forEach((agent, i) => lines.push(label(i === 0 ? 'agents' : '', agentLine(agent, now))));
63
68
  }
64
- if (remote?.pressure?.reason) lines.push(label('waiting', `Waiting for the machine — ${remote.pressure.reason}.`));
69
+ // The server scrubs these two (machineBoxes.ts, machinePressure.ts); the
70
+ // CLI keeps its belt, as printable.mjs explains.
71
+ if (remote?.pressure?.reason) lines.push(label('waiting', `Waiting for the machine — ${printable(remote.pressure.reason)}.`));
65
72
  for (const rt of project.runtimes ?? []) {
66
73
  if (rt.parkedByLimit && rt.message) lines.push(label('limit', `${rt.id} hit its limit: “${rt.message}”`));
67
74
  }
@@ -94,19 +101,36 @@ export function resolveProjectForView(argv = process.argv, cwd = process.cwd())
94
101
  };
95
102
  }
96
103
 
97
- /** The last `count` lines of a file, read from its end. */
104
+ /**
105
+ * The last `count` lines of a file, read from its end — PRINTABLE (2026-10-10,
106
+ * the audit's F133). A daemon before this release logged member-written card
107
+ * titles raw, so a replay could carry an OSC 52 into whichever terminal runs
108
+ * `flowviant logs`. `printableText` keeps the lines' shape; a log written on a
109
+ * TTY loses its colours with everything else.
110
+ */
98
111
  export function tailLines(path, count) {
99
112
  const size = statSync(path).size;
100
113
  const want = Math.min(size, 256 * 1024);
101
114
  const buf = Buffer.alloc(want);
102
115
  const fd = openSync(path, 'r');
103
116
  try { readSync(fd, buf, 0, want, size - want); } finally { closeSync(fd); }
104
- const lines = buf.toString('utf8').split('\n');
117
+ const lines = printableText(buf.toString('utf8')).split('\n');
105
118
  if (lines.at(-1) === '') lines.pop();
106
119
  if (want < size) lines.shift(); // the first line is cut mid-way
107
120
  return { text: lines.slice(-count).join('\n'), size };
108
121
  }
109
122
 
123
+ /**
124
+ * What `logs -f` prints of each new chunk: decoded as a STREAM, because a read
125
+ * can end inside a multi-byte character, and scrubbed like the tail. A sequence
126
+ * split across two reads loses its ESC in the first and its terminator in the
127
+ * second, so what is left of it is plain text.
128
+ */
129
+ export function createLogFollower() {
130
+ const decoder = new StringDecoder('utf8');
131
+ return (buf) => printableText(decoder.write(buf));
132
+ }
133
+
110
134
  export async function runLogs(argv = process.argv) {
111
135
  const which = resolveProjectForView(argv);
112
136
  if (!which.entry) { console.error(which.message); process.exit(1); }
@@ -125,6 +149,7 @@ export async function runLogs(argv = process.argv) {
125
149
  // Poll rather than fs.watch: the daemon rotates the file (a rename), and a
126
150
  // watch on the old inode would go quiet exactly then.
127
151
  let offset = existsSync(path) ? statSync(path).size : 0;
152
+ const followed = createLogFollower();
128
153
  for (;;) {
129
154
  await new Promise((resolve) => setTimeout(resolve, 500));
130
155
  if (!existsSync(path)) continue;
@@ -134,7 +159,7 @@ export async function runLogs(argv = process.argv) {
134
159
  const buf = Buffer.alloc(size - offset);
135
160
  const fd = openSync(path, 'r');
136
161
  try { readSync(fd, buf, 0, buf.length, offset); } finally { closeSync(fd); }
137
- process.stdout.write(buf);
162
+ process.stdout.write(followed(buf));
138
163
  offset = size;
139
164
  }
140
165
  }
@@ -173,7 +198,7 @@ export async function runDoctor({ cwd = process.cwd(), fetchImpl = fetch } = {})
173
198
  const add = (state, text, fix) => { if (state === false) failed++; lines.push(check(state, text, fix)); };
174
199
 
175
200
  let git = null;
176
- try { git = String(execFileSync('git', ['--version'], { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })).trim(); } catch { /* absent */ }
201
+ try { git = runGit(['--version'], undefined, { timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] }); } catch { /* absent */ }
177
202
  add(Boolean(git), git ?? 'git is not installed', 'Install git; agents work in git worktrees.');
178
203
 
179
204
  const installed = detectRuntimes().filter((rt) => rt.installed);
@@ -21,24 +21,23 @@
21
21
  */
22
22
 
23
23
  import { existsSync } from 'node:fs';
24
- import { execFileSync } from 'node:child_process';
25
24
  import { homedir } from 'node:os';
26
25
  import { join } from 'node:path';
27
26
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
28
- import { git, resetWorktree, originSlug, isSafePathSegment } from './git.mjs';
27
+ import { git, gitOrNull, gitRaw, resetWorktree, originSlug, isSafePathSegment, SHA1_ABBREV_RE } from './git.mjs';
29
28
  import { c, note, ok, warn } from './ui.mjs';
30
29
  import { sawSentinel } from './claude.mjs';
31
30
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
32
31
  import { SYSTEM_WIKI, WIKI_KICKOFF, SYSTEM_REGROUND, REGROUND_KICKOFF } from './prompts.mjs';
33
32
  import { ensureVault, syncVault } from './vault.mjs';
34
33
  import { scrub as envScrub } from './uplinkScrub.mjs';
35
- import { RUNTIMES } from './runtimes.mjs';
34
+ import { runtimeLabel } from './runtimes.mjs';
36
35
  import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
37
- import { recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
38
36
  import { THINK_MARKER } from './runtimeEvents.mjs';
39
37
  import { fleetEndpoint } from './fleetWire.mjs';
40
- import { postToFleet } from './fleetPost.mjs';
38
+ import { fleetHeaders, postToFleet } from './fleetPost.mjs';
41
39
  import { addUsage } from './turnUsage.mjs';
40
+ import { printable } from './printable.mjs';
42
41
 
43
42
  export function createWikiRunner({
44
43
  repoRoot,
@@ -126,11 +125,7 @@ export function createWikiRunner({
126
125
  try {
127
126
  await fetch(WIKI_PROGRESS_URL, {
128
127
  method: 'POST',
129
- headers: {
130
- Authorization: `Bearer ${FLEET_TOKEN}`,
131
- 'User-Agent': USER_AGENT,
132
- 'Content-Type': 'application/json',
133
- },
128
+ headers: fleetHeaders(),
134
129
  signal: AbortSignal.timeout(15_000),
135
130
  body: JSON.stringify(safe),
136
131
  });
@@ -148,22 +143,10 @@ export function createWikiRunner({
148
143
  * sweep queued, for the rest of the day. Best-effort: an older server 404s
149
144
  * once and the TTL is still the backstop it always was.
150
145
  */
151
- const postWikiAbandoned = async () => {
152
- try {
153
- await fetch(WIKI_ABANDONED_URL, {
154
- method: 'POST',
155
- headers: {
156
- Authorization: `Bearer ${FLEET_TOKEN}`,
157
- 'User-Agent': USER_AGENT,
158
- 'Content-Type': 'application/json',
159
- },
160
- signal: AbortSignal.timeout(15_000),
161
- body: '{}',
162
- });
163
- } catch {
164
- /* best-effort — the request's own TTL still expires it */
165
- }
166
- };
146
+ // No longer a near copy of `postToFleet` (decisions.md's 2026-09-26 open
147
+ // item, closed audit 2026-10-10 F136): the same post, on its own 15 s
148
+ // ceiling. Never throws — the request's own TTL still expires it.
149
+ const postWikiAbandoned = () => postToFleet(WIKI_ABANDONED_URL, {}, { timeoutMs: 15_000 });
167
150
 
168
151
  const enqueueSweep = (job) => {
169
152
  if (!job || job.requestedAt === lastSweepAt) return;
@@ -187,7 +170,19 @@ export function createWikiRunner({
187
170
  type: 'reground',
188
171
  intentId,
189
172
  prUrl,
190
- title: title || 'a delivered task',
173
+ // A CARD'S TITLE, which any member writes (and intake can seed from a
174
+ // stranger's ticket), printed by the drain's narration six times and put
175
+ // in the prompt. SANITIZED AT THE INTAKE like the pages below (2026-10-10,
176
+ // the audit's F097): an OSC 52 in a title rewrote the operator's
177
+ // clipboard the moment the card shipped. printable() takes the whole
178
+ // display-control class, newlines included, so a title is one line.
179
+ title:
180
+ (typeof title === 'string'
181
+ ? printable(title)
182
+ .replace(/\s+/g, ' ')
183
+ .trim()
184
+ .slice(0, 300)
185
+ : '') || 'a delivered task',
191
186
  // What the PLAN thought this would invalidate. A hint, not the truth —
192
187
  // the turn still reads the real changed files; this catches pages whose
193
188
  // frontmatter file list has drifted, or that document a concept rather
@@ -239,13 +234,11 @@ export function createWikiRunner({
239
234
  if (!Array.isArray(shas) || shas.length === 0) return null;
240
235
  const files = new Set();
241
236
  for (const sha of shas.slice(0, 50)) {
242
- if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
237
+ // git.mjs's SHA-1 shape, either case — the shape this lookup has
238
+ // always taken (audit 2026-10-10, F749).
239
+ if (!SHA1_ABBREV_RE.test(String(sha).toLowerCase())) continue;
243
240
  try {
244
- const out = execFileSync(
245
- 'git',
246
- ['show', '--name-only', '--pretty=format:', String(sha)],
247
- { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
248
- );
241
+ const out = gitRaw(['show', '--name-only', '--pretty=format:', String(sha)], repoRoot);
249
242
  for (const line of out.split('\n')) {
250
243
  const f = line.trim();
251
244
  if (f) files.add(f);
@@ -399,12 +392,8 @@ export function createWikiRunner({
399
392
  }
400
393
  }
401
394
  resetWorktree(wikiWt, getBaseRef());
402
- let sha = '';
403
- try {
404
- sha = git(['rev-parse', 'HEAD'], wikiWt);
405
- } catch {
406
- /* detached/no HEAD — still writes the map, just ungrounded */
407
- }
395
+ // No HEAD → '' — still writes the map, just ungrounded.
396
+ const sha = gitOrNull(['rev-parse', 'HEAD'], wikiWt) ?? '';
408
397
  // Sync the vault after the turn regardless of the sentinel: a died
409
398
  // sweep's partial pages still persist (merge, no prune) — only a
410
399
  // COMPLETED sweep finalizes, so an interrupted one can't erase pages.
@@ -441,7 +430,7 @@ export function createWikiRunner({
441
430
  warn('wiki generation skipped — no installed CLI can run a vault-scoped turn');
442
431
  return;
443
432
  }
444
- const wikiLabel = RUNTIMES[wikiRt].label;
433
+ const wikiLabel = runtimeLabel(wikiRt);
445
434
  if (task.type === 'sweep') {
446
435
  sweepAttempts++;
447
436
  note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
@@ -454,19 +443,11 @@ export function createWikiRunner({
454
443
  vaultDir,
455
444
  runtime: wikiRt,
456
445
  label: c.cyan('[wiki]'),
457
- streamJson: true,
446
+ // No skills or connectors are harvested off a wiki turn: it is
447
+ // fenced (no settings source, strict MCP), so its init names less
448
+ // than the machine has (runTurn.mjs, `initHarvestFor`).
458
449
  onActivity,
459
450
  onUsage: onUsage(wikiRt),
460
- // FREE SKILLS, off a turn that was running anyway. This stream is
461
- // already parsed and its init event already carries the CLI's own
462
- // resolved skill set — the same fact a tab turn teaches — so the
463
- // only thing missing was the handler. The wiki turn runs in a
464
- // detached worktree of THIS repo, so its `.claude/skills` and the
465
- // machine's personal ones resolve identically to a tab's.
466
- onInit: (i) => {
467
- recordSkills(i.skills);
468
- recordMcpServers(i.mcpServers);
469
- },
470
451
  onSpawn: (ch) => {
471
452
  wikiChild = ch;
472
453
  },
@@ -512,14 +493,8 @@ export function createWikiRunner({
512
493
  vaultDir,
513
494
  runtime: wikiRt,
514
495
  label: c.cyan('[wiki]'),
515
- streamJson: true,
516
496
  onActivity,
517
497
  onUsage: onUsage(wikiRt),
518
- // Same free harvest as the sweep above.
519
- onInit: (i) => {
520
- recordSkills(i.skills);
521
- recordMcpServers(i.mcpServers);
522
- },
523
498
  onSpawn: (ch) => {
524
499
  wikiChild = ch;
525
500
  },
@@ -579,10 +554,14 @@ export function createWikiRunner({
579
554
  // Terminal frame so the app cover clears promptly (don't wait for the
580
555
  // freshness window to lapse). force-sent past the throttle.
581
556
  await postWikiProgress(frame({ done: true, ...(usage ? { usage } : {}) }), true);
582
- // Safety net: the wiki turn is read-only on the repo by CONTRACT, but
583
- // permission enforcement is a curated tool list, not a path jail —
584
- // discard anything a confused turn wrote to the worktree so it can
585
- // never leak into a later turn or a push.
557
+ // Safety net: the wiki turn is read-only on the repo by CONTRACT.
558
+ // Claude's list writes only the vault (claudePosture.mjs
559
+ // `wikiPermFor`, 2026-10-10) and Codex's kernel profile likewise,
560
+ // but Antigravity's flags cannot say "only the vault" — so discard
561
+ // anything a turn wrote to the worktree before it can leak into a
562
+ // later turn or a push. It reaches the worktree ONLY: a write
563
+ // anywhere else is the posture's to refuse, never this reset's to
564
+ // undo.
586
565
  try {
587
566
  resetWorktree(wikiWt, getBaseRef());
588
567
  } catch {
package/bin/lib/work.mjs CHANGED
@@ -33,6 +33,7 @@
33
33
  * workChildren.mjs the live CLI children, teardown, the admission
34
34
  * workAgentPublish.mjs pushing and fetching an agent's branch
35
35
  * workPlaceReports.mjs the worktree readout: settle, burst, sweep
36
+ * workArtifactReconcile.mjs an idle agent's stored delivery against its worktree
36
37
  * workReportQueue.mjs every settle, held and retried until delivered
37
38
  * workSessionTokens.mjs the per-session work credential
38
39
  * workNarration.mjs the tab's live line and the deferral sentence
@@ -40,17 +41,22 @@
40
41
  * workSessionRuntime.mjs which CLI a tab speaks, which conversation resumes
41
42
  * workAdoptCarry.mjs adoption's dirty carry
42
43
  * workSessionTurns.mjs a tab's turn, start to settle
44
+ * workTurnShape.mjs what that turn is — plain, plan, capture or build —
45
+ * and its prompt pair (pure, 2026-10-10)
43
46
  * workRetire.mjs held sessions, retirement, manual-place freshening
44
47
  * workToolLog.mjs one turn's tool cards, folded and capped (pure)
45
48
  * workCommandAudit.mjs one turn's `$ …` audit, batched to the server
46
49
  * workBrain.mjs the model and effort a turn may put on argv (pure)
47
50
  * workIntake.mjs one incoming ticket drafted into a card, read-only (2026-09-28)
51
+ * workCappedTurn.mjs one capped read-only consult turn: its child, its slot,
52
+ * its cap and its transcript — the planner, the pre-review
53
+ * and intake all run one (2026-10-10)
48
54
  * turnLock.mjs whether a live CLI of ours holds a directory's turn
49
55
  * (and, split earlier: workDiffs, workPreviews, workPullRequests, workShip,
50
56
  * workAgentPlans, workAgentCheck, workAgentPrecheck, workAgentReview,
51
57
  * workAgentTurns, workAgentMerges, workProcesses; and out of workAgentTurns,
52
- * SOLID F036: workAgentTurnReports, workAgentTurnExecution,
53
- * workAgentTurnBegun, workAgentTurnOutcome)
58
+ * SOLID F036: workAgentTurnReports, workAgentTurnExecution, workAgentTurnBegun,
59
+ * workAgentTurnOutcome; the live brief: workAgentBrief, workAgentBriefRunner)
54
60
  */
55
61
 
56
62
  import { createWorkProcesses } from './workProcesses.mjs';
@@ -71,6 +77,7 @@ import { createWorkChildren } from './workChildren.mjs';
71
77
  import { createWorkAgentPublish } from './workAgentPublish.mjs';
72
78
  import { createWorkPlaceReports } from './workPlaceReports.mjs';
73
79
  import { createWorkReportQueue } from './workReportQueue.mjs';
80
+ import { postBestEffort } from './fleetPost.mjs';
74
81
  import { createWorkSessionTokens } from './workSessionTokens.mjs';
75
82
  import { createWorkNarration } from './workNarration.mjs';
76
83
  import { createWorkAttachments } from './workAttachments.mjs';
@@ -86,6 +93,7 @@ import { warn } from './ui.mjs';
86
93
  import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
87
94
  import { buildArtifactUpload, changedSince, snapshotArtifacts } from './artifacts.mjs';
88
95
  import { createArtifactReporter } from './artifactReporter.mjs';
96
+ import { createWorkArtifactReconcile } from './workArtifactReconcile.mjs';
89
97
  import { renderDesignPreview } from './artifactPreview.mjs';
90
98
  import { scrub as envScrub, secretIn as envSecretIn } from './uplinkScrub.mjs';
91
99
 
@@ -150,6 +158,13 @@ export function createWorkManager({
150
158
  listChanged: changedSince,
151
159
  buildUpload: buildArtifactUpload,
152
160
  renderPreview: renderDesignPreview,
161
+ // What each place held, per owner, beside its worktree's git dir: it dies
162
+ // with the worktree, so a recreated one proves no discard (artifactIteration.mjs).
163
+ // `sessionMetaPath` and `worktreeBornAt` are declared below; both are called only once the manager is built.
164
+ seenPath: (dir, owner) =>
165
+ /^[A-Za-z0-9_-]{1,64}$/.test(String(owner)) ? sessionMetaPath(dir, 'flowviant-artifacts-seen', owner) : null,
166
+ // …and when that worktree was cut, so rows stored before it are never its own.
167
+ placeBornAt: (dir) => worktreeBornAt(dir),
153
168
  });
154
169
  /**
155
170
  * Snapshot a place's artifact directory before a CLI spawns in it — and make
@@ -186,7 +201,7 @@ export function createWorkManager({
186
201
  const gitNet = (args, ms) => gitNetIn(args, repoRoot, ms);
187
202
 
188
203
  // ── THE SESSION'S OWN HALF — built first, because the lanes below are handed it.
189
- const { sessionPlaces, placeOf, learnPlaces, placeDir, placeWtFor, sessionMetaPath } =
204
+ const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt } =
190
205
  createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
191
206
  const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
192
207
  createWorkSessionGroups({ repoRoot });
@@ -194,6 +209,15 @@ export function createWorkManager({
194
209
  createWorkChildren({ extraLiveTurns });
195
210
  const { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch } =
196
211
  createWorkAgentPublish({ repoRoot, gitNet });
212
+ const { reconcileIdleArtifacts } = createWorkArtifactReconcile({
213
+ artifacts,
214
+ placeLocks,
215
+ inPlace,
216
+ placeDir,
217
+ beforeArtifacts,
218
+ getArtifactsAccepted,
219
+ log: (line) => warn(line),
220
+ });
197
221
  const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
198
222
  createWorkPlaceReports({
199
223
  repoRoot,
@@ -211,7 +235,6 @@ export function createWorkManager({
211
235
  const {
212
236
  pendingWorkReports,
213
237
  pendingShipReports,
214
- postBestEffort,
215
238
  REJECT_RETRY_MS,
216
239
  settleWorkTurn,
217
240
  settleShip,
@@ -232,8 +255,9 @@ export function createWorkManager({
232
255
  const { fetchAttachments, fetchAgentFiles } = createWorkAttachments();
233
256
  const { sessionRuntime, agyRegistryLookup } = createWorkSessionRuntime({ sessionMetaPath });
234
257
  const { carryDirtyState } = createAdoptCarry({ sessionMetaPath });
235
- const { processWorkTurns, workAnswering } = createWorkSessionTurns({
258
+ const { processWorkTurns, interruptWorkTurns, workAnswering } = createWorkSessionTurns({
236
259
  repoRoot,
260
+ baseRef,
237
261
  baseDir,
238
262
  getMcpUrl,
239
263
  getArtifactsAccepted,
@@ -241,7 +265,7 @@ export function createWorkManager({
241
265
  inPlace,
242
266
  workChildren,
243
267
  workAttempts,
244
- sessionPlaces,
268
+ notePlace,
245
269
  placeOf,
246
270
  placeWtFor,
247
271
  sessionMetaPath,
@@ -282,8 +306,9 @@ export function createWorkManager({
282
306
 
283
307
  const { processPrJobs } = createWorkPullRequests({
284
308
  placeOf,
309
+ placeDir,
310
+ branchAt,
285
311
  repoRoot,
286
- baseDir,
287
312
  baseRef,
288
313
  gitNet,
289
314
  landed,
@@ -298,6 +323,8 @@ export function createWorkManager({
298
323
  baseDir,
299
324
  gitNet,
300
325
  baseRef,
326
+ placeDir,
327
+ branchAt,
301
328
  placeWtFor,
302
329
  sessionMetaPath,
303
330
  shipping,
@@ -341,7 +368,7 @@ export function createWorkManager({
341
368
  });
342
369
  const { runReviewEntry } = createWorkAgentReview({ runCheck, runPrecheck });
343
370
 
344
- const { processAgentTurnJobs, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
371
+ const { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
345
372
  REJECT_RETRY_MS,
346
373
  baseRef,
347
374
  inPlace,
@@ -433,7 +460,7 @@ export function createWorkManager({
433
460
  return {
434
461
  flushWorkReports,
435
462
  learnPlaces,
436
- processWorkTurns,
463
+ processWorkTurns, interruptWorkTurns,
437
464
  processShipJobs,
438
465
  processDiffJobs,
439
466
  processKillJobs,
@@ -445,8 +472,10 @@ export function createWorkManager({
445
472
  shutdownPreviews,
446
473
  retireWorkSessions,
447
474
  reportWorktrees,
475
+ reconcileIdleArtifacts,
448
476
  shutdownWork,
449
477
  workBusy,
478
+ runningAgentCount: () => agentTurns.size,
450
479
  // The machine's own admission answer, and what it is counting. Handed to
451
480
  // the loop so the lanes it owns — the wiki cartographer (wikiRunner.mjs) — ask the
452
481
  // same question, and so the machine snapshot can charge memory to the work
@@ -456,10 +485,12 @@ export function createWorkManager({
456
485
  liveTurnCount,
457
486
  processAgentPlanJobs,
458
487
  processIntakeJobs,
459
- processAgentTurnJobs,
460
- // Only the displacement stand-down calls this — see its comment. Exported
461
- // rather than hooked into `shutdownWork` because the signal handlers cannot
462
- // await, and a settle that is not awaited is a settle that did not happen.
488
+ processAgentTurnJobs, interruptAgentTurns,
489
+ // Only the roster's stand-down calls this — a displacement, a removal or a
490
+ // commanded stop (holder.mjs, `standDownDisplaced`); see its comment.
491
+ // Exported rather than hooked into `shutdownWork` because the signal
492
+ // handlers cannot await, and a settle that is not awaited is a settle that
493
+ // did not happen.
463
494
  settleAgentTurns,
464
495
  processAgentMergeJobs,
465
496
  freshenManualPlaces,
@@ -6,7 +6,6 @@
6
6
  * promise of its own (the source is never written, a problem becomes one line
7
7
  * for the agent to say), reached once, from the session lane's adopt path.
8
8
  */
9
- import { execFileSync } from 'node:child_process';
10
9
  import { cpSync, lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
11
10
  import { dirname, join } from 'node:path';
12
11
  import { git, gitRaw, splitNul } from './git.mjs';
@@ -31,9 +30,8 @@ export function createAdoptCarry({ sessionMetaPath }) {
31
30
  // text file) must round-trip byte-exact or the apply corrupts what it
32
31
  // carries. 64MB of headroom — a dirtier tree than that fails the read
33
32
  // here and is SAID, below, rather than half-applied.
34
- const patch = execFileSync('git', ['diff', 'HEAD', '--binary'], {
35
- cwd: srcCwd,
36
- stdio: ['ignore', 'pipe', 'pipe'],
33
+ const patch = gitRaw(['diff', 'HEAD', '--binary'], srcCwd, {
34
+ encoding: 'buffer',
37
35
  maxBuffer: 64 * 1024 * 1024,
38
36
  });
39
37
  if (patch.length) {
@@ -0,0 +1,92 @@
1
+ /** A readout alongside ONE running CLI, never another worker on its branch.
2
+ * No events since the last snapshot means no model call. A stopped child,
3
+ * pause, park or lost holder prevents a call or a late result from publishing. */
4
+ import { AGENT_BRIEF_SYSTEM, parseAgentBrief, briefModelFor } from './agentBrief.mjs';
5
+
6
+ export const AGENT_BRIEF_INTERVAL_MS = 3 * 60_000;
7
+ export const AGENT_BRIEF_FIRST_MS = 20_000;
8
+ export const AGENT_BRIEF_INPUT_MAX = 24_000;
9
+ export function makeAgentBrief({ runtime, model, context, summarize, post, scrub = (s) => s, models = () => [], now = Date.now,
10
+ setTimer = setTimeout, clearTimer = clearTimeout }) {
11
+ let running = false;
12
+ let stopped = false;
13
+ let timer = null;
14
+ let sequence = 0;
15
+ let consumed = 0;
16
+ let entries = [];
17
+ let flight = null;
18
+ let controller = null;
19
+ let held = null;
20
+ const append = (kind, data) => {
21
+ if (stopped || data == null) return;
22
+ // Scrub before trimming so a cut never turns a secret into an unrecognized prefix.
23
+ let text;
24
+ try {
25
+ text = (typeof data === 'string' ? scrub(data) : JSON.stringify(data, (_key, value) => {
26
+ // Redact decoded strings BEFORE JSON escapes quotes and newlines;
27
+ // otherwise a multiline secret no longer matches its learned value.
28
+ if (typeof value === 'string') return scrub(value);
29
+ if (value && typeof value === 'object' && !Array.isArray(value))
30
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [scrub(key), item]));
31
+ return value;
32
+ })).slice(0, 4_000);
33
+ } catch { return; } // an optional readout must never fail the working CLI
34
+ if (!text.trim()) return;
35
+ entries.push({ seq: ++sequence, kind, text });
36
+ while (entries.length > 100 || entries.reduce((sum, e) => sum + e.text.length, 0) > AGENT_BRIEF_INPUT_MAX) entries.shift();
37
+ };
38
+ const schedule = (ms = AGENT_BRIEF_INTERVAL_MS) => {
39
+ if (timer || stopped || !running) return;
40
+ timer = setTimer(() => { timer = null; void tick(); }, ms);
41
+ timer?.unref?.();
42
+ };
43
+ const refresh = async () => {
44
+ if (!running || stopped || (!held && sequence === consumed)) return;
45
+ // Retry a paid-for reading without paying for another. The API rechecks
46
+ // holder and turn in the write, and duplicate/older snapshots cannot win.
47
+ if (held) { if (await post(held)) held = null; return; }
48
+ const brain = briefModelFor(runtime, typeof model === 'function' ? model() : model, models());
49
+ if (!brain) return;
50
+ controller = new AbortController();
51
+ const state = await context(controller.signal);
52
+ if (state?.unsupported) { stopped = true; if (timer) clearTimer(timer); timer = null; return; }
53
+ if (stopped || !running || !state?.active || controller.signal.aborted) return;
54
+ // Card changes and daemon restarts do not reset the agent's three-minute
55
+ // cadence. Both dates came from the server, not this machine's clock.
56
+ if (state.brief && Date.parse(state.sourceAt) - Date.parse(state.brief.updatedAt) < AGENT_BRIEF_INTERVAL_MS) return;
57
+ const through = sequence;
58
+ const events = entries.filter((e) => e.seq > consumed);
59
+ if (!events.length) return;
60
+ const prompt = JSON.stringify({ previous: state.brief ?? null, taskId: state.taskId ?? null,
61
+ omittedEvents: Math.max(0, events[0].seq - consumed - 1), events: events.map(({ kind, text }) => ({ kind, text })) });
62
+ const out = await summarize({ runtime, ...brain, system: AGENT_BRIEF_SYSTEM, prompt, signal: controller.signal });
63
+ if (stopped || !running || controller.signal.aborted) return;
64
+ const content = parseAgentBrief(typeof out === 'string' ? out : out?.out, scrub);
65
+ if (!content) return;
66
+ consumed = through;
67
+ entries = entries.filter((e) => e.seq > through);
68
+ held = { ...content, sourceAt: state.sourceAt ?? new Date(now()).toISOString(), ...(out?.usage ? { usage: out.usage } : {}) };
69
+ if (await post(held)) held = null;
70
+ };
71
+ const tick = () => {
72
+ if (flight) return flight;
73
+ flight = refresh().catch(() => {}).finally(() => { flight = null; controller = null; schedule(); });
74
+ return flight;
75
+ };
76
+ return {
77
+ append, tick,
78
+ running(active) {
79
+ running = active;
80
+ if (active) schedule(AGENT_BRIEF_FIRST_MS);
81
+ else { if (timer) clearTimer(timer); timer = null; controller?.abort(); }
82
+ },
83
+ async stop() {
84
+ stopped = true;
85
+ running = false;
86
+ if (timer) clearTimer(timer);
87
+ timer = null;
88
+ controller?.abort();
89
+ await flight;
90
+ },
91
+ };
92
+ }