@uchibeke/gallivant 0.0.0-stage → 0.2.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 (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +201 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +257 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +70 -0
  12. package/docs/agent-setup.md +214 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +620 -0
  18. package/docs/config.md +430 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +338 -0
  22. package/docs/launch.md +42 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +140 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +457 -0
  43. package/lib/commands/init.mjs +34 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +195 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +100 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +631 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +133 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1635 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
package/lib/git.mjs ADDED
@@ -0,0 +1,434 @@
1
+ // Checkouts for sessions. Each PRD gets its own partial clone at <worktrees>/<owner>/<repo>/<prd id>, so the
2
+ // operator's own checkout is never touched, two PRDs never collide, and a lost registry can recreate it from the
3
+ // PR branch with nothing but the repo name. It is called a worktree throughout because that is what it is to the
4
+ // session; under the hood it is `git clone --filter=blob:none`, which fetches blobs on demand.
5
+ import { join } from "node:path";
6
+ import { lstatSync } from "node:fs";
7
+ import { exists, ensureDir } from "./fs.mjs";
8
+ import { repositoryPath, writeRepositoryText } from "./repository_files.mjs";
9
+ import { execFileAsync, remoteMatches, repoFromRemote } from "./gh.mjs";
10
+ import { COMMIT_MSG_HOOK } from "./attribution.mjs";
11
+ import { BRANCH_PREFIX, BRANCH_PREFIXES } from "./public-format.mjs";
12
+
13
+ const CLONE_TIMEOUT_MS = 15 * 60_000;
14
+ const GIT_TIMEOUT_MS = 5 * 60_000;
15
+ export const GIT_PATH_LIMITS = Object.freeze({ timeoutMs: 2000, bytes: 1_048_576 });
16
+ const cloneUrl = repo => `https://github.com/${repo}.git`;
17
+
18
+ export const worktreePath = (config, nameWithOwner, prdId) => join(config.worktrees, ...nameWithOwner.toLowerCase().split("/"), prdId);
19
+
20
+ /** The branch naming convention, in one place with its inverse. */
21
+ export const branchFor = prdId => `${BRANCH_PREFIX}${prdId}`;
22
+ const branchPrefix = branch => typeof branch === "string" ? BRANCH_PREFIXES.find(prefix => branch.startsWith(prefix)) : null;
23
+ export const isOurBranch = branch => !!branchPrefix(branch);
24
+ export const prdIdFromBranch = branch => (isOurBranch(branch) ? branch.slice(branchPrefix(branch).length) : null);
25
+
26
+ /**
27
+ * Git config for a run, as the pairs git's own `GIT_CONFIG_KEY_n`/`GIT_CONFIG_VALUE_n` variables take. Exported so a
28
+ * test can assert what a run is given without reconstructing the numbering.
29
+ *
30
+ * The helper is a shell snippet git runs with `sh -c` when it needs a credential, and it reads `GH_TOKEN` out of the
31
+ * run's own environment. Nothing operator-supplied is interpolated into it: the value is this literal, and the token
32
+ * travels separately as `GH_TOKEN`. That is deliberate. A snippet assembled from a config value would be a shell
33
+ * injection with a config file as its input.
34
+ */
35
+ export const RUN_GIT_CONFIG = [
36
+ ["push.default", "current"],
37
+ ["credential.helper", `!f() { test -n "$GH_TOKEN" && printf 'username=x\\npassword=%s\\n' "$GH_TOKEN"; }; f`],
38
+ ];
39
+
40
+ /**
41
+ * The identity pairs, from the operator's own resolved `{ name, email }`.
42
+ *
43
+ * These used to be `gallivant <gallivant@users.noreply.github.com>` literals in the list above. They are not literals
44
+ * any more because a run commits AS THE OPERATOR: they wrote the brief and are shipping the work, and a bot committer
45
+ * tells a reviewer a robot did it. lib/identity.mjs carries the whole reasoning and is where the values come from.
46
+ *
47
+ * Still passed on the command line, which is the half that was always right: nothing in a repository can override a
48
+ * value set there, so no `.git/config` a session writes can change who a commit is from.
49
+ */
50
+ export const identityConfig = ({ name, email }) => [["user.name", name], ["user.email", email]];
51
+
52
+ /**
53
+ * The same isolation the git variables give, for node's package managers, which have the identical problem and had no
54
+ * equivalent treatment.
55
+ *
56
+ * Every one of these was being typed by hand, on every command, in four consecutive live runs, because the session
57
+ * discovered the failure and worked out the workaround itself each time. That is the signal they belong in the run's
58
+ * environment rather than in a session's memory.
59
+ *
60
+ * `NPM_CONFIG_USERCONFIG` stops npm and pnpm reading `~/.npmrc`. Under a sandbox that denies reads outside the
61
+ * worktree, that read fails the command outright (`EPERM ... open '/Users/<you>/.npmrc'`). It is worth doing even
62
+ * without a sandbox: that file holds a live npm auth token, and nothing a Gallivant run does needs it.
63
+ *
64
+ * The other two stop a `"packageManager": "pnpm@x.y.z"` field in the repository's own package.json sending the run
65
+ * off to a self-installed binary under `~/Library/pnpm/.tools`, which is both outside the worktree (so denied) and a
66
+ * version the operator never chose to install. The run uses whatever pnpm is on PATH instead.
67
+ *
68
+ * `/dev/null` is a path, and the only one here, for the reason `GIT_CONFIG_GLOBAL` already gives: every sandbox
69
+ * permits it, and the alternative of leaving the variable unset sends the tool to the operator's home directory.
70
+ */
71
+ export const NODE_TOOLING_ENV = Object.freeze({
72
+ NPM_CONFIG_USERCONFIG: "/dev/null",
73
+ npm_config_manage_package_manager_versions: "false",
74
+ COREPACK_ENABLE_AUTO_PIN: "0",
75
+ });
76
+
77
+ /**
78
+ * Env for anything that runs git or gh inside a worktree: every git setting a run needs, and a credential when one
79
+ * was resolved. `credential` is the object `resolveCredential` returns (lib/credential.mjs); pass none and the run
80
+ * gets the settings and no token, which is what the read-only callers (`originOf`, `currentBranch`) need.
81
+ *
82
+ * Nothing here names a file outside the worktree, and that is the whole point of its current shape.
83
+ *
84
+ * This used to be `GIT_CONFIG_GLOBAL=~/.gallivant/gitconfig`, with the credential helper in that file. A sandboxed
85
+ * session cannot read outside its worktree, so every git command failed with
86
+ *
87
+ * fatal: unable to access '/Users/<you>/.gallivant/gitconfig': Operation not permitted
88
+ *
89
+ * including `git status`. The token was in the environment the whole time and unusable, because the file that would
90
+ * have read it could not itself be read. A live run implemented nine acceptance criteria, committed four times, and
91
+ * could not push. Two sessions then diagnosed it as `~/.git-credentials` being denied, because that is the path git
92
+ * names in its fallback message, not the one it actually failed on.
93
+ *
94
+ * `GIT_CONFIG_COUNT` and its numbered pairs are git's own way to pass config with no file at all, so the settings
95
+ * travel with the run instead of being something the run has to be granted access to read. A sandbox that denies
96
+ * everything outside the worktree is then the correct setting rather than an obstacle. `runEnv` takes no path from
97
+ * `config` for this reason, and a test asserts that every value it returns is free of absolute paths.
98
+ *
99
+ * Two variables do the isolating, and both are load-bearing:
100
+ *
101
+ * - `GIT_CONFIG_NOSYSTEM=1` drops the SYSTEM file. On any Mac with the Xcode command line tools that file is
102
+ * `/Library/Developer/CommandLineTools/usr/share/git-core/gitconfig` and it sets
103
+ * `credential.helper = osxkeychain`. Verified: without this, `git config --list --show-origin` reports that
104
+ * helper from that file even with `GIT_CONFIG_GLOBAL` pointed elsewhere, so a run had a path to the operator's
105
+ * keychain whatever else was set. With it, `git config --get-all credential.helper` exits 1.
106
+ * - `GIT_CONFIG_GLOBAL=/dev/null` drops the GLOBAL file. It is a path, and the only one here, but it is a device
107
+ * every sandbox permits; the alternative of leaving it unset makes git read `$HOME/.gitconfig`, which is outside
108
+ * the worktree and is the class of failure this whole function exists to end.
109
+ *
110
+ * What is NOT bounded by this, stated plainly because it is not new and is easy to mistake for new: a run's own
111
+ * `.git/config` is still read, `credential.helper` is multi-valued, and a repo-local helper is consulted first. That
112
+ * was equally true when the helper lived in a global file. A run controls its worktree, so it can always add one; the
113
+ * control that bounds a run is the token's scope and the branch ruleset, not the transport of the config.
114
+ *
115
+ * The token reaches the helper through this environment and nothing else. It is never an argument, so `ps` cannot
116
+ * show it, and nothing here writes it to a file.
117
+ */
118
+ export function runEnv(_config = null, credential = null, identity = null, worktree = null) {
119
+ // The identity pairs join the fixed ones only when one was resolved. A run with no identity is refused before it is
120
+ // spawned (`requireIdentity`), so the null case here is a git call that commits nothing: the daemon's own read-only
121
+ // ones (`originOf`, `currentBranch`), or `doctor`'s probe. Anything that COMMITS must be given an identity, because
122
+ // this function's own `GIT_CONFIG_GLOBAL=/dev/null` is what leaves the clone without one; `makeGit` says what git
123
+ // does when it is not, and it is not a clean failure on every platform.
124
+ const pairs = [...RUN_GIT_CONFIG, ...(identity?.name && identity?.email ? identityConfig(identity) : [])];
125
+ const env = { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_COUNT: String(pairs.length) };
126
+ for (const [i, [key, value]] of pairs.entries()) { env[`GIT_CONFIG_KEY_${i}`] = key; env[`GIT_CONFIG_VALUE_${i}`] = value; }
127
+ if (credential?.token) env.GH_TOKEN = credential.token;
128
+ // gh loads config even with GH_TOKEN. A sandboxed run cannot read ~/.config/gh, so keep its CLI state inside
129
+ // Gallivant's private clone. The directory may be absent; gh uses defaults. No personal credentials are copied.
130
+ if (worktree) env.GH_CONFIG_DIR = join(worktree, ".git", "gallivant-gh");
131
+ return { ...env, ...NODE_TOOLING_ENV };
132
+ }
133
+
134
+ /**
135
+ * Git for the daemon's own clones and fetches.
136
+ *
137
+ * `credential` is a function so the token is resolved when a command actually runs rather than captured at
138
+ * construction. The daemon is long-lived and the operator may re-authenticate under it; a value captured once at
139
+ * boot would go stale and every clone after that would fail with a credential the operator had already replaced.
140
+ * It defaults to resolving nothing, which is what the read-only callers need and what every test gets for free.
141
+ *
142
+ * `identity` is a function for the same reason and with the same default, and it is here because `mergeBase` COMMITS.
143
+ * It was missing, and the shape of the bug is worth writing down because the obvious diagnosis is not the whole of it.
144
+ * `runEnv` drops the global and system config files deliberately, so a fresh `--filter=blob:none` clone has no
145
+ * `user.name` or `user.email` anywhere git will look. What happens next depends on the machine:
146
+ *
147
+ * - Where git can guess an identity from the passwd database (macOS, and any Linux box with a gecos field), the merge
148
+ * SUCCEEDS and commits as `<login>@<hostname>.local`. Nothing fails, and the daemon's merge commit is attributed to
149
+ * a machine-derived address that is not the operator's. That is the exact outcome lib/identity.mjs exists to prevent,
150
+ * arriving on the one commit the daemon makes itself rather than on a session's.
151
+ * - Where it cannot (a container, a CI runner, `user.useConfigOnly`), the merge fails with "Committer identity
152
+ * unknown". `mergeBaseInto` reports any such failure as `conflict: true`, so the operator is told their branch
153
+ * cannot be merged with its base and pointed at a conflict that does not exist, and every divergent-base fix round
154
+ * and every `resume` is blocked behind it.
155
+ *
156
+ * A null resolution stays legal rather than throwing, and that is the deliberate half. `originOf` and `currentBranch`
157
+ * commit nothing, and refusing a read because the machine has no committer would be a worse bug than the one above.
158
+ * The refusal belongs where a commit is actually being paid for: `requireIdentity` in `startRun` and in `start`, before
159
+ * a session is spawned. So this resolver is allowed to answer nothing, and git decides what a commit without one means.
160
+ */
161
+ export function makeGit({ config, exec = execFileAsync, credential = async () => null, identity = async () => null } = {}) {
162
+ const env = async () => ({ ...process.env, ...runEnv(config, await credential(), await identity()) });
163
+ const git = async (args, cwd, timeout = GIT_TIMEOUT_MS) => (await exec("git", args, { cwd, env: await env(), timeout })).stdout.trim();
164
+ // NUL-delimited paths preserve whitespace and newlines; never trim them.
165
+ const gitPaths = async (args, cwd) => {
166
+ const { stdout } = await exec("git", args, { cwd, env: await env(), timeout: GIT_PATH_LIMITS.timeoutMs, maxBuffer: GIT_PATH_LIMITS.bytes });
167
+ if (typeof stdout !== "string" || Buffer.byteLength(stdout) > GIT_PATH_LIMITS.bytes || (stdout && !stdout.endsWith("\0"))) throw new Error("incomplete or oversized Git path metadata; preserve the checkout and retrieve complete paths before publication");
168
+ return stdout ? stdout.slice(0, -1).split("\0") : [];
169
+ };
170
+ const gh = async (args, cwd, timeout = CLONE_TIMEOUT_MS, beforeExec = null) => {
171
+ const options = { cwd, env: await env(), timeout };
172
+ beforeExec?.();
173
+ return (await exec("gh", args, options)).stdout.trim();
174
+ };
175
+
176
+ async function ensureHttpsRemote(dir) {
177
+ const origin = await git(["remote", "get-url", "origin"], dir);
178
+ const repo = repoFromRemote(origin);
179
+ if (repo && !origin.startsWith("https://")) await git(["remote", "set-url", "origin", cloneUrl(repo)], dir);
180
+ }
181
+
182
+ /**
183
+ * The `commit-msg` hook that refuses a commit message crediting an AI harness, written into a checkout's own
184
+ * `.git/hooks`. Idempotent, and called on every path that hands a session a worktree, not only on the clone: a
185
+ * checkout made before this existed, or one whose hooks directory an operator cleared, must get it on the next use
186
+ * rather than staying unguarded until someone deletes it.
187
+ *
188
+ * `.git/hooks` is git's default, so nothing has to be configured and `runEnv` gains no path. See lib/attribution.mjs
189
+ * for why not a directory inside the working tree.
190
+ *
191
+ * Never fatal. A hook that could not be written is a missing guard and not a reason to refuse a session the checkout
192
+ * it needs, so the failure is returned and the caller logs it. The identity half of the guarantee (`user.name` and
193
+ * `user.email` in `RUN_GIT_CONFIG`) does not depend on this file at all.
194
+ */
195
+ function installHooks(dir) {
196
+ try {
197
+ writeRepositoryText(dir, ".git/hooks/commit-msg", COMMIT_MSG_HOOK, { mode: 0o755, allowGit: true });
198
+ return { installed: true };
199
+ } catch (e) { return { installed: false, why: e.message }; }
200
+ }
201
+
202
+ return {
203
+ containsCommit: async (dir, sha) => {
204
+ try { await git(["merge-base", "--is-ancestor", sha, "HEAD"], dir); return true; }
205
+ catch (e) { if (e.code === 1) return false; throw e; }
206
+ },
207
+ fileAt: async (dir, ref, path) => {
208
+ const listing = await git(["ls-tree", "-z", ref, "--", path], dir);
209
+ if (!listing) return null;
210
+ if (!listing.startsWith("100644 blob ") && !listing.startsWith("100755 blob ")) throw new Error(`${path} at ${ref} is not a regular file`);
211
+ // Keep final newlines: these bytes are compared with the approved source snapshot.
212
+ return (await exec("git", ["show", `${ref}:${path}`], { cwd: dir, env: await env(), timeout: GIT_TIMEOUT_MS })).stdout;
213
+ },
214
+ installHooks,
215
+ ensureHttpsRemote,
216
+ /**
217
+ * Fresh worktree on a new branch cut from `base` on the remote.
218
+ *
219
+ * `base` is whatever `resolveBase` decided (lib/config.mjs): the repository's configured base, or its default
220
+ * branch when nothing configured one. It used to be `defaultBranch` and nothing else, which cut every branch from
221
+ * the repository's default even for a project whose pull requests all target `staging`.
222
+ *
223
+ * The fetch is the reason the clone is not the whole of this. `gh repo clone --no-checkout` brings the remote's
224
+ * refs as they are at clone time, which for a fresh directory is current; for one whose parent directory was left
225
+ * behind by a previous run it is whatever git last saw. Fetching the base by name before cutting from it is what
226
+ * makes "cut from the current base" true rather than nearly true, and it is the whole of what a rebase would have
227
+ * done at `start`: a branch cut from the tip of its base has nothing to rebase onto.
228
+ */
229
+ async create({ nameWithOwner, prdId, base }) {
230
+ let cloneAttempted = false;
231
+ try {
232
+ const path = worktreePath(config, nameWithOwner, prdId);
233
+ if (exists(join(path, ".git"))) throw new Error(`worktree already exists at ${path}`);
234
+ ensureDir(join(path, ".."), 0o755);
235
+ await gh(["repo", "clone", cloneUrl(nameWithOwner), path, "--", "--filter=blob:none", "--no-checkout"], undefined, CLONE_TIMEOUT_MS, () => { cloneAttempted = true; });
236
+ await git(["fetch", "origin", base], path);
237
+ await git(["checkout", "-b", branchFor(prdId), `origin/${base}`], path);
238
+ installHooks(path);
239
+ return path;
240
+ } catch (error) {
241
+ // Only the creator can prove that no clone executor was invoked. Env
242
+ // resolution is preflight too; any executor failure can leave a clone.
243
+ error.cloneAttempted = cloneAttempted;
244
+ throw error;
245
+ }
246
+ },
247
+ /**
248
+ * Bring `base` into the checked-out branch with a merge commit, before a session is handed the worktree.
249
+ *
250
+ * A MERGE and not a rebase, and this is the decision the whole change turns on. Three things in this codebase make
251
+ * a rebase the wrong instrument, and none of them is a matter of taste:
252
+ *
253
+ * - A rebase rewrites history, so the pushed branch can only move with `--force`. `lib/prompt.mjs`'s RULES tell
254
+ * every session "Do not force-push". A daemon that force-pushes while forbidding its sessions to is two rules,
255
+ * and the one an operator would find is the one that broke their branch.
256
+ * - `reviewerVerdict` (lib/events.mjs) clears a head only when the sha the reviewer named is a prefix of it. Every
257
+ * rewrite makes every prior clearance describe a commit that no longer exists, so a rebase between rounds
258
+ * silently costs a re-review cycle, and the reviewer's inline comments are anchored to those same shas.
259
+ * - The tick reads a head it did not put there as a human's push (`headChanged`): it resets the reviewer clock and
260
+ * un-nudges. A daemon force-push would look to the daemon itself like a stranger pushing.
261
+ *
262
+ * A merge has none of those. It moves the head forward, so the remote takes it without `--force`; every reviewed
263
+ * commit stays an ancestor, so a clearance keeps describing something real; and the commit it adds is the daemon's
264
+ * own, which is the honest record of what happened. The cost is a merge commit in the history, which is the
265
+ * cheaper of the two prices.
266
+ *
267
+ * The merge is the daemon's, so `--no-ff` is explicit: a base that is already an ancestor is `{ merged: false }`
268
+ * and nothing is committed, and anything else gets one commit that says what it brought in.
269
+ *
270
+ * Conflicts abort. A half-merged checkout with conflict markers handed to a session as if it were clean is the
271
+ * worst thing this function could leave, so a failure runs `git merge --abort` and reports rather than throwing
272
+ * something the caller has to guess at. `--abort` is itself allowed to fail (there may be nothing to abort) and
273
+ * that failure is not allowed to hide the conflict.
274
+ */
275
+ async mergeBase(dir, base) {
276
+ await git(["fetch", "origin", base], dir);
277
+ const head = await git(["rev-parse", "HEAD"], dir);
278
+ const baseSha = await git(["rev-parse", `origin/${base}`], dir);
279
+ // Already up to date: the base's tip is an ancestor of this branch, so there is nothing to bring in and nothing
280
+ // to record. `merge-base --is-ancestor` exits 0 for that and non-zero otherwise, which is what the throw means.
281
+ try { await git(["merge-base", "--is-ancestor", baseSha, head], dir); return { merged: false, base, baseSha, head }; }
282
+ catch { /* not an ancestor: there is something to merge */ }
283
+ try {
284
+ await git(["merge", "--no-ff", "--no-edit", "-m", "Merge approved base into task branch", `origin/${base}`], dir);
285
+ } catch (e) {
286
+ try { await git(["merge", "--abort"], dir); } catch { /* nothing to abort, or the abort itself failed; the conflict is what matters */ }
287
+ return { merged: false, conflict: true, base, baseSha, head, why: e.message.split("\n").find(l => l.trim()) || `could not merge origin/${base}` };
288
+ }
289
+ return { merged: true, base, baseSha, head, sha: await git(["rev-parse", "HEAD"], dir) };
290
+ },
291
+ /**
292
+ * Worktree for an existing PR branch, used by `register` and by recovery. Idempotent. When the directory
293
+ * exists it must be a clone of the same repository on that branch; anything else is an error, never silently
294
+ * reused.
295
+ */
296
+ async fromBranch({ nameWithOwner, prdId, branch, path = worktreePath(config, nameWithOwner, prdId) }) {
297
+ if (exists(join(path, ".git"))) {
298
+ const origin = await git(["remote", "get-url", "origin"], path);
299
+ // Exact owner/repo, never a substring: `acme/widget-fork` is not `acme/widget`.
300
+ if (!remoteMatches(origin, nameWithOwner)) throw new Error(`${path} is a clone of ${origin}, not ${nameWithOwner}`);
301
+ await ensureHttpsRemote(path);
302
+ await git(["fetch", "origin", branch], path);
303
+ await git(["checkout", branch], path);
304
+ installHooks(path);
305
+ return path;
306
+ }
307
+ ensureDir(join(path, ".."), 0o755);
308
+ await gh(["repo", "clone", cloneUrl(nameWithOwner), path, "--", "--filter=blob:none", "--branch", branch]);
309
+ installHooks(path);
310
+ return path;
311
+ },
312
+ exists: path => exists(join(path, ".git")),
313
+ toplevel: dir => git(["rev-parse", "--show-toplevel"], dir),
314
+ /**
315
+ * Commits on the checked-out branch that the remote does not have, newest first, as `{ sha, subject }`.
316
+ *
317
+ * This is what `resume` tells a session so it does not spend a round rediscovering its own work. The real case:
318
+ * a run implemented a brief, made four commits, could not push, and stopped. Resumed, the session's memory ends
319
+ * mid-task and it cannot tell "I committed this" from "I was about to"; without being told, the cheapest thing it
320
+ * can do is re-derive the state by reading the diff, which costs a round and risks it redoing finished work.
321
+ *
322
+ * Two ranges, and the second is the one that matters. `origin/<branch>..HEAD` is right for a branch that has been
323
+ * pushed before and has moved since. But the case this exists for is a branch that was NEVER pushed, and there
324
+ * `origin/<branch>` does not exist: git errors, or worse, an older git reports nothing and the whole paragraph
325
+ * silently goes missing for exactly the run it was written for. Verified against the real stranded branch, where
326
+ * the first range found nothing and the second found all four commits. So a missing remote branch falls back to
327
+ * `origin/<base>..HEAD`, which is every commit the branch has added since it was cut.
328
+ *
329
+ * Empty on any failure, and deliberately so. A detached HEAD, a checkout whose refs are not fetched, no remote at
330
+ * all: none is worth failing a resume over, and the prompt omits the paragraph rather than asserting something it
331
+ * could not confirm.
332
+ */
333
+ async unpushed(dir, branch, base = null) {
334
+ const parse = out => (out ? out.split("\n").filter(Boolean).map(l => { const [sha, ...rest] = l.split(" "); return { sha, subject: rest.join(" ") }; }) : []);
335
+ const range = async r => { try { return parse(await git(["log", "--oneline", "--no-decorate", r], dir)); } catch { return null; } };
336
+ // Does the remote even have this branch? `rev-parse --verify` answers without a network round trip.
337
+ const hasRemote = await git(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${branch}`], dir).then(o => !!o).catch(() => false);
338
+ if (hasRemote) return (await range(`origin/${branch}..HEAD`)) ?? [];
339
+ // Never pushed. Everything since the base is unpushed by definition. `origin/HEAD` covers the common case where
340
+ // no base was passed; it is a symbolic ref to the remote's default branch.
341
+ for (const r of [base && `origin/${base}..HEAD`, "origin/HEAD..HEAD"].filter(Boolean)) {
342
+ const found = await range(r);
343
+ if (found?.length) return found;
344
+ }
345
+ return [];
346
+ },
347
+ /**
348
+ * Uncommitted work in a checkout, as { modified, untracked }. Counts only; no paths, because the caller records this
349
+ * in the registry and a file list there would grow without bound and carry the operator's filenames into state.
350
+ *
351
+ * This exists because `unpushed` does not cover the case that loses work. A run was SIGKILLed at 31.2 minutes having
352
+ * done a full review round and written its commit message to a temp file, one tool call before `git commit`. Nothing
353
+ * was committed, so `unpushed` counted zero and every signal said the entry simply timed out, while the worktree held
354
+ * six modified and four untracked files that no commit, branch or PR contained. Uncommitted is strictly more fragile
355
+ * than unpushed: `resume` recreates a missing checkout without asking, and a `git clean` takes it, either of which
356
+ * destroys the round in silence.
357
+ *
358
+ * `--porcelain` because its format is a stability guarantee and `-uall` so an untracked DIRECTORY is counted as its
359
+ * files rather than one line; the untracked half is what a recreate would take with no trace.
360
+ *
361
+ * Zeroes on any failure, like `unpushed`: this is reporting, and a checkout that has gone missing is not worth
362
+ * failing a kill over. `{ modified: 0, untracked: 0 }` reads as "nothing to report", which is also the honest answer
363
+ * when the directory is gone, and the caller has the worktree path on screen either way.
364
+ */
365
+ async dirty(dir, { strict = false } = {}) {
366
+ let out;
367
+ try { out = await git(["status", "--porcelain", "-uall"], dir); }
368
+ catch (error) { if (strict) throw error; return { modified: 0, untracked: 0 }; }
369
+ let modified = 0, untracked = 0;
370
+ for (const line of (out || "").split("\n")) {
371
+ if (!line.trim()) continue;
372
+ if (line.startsWith("??")) untracked++;
373
+ else modified++;
374
+ }
375
+ return { modified, untracked };
376
+ },
377
+ /** The `origin` remote URL of a checkout, as git stores it. */
378
+ originOf: dir => git(["remote", "get-url", "origin"], dir),
379
+ /** The checked-out branch name, or "HEAD" when the checkout is detached. */
380
+ currentBranch: dir => git(["rev-parse", "--abbrev-ref", "HEAD"], dir),
381
+ head: dir => git(["rev-parse", "HEAD"], dir),
382
+ async trackedPaths(dir) {
383
+ const [head, index] = await Promise.all([
384
+ gitPaths(["ls-tree", "--name-only", "-r", "-z", "HEAD"], dir),
385
+ gitPaths(["ls-files", "--cached", "-z"], dir),
386
+ ]);
387
+ return [...new Set([...head, ...index])];
388
+ },
389
+ /** Text artifacts preserve input modes; new files use the regular 0644 mode. */
390
+ async resultModesMatch(dir, inputHead, paths) {
391
+ const [base, head, index] = await Promise.all([
392
+ gitPaths(["ls-tree", "-r", "-z", inputHead], dir),
393
+ gitPaths(["ls-tree", "-r", "-z", "HEAD"], dir),
394
+ gitPaths(["ls-files", "--stage", "-z"], dir),
395
+ ]);
396
+ const modes = (records, staged = false) => {
397
+ const map = new Map();
398
+ for (const record of records) {
399
+ const match = record.match(staged ? /^(\d{6}) [a-f0-9]{40,64} ([0-3])\t([\s\S]+)$/ : /^(\d{6}) (?:blob|commit) [a-f0-9]{40,64}\t([\s\S]+)$/);
400
+ if (!match || (!staged && map.has(match[2]))) throw new Error("invalid Git mode metadata; retrieve complete tree/index records before publication");
401
+ map.set(match[staged ? 3 : 2], staged && match[2] !== "0" ? "unmerged" : match[1]);
402
+ }
403
+ return map;
404
+ };
405
+ const input = modes(base), current = modes(head), staged = modes(index, true);
406
+ return paths.every(path => {
407
+ const expected = input.get(path) ?? "100644";
408
+ if (!["100644", "100755"].includes(expected) || (current.has(path) && current.get(path) !== expected) ||
409
+ (staged.has(path) && staged.get(path) !== expected)) return false;
410
+ let stat;
411
+ try { stat = lstatSync(repositoryPath(dir, path, { allowMissingParents: true })); }
412
+ catch (error) { if (error.code === "ENOENT") return true; throw error; }
413
+ return stat.isFile() && ((stat.mode & 0o100) ? "100755" : "100644") === expected;
414
+ });
415
+ },
416
+ async commitResult(dir, paths) {
417
+ const staged = await gitPaths(["diff", "--cached", "--name-only", "-z"], dir);
418
+ if (staged.some(path => !paths.includes(path))) throw new Error("unrelated staged changes prevent execution result commit; preserve them before publishing");
419
+ // A lost commit reply may leave a deletion already absent from the index
420
+ // and disk. Its owned HEAD is reconciled by the result boundary.
421
+ const tracked = new Set(await gitPaths(["ls-files", "--cached", "-z"], dir));
422
+ const pending = paths.filter(path => tracked.has(path) || exists(repositoryPath(dir, path, { allowMissingParents: true })));
423
+ if (pending.length) await git(["add", "--", ...pending], dir);
424
+ if (await git(["diff", "--cached", "--name-only"], dir)) await git(["commit", "-m", "Apply execution artifacts"], dir);
425
+ },
426
+ pushResult: (dir, branch) => git(["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], dir),
427
+ pushSnapshotRef: (dir, branch) => git(["push", "origin", `HEAD:refs/heads/${branch}`], dir),
428
+ async resultHeadMatches(dir, inputHead, paths) {
429
+ try { await git(["merge-base", "--is-ancestor", inputHead, "HEAD"], dir); } catch { return false; }
430
+ const changed = await gitPaths(["diff", "--name-only", "-z", inputHead, "HEAD"], dir);
431
+ return changed.every(path => paths.includes(path));
432
+ },
433
+ };
434
+ }