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.
- package/bin/cli.mjs +87 -474
- package/bin/lib/admission.mjs +19 -3
- package/bin/lib/agentPlan.mjs +2 -2
- package/bin/lib/agentTaskKinds.mjs +93 -0
- package/bin/lib/agySessions.mjs +158 -0
- package/bin/lib/artifactPolicy.mjs +44 -0
- package/bin/lib/artifactPreview.mjs +52 -0
- package/bin/lib/artifactReporter.mjs +163 -0
- package/bin/lib/artifacts.mjs +47 -227
- package/bin/lib/authReject.mjs +16 -4
- package/bin/lib/authproxy.mjs +47 -304
- package/bin/lib/boxIdentity.mjs +245 -0
- package/bin/lib/childEnv.mjs +11 -9
- package/bin/lib/claude.mjs +14 -851
- package/bin/lib/claudeAuth.mjs +3 -3
- package/bin/lib/claudePosture.mjs +367 -0
- package/bin/lib/claudeSessions.mjs +411 -0
- package/bin/lib/claudeStream.mjs +207 -0
- package/bin/lib/cliLimit.mjs +34 -0
- package/bin/lib/commandSpecs.mjs +173 -0
- package/bin/lib/config.mjs +11 -42
- package/bin/lib/credentialDate.mjs +22 -0
- package/bin/lib/credentials.mjs +21 -26
- package/bin/lib/daemonLogging.mjs +55 -0
- package/bin/lib/daemonState.mjs +64 -0
- package/bin/lib/deploy.mjs +148 -647
- package/bin/lib/deployCheckout.mjs +265 -0
- package/bin/lib/deployConfig.mjs +142 -0
- package/bin/lib/deployRunner.mjs +311 -0
- package/bin/lib/deployWire.mjs +41 -0
- package/bin/lib/desktopContract.mjs +56 -105
- package/bin/lib/env.mjs +49 -484
- package/bin/lib/fleet.mjs +184 -2396
- package/bin/lib/fleetJobs.mjs +120 -0
- package/bin/lib/fleetPost.mjs +40 -0
- package/bin/lib/fleetReports.mjs +392 -0
- package/bin/lib/fleetRoster.mjs +470 -0
- package/bin/lib/fleetStartup.mjs +196 -0
- package/bin/lib/fleetWake.mjs +45 -0
- package/bin/lib/fleetWire.mjs +46 -0
- package/bin/lib/git.mjs +34 -3
- package/bin/lib/gitMergeIdentity.mjs +51 -0
- package/bin/lib/grant.mjs +5 -3
- package/bin/lib/help.mjs +3 -125
- package/bin/lib/holder.mjs +482 -0
- package/bin/lib/instance.mjs +124 -46
- package/bin/lib/knowledge.mjs +16 -799
- package/bin/lib/knowledgeFetch.mjs +38 -0
- package/bin/lib/knowledgeFileSync.mjs +112 -0
- package/bin/lib/knowledgeFiles.mjs +487 -0
- package/bin/lib/knowledgeLibrary.mjs +249 -0
- package/bin/lib/landed.mjs +2 -1
- package/bin/lib/listeners.mjs +99 -141
- package/bin/lib/localSessions.mjs +30 -555
- package/bin/lib/login.mjs +3 -2
- package/bin/lib/loginPath.mjs +3 -5
- package/bin/lib/machineDisconnect.mjs +203 -0
- package/bin/lib/machineEnv.mjs +22 -0
- package/bin/lib/machineLimits.mjs +61 -0
- package/bin/lib/machineListing.mjs +338 -0
- package/bin/lib/machines.mjs +78 -455
- package/bin/lib/machinesCommand.mjs +214 -0
- package/bin/lib/mcp-cli.mjs +2 -1
- package/bin/lib/patchRevert.mjs +60 -0
- package/bin/lib/prWorkflow.mjs +157 -0
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/pressurePolicy.mjs +104 -0
- package/bin/lib/preview.mjs +2 -272
- package/bin/lib/previewFramePolicy.mjs +112 -0
- package/bin/lib/previewGatePolicy.mjs +236 -0
- package/bin/lib/previewRegistry.mjs +235 -0
- package/bin/lib/printable.mjs +2 -2
- package/bin/lib/procRegistry.mjs +28 -18
- package/bin/lib/procScan.mjs +146 -0
- package/bin/lib/processes.mjs +29 -37
- package/bin/lib/projectToolRuntimes.mjs +138 -0
- package/bin/lib/projectTools.mjs +13 -88
- package/bin/lib/prompts.mjs +28 -42
- package/bin/lib/repoPath.mjs +26 -0
- package/bin/lib/resources.mjs +7 -69
- package/bin/lib/resumeLost.mjs +107 -0
- package/bin/lib/runTurn.mjs +324 -0
- package/bin/lib/runtimeAntigravity.mjs +230 -0
- package/bin/lib/runtimeCapabilities.mjs +334 -0
- package/bin/lib/runtimeClaude.mjs +126 -0
- package/bin/lib/runtimeCodex.mjs +217 -0
- package/bin/lib/runtimeDetection.mjs +121 -0
- package/bin/lib/runtimeEvents.mjs +482 -0
- package/bin/lib/runtimeOfferings.mjs +4 -1
- package/bin/lib/runtimes.mjs +42 -1402
- package/bin/lib/safeFileName.mjs +74 -0
- package/bin/lib/shipMerge.mjs +33 -2
- package/bin/lib/standDownExit.mjs +181 -0
- package/bin/lib/standDownGate.mjs +48 -0
- package/bin/lib/startProject.mjs +279 -0
- package/bin/lib/trace.mjs +3 -3
- package/bin/lib/turnLock.mjs +68 -0
- package/bin/lib/turnProfile.mjs +77 -0
- package/bin/lib/uninstall.mjs +55 -45
- package/bin/lib/uninstallCopies.mjs +112 -0
- package/bin/lib/update.mjs +30 -17
- package/bin/lib/uplinkScrub.mjs +265 -0
- package/bin/lib/vault.mjs +22 -151
- package/bin/lib/vaultDiff.mjs +183 -0
- package/bin/lib/versionOrder.mjs +42 -0
- package/bin/lib/views.mjs +35 -12
- package/bin/lib/wikiRunner.mjs +630 -0
- package/bin/lib/work.mjs +188 -3037
- package/bin/lib/workAdoptCarry.mjs +94 -0
- package/bin/lib/workAgentCheck.mjs +278 -0
- package/bin/lib/workAgentMerges.mjs +54 -142
- package/bin/lib/workAgentPlans.mjs +14 -12
- package/bin/lib/workAgentPrecheck.mjs +393 -0
- package/bin/lib/workAgentPublish.mjs +177 -0
- package/bin/lib/workAgentReview.mjs +8 -666
- package/bin/lib/workAgentTurnBegun.mjs +109 -0
- package/bin/lib/workAgentTurnExecution.mjs +604 -0
- package/bin/lib/workAgentTurnOutcome.mjs +207 -0
- package/bin/lib/workAgentTurnReports.mjs +189 -0
- package/bin/lib/workAgentTurns.mjs +92 -897
- package/bin/lib/workAttachments.mjs +93 -0
- package/bin/lib/workBrain.mjs +53 -0
- package/bin/lib/workChildren.mjs +110 -0
- package/bin/lib/workCommandAudit.mjs +62 -0
- package/bin/lib/workDiffs.mjs +2 -1
- package/bin/lib/workNarration.mjs +152 -0
- package/bin/lib/workPlaceReports.mjs +426 -0
- package/bin/lib/workPlaces.mjs +228 -0
- package/bin/lib/workPreviews.mjs +31 -9
- package/bin/lib/workProcesses.mjs +4 -5
- package/bin/lib/workPullRequests.mjs +52 -126
- package/bin/lib/workReportQueue.mjs +254 -0
- package/bin/lib/workRetire.mjs +185 -0
- package/bin/lib/workSessionGroups.mjs +166 -0
- package/bin/lib/workSessionRuntime.mjs +164 -0
- package/bin/lib/workSessionTokens.mjs +68 -0
- package/bin/lib/workSessionTurns.mjs +892 -0
- package/bin/lib/workShip.mjs +9 -29
- package/bin/lib/workToolLog.mjs +76 -0
- package/package.json +3 -2
- 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
|
+
}
|