flowviant 0.102.2 → 0.104.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 (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,630 @@
1
+ /**
2
+ * THE LIVING-WIKI RUNNER — the queue, the admission-gated drain, and the one
3
+ * cartographer CLI it runs at a time in the daemon's shared `wiki` worktree.
4
+ *
5
+ * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). The wiki lane changes
6
+ * for wiki reasons (sweep retry budgets, re-ground inputs, the progress feed,
7
+ * the vault sync) and none of them are the reconcile loop's; the loop hands
8
+ * each roster to `onRoster` and asks `busy()` / `liveTurns()` when it needs
9
+ * to know whether this lane is working.
10
+ *
11
+ * Everything the lane needs from the daemon is passed in: the checkout and the
12
+ * worktree home, the LIVE base ref (a getter — the roster can move it mid-run),
13
+ * and the churn admission the other unattended lanes ask. `runTurn` and
14
+ * `pickRuntimeFor` default to the real CLI driver and are injectable only so
15
+ * the lane can be driven in a test without spending a model turn.
16
+ *
17
+ * Over the 500-line mark as ONE LIFECYCLE (queue → admission → turn → vault
18
+ * sync → settle), not as debt. If it grows, the seam is the progress feed
19
+ * (`postWikiProgress` / `postWikiAbandoned`), which changes for the canvas's
20
+ * reasons rather than the lane's.
21
+ */
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { execFileSync } from 'node:child_process';
25
+ import { homedir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
28
+ import { git, resetWorktree, originSlug, isSafePathSegment } from './git.mjs';
29
+ import { c, note, ok, warn } from './ui.mjs';
30
+ import { sawSentinel } from './claude.mjs';
31
+ import { runTurn as defaultRunTurn } from './runTurn.mjs';
32
+ import { SYSTEM_WIKI, WIKI_KICKOFF, SYSTEM_REGROUND, REGROUND_KICKOFF } from './prompts.mjs';
33
+ import { ensureVault, syncVault } from './vault.mjs';
34
+ import { scrub as envScrub } from './uplinkScrub.mjs';
35
+ import { RUNTIMES } from './runtimes.mjs';
36
+ import { pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
37
+ import { recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
38
+ import { THINK_MARKER } from './runtimeEvents.mjs';
39
+ import { fleetEndpoint } from './fleetWire.mjs';
40
+ import { postToFleet } from './fleetPost.mjs';
41
+
42
+ export function createWikiRunner({
43
+ repoRoot,
44
+ baseDir,
45
+ repoKey,
46
+ getBaseRef,
47
+ admit,
48
+ runTurn = defaultRunTurn,
49
+ pickRuntimeFor = defaultPickRuntimeFor,
50
+ }) {
51
+ /**
52
+ * Everything that reads or rewrites the shared `wikiWt` worktree takes this:
53
+ * the wiki sweep, the post-merge re-ground, the plan check, and consults.
54
+ *
55
+ * They are one directory. The wiki queue hard-resets it (`checkout --detach`,
56
+ * `reset --hard`, `clean -fd`) between tasks, which pulls the files out from
57
+ * under anything else mid-read — and two Claude turns in one working tree is
58
+ * incoherent even without the reset.
59
+ */
60
+ let wikiLock = Promise.resolve();
61
+ const withWikiLock = (fn) => {
62
+ const run = wikiLock.then(fn, fn);
63
+ wikiLock = run.then(
64
+ () => {},
65
+ () => {}
66
+ );
67
+ return run;
68
+ };
69
+
70
+ // Living-wiki work runs ONE turn at a time in a dedicated repo worktree (off
71
+ // the agents' checkouts). Claude READS the repo there and writes the markdown
72
+ // VAULT (~/.flowviant/vaults/<projectId>) — plain files, no MCP tools; the
73
+ // daemon hash-diff syncs the vault to the server after each turn. Two
74
+ // triggers enqueue: a Regenerate click (full SWEEP, finalize-prunes) and a
75
+ // successful merge (incremental RE-GROUND). One queue + runner serializes
76
+ // them so they never collide on the worktree or the vault. Wiki work needs no
77
+ // agent online.
78
+ const wikiWt = join(baseDir, 'wiki');
79
+ const REGROUND_DONE_URL = fleetEndpoint('reground-done', FLEET_URL);
80
+ const WIKI_VAULT_URL = fleetEndpoint('wiki-vault', FLEET_URL);
81
+ const WIKI_PROGRESS_URL = fleetEndpoint('wiki-progress', FLEET_URL);
82
+ const WIKI_ABANDONED_URL = fleetEndpoint('wiki-abandoned', FLEET_URL);
83
+ const wikiQueue = [];
84
+ let wikiBusy = false;
85
+ let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
86
+ let wikiHoldSaidAt = 0; // last time the drain said it was waiting on the box
87
+ let lastSweepAt = null; // dedup: run each Regenerate request once
88
+ // …UNLESS IT FAILED. A sweep that ends without WIKI_DONE never finalizes, so
89
+ // the server's `regen_requested_at` stays set and the roster keeps offering
90
+ // the same `requestedAt` — which this dedup then swallowed forever. The
91
+ // console said "retry from the app", to a console nobody reads. Bounded the
92
+ // same way the re-ground path is: a full sweep is an expensive model turn, so
93
+ // a repo that fails one every time must not be able to loop-burn quota.
94
+ let sweepAttempts = 0;
95
+ // Which request the counter belongs to, so a NEW Regenerate click starts with
96
+ // a full budget rather than inheriting an exhausted one.
97
+ let sweepAttemptsFor = null;
98
+ const MAX_SWEEP_ATTEMPTS = 3;
99
+ const groundedIntents = new Set(); // dedup: re-ground each delivery once
100
+ // The vault is keyed by the server project this fleet credential serves
101
+ // (learned from the roster); until the first poll names it, fall back to a
102
+ // repo-keyed dir so a stale-server daemon still works.
103
+ let wikiProjectId = null;
104
+ const vaultDirFor = () =>
105
+ wikiProjectId && isSafePathSegment(wikiProjectId)
106
+ ? join(homedir(), '.flowviant', 'vaults', wikiProjectId)
107
+ : join(homedir(), '.flowviant', 'vaults', repoKey);
108
+
109
+ // Stream what the wiki turn is doing to the app (the canvas renders the read
110
+ // phase). Throttled to ~1/s — the FIRST activity of a run and the terminal
111
+ // `done` frame force-send so the cover appears fast and clears cleanly.
112
+ let lastProgressAt = 0;
113
+ const postWikiProgress = async (body, force = false) => {
114
+ const now = Date.now();
115
+ if (!force && now - lastProgressAt < 600) return;
116
+ lastProgressAt = now;
117
+ // Uplink scrub: narration/labels can quote repo content, and repo content
118
+ // can contain a synced secret — redact known values before anything leaves
119
+ // this machine.
120
+ const safe = {
121
+ ...body,
122
+ ...(typeof body.activity === 'string' ? { activity: envScrub(body.activity) } : {}),
123
+ ...(Array.isArray(body.recent) ? { recent: body.recent.map((s) => envScrub(s)) } : {}),
124
+ };
125
+ try {
126
+ await fetch(WIKI_PROGRESS_URL, {
127
+ method: 'POST',
128
+ headers: {
129
+ Authorization: `Bearer ${FLEET_TOKEN}`,
130
+ 'User-Agent': USER_AGENT,
131
+ 'Content-Type': 'application/json',
132
+ },
133
+ signal: AbortSignal.timeout(15_000),
134
+ body: JSON.stringify(safe),
135
+ });
136
+ } catch {
137
+ /* best-effort — a dropped frame is harmless, the next one supersedes it */
138
+ }
139
+ };
140
+
141
+ /**
142
+ * Tell the server this daemon has stopped retrying the pending sweep.
143
+ *
144
+ * The retry budget is a `let` in this process; `regen_requested_at` is a
145
+ * durable column with a 24-hour TTL. Without this the two disagreed — the
146
+ * daemon had permanently given up while every surface went on calling the
147
+ * sweep queued, for the rest of the day. Best-effort: an older server 404s
148
+ * once and the TTL is still the backstop it always was.
149
+ */
150
+ const postWikiAbandoned = async () => {
151
+ try {
152
+ await fetch(WIKI_ABANDONED_URL, {
153
+ method: 'POST',
154
+ headers: {
155
+ Authorization: `Bearer ${FLEET_TOKEN}`,
156
+ 'User-Agent': USER_AGENT,
157
+ 'Content-Type': 'application/json',
158
+ },
159
+ signal: AbortSignal.timeout(15_000),
160
+ body: '{}',
161
+ });
162
+ } catch {
163
+ /* best-effort — the request's own TTL still expires it */
164
+ }
165
+ };
166
+
167
+ const enqueueSweep = (job) => {
168
+ if (!job || job.requestedAt === lastSweepAt) return;
169
+ if (job.requestedAt !== sweepAttemptsFor) {
170
+ sweepAttemptsFor = job.requestedAt;
171
+ sweepAttempts = 0;
172
+ }
173
+ lastSweepAt = job.requestedAt;
174
+ // A full sweep is expensive — never stack two. One queued sweep already
175
+ // covers any newer Regenerate click (it reads the repo fresh when it runs).
176
+ // A failed/partial sweep stays recoverable: re-clicking Regenerate always
177
+ // refreshes requestedAt server-side, beating this dedup.
178
+ if (wikiQueue.some((t) => t.type === 'sweep')) return;
179
+ wikiQueue.push({ type: 'sweep' });
180
+ void drainWiki();
181
+ };
182
+ const enqueueReground = (intentId, prUrl, title, dirtiesPages, shas) => {
183
+ if (!intentId || groundedIntents.has(intentId)) return;
184
+ groundedIntents.add(intentId);
185
+ wikiQueue.push({
186
+ type: 'reground',
187
+ intentId,
188
+ prUrl,
189
+ title: title || 'a delivered task',
190
+ // What the PLAN thought this would invalidate. A hint, not the truth —
191
+ // the turn still reads the real changed files; this catches pages whose
192
+ // frontmatter file list has drifted, or that document a concept rather
193
+ // than a directory.
194
+ //
195
+ // SANITIZED AT THE INTAKE, because every entry is server-supplied text
196
+ // that ends up interpolated into a prompt and printed by the drain's
197
+ // narration: a control byte can repaint the console it lands on, a
198
+ // newline can break out of the prompt's own list framing, and an
199
+ // unbounded array of unbounded strings is an unbounded prompt. This is
200
+ // the belt at the intake; the prompt keeps its own fence at the
201
+ // interpolation.
202
+ dirtiesPages: (Array.isArray(dirtiesPages) ? dirtiesPages : [])
203
+ .filter((p) => typeof p === 'string')
204
+ .slice(0, 40)
205
+ .map((p) =>
206
+ p
207
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
208
+ .trim()
209
+ .slice(0, 300)
210
+ )
211
+ .filter(Boolean),
212
+ // THE COMMITS THAT SHIPPED — what changedFilesForShas resolves against.
213
+ // Dropping this here was the whole 0.54.0/0.54.1 defect: the server sent
214
+ // shas on every reground job, this function never stored them, and the
215
+ // drain's `task.shas` was undefined on every job — so the re-ground
216
+ // "revived" on 2026-08-22 retried three times against nothing and gave
217
+ // up, on a console nobody reads, on every single ship.
218
+ shas: Array.isArray(shas) ? shas : [],
219
+ });
220
+ void drainWiki();
221
+ };
222
+
223
+ // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
224
+ // PR can't blow up the prompt. prUrl was already validated before the merge.
225
+ // WHICH FILES A SHIP CHANGED, read from the commits it landed.
226
+ //
227
+ // This asked `gh pr view <prUrl> --json files` until 2026-08-22, and `prUrl`
228
+ // has been null by construction since dispatch was deleted on 2026-08-19 —
229
+ // the server writes null and says so in a comment. Node threw on the null
230
+ // argument, the catch below read it as "gh failed", and the re-ground retried
231
+ // three times and gave up. Every post-ship re-ground for three months did
232
+ // that silently, while the spec said ship re-grounds the wiki.
233
+ //
234
+ // Returns null when it learned NOTHING (no shas, or none of them resolvable),
235
+ // which the caller still treats as retryable — distinct from a ship that
236
+ // genuinely changed no files.
237
+ const changedFilesForShas = (shas) => {
238
+ if (!Array.isArray(shas) || shas.length === 0) return null;
239
+ const files = new Set();
240
+ for (const sha of shas.slice(0, 50)) {
241
+ if (!/^[0-9a-f]{7,40}$/i.test(String(sha))) continue;
242
+ try {
243
+ const out = execFileSync(
244
+ 'git',
245
+ ['show', '--name-only', '--pretty=format:', String(sha)],
246
+ { cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
247
+ );
248
+ for (const line of out.split('\n')) {
249
+ const f = line.trim();
250
+ if (f) files.add(f);
251
+ if (files.size >= 60) break;
252
+ }
253
+ } catch {
254
+ // One unreachable commit is not a failed re-ground — the ship merged
255
+ // to main and the rest of the shas still name real files. Only an
256
+ // EMPTY result is treated as "we learned nothing".
257
+ }
258
+ if (files.size >= 60) break;
259
+ }
260
+ return files.size ? [...files] : null;
261
+ };
262
+ const regroundAttempts = new Map(); // intentId -> gh-failure count
263
+
264
+ async function drainWiki() {
265
+ if (wikiBusy || wikiQueue.length === 0) return;
266
+ /**
267
+ * NOT WHILE THE BOX IS UNDER PRESSURE. A sweep is a CLI reading a whole
268
+ * repository, which is the heaviest turn the daemon runs and the one
269
+ * nobody is waiting on — so it is the first thing to yield.
270
+ *
271
+ * The queue is left INTACT: nothing is claimed, nothing is consumed, and
272
+ * the reconcile loop calls this again on its next poll. The one thing that
273
+ * must not happen is setting `wikiBusy` and returning, which would strand
274
+ * the drain until a restart.
275
+ */
276
+ const hold = admit('churn');
277
+ if (hold) {
278
+ // Said at most every five minutes: this runs on every poll, and a queued
279
+ // sweep can sit through a long stretch of pressure — a line every twenty
280
+ // seconds would be the console restating one unchanged fact all evening.
281
+ if (Date.now() - wikiHoldSaidAt > 5 * 60_000) {
282
+ wikiHoldSaidAt = Date.now();
283
+ note(`${c.cyan('wiki')} ${c.dim(`— holding off: ${hold.reason}`)}`);
284
+ }
285
+ return;
286
+ }
287
+ wikiHoldSaidAt = 0;
288
+ wikiBusy = true;
289
+ // Held for the WHOLE drain: this loop resets the worktree between tasks, and
290
+ // a consult reading it mid-reset sees files vanish under it.
291
+ return withWikiLock(async () => {
292
+ try {
293
+ while (wikiQueue.length) {
294
+ const task = wikiQueue.shift();
295
+ // The vault is plain files — the turn needs no MCP server and no
296
+ // cartographer token; the daemon itself syncs afterwards on the fleet
297
+ // credential.
298
+ // Guard the mkdir: this runs OUTSIDE the per-task try below, so an
299
+ // ENOSPC/EACCES here (disk full is an anticipated prod condition —
300
+ // worktrees + vault history grow) would escape drainWiki as an unhandled
301
+ // rejection and take down the whole daemon mid-work. Skip this sweep on
302
+ // failure instead.
303
+ let vaultDir;
304
+ try {
305
+ vaultDir = vaultDirFor();
306
+ ensureVault(vaultDir);
307
+ } catch (e) {
308
+ warn(`wiki sweep skipped — vault dir unavailable: ${e?.message || e}`);
309
+ continue;
310
+ }
311
+ // Live progress for this turn: a rolling FEED of everything Claude does
312
+ // (thinking, narration, reads, node writes), the file count, and the
313
+ // phase — streamed to the app (throttled; each frame carries the whole
314
+ // recent tail so a dropped POST loses nothing). elapsedSec is the
315
+ // daemon's own clock.
316
+ const mode = task.type === 'sweep' ? 'sweep' : 'reground';
317
+ const startedAt = Date.now();
318
+ let filesRead = 0;
319
+ let phase = 'reading';
320
+ // Distinct vault pages this turn has written. Counted HERE, from the
321
+ // stream, because it is the only place that knows mid-turn: the daemon
322
+ // syncs the vault to the server once, AFTER the turn returns, so a
323
+ // server-side count of "rows touched since the turn began" is zero for
324
+ // the entire writing phase — which is exactly how long the bar needs it.
325
+ // A Set, not a counter: pages get written once and then edited, and
326
+ // three tool calls on one page are one page.
327
+ const pagesSeen = new Set();
328
+ const feed = [];
329
+ const frame = (extra) => ({
330
+ mode,
331
+ phase,
332
+ activity: feed[feed.length - 1] ?? '',
333
+ recent: feed.slice(-24),
334
+ filesRead,
335
+ pagesWritten: pagesSeen.size,
336
+ elapsedSec: Math.round((Date.now() - startedAt) / 1000),
337
+ ...extra,
338
+ });
339
+ const onActivity = (a) => {
340
+ if (a.kind === 'read') filesRead++;
341
+ if (a.kind === 'write') {
342
+ phase = 'writing';
343
+ pagesSeen.add(a.path || a.label);
344
+ }
345
+ // Collapse runs of bare "thinking…" so the feed doesn't fill with it.
346
+ // Keyed on the SHARED constant (runtimeEvents.mjs), not on the literal: the
347
+ // labels being compared here are the ones claudeStream.mjs now builds from
348
+ // that constant, so a reworded marker would leave this comparison
349
+ // matching nothing and the 48-slot feed filling with the repeat — the
350
+ // exact noise this line exists to stop, and silent, because a collapse
351
+ // that stops collapsing fails no test.
352
+ if (!(a.label === THINK_MARKER && feed[feed.length - 1] === THINK_MARKER)) {
353
+ feed.push(a.label);
354
+ if (feed.length > 48) feed.shift();
355
+ }
356
+ void postWikiProgress(frame());
357
+ };
358
+ // Heartbeat: re-send the current frame every 5s even with no new stream
359
+ // event, so the app's freshness window never lapses during a long
360
+ // thinking block or slow tool (which emit nothing until they finish) —
361
+ // otherwise the cover would flap back to the empty state mid-sweep.
362
+ let heartbeat = null;
363
+ /**
364
+ * Did THIS sweep finish? Read by the `finally` below, which owns the
365
+ * retry decision for every way out of this block.
366
+ *
367
+ * It used to be decided inline on the one branch where the turn
368
+ * returned without its sentinel — so the two OTHER ways a sweep fails,
369
+ * `pickRuntimeFor` finding no CLI and the catch around the whole turn,
370
+ * left the request pinned and never retried at all. Those are the
371
+ * failures most worth retrying: a missing runtime is fixed by
372
+ * installing one, and a thrown turn is exactly the transient case.
373
+ */
374
+ let sweepCompleted = false;
375
+ try {
376
+ // Immediate frame so the cover shows the daemon feed right away (the
377
+ // "reading your code" phase), not a static message, while Claude warms up.
378
+ feed.push('starting…');
379
+ await postWikiProgress(frame(), true);
380
+ heartbeat = setInterval(() => void postWikiProgress(frame(), true), 5000);
381
+ if (!existsSync(wikiWt)) {
382
+ try {
383
+ git(['worktree', 'add', '--detach', wikiWt, getBaseRef()], repoRoot);
384
+ } catch {
385
+ git(['worktree', 'prune'], repoRoot);
386
+ git(['worktree', 'add', '--detach', wikiWt, getBaseRef()], repoRoot);
387
+ }
388
+ }
389
+ resetWorktree(wikiWt, getBaseRef());
390
+ let sha = '';
391
+ try {
392
+ sha = git(['rev-parse', 'HEAD'], wikiWt);
393
+ } catch {
394
+ /* detached/no HEAD — still writes the map, just ungrounded */
395
+ }
396
+ // Sync the vault after the turn regardless of the sentinel: a died
397
+ // sweep's partial pages still persist (merge, no prune) — only a
398
+ // COMPLETED sweep finalizes, so an interrupted one can't erase pages.
399
+ const runSync = async (finalize) => {
400
+ try {
401
+ const r = await syncVault({
402
+ dir: vaultDir,
403
+ url: WIKI_VAULT_URL,
404
+ token: FLEET_TOKEN,
405
+ userAgent: USER_AGENT,
406
+ finalize,
407
+ groundedAtSha: sha || undefined,
408
+ // Powers the GitHub blob links behind every cited file path.
409
+ repoFullName: originSlug(repoRoot) || undefined,
410
+ warn,
411
+ // Redact synced secrets a page may have quoted from the repo.
412
+ scrub: envScrub,
413
+ });
414
+ if (r.skipped) note(`${c.cyan('wiki')} ${c.dim('— vault unchanged, nothing to sync')}`);
415
+ else
416
+ ok(
417
+ `${c.cyan('wiki')} ${c.dim(
418
+ `— synced ${r.uploaded} page${r.uploaded === 1 ? '' : 's'} (${r.pages} total${r.deleted ? `, ${r.deleted} removed` : ''})`
419
+ )}`
420
+ );
421
+ } catch (e) {
422
+ warn(`wiki vault sync failed: ${e.message} — pages stay local; next turn retries`);
423
+ }
424
+ };
425
+ // The cartographer needs to read the repo and write ONLY the vault —
426
+ // a narrower promise than "build", so it is its own profile.
427
+ const wikiRt = pickRuntimeFor('wiki');
428
+ if (!wikiRt) {
429
+ warn('wiki generation skipped — no installed CLI can run a vault-scoped turn');
430
+ return;
431
+ }
432
+ const wikiLabel = RUNTIMES[wikiRt].label;
433
+ if (task.type === 'sweep') {
434
+ sweepAttempts++;
435
+ note(`${c.cyan('wiki')} ${c.dim(`— regenerating: your ${wikiLabel} is reading the repo…`)}`);
436
+ const out = await runTurn({
437
+ prompt: WIKI_KICKOFF(sha, vaultDir),
438
+ resume: false,
439
+ system: SYSTEM_WIKI(vaultDir),
440
+ cwd: wikiWt,
441
+ profile: 'wiki',
442
+ vaultDir,
443
+ runtime: wikiRt,
444
+ label: c.cyan('[wiki]'),
445
+ streamJson: true,
446
+ onActivity,
447
+ // FREE SKILLS, off a turn that was running anyway. This stream is
448
+ // already parsed and its init event already carries the CLI's own
449
+ // resolved skill set — the same fact a tab turn teaches — so the
450
+ // only thing missing was the handler. The wiki turn runs in a
451
+ // detached worktree of THIS repo, so its `.claude/skills` and the
452
+ // machine's personal ones resolve identically to a tab's.
453
+ onInit: (i) => {
454
+ recordSkills(i.skills);
455
+ recordMcpServers(i.mcpServers);
456
+ },
457
+ onSpawn: (ch) => {
458
+ wikiChild = ch;
459
+ },
460
+ });
461
+ const complete = sawSentinel(out, 'WIKI_DONE');
462
+ if (complete) {
463
+ sweepCompleted = true;
464
+ ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
465
+ } else {
466
+ warn('wiki sweep ended without WIKI_DONE — partial pages synced');
467
+ }
468
+ await runSync(complete);
469
+ } else {
470
+ const files = changedFilesForShas(task.shas);
471
+ if (files === null) {
472
+ // gh failed (network/auth) — retry via the durable job a couple
473
+ // of times before consuming it, so a transient outage doesn't
474
+ // silently drop the re-ground.
475
+ const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
476
+ regroundAttempts.set(task.intentId, n);
477
+ if (n < 3) {
478
+ warn(`wiki re-ground for "${task.title}": no changed files resolved — will retry (${n}/3)`);
479
+ groundedIntents.delete(task.intentId); // let the roster re-offer it
480
+ continue;
481
+ }
482
+ warn(`wiki re-ground for "${task.title}": could not resolve changed files ${n} times — giving up (heals on the next full sweep)`);
483
+ } else if (files.length === 0) {
484
+ note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
485
+ } else {
486
+ note(`${c.cyan('wiki')} ${c.dim(`— re-grounding after "${task.title}"…`)}`);
487
+ const out = await runTurn({
488
+ prompt: REGROUND_KICKOFF({
489
+ sha,
490
+ title: task.title,
491
+ files,
492
+ vaultDir,
493
+ predictedPages: task.dirtiesPages ?? [],
494
+ }),
495
+ resume: false,
496
+ system: SYSTEM_REGROUND(vaultDir),
497
+ cwd: wikiWt,
498
+ profile: 'wiki',
499
+ vaultDir,
500
+ runtime: wikiRt,
501
+ label: c.cyan('[wiki]'),
502
+ streamJson: true,
503
+ onActivity,
504
+ // Same free harvest as the sweep above.
505
+ onInit: (i) => {
506
+ recordSkills(i.skills);
507
+ recordMcpServers(i.mcpServers);
508
+ },
509
+ onSpawn: (ch) => {
510
+ wikiChild = ch;
511
+ },
512
+ });
513
+ if (sawSentinel(out, 'REGROUND_DONE'))
514
+ ok(`${c.cyan('wiki')} ${c.dim(`— vault updated for "${task.title}".`)}`);
515
+ else warn(`wiki re-ground for "${task.title}" ended without REGROUND_DONE.`);
516
+ await runSync(false);
517
+ }
518
+ // Consume the durable job: attempted = done (success or not — the
519
+ // sync is idempotent and a failed turn heals on the next full
520
+ // sweep), so a failing re-ground can't loop-burn quota. Only a
521
+ // crash BEFORE this line leaves the job listed for a retry.
522
+ regroundAttempts.delete(task.intentId);
523
+ await postToFleet(REGROUND_DONE_URL, { taskId: task.intentId });
524
+ // The dedup was DAEMON-LIFETIME, which wedged a reopened card: its
525
+ // second ship writes a fresh durable job, this Set still holds the
526
+ // taskId, enqueueReground refuses it on every poll forever, and
527
+ // the never-consumed job churns the wiki-writer lease until a
528
+ // restart. The job is consumed now, so the guard has done its work;
529
+ // a FUTURE ship of the same card is new work, not a duplicate.
530
+ groundedIntents.delete(task.intentId);
531
+ }
532
+ } catch (e) {
533
+ warn(`wiki ${task.type} failed: ${e.message}`);
534
+ } finally {
535
+ wikiChild = null;
536
+ if (heartbeat) clearInterval(heartbeat);
537
+ /**
538
+ * THE RETRY DECISION, IN ONE PLACE, FOR EVERY WAY OUT OF THIS BLOCK.
539
+ *
540
+ * Deciding it inline on the no-sentinel branch covered one of the
541
+ * three ways a sweep fails and silently declined the other two. Here
542
+ * it covers the thrown turn and the no-runtime return as well, which
543
+ * are the two most worth retrying.
544
+ *
545
+ * A successful sweep resets the budget. A failed one with budget left
546
+ * clears `lastSweepAt` so the roster's next offer of the SAME request
547
+ * is accepted — partial pages are synced without pruning either way,
548
+ * so a retry resumes rather than starting over. A failed one with the
549
+ * budget spent tells the SERVER, because the counter is process-local
550
+ * and the request it bounds is durable for 24 hours.
551
+ */
552
+ if (task.type === 'sweep') {
553
+ if (sweepCompleted) {
554
+ sweepAttempts = 0;
555
+ } else if (sweepAttempts < MAX_SWEEP_ATTEMPTS) {
556
+ lastSweepAt = null;
557
+ warn(`wiki sweep failed — retrying (${sweepAttempts}/${MAX_SWEEP_ATTEMPTS})`);
558
+ } else {
559
+ warn(
560
+ `wiki sweep failed ${sweepAttempts} times — giving up on this request; press Regenerate to try again.`
561
+ );
562
+ await postWikiAbandoned();
563
+ }
564
+ }
565
+ // Terminal frame so the app cover clears promptly (don't wait for the
566
+ // freshness window to lapse). force-sent past the throttle.
567
+ await postWikiProgress(frame({ done: true }), true);
568
+ // Safety net: the wiki turn is read-only on the repo by CONTRACT, but
569
+ // permission enforcement is a curated tool list, not a path jail —
570
+ // discard anything a confused turn wrote to the worktree so it can
571
+ // never leak into a later turn or a push.
572
+ try {
573
+ resetWorktree(wikiWt, getBaseRef());
574
+ } catch {
575
+ /* best-effort */
576
+ }
577
+ }
578
+ }
579
+ } finally {
580
+ wikiBusy = false;
581
+ }
582
+ });
583
+ }
584
+
585
+ /** The roster's wiki work, taken each poll. */
586
+ const onRoster = (roster) => {
587
+ // Living-wiki work (runs under its own minted wiki token — no agent
588
+ // needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
589
+ // deliveries whose re-ground never ran (e.g. we restarted between merge and
590
+ // turn) until we report reground-done; the bare drain flushes anything
591
+ // whose earlier mint failed.
592
+ enqueueSweep(roster.codeMapJob);
593
+ for (const j of roster.regroundJobs ?? []) {
594
+ const rid = j && (j.taskId ?? j.intentId); // new name first, old as fallback
595
+ if (!j || typeof rid !== 'string') continue; // a null element would throw + wedge the loop
596
+ enqueueReground(rid, j.prUrl, j.title, j.dirtiesPages, j.shas);
597
+ }
598
+ void drainWiki();
599
+ };
600
+
601
+ return {
602
+ onRoster,
603
+ enqueueSweep,
604
+ enqueueReground,
605
+ drainWiki,
606
+ /** The vault is keyed by the project the roster names (see `vaultDirFor`). */
607
+ setProjectId: (id) => {
608
+ wikiProjectId = id;
609
+ },
610
+ /** Whether this lane is working — one of `machineBusy`'s answers. */
611
+ busy: () => wikiBusy,
612
+ /**
613
+ * The cartographer's share of the machine's live CLI count — what
614
+ * fleet.mjs hands the work manager as `extraLiveTurns`.
615
+ *
616
+ * `wikiBusy` COUNTS, not just the live child, and that is the wiki lane's
617
+ * version of the reservation `admission.mjs` describes: the drain sets the
618
+ * flag the moment it is admitted and the CLI does not exist until several
619
+ * awaits later, so counting the child alone left a hole exactly wide enough
620
+ * for the other lanes to spend the slot this one had already taken. The
621
+ * drain runs at most one CLI at a time, so the flag and the child are the
622
+ * same one turn and this can never double-count.
623
+ */
624
+ liveTurns: () => (wikiBusy || wikiChild ? 1 : 0),
625
+ /** Teardown: a mid-sweep cartographer dies with the daemon. */
626
+ kill: () => {
627
+ wikiChild?.kill('SIGKILL');
628
+ },
629
+ };
630
+ }