@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.
Files changed (133) hide show
  1. package/README.md +120 -60
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +41 -14
  4. package/bin/promote-bitbucket.mjs +40 -0
  5. package/bin/task-pr.mjs +9 -0
  6. package/bin/task-review.mjs +15 -0
  7. package/docs/README.md +6 -2
  8. package/docs/agent-setup.md +55 -41
  9. package/docs/architecture.md +11 -3
  10. package/docs/bitbucket-promotion.md +69 -0
  11. package/docs/bitbucket-review.md +330 -0
  12. package/docs/chains.md +30 -1
  13. package/docs/codex-cloud.md +7 -0
  14. package/docs/codex-review.md +50 -0
  15. package/docs/commands.md +103 -14
  16. package/docs/config.md +45 -13
  17. package/docs/execution.md +1 -1
  18. package/docs/first-party-review.md +266 -0
  19. package/docs/git-hosts.md +294 -0
  20. package/docs/guardrails.md +56 -0
  21. package/docs/install.md +137 -67
  22. package/docs/launch.md +7 -4
  23. package/docs/network-recovery.md +9 -0
  24. package/docs/openai-agents.md +3 -2
  25. package/docs/prd.md +23 -2
  26. package/docs/preparation.md +2 -2
  27. package/docs/release-qualification.md +1 -1
  28. package/docs/releasing.md +46 -22
  29. package/docs/task-delivery.md +5 -5
  30. package/docs/task-profiles.md +537 -0
  31. package/docs/tasks.md +59 -197
  32. package/lib/attribution.mjs +7 -3
  33. package/lib/block.mjs +10 -1
  34. package/lib/chains.mjs +101 -30
  35. package/lib/codex_cloud.mjs +2 -1
  36. package/lib/codex_cloud_input.mjs +15 -6
  37. package/lib/codex_cloud_result.mjs +1 -1
  38. package/lib/commands/doctor.mjs +22 -453
  39. package/lib/commands/init.mjs +281 -28
  40. package/lib/commands/register.mjs +81 -13
  41. package/lib/commands/resume.mjs +5 -1
  42. package/lib/commands/review.mjs +66 -0
  43. package/lib/commands/serve.mjs +8 -0
  44. package/lib/commands/start.mjs +30 -11
  45. package/lib/commands/status.mjs +18 -2
  46. package/lib/commands/task.mjs +9 -1
  47. package/lib/commands/up.mjs +19 -11
  48. package/lib/commands/why.mjs +7 -3
  49. package/lib/config.mjs +42 -9
  50. package/lib/context.mjs +28 -21
  51. package/lib/control.mjs +21 -6
  52. package/lib/credential.mjs +20 -14
  53. package/lib/deliverable.mjs +2 -0
  54. package/lib/events.mjs +96 -53
  55. package/lib/execution.mjs +132 -11
  56. package/lib/execution_preparation.mjs +16 -1
  57. package/lib/execution_result.mjs +26 -5
  58. package/lib/first_party_review.mjs +618 -0
  59. package/lib/fs.mjs +10 -3
  60. package/lib/gh.mjs +144 -56
  61. package/lib/git.mjs +133 -32
  62. package/lib/git_diff.mjs +121 -0
  63. package/lib/harness.mjs +14 -10
  64. package/lib/host_contract.mjs +71 -0
  65. package/lib/host_definitions.mjs +22 -0
  66. package/lib/hosts/bitbucket.mjs +553 -0
  67. package/lib/hosts/bitbucket_contract.mjs +57 -0
  68. package/lib/hosts/bitbucket_credential.mjs +62 -0
  69. package/lib/hosts/bitbucket_review.mjs +44 -0
  70. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  71. package/lib/hosts/github.mjs +57 -0
  72. package/lib/hosts/github_contract.mjs +27 -0
  73. package/lib/hosts/github_doctor.mjs +482 -0
  74. package/lib/hosts/github_review_summary.mjs +38 -0
  75. package/lib/hosts/transport_doctor.mjs +35 -0
  76. package/lib/hosts.mjs +130 -0
  77. package/lib/learning_qualification.mjs +1 -1
  78. package/lib/notify.mjs +1 -1
  79. package/lib/openai_agents_input.mjs +7 -2
  80. package/lib/openai_agents_sources.mjs +16 -8
  81. package/lib/plugin_package.mjs +13 -1
  82. package/lib/pr_operation.mjs +41 -0
  83. package/lib/prd.mjs +18 -3
  84. package/lib/preparation_codex.mjs +6 -1
  85. package/lib/probe.mjs +6 -4
  86. package/lib/process.mjs +24 -0
  87. package/lib/promotion.mjs +132 -0
  88. package/lib/promotion_git.mjs +63 -0
  89. package/lib/prompt.mjs +16 -13
  90. package/lib/provider_health.mjs +14 -3
  91. package/lib/repository.mjs +68 -0
  92. package/lib/repository_preflight.mjs +11 -0
  93. package/lib/review_evaluation.mjs +25 -0
  94. package/lib/review_evidence.mjs +500 -0
  95. package/lib/review_history.mjs +73 -0
  96. package/lib/review_image.mjs +145 -0
  97. package/lib/review_link.mjs +12 -0
  98. package/lib/review_policy.mjs +132 -0
  99. package/lib/review_policy_contract.mjs +18 -0
  100. package/lib/review_state.mjs +13 -0
  101. package/lib/review_visual.mjs +94 -0
  102. package/lib/runtime-environment.mjs +6 -0
  103. package/lib/runtime_node.mjs +3 -2
  104. package/lib/runtime_state.mjs +39 -5
  105. package/lib/sources/prd.mjs +6 -3
  106. package/lib/state.mjs +137 -24
  107. package/lib/task_completion.mjs +7 -3
  108. package/lib/task_contract.mjs +201 -27
  109. package/lib/task_delivery.mjs +9 -2
  110. package/lib/task_delivery_contract.mjs +13 -9
  111. package/lib/task_delivery_view.mjs +18 -2
  112. package/lib/task_environment.mjs +13 -1
  113. package/lib/task_limits.mjs +13 -0
  114. package/lib/task_model_config.mjs +99 -0
  115. package/lib/task_model_route.mjs +243 -0
  116. package/lib/task_prepare.mjs +21 -8
  117. package/lib/task_profile.mjs +1875 -0
  118. package/lib/task_repository_context.mjs +65 -0
  119. package/lib/tasks.mjs +252 -51
  120. package/lib/tick.mjs +373 -98
  121. package/lib/time.mjs +1 -0
  122. package/lib/tracker/pages.mjs +19 -5
  123. package/lib/tracker/review.mjs +21 -0
  124. package/lib/tracker_process.mjs +8 -1
  125. package/lib/verification_sandbox.mjs +2 -1
  126. package/lib/work_artifacts.mjs +79 -0
  127. package/npm-shrinkwrap.json +12 -3
  128. package/package.json +9 -3
  129. package/templates/plugin/task-delivery.SKILL.md +17 -10
  130. package/templates/plugin/task-intake-contract.json +18 -0
  131. package/templates/plugin/task-profile-requests.mjs +1448 -0
  132. package/templates/tracker/tracker.css +3 -0
  133. package/templates/tracker/tracker.js +38 -0
@@ -1,34 +1,287 @@
1
- import { mkdirSync, openSync, closeSync, writeFileSync, existsSync, lstatSync } from "node:fs";
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 { gallivantHome, expand, templatePath } from "../fs.mjs";
4
- import { discoverHarnesses, validate, merge } from "../config.mjs";
5
- import { makeGh } from "../gh.mjs";
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
- // Explicit setup only; never rewrite an existing config or touch global harness
9
- // settings, credentials or repository permissions.
10
- export async function init({ home = gallivantHome(), harness = "codex", login, worktrees,
11
- discover = discoverHarnesses, user = () => makeGh({ log: silentLog }).user() } = {}) {
12
- if (!["codex", "claude"].includes(harness)) throw new Error("init supports --harness codex or claude; configure other adapters explicitly after checking docs/install.md");
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 = join(home, "config.json");
15
- if (existsSync(file)) throw new Error(`Config already exists at ${file}; edit it or run gallivant doctor. Nothing was overwritten.`);
16
- for (let dir = home; ; dir = dirname(dir)) {
17
- if (existsSync(dir) && lstatSync(dir).isSymbolicLink()) throw new Error(`Setup directory must not be a symlink: ${dir}`);
18
- if (dirname(dir) === dir) break;
19
- }
20
- const selected = discover(home)[harness];
21
- if (!selected) throw new Error(`${harness} is not on PATH. Install it and sign in, then retry gallivant init --harness ${harness}`);
22
- const ownLogin = login || await user();
23
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*$/.test(ownLogin || "")) throw new Error("Sign in with gh auth login before running init");
24
- const config = { trusted_authors: [ownLogin], worktrees: expand(worktrees || join(dirname(home), ".gallivant-worktrees")),
25
- harness: { [harness]: selected }, notify: null, github_token_file: null, repos: [] };
26
- const problems = validate(merge(config, home), home);
27
- if (problems.length) throw new Error(problems.join("; "));
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
- mkdirSync(config.worktrees, { recursive: true, mode: 0o700 });
30
- // wx also refuses a dangling symlink and protects concurrent invocations.
31
- const fd = openSync(file, "wx", 0o600);
32
- try { writeFileSync(fd, JSON.stringify(config, null, 2) + "\n"); } finally { closeSync(fd); }
33
- return { file, harness, config, templates: { passport: templatePath("passport.gallivant.json"), sandbox: templatePath("claude-settings.json") } };
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, remoteMatches, RESET_MARKER } from "../gh.mjs";
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, gh, git, log } = ctx;
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 parsed = repoFromUrl(url);
26
- if (!parsed) throw new Error(`not a GitHub PR URL: ${url}`);
27
- const pr = await gh.prView(parsed.repo, parsed.number);
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 repo = repoFromUrl(pr.url)?.repo || parsed.repo;
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 gh.comment(repo, pr.number, `Watching again from here.\n\n${RESET_MARKER}`); }
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
- try { const updated = await gh.setRecoveryMetadata(repo, pr.number, identity, { taskId: id }); pr.body = updated.body; }
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 gh.ensureLabels(repo).catch(e => log.warn(`${repo}: labels not created: ${e.message}`));
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}`); }
@@ -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
+ }
@@ -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." });