@uchibeke/gallivant 0.2.1 → 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 +38 -16
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +33 -10
- 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 +2 -0
- 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 +65 -5
- package/docs/config.md +35 -10
- 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 +18 -8
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +2 -1
- package/docs/prd.md +23 -2
- package/docs/preparation.md +1 -1
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +17 -2
- package/docs/task-delivery.md +4 -4
- 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 -462
- package/lib/commands/init.mjs +72 -30
- package/lib/commands/register.mjs +25 -11
- 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 +23 -18
- 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 +48 -3
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- 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 +353 -97
- 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
|
@@ -9,14 +9,19 @@
|
|
|
9
9
|
import { mkdirSync, openSync, closeSync, writeFileSync, lstatSync, realpathSync } from "node:fs";
|
|
10
10
|
import { join, dirname } from "node:path";
|
|
11
11
|
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { isDeepStrictEqual } from "node:util";
|
|
12
13
|
import { gallivantHome, expand, templatePath, readJson, writeJsonAtomic, contract, VERSION, PACKAGE_NAME } from "../fs.mjs";
|
|
13
14
|
import { discoverHarnesses, validate, merge, configPath, repoEntry } from "../config.mjs";
|
|
14
|
-
import {
|
|
15
|
+
import { hosts, makeHostRegistry } from "../hosts.mjs";
|
|
16
|
+
import { repositoryReviewConfig } from "../review_policy.mjs";
|
|
17
|
+
import { safeOrigin, assertBranchName } from "../repository.mjs";
|
|
18
|
+
export { safeOrigin };
|
|
15
19
|
import { silentLog } from "../log.mjs";
|
|
16
20
|
import { checkHarness } from "../probe.mjs";
|
|
17
|
-
import {
|
|
21
|
+
import { describe as describeCredential } from "../credential.mjs";
|
|
18
22
|
import { doctor as runDoctor, repositoryWriteScopeCheck } from "./doctor.mjs";
|
|
19
23
|
import { acquireCliMutation } from "../daemon.mjs";
|
|
24
|
+
import { repositoryPreflight } from "../repository_preflight.mjs";
|
|
20
25
|
|
|
21
26
|
export const INIT_HARNESSES = ["codex", "claude"];
|
|
22
27
|
|
|
@@ -26,13 +31,6 @@ const NOTHING_WRITTEN = "Setup did not complete and nothing was written.";
|
|
|
26
31
|
const firstLine = e => String(e?.message || e).split("\n")[0];
|
|
27
32
|
const isSymlink = p => { try { return lstatSync(p).isSymbolicLink(); } catch { return false; } };
|
|
28
33
|
|
|
29
|
-
/**
|
|
30
|
-
* A remote URL with its userinfo removed. `https://x-access-token:SECRET@github.com/...` is a remote git and
|
|
31
|
-
* `repoFromRemote` both accept, and every message here that names the origin would otherwise print the secret into
|
|
32
|
-
* a terminal or a CI log. Only scheme URLs carry userinfo; `git@github.com:owner/name` names a login, not a secret.
|
|
33
|
-
*/
|
|
34
|
-
export const safeOrigin = url => String(url || "").replace(/^([a-z][a-z0-9+.-]*:\/\/)[^@/]*@/i, "$1");
|
|
35
|
-
|
|
36
34
|
/**
|
|
37
35
|
* The repository a checkout belongs to, read from its `origin` remote. Local only: no network, no `gh`. Throws a
|
|
38
36
|
* message naming the unmet prerequisite, so a directory that is not a GitHub checkout is refused before anything else
|
|
@@ -50,8 +48,9 @@ export function targetRepository(dir = process.cwd(), exec = execFileSync) {
|
|
|
50
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}`);
|
|
51
49
|
const origin = git(["remote", "get-url", "origin"]);
|
|
52
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}`);
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
let repo;
|
|
52
|
+
try { repo = hosts.inspectRemote(origin).repository.key; }
|
|
53
|
+
catch (error) { throw new Error(`${error.message} ${NOTHING_WRITTEN}`); }
|
|
55
54
|
// The redacted form is the only one returned: nothing downstream needs the credential, and nothing may print it.
|
|
56
55
|
return { checkout, origin: safeOrigin(origin), repo };
|
|
57
56
|
}
|
|
@@ -86,9 +85,9 @@ export async function init(options = {}) {
|
|
|
86
85
|
}
|
|
87
86
|
}
|
|
88
87
|
|
|
89
|
-
async function initAttempt({ home = gallivantHome(), harness = null, worktrees = null, dir = process.cwd(),
|
|
90
|
-
discover = discoverHarnesses, gh =
|
|
91
|
-
resolve =
|
|
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 } = {}) {
|
|
92
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");
|
|
93
92
|
home = expand(home);
|
|
94
93
|
const file = configPath(home);
|
|
@@ -104,15 +103,41 @@ async function initAttempt({ home = gallivantHome(), harness = null, worktrees =
|
|
|
104
103
|
|
|
105
104
|
// 1. The repository, before any network call: a directory that is not a GitHub checkout has nothing to set up.
|
|
106
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;
|
|
107
129
|
|
|
108
130
|
// 2. GitHub sign-in, by asking `gh` who it is. The fix is `gh`'s own login flow; nothing here reads a credential.
|
|
109
131
|
let login = null, ghError = null;
|
|
110
132
|
try { login = await gh.user(); } catch (e) { ghError = firstLine(e); }
|
|
111
|
-
if (
|
|
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}`);
|
|
112
136
|
|
|
113
137
|
// 3. The config this run would leave behind, built but not yet written.
|
|
114
138
|
const selected = harness ?? (existing ? onlyLocalHarness(raw) : "codex");
|
|
115
|
-
const candidate = existing ?
|
|
139
|
+
const candidate = existing ? setup : {
|
|
140
|
+
...setup,
|
|
116
141
|
trusted_authors: [login], worktrees: expand(worktrees || join(dirname(home), ".gallivant-worktrees")),
|
|
117
142
|
harness: {}, notify: null, github_token_file: null, repos: [],
|
|
118
143
|
};
|
|
@@ -122,6 +147,11 @@ async function initAttempt({ home = gallivantHome(), harness = null, worktrees =
|
|
|
122
147
|
if (!found) throw new Error(`${selected} is not on PATH. Install it and sign in, then retry gallivant init --harness ${selected}. ${NOTHING_WRITTEN}`);
|
|
123
148
|
candidate.harness = { ...(candidate.harness || {}), [selected]: found };
|
|
124
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
|
+
}
|
|
125
155
|
const merged = merge(candidate, home);
|
|
126
156
|
const problems = validate(merged, home);
|
|
127
157
|
if (problems.length) throw new Error(`${existing ? `Config problems in ${contract(file)}: ` : ""}${problems.join("; ")}. ${NOTHING_WRITTEN}`);
|
|
@@ -139,18 +169,26 @@ async function initAttempt({ home = gallivantHome(), harness = null, worktrees =
|
|
|
139
169
|
// operator's ambient login is the wrong identity to ask: an explicit PAT that cannot reach the repository is the
|
|
140
170
|
// failure this check exists to catch, and an ambient login that can would hide it.
|
|
141
171
|
const runConfig = { ...merged, github_token_file: merged.github_token_file ? expand(merged.github_token_file) : null };
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
+
}
|
|
154
192
|
|
|
155
193
|
// 6. Write. The repository is deduplicated by lowercased name whichever form each entry takes, so a second run,
|
|
156
194
|
// or a run from a clone whose remote spells the owner differently, never appends a duplicate. The string form is
|
|
@@ -183,8 +221,8 @@ async function initAttempt({ home = gallivantHome(), harness = null, worktrees =
|
|
|
183
221
|
repo: target.repo, checkout: target.checkout, recorded: !known,
|
|
184
222
|
harness: selected, harnesses: Object.keys(merged.harness),
|
|
185
223
|
worktrees: expand(merged.worktrees),
|
|
186
|
-
preserved: existing ? Object.keys(raw).filter(k => k !== "repos") : [],
|
|
187
|
-
config: merged, doctor: results,
|
|
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,
|
|
188
226
|
templates: { passport: templatePath("passport.gallivant.json"), sandbox: templatePath("claude-settings.json") },
|
|
189
227
|
};
|
|
190
228
|
}
|
|
@@ -223,6 +261,10 @@ export function renderInit(r, report, { npx = viaNpx(), script = process.argv[1]
|
|
|
223
261
|
"",
|
|
224
262
|
"Next:",
|
|
225
263
|
];
|
|
264
|
+
if (r.dispatchGuidance) {
|
|
265
|
+
lines.push(` ${r.dispatchGuidance}`, ` Check access again: ${cmd} doctor`);
|
|
266
|
+
return lines.join("\n");
|
|
267
|
+
}
|
|
226
268
|
if (failed) lines.push(` Fix the ${failed} failing check(s) above, then run: ${cmd} doctor`);
|
|
227
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.`);
|
|
228
270
|
lines.push(
|
|
@@ -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,7 +9,9 @@ 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
17
|
import { isSessionFor, capabilitiesFor } from "../harness.mjs";
|
|
@@ -13,22 +19,28 @@ import { executionEpoch, reserveExecutionEpoch } from "../execution.mjs";
|
|
|
13
19
|
import { acquireRuntimeOwner } from "../daemon.mjs";
|
|
14
20
|
import { ensurePrdSnapshot, toPrdId } from "../prd.mjs";
|
|
15
21
|
import { nowIso, floorSecond } from "../time.mjs";
|
|
22
|
+
import { legacyReviewPolicy, selectReviewPolicy } from "../review_policy.mjs";
|
|
16
23
|
|
|
17
24
|
/** The PRD id for a hand-made branch: our own convention when the branch follows it, else a slug of the name. */
|
|
18
25
|
const prdIdFor = branch => prdIdFromBranch(branch) ?? toPrdId(branch);
|
|
19
26
|
|
|
20
27
|
export async function register(ctx, { url, harness = null, session = null, worktree = null }) {
|
|
21
|
-
const { config, state,
|
|
28
|
+
const { config, state, git, log } = ctx;
|
|
29
|
+
const host = hostApi(ctx);
|
|
22
30
|
const publicSince = floorSecond(nowIso());
|
|
23
31
|
// Resolved against the directory `register` runs in, before anything reads it. The registry is read back by a
|
|
24
32
|
// daemon whose working directory is its own, so a relative path stored as typed would point somewhere else there:
|
|
25
33
|
// the harness would run in the wrong tree, or `ensureWorktree` would clone over it. Absolute from here on.
|
|
26
34
|
if (worktree) worktree = resolve(worktree);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
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);
|
|
30
40
|
// The repo as GitHub spells it, so an oddly cased URL never yields a second entry for the same PR.
|
|
31
|
-
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;
|
|
32
44
|
if (pr.isCrossRepository) throw new Error("fork PRs are not registered");
|
|
33
45
|
if (pr.state !== "OPEN") throw new Error(`PR is ${pr.state}`);
|
|
34
46
|
|
|
@@ -37,10 +49,12 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
37
49
|
const found = state.findByPr(repo, pr.number);
|
|
38
50
|
const id = found ? found[0] : entryId(repo, prdId);
|
|
39
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));
|
|
40
53
|
state.assertCanRegister(id);
|
|
41
54
|
if (existing && existing.pr !== pr.number && existing.pr !== null) throw new Error(`${id} already watches PR #${existing.pr}`);
|
|
42
55
|
const chosenHarness = harness || existing?.harness || block?.harness || defaultHarness(config);
|
|
43
56
|
if (!config.harness[chosenHarness]) throw new Error(`harness "${chosenHarness}" is not configured`);
|
|
57
|
+
assertRepositoryAccess(capabilitiesFor(chosenHarness), selectedHost.repository);
|
|
44
58
|
const switching = existing && chosenHarness !== existing.harness;
|
|
45
59
|
// A daemon holds this lock for its entire lifetime. Claim it through the
|
|
46
60
|
// GitHub reset and registry write so an old-provider tick cannot dispatch
|
|
@@ -141,14 +155,14 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
141
155
|
fenced = true;
|
|
142
156
|
}
|
|
143
157
|
if (existing) {
|
|
144
|
-
try { await
|
|
158
|
+
try { await host.comment(repo, pr.number, `Watching again from here.\n\n${RESET_MARKER}`); }
|
|
145
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.`); }
|
|
146
160
|
}
|
|
147
161
|
// The identity of record, written last of the two and still before anything reaches the registry, so a failure here
|
|
148
162
|
// leaves GitHub and the registry agreeing on the OLD identity rather than disagreeing.
|
|
149
163
|
if (writesRecovery && (!block || stale)) {
|
|
150
164
|
if (switching) remoteMayBeNew = true; // A failed reply cannot prove that the body write did not land.
|
|
151
|
-
try { const updated = await
|
|
165
|
+
try { const updated = await host.setRecoveryMetadata(repo, pr.number, identity, { taskId: id }); pr.body = updated.body; }
|
|
152
166
|
// The message names the identity that survived, and only claims the PR agrees when the PR actually had a block to
|
|
153
167
|
// agree with: an existing entry on a PR with no block at all is a case where the registry has an identity and
|
|
154
168
|
// GitHub has none, which is what this write was about to fix and is not the same as the two of them agreeing.
|
|
@@ -177,7 +191,7 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
177
191
|
// piece of feedback written in that second and lose it for good.
|
|
178
192
|
const since = floorSecond(existing ? nowIso() : pr.createdAt);
|
|
179
193
|
const entry = {
|
|
180
|
-
...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,
|
|
181
195
|
...(switching ? { execution_epoch: nextEpoch, execution_handoff: { from: existing.harness, to: chosenHarness, previousRun: existing.last?.run ?? null, at: nowIso() },
|
|
182
196
|
dispatch_stopped: null, handoff_pending: null } : {}),
|
|
183
197
|
// `retry` goes with `reason`: it counts the consecutive failures of one GitHub write the tick declines a step for,
|
|
@@ -218,7 +232,7 @@ export async function register(ctx, { url, harness = null, session = null, workt
|
|
|
218
232
|
// A snapshot already on disk is kept, so a reset never replaces the authoritative copy with the one the session
|
|
219
233
|
// could have edited.
|
|
220
234
|
await ensurePrdSnapshot(ctx, { id, repo, path: blockPrd, ref: pr.headRefName });
|
|
221
|
-
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}`));
|
|
222
236
|
log.info(`${id}: ${existing ? "reset and " : ""}watching ${pr.url} with ${chosenHarness}${chosenSession ? " (resume)" : " (fresh session on first fix)"}`);
|
|
223
237
|
return { id, pr: pr.number, worktree: dir, harness: chosenHarness, session: chosenSession };
|
|
224
238
|
} catch (error) {
|
|
@@ -241,7 +255,7 @@ async function assertCheckout(git, dir, repo, branch) {
|
|
|
241
255
|
let origin;
|
|
242
256
|
try { origin = await git.originOf(dir); }
|
|
243
257
|
catch (e) { throw new Error(`--worktree ${dir} has no origin remote: ${e.message}`); }
|
|
244
|
-
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}`);
|
|
245
259
|
let current;
|
|
246
260
|
try { current = await git.currentBranch(dir); }
|
|
247
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." });
|
package/lib/commands/start.mjs
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { publicationPolicyOf } from "../work_artifacts.mjs";
|
|
2
|
+
import { repositoryReviewConfig } from "../review_policy.mjs";
|
|
3
|
+
import { repositoryPreflight } from "../repository_preflight.mjs";
|
|
4
|
+
import { hosts } from "../hosts.mjs";
|
|
5
|
+
import { assertRepositoryAccess } from "../host_contract.mjs";
|
|
6
|
+
import { hostApi } from "../hosts.mjs";
|
|
1
7
|
// `gallivant start <prd path>`: one brief becomes one running session in its own checkout.
|
|
2
8
|
//
|
|
3
9
|
// The brief arrives as a Request (lib/sources/), which already names the target repository. `start` no longer works
|
|
@@ -10,7 +16,7 @@ import { remoteInput } from "../execution_input.mjs";
|
|
|
10
16
|
import { learningInput, learningDispatch, learningDelivered, learningDeliveryPatch } from "../learning_input.mjs";
|
|
11
17
|
import { capabilitiesFor, adapterFor, buildRun } from "../harness.mjs";
|
|
12
18
|
import { newEntry, entryId, STATES } from "../state.mjs";
|
|
13
|
-
import { branchFor,
|
|
19
|
+
import { branchFor, runSessionEnv, worktreePath } from "../git.mjs";
|
|
14
20
|
import { resolveCredentialFor, describe as describeCredential } from "../credential.mjs";
|
|
15
21
|
import { requireIdentity, describeIdentity } from "../identity.mjs";
|
|
16
22
|
import { rememberRepo, resolveContextPaths, resolveBase, configuredBase, repoNames } from "../config.mjs";
|
|
@@ -21,6 +27,7 @@ import { requireHarness } from "../probe.mjs";
|
|
|
21
27
|
import { criteriaFromPrd, receiptInstructions } from "../deliverable.mjs";
|
|
22
28
|
import { executionRunner, EXECUTION_VERSION } from "../execution.mjs";
|
|
23
29
|
import { taskExecutionConfig } from "../task_allowance.mjs";
|
|
30
|
+
import { selectReviewPolicy } from "../review_policy.mjs";
|
|
24
31
|
|
|
25
32
|
export async function start(ctx, options) {
|
|
26
33
|
ctx = { ...ctx, config: taskExecutionConfig(ctx.config, options.task) };
|
|
@@ -37,10 +44,11 @@ export async function start(ctx, options) {
|
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
async function startRequest(ctx, { path, base: askedBase = null, chain = null, request: prepared = null, task = null }, progress) {
|
|
40
|
-
const { config, state,
|
|
47
|
+
const { config, state, git, log, runnerFor, writePrd, writeSource, readSource } = ctx;
|
|
48
|
+
const host = hostApi(ctx);
|
|
41
49
|
// Intake supplies an immutable, repository-resolved draft snapshot. All gates,
|
|
42
50
|
// source capture and dispatch ownership below remain shared with file starts.
|
|
43
|
-
const request = prepared ?? await requestFor(path, { git,
|
|
51
|
+
const request = prepared ?? await requestFor(path, { git, host });
|
|
44
52
|
// The gate. Every rule that applies to this source, every time, no override: a brief that fails cannot start.
|
|
45
53
|
// A `context_paths:` key in the frontmatter adds to the config list for this brief alone. It is held to exactly
|
|
46
54
|
// the same rules, so a PRD cannot hand a session the home directory or the Gallivant home that the config file is
|
|
@@ -57,8 +65,14 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
57
65
|
// and there was no second half to join it to. It is the operator's path, never the session's, so nothing a session
|
|
58
66
|
// does can move it, and the daemon reads it for exactly one thing: the divergence check.
|
|
59
67
|
const { repo: nameWithOwner, defaultBranch, relPath: prdRel, checkout } = request;
|
|
68
|
+
const selectedHost = (ctx.hosts ?? hosts).select(nameWithOwner);
|
|
69
|
+
repositoryReviewConfig(config, nameWithOwner, selectedHost.adapter);
|
|
70
|
+
assertRepositoryAccess(capabilitiesFor(request.harness), selectedHost.repository);
|
|
60
71
|
const id = entryId(nameWithOwner, request.id);
|
|
61
72
|
if (state.get(id)) throw new Error(`${id} already exists; \`gallivant why ${id}\` explains it, \`gallivant remove ${id}\` forgets it`);
|
|
73
|
+
// Pin policy before cloning or launching the author. Config edits only apply
|
|
74
|
+
// to future tasks; the explicit review policy command owns migration.
|
|
75
|
+
const reviewPolicy = ctx.reviewService ? await ctx.reviewService.preflight(nameWithOwner) : selectReviewPolicy(config, nameWithOwner);
|
|
62
76
|
|
|
63
77
|
// The base branch this pull request will target, resolved once here and then carried everywhere it is needed: the
|
|
64
78
|
// branch the clone is cut from, the instruction the session is given, and the branch the daemon merges in before
|
|
@@ -69,17 +83,18 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
69
83
|
// Intake pins the resolved base, including its original provenance. Passing
|
|
70
84
|
// that frozen value must not turn a default into a persisted operator override.
|
|
71
85
|
const baseSource = task?.baseSource || selection.source;
|
|
72
|
-
if (!base) throw new Error(`no base branch for ${nameWithOwner}:
|
|
86
|
+
if (!base) throw new Error(`no base branch for ${nameWithOwner}: the host reported no default branch and none is configured. Set one with \`gallivant start ${path} --base <branch>\`.`);
|
|
73
87
|
// A base that is not on the remote must fail HERE. Left to `gh pr create --base`, it fails after the session has
|
|
74
88
|
// implemented the entire brief, run the tests and spent the budget, with the work stranded on a branch nobody asked
|
|
75
89
|
// for. One API call before anything is cloned is the whole cost of not doing that.
|
|
76
90
|
//
|
|
77
91
|
// `branchExists` returns false for HTTP404; transport/CLI failures throw a
|
|
78
92
|
// bounded credential-redacted diagnostic before any clone or provider dispatch.
|
|
79
|
-
if (!await
|
|
93
|
+
if (!await host.branchExists(nameWithOwner, base)) {
|
|
80
94
|
const how = baseSource === "flag" ? "--base names it" : baseSource === "config" ? `repos in config.json names it for ${nameWithOwner}` : `it is ${nameWithOwner}'s default branch`;
|
|
81
|
-
throw new Error(`base branch "${base}" is not on ${nameWithOwner} (${how}), or
|
|
95
|
+
throw new Error(`base branch "${base}" is not on ${nameWithOwner} (${how}), or the host could not be asked. Nothing was cloned and nothing was started. Check the branch name and host access.`);
|
|
82
96
|
}
|
|
97
|
+
if (selectedHost.adapter.features?.transportSetup) await repositoryPreflight(config, nameWithOwner, { registry: ctx.hosts ?? hosts, git, base });
|
|
83
98
|
if (baseSource !== "default") log.info(`${id}: base branch ${base} (from ${baseSource === "flag" ? "--base" : "config"}), not ${nameWithOwner}'s default ${defaultBranch ?? "branch"}`);
|
|
84
99
|
|
|
85
100
|
// Nothing is recorded or cloned for a harness too old to honour its adapter, or one that is signed out.
|
|
@@ -94,9 +109,9 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
94
109
|
// There is no checkout yet, so the host reads as github.com, which is what `gh repo clone owner/name` resolves.
|
|
95
110
|
// An enterprise install is the case this cannot see from here, and `noCredentialMessage` says so by name: the
|
|
96
111
|
// answer there is `github_token_file`, rather than sending a github.com token to a host it cannot authenticate.
|
|
97
|
-
const credential = await resolveCredentialFor(ctx);
|
|
112
|
+
const credential = await resolveCredentialFor(ctx, { repo: nameWithOwner });
|
|
98
113
|
if (!credential.token) throw new Error(credential.why);
|
|
99
|
-
log.info(`${id}:
|
|
114
|
+
log.info(`${id}: repository credential from ${describeCredential(credential)}`);
|
|
100
115
|
// And an identity, refused here for the same reason and in the same place. A run commits as the operator, so with no
|
|
101
116
|
// identity on the machine every commit the session makes would fail; refusing before the clone means no money spent.
|
|
102
117
|
const identity = await requireIdentity(ctx);
|
|
@@ -117,7 +132,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
117
132
|
// The list the brief declared, which the contract rule has already proved readable, so the prompt can name the files
|
|
118
133
|
// before they are snapshotted below and there is no second source of truth about what a brief carries.
|
|
119
134
|
const declared = sourcePathsOf(request);
|
|
120
|
-
const prompt = startPrompt({ prd: request, repo: nameWithOwner, branch, base, verify: verifyCommands(request), receipt, carried: declared });
|
|
135
|
+
const prompt = startPrompt({ host: selectedHost.adapter.prompts, prd: request, repo: nameWithOwner, branch, base, verify: verifyCommands(request), receipt, carried: declared });
|
|
121
136
|
// Read-only extras for this session: the config list plus this brief's own frontmatter, merged in one place.
|
|
122
137
|
const contextPaths = resolveContextPaths(config, request);
|
|
123
138
|
if (remote && contextPaths.length) throw Object.assign(new Error("hosted backend cannot enforce private read-only context_paths"), { code: "EXECUTION_POLICY" });
|
|
@@ -168,6 +183,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
168
183
|
if (remote) {
|
|
169
184
|
state.validateStart(id, claim.token, chain);
|
|
170
185
|
const pending = newEntry({ prd: prdRel, harness: request.harness, session: null, repo: nameWithOwner, branch, worktree, state: STATES.needsHuman, nowIso: nowIso(), checkout, prdFingerprint: prdFingerprint(request), prdDecision: prdDecision(request), base, sources: carried });
|
|
186
|
+
pending.reviewPolicy = reviewPolicy;
|
|
171
187
|
// The clone is owned, but snapshot delivery and read-only API preparation
|
|
172
188
|
// can still fail. Consume the claim into an actionable entry before them;
|
|
173
189
|
// preserve the checkout rather than silently deleting unfinished files.
|
|
@@ -184,6 +200,8 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
184
200
|
// operator edits the file in the second between the two writes. Same 0o644/0o755 as the brief, because these are
|
|
185
201
|
// files in a source tree and must not be clamped to daemon-state modes.
|
|
186
202
|
for (const rel of carried) writeRepositoryText(worktree, rel, readSource(id, rel));
|
|
203
|
+
const hook = git.installHooks?.(worktree, publicationPolicyOf(request, { sources: carried, base }));
|
|
204
|
+
if (hook && !hook.installed) log.warn(`${id}: publication hook could not be installed (${hook.why}); coordinator publication checks remain mandatory`);
|
|
187
205
|
if (carried.length) log.info(`${id}: carried ${carried.length} file${carried.length === 1 ? "" : "s"} from ${request.checkout} into the checkout`);
|
|
188
206
|
if (!remote) state.validateStart(id, claim.token, chain);
|
|
189
207
|
const execution = executionRunner(ctx, id, request.harness);
|
|
@@ -209,7 +227,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
209
227
|
} else {
|
|
210
228
|
// Even a throwing local spawn can have created a child.
|
|
211
229
|
progress.spawnAttempted = true;
|
|
212
|
-
job = await learningDispatch(ctx, id, managed, () => execution.start("start", spec.cmd, spec.args, { cwd: worktree, runtimeSettings: spec.runtimeSettings, env:
|
|
230
|
+
job = await learningDispatch(ctx, id, managed, () => execution.start("start", spec.cmd, spec.args, { cwd: worktree, runtimeSettings: spec.runtimeSettings, env: runSessionEnv(spec.env, config, credential, identity, worktree), execution: dispatchInput }));
|
|
213
231
|
}
|
|
214
232
|
|
|
215
233
|
// `checkout` and the two prd fields are what the divergence check compares against: where the operator's copy lives,
|
|
@@ -220,6 +238,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
220
238
|
prd: prdRel, harness: request.harness, session, repo: nameWithOwner, branch, worktree, state: STATES.running, nowIso: nowIso(),
|
|
221
239
|
checkout, prdFingerprint: prdFingerprint(request), prdDecision: prdDecision(request), base, sources: carried,
|
|
222
240
|
});
|
|
241
|
+
entry.reviewPolicy = reviewPolicy;
|
|
223
242
|
const startedEntry = { ...entry, ...(remote ? { initial_dispatch: null } : {}), ...(job.execution ? { execution: job.execution } : {}), ...(task ? { task: { ...task, run: job.id } } : {}), public_since: publicSince, ...(chain ? { chain: { name: chain.name, index: chain.index, dispatch: chain.dispatch } } : {}), last: { at: nowIso(), run: job.id, exit: null, event: "start" } };
|
|
224
243
|
if (remoteRegistered) state.update(id, current => ({ ...startedEntry, ...learningDeliveryPatch(current, managed, job) }));
|
|
225
244
|
else state.putStarted(id, { ...startedEntry, ...learningDeliveryPatch(null, managed, job) }, claim.token);
|
|
@@ -237,7 +256,7 @@ async function startRequest(ctx, { path, base: askedBase = null, chain = null, r
|
|
|
237
256
|
const persistBase = baseSource === "flag" && !chain ? base : null;
|
|
238
257
|
rememberRepo(nameWithOwner, config.home, persistBase);
|
|
239
258
|
if (persistBase) log.info(`${nameWithOwner}: base branch ${base} recorded in ${config.home}/config.json; later starts in this repository need no --base`);
|
|
240
|
-
if (isNewRepo) await
|
|
259
|
+
if (isNewRepo) await host.ensureLabels(nameWithOwner).catch(e => log.warn(`${nameWithOwner}: labels not created: ${e.message}`));
|
|
241
260
|
log.info(`${id}: started ${request.harness} session in ${worktree}`);
|
|
242
261
|
return { id, worktree, branch, base, baseSource, session, run: job.id };
|
|
243
262
|
} catch (error) {
|
package/lib/commands/status.mjs
CHANGED
|
@@ -10,6 +10,19 @@ import { chainRows } from "../chains.mjs";
|
|
|
10
10
|
import { executionRunner } from "../execution.mjs";
|
|
11
11
|
import { networkRecoveryDetail } from "../network_recovery.mjs";
|
|
12
12
|
import { probeStatus } from "../probe.mjs";
|
|
13
|
+
import { reviewDisclosure, reviewProgressText } from "../review_policy.mjs";
|
|
14
|
+
import { repoNames } from "../config.mjs";
|
|
15
|
+
import { hostGuidance } from "../host_definitions.mjs";
|
|
16
|
+
|
|
17
|
+
// Registry writes include routine polling. Show an actual run/review start or
|
|
18
|
+
// recorded event instead of making an idle row look newly active on each tick.
|
|
19
|
+
const activityAt = (entry, run, inFlight) => {
|
|
20
|
+
const reviewStart = entry.firstPartyReview?.startedAt;
|
|
21
|
+
const times = [reviewStart, inFlight ? run?.startedAt : null, entry.activity_at, entry.last?.at, entry.created_at]
|
|
22
|
+
.map(value => value == null ? NaN : new Date(value).getTime())
|
|
23
|
+
.filter(Number.isFinite);
|
|
24
|
+
return times.length ? new Date(Math.max(...times)).toISOString() : null;
|
|
25
|
+
};
|
|
13
26
|
|
|
14
27
|
/**
|
|
15
28
|
* The divergence for one entry, never fatal. `status` is what an operator runs when something already looks wrong, so a
|
|
@@ -44,8 +57,9 @@ export function model(ctx) {
|
|
|
44
57
|
else if (e.state === STATES.needsHuman) { section = "needs_you"; label = "needs-human"; detail = e.reason; }
|
|
45
58
|
// `gate_skipped` says the completion gate never ran, which is a different thing from passing it, and a person
|
|
46
59
|
// deciding whether to merge on this line needs to be told which of the two it was.
|
|
47
|
-
else if (e.state === STATES.ready) { section = "needs_you"; label = "ready"; detail = e.gate_skipped ? `
|
|
60
|
+
else if (e.state === STATES.ready) { section = "needs_you"; label = "ready"; detail = `${e.review_readiness?.note || reviewDisclosure(e)}; ${e.gate_skipped ? `RECEIPT NOT CHECKED (${e.gate_skipped})` : "receipt accepted"}`; }
|
|
48
61
|
else if (e.state === STATES.done) { section = "done"; label = "done"; detail = ""; }
|
|
62
|
+
else if (e.firstPartyReview?.status === "running") { section = "running"; label = "reviewing"; detail = reviewProgressText(e); }
|
|
49
63
|
// `retry` names the one GitHub write the tick is declining a step for, and it is the truest thing this row can say
|
|
50
64
|
// while it is set. Without it, a start run whose identity block will not write read as "ended, settling on the next
|
|
51
65
|
// tick" on every poll: the process HAD ended, and the tick was not settling it, it was retrying one write and
|
|
@@ -86,7 +100,7 @@ export function model(ctx) {
|
|
|
86
100
|
base: e.base ?? null,
|
|
87
101
|
// When the daemon last merged the base into this branch, so an operator can see the history was changed and when.
|
|
88
102
|
base_merge: e.base_merge ?? null,
|
|
89
|
-
retry: e.retry ?? null, network_retry: e.network_retry ?? null, pr: e.pr, url: e.url || null, harness: e.harness, since: e
|
|
103
|
+
retry: e.retry ?? null, network_retry: e.network_retry ?? null, pr: e.pr, url: e.url || null, harness: e.harness, since: activityAt(e, run, inFlight),
|
|
90
104
|
// `handle` rides along so a consumer can print the command that resolves it without rebuilding the handle from the
|
|
91
105
|
// other fields: the tracker does exactly that, and `handleFor` is not its to know.
|
|
92
106
|
divergence: divReason ? { verdict: divergence.verdict, reason: divReason, why: divergence.why, diff: divergence.diff, path: divergence.path ?? null, handle } : null,
|
|
@@ -95,6 +109,7 @@ export function model(ctx) {
|
|
|
95
109
|
};
|
|
96
110
|
});
|
|
97
111
|
return {
|
|
112
|
+
repositoryGuidance: [...new Set([...repoNames(config), ...state.entries().map(([, entry]) => entry.repo)])].map(repo => ({ repo, detail: hostGuidance(repo) })).filter(r => r.detail),
|
|
98
113
|
daemon: daemon ? { alive: daemon.alive, unverified: Boolean(daemon.unverified), pid: daemon.pid, startedAt: daemon.startedAt, lastTick: daemon.lastTick, nextTick: daemon.nextTick, probes: daemon.probes || {}, stale: Boolean(daemon.stale) } : null,
|
|
99
114
|
latestError: log.latest(),
|
|
100
115
|
chains: chainRows(ctx),
|
|
@@ -120,6 +135,7 @@ export function render(m, { all = false, clock = Date } = {}) {
|
|
|
120
135
|
if (m.daemon.stale) lines.push(" the daemon is running code older than what is installed; its runs use the old behaviour. Restart it: gallivant down && gallivant up");
|
|
121
136
|
}
|
|
122
137
|
if (m.latestError) lines.push(`latest error: ${m.latestError}`);
|
|
138
|
+
for (const guidance of m.repositoryGuidance ?? []) lines.push(`${guidance.repo}: ${guidance.detail}`);
|
|
123
139
|
const section = (title, rows) => {
|
|
124
140
|
lines.push("", title);
|
|
125
141
|
if (!rows.length) { lines.push(" (none)"); return; }
|