flowviant 0.103.0 → 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 (140) 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 +183 -2383
  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 +2 -2
  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/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,94 @@
1
+ /**
2
+ * ADOPTION'S DIRTY CARRY — a terminal checkout's uncommitted state copied,
3
+ * read-only, into the fresh worktree a tab adopts it into.
4
+ *
5
+ * Split out of work.mjs (2026-09-26, SOLID F037). It is git plumbing with a
6
+ * promise of its own (the source is never written, a problem becomes one line
7
+ * for the agent to say), reached once, from the session lane's adopt path.
8
+ */
9
+ import { execFileSync } from 'node:child_process';
10
+ import { cpSync, lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { git, gitRaw, splitNul } from './git.mjs';
13
+
14
+ export function createAdoptCarry({ sessionMetaPath }) {
15
+ /**
16
+ * Carry a terminal checkout's DIRTY state into a fresh adopt worktree. The
17
+ * source is strictly READ-ONLY — nothing here writes to it, because it is
18
+ * the human's own checkout and adoption promises to leave it exactly as the
19
+ * closed terminal did. Tracked changes travel as one binary patch staged
20
+ * through the worktree's PRIVATE git dir (invisible to status, dies with the
21
+ * tree); untracked files are copied one by one, skipping anything over 5MB.
22
+ *
23
+ * Returns '' or ONE bracketed line for the turn's prompt: a carry problem is
24
+ * the AGENT's to explain to the user, never a reason to fail the adoption —
25
+ * the conversation is the thing being adopted, and it resumes either way.
26
+ */
27
+ const carryDirtyState = (srcCwd, wt, sessionId) => {
28
+ const problems = [];
29
+ try {
30
+ // A Buffer, not utf8: a `--binary` patch (and a hunk from a non-UTF-8
31
+ // text file) must round-trip byte-exact or the apply corrupts what it
32
+ // carries. 64MB of headroom — a dirtier tree than that fails the read
33
+ // 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'],
37
+ maxBuffer: 64 * 1024 * 1024,
38
+ });
39
+ if (patch.length) {
40
+ const patchPath = sessionMetaPath(wt, 'flowviant-adopt.patch', sessionId);
41
+ if (!patchPath) throw new Error('no private git dir to stage the patch in');
42
+ try {
43
+ writeFileSync(patchPath, patch);
44
+ git(['apply', '--whitespace=nowarn', patchPath], wt);
45
+ } finally {
46
+ try {
47
+ rmSync(patchPath, { force: true });
48
+ } catch {
49
+ /* best-effort — the private git dir dies with the worktree anyway */
50
+ }
51
+ }
52
+ }
53
+ } catch {
54
+ problems.push(
55
+ 'their uncommitted TRACKED changes did not carry over (they are still in the terminal checkout, untouched)'
56
+ );
57
+ }
58
+ try {
59
+ const skipped = [];
60
+ for (const rel of splitNul(
61
+ gitRaw(['ls-files', '--others', '--exclude-standard', '-z'], srcCwd)
62
+ )) {
63
+ try {
64
+ const from = join(srcCwd, rel);
65
+ // lstat, not stat: a symlink is carried as itself, and its own size
66
+ // is what the 5MB budget judges — never the file it points at.
67
+ if (lstatSync(from).size > 5 * 1024 * 1024) {
68
+ skipped.push(rel);
69
+ continue;
70
+ }
71
+ const to = join(wt, rel);
72
+ mkdirSync(dirname(to), { recursive: true });
73
+ cpSync(from, to);
74
+ } catch {
75
+ skipped.push(rel);
76
+ }
77
+ }
78
+ if (skipped.length) {
79
+ problems.push(
80
+ `${skipped.length} untracked file${skipped.length === 1 ? '' : 's'} did not carry (over 5MB or unreadable): ${skipped.slice(0, 5).join(', ')}${skipped.length > 5 ? ', …' : ''}`
81
+ );
82
+ }
83
+ } catch {
84
+ problems.push(
85
+ 'untracked files could not be listed in the terminal checkout, so none were carried'
86
+ );
87
+ }
88
+ return problems.length
89
+ ? `[ADOPTION NOTE from the daemon — tell the user plainly at the start of your reply: ${problems.join('; ')}.]`
90
+ : '';
91
+ };
92
+
93
+ return { carryDirtyState };
94
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * THE PROJECT'S OWN CHECK — the repo's declared command, run in the agent's
3
+ * worktree the moment its queue empties, and its answer posted as a label.
4
+ *
5
+ * Split out of workAgentReview.mjs (SOLID F039): this half changes for
6
+ * SECURITY reasons (what a repo-controlled command may see and reach — the
7
+ * artifact-directory fence, the environment without the machine credential,
8
+ * the process-group kill), the pre-review for MODEL-REPORTING ones. The review
9
+ * entry beat sequences the two and owns neither.
10
+ */
11
+ import { readFileSync, lstatSync, rmSync, readdirSync } from 'node:fs';
12
+ import { spawn } from 'node:child_process';
13
+ import { join } from 'node:path';
14
+ import { FLEET_URL } from './config.mjs';
15
+ import { git } from './git.mjs';
16
+ import { warn } from './ui.mjs';
17
+ import { ARTIFACT_DIR, artifactTypeFor } from './artifacts.mjs';
18
+ import { scrub as envScrub } from './uplinkScrub.mjs';
19
+ import { withoutMachineCredentials } from './machineEnv.mjs';
20
+ import { fleetEndpoint } from './fleetWire.mjs';
21
+ import { workLanesStopped } from './standDownGate.mjs';
22
+
23
+ /**
24
+ * CLEAR THE ARTIFACTS DIRECTORY OF ANYTHING THAT IS NOT AN ARTIFACT, BEFORE
25
+ * THE PROJECT'S CHECK RUNS OVER THE WORKTREE (2026-09-24).
26
+ *
27
+ * A design or research turn may write under `.flowviant/artifacts/` and
28
+ * nowhere else — that posture's whole safety claim is that such a card
29
+ * changes no repository file. But the check is the repo's own command, run
30
+ * unattended in the same worktree the moment the queue empties, and a test
31
+ * runner's DEFAULT discovery reaches into that directory: vitest collected and
32
+ * ran `.flowviant/artifacts/pwn.test.js` with no include override. So a card
33
+ * steered by text it read could plant a test and have the machine execute it,
34
+ * before any person looked — and the directory is git-excluded, so the file
35
+ * never appears in the diff a reviewer reads.
36
+ *
37
+ * What an artifact IS is already a closed list (`ARTIFACT_TYPES`), and the
38
+ * scan only ever shows TOP-LEVEL regular files: anything else there is
39
+ * nothing the product will show, so removing it before the check costs
40
+ * nothing a person could see. Symlinks and subdirectories go whole; a
41
+ * `.flowviant` that is not a real directory is left alone (that is the
42
+ * repository's own content, not something a turn wrote). Returns the names
43
+ * removed, for the log line.
44
+ */
45
+ export function clearNonArtifacts(wt) {
46
+ const removed = [];
47
+ try {
48
+ const parent = lstatSync(join(wt, '.flowviant'));
49
+ if (parent.isSymbolicLink() || !parent.isDirectory()) return removed;
50
+ const dir = join(wt, ARTIFACT_DIR);
51
+ const st = lstatSync(dir);
52
+ if (st.isSymbolicLink() || !st.isDirectory()) {
53
+ rmSync(dir, { force: true, recursive: false });
54
+ removed.push(ARTIFACT_DIR);
55
+ return removed;
56
+ }
57
+ for (const name of readdirSync(dir)) {
58
+ const p = join(dir, name);
59
+ let e;
60
+ try {
61
+ e = lstatSync(p);
62
+ } catch {
63
+ continue; // vanished between the list and the stat
64
+ }
65
+ const keep = e.isFile() && !name.startsWith('.') && artifactTypeFor(name) !== null;
66
+ if (keep) continue;
67
+ try {
68
+ rmSync(p, { recursive: true, force: true });
69
+ removed.push(name);
70
+ } catch {
71
+ /* best-effort — reported by omission */
72
+ }
73
+ }
74
+ } catch {
75
+ /* no artifacts directory — the ordinary case */
76
+ }
77
+ return removed;
78
+ }
79
+
80
+ /**
81
+ * THE ENVIRONMENT THE PROJECT'S CHECK RUNS UNDER: the daemon's own, minus the
82
+ * machine credential — the same rule `cliEnv` (runTurn.mjs) applies to a turn,
83
+ * imported from its one home (machineEnv.mjs) rather than from the CLI
84
+ * module, so the check never depends on the CLI module's spawn helpers. Nothing a check runs needs the credential the daemon
85
+ * authenticates with; everything else is what the agent's own turn saw when it
86
+ * ran the same tests.
87
+ */
88
+ export function checkEnv(env = process.env) {
89
+ return withoutMachineCredentials(env);
90
+ }
91
+
92
+ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, groupKillChildren }) {
93
+ const AGENT_CHECK_DONE_URL = fleetEndpoint('agent-check-done', FLEET_URL);
94
+
95
+ // ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
96
+ //
97
+ // The check runs in the agent's own worktree the moment its queue empties, so
98
+ // a reviewer knows before they start reading whether they are reviewing
99
+ // working code. It LABELS the review row; it never blocks it.
100
+ //
101
+ // IT IS THE REPO'S COMMAND, DECLARED IN THE REPO. `.flowviant/check.json`,
102
+ // beside `deploy.json`, because a check travels with the code and changes
103
+ // with it — a setting in the app would go stale the first time somebody
104
+ // renamed a script. An absent file is a MEASURED answer ('none'), not a nag:
105
+ // plenty of projects have no single command that means "is this alright".
106
+ //
107
+ // It runs through a shell, and that is no wider than what already happens in
108
+ // that directory: every turn in this worktree spawns a CLI with build
109
+ // permissions, so a repository that can run arbitrary code during a turn can
110
+ // run it here too. What this is NOT is the deleted dev-run supervisor —
111
+ // nothing here resolves a command, guesses a stack, or starts a server.
112
+ const CHECK_TIMEOUT_MS = 10 * 60_000;
113
+ const CHECK_OUTPUT_CAP = 4000;
114
+
115
+ const readCheckCommand = () => {
116
+ try {
117
+ const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
118
+ const cfg = JSON.parse(raw);
119
+ const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
120
+ return cmd ? cmd.slice(0, 500) : null;
121
+ } catch {
122
+ return null; // absent, unreadable or not JSON — all mean "no check"
123
+ }
124
+ };
125
+
126
+ const postCheck = async (body) => {
127
+ // Same bounded retry as postAgentPlan — see postBestEffort. On the
128
+ // final failure the row simply keeps its previous answer, which is
129
+ // null the first time.
130
+ await postBestEffort(AGENT_CHECK_DONE_URL, body);
131
+ };
132
+
133
+ const runCheck = async (agentId, wt) => {
134
+ const cmd = readCheckCommand();
135
+ const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
136
+ if (!cmd) {
137
+ await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
138
+ return;
139
+ }
140
+ const cleared = clearNonArtifacts(wt);
141
+ if (cleared.length)
142
+ warn(
143
+ `agent ${agentId}: removed ${cleared.length} non-artifact file(s) from ${ARTIFACT_DIR} before the check — ${cleared.slice(0, 5).join(', ')}`
144
+ );
145
+ const out = await new Promise((resolve) => {
146
+ let text = '';
147
+ let done = false;
148
+ const finish = (status) => {
149
+ if (done) return;
150
+ done = true;
151
+ // A check the teardown killed never reports (standDownGate.mjs): the
152
+ // answer stays pending, as `process.exit` left it before the drain.
153
+ if (workLanesStopped()) return;
154
+ resolve({ status, text });
155
+ };
156
+ // After the teardown no check starts; this promise stays pending.
157
+ if (workLanesStopped()) return;
158
+ let child;
159
+ try {
160
+ // DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
161
+ // always a shell that spawns the real runner, and signalling the shell
162
+ // alone leaves the runner holding the worktree — and this place's
163
+ // WRITER lock — for as long as it likes.
164
+ //
165
+ // AND WITHOUT THE MACHINE CREDENTIAL. The check is the repo's command
166
+ // run with nobody watching, and it inherited `FLOWVIANT_MACHINE_TOKEN`
167
+ // — so anything the check executes (a test a turn wrote, a
168
+ // dependency's postinstall) could read the credential the whole
169
+ // machine authenticates with. It is `checkEnv()`, the environment the
170
+ // agent's own turn ran under (`cliEnv`'s rule), NOT `childEnv`'s
171
+ // allowlist: the agent runs these same tests in its turn, and a check
172
+ // stripped of `JAVA_HOME`, `DATABASE_URL` or the rest of the operator's
173
+ // shell would FAIL where the agent's own run passed — a "Check failed"
174
+ // the product manufactured and then relayed as the project's verdict.
175
+ // The planted-file path is closed by `clearNonArtifacts` above.
176
+ child = spawn(cmd, {
177
+ cwd: wt,
178
+ shell: true,
179
+ detached: true,
180
+ stdio: ['ignore', 'pipe', 'pipe'],
181
+ env: checkEnv(),
182
+ });
183
+ /**
184
+ * …AND IT IS TRACKED, so a stop or a takeover takes it with them.
185
+ *
186
+ * A check is a full test or build run in the agent's worktree, and it
187
+ * was the one long-lived child the daemon spawned without telling its
188
+ * own teardown about it. `shutdownWork` signalled the CLI children and
189
+ * left this one — so restarting the daemon, or a same-repo takeover,
190
+ * orphaned a running test suite inside a worktree the sweep may then
191
+ * try to remove. The ten-minute timer would eventually kill it, but by
192
+ * then it belongs to no daemon and nothing on any surface names it.
193
+ *
194
+ * Registered for a GROUP kill: with `shell: true` the child is
195
+ * `/bin/sh`, and signalling it leaves the runner it started behind —
196
+ * which is the process actually holding the worktree. See
197
+ * `groupKillChildren` for why this one is exempt from the
198
+ * never-signal-the-group rule.
199
+ */
200
+ workChildren.set(child, agentId);
201
+ groupKillChildren.add(child);
202
+ } catch (e) {
203
+ // TEXT BEFORE FINISH: `finish` captures `text` by value into the
204
+ // resolved object, so assigning afterwards threw the spawn error away
205
+ // and the surface showed an empty failure.
206
+ text = String(e?.message || e);
207
+ finish('failed');
208
+ return;
209
+ }
210
+ /**
211
+ * The TAIL, not the head: a failing check says why at the end. And
212
+ * SCRUBBED BEFORE IT IS CUT, which is the order that matters.
213
+ *
214
+ * It used to slice first: `text = (text + buf).slice(-CAP)`, with a
215
+ * single `envScrub` at the very end. `envScrub` replaces EXACT full
216
+ * values, so any credential straddling either boundary — the rolling
217
+ * window's, or a chunk's — was already cut in half by the time it was
218
+ * looked at, matched nothing, and the surviving tail was written to
219
+ * `agent.checkOutput` and shown to every member of the project. A failing
220
+ * integration test that dumps its environment is an ordinary way to reach
221
+ * that, and the partial is enough where the prefix of the key is a
222
+ * publicly known constant.
223
+ *
224
+ * Scrubbing on every chunk fixes both straddles at once: the accumulated
225
+ * text always holds the previous kept tail plus the whole new chunk, so a
226
+ * value split across chunks is whole here, and a value near the window
227
+ * edge is redacted before anything is discarded. Bounded work — the
228
+ * string is never longer than the cap plus one chunk.
229
+ *
230
+ * The one case it cannot cover is a secret LONGER than the cap itself,
231
+ * which can never sit in the window whole. The final scrub below stays as
232
+ * the second pass over what actually ships.
233
+ */
234
+ const keep = (buf) => {
235
+ text = envScrub(text + buf.toString()).slice(-CHECK_OUTPUT_CAP);
236
+ };
237
+ child.stdout?.on('data', keep);
238
+ child.stderr?.on('data', keep);
239
+ const timer = setTimeout(() => {
240
+ try {
241
+ // The GROUP, not the child: killing the shell leaves whatever it
242
+ // started running, which is the thing actually taking ten minutes.
243
+ process.kill(-child.pid, 'SIGKILL');
244
+ } catch {
245
+ try {
246
+ child.kill('SIGKILL');
247
+ } catch {
248
+ /* already gone */
249
+ }
250
+ }
251
+ text += '\n[flowviant] the check ran past ten minutes and was stopped';
252
+ // RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
253
+ // hangs: if the group is already gone the event never arrives, and this
254
+ // promise holds the place's writer lock forever.
255
+ finish('failed');
256
+ }, CHECK_TIMEOUT_MS);
257
+ child.on('error', (e) => {
258
+ clearTimeout(timer);
259
+ workChildren.delete(child);
260
+ text += String(e?.message || e);
261
+ finish('failed');
262
+ });
263
+ child.on('close', (code) => {
264
+ clearTimeout(timer);
265
+ workChildren.delete(child);
266
+ finish(code === 0 ? 'passed' : 'failed');
267
+ });
268
+ });
269
+ await postCheck({
270
+ agentId,
271
+ status: out.status,
272
+ output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
273
+ ...(headSha ? { headSha } : {}),
274
+ });
275
+ };
276
+
277
+ return { runCheck };
278
+ }
@@ -1,5 +1,5 @@
1
1
  /** Agent merge claims, pull-request approval, and branch cleanup. */
2
- import { ghFirstLine, PR_URL_RE } from './workPullRequests.mjs';
2
+ import { ensureOpenPr, ghFirstLine, ghReady, mergeAndVerifyTip, PR_URL_RE } from './prWorkflow.mjs';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { join } from 'node:path';
@@ -7,8 +7,10 @@ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mj
7
7
  import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
8
8
  import { isPublishRef, publishPushArgs, publishDeleteArgs, publishErrorText } from './agentPublish.mjs';
9
9
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
10
+ import { gitMergeIn } from './gitMergeIdentity.mjs';
10
11
  import { warn } from './ui.mjs';
11
- import { scrub as envScrub } from './env.mjs';
12
+ import { scrub as envScrub } from './uplinkScrub.mjs';
13
+ import { fleetEndpoint } from './fleetWire.mjs';
12
14
 
13
15
  export function createWorkAgentMerges({
14
16
  repoRoot,
@@ -19,12 +21,11 @@ export function createWorkAgentMerges({
19
21
  runReviewEntry,
20
22
  onRepoChanged,
21
23
  landed,
22
- state,
24
+ agentRemoteAt,
25
+ agentPublished,
23
26
  }) {
24
- const AGENT_MERGE_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-claim');
25
- const AGENT_MERGE_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-merge-done');
26
-
27
- const { agentRemoteAt, agentPublished } = state;
27
+ const AGENT_MERGE_CLAIM_URL = fleetEndpoint('agent-merge-claim', FLEET_URL);
28
+ const AGENT_MERGE_DONE_URL = fleetEndpoint('agent-merge-done', FLEET_URL);
28
29
 
29
30
  // ── THE MERGE ──────────────────────────────────────────────────────────────
30
31
  //
@@ -75,31 +76,9 @@ export function createWorkAgentMerges({
75
76
  }
76
77
  };
77
78
 
78
- /**
79
- * A merge COMMIT needs a git identity and the machine may have none. Prefer
80
- * the operator's own config; fall back to the daemon's, the same fallback
81
- * ship's merge keeps, so a bare machine does not fail the fold with
82
- * "Please tell me who you are".
83
- */
84
- const gitMerge = (args, cwd) => {
85
- let idEnv = null;
86
- try {
87
- git(['config', 'user.email'], repoRoot);
88
- } catch {
89
- idEnv = {
90
- GIT_AUTHOR_NAME: 'Flowviant',
91
- GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
92
- GIT_COMMITTER_NAME: 'Flowviant',
93
- GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
94
- };
95
- }
96
- return execFileSync('git', args, {
97
- cwd,
98
- encoding: 'utf8',
99
- stdio: ['ignore', 'pipe', 'pipe'],
100
- ...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
101
- });
102
- };
79
+ // A merge COMMIT needs an identity the machine may not have; the probe and
80
+ // the daemon's fallback live in gitMergeIdentity.mjs.
81
+ const gitMerge = gitMergeIn(repoRoot);
103
82
 
104
83
  const runAgentMerge = async (job) => {
105
84
  const agentId = String(job.agentId);
@@ -221,34 +200,23 @@ export function createWorkAgentMerges({
221
200
  * agent approval failed at the push and came back to review carrying
222
201
  * git's refusal, forever.
223
202
  *
224
- * PUSH, CREATE, MERGE, in that order, and each step's reasoning is the
225
- * session PR job's: push first because GitHub merges the REMOTE tip;
226
- * `--fill` titles from the branch's own commits so nothing is invented;
227
- * `--merge` and never squash, because the cards' receipts are commit
228
- * shas and a squash rewrites them off base, orphaning every receipt and
229
- * blinding the landed observer's trailer read. Adopt only an OPEN PR:
230
- * gh's branch finder falls back to the most recent merged one, and
231
- * adopting a dead PR would report success over work that never moves.
203
+ * PUSH, then the shared GitHub rules (`prWorkflow.mjs`, the same ones
204
+ * the session PR job runs): adopt only an OPEN PR into this project's
205
+ * base or create one with `--fill`, merge with `--merge`, and report ok
206
+ * only once the tip is measured on base. What is the AGENT's stays
207
+ * here: which ref is the head, the leased push, and every sentence.
232
208
  */
233
209
  if (job.prMode) {
234
- try {
235
- // EVERY `gh` CALL IS TIMED OUT. `execFileSync` blocks the daemon's
236
- // whole event loop, and this one runs INSIDE the place writer lock —
237
- // so a `gh` that hangs (an expired token prompting, a network black
238
- // hole, a hung credential helper) stops every turn on the machine,
239
- // not merely this merge, and holds the lock while it does.
240
- execFileSync('gh', ['auth', 'status'], {
241
- stdio: ['ignore', 'pipe', 'pipe'],
242
- timeout: 20_000,
243
- });
244
- } catch (e) {
210
+ // EVERY `gh` CALL IS TIMED OUT (prWorkflow.mjs) — this one runs
211
+ // INSIDE the place writer lock.
212
+ const ghDown = ghReady();
213
+ if (ghDown) {
245
214
  await report({
246
215
  agentId,
247
216
  ok: false,
248
- detail:
249
- e?.code === 'ENOENT'
250
- ? 'this project merges through pull requests, and the GitHub CLI (gh) is not installed on this machine'
251
- : `this project merges through pull requests, and gh is not signed in here: ${ghFirstLine(e)}`,
217
+ detail: ghDown.missing
218
+ ? 'this project merges through pull requests, and the GitHub CLI (gh) is not installed on this machine'
219
+ : `this project merges through pull requests, and gh is not signed in here: ${ghDown.error}`,
252
220
  });
253
221
  return;
254
222
  }
@@ -306,102 +274,46 @@ export function createWorkAgentMerges({
306
274
  await report({ agentId, ok: false, detail: envScrub(ghFirstLine(e)) });
307
275
  return;
308
276
  }
309
- let prUrl = null;
310
- try {
311
- const j = JSON.parse(
312
- execFileSync('gh', ['pr', 'view', head, '--json', 'url,state,baseRefName'], {
313
- cwd: repoRoot,
314
- stdio: ['ignore', 'pipe', 'pipe'],
315
- timeout: 30_000,
316
- }).toString()
317
- );
318
- if (j?.state === 'OPEN' && typeof j?.url === 'string') {
319
- // Adoptable only when it points at the project's own base. A PR
320
- // somebody opened by hand against another branch would otherwise
321
- // be merged INTO that branch, and the ok settle would claim work
322
- // reached base that landed somewhere else entirely. Refused only
323
- // on a MEASURED mismatch — an absent field adopts as before.
324
- if (typeof j?.baseRefName === 'string' && j.baseRefName !== prBase) {
325
- await report({
326
- agentId,
327
- ok: false,
328
- detail: `the open pull request for ${head} targets ${j.baseRefName}, not ${prBase} — retarget or close it, then approve again`,
329
- });
330
- return;
331
- }
332
- prUrl = j.url.trim();
333
- }
334
- } catch {
335
- /* no PR for this branch at all — created below */
277
+ const pr = ensureOpenPr(head, prBase, { cwd: repoRoot });
278
+ if (pr.wrongBase) {
279
+ await report({
280
+ agentId,
281
+ ok: false,
282
+ detail: `the open pull request for ${head} targets ${pr.wrongBase}, not ${prBase} — retarget or close it, then approve again`,
283
+ });
284
+ return;
336
285
  }
337
- if (!prUrl) {
338
- try {
339
- const out = execFileSync(
340
- 'gh',
341
- // baseBranchName, not baseRef: gh 422s on a remote-tracking name.
342
- ['pr', 'create', '--head', head, '--base', prBase, '--fill'],
343
- { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000 }
344
- )
345
- .toString()
346
- .trim();
347
- prUrl = out.split('\n').filter(Boolean).pop() ?? null;
348
- } catch (e) {
349
- await report({ agentId, ok: false, detail: ghFirstLine(e) });
350
- return;
351
- }
286
+ if (pr.error) {
287
+ await report({ agentId, ok: false, detail: pr.error });
288
+ return;
352
289
  }
353
- try {
354
- execFileSync('gh', ['pr', 'merge', head, '--merge'], {
355
- cwd: repoRoot,
356
- stdio: ['ignore', 'pipe', 'pipe'],
357
- timeout: 120_000,
290
+ const prUrl = pr.url;
291
+ const linked = prUrl && PR_URL_RE.test(prUrl);
292
+ const merged = await mergeAndVerifyTip(head, {
293
+ cwd: repoRoot,
294
+ tip,
295
+ baseRef,
296
+ repoRoot,
297
+ fetchOrigin: () => gitNet(['fetch', 'origin', '--quiet'], 60_000),
298
+ });
299
+ if (merged.error) {
300
+ await report({
301
+ agentId,
302
+ ok: false,
303
+ detail: linked ? `${merged.error} — the pull request is at ${prUrl}` : merged.error,
358
304
  });
359
- } catch (e) {
360
- const line = ghFirstLine(e);
361
- // Already merged is a SUCCESS: a re-offered job, or somebody merged
362
- // it in the browser. The observer closes the cards either way.
363
- if (!/already merged/i.test(line)) {
364
- await report({
365
- agentId,
366
- ok: false,
367
- detail:
368
- prUrl && PR_URL_RE.test(prUrl) ? `${line} — the pull request is at ${prUrl}` : line,
369
- });
370
- return;
371
- }
372
- }
373
- // VERIFY before reporting ok: modern gh exits 0 on an already-MERGED
374
- // PR, and on a repo with a merge queue or auto-merge it exits 0 after
375
- // ENQUEUEING — in both, "merged" is a claim about the future. The tip
376
- // being an ancestor of base is the fact `ok` asserts, and the server
377
- // closes every delivered card on this sha the moment it hears it — so
378
- // measure it, with one short retry for the fetch racing GitHub's
379
- // merge commit. The same guard the session PR path carries.
380
- const tipOnBase = () => {
381
- try {
382
- git(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
383
- return true;
384
- } catch {
385
- return false;
386
- }
387
- };
388
- let landedOnBase = false;
389
- for (let attempt = 0; attempt < 2 && !landedOnBase; attempt++) {
390
- if (attempt > 0) await new Promise((r) => setTimeout(r, 2000));
391
- try {
392
- gitNet(['fetch', 'origin', '--quiet'], 60_000);
393
- } catch {
394
- /* offline — the check below answers from what we have */
395
- }
396
- landedOnBase = tipOnBase();
305
+ return;
397
306
  }
398
- if (!landedOnBase) {
307
+ if (merged.notOnBase) {
308
+ // gh said yes and base does not have the tip: a merge queue may
309
+ // still be running it, or an older PR merged. The server closes
310
+ // every delivered card on this sha the moment it hears ok.
399
311
  await report({
400
312
  agentId,
401
313
  ok: false,
402
314
  detail:
403
315
  "GitHub accepted the merge, but this branch's tip is not on the base branch — a merge queue may still be running it, or the PR that merged was an older one. Approve again once it lands." +
404
- (prUrl && PR_URL_RE.test(prUrl) ? ` The pull request is at ${prUrl}.` : ''),
316
+ (linked ? ` The pull request is at ${prUrl}.` : ''),
405
317
  });
406
318
  return;
407
319
  }