@uchibeke/gallivant 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +26 -0
- package/LICENSE +204 -0
- package/README.md +239 -2
- package/SECURITY.md +19 -0
- package/bin/gallivant.mjs +261 -0
- package/bin/package-plugin.mjs +16 -0
- package/bin/release-live.mjs +29 -0
- package/bin/release-package.mjs +23 -0
- package/bin/session-runtime.mjs +13 -0
- package/bin/task-delivery-state.mjs +12 -0
- package/docs/README.md +72 -0
- package/docs/agent-setup.md +228 -0
- package/docs/app.md +111 -0
- package/docs/architecture.md +265 -0
- package/docs/chains.md +102 -0
- package/docs/codex-cloud.md +284 -0
- package/docs/commands.md +649 -0
- package/docs/config.md +437 -0
- package/docs/execution.md +796 -0
- package/docs/guardrails.md +311 -0
- package/docs/install.md +398 -0
- package/docs/launch.md +45 -0
- package/docs/learning.md +369 -0
- package/docs/network-recovery.md +101 -0
- package/docs/openai-agents.md +457 -0
- package/docs/prd.md +246 -0
- package/docs/preparation.md +110 -0
- package/docs/release-qualification.md +196 -0
- package/docs/releasing.md +149 -0
- package/docs/runtime-environment.md +113 -0
- package/docs/task-delivery.md +476 -0
- package/docs/tasks.md +739 -0
- package/lib/aport.mjs +95 -0
- package/lib/attribution.mjs +95 -0
- package/lib/block.mjs +89 -0
- package/lib/capabilities.mjs +3 -0
- package/lib/chains.mjs +222 -0
- package/lib/codex_cloud.mjs +160 -0
- package/lib/codex_cloud_config.mjs +20 -0
- package/lib/codex_cloud_input.mjs +49 -0
- package/lib/codex_cloud_result.mjs +74 -0
- package/lib/commands/doctor.mjs +466 -0
- package/lib/commands/init.mjs +245 -0
- package/lib/commands/learn.mjs +54 -0
- package/lib/commands/new.mjs +47 -0
- package/lib/commands/ready.mjs +45 -0
- package/lib/commands/register.mjs +249 -0
- package/lib/commands/resume.mjs +224 -0
- package/lib/commands/serve.mjs +349 -0
- package/lib/commands/start.mjs +247 -0
- package/lib/commands/start_services.mjs +22 -0
- package/lib/commands/status.mjs +150 -0
- package/lib/commands/task.mjs +73 -0
- package/lib/commands/up.mjs +228 -0
- package/lib/commands/why.mjs +143 -0
- package/lib/config.mjs +461 -0
- package/lib/context.mjs +102 -0
- package/lib/control.mjs +202 -0
- package/lib/control_contract.mjs +16 -0
- package/lib/coordinator.mjs +19 -0
- package/lib/credential.mjs +161 -0
- package/lib/daemon.mjs +156 -0
- package/lib/deliverable.mjs +357 -0
- package/lib/events.mjs +261 -0
- package/lib/executable.mjs +31 -0
- package/lib/execution.mjs +707 -0
- package/lib/execution_contract.mjs +15 -0
- package/lib/execution_input.mjs +4 -0
- package/lib/execution_io.mjs +94 -0
- package/lib/execution_journal.mjs +69 -0
- package/lib/execution_preparation.mjs +29 -0
- package/lib/execution_result.mjs +266 -0
- package/lib/fs.mjs +140 -0
- package/lib/gh.mjs +289 -0
- package/lib/git.mjs +434 -0
- package/lib/harness.mjs +521 -0
- package/lib/harness_log.mjs +432 -0
- package/lib/hash.mjs +16 -0
- package/lib/identity.mjs +98 -0
- package/lib/learning.mjs +654 -0
- package/lib/learning_contract.mjs +2 -0
- package/lib/learning_data.mjs +135 -0
- package/lib/learning_input.mjs +111 -0
- package/lib/learning_qualification.mjs +67 -0
- package/lib/learning_signer.mjs +151 -0
- package/lib/learning_store.mjs +88 -0
- package/lib/log.mjs +53 -0
- package/lib/network_diagnostics.mjs +64 -0
- package/lib/network_recovery.mjs +42 -0
- package/lib/notify.mjs +58 -0
- package/lib/openai_agents.mjs +373 -0
- package/lib/openai_agents_config.mjs +14 -0
- package/lib/openai_agents_http.mjs +87 -0
- package/lib/openai_agents_input.mjs +15 -0
- package/lib/openai_agents_sources.mjs +112 -0
- package/lib/owner_delivery.mjs +443 -0
- package/lib/plugin_package.mjs +340 -0
- package/lib/prd.mjs +635 -0
- package/lib/preparation_agents.mjs +33 -0
- package/lib/preparation_codex.mjs +212 -0
- package/lib/probe.mjs +126 -0
- package/lib/prompt.mjs +238 -0
- package/lib/provider_health.mjs +55 -0
- package/lib/public-format.mjs +31 -0
- package/lib/public_output.mjs +49 -0
- package/lib/release_package.mjs +154 -0
- package/lib/repository_files.mjs +86 -0
- package/lib/run.mjs +385 -0
- package/lib/run_log.mjs +94 -0
- package/lib/runtime-environment.mjs +87 -0
- package/lib/runtime_dependencies.mjs +15 -0
- package/lib/runtime_node.mjs +107 -0
- package/lib/runtime_state.mjs +174 -0
- package/lib/sources/index.mjs +27 -0
- package/lib/sources/prd.mjs +50 -0
- package/lib/start_contract.mjs +37 -0
- package/lib/state.mjs +246 -0
- package/lib/task_allowance.mjs +31 -0
- package/lib/task_completion.mjs +61 -0
- package/lib/task_contract.mjs +450 -0
- package/lib/task_delivery.mjs +219 -0
- package/lib/task_delivery_cancel.mjs +34 -0
- package/lib/task_delivery_contract.mjs +61 -0
- package/lib/task_delivery_mcp.mjs +40 -0
- package/lib/task_delivery_store.mjs +43 -0
- package/lib/task_delivery_view.mjs +88 -0
- package/lib/task_environment.mjs +281 -0
- package/lib/task_prepare.mjs +224 -0
- package/lib/task_store.mjs +38 -0
- package/lib/tasks.mjs +513 -0
- package/lib/tick.mjs +1654 -0
- package/lib/time.mjs +36 -0
- package/lib/tracker/assets.mjs +43 -0
- package/lib/tracker/pages.mjs +320 -0
- package/lib/tracker/runs.mjs +102 -0
- package/lib/tracker/stream.mjs +74 -0
- package/lib/tracker_process.mjs +107 -0
- package/lib/verification_sandbox.mjs +181 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +605 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +156 -0
- package/node_modules/smol-toml/dist/error.d.ts +37 -0
- package/node_modules/smol-toml/dist/error.js +67 -0
- package/node_modules/smol-toml/dist/extract.js +289 -0
- package/node_modules/smol-toml/dist/index.cjs +770 -0
- package/node_modules/smol-toml/dist/index.d.ts +44 -0
- package/node_modules/smol-toml/dist/index.js +34 -0
- package/node_modules/smol-toml/dist/parse.d.ts +73 -0
- package/node_modules/smol-toml/dist/parse.js +164 -0
- package/node_modules/smol-toml/dist/primitive.js +151 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
- package/node_modules/smol-toml/dist/stringify.js +219 -0
- package/node_modules/smol-toml/dist/struct.js +156 -0
- package/node_modules/smol-toml/dist/util.d.ts +43 -0
- package/node_modules/smol-toml/dist/util.js +57 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/npm-shrinkwrap.json +1823 -0
- package/package.json +105 -4
- package/templates/claude-settings.json +18 -0
- package/templates/passport.gallivant.json +83 -0
- package/templates/plugin/bundled-runtime.json +26 -0
- package/templates/plugin/schemas/mcp.schema.json +120 -0
- package/templates/plugin/schemas/plugin.schema.json +65 -0
- package/templates/plugin/task-delivery.SKILL.md +60 -0
- package/templates/prd.md +32 -0
- package/templates/tracker/tracker.css +301 -0
- 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
|
+
}
|