flowviant 0.102.2 → 0.104.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +184 -2396
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +34 -3
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/shipMerge.mjs +33 -2
  93. package/bin/lib/standDownExit.mjs +181 -0
  94. package/bin/lib/standDownGate.mjs +48 -0
  95. package/bin/lib/startProject.mjs +279 -0
  96. package/bin/lib/trace.mjs +3 -3
  97. package/bin/lib/turnLock.mjs +68 -0
  98. package/bin/lib/turnProfile.mjs +77 -0
  99. package/bin/lib/uninstall.mjs +55 -45
  100. package/bin/lib/uninstallCopies.mjs +112 -0
  101. package/bin/lib/update.mjs +30 -17
  102. package/bin/lib/uplinkScrub.mjs +265 -0
  103. package/bin/lib/vault.mjs +22 -151
  104. package/bin/lib/vaultDiff.mjs +183 -0
  105. package/bin/lib/versionOrder.mjs +42 -0
  106. package/bin/lib/views.mjs +35 -12
  107. package/bin/lib/wikiRunner.mjs +630 -0
  108. package/bin/lib/work.mjs +188 -3037
  109. package/bin/lib/workAdoptCarry.mjs +94 -0
  110. package/bin/lib/workAgentCheck.mjs +278 -0
  111. package/bin/lib/workAgentMerges.mjs +54 -142
  112. package/bin/lib/workAgentPlans.mjs +14 -12
  113. package/bin/lib/workAgentPrecheck.mjs +393 -0
  114. package/bin/lib/workAgentPublish.mjs +177 -0
  115. package/bin/lib/workAgentReview.mjs +8 -666
  116. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  117. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  118. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  119. package/bin/lib/workAgentTurnReports.mjs +189 -0
  120. package/bin/lib/workAgentTurns.mjs +92 -897
  121. package/bin/lib/workAttachments.mjs +93 -0
  122. package/bin/lib/workBrain.mjs +53 -0
  123. package/bin/lib/workChildren.mjs +110 -0
  124. package/bin/lib/workCommandAudit.mjs +62 -0
  125. package/bin/lib/workDiffs.mjs +2 -1
  126. package/bin/lib/workNarration.mjs +152 -0
  127. package/bin/lib/workPlaceReports.mjs +426 -0
  128. package/bin/lib/workPlaces.mjs +228 -0
  129. package/bin/lib/workPreviews.mjs +31 -9
  130. package/bin/lib/workProcesses.mjs +4 -5
  131. package/bin/lib/workPullRequests.mjs +52 -126
  132. package/bin/lib/workReportQueue.mjs +254 -0
  133. package/bin/lib/workRetire.mjs +185 -0
  134. package/bin/lib/workSessionGroups.mjs +166 -0
  135. package/bin/lib/workSessionRuntime.mjs +164 -0
  136. package/bin/lib/workSessionTokens.mjs +68 -0
  137. package/bin/lib/workSessionTurns.mjs +892 -0
  138. package/bin/lib/workShip.mjs +9 -29
  139. package/bin/lib/workToolLog.mjs +76 -0
  140. package/package.json +3 -2
  141. package/bin/lib/patch.mjs +0 -363
@@ -1,28 +1,27 @@
1
1
  /** Session branch shipping and settlement. */
2
2
  import { existsSync } from 'node:fs';
3
- import { execFileSync } from 'node:child_process';
4
3
  import { join } from 'node:path';
5
4
  import { git, baseBranchName, isSafePathSegment } from './git.mjs';
6
5
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
6
+ import { gitMergeIn } from './gitMergeIdentity.mjs';
7
7
  import { c, note, ok, warn } from './ui.mjs';
8
- import { scrub as envScrub } from './env.mjs';
8
+ import { scrub as envScrub } from './uplinkScrub.mjs';
9
+ import { REPO_PLACE } from './workPlaces.mjs';
10
+ import { turnLockedByLivePid } from './turnLock.mjs';
9
11
 
10
12
  export function createWorkShipper({
11
13
  inPlace,
12
14
  placeOf,
13
15
  settleShip,
14
- REPO_PLACE,
15
16
  repoRoot,
16
17
  baseDir,
17
18
  gitNet,
18
19
  baseRef,
19
20
  placeWtFor,
20
- turnLockedByLivePid,
21
21
  sessionMetaPath,
22
- state,
22
+ shipping,
23
+ pendingShipReports,
23
24
  }) {
24
- const { shipping, pendingShipReports } = state;
25
-
26
25
  // Ship — a session's branch merging to main, on the human's word.
27
26
  //
28
27
  // --no-ff, NEVER squash: every delivered card carries commit shas as its
@@ -154,28 +153,9 @@ export function createWorkShipper({
154
153
  return false;
155
154
  }
156
155
  };
157
- // The machine may have no git identity, and a merge COMMIT needs
158
- // one. Prefer the user's own config; fall back to the daemon's (the
159
- // same fallback ship's merge keeps) so a bare machine doesn't fail
160
- // the fold with "Please tell me who you are".
161
- let idEnv = null;
162
- try {
163
- git(['config', 'user.email'], repoRoot);
164
- } catch {
165
- idEnv = {
166
- GIT_AUTHOR_NAME: 'Flowviant',
167
- GIT_AUTHOR_EMAIL: 'daemon@flowviant.com',
168
- GIT_COMMITTER_NAME: 'Flowviant',
169
- GIT_COMMITTER_EMAIL: 'daemon@flowviant.com',
170
- };
171
- }
172
- const gitMerge = (args, cwd) =>
173
- execFileSync('git', args, {
174
- cwd,
175
- encoding: 'utf8',
176
- stdio: ['ignore', 'pipe', 'pipe'],
177
- ...(idEnv ? { env: { ...process.env, ...idEnv } } : {}),
178
- });
156
+ // A merge COMMIT needs an identity the machine may not have; the
157
+ // probe and the daemon's fallback live in gitMergeIdentity.mjs.
158
+ const gitMerge = gitMergeIn(repoRoot);
179
159
  // Receipts for a range: --no-merges, because fold commits describe
180
160
  // plumbing, not work.
181
161
  const logCommits = (range) =>
@@ -0,0 +1,76 @@
1
+ /**
2
+ * ONE TURN'S TOOL LOG — the collector behind the transcript's tool cards.
3
+ *
4
+ * Split out of work.mjs's session turn (2026-09-26, SOLID F037) as pure logic:
5
+ * it folds the CLI's own tool events into the capped, collapsed shape the
6
+ * server stores, and nothing else. A fresh log per turn; the lane reads
7
+ * `toolLog` for the live beat and the settle, and feeds `pushToolEvent` from
8
+ * the stream. Tested directly (workToolLog.test.mjs).
9
+ */
10
+ import { toolEventOf } from './runtimeEvents.mjs';
11
+ import { scrub as envScrub } from './uplinkScrub.mjs';
12
+
13
+ /**
14
+ * THE TURN'S TOOL LOG — the structured relay behind the transcript's
15
+ * tool cards. Same source as the narrator (the CLI's own tool_use
16
+ * events), zero inference; scrubbed AT COLLECTION so every copy that
17
+ * leaves the machine — live beat and settle alike — is already clean.
18
+ *
19
+ * Shape rules, applied here because the collector is the one writer:
20
+ * · consecutive identical read/grep/glob/bash/task events collapse
21
+ * into one row with a count (n);
22
+ * · consecutive edits of ONE file merge, summing counts, keeping
23
+ * the newest preview;
24
+ * · the PLAN is a single event — a new TodoWrite replaces the old
25
+ * plan at the current position, so the log shows the latest plan
26
+ * where it last changed rather than five stale copies;
27
+ * · capped at the newest 60 NON-PLAN rows, with the shed counted
28
+ * call-for-call (`dropped += n`) — scrollback semantics, the
29
+ * same trade the transcript itself makes; the plan is exempt,
30
+ * because it is current state rather than scrollback.
31
+ */
32
+ export function createToolLog(cwd) {
33
+ const toolLog = { ev: [], dropped: 0 };
34
+ const pushToolEvent = (name, input) => {
35
+ // envScrub rides INTO the builder, which scrubs over a bounded
36
+ // window BEFORE its caps — scrubbing after the cut both leaked a
37
+ // boundary-straddling secret's prefix and grew a capped field
38
+ // past the server's limits (review, 2026-09-01).
39
+ const e = toolEventOf(name, input, cwd, envScrub);
40
+ if (!e) return;
41
+ if (e.t === 'plan') {
42
+ const i = toolLog.ev.findIndex((x) => x.t === 'plan');
43
+ if (i >= 0) toolLog.ev.splice(i, 1);
44
+ toolLog.ev.push(e);
45
+ } else {
46
+ const last = toolLog.ev[toolLog.ev.length - 1];
47
+ const sameKey =
48
+ last &&
49
+ last.t === e.t &&
50
+ last.p === e.p &&
51
+ last.q === e.q &&
52
+ last.c === e.c;
53
+ if (sameKey && (e.t === 'edit' || e.t === 'write')) {
54
+ last.n = (last.n ?? 1) + 1;
55
+ last.a = (last.a ?? 0) + (e.a ?? 0);
56
+ last.d = (last.d ?? 0) + (e.d ?? 0);
57
+ if (e.dl) last.dl = e.dl;
58
+ } else if (sameKey) {
59
+ last.n = (last.n ?? 1) + 1;
60
+ } else {
61
+ toolLog.ev.push(e);
62
+ }
63
+ }
64
+ // The cap evicts the oldest NON-plan row: the plan is current
65
+ // state, not scrollback — the one card the fold keeps out — and a
66
+ // shed collapsed row counts its repeats, so "N steps" never
67
+ // understates what the cut removed.
68
+ while (toolLog.ev.length > 60) {
69
+ const i = toolLog.ev.findIndex((x) => x.t !== 'plan');
70
+ if (i < 0) break; // only the plan left; it stays
71
+ const [shed] = toolLog.ev.splice(i, 1);
72
+ toolLog.dropped = Math.min(1_000_000, toolLog.dropped + (shed?.n ?? 1));
73
+ }
74
+ };
75
+ return { toolLog, pushToolEvent };
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.102.2",
3
+ "version": "0.104.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  "node": ">=20"
17
17
  },
18
18
  "scripts": {
19
- "test": "node --test bin/lib/*.test.mjs"
19
+ "test": "node --test bin/lib/*.test.mjs",
20
+ "prepublishOnly": "bun scripts/check-app-parity.mjs"
20
21
  },
21
22
  "dependencies": {
22
23
  "libsodium-wrappers-sumo": "^0.8.4",
package/bin/lib/patch.mjs DELETED
@@ -1,363 +0,0 @@
1
- /**
2
- * Patch placement — landing a small change in the OWNER's own checkout.
3
- *
4
- * The motivating case: a teammate wants the login button to read "Sign in"
5
- * while you are mid-work on auth. A clone → branch → push → PR → review → merge
6
- * → pull cycle for a nine-character diff is absurd, and it blocks THEM on YOU.
7
- *
8
- * The naive version of this — let their agent write into your working directory
9
- * — is the one thing we will not do. Two writers in one directory produce silent
10
- * lost edits: the agent reads a file, thinks for forty seconds, and writes it
11
- * back over the change you made in between. No conflict marker, nothing to
12
- * resolve, and you find out three tasks later.
13
- *
14
- * So the agent still works in its OWN worktree, branched off your current
15
- * branch, and the daemon carries the result across with a cherry-pick:
16
- *
17
- * 1. the agent commits in its worktree (no push, no PR)
18
- * 2. we check the files it touched against YOUR uncommitted edits
19
- * 3. only if they are disjoint do we cherry-pick into your checkout
20
- *
21
- * Step 2 is the whole safety argument. A collision is reported as a blocker for
22
- * a human to sort out — never resolved by guessing, and never applied anyway.
23
- *
24
- * One patch at a time per daemon: `withPatchLock` serialises every apply in this
25
- * process, so two agents can't race to write your tree. (Two daemons on one repo
26
- * would still race — nothing in the daemon guards that today.)
27
- */
28
-
29
- import { git, gitRaw, splitNul, isValidSha } from './git.mjs';
30
-
31
- /** Serialises applies within this process. */
32
- let patchChain = Promise.resolve();
33
-
34
- export function withPatchLock(fn) {
35
- const run = patchChain.then(fn, fn);
36
- // Keep the chain alive regardless of outcome, but don't swallow the result.
37
- patchChain = run.then(
38
- () => {},
39
- () => {}
40
- );
41
- return run;
42
- }
43
-
44
- /** The branch the human is actually on, or null in a detached head. */
45
- export function ownerCurrentBranch(repoRoot) {
46
- try {
47
- const name = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot);
48
- return name && name !== 'HEAD' ? name : null;
49
- } catch {
50
- return null;
51
- }
52
- }
53
-
54
- /**
55
- * Repo-relative paths with uncommitted changes in the owner's checkout.
56
- *
57
- * `-z` on purpose. The line-based form quotes any path that isn't plain ASCII
58
- * ("src/caf\303\251.ts") while `git diff --name-only` renders the same file
59
- * differently — so the collision check, which is the entire safety argument for
60
- * patch placement, quietly stopped matching for anyone with an accent in a
61
- * filename and let the cherry-pick land on top of their edits. NUL-separated
62
- * output is verbatim on both sides.
63
- */
64
- export function dirtyPaths(repoRoot) {
65
- let out = '';
66
- try {
67
- out = gitRaw(['status', '--porcelain', '-z'], repoRoot);
68
- } catch {
69
- return [];
70
- }
71
- // Porcelain -z is "XY path\0", and a RENAME is "R new\0old\0" — the extra
72
- // entry is the source, which we skip: the destination is what gets clobbered.
73
- const entries = splitNul(out);
74
- const paths = [];
75
- for (let i = 0; i < entries.length; i++) {
76
- const entry = entries[i];
77
- // "XY " — two status columns and a space — then the path, verbatim.
78
- if (entry.length < 4) continue;
79
- const code = entry.slice(0, 2);
80
- paths.push(entry.slice(3));
81
- if (code.includes('R') || code.includes('C')) i += 1; // consume the source
82
- }
83
- return paths;
84
- }
85
-
86
- /** Commits the agent made on top of `base`, oldest first. */
87
- export function commitsSince(cwd, base) {
88
- try {
89
- const out = git(['rev-list', '--reverse', `${base}..HEAD`], cwd);
90
- return out.split('\n').map((l) => l.trim()).filter(Boolean);
91
- } catch {
92
- return [];
93
- }
94
- }
95
-
96
- /** Files those commits touch. `-z` to match dirtyPaths byte for byte. */
97
- export function filesChanged(cwd, base) {
98
- try {
99
- return splitNul(gitRaw(['diff', '--name-only', '-z', `${base}..HEAD`], cwd));
100
- } catch {
101
- return [];
102
- }
103
- }
104
-
105
- // A patch never reaches GitHub, so the server has no provider to read a diff
106
- // back from — these commits exist only here. Without carrying it across, the
107
- // delivery card asks the human to Keep or Revert on the agent's word alone,
108
- // which is the review we skipped the PR to avoid needing.
109
- //
110
- // Capped because it crosses the wire and lands in a row read on every card
111
- // render: 40 files, 24KB of hunks each. Truncation is stated in the patch text
112
- // rather than silently trimmed — "no diff shown" is honest, a diff that lies
113
- // about its own extent is not.
114
- const MAX_DIFF_FILES = 40;
115
- const MAX_PATCH_BYTES = 24_000;
116
-
117
- const DIFF_STATUS = { A: 'added', D: 'removed', M: 'modified' };
118
-
119
- /**
120
- * The per-file unified diffs for the agent's commits, in the same shape the PR
121
- * path returns so the delivery card renders both through one component.
122
- *
123
- * Rename detection is deliberately OFF (no -M): with it on, `--numstat` writes
124
- * the path as `dir/{old => new}.ts`, which no longer matches anything you can
125
- * pass back to `git diff -- <path>`. A rename showing up as a delete plus an add
126
- * is a slightly longer diff and a correct one.
127
- */
128
- export function fileDiffs(cwd, base, { range, maxFiles = MAX_DIFF_FILES } = {}) {
129
- // `range` lets the per-COMMIT walk reuse this (`sha^..sha`); without it the
130
- // original meaning holds — everything the agent did since `base`.
131
- const rev = range ?? `${base}..HEAD`;
132
- let numstat = '';
133
- let names = '';
134
- try {
135
- numstat = git(['diff', '--numstat', '--no-renames', rev], cwd);
136
- names = git(['diff', '--name-status', '--no-renames', rev], cwd);
137
- } catch {
138
- return [];
139
- }
140
-
141
- const statusOf = new Map();
142
- for (const line of names.split('\n')) {
143
- const [letter, path] = line.split('\t');
144
- if (!letter || !path) continue;
145
- statusOf.set(path.trim(), letter.trim().charAt(0));
146
- }
147
-
148
- const out = [];
149
- for (const line of numstat.split('\n')) {
150
- if (out.length >= maxFiles) break;
151
- const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line.replace(/\n$/, ''));
152
- if (!m) continue;
153
- const path = m[3].trim();
154
- // A "-" count means binary — git has no textual hunks to give us.
155
- const binary = m[1] === '-' || m[2] === '-';
156
-
157
- let patch = null;
158
- if (!binary) {
159
- try {
160
- const full = git(['diff', rev, '--', path], cwd);
161
- // Drop git's own "diff --git a/… b/…" preamble; the card shows the path.
162
- const at = full.indexOf('@@');
163
- const hunks = at === -1 ? full : full.slice(at);
164
- patch =
165
- hunks.length > MAX_PATCH_BYTES
166
- ? `${hunks.slice(0, MAX_PATCH_BYTES)}\n… diff truncated (${hunks.length} bytes)`
167
- : hunks;
168
- } catch {
169
- patch = null;
170
- }
171
- }
172
-
173
- out.push({
174
- path,
175
- status: DIFF_STATUS[statusOf.get(path)] ?? 'modified',
176
- additions: binary ? 0 : Number(m[1]),
177
- deletions: binary ? 0 : Number(m[2]),
178
- patch,
179
- });
180
- }
181
- return out;
182
- }
183
-
184
- /**
185
- * Carry the agent's commits into the owner's checkout.
186
- *
187
- * Returns one of:
188
- * { ok: true, shas, files } — applied
189
- * { ok: false, reason: 'no_commits' } — the agent committed nothing
190
- * { ok: false, reason: 'branch_moved' } — the owner switched branches mid-run
191
- * { ok: false, reason: 'conflict', paths } — the owner is editing those files
192
- * { ok: false, reason: 'apply_failed', error }
193
- *
194
- * Never leaves a half-applied state: a failed cherry-pick is aborted and the
195
- * already-applied commits are rolled back, so the owner's tree is either fully
196
- * patched or untouched.
197
- */
198
- export function applyPatch({ repoRoot, cwd, basedOnBranch, commitsFrom }) {
199
- // NB: uses revertPatch (declared below — hoisted) for its rollback path.
200
- //
201
- // Two different refs, and conflating them lost work. `basedOnBranch` is the
202
- // owner's branch, used ONLY to check they haven't moved since we mirrored it;
203
- // it is null when we couldn't mirror it at all (detached HEAD, a failed
204
- // fetch). `commitsFrom` is where the agent's commits start, which in that case
205
- // is the base ref the worktree actually got reset to. Defaulting the diff to
206
- // 'HEAD' meant HEAD..HEAD — an empty range reported as "the agent committed
207
- // nothing", which is a lie about work that is sitting right there.
208
- const base = commitsFrom ?? basedOnBranch ?? 'HEAD';
209
- const shas = commitsSince(cwd, base);
210
- if (shas.length === 0) return { ok: false, reason: 'no_commits' };
211
-
212
- // The tree we branched from must still be the tree we're landing in.
213
- const current = ownerCurrentBranch(repoRoot);
214
- if (basedOnBranch && current !== basedOnBranch) {
215
- return { ok: false, reason: 'branch_moved', expected: basedOnBranch, actual: current };
216
- }
217
-
218
- const files = filesChanged(cwd, base);
219
- const dirty = new Set(dirtyPaths(repoRoot));
220
- const collisions = files.filter((f) => dirty.has(f));
221
- if (collisions.length > 0) {
222
- return { ok: false, reason: 'conflict', paths: collisions };
223
- }
224
-
225
- // Make the agent's commits reachable from the main checkout. Worktrees of the
226
- // same repo share an object store, so this needs no network and no remote.
227
- const applied = [];
228
- try {
229
- for (const sha of shas) {
230
- git(['cherry-pick', '--allow-empty', '-x', sha], repoRoot);
231
- applied.push(sha);
232
- }
233
- } catch (e) {
234
- try {
235
- git(['cherry-pick', '--abort'], repoRoot);
236
- } catch {
237
- /* nothing in progress */
238
- }
239
- let rolledBack = true;
240
- if (applied.length > 0) {
241
- // Roll back the ones that did land — by REVERTING them, never by
242
- // resetting. `git reset --hard HEAD~n` would restore every tracked file
243
- // in the repo, silently destroying the owner's uncommitted work in files
244
- // this patch never touched. The pre-flight guard above only clears the
245
- // patch's OWN file set, so a reset here is unbounded damage for a bounded
246
- // mistake. A revert touches only the files in those commits.
247
- const rev = revertPatch({ repoRoot, shas: applied });
248
- rolledBack = rev.ok;
249
- }
250
- return {
251
- ok: false,
252
- reason: 'apply_failed',
253
- error: e?.message ?? String(e),
254
- // The caller must NOT claim "your tree is untouched" when part of the
255
- // patch is still sitting in the owner's history.
256
- partiallyApplied: !rolledBack,
257
- appliedShas: rolledBack ? [] : applied,
258
- };
259
- }
260
-
261
- // The landed shas differ from the source shas (cherry-pick rewrites them);
262
- // report the ones now in the owner's history, since Revert acts on those.
263
- let landed = applied;
264
- try {
265
- const out = git(['rev-list', `-n${applied.length}`, 'HEAD'], repoRoot);
266
- landed = out.split('\n').map((l) => l.trim()).filter(Boolean).reverse();
267
- } catch {
268
- /* keep the source shas as a best-effort record */
269
- }
270
- return { ok: true, shas: landed, files };
271
- }
272
-
273
- /**
274
- * Undo a landed patch. `git revert` rather than `reset` on purpose: the owner
275
- * has almost certainly committed or edited on top by now, and rewriting their
276
- * history to take something back would be far worse than the patch was.
277
- */
278
- export function revertPatch({ repoRoot, shas }) {
279
- // These arrive over the roster and go straight into git argv. Anything that
280
- // isn't a bare object id — a revision range, a leading-dash option — is
281
- // refused here rather than trusted because the server said so.
282
- const clean = (shas ?? []).filter(isValidSha);
283
- if (clean.length === 0 || clean.length !== (shas ?? []).length) {
284
- return { ok: false, error: 'refused: patch revert carried a non-sha value' };
285
- }
286
- const ordered = [...clean].reverse(); // newest first
287
- try {
288
- for (const sha of ordered) git(['revert', '--no-edit', sha], repoRoot);
289
- return { ok: true };
290
- } catch (e) {
291
- try {
292
- git(['revert', '--abort'], repoRoot);
293
- } catch {
294
- /* nothing in progress */
295
- }
296
- return { ok: false, error: e?.message ?? String(e) };
297
- }
298
- }
299
-
300
-
301
- // How many commits of a task's branch we carry across. The server used to read
302
- // this from GitHub and capped at 50 for the same reason: each commit costs a
303
- // diff, and a runaway branch must not fan out unbounded work or produce a row
304
- // too big to read on every card render. Truncation keeps the MOST RECENT
305
- // commits — the tail is what a reviewer is looking at.
306
- const MAX_COMMITS = 50;
307
-
308
- /**
309
- * A task branch's commits with their real per-file diffs, in the exact shape
310
- * the server's GitHub read used to return (`TaskCommit[]`).
311
- *
312
- * This is the function that let the GitHub App die. The server used to resolve
313
- * the project's linked repo, mint an installation token, fetch
314
- * `GET /pulls/{n}/commits` and then run an N+1 of `GET /commits/{sha}` for the
315
- * per-file patches — up to ~52 API calls to describe work THIS process had just
316
- * performed, in a checkout it is standing in. Now the daemon reports it through
317
- * `report_commits` and the server reads a row.
318
- *
319
- * Oldest → newest, because the thread appends chronologically.
320
- */
321
- export function commitHistory(cwd, base) {
322
- let log = '';
323
- try {
324
- // %x1f/%x1e are unit/record separators: a commit subject can contain
325
- // anything, tabs and pipes included, so the delimiters have to be bytes a
326
- // human will never type.
327
- log = git(
328
- ['log', '--reverse', `--max-count=${MAX_COMMITS}`, '--format=%H%x1f%s%x1f%an%x1f%aI%x1e', `${base}..HEAD`],
329
- cwd,
330
- );
331
- } catch {
332
- return [];
333
- }
334
-
335
- const out = [];
336
- for (const record of log.split('\x1e')) {
337
- const line = record.trim();
338
- if (!line) continue;
339
- const [sha, message, authorName, committedAt] = line.split('\x1f');
340
- if (!sha) continue;
341
- // First-parent range for the commit itself. A root commit has no `^`, in
342
- // which case git's empty-tree hash gives us the whole thing as an add.
343
- let range = `${sha}^..${sha}`;
344
- try {
345
- git(['rev-parse', `${sha}^`], cwd);
346
- } catch {
347
- range = `4b825dc642cb6eb9a060e54bf8d69288fbee4904..${sha}`;
348
- }
349
- const files = fileDiffs(cwd, null, { range });
350
- out.push({
351
- sha,
352
- message: (message ?? '').slice(0, 500),
353
- authorName: (authorName ?? '').slice(0, 200),
354
- authorLogin: null,
355
- committedAt: committedAt ?? new Date().toISOString(),
356
- url: null,
357
- additions: files.reduce((n, f) => n + f.additions, 0),
358
- deletions: files.reduce((n, f) => n + f.deletions, 0),
359
- files,
360
- });
361
- }
362
- return out;
363
- }