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,214 @@
1
+ /**
2
+ * `flowviant machines` — the command's whole body: the scripted verbs
3
+ * (`--forget`, `--remove`), the listing assembled from each stored
4
+ * credential's boxes, and, on a terminal, the ↑/↓ menu over this box's own
5
+ * connections.
6
+ *
7
+ * SPLIT OUT OF cli.mjs (SOLID audit 2026-09-26, F053): the entrypoint changed
8
+ * for terminal dispatch, for this menu, and for the start path's project
9
+ * binding. The menu is a stateful interaction with its own confirm and its own
10
+ * re-ask loop, so it lives here and cli.mjs keeps one line that dispatches to
11
+ * it. Moved verbatim; the imports stay lazy, as they were, so `--forget` still
12
+ * never loads config.mjs or reaches the network.
13
+ *
14
+ * The rules this body is pinned to (machines.test.mjs, tty.test.mjs): the menu
15
+ * is gated on BOTH `canPrompt()` and `menuSupported()`, `--remove` and the
16
+ * menu share one `disconnectHere`, the listing is re-asked after a verb, the
17
+ * keypair is read and never minted, and no prompt carries a time budget.
18
+ */
19
+ import { terminalCommand } from './launchCommand.mjs';
20
+
21
+ // ── `flowviant machines` — every project connected on THIS box, and every box
22
+ // that has polled each of them; and, on a terminal, a menu over them.
23
+ //
24
+ // A THIRD COMMAND, and the owner's ruling it amends is his own: the terminal
25
+ // surface is `npx flowviant` and `npx flowviant login`, full stop — which is
26
+ // what deleted the old terminal flag for moving a machine — and he asked for
27
+ // this one directly ("the CLI gets a command to list connections"). It shipped
28
+ // VIEW-ONLY, and on 2026-09-23 he asked for the rest: "it seems to be only a
29
+ // read cmd but I want to be able to interact with it and use arrow keys to
30
+ // select the machines or to remove them." So: the listing, then — only where a
31
+ // person can drive one — a menu. Its verbs act on THIS BOX'S OWN CONNECTIONS
32
+ // (stop the daemon here, leave the project's list in the app, forget the
33
+ // credential here) and never on another computer's daemon; machineListing.mjs
34
+ // carries the argument.
35
+ //
36
+ // The question is the owner's, verbatim: "is there a way to view ALL the
37
+ // connected flowviants? because im not sure if i have any duplicate or
38
+ // redundant daemons running". It needs the NETWORK, unlike `projects` next
39
+ // door, because the boxes are the server's answer — this box cannot see a
40
+ // daemon running on somebody else's computer, and guessing would be worse than
41
+ // asking.
42
+ //
43
+ // ONE CALL PER STORED CREDENTIAL, because the CLI has NO PERSON IDENTITY: every
44
+ // request it can make is `Bearer <machine token>`, so "all my machines" has to
45
+ // be assembled from the credentials this box holds. That is also the limit the
46
+ // footer states out loud — the app's Home is where the whole account's answer
47
+ // lives.
48
+ //
49
+ // EXIT 0 whatever it finds, including nothing: the listing is the answer the
50
+ // asker came for, and a non-zero code over "you have no projects connected"
51
+ // would make this command unusable in anything that checks one. `--remove`
52
+ // is the one exit that can be 1, when the daemon it had to stop would not.
53
+ export async function runMachinesCommand() {
54
+ const creds = await import('./credentials.mjs');
55
+ const machineJson = process.argv.includes('--json');
56
+ const forgetAt = process.argv.indexOf('--forget');
57
+ if (forgetAt >= 0) {
58
+ if (machineJson && !process.argv.includes('--yes')) {
59
+ process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
60
+ process.exit(1);
61
+ }
62
+ // THE LOCAL-ONLY WRITE. Named explicitly, matched by full id or a ≥6
63
+ // prefix, and AMBIGUITY REFUSES rather than guessing — this deletes a
64
+ // credential, and two projects that look alike is the case that produced
65
+ // the command.
66
+ const res = creds.forgetStoredProject(process.argv[forgetAt + 1]);
67
+ if (res.error) {
68
+ if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: res.error })}\n`); process.exit(1); }
69
+ console.error(`error: ${res.error}`);
70
+ process.exit(1);
71
+ }
72
+ if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: true, action: 'forget', projectId: res.entry.projectId })}\n`); process.exit(0); }
73
+ console.log(
74
+ `forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box.\n` +
75
+ ` Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
76
+ );
77
+ process.exit(0);
78
+ }
79
+ const { boxesUrlFrom, leaveUrlFrom, fetchBoxesFor } = await import('./machines.mjs');
80
+ const { renderMachines, MACHINES_FOOTER, MACHINES_FLAGS_FOOTER } = await import('./machineListing.mjs');
81
+ const { disconnectHere, disconnectShortfall, realDisconnectDeps } = await import('./machineDisconnect.mjs');
82
+ const { FLEET_URL } = await import('./config.mjs');
83
+ const removeAt = process.argv.indexOf('--remove');
84
+ if (removeAt >= 0) {
85
+ if (machineJson && !process.argv.includes('--yes')) {
86
+ process.stdout.write(`${JSON.stringify({ ok: false, error: '--yes is required with --json' })}\n`);
87
+ process.exit(1);
88
+ }
89
+ // THE SCRIPTED DISCONNECT — the menu's first verb, named by id for a
90
+ // terminal nobody is sitting at. Same matcher and the same refusal of an
91
+ // ambiguous prefix as --forget: a name two projects share is exactly the
92
+ // input this command exists to untangle, and it must not pick one.
93
+ const m = creds.matchStoredProject(process.argv[removeAt + 1]);
94
+ if (m.error) {
95
+ if (machineJson) { process.stdout.write(`${JSON.stringify({ ok: false, error: m.error })}\n`); process.exit(1); }
96
+ console.error(`error: ${m.error}. \`${terminalCommand('machines')}\` lists what is stored.`);
97
+ process.exit(1);
98
+ }
99
+ const res = await disconnectHere(m.entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log: machineJson ? () => {} : undefined }), { log: machineJson ? () => {} : undefined });
100
+ if (machineJson) {
101
+ // A refusal keeps its words for the caller (the tray reads stderr when
102
+ // the exit is nonzero): a daemon finishing a deploy is said, not "failed".
103
+ const why = res.ok ? null : (disconnectShortfall(res)?.reason ?? 'failed');
104
+ process.stdout.write(`${JSON.stringify({ ok: res.ok, action: 'disconnect', projectId: m.entry.projectId, ...(why ? { error: why } : {}) })}\n`);
105
+ if (why) process.stderr.write(`${why}\n`);
106
+ }
107
+ process.exit(res.ok ? 0 : 1);
108
+ }
109
+
110
+ let entries = creds.listStoredProjects();
111
+ if (entries.length === 0) {
112
+ console.log(`no projects connected on this machine yet — run \`${terminalCommand('login')}\` inside a repo.`);
113
+ process.exit(0);
114
+ }
115
+ // OUR OWN BOX ID, so the listing can mark "← this box".
116
+ //
117
+ // READ, NEVER CREATED. This called `ensureKeypair()` until 2026-09-19, which
118
+ // MINTS a keypair when there is no file — a 0600 write establishing this
119
+ // machine's durable identity, performed by a command whose entire job is to
120
+ // print a list. A view-only command is what let a third terminal command
121
+ // exist at all, and enrolling a box as a side effect of looking at one is not
122
+ // that. `readStoredPubB64` reads or answers null.
123
+ //
124
+ // NULL COSTS ONLY THE MARK, and that is the honest outcome: a box that has
125
+ // never run a daemon has never polled, so it is not in this listing to be
126
+ // marked in the first place.
127
+ let me;
128
+ try {
129
+ const env = await import('./boxIdentity.mjs');
130
+ me = env.readStoredPubB64() ?? undefined;
131
+ } catch {
132
+ /* unreadable keypair — nothing is marked, and nothing is claimed */
133
+ }
134
+ const url = boxesUrlFrom(FLEET_URL);
135
+ const listing = async () => {
136
+ const results = {};
137
+ // SEQUENTIAL, not a fan-out: this is a handful of credentials on somebody's
138
+ // laptop, and a parallel burst against the API buys nothing a person
139
+ // waiting two seconds can perceive.
140
+ for (const e of entries) {
141
+ results[e.projectId] = await fetchBoxesFor(e, { url, envpub: me });
142
+ }
143
+ console.log('');
144
+ for (const line of renderMachines(entries, results)) console.log(line);
145
+ console.log('');
146
+ };
147
+ await listing();
148
+
149
+ // THE MENU, only where a person can drive one. `canPrompt()` is the same
150
+ // gate the start path keeps — a backgrounded job or a pipe gets the listing
151
+ // and the flags, never a prompt that stops the process — and `menuSupported`
152
+ // is whether ↑/↓ can be read at all. Without both this is the command it was.
153
+ const { canPrompt, menuSupported, selectMenu, askWithTimeout } = await import('./tty.mjs');
154
+ if (!(canPrompt() && menuSupported())) {
155
+ for (const line of MACHINES_FOOTER) console.log(line);
156
+ for (const line of MACHINES_FLAGS_FOOTER) console.log(line);
157
+ process.exit(0);
158
+ }
159
+ const deps = await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL) });
160
+ for (;;) {
161
+ // A ROW PER PROJECT connected on this box — the things this box can act on.
162
+ // The other computers under each project are printed above for the
163
+ // duplicate to be visible, and are not rows here: stopping or removing
164
+ // THEM is the app's verb, and a menu row that answers "not from here" is
165
+ // a control wired to a refusal.
166
+ console.log(' projects connected on this box — enter one for what you can do about it:');
167
+ const rowLabel = (e) => creds.projectRowLabel(e, entries) + (e.repoRoot ? ` — ${e.repoRoot}` : ' — not tied to a repo');
168
+ const pick = await selectMenu({ options: [...entries.map(rowLabel), 'done'] });
169
+ if (pick.cancelled || pick.unsupported || pick.index === entries.length) break;
170
+ const picked = entries[pick.index];
171
+ const who = creds.projectRowLabel(picked, entries);
172
+ // TWO VERBS, each row saying its whole consequence — the app's ⋯ menu on a
173
+ // machine row says the same two things, and a menu whose rows are verbs
174
+ // with the outcome in a manual somewhere is one you have to be sure about
175
+ // before you can use it.
176
+ const action = await selectMenu({
177
+ options: [
178
+ `disconnect this box from ${who} — stops its daemon here, removes this box from its machines list in the app, forgets its credential here`,
179
+ `forget ${who} here only — nothing is stopped; the app keeps listing this box until it goes quiet`,
180
+ 'back',
181
+ ],
182
+ });
183
+ if (action.cancelled || action.unsupported || action.index === 2) continue;
184
+ // ONE CONFIRM, in words, because both verbs delete a credential and the
185
+ // arrow keys make a slip cheap. A read that failed (stdin gone) is "no".
186
+ const verb = action.index === 0 ? 'Disconnect this box from' : 'Forget';
187
+ const answer = await askWithTimeout(` ${verb} ${who}? [y/N] `);
188
+ if (!/^y(es)?$/i.test(answer ?? '')) {
189
+ console.log(' left as it was.\n');
190
+ continue;
191
+ }
192
+ if (action.index === 0) {
193
+ await disconnectHere(picked, deps, { log: (m) => console.log(` ${m}`) });
194
+ } else {
195
+ const res = creds.forgetStoredProject(picked.projectId);
196
+ console.log(
197
+ res.error
198
+ ? ` ${res.error}`
199
+ : ` forgot ${creds.projectLabel(res.entry)} (${res.entry.projectId.slice(0, 8)}…) on this box. Nothing was stopped or deleted anywhere else — \`${terminalCommand('login')}\` connects it again.`
200
+ );
201
+ }
202
+ entries = creds.listStoredProjects();
203
+ if (entries.length === 0) {
204
+ console.log('\n no projects are connected on this box now.\n');
205
+ break;
206
+ }
207
+ // THE LISTING AGAIN, re-asked: the server's rows are the only true account
208
+ // of what the leave did, and a menu redrawn over a stale listing would be
209
+ // this command asserting the outcome it hoped for.
210
+ await listing();
211
+ }
212
+ for (const line of MACHINES_FOOTER) console.log(line);
213
+ process.exit(0);
214
+ }
@@ -19,8 +19,9 @@
19
19
 
20
20
  import { FLEET_TOKEN, USER_AGENT, MCP_URL, FLEET_URL } from './config.mjs';
21
21
  import { terminalCommand } from './launchCommand.mjs';
22
+ import { fleetEndpoint } from './fleetWire.mjs';
22
23
 
23
- const CLI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/cli-token');
24
+ const CLI_TOKEN_URL = fleetEndpoint('cli-token', FLEET_URL);
24
25
 
25
26
  export async function runMcpCommand(args = []) {
26
27
  if (!FLEET_TOKEN) {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * TAKING A LANDED PATCH BACK — the one live half of what used to be
3
+ * `patch.mjs`.
4
+ *
5
+ * `patch.mjs` held two lifecycles: patch PLACEMENT (cherry-picking a
6
+ * teammate's agent commit into the owner's checkout after a dirty-path
7
+ * collision check, plus a commit-history reader for the retired
8
+ * `report_commits` path) and patch REVERT. Nothing in the daemon has called
9
+ * placement or the history reader since the dispatch lane was deleted — the
10
+ * roster still carries `patchRevertJobs` for a patch that landed before then,
11
+ * and fleetJobs.mjs is the only caller of what is left. So the revert moved here
12
+ * under its own name and the uncalled half was deleted rather than kept as a
13
+ * second, stale commit-record parser nobody tests (SOLID audit 2026-09-26,
14
+ * F167). Git history has the placement code if a new argument ever wants it.
15
+ *
16
+ * One revert at a time per daemon: `withPatchLock` serialises every write this
17
+ * lane makes to the owner's tree, so two jobs cannot race in one checkout.
18
+ */
19
+
20
+ import { git, isValidSha } from './git.mjs';
21
+
22
+ /** Serialises reverts within this process. */
23
+ let patchChain = Promise.resolve();
24
+
25
+ export function withPatchLock(fn) {
26
+ const run = patchChain.then(fn, fn);
27
+ // Keep the chain alive regardless of outcome, but don't swallow the result.
28
+ patchChain = run.then(
29
+ () => {},
30
+ () => {}
31
+ );
32
+ return run;
33
+ }
34
+
35
+ /**
36
+ * Undo a landed patch. `git revert` rather than `reset` on purpose: the owner
37
+ * has almost certainly committed or edited on top by now, and rewriting their
38
+ * history to take something back would be far worse than the patch was.
39
+ */
40
+ export function revertPatch({ repoRoot, shas }) {
41
+ // These arrive over the roster and go straight into git argv. Anything that
42
+ // isn't a bare object id — a revision range, a leading-dash option — is
43
+ // refused here rather than trusted because the server said so.
44
+ const clean = (shas ?? []).filter(isValidSha);
45
+ if (clean.length === 0 || clean.length !== (shas ?? []).length) {
46
+ return { ok: false, error: 'refused: patch revert carried a non-sha value' };
47
+ }
48
+ const ordered = [...clean].reverse(); // newest first
49
+ try {
50
+ for (const sha of ordered) git(['revert', '--no-edit', sha], repoRoot);
51
+ return { ok: true };
52
+ } catch (e) {
53
+ try {
54
+ git(['revert', '--abort'], repoRoot);
55
+ } catch {
56
+ /* nothing in progress */
57
+ }
58
+ return { ok: false, error: e?.message ?? String(e) };
59
+ }
60
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * THE GITHUB PULL-REQUEST RULES both merge paths share — adopt only an OPEN PR
3
+ * into the project's base, create one with `--fill`, merge with `--merge`, and
4
+ * believe "merged" only once the tip is measured on base.
5
+ *
6
+ * ── WHY ONE FILE (SOLID audit 2026-09-26, F040) ──
7
+ *
8
+ * The session PR lane (`workPullRequests.mjs`) and the agent approve path
9
+ * (`workAgentMerges.mjs`) each spelled these rules, forty lines apart in
10
+ * spirit and two files apart in fact, and they had already drifted once: the
11
+ * session copy adopted a PR into `staging` for months after the agent copy
12
+ * learned to refuse it, and it went without `gh` timeouts until 0.77.1. What
13
+ * is theirs stays theirs — which branch is pushed and how, the lease, the
14
+ * sentences each settles with, the job and the report — and everything that
15
+ * is a fact about GitHub lives here once:
16
+ *
17
+ * - ADOPT ONLY AN OPEN PR. gh's branch finder falls back to the most recent
18
+ * MERGED/CLOSED PR when no open one exists, and adopting a dead PR turns
19
+ * every later delivery on a long-lived branch into a silent black hole.
20
+ * - …AND ONLY ONE THAT TARGETS THE PROJECT'S BASE. A PR somebody opened by
21
+ * hand into another branch would be merged INTO that branch. Refused only
22
+ * on a MEASURED mismatch: an absent or empty `baseRefName` adopts as
23
+ * before. (The agent copy refused an empty string; the session copy
24
+ * adopted it. GitHub never sends one, and "measured" is the rule both
25
+ * copies stated, so the session spelling won.)
26
+ * - `--fill` titles a created PR from the branch's own commits — no model
27
+ * call, nothing invented — and the base is `baseBranchName`, never a
28
+ * remote-tracking name gh 422s on.
29
+ * - `--merge`, never squash and never rebase: the cards' receipts are commit
30
+ * shas, and a squash rewrites them off base, orphaning every receipt and
31
+ * blinding the landed walk's trailer read. "Already merged" is a success.
32
+ * - VERIFY before claiming merged: modern gh exits 0 on an already-MERGED PR
33
+ * and on a merge queue it exits 0 after ENQUEUEING. The tip being an
34
+ * ancestor of base is the fact "merged" claims — measured after a fetch,
35
+ * with one short retry for the fetch racing GitHub's merge commit.
36
+ *
37
+ * EVERY `gh` CALL IS TIMED OUT. `execFileSync` blocks the daemon's whole event
38
+ * loop, and the agent path runs inside the place writer lock — a `gh` that
39
+ * hangs (an expired token prompting, a network black hole, a hung credential
40
+ * helper) would stop every turn on the machine.
41
+ *
42
+ * `gh`, the fetch and the sleep are injectable so the rules are provable
43
+ * without GitHub; nothing in the daemon passes them except the fetch.
44
+ */
45
+
46
+ import { execFileSync } from 'node:child_process';
47
+ import { git } from './git.mjs';
48
+
49
+ /** gh's own first line of complaint, capped — the words a failure relays. */
50
+ export const ghFirstLine = (e) =>
51
+ ((e?.stderr?.toString?.() || e?.message || 'failed').split('\n').find((l) => l.trim()) ||
52
+ 'failed')
53
+ .slice(0, 400);
54
+
55
+ /** A pull request URL worth storing. Anything else gh printed is not one. */
56
+ export const PR_URL_RE = /^https:\/\/github\.com\/[^\s/]+\/[^\s/]+\/pull\/\d+$/;
57
+
58
+ const runGh = (args, opts) => execFileSync('gh', args, { stdio: ['ignore', 'pipe', 'pipe'], ...opts });
59
+
60
+ /**
61
+ * Is `gh` installed and signed in? null when it is; otherwise
62
+ * `{ missing, error }` — `missing` when the binary is absent, so each caller
63
+ * can say "not installed" and "not signed in" in its own words.
64
+ */
65
+ export function ghReady({ gh = runGh } = {}) {
66
+ try {
67
+ gh(['auth', 'status'], { timeout: 20_000 });
68
+ return null;
69
+ } catch (e) {
70
+ return { missing: e?.code === 'ENOENT', error: ghFirstLine(e) };
71
+ }
72
+ }
73
+
74
+ /** The OPEN pull request for `head`, as `{ url, base }` — or null when there
75
+ * is none, or only a merged/closed one. `base` is null when gh did not say. */
76
+ export function findOpenPr(head, { cwd, gh = runGh } = {}) {
77
+ try {
78
+ const j = JSON.parse(gh(['pr', 'view', head, '--json', 'url,state,baseRefName'], { cwd, timeout: 30_000 }).toString());
79
+ if (j?.state !== 'OPEN' || typeof j?.url !== 'string') return null;
80
+ return { url: j.url.trim(), base: typeof j?.baseRefName === 'string' ? j.baseRefName : null };
81
+ } catch {
82
+ return null; // no PR for the branch at all
83
+ }
84
+ }
85
+
86
+ /** The branch an open PR targets when it is MEASURED to be not `base`, else
87
+ * null — the one refusal condition for adopting it. */
88
+ export function prTargetsOtherBase(pr, base) {
89
+ return pr && pr.base && pr.base !== base ? pr.base : null;
90
+ }
91
+
92
+ /**
93
+ * THE OPEN PR FOR `head` INTO `base`: adopt one already open (a re-delivery,
94
+ * or one somebody opened by hand), or create it. Answers
95
+ * `{ url, adopted }` — url may be null if gh printed nothing usable
96
+ * `{ wrongBase }` — an open PR targets another branch; nothing created
97
+ * `{ error }` — gh's own words from a failed create
98
+ */
99
+ export function ensureOpenPr(head, base, { cwd, gh = runGh } = {}) {
100
+ const existing = findOpenPr(head, { cwd, gh });
101
+ const wrongBase = prTargetsOtherBase(existing, base);
102
+ if (wrongBase) return { wrongBase };
103
+ if (existing?.url) return { url: existing.url, adopted: true };
104
+ try {
105
+ const out = gh(['pr', 'create', '--head', head, '--base', base, '--fill'], { cwd, timeout: 60_000 })
106
+ .toString()
107
+ .trim();
108
+ return { url: out.split('\n').filter(Boolean).pop() ?? null, adopted: false };
109
+ } catch (e) {
110
+ return { error: ghFirstLine(e) };
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Is `tip` on `baseRef` yet? Fetch, then ask git; once more after a short
116
+ * pause if not, for the fetch racing GitHub's merge commit. An offline fetch
117
+ * answers from what the box already has. A null tip is never on base.
118
+ */
119
+ export async function tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), gitImpl = git }) {
120
+ if (!tip) return false;
121
+ const onBase = () => {
122
+ try {
123
+ gitImpl(['merge-base', '--is-ancestor', tip, baseRef()], repoRoot);
124
+ return true;
125
+ } catch {
126
+ return false;
127
+ }
128
+ };
129
+ for (let attempt = 0; attempt < 2; attempt++) {
130
+ if (attempt > 0) await sleep(2000);
131
+ try {
132
+ fetchOrigin();
133
+ } catch {
134
+ /* offline — the check below answers from what we have */
135
+ }
136
+ if (onBase()) return true;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /**
142
+ * MERGE `head`'s PR with a merge commit and VERIFY the tip reached base.
143
+ * Answers `{ ok: true }`, `{ error }` (gh refused, in its own words — an
144
+ * "already merged" is not a refusal), or `{ notOnBase: true }` (gh said yes
145
+ * and base does not have the tip: an older PR merged, or a queue holds it).
146
+ * The push that must precede it is the caller's: GitHub merges the REMOTE tip.
147
+ */
148
+ export async function mergeAndVerifyTip(head, { cwd, tip, baseRef, repoRoot, fetchOrigin, gh = runGh, sleep, gitImpl }) {
149
+ try {
150
+ gh(['pr', 'merge', head, '--merge'], { cwd, timeout: 120_000 });
151
+ } catch (e) {
152
+ const line = ghFirstLine(e);
153
+ if (!/already merged/i.test(line)) return { error: line };
154
+ }
155
+ const landed = await tipReachedBase({ tip, baseRef, repoRoot, fetchOrigin, sleep, gitImpl });
156
+ return landed ? { ok: true } : { notOnBase: true };
157
+ }
@@ -7,7 +7,8 @@
7
7
  import { execFileSync } from 'node:child_process';
8
8
  import { ok, warn, info, c } from './ui.mjs';
9
9
  import { addLocalBinToPath, promptYesNo, installClaude, installGh } from './install.mjs';
10
- import { RUNTIMES, detectRuntimes, mediatedSafeGap } from './runtimes.mjs';
10
+ import { RUNTIMES, mediatedSafeGap } from './runtimes.mjs';
11
+ import { detectRuntimes } from './runtimeDetection.mjs';
11
12
  import { terminalCommand } from './launchCommand.mjs';
12
13
 
13
14
  function present(cmd) {
@@ -0,0 +1,104 @@
1
+ /**
2
+ * THE PRESSURE GUARD'S DECISION — pure, and nothing else (2026-09-26, SOLID
3
+ * F050).
4
+ *
5
+ * Split out of resources.mjs, which MEASURES the box. The verdict used to read
6
+ * `FLOWVIANT_NO_PRESSURE_GUARD` and the three numeric overrides out of
7
+ * `process.env` itself while deciding from a measurement it was handed, so one
8
+ * measurement could yield two answers depending on ambient state, and every
9
+ * test of a threshold had to mutate the process's global environment.
10
+ *
11
+ * Now the thresholds are an ARGUMENT. `readPressureThresholds(env)` is the one
12
+ * adapter from an environment to them, and admission.mjs calls it per decision
13
+ * (never once at import), so an operator's override is still true of the
14
+ * environment the daemon is in RIGHT NOW rather than the one it booted in.
15
+ *
16
+ * Why the guard exists, and why it is a runaway bound and never a headroom
17
+ * meter, is argued at the top of resources.mjs.
18
+ */
19
+
20
+ const MiB = 1024 * 1024;
21
+ const GiB = 1024 * MiB;
22
+
23
+ /** What a box that nobody tuned is judged against. */
24
+ export const DEFAULT_PRESSURE_THRESHOLDS = Object.freeze({
25
+ off: false,
26
+ criticalFreeBytes: 400 * MiB,
27
+ minFreeBytes: 1024 * MiB,
28
+ maxLoadPerCore: 4,
29
+ });
30
+
31
+ /** An operator's override, or the default. Positive finite numbers only: a
32
+ * typo'd `FLOWVIANT_MIN_FREE_MB=lots` must fall back to the default rather
33
+ * than turning every comparison into NaN, which compares false and would
34
+ * silently disable the guard it was trying to tune. */
35
+ const envNum = (env, name, fallback) => {
36
+ const n = Number(env?.[name]);
37
+ return Number.isFinite(n) && n > 0 ? n : fallback;
38
+ };
39
+
40
+ /**
41
+ * The thresholds an environment states. `FLOWVIANT_NO_PRESSURE_GUARD=1` is the
42
+ * escape hatch for a box whose operator knows better than the thresholds;
43
+ * absence and `'0'` both mean the guard is on. The default parameter is
44
+ * evaluated per CALL, so a caller that passes nothing reads the live
45
+ * `process.env`.
46
+ */
47
+ export function readPressureThresholds(env = process.env) {
48
+ const d = DEFAULT_PRESSURE_THRESHOLDS;
49
+ return {
50
+ off: env?.FLOWVIANT_NO_PRESSURE_GUARD === '1',
51
+ criticalFreeBytes: envNum(env, 'FLOWVIANT_CRITICAL_FREE_MB', d.criticalFreeBytes / MiB) * MiB,
52
+ minFreeBytes: envNum(env, 'FLOWVIANT_MIN_FREE_MB', d.minFreeBytes / MiB) * MiB,
53
+ maxLoadPerCore: envNum(env, 'FLOWVIANT_MAX_LOAD_PER_CORE', d.maxLoadPerCore),
54
+ };
55
+ }
56
+
57
+ const sizeWord = (n) =>
58
+ n >= GiB ? `${(n / GiB).toFixed(1)} GB` : `${Math.round(n / MiB)} MB`;
59
+
60
+ /**
61
+ * IS THIS A MOMENT TO START ANOTHER CLI? — null when there is nothing to say.
62
+ *
63
+ * TWO LEVELS, because the two lanes are not the same promise. `churn` is the
64
+ * unattended work — agent turns, a Deploy press's planner, the wiki
65
+ * cartographer — which nobody is sitting in front of and which the server will
66
+ * cheerfully re-offer next poll, so it yields early and generously. A session
67
+ * turn is `interactive`: somebody is watching a composer they just pressed
68
+ * enter in, and deferring that is a visible stall, so it holds out until the
69
+ * box is genuinely about to fall over.
70
+ *
71
+ * THE REASON IS MEASURED WORDS AND NOTHING ELSE. No adjectives about the
72
+ * machine, no advice, no "try again in a few minutes" — a sentence naming the
73
+ * number that fired, which is the only thing this side actually knows.
74
+ *
75
+ * An unreadable measurement contributes NO verdict: memory that could not be
76
+ * read cannot refuse, and a load average this platform does not publish cannot
77
+ * either. A missing measurement altogether is the same ignorance. Ignorance
78
+ * never withholds.
79
+ */
80
+ export function pressureVerdict(level, measured, thresholds = DEFAULT_PRESSURE_THRESHOLDS) {
81
+ const t = { ...DEFAULT_PRESSURE_THRESHOLDS, ...(thresholds ?? {}) };
82
+ if (t.off) return null;
83
+ const m = measured ?? null;
84
+ const avail = Number.isFinite(m?.memAvailable) ? m.memAvailable : null;
85
+ if (level === 'interactive') {
86
+ if (avail !== null && avail < t.criticalFreeBytes)
87
+ return { reason: `nearly out of memory — ${sizeWord(avail)} available` };
88
+ return null;
89
+ }
90
+ const total = Number.isFinite(m?.memTotal) && m.memTotal > 0 ? m.memTotal : 0;
91
+ // A fixed floor AND a proportional one, whichever is larger: 1 GB is the
92
+ // right reserve on a laptop and is nothing on a 256 GB box, where six per
93
+ // cent is the honest "the page cache is already being squeezed" line.
94
+ const floor = Math.max(t.minFreeBytes, Math.round(total * 0.06));
95
+ if (avail !== null && avail < floor)
96
+ return { reason: `low memory — ${sizeWord(avail)} of ${sizeWord(total)} available` };
97
+ const cores = Number.isFinite(m?.cores) && m.cores > 0 ? m.cores : null;
98
+ const load = Number.isFinite(m?.load1) ? m.load1 : null;
99
+ if (load !== null && cores !== null && load > cores * t.maxLoadPerCore)
100
+ return {
101
+ reason: `cpu overloaded — load ${load.toFixed(1)} on ${cores} core${cores === 1 ? '' : 's'}`,
102
+ };
103
+ return null;
104
+ }