@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.
Files changed (129) hide show
  1. package/README.md +38 -16
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +33 -10
  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 +2 -0
  8. package/docs/architecture.md +11 -3
  9. package/docs/bitbucket-promotion.md +69 -0
  10. package/docs/bitbucket-review.md +330 -0
  11. package/docs/chains.md +30 -1
  12. package/docs/codex-cloud.md +7 -0
  13. package/docs/codex-review.md +50 -0
  14. package/docs/commands.md +65 -5
  15. package/docs/config.md +35 -10
  16. package/docs/first-party-review.md +266 -0
  17. package/docs/git-hosts.md +294 -0
  18. package/docs/guardrails.md +56 -0
  19. package/docs/install.md +18 -8
  20. package/docs/network-recovery.md +9 -0
  21. package/docs/openai-agents.md +2 -1
  22. package/docs/prd.md +23 -2
  23. package/docs/preparation.md +1 -1
  24. package/docs/release-qualification.md +1 -1
  25. package/docs/releasing.md +17 -2
  26. package/docs/task-delivery.md +4 -4
  27. package/docs/task-profiles.md +537 -0
  28. package/docs/tasks.md +59 -197
  29. package/lib/attribution.mjs +7 -3
  30. package/lib/block.mjs +10 -1
  31. package/lib/chains.mjs +101 -30
  32. package/lib/codex_cloud.mjs +2 -1
  33. package/lib/codex_cloud_input.mjs +15 -6
  34. package/lib/codex_cloud_result.mjs +1 -1
  35. package/lib/commands/doctor.mjs +22 -462
  36. package/lib/commands/init.mjs +72 -30
  37. package/lib/commands/register.mjs +25 -11
  38. package/lib/commands/resume.mjs +5 -1
  39. package/lib/commands/review.mjs +66 -0
  40. package/lib/commands/serve.mjs +8 -0
  41. package/lib/commands/start.mjs +30 -11
  42. package/lib/commands/status.mjs +18 -2
  43. package/lib/commands/task.mjs +9 -1
  44. package/lib/commands/up.mjs +19 -11
  45. package/lib/commands/why.mjs +7 -3
  46. package/lib/config.mjs +42 -9
  47. package/lib/context.mjs +23 -18
  48. package/lib/control.mjs +21 -6
  49. package/lib/credential.mjs +20 -14
  50. package/lib/deliverable.mjs +2 -0
  51. package/lib/events.mjs +96 -53
  52. package/lib/execution.mjs +48 -3
  53. package/lib/execution_preparation.mjs +16 -1
  54. package/lib/execution_result.mjs +26 -5
  55. package/lib/first_party_review.mjs +618 -0
  56. package/lib/gh.mjs +144 -56
  57. package/lib/git.mjs +133 -32
  58. package/lib/git_diff.mjs +121 -0
  59. package/lib/harness.mjs +14 -10
  60. package/lib/host_contract.mjs +71 -0
  61. package/lib/host_definitions.mjs +22 -0
  62. package/lib/hosts/bitbucket.mjs +553 -0
  63. package/lib/hosts/bitbucket_contract.mjs +57 -0
  64. package/lib/hosts/bitbucket_credential.mjs +62 -0
  65. package/lib/hosts/bitbucket_review.mjs +44 -0
  66. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  67. package/lib/hosts/github.mjs +57 -0
  68. package/lib/hosts/github_contract.mjs +27 -0
  69. package/lib/hosts/github_doctor.mjs +482 -0
  70. package/lib/hosts/github_review_summary.mjs +38 -0
  71. package/lib/hosts/transport_doctor.mjs +35 -0
  72. package/lib/hosts.mjs +130 -0
  73. package/lib/learning_qualification.mjs +1 -1
  74. package/lib/notify.mjs +1 -1
  75. package/lib/openai_agents_input.mjs +7 -2
  76. package/lib/openai_agents_sources.mjs +16 -8
  77. package/lib/plugin_package.mjs +13 -1
  78. package/lib/pr_operation.mjs +41 -0
  79. package/lib/prd.mjs +18 -3
  80. package/lib/preparation_codex.mjs +6 -1
  81. package/lib/probe.mjs +6 -4
  82. package/lib/process.mjs +24 -0
  83. package/lib/promotion.mjs +132 -0
  84. package/lib/promotion_git.mjs +63 -0
  85. package/lib/prompt.mjs +16 -13
  86. package/lib/provider_health.mjs +14 -3
  87. package/lib/repository.mjs +68 -0
  88. package/lib/repository_preflight.mjs +11 -0
  89. package/lib/review_evaluation.mjs +25 -0
  90. package/lib/review_evidence.mjs +500 -0
  91. package/lib/review_history.mjs +73 -0
  92. package/lib/review_image.mjs +145 -0
  93. package/lib/review_link.mjs +12 -0
  94. package/lib/review_policy.mjs +132 -0
  95. package/lib/review_policy_contract.mjs +18 -0
  96. package/lib/review_state.mjs +13 -0
  97. package/lib/review_visual.mjs +94 -0
  98. package/lib/runtime-environment.mjs +6 -0
  99. package/lib/runtime_node.mjs +3 -2
  100. package/lib/runtime_state.mjs +39 -5
  101. package/lib/sources/prd.mjs +6 -3
  102. package/lib/state.mjs +137 -24
  103. package/lib/task_completion.mjs +7 -3
  104. package/lib/task_contract.mjs +201 -27
  105. package/lib/task_delivery.mjs +9 -2
  106. package/lib/task_delivery_contract.mjs +13 -9
  107. package/lib/task_delivery_view.mjs +18 -2
  108. package/lib/task_environment.mjs +13 -1
  109. package/lib/task_limits.mjs +13 -0
  110. package/lib/task_model_config.mjs +99 -0
  111. package/lib/task_model_route.mjs +243 -0
  112. package/lib/task_prepare.mjs +21 -8
  113. package/lib/task_profile.mjs +1875 -0
  114. package/lib/task_repository_context.mjs +65 -0
  115. package/lib/tasks.mjs +252 -51
  116. package/lib/tick.mjs +353 -97
  117. package/lib/time.mjs +1 -0
  118. package/lib/tracker/pages.mjs +19 -5
  119. package/lib/tracker/review.mjs +21 -0
  120. package/lib/tracker_process.mjs +8 -1
  121. package/lib/verification_sandbox.mjs +2 -1
  122. package/lib/work_artifacts.mjs +79 -0
  123. package/npm-shrinkwrap.json +12 -3
  124. package/package.json +9 -3
  125. package/templates/plugin/task-delivery.SKILL.md +17 -10
  126. package/templates/plugin/task-intake-contract.json +18 -0
  127. package/templates/plugin/task-profile-requests.mjs +1448 -0
  128. package/templates/tracker/tracker.css +3 -0
  129. package/templates/tracker/tracker.js +38 -0
@@ -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 { makeGh, repoFromRemote } from "../gh.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 };
15
19
  import { silentLog } from "../log.mjs";
16
20
  import { checkHarness } from "../probe.mjs";
17
- import { resolveCredential, describe as describeCredential } from "../credential.mjs";
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
- const repo = repoFromRemote(origin);
54
- if (!repo) throw new Error(`origin remote ${safeOrigin(origin)} is not a github.com repository URL. Gallivant opens pull requests on github.com; point origin at the GitHub repository, 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}`); }
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 = makeGh({ log: silentLog }), repository = targetRepository, harnessCheck = checkHarness, doctor = runDoctor,
91
- resolve = resolveCredential, ghFor = credential => makeGh({ credential: async () => credential, log: silentLog }) } = {}) {
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 (!/^[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}`);
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 ? structuredClone(raw) : {
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
- let credential;
143
- try { credential = await resolve(runConfig); } catch (e) { throw new Error(`${firstLine(e)}\n${NOTHING_WRITTEN}`); }
144
- if (!credential?.token) throw new Error(`${credential?.why || "No GitHub credential could be resolved for a run."}\n${NOTHING_WRITTEN}`);
145
- const identity = describeCredential(credential);
146
- const runGh = ghFor(credential);
147
- const perm = await runGh.repoPermissions(target.repo).catch(e => ({ error: firstLine(e) }));
148
- 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}`);
149
- 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}`);
150
- 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}`);
151
- // Repository role alone does not grant a classic token write scope, even when that account can push.
152
- const scope = await repositoryWriteScopeCheck(runGh, credential);
153
- if (!scope.ok) throw new Error(`${scope.detail}.\nFix: ${scope.fix}\n${NOTHING_WRITTEN}`);
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, 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
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, gh, git, log } = ctx;
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 parsed = repoFromUrl(url);
28
- if (!parsed) throw new Error(`not a GitHub PR URL: ${url}`);
29
- 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);
30
40
  // The repo as GitHub spells it, so an oddly cased URL never yields a second entry for the same PR.
31
- 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;
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 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}`); }
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 gh.setRecoveryMetadata(repo, pr.number, identity, { taskId: id }); pr.body = updated.body; }
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 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}`));
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}`); }
@@ -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." });
@@ -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, runEnv, worktreePath } from "../git.mjs";
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, gh, git, log, runnerFor, writePrd, writeSource, readSource } = ctx;
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, gh });
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}: GitHub reported no default branch and none is configured. Set one with \`gallivant start ${path} --base <branch>\`.`);
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 gh.branchExists(nameWithOwner, base)) {
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 GitHub could not be asked. Nothing was cloned and nothing was started. Check the branch name, or run \`gh api repos/${nameWithOwner}/branches/${base}\` to see what GitHub says.`);
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}: GitHub credential from ${describeCredential(credential)}`);
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: { ...runEnv(config, credential, identity, worktree), ...spec.env }, execution: dispatchInput }));
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 gh.ensureLabels(nameWithOwner).catch(e => log.warn(`${nameWithOwner}: labels not created: ${e.message}`));
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) {
@@ -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 ? `green, no blocking findings, RECEIPT NOT CHECKED (${e.gate_skipped})` : "green, no blocking findings, receipt accepted"; }
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.updated_at,
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; }