@uchibeke/gallivant 0.2.0 → 0.3.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.
- package/README.md +120 -60
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +41 -14
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +6 -2
- package/docs/agent-setup.md +55 -41
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +103 -14
- package/docs/config.md +45 -13
- package/docs/execution.md +1 -1
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +137 -67
- package/docs/launch.md +7 -4
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +3 -2
- package/docs/prd.md +23 -2
- package/docs/preparation.md +2 -2
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +46 -22
- package/docs/task-delivery.md +5 -5
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -453
- package/lib/commands/init.mjs +281 -28
- package/lib/commands/register.mjs +81 -13
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +28 -21
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +132 -11
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/fs.mjs +10 -3
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +373 -98
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/commands/init.mjs
CHANGED
|
@@ -1,34 +1,287 @@
|
|
|
1
|
-
|
|
1
|
+
// `gallivant init`: set up the repository you are standing in, then hand over to `doctor`.
|
|
2
|
+
//
|
|
3
|
+
// Explicit setup only. It never signs in for you, never reads a password or token, never touches global harness
|
|
4
|
+
// settings, credentials or repository permissions, and never rewrites a key the operator wrote. Repeating it is safe:
|
|
5
|
+
// an existing config keeps every value as written and gains at most one `repos` entry, the one for this checkout.
|
|
6
|
+
//
|
|
7
|
+
// Every prerequisite is checked before anything is written, so a signed-out `gh`, a signed-out harness or a checkout
|
|
8
|
+
// that is not a GitHub repository stops the command with the exact action and leaves no partial state behind.
|
|
9
|
+
import { mkdirSync, openSync, closeSync, writeFileSync, lstatSync, realpathSync } from "node:fs";
|
|
2
10
|
import { join, dirname } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { isDeepStrictEqual } from "node:util";
|
|
13
|
+
import { gallivantHome, expand, templatePath, readJson, writeJsonAtomic, contract, VERSION, PACKAGE_NAME } from "../fs.mjs";
|
|
14
|
+
import { discoverHarnesses, validate, merge, configPath, repoEntry } from "../config.mjs";
|
|
15
|
+
import { hosts, makeHostRegistry } from "../hosts.mjs";
|
|
16
|
+
import { repositoryReviewConfig } from "../review_policy.mjs";
|
|
17
|
+
import { safeOrigin, assertBranchName } from "../repository.mjs";
|
|
18
|
+
export { safeOrigin };
|
|
6
19
|
import { silentLog } from "../log.mjs";
|
|
20
|
+
import { checkHarness } from "../probe.mjs";
|
|
21
|
+
import { describe as describeCredential } from "../credential.mjs";
|
|
22
|
+
import { doctor as runDoctor, repositoryWriteScopeCheck } from "./doctor.mjs";
|
|
23
|
+
import { acquireCliMutation } from "../daemon.mjs";
|
|
24
|
+
import { repositoryPreflight } from "../repository_preflight.mjs";
|
|
7
25
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
26
|
+
export const INIT_HARNESSES = ["codex", "claude"];
|
|
27
|
+
|
|
28
|
+
/** Every message that stops setup ends with this, so nobody reads a stopped run as a half-finished one. */
|
|
29
|
+
const NOTHING_WRITTEN = "Setup did not complete and nothing was written.";
|
|
30
|
+
|
|
31
|
+
const firstLine = e => String(e?.message || e).split("\n")[0];
|
|
32
|
+
const isSymlink = p => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The repository a checkout belongs to, read from its `origin` remote. Local only: no network, no `gh`. Throws a
|
|
36
|
+
* message naming the unmet prerequisite, so a directory that is not a GitHub checkout is refused before anything else
|
|
37
|
+
* runs. `owner/name` is lowercased as `repoFromRemote` does, which is also how `rememberRepo` and `doctor` spell it.
|
|
38
|
+
*/
|
|
39
|
+
export function targetRepository(dir = process.cwd(), exec = execFileSync) {
|
|
40
|
+
const git = args => {
|
|
41
|
+
try { return exec("git", args, { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim(); }
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e?.code === "ENOENT") throw new Error(`git is not on PATH. Install Git, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
const checkout = git(["rev-parse", "--show-toplevel"]);
|
|
48
|
+
if (!checkout) throw new Error(`${dir} is not inside a Git checkout. Run gallivant init from the repository you want to set up (cd into it first). ${NOTHING_WRITTEN}`);
|
|
49
|
+
const origin = git(["remote", "get-url", "origin"]);
|
|
50
|
+
if (!origin) throw new Error(`${checkout} has no origin remote. Create the repository on GitHub and add it (git remote add origin https://github.com/<owner>/<name>.git), then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
51
|
+
let repo;
|
|
52
|
+
try { repo = hosts.inspectRemote(origin).repository.key; }
|
|
53
|
+
catch (error) { throw new Error(`${error.message} ${NOTHING_WRITTEN}`); }
|
|
54
|
+
// The redacted form is the only one returned: nothing downstream needs the credential, and nothing may print it.
|
|
55
|
+
return { checkout, origin: safeOrigin(origin), repo };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Infer a harness only when there is one local choice; otherwise setup requires an explicit selection. */
|
|
59
|
+
const onlyLocalHarness = raw => {
|
|
60
|
+
const names = Object.keys(raw?.harness || {}).filter(n => INIT_HARNESSES.includes(n));
|
|
61
|
+
return names.length === 1 ? names[0] : null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** `checkHarness` reads expanded paths, as `load` would hand them over; a raw config may still spell them with `~`. */
|
|
65
|
+
const expandedFor = (config, name) => {
|
|
66
|
+
const h = config.harness[name];
|
|
67
|
+
return { ...config, harness: { ...config.harness, [name]: { ...h, bin: expand(h.bin), ...(h.settings ? { settings: expand(h.settings) } : {}) } } };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Options:
|
|
72
|
+
* home the Gallivant home; `config.json` lives here
|
|
73
|
+
* harness `codex` or `claude`; null keeps an existing config's harnesses and defaults a new one to codex
|
|
74
|
+
* worktrees where task checkouts go; refused when an existing config already chose a different directory
|
|
75
|
+
* dir the target checkout; the repository it belongs to is what gets recorded
|
|
76
|
+
* The rest are injected for tests: harness discovery, the operator's own `gh` (used only to name the login), the
|
|
77
|
+
* credential resolver and the `gh` built from what it resolves, repository resolution, the readiness check and `doctor`.
|
|
78
|
+
*/
|
|
79
|
+
export async function init(options = {}) {
|
|
80
|
+
for (let attempt = 0; ; attempt++) {
|
|
81
|
+
try { return await initAttempt(options); }
|
|
82
|
+
catch (error) {
|
|
83
|
+
if (error.code !== "INIT_CONFIG_CHANGED" || attempt >= 2) throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function initAttempt({ home = gallivantHome(), harness = null, worktrees = null, hostConfig = null, base = null, dir = process.cwd(),
|
|
89
|
+
discover = discoverHarnesses, gh = null, repository = targetRepository, harnessCheck = checkHarness, doctor = runDoctor,
|
|
90
|
+
resolve = null, ghFor = null, transportPreflight = repositoryPreflight } = {}) {
|
|
91
|
+
if (harness != null && !INIT_HARNESSES.includes(harness)) throw new Error("init supports --harness codex or claude; configure other adapters explicitly after checking docs/install.md");
|
|
13
92
|
home = expand(home);
|
|
14
|
-
const file =
|
|
15
|
-
|
|
16
|
-
for (let
|
|
17
|
-
if (
|
|
18
|
-
if (dirname(
|
|
19
|
-
}
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
93
|
+
const file = configPath(home);
|
|
94
|
+
// `lstat` rather than `exists`: a dangling symlink does not exist and is still a symlink.
|
|
95
|
+
for (let d = home; ; d = dirname(d)) {
|
|
96
|
+
if (isSymlink(d)) throw new Error(`Setup directory must not be a symlink: ${d}. ${NOTHING_WRITTEN}`);
|
|
97
|
+
if (dirname(d) === d) break;
|
|
98
|
+
}
|
|
99
|
+
const missing = Symbol("missing config");
|
|
100
|
+
const raw = readJson(file, missing);
|
|
101
|
+
const existing = raw !== missing;
|
|
102
|
+
if (existing && (raw === null || typeof raw !== "object" || Array.isArray(raw))) throw new Error(`${file} is not a JSON object. Fix or move it, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
103
|
+
|
|
104
|
+
// 1. The repository, before any network call: a directory that is not a GitHub checkout has nothing to set up.
|
|
105
|
+
const target = repository(dir);
|
|
106
|
+
if (base !== null) assertBranchName(base);
|
|
107
|
+
const setup = existing ? structuredClone(raw) : {};
|
|
108
|
+
let selectedHost = makeHostRegistry({ config: setup }).inspect(target.repo);
|
|
109
|
+
if (hostConfig !== null) {
|
|
110
|
+
const settings = readJson(expand(hostConfig), null);
|
|
111
|
+
const errors = selectedHost.adapter.validateConfig?.(settings) ?? ["This host does not support --host-config"];
|
|
112
|
+
if (errors.length) throw new Error(`${errors.join("; ")}. ${NOTHING_WRITTEN}`);
|
|
113
|
+
const previous = setup.git_hosts?.[selectedHost.repository.host];
|
|
114
|
+
// Setup adds observed identities after importing policy. Carry only those
|
|
115
|
+
// generated fields into the comparison; omitted policy remains a conflict,
|
|
116
|
+
// and a supplied identity may never overwrite a different saved binding.
|
|
117
|
+
for (const [path, binding] of Object.entries(previous?.repositories ?? {})) {
|
|
118
|
+
if (!binding || typeof binding !== "object") continue;
|
|
119
|
+
const identity = Object.fromEntries(["uuid", "workspaceUuid"].filter(key => Object.hasOwn(binding, key)).map(key => [key, binding[key]]));
|
|
120
|
+
if (Object.keys(identity).length) settings.repositories = { ...settings.repositories, [path]: { ...identity, ...settings.repositories?.[path] } };
|
|
121
|
+
}
|
|
122
|
+
if (previous && !isDeepStrictEqual(previous, settings)) throw new Error(`Existing host settings differ; edit the selected config explicitly instead of replacing them with --host-config. ${NOTHING_WRITTEN}`);
|
|
123
|
+
setup.git_hosts = { ...setup.git_hosts, [selectedHost.repository.host]: previous ?? settings };
|
|
124
|
+
selectedHost = makeHostRegistry({ config: setup }).inspect(target.repo);
|
|
125
|
+
}
|
|
126
|
+
gh ??= selectedHost.api;
|
|
127
|
+
resolve ??= config => selectedHost.adapter.credential(config, selectedHost.repository);
|
|
128
|
+
ghFor ??= credential => makeHostRegistry({ credential: async () => credential, log: silentLog }).inspect(target.repo).api;
|
|
129
|
+
|
|
130
|
+
// 2. GitHub sign-in, by asking `gh` who it is. The fix is `gh`'s own login flow; nothing here reads a credential.
|
|
131
|
+
let login = null, ghError = null;
|
|
132
|
+
try { login = await gh.user(); } catch (e) { ghError = firstLine(e); }
|
|
133
|
+
if (selectedHost.adapter.features?.transportSetup) {
|
|
134
|
+
if (!login) throw new Error(`${ghError || "Host API credential is missing"}\n${NOTHING_WRITTEN}`);
|
|
135
|
+
} else if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(login || "")) throw new Error(`GitHub CLI is not signed in${ghError ? ` (${ghError})` : ""}.\nRun: gh auth login\nThen rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
136
|
+
|
|
137
|
+
// 3. The config this run would leave behind, built but not yet written.
|
|
138
|
+
const selected = harness ?? (existing ? onlyLocalHarness(raw) : "codex");
|
|
139
|
+
const candidate = existing ? setup : {
|
|
140
|
+
...setup,
|
|
141
|
+
trusted_authors: [login], worktrees: expand(worktrees || join(dirname(home), ".gallivant-worktrees")),
|
|
142
|
+
harness: {}, notify: null, github_token_file: null, repos: [],
|
|
143
|
+
};
|
|
144
|
+
if (existing && worktrees && expand(worktrees) !== expand(raw.worktrees)) throw new Error(`config already sets worktrees to ${raw.worktrees}; edit ${contract(file)} to move it rather than passing --worktrees. ${NOTHING_WRITTEN}`);
|
|
145
|
+
if (selected && !candidate.harness?.[selected]) {
|
|
146
|
+
const found = discover(home)[selected];
|
|
147
|
+
if (!found) throw new Error(`${selected} is not on PATH. Install it and sign in, then retry gallivant init --harness ${selected}. ${NOTHING_WRITTEN}`);
|
|
148
|
+
candidate.harness = { ...(candidate.harness || {}), [selected]: found };
|
|
149
|
+
}
|
|
150
|
+
if (selectedHost.adapter.reviewPolicy) repositoryReviewConfig(candidate, target.repo, selectedHost.adapter);
|
|
151
|
+
if (base !== null) {
|
|
152
|
+
const previous = (candidate.repos || []).find(r => repoEntry(r)?.repo === target.repo);
|
|
153
|
+
candidate.repos = [...(candidate.repos || []).filter(r => repoEntry(r)?.repo !== target.repo), { ...(typeof previous === "object" ? previous : {}), repo: target.repo, base }];
|
|
154
|
+
}
|
|
155
|
+
const merged = merge(candidate, home);
|
|
156
|
+
const problems = validate(merged, home);
|
|
157
|
+
if (problems.length) throw new Error(`${existing ? `Config problems in ${contract(file)}: ` : ""}${problems.join("; ")}. ${NOTHING_WRITTEN}`);
|
|
158
|
+
if (!selected) throw new Error(`Choose a harness: rerun gallivant init --harness codex or gallivant init --harness claude. ${NOTHING_WRITTEN}`);
|
|
159
|
+
|
|
160
|
+
// 4. The selected harness has to be installed at a supported version and signed in. The adapter owns the fix line
|
|
161
|
+
// (`codex login`, `claude auth login`); init only repeats it. Version and sign-in say nothing about model access.
|
|
162
|
+
if (selected) {
|
|
163
|
+
const ready = await harnessCheck(selected, expandedFor(merged, selected));
|
|
164
|
+
if (!ready.ok) throw new Error(`${selected} is not ready (${ready.detail}).\nRun: ${ready.fix}\nThen rerun gallivant init --harness ${selected}. ${NOTHING_WRITTEN}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5. The repository has to be one a RUN can push to, asked of GitHub with the credential this config resolves
|
|
168
|
+
// (`github_token_file` first, then the environment, then `gh`), exactly as `start` and `doctor` resolve it. The
|
|
169
|
+
// operator's ambient login is the wrong identity to ask: an explicit PAT that cannot reach the repository is the
|
|
170
|
+
// failure this check exists to catch, and an ambient login that can would hide it.
|
|
171
|
+
const runConfig = { ...merged, github_token_file: merged.github_token_file ? expand(merged.github_token_file) : null };
|
|
172
|
+
if (selectedHost.adapter.features?.transportSetup) {
|
|
173
|
+
const report = await transportPreflight(runConfig, target.repo);
|
|
174
|
+
const host = selectedHost.repository.host, path = selectedHost.repository.path;
|
|
175
|
+
candidate.git_hosts = { ...candidate.git_hosts, [host]: { ...candidate.git_hosts?.[host], repositories: {
|
|
176
|
+
...candidate.git_hosts?.[host]?.repositories, [path]: { ...candidate.git_hosts?.[host]?.repositories?.[path], uuid: report.identity.uuid, workspaceUuid: report.identity.workspaceUuid },
|
|
177
|
+
} } };
|
|
178
|
+
} else {
|
|
179
|
+
let credential;
|
|
180
|
+
try { credential = await resolve(runConfig); } catch (e) { throw new Error(`${firstLine(e)}\n${NOTHING_WRITTEN}`); }
|
|
181
|
+
if (!credential?.token) throw new Error(`${credential?.why || "No GitHub credential could be resolved for a run."}\n${NOTHING_WRITTEN}`);
|
|
182
|
+
const identity = describeCredential(credential);
|
|
183
|
+
const runGh = ghFor(credential);
|
|
184
|
+
const perm = await runGh.repoPermissions(selectedHost.repository.path).catch(e => ({ error: firstLine(e) }));
|
|
185
|
+
if (!perm || perm.error) throw new Error(`GitHub cannot resolve ${target.repo} with the run credential (${identity})${perm?.error ? `: ${perm.error}` : ""}. Check that origin (${safeOrigin(target.origin)}) names a repository that credential can see, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
186
|
+
if (perm.archived) throw new Error(`${target.repo} is archived, so nothing can be pushed to it. Unarchive it or run gallivant init in another repository. ${NOTHING_WRITTEN}`);
|
|
187
|
+
if (!perm.push) throw new Error(`the run credential (${identity}) has no push permission on ${target.repo}. ${runConfig.github_token_file ? `Give the token in ${contract(runConfig.github_token_file)} Contents: write on ${target.repo}` : "Ask for write access, or fork it and point origin at the fork"}, then rerun gallivant init. ${NOTHING_WRITTEN}`);
|
|
188
|
+
// Repository role alone does not grant a classic token write scope, even when that account can push.
|
|
189
|
+
const scope = selectedHost.adapter.features?.repositoryScopes ? await repositoryWriteScopeCheck(runGh, credential) : { ok: true };
|
|
190
|
+
if (!scope.ok) throw new Error(`${scope.detail}.\nFix: ${scope.fix}\n${NOTHING_WRITTEN}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 6. Write. The repository is deduplicated by lowercased name whichever form each entry takes, so a second run,
|
|
194
|
+
// or a run from a clone whose remote spells the owner differently, never appends a duplicate. The string form is
|
|
195
|
+
// what a first use writes; a base branch is chosen with `start --base` or by hand, not here.
|
|
196
|
+
const repos = Array.isArray(candidate.repos) ? candidate.repos : [];
|
|
197
|
+
const known = repos.some(r => repoEntry(r)?.repo === target.repo);
|
|
198
|
+
if (!known) candidate.repos = [...repos, target.repo];
|
|
199
|
+
const changed = !existing || JSON.stringify(candidate) !== JSON.stringify(raw);
|
|
28
200
|
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
201
|
+
const owner = acquireCliMutation(home);
|
|
202
|
+
try {
|
|
203
|
+
// Probes await external tools. Another CLI writer may have changed the config meanwhile. Compare under the
|
|
204
|
+
// shared mutation lock, then retry all probes against the new settings rather than overwriting or trusting them.
|
|
205
|
+
const current = readJson(file, missing);
|
|
206
|
+
if (JSON.stringify(current) !== JSON.stringify(raw)) throw Object.assign(new Error(`Config changed during setup; rerun gallivant init. ${NOTHING_WRITTEN}`), { code: "INIT_CONFIG_CHANGED" });
|
|
207
|
+
mkdirSync(expand(merged.worktrees), { recursive: true, mode: 0o700 });
|
|
208
|
+
if (!existing) {
|
|
209
|
+
// wx also refuses a dangling symlink.
|
|
210
|
+
const fd = openSync(file, "wx", 0o600);
|
|
211
|
+
try { writeFileSync(fd, JSON.stringify(candidate, null, 2) + "\n"); } finally { closeSync(fd); }
|
|
212
|
+
} else if (changed) {
|
|
213
|
+
writeJsonAtomic(file, candidate, 0o600);
|
|
214
|
+
}
|
|
215
|
+
} finally { owner.release(); }
|
|
216
|
+
|
|
217
|
+
// 7. The same report `doctor` prints, so the operator reads one checklist rather than two.
|
|
218
|
+
const results = await doctor({ home });
|
|
219
|
+
return {
|
|
220
|
+
file, created: !existing, changed, login,
|
|
221
|
+
repo: target.repo, checkout: target.checkout, recorded: !known,
|
|
222
|
+
harness: selected, harnesses: Object.keys(merged.harness),
|
|
223
|
+
worktrees: expand(merged.worktrees),
|
|
224
|
+
preserved: existing ? Object.keys(raw).filter(k => k !== "repos" && !(selectedHost.adapter.features?.transportSetup && k === "git_hosts")) : [],
|
|
225
|
+
config: merged, doctor: results, dispatchGuidance: selectedHost.adapter.dispatchReason ?? null,
|
|
226
|
+
templates: { passport: templatePath("passport.gallivant.json"), sandbox: templatePath("claude-settings.json") },
|
|
227
|
+
};
|
|
34
228
|
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Whether this process was started by `npx` / `npm exec` rather than from an installed `gallivant` command. npm
|
|
232
|
+
* marks the child's environment; the `_npx` cache directory in the script path is the fallback for a shell that
|
|
233
|
+
* stripped the environment.
|
|
234
|
+
*/
|
|
235
|
+
export const viaNpx = (env = process.env, script = process.argv[1] || "") =>
|
|
236
|
+
env.npm_command === "exec" || env.npm_lifecycle_event === "npx" || /[\\/]_npx[\\/]/.test(script);
|
|
237
|
+
|
|
238
|
+
/** The command that runs this package again, spelled for how it was started this time. */
|
|
239
|
+
export const commandPrefix = npx => (npx ? `npx --yes ${PACKAGE_NAME}` : "gallivant");
|
|
240
|
+
|
|
241
|
+
export const FIRST_TASK = "Document how to run the existing tests in CONTRIBUTING.md. Use the commands already defined in the repository. Change documentation only.";
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What `gallivant init` prints: what it did, doctor's report, and what to run next. `report` is `renderDoctor`'s
|
|
245
|
+
* result. The `npx` paragraph says what `npx` did and did not do, because a one-command setup that silently looked
|
|
246
|
+
* like a global installation would send the operator looking for a `gallivant` command that is not there.
|
|
247
|
+
*/
|
|
248
|
+
export function renderInit(r, report, { npx = viaNpx(), script = process.argv[1] } = {}) {
|
|
249
|
+
const cmd = commandPrefix(npx);
|
|
250
|
+
const failed = r.doctor.filter(x => !x.ok).length;
|
|
251
|
+
const lines = [
|
|
252
|
+
`Package: ${PACKAGE_NAME} ${VERSION}${npx ? " (run by npx)" : ""}`,
|
|
253
|
+
`Running: ${script ? safeRealpath(script) : "(unknown)"}`,
|
|
254
|
+
`Config: ${contract(r.file)} (${r.created ? "created, mode 600" : r.changed ? `kept; ${r.preserved.length} existing key(s) preserved` : "unchanged"})`,
|
|
255
|
+
`Harness: ${r.harness ? `${r.harness} (${r.config.harness[r.harness]?.bin || "configured"})` : r.harnesses.join(", ") || "none"}`,
|
|
256
|
+
`Repository: ${r.repo} (${r.recorded ? "recorded in repos" : "already in repos; not duplicated"})`,
|
|
257
|
+
`Worktrees: ${r.worktrees}`,
|
|
258
|
+
"",
|
|
259
|
+
"Doctor:",
|
|
260
|
+
report.text,
|
|
261
|
+
"",
|
|
262
|
+
"Next:",
|
|
263
|
+
];
|
|
264
|
+
if (r.dispatchGuidance) {
|
|
265
|
+
lines.push(` ${r.dispatchGuidance}`, ` Check access again: ${cmd} doctor`);
|
|
266
|
+
return lines.join("\n");
|
|
267
|
+
}
|
|
268
|
+
if (failed) lines.push(` Fix the ${failed} failing check(s) above, then run: ${cmd} doctor`);
|
|
269
|
+
else lines.push(` All checks passed. Confirm the reviewer setting in ${contract(r.file)} (default: the Codex GitHub review integration), then start a small task.`);
|
|
270
|
+
lines.push(
|
|
271
|
+
` First task (uses your ${r.harness} account for inference; --run authorizes implementation once the brief is ready):`,
|
|
272
|
+
` ${cmd} task ${JSON.stringify(FIRST_TASK)} --harness ${r.harness} --verify 'git diff --check' --run`,
|
|
273
|
+
` ${cmd} start`,
|
|
274
|
+
" Setup did not create a GitHub ruleset, install harness hooks, start a task or merge anything; doctor names each remaining step.",
|
|
275
|
+
` Guide: https://github.com/uchibeke/gallivant/blob/main/docs/install.md`,
|
|
276
|
+
);
|
|
277
|
+
if (npx) {
|
|
278
|
+
lines.push("",
|
|
279
|
+
`npx ran ${PACKAGE_NAME} on demand from npm's cache. Nothing was installed globally and no gallivant command was added to your PATH.`,
|
|
280
|
+
` Run it again any time: ${cmd} doctor`,
|
|
281
|
+
` Optional persistent install: npm install -g ${PACKAGE_NAME} # then use plain: gallivant doctor`);
|
|
282
|
+
}
|
|
283
|
+
lines.push("", "Packaged templates:", ` ${r.templates.passport}`, ` ${r.templates.sandbox}`);
|
|
284
|
+
return lines.join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const safeRealpath = p => { try { return realpathSync(p); } catch { return p; } };
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { repositoryReviewConfig } from "../review_policy.mjs";
|
|
2
|
+
import { hosts } from "../hosts.mjs";
|
|
3
|
+
import { assertRepositoryAccess } from "../host_contract.mjs";
|
|
4
|
+
import { hostApi } from "../hosts.mjs";
|
|
1
5
|
// `gallivant register <pr-url>`: put a hand-made PR, or one opened from a terminal session, into the loop.
|
|
2
6
|
// On an entry that already exists it is the reset: reason cleared, iteration zero, a reset comment posted so the
|
|
3
7
|
// count recovered from GitHub agrees, watching again.
|
|
@@ -5,28 +9,38 @@ import { resolve } from "node:path";
|
|
|
5
9
|
import { newEntry, entryId, STATES } from "../state.mjs";
|
|
6
10
|
import { worktreePath, isOurBranch, prdIdFromBranch } from "../git.mjs";
|
|
7
11
|
import { parseBlock, normalizePublicBody } from "../block.mjs";
|
|
8
|
-
import { repoFromUrl
|
|
12
|
+
import { repoFromUrl } from "../hosts.mjs";
|
|
13
|
+
import { remoteMatches, safeOrigin } from "../repository.mjs";
|
|
14
|
+
import { RESET_MARKER } from "../host_contract.mjs";
|
|
9
15
|
import { receiptHash } from "../deliverable.mjs";
|
|
10
16
|
import { rememberRepo, defaultHarness } from "../config.mjs";
|
|
11
|
-
import { isSessionFor } from "../harness.mjs";
|
|
17
|
+
import { isSessionFor, capabilitiesFor } from "../harness.mjs";
|
|
18
|
+
import { executionEpoch, reserveExecutionEpoch } from "../execution.mjs";
|
|
19
|
+
import { acquireRuntimeOwner } from "../daemon.mjs";
|
|
12
20
|
import { ensurePrdSnapshot, toPrdId } from "../prd.mjs";
|
|
13
21
|
import { nowIso, floorSecond } from "../time.mjs";
|
|
22
|
+
import { legacyReviewPolicy, selectReviewPolicy } from "../review_policy.mjs";
|
|
14
23
|
|
|
15
24
|
/** The PRD id for a hand-made branch: our own convention when the branch follows it, else a slug of the name. */
|
|
16
25
|
const prdIdFor = branch => prdIdFromBranch(branch) ?? toPrdId(branch);
|
|
17
26
|
|
|
18
27
|
export async function register(ctx, { url, harness = null, session = null, worktree = null }) {
|
|
19
|
-
const { config, state,
|
|
28
|
+
const { config, state, git, log } = ctx;
|
|
29
|
+
const host = hostApi(ctx);
|
|
20
30
|
const publicSince = floorSecond(nowIso());
|
|
21
31
|
// Resolved against the directory `register` runs in, before anything reads it. The registry is read back by a
|
|
22
32
|
// daemon whose working directory is its own, so a relative path stored as typed would point somewhere else there:
|
|
23
33
|
// the harness would run in the wrong tree, or `ensureWorktree` would clone over it. Absolute from here on.
|
|
24
34
|
if (worktree) worktree = resolve(worktree);
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
const selectedHost = (ctx.hosts ?? hosts).fromUrl(url);
|
|
36
|
+
const parsed = { repo: selectedHost.repository.key, number: selectedHost.number };
|
|
37
|
+
if (!parsed) throw new Error("Invalid or unsupported host PR URL; use a canonical URL on a qualified host");
|
|
38
|
+
repositoryReviewConfig(config, parsed.repo, selectedHost.adapter);
|
|
39
|
+
const pr = await host.prView(parsed.repo, parsed.number);
|
|
28
40
|
// The repo as GitHub spells it, so an oddly cased URL never yields a second entry for the same PR.
|
|
29
|
-
const
|
|
41
|
+
const observed = (ctx.hosts ?? hosts).fromUrl(pr.url);
|
|
42
|
+
if (observed.repository.key !== parsed.repo || observed.number !== parsed.number) throw new Error("PR identity mismatch; restore the qualified URL before registering");
|
|
43
|
+
const repo = observed.repository.key;
|
|
30
44
|
if (pr.isCrossRepository) throw new Error("fork PRs are not registered");
|
|
31
45
|
if (pr.state !== "OPEN") throw new Error(`PR is ${pr.state}`);
|
|
32
46
|
|
|
@@ -35,10 +49,39 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
35
49
|
const found = state.findByPr(repo, pr.number);
|
|
36
50
|
const id = found ? found[0] : entryId(repo, prdId);
|
|
37
51
|
const existing = found ? found[1] : state.get(id);
|
|
52
|
+
const reviewPolicy = existing?.reviewPolicy || (existing ? legacyReviewPolicy(repositoryReviewConfig(config, repo, selectedHost.adapter)) : ctx.reviewService ? await ctx.reviewService.preflight(repo) : selectReviewPolicy(config, repo));
|
|
38
53
|
state.assertCanRegister(id);
|
|
39
54
|
if (existing && existing.pr !== pr.number && existing.pr !== null) throw new Error(`${id} already watches PR #${existing.pr}`);
|
|
40
55
|
const chosenHarness = harness || existing?.harness || block?.harness || defaultHarness(config);
|
|
41
56
|
if (!config.harness[chosenHarness]) throw new Error(`harness "${chosenHarness}" is not configured`);
|
|
57
|
+
assertRepositoryAccess(capabilitiesFor(chosenHarness), selectedHost.repository);
|
|
58
|
+
const switching = existing && chosenHarness !== existing.harness;
|
|
59
|
+
// A daemon holds this lock for its entire lifetime. Claim it through the
|
|
60
|
+
// GitHub reset and registry write so an old-provider tick cannot dispatch
|
|
61
|
+
// after the settlement check, nor can a daemon boot midway through it.
|
|
62
|
+
const owner = switching ? (ctx.acquireRuntimeOwner ?? acquireRuntimeOwner)(ctx.home) : null;
|
|
63
|
+
let fenced = false, remoteMayBeNew = false, reservationAttempted = false;
|
|
64
|
+
try {
|
|
65
|
+
if (switching && JSON.stringify(state.get(id)) !== JSON.stringify(existing)) {
|
|
66
|
+
throw new Error("the watched entry changed while stopping the coordinator; inspect its latest run and retry the harness reset");
|
|
67
|
+
}
|
|
68
|
+
let nextEpoch = existing?.execution_epoch ?? 0;
|
|
69
|
+
if (switching) {
|
|
70
|
+
if (existing.handoff_pending && (existing.dispatch_stopped !== "harness-handoff-pending" || existing.handoff_pending.to !== chosenHarness || existing.handoff_pending.pr !== pr.number)) {
|
|
71
|
+
throw new Error("another provider handoff is pending; inspect and finish that reset before choosing a different harness");
|
|
72
|
+
}
|
|
73
|
+
if (existing.task || capabilitiesFor(existing.harness).transport !== "cli" || capabilitiesFor(chosenHarness).transport !== "cli") throw new Error("changing harness on an owned task or remote execution requires an explicit provider handoff; nothing was registered");
|
|
74
|
+
if (session) throw new Error("a cross-harness reset requires a fresh provider session; --session cannot prove ownership in the new execution ledger");
|
|
75
|
+
if (existing.last?.run && existing.last.exit == null) throw new Error("the previous run is not settled; wait for its outcome before changing harness");
|
|
76
|
+
const previous = ctx.executionFor?.(id, existing.harness);
|
|
77
|
+
const ledger = previous?.state?.();
|
|
78
|
+
const job = previous?.current?.();
|
|
79
|
+
if (job?.running || ledger?.stopped || ledger?.current && (ledger.current.id !== existing.last?.run || ledger.current.exit == null || ledger.current.execution?.collected !== true)) {
|
|
80
|
+
throw new Error("the previous provider still owns unsettled execution; reconcile it before changing harness");
|
|
81
|
+
}
|
|
82
|
+
const root = previous?.root;
|
|
83
|
+
nextEpoch = root ? executionEpoch({ root, taskId: id, entry: existing, provider: existing.harness, account: previous.account, runner: ctx.runnerFor?.(id) }) + 1 : nextEpoch + 1;
|
|
84
|
+
}
|
|
42
85
|
if (session && !worktree && !existing?.worktree) throw new Error("--session needs --worktree: both harnesses key sessions by the directory they ran in");
|
|
43
86
|
// The same rule the block is held to, so the flag and the block agree on what a session is. A value typed here
|
|
44
87
|
// reaches argv position 2 on the next fix round, right after `--resume` or `--name`, where `assertSafeArgv` matches
|
|
@@ -77,6 +120,7 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
77
120
|
const identity = { prd: blockPrd, harness: chosenHarness, session: chosenSession, branch: pr.headRefName };
|
|
78
121
|
const writesRecovery = isOurBranch(pr.headRefName) && blockPrd !== null;
|
|
79
122
|
const stale = writesRecovery && block && (block.harness !== chosenHarness || block.session !== chosenSession || normalizePublicBody(pr.body, { identity, taskId: id }) !== pr.body);
|
|
123
|
+
remoteMayBeNew = switching && block?.harness === chosenHarness && block?.session === chosenSession;
|
|
80
124
|
|
|
81
125
|
// Two durable GitHub writes, and the ORDER between them is the whole of this. Both are preconditions of the local
|
|
82
126
|
// reset, so either failing throws and leaves the registry untouched; what the order decides is what GitHub is left
|
|
@@ -104,21 +148,35 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
104
148
|
// it, so a recovery after `state.json` is lost rebuilds the iteration this command had just zeroed. An entry restored
|
|
105
149
|
// at `max_iterations` then stops with `reason: "iterations"` on its next event, having done no work in this round at
|
|
106
150
|
// all, and the operator is told the budget is spent on a PR they had just reset.
|
|
151
|
+
if (switching) {
|
|
152
|
+
state.put(id, { ...existing, state: STATES.needsHuman, reason: "harness-handoff-pending",
|
|
153
|
+
dispatch_stopped: "harness-handoff-pending",
|
|
154
|
+
handoff_pending: { from: existing.harness, to: chosenHarness, epoch: nextEpoch, pr: pr.number, at: nowIso() } });
|
|
155
|
+
fenced = true;
|
|
156
|
+
}
|
|
107
157
|
if (existing) {
|
|
108
|
-
try { await
|
|
158
|
+
try { await host.comment(repo, pr.number, `Watching again from here.\n\n${RESET_MARKER}`); }
|
|
109
159
|
catch (e) { throw new Error(`cannot post the reset comment on ${pr.url}: ${e.message}. Nothing was reset; the entry keeps its ${existing.iteration} round${existing.iteration === 1 ? "" : "s"} and its state, and its recorded harness and session are unchanged. Fix the cause and run register again.`); }
|
|
110
160
|
}
|
|
111
161
|
// The identity of record, written last of the two and still before anything reaches the registry, so a failure here
|
|
112
162
|
// leaves GitHub and the registry agreeing on the OLD identity rather than disagreeing.
|
|
113
163
|
if (writesRecovery && (!block || stale)) {
|
|
114
|
-
|
|
164
|
+
if (switching) remoteMayBeNew = true; // A failed reply cannot prove that the body write did not land.
|
|
165
|
+
try { const updated = await host.setRecoveryMetadata(repo, pr.number, identity, { taskId: id }); pr.body = updated.body; }
|
|
115
166
|
// The message names the identity that survived, and only claims the PR agrees when the PR actually had a block to
|
|
116
167
|
// agree with: an existing entry on a PR with no block at all is a case where the registry has an identity and
|
|
117
168
|
// GitHub has none, which is what this write was about to fix and is not the same as the two of them agreeing.
|
|
118
|
-
catch (e) { throw new Error(`cannot write the gallivant block to ${pr.url}: ${e.message}. Nothing was registered${existing ? `: the entry keeps harness ${existing.harness} and session ${existing.session ?? "none"}${block ? ", which is still what the PR records" : ", and the PR still records no identity at all"}` : ""}. Fix the cause and run register again.`); }
|
|
169
|
+
catch (e) { throw new Error(`cannot write the gallivant block to ${pr.url}: ${e.message}. Nothing was registered${existing ? `: the entry keeps harness ${existing.harness} and session ${existing.session ?? "none"}${!switching ? block ? ", which is still what the PR records" : ", and the PR still records no identity at all" : ""}` : ""}.${switching ? " The old entry remains fenced because the remote write may have landed." : ""} Fix the cause and run register again.`); }
|
|
119
170
|
if (stale) log.info(`${id}: PR block updated to harness ${chosenHarness}, session ${chosenSession ?? "none"}`);
|
|
120
171
|
}
|
|
121
172
|
|
|
173
|
+
if (switching && ctx.executionFor) {
|
|
174
|
+
const previous = ctx.executionFor(id, existing.harness);
|
|
175
|
+
reservationAttempted = true;
|
|
176
|
+
(ctx.reserveExecutionEpoch ?? reserveExecutionEpoch)({ root: previous.root, taskId: id, provider: chosenHarness,
|
|
177
|
+
account: config.harness[chosenHarness]?.account || "operator", epoch: nextEpoch, pr: pr.number });
|
|
178
|
+
}
|
|
179
|
+
|
|
122
180
|
// `base` on the entry is the branch this PR targets, and it comes from the PR itself. A registered PR is one a person
|
|
123
181
|
// opened, so the operator's config has no say in where it was aimed: `baseRefName` is the truth, and it is what the
|
|
124
182
|
// daemon merges in before every later round. An existing entry keeps whatever it had unless the PR says otherwise,
|
|
@@ -133,7 +191,9 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
133
191
|
// piece of feedback written in that second and lose it for good.
|
|
134
192
|
const since = floorSecond(existing ? nowIso() : pr.createdAt);
|
|
135
193
|
const entry = {
|
|
136
|
-
...base, public_since: existing?.public_since || publicSince, harness: chosenHarness, session: chosenSession, worktree: dir, pr: pr.number, url: pr.url, base: prBase ?? base.base ?? null,
|
|
194
|
+
...base, reviewPolicy, public_since: existing?.public_since || publicSince, harness: chosenHarness, session: chosenSession, worktree: dir, pr: pr.number, url: pr.url, base: prBase ?? base.base ?? null,
|
|
195
|
+
...(switching ? { execution_epoch: nextEpoch, execution_handoff: { from: existing.harness, to: chosenHarness, previousRun: existing.last?.run ?? null, at: nowIso() },
|
|
196
|
+
dispatch_stopped: null, handoff_pending: null } : {}),
|
|
137
197
|
// `retry` goes with `reason`: it counts the consecutive failures of one GitHub write the tick declines a step for,
|
|
138
198
|
// and a reset is the operator saying the cause is dealt with. Left behind, an entry reset after a `stuck` stop would
|
|
139
199
|
// carry four failures into its next one and stop again on the first write that failed.
|
|
@@ -165,15 +225,23 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
165
225
|
updated_at: nowIso(),
|
|
166
226
|
};
|
|
167
227
|
state.put(id, entry);
|
|
228
|
+
fenced = false;
|
|
168
229
|
// The snapshot the completion gate reads, taken the same way `reconcile` takes it. Without it `prdFor` returns
|
|
169
230
|
// nothing, the gate logs "no PRD snapshot" and the PR can be marked ready with no acceptance criterion evaluated.
|
|
170
231
|
// After the entry is stored, so a GitHub read that fails leaves a watched PR rather than an aborted registration.
|
|
171
232
|
// A snapshot already on disk is kept, so a reset never replaces the authoritative copy with the one the session
|
|
172
233
|
// could have edited.
|
|
173
234
|
await ensurePrdSnapshot(ctx, { id, repo, path: blockPrd, ref: pr.headRefName });
|
|
174
|
-
if (rememberRepo(repo, config.home)) await
|
|
235
|
+
if (rememberRepo(repo, config.home)) await host.ensureLabels(repo).catch(e => log.warn(`${repo}: labels not created: ${e.message}`));
|
|
175
236
|
log.info(`${id}: ${existing ? "reset and " : ""}watching ${pr.url} with ${chosenHarness}${chosenSession ? " (resume)" : " (fresh session on first fix)"}`);
|
|
176
237
|
return { id, pr: pr.number, worktree: dir, harness: chosenHarness, session: chosenSession };
|
|
238
|
+
} catch (error) {
|
|
239
|
+
// Before any possible identity write or ledger reservation, a failed reset
|
|
240
|
+
// may restore the exact prior entry. Once either may have landed, retain
|
|
241
|
+
// the dispatch fence so a restarted daemon cannot launch the old harness.
|
|
242
|
+
if (fenced && !remoteMayBeNew && !reservationAttempted) state.put(id, existing);
|
|
243
|
+
throw error;
|
|
244
|
+
} finally { owner?.release(); }
|
|
177
245
|
}
|
|
178
246
|
|
|
179
247
|
/**
|
|
@@ -187,7 +255,7 @@ async function assertCheckout(git, dir, repo, branch) {
|
|
|
187
255
|
let origin;
|
|
188
256
|
try { origin = await git.originOf(dir); }
|
|
189
257
|
catch (e) { throw new Error(`--worktree ${dir} has no origin remote: ${e.message}`); }
|
|
190
|
-
if (!remoteMatches(origin, repo)) throw new Error(`--worktree ${dir} is a clone of ${origin}, not ${repo}`);
|
|
258
|
+
if (!remoteMatches(origin, repo)) throw new Error(`--worktree ${dir} is a clone of ${safeOrigin(origin)}, not ${repo}`);
|
|
191
259
|
let current;
|
|
192
260
|
try { current = await git.currentBranch(dir); }
|
|
193
261
|
catch (e) { throw new Error(`--worktree ${dir}: cannot read the checked-out branch: ${e.message}`); }
|
package/lib/commands/resume.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hosts } from "../hosts.mjs";
|
|
2
|
+
import { assertRepositoryAccess } from "../host_contract.mjs";
|
|
1
3
|
// Resume a stopped session after validating its contract and preserving any unsettled run.
|
|
2
4
|
import { NETWORK_RECOVERY_WINDOW } from "../network_recovery.mjs";
|
|
3
5
|
import { learningDelivered, learningDeliveryPatch } from "../learning_input.mjs";
|
|
@@ -20,6 +22,7 @@ export async function resume(ctx, { handle, note = null, resnapshot = false, fre
|
|
|
20
22
|
const { state, git, log, readPrd } = ctx;
|
|
21
23
|
const id = resolveHandle(state.entries(), handle);
|
|
22
24
|
let entry = state.get(id);
|
|
25
|
+
assertRepositoryAccess(capabilitiesFor(entry.harness), (ctx.hosts ?? hosts).select(entry).repository);
|
|
23
26
|
if (entry.dispatch_stopped) throw new Error(`dispatch stopped: ${entry.dispatch_stopped}; cancellation and late results must be reconciled before any new task`);
|
|
24
27
|
|
|
25
28
|
if (!RESUMABLE.has(entry.state)) throw new Error(`${handleFor(id, entry)} is ${entry.state}; resume takes a stopped or watched entry`);
|
|
@@ -159,8 +162,9 @@ export async function resume(ctx, { handle, note = null, resnapshot = false, fre
|
|
|
159
162
|
const unpushed = git.unpushed ? await git.unpushed(entry.worktree, entry.branch, entry.base ?? null) : [];
|
|
160
163
|
if (unpushed.length) log.info(`${id}: ${unpushed.length} unpushed commit${unpushed.length === 1 ? "" : "s"} on ${entry.branch}; the session is told to preserve and review them before pushing`);
|
|
161
164
|
|
|
162
|
-
let prompt = initialDispatch ? [startPrompt({ prd: parsed, repo: entry.repo, branch: entry.branch, base: entry.base, verify: verifyCommands(parsed), receipt: receiptInstructions(criteriaFromPrd(parsed), id), carried: entry.sources ?? [] }),
|
|
165
|
+
let prompt = initialDispatch ? [startPrompt({ host: (ctx.hosts ?? hosts).select(entry).adapter.prompts, prd: parsed, repo: entry.repo, branch: entry.branch, base: entry.base, verify: verifyCommands(parsed), receipt: receiptInstructions(criteriaFromPrd(parsed), id), carried: entry.sources ?? [] }),
|
|
163
166
|
"This is the first provider implementation; the previous launch was refused before submission. Work from the supplied current source revision and preserve its commits.", ...(note ? [`Operator clarification: ${note}`] : [])].join("\n\n") : resumePrompt({
|
|
167
|
+
host: (ctx.hosts ?? hosts).select(entry).adapter.prompts,
|
|
164
168
|
repo: entry.repo,
|
|
165
169
|
pr: entry.pr,
|
|
166
170
|
base: entry.base,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { resolveHandle } from "../state.mjs";
|
|
2
|
+
import { executionRunner } from "../execution.mjs";
|
|
3
|
+
import { STATES } from "../state.mjs";
|
|
4
|
+
import { READY_LABEL } from "../host_contract.mjs";
|
|
5
|
+
import { hostApi, hosts } from "../hosts.mjs";
|
|
6
|
+
import { reviewDisclosure, REVIEW_MODES } from "../review_policy.mjs";
|
|
7
|
+
import { withReviewState } from "../review_state.mjs";
|
|
8
|
+
|
|
9
|
+
export async function reviewCommand(ctx, { action, handle, repo, mode, reason, finding, run = false, capability = "text", retry = false, wait = false }) {
|
|
10
|
+
if (action === "qualify") {
|
|
11
|
+
if (!repo) throw new Error("review qualify requires --repo owner/name or host/owner/name");
|
|
12
|
+
const { repository } = (ctx.hosts ?? hosts).select(repo);
|
|
13
|
+
if (wait && !run) throw new Error("qualification --wait requires --run");
|
|
14
|
+
let result = await ctx.reviewService.qualify(repository.key, { run, capability, retry, reason });
|
|
15
|
+
while (wait && ["preparing", "launch-intent", "running", "retry-waiting"].includes(result.status)) {
|
|
16
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
17
|
+
result = await ctx.reviewService.qualify(repository.key, { run: true, capability });
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
if (!handle) throw new Error("review status|policy|dispute|retry needs a task handle");
|
|
22
|
+
const id = resolveHandle(ctx.state.entries(), handle), entry = ctx.state.get(id);
|
|
23
|
+
if (action === "status") return { id, policy: entry.reviewPolicy, disclosure: reviewDisclosure(entry), record: entry.firstPartyReview, readiness: entry.review_readiness, history: ctx.state.reviewHistory ? ctx.state.reviewHistory(id) : entry.reviewHistory || [] };
|
|
24
|
+
if (!reason?.trim()) throw new Error("review policy/dispute/retry requires --reason with an explicit operator reason");
|
|
25
|
+
return withReviewState(ctx, id, () => mutateReview(ctx, id, { action, mode, reason, finding }));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function mutateReview(ctx, id, { action, mode, reason, finding }) {
|
|
29
|
+
const entry = ctx.state.get(id);
|
|
30
|
+
(ctx.hosts ?? hosts).select(entry);
|
|
31
|
+
const host = hostApi(ctx);
|
|
32
|
+
if (executionRunner(ctx, id).current()?.running) throw new Error("settle the active coding run before changing review state");
|
|
33
|
+
if (action === "retry") {
|
|
34
|
+
const audit = await ctx.reviewService.retry(id, entry, reason);
|
|
35
|
+
ctx.state.update(id, { state: STATES.prOpen, reason: null, firstPartyReview: null,
|
|
36
|
+
reviewHistory: [...(entry.reviewHistory || []), entry.firstPartyReview], review_readiness: null,
|
|
37
|
+
reviewBinding: entry.reviewBinding ?? entry.firstPartyReview?.binding ?? null,
|
|
38
|
+
// Only local output is retried. Keep the existing external review window
|
|
39
|
+
// and binding: unchanged evidence retains approval, while a moved base,
|
|
40
|
+
// receipt or check still triggers the ordinary fresh-review requirement.
|
|
41
|
+
review_next_at: 0,
|
|
42
|
+
last: { ...entry.last, event: "operator authorized retry of a settled review job" } });
|
|
43
|
+
return { id, ...audit, reevaluation: "failed pieces queued; completed pieces and original attempts retained" };
|
|
44
|
+
}
|
|
45
|
+
if (action === "dispute") {
|
|
46
|
+
if (!entry.firstPartyReview?.result?.findings?.some(f => f.id === finding)) throw new Error("--finding must identify a recorded first-party finding");
|
|
47
|
+
const dispute = { finding, reason: reason.trim(), status: "pending-human", at: new Date().toISOString() };
|
|
48
|
+
ctx.state.update(id, { reviewDisputes: [...(entry.reviewDisputes || []), dispute], state: STATES.needsHuman, reason: "review-dispute", review_readiness: null, review_label_pending: Boolean(entry.pr) });
|
|
49
|
+
if (entry.pr) await host.removeLabel(entry.repo, entry.pr, READY_LABEL);
|
|
50
|
+
ctx.state.update(id, { review_label_pending: false });
|
|
51
|
+
return dispute;
|
|
52
|
+
}
|
|
53
|
+
if (action !== "policy" || !REVIEW_MODES.includes(mode)) throw new Error(`review policy requires --mode ${REVIEW_MODES.join("|")}`);
|
|
54
|
+
const policy = await ctx.reviewService.preflight(entry.repo, { mode, legacy_no_review: false });
|
|
55
|
+
const at = new Date().toISOString();
|
|
56
|
+
const patch = { reviewPolicy: policy, reviewPolicyHistory: [...(entry.reviewPolicyHistory || []), { policy: entry.reviewPolicy, reason, at }],
|
|
57
|
+
firstPartyReview: null, reviewHistory: [...(entry.reviewHistory || []), ...(entry.firstPartyReview ? [entry.firstPartyReview] : [])],
|
|
58
|
+
reviewBinding: null, review_readiness: null, review_next_at: 0, rereview: { at, nudged: false },
|
|
59
|
+
...(entry.completionEvidence?.delivery ? { completionEvidence: { ...entry.completionEvidence, delivery: { ...entry.completionEvidence.delivery, invalidatedAt: at, invalidationReason: "explicit review policy change" } } } : {}),
|
|
60
|
+
state: STATES.needsHuman, reason: "review-policy-changed", review_label_pending: Boolean(entry.pr) };
|
|
61
|
+
// Fence before the host label write. A failed write cannot preserve clearance.
|
|
62
|
+
ctx.state.update(id, patch);
|
|
63
|
+
if (entry.pr) await host.removeLabel(entry.repo, entry.pr, READY_LABEL);
|
|
64
|
+
ctx.state.update(id, { state: entry.pr ? STATES.prOpen : STATES.needsHuman, review_label_pending: false });
|
|
65
|
+
return { id, policy, reason, reevaluation: "current head requires new review evidence" };
|
|
66
|
+
}
|
package/lib/commands/serve.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { streamRun, sliceRun, TAIL } from "../tracker/stream.mjs";
|
|
|
24
24
|
import { listRuns, listFlatRuns, runDir, sourceRunDir, validRunId } from "../tracker/runs.mjs";
|
|
25
25
|
import { ASSETS, themeOf } from "../tracker/assets.mjs";
|
|
26
26
|
import { dashboard, runPage, runsPage, page, esc, CSP, GROUPINGS } from "../tracker/pages.mjs";
|
|
27
|
+
import { reviewActivity } from "../tracker/review.mjs";
|
|
27
28
|
|
|
28
29
|
export const DEFAULT_PORT = 8791;
|
|
29
30
|
const HOST = "127.0.0.1";
|
|
@@ -175,6 +176,13 @@ export const routes = {
|
|
|
175
176
|
json(res, 200, { control: hit.entry.control || null, lastMessage: lastMessage(hit.entry), state: hit.entry.state, reason: hit.entry.reason });
|
|
176
177
|
},
|
|
177
178
|
|
|
179
|
+
"GET /api/run/review-activity": (ctx, _req, res, url) => {
|
|
180
|
+
const id = url.searchParams.get("id") || "";
|
|
181
|
+
const entry = id && (ctx.state.reviewActivityEntry ? ctx.state.reviewActivityEntry(id) : ctx.state.get(id));
|
|
182
|
+
if (!entry) return json(res, 404, { error: "unknown entry" });
|
|
183
|
+
json(res, 200, reviewActivity(entry));
|
|
184
|
+
},
|
|
185
|
+
|
|
178
186
|
"POST /api/run/control": async (ctx, req, res, url) => {
|
|
179
187
|
if (!localRequest(req) || req.headers.origin !== `http://${req.headers.host}`
|
|
180
188
|
|| req.headers["x-gallivant-token"] !== tokenFor(ctx)) return json(res, 403, { error: "Open this page on localhost and reload before trying again." });
|