@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,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; }
@@ -31,6 +31,7 @@ export async function taskCommand(ctx, { args, values, service = null, stdin = r
31
31
  const shared = { taskId: id, expectedRevision, noDaemon: values["no-daemon"] };
32
32
  if (values.context !== undefined && !["prepare", "answer"].includes(operation)) throw taskError("TASK_INPUT", "--context is supported by task prepare and task answer");
33
33
  if (values["completion-target"] !== undefined && operation !== "prepare") throw taskError("TASK_INPUT", "--completion-target is selected only at task creation");
34
+ for (const flag of ["delivery", "route"]) if (values[flag] !== undefined && operation !== "prepare") throw taskError("TASK_INPUT", `--${flag} is selected only at task creation`);
34
35
  if (operation === "list") return (service || createTaskService(ctx)).list();
35
36
  if (operation === "inspect") return (service || createTaskService(ctx)).inspect(shared);
36
37
  const owner = acquireCliMutation(ctx.home);
@@ -53,7 +54,10 @@ export async function taskCommand(ctx, { args, values, service = null, stdin = r
53
54
  const textArgs = first === "prepare" ? args.slice(1) : args;
54
55
  if (values.stdin && textArgs.length) throw taskError("TASK_INPUT", "use text or --stdin, not both");
55
56
  const text = textArgs.length ? textArgs.join(" ") : await stdin();
56
- return await service.prepare({ text, dir: values.dir, repo: values.repo, base: values.base, harness: values.harness, context: values.context || [], requestId: values["request-id"], run: values.run, noDaemon: values["no-daemon"], verifyCommands: values.verify || [], ...(values["completion-target"] === undefined ? {} : { completion: { target: values["completion-target"] } }) });
57
+ // --delivery selects the typed profile action; --completion-target keeps its
58
+ // meaning as the profile's completion condition. Both are owner choices.
59
+ const delivery = values.delivery === undefined ? undefined : { action: values.delivery, ...(values["completion-target"] === undefined ? {} : { completion: values["completion-target"] }) };
60
+ return await service.prepare({ text, dir: values.dir, repo: values.repo, base: values.base, harness: values.harness, context: values.context || [], requestId: values["request-id"], run: values.run, noDaemon: values["no-daemon"], verifyCommands: values.verify || [], ...(values["completion-target"] === undefined ? {} : { completion: { target: values["completion-target"] } }), ...(delivery ? { delivery } : {}), route: values.route ?? null });
57
61
  }); } finally { owner.release(); }
58
62
  }
59
63
 
@@ -67,6 +71,10 @@ export function renderTask(result) {
67
71
  result.dispatchRecovery && `Dispatch recovery: inspect the clone at ${result.dispatchRecovery.worktree}; start ownership is retained`,
68
72
  result.implementation && `Implementation: ${result.implementation.id} (run ${result.implementation.run})`,
69
73
  result.completion && `Completion target: ${result.completion.target} (${result.completion.status})`,
74
+ result.deliveryProfile && (result.deliveryProfile.legacy ? `Delivery profile: historical task (${result.deliveryProfile.label})` : result.deliveryProfile.supported ? `Delivery profile: ${result.deliveryProfile.action} → ${result.deliveryProfile.target.repository}@${result.deliveryProfile.target.base}, completion ${result.deliveryProfile.completion.condition} (v${result.deliveryProfile.version}); no merge or deployment authority` : `Delivery profile: unsupported (${result.deliveryProfile.error?.message})`),
75
+ result.modelRoute && `Planner route: ${result.modelRoute.kind === "model" ? `${result.modelRoute.adapter} ${result.modelRoute.model} via ${result.modelRoute.endpointHost}` : `harness ${result.modelRoute.harness}`}; calls ${result.modelRoute.calls}; cost ${result.cost.status}${Number.isFinite(result.cost.usd) ? ` $${result.cost.usd.toFixed(4)}` : ""}`,
76
+ result.repositoryContext && `Repository context: ${result.repositoryContext.status}${result.repositoryContext.revision ? ` at ${result.repositoryContext.revision.slice(0, 12)} (digest ${result.repositoryContext.digest.slice(0, 12)})` : result.repositoryContext.reason ? `: ${result.repositoryContext.reason}` : ""}`,
77
+ result.review && `Review: revision ${result.review.revision}, digest ${result.review.digest.slice(0, 12)}, ${result.review.requirementMap.length} clauses mapped, ${result.review.questions.length} open question(s)${result.approval ? `, approved at ${result.approval.at}` : ", not yet approved"}`,
70
78
  result.daemonError,
71
79
  `Next: ${result.nextAction}`,
72
80
  ].filter(Boolean).join("\n");
@@ -1,3 +1,4 @@
1
+ import { hostApi, hosts } from "../hosts.mjs";
1
2
  // `gallivant up`: the daemon. Reconciles with GitHub on boot, then ticks on a setTimeout chain so ticks never
2
3
  // overlap. Health is a file (daemon.json) that `status` reads; a second daemon exits when the first is alive.
3
4
  import { join } from "node:path";
@@ -6,7 +7,7 @@ import { openSync, closeSync } from "node:fs";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { makeTick } from "../tick.mjs";
8
9
  import { checkGh, checkHarness, probeFix } from "../probe.mjs";
9
- import { makeProviderHealth } from "../provider_health.mjs";
10
+ import { makeProviderHealth, coordinatorHealthLimits, scheduleProviderHealth } from "../provider_health.mjs";
10
11
  import { EXECUTION_LIMITS } from "../execution_io.mjs";
11
12
  import { readJson, writeJsonAtomic, VERSION, codeStamp, gallivantHome } from "../fs.mjs";
12
13
  import { nowIso, later } from "../time.mjs";
@@ -17,6 +18,7 @@ import { newEntry, entryId, STATES } from "../state.mjs";
17
18
  import { receiptHash } from "../deliverable.mjs";
18
19
  import { worktreePath, isOurBranch, prdIdFromBranch } from "../git.mjs";
19
20
  import { daemonPath, readDaemon, assertDaemonUsable, acquireDaemon, assertUnpinnedHome } from "../daemon.mjs";
21
+ import { repositoryReviewConfig } from "../review_policy.mjs";
20
22
  import { repoNames } from "../config.mjs";
21
23
 
22
24
  export { daemonPath, readDaemon } from "../daemon.mjs";
@@ -54,11 +56,12 @@ export function down(ctx, { home = ctx?.home ?? gallivantHome() } = {}) {
54
56
  * the session, which the log says. Missing checkouts are recreated lazily by the tick when a run is needed.
55
57
  */
56
58
  export async function reconcile(ctx, ownLogin) {
57
- const { config, state, gh, log } = ctx;
59
+ const { config, state, log } = ctx;
60
+ const host = hostApi(ctx);
58
61
  const added = [];
59
62
  for (const repo of repoNames(config)) {
60
63
  let prs;
61
- try { prs = await gh.prListMine(repo); } catch (e) { log.warn(`reconcile ${repo}: ${e.message}`); continue; }
64
+ try { const selected = (ctx.hosts ?? hosts).select(repo); repositoryReviewConfig(config, repo, selected.adapter); prs = await host.prListMine(repo); } catch (e) { log.warn(`reconcile ${repo}: ${e.message}`); continue; }
62
65
  for (const pr of prs || []) {
63
66
  if (pr.isCrossRepository || !isOurBranch(pr.headRefName)) continue;
64
67
  if (state.findByPr(repo, pr.number)) continue;
@@ -70,15 +73,15 @@ export async function reconcile(ctx, ownLogin) {
70
73
  if (state.get(id)) { log.warn(`reconcile ${repo}#${pr.number}: id ${id} already in use, ignored`); continue; }
71
74
  try { state.assertCanRegister(id); }
72
75
  catch (e) { log.warn(`reconcile ${repo}#${pr.number}: ${e.message}, ignored`); continue; }
73
- const [comments, headDate] = await Promise.all([gh.issueComments(repo, pr.number), gh.commitDate(repo, pr.headRefOid).catch(() => null)]);
74
- const lastOwn = lastOwnCommentAt(comments, ownLogin);
76
+ const [comments, headDate] = await Promise.all([host.issueComments(repo, pr.number), host.commitDate(repo, pr.headRefOid).catch(() => null)]);
77
+ const lastOwn = lastOwnCommentAt(comments, host.userFor ? await host.userFor(repo) : ownLogin);
75
78
  await ensurePrdSnapshot(ctx, { id, repo, path: block.prd, ref: pr.headRefName });
76
79
  // `base` is the branch the PR targets, read off the PR rather than guessed from config: a recovered entry has to merge
77
80
  // in the branch this pull request will actually be merged into, and only GitHub knows which that is.
78
81
  const entry = newEntry({ prd: block.prd, harness: block.harness, session: block.session, repo, branch: pr.headRefName, worktree: worktreePath(config, repo, prdId), pr: pr.number, url: pr.url, state: STATES.prOpen, nowIso: nowIso(), base: typeof pr.baseRefName === "string" && pr.baseRefName ? pr.baseRefName : null });
79
82
  try { state.put(id, {
80
83
  ...entry,
81
- iteration: countRounds(comments, ownLogin),
84
+ iteration: countRounds(comments, host.userFor ? await host.userFor(repo) : ownLogin),
82
85
  // The receipt fingerprint comes from the body `prListMine` already returned, as every other writer of this
83
86
  // cursor sets it. Left null, a receipt that was on the PR before the recovery read as a change the next time
84
87
  // a run ended, so a run that did nothing counted as a round and posted "Completion receipt updated." to the
@@ -151,15 +154,18 @@ export async function upInBackground(ctx, { bin = process.execPath, script = fil
151
154
  }
152
155
 
153
156
  export async function up(ctx, { once = false } = {}) {
154
- const { home, config, gh, notify, log, clock = Date } = ctx;
157
+ const { home, config, notify, log, clock = Date } = ctx;
158
+ const host = hostApi(ctx);
155
159
  const existing = readDaemon(home);
156
160
  if (existing?.alive && existing.pid !== process.pid) throw new Error(`daemon already running (pid ${existing.pid})`);
157
161
  if (existing?.unverified) throw new Error(`daemon pid ${existing.pid} has an unverifiable process identity; verify and stop the older daemon before restarting`);
158
162
  const { owner, release } = acquireDaemon(home);
159
163
  let ownLogin = null;
160
164
  let timer = null;
165
+ let stopProviderHealth = null;
161
166
  const cleanup = () => {
162
167
  if (timer) clearTimeout(timer);
168
+ stopProviderHealth?.(); stopProviderHealth = null;
163
169
  try { writeJsonAtomic(daemonPath(home), { ...readJson(daemonPath(home), {}), ...owner, stoppedAt: nowIso() }); } catch { /* exiting */ }
164
170
  try { release(); } catch { /* stale lock can be recovered next boot */ }
165
171
  process.off("SIGINT", stop); process.off("SIGTERM", stop); process.off("exit", cleanup);
@@ -181,11 +187,11 @@ export async function up(ctx, { once = false } = {}) {
181
187
  catch (e) { log.warn(`cannot write ${daemonPath(home)} (${e.code || e.message}); status will not see this tick`); }
182
188
  };
183
189
  health({ starting: true });
184
- ownLogin = await gh.user();
185
- const providerHealth = makeProviderHealth({ home, clock, limits: config.execution, probe: (provider, account, signal) => checkHarness(provider, config, { signal }) });
190
+ ownLogin = await host.user();
191
+ const providerHealth = makeProviderHealth({ home, clock, limits: coordinatorHealthLimits(config.execution), probe: (provider, account, signal) => checkHarness(provider, config, { signal }) });
186
192
  const githubHealth = makeProviderHealth({ home, file: join(home, "github-health.json"), clock,
187
193
  limits: { ...config.execution, progressMs: config.poll_seconds * 1000 },
188
- probe: (_provider, _account, signal) => checkGh(gh, { signal }) });
194
+ probe: (_provider, _account, signal) => checkGh(host, { signal }) });
189
195
  const tickImpl = makeTick({ ...ctx, ownLogin, providerHealth });
190
196
  health({ starting: false });
191
197
  keepAwake();
@@ -193,11 +199,13 @@ export async function up(ctx, { once = false } = {}) {
193
199
  const recovered = await reconcile(ctx, ownLogin);
194
200
  if (recovered.length) log.info(`recovered ${recovered.length} PR(s)`);
195
201
  const tickMs = Math.min(config.poll_seconds * 1000, config.execution?.progressMs || EXECUTION_LIMITS.progressMs);
202
+ const scopes = Object.keys(config.harness).map(h => [h, config.harness[h]?.account || "operator"]);
203
+ const providerInterval = Math.min(30_000, Math.max(1_000, Math.floor(coordinatorHealthLimits(config.execution).progressMs / 2)));
204
+ stopProviderHealth = scheduleProviderHealth(providerHealth, scopes, { intervalMs: providerInterval, onError: e => log.warn(`provider health refresh failed: ${e.code || "check unavailable"}`) });
196
205
 
197
206
  /** One tick, with every failure caught: a daemon that dies on one bad tick is worse than one that logs it. */
198
207
  const loop = async () => {
199
208
  try {
200
- const scopes = Object.keys(config.harness).map(h => [h, config.harness[h]?.account || "operator"]);
201
209
  // Provider checks and GitHub/progress work use separate lanes. Failed or
202
210
  // due/pending health reads fence only that account/provider, even before
203
211
  // refresh gets a worker; the previous healthy cache cannot authorize work.
@@ -1,12 +1,13 @@
1
1
  // `gallivant why <handle>`: the reason a PR stopped, what was dispatched, what the harness last said, any permission
2
2
  // denials, and APort audit lines from the run's window. Reads files; never calls GitHub.
3
+ import { reviewDisclosure } from "../review_policy.mjs";
3
4
  import { resolveHandle, shortRepo } from "../state.mjs";
4
5
  import { adapterFor } from "../harness.mjs";
5
6
  import { prdDivergence, divergenceReason, renderDivergence, prdDecision, sourceDivergence } from "../prd.mjs";
6
7
  import { contract } from "../fs.mjs";
7
8
  import { aportDirFor, auditEntries } from "../aport.mjs";
8
9
  import { READY_LABEL_WRITE, COMPLETION_GATE } from "../tick.mjs";
9
- import { READY_LABEL } from "../gh.mjs";
10
+ import { READY_LABEL } from "../host_contract.mjs";
10
11
  import { executionRunner } from "../execution.mjs";
11
12
  import { networkRecoveryDetail } from "../network_recovery.mjs";
12
13
  import { redact } from "../execution_io.mjs";
@@ -103,10 +104,13 @@ export function why(ctx, { handle }) {
103
104
  else if (e.prd_decision && now === e.prd_decision) out.push(` aport_decision is unchanged (${e.prd_decision}), so the edit has not been through \`gallivant ready\`.`);
104
105
  }
105
106
  if (e.retry) out.push(`waiting on ${e.retry.op === COMPLETION_GATE ? "completion verification" : "GitHub"}: ${e.retry.op} has failed ${e.retry.count} tick${e.retry.count === 1 ? "" : "s"} in a row; the entry stops with "stuck" if it keeps failing`);
106
- if (e.retry?.op === COMPLETION_GATE) out.push(` checks green and reviewer clear; completion verification is pending: ${e.retry.error ?? "no decision from the gate"}`);
107
+ out.push(` Review: ${reviewDisclosure(e)}`);
108
+ if (e.firstPartyReview?.error) out.push(` Review failure: ${e.firstPartyReview.error}`);
109
+ if (e.firstPartyReview?.unresolvedScope?.length) for (const item of e.firstPartyReview.unresolvedScope) out.push(` Unresolved: ${item}`);
110
+ if (e.retry?.op === COMPLETION_GATE) out.push(` checks settled; ${e.review_readiness?.note || reviewDisclosure(e)}; completion verification is pending: ${e.retry.error ?? "no decision from the gate"}`);
107
111
  // The one retry whose op is not a step toward a fix round: everything a person waits for has already happened and
108
112
  // only the label that says so will not write, which is worth spelling out where the operator is already reading.
109
- if (e.retry?.op === READY_LABEL_WRITE) out.push(` this PR is otherwise ready: checks green, reviewer clear, receipt accepted. Only the ${READY_LABEL} label will not write.`);
113
+ if (e.retry?.op === READY_LABEL_WRITE) out.push(` this PR is otherwise ready: checks settled, ${e.review_readiness?.note || reviewDisclosure(e)}, receipt accepted. Only the ${READY_LABEL} label will not write.`);
110
114
  // The round comment is the announcement of a round that is already counted, so the operator needs both halves: the
111
115
  // work is on the branch and charged, and the PR does not say so. The second half matters because the count on GitHub
112
116
  // is what a recovery rebuilds from, so an unposted marker is a round a recovery after this would not see.
package/lib/config.mjs CHANGED
@@ -1,7 +1,11 @@
1
+ import { repositoryIdentity } from "./repository.mjs";
2
+ import { repositoryReviewConfig, reviewPolicyProblems } from "./review_policy.mjs";
3
+ import { HOST_DEFINITIONS } from "./host_definitions.mjs";
1
4
  // Config: one JSON file the operator writes by hand, validated here so every other module can trust its shape.
2
5
  //
3
6
  // Defaults hold nothing machine-specific. Anything that identifies the operator (trusted logins, token file,
4
7
  // binaries) has to be written explicitly, which is what `doctor` walks you through.
8
+ import { reviewSettings, validateReviewSettings } from "./review_policy.mjs";
5
9
  import { join, relative, isAbsolute } from "node:path";
6
10
  import { homedir } from "node:os";
7
11
  import { realpathSync } from "node:fs";
@@ -10,6 +14,7 @@ import { validateAgentsConfig } from "./openai_agents_config.mjs";
10
14
  import { validateCloudConfig } from "./codex_cloud_config.mjs";
11
15
  import { adapters, executionRegistry } from "./harness.mjs";
12
16
  import { findExecutable } from "./executable.mjs";
17
+ import { intakeModelProblems } from "./task_model_config.mjs";
13
18
 
14
19
  // Recognition is independent of new-dispatch availability: a registered provider
15
20
  // must retain configuration and recovery identities while its readiness is refused.
@@ -77,7 +82,7 @@ export const DEFAULT_DELIVERABLE = Object.freeze({
77
82
  export const DEFAULTS = Object.freeze({
78
83
  deliverable: DEFAULT_DELIVERABLE,
79
84
  poll_seconds: 120,
80
- max_iterations: 30,
85
+ max_iterations: 60,
81
86
  checks_grace_minutes: 5, // a head younger than this with no checks yet is not "green", it is "not reported yet"
82
87
  // What this number is FOR, because the value only makes sense once that is settled. The timeout catches a session that
83
88
  // is genuinely wedged: spinning on a command that never returns, waiting on a prompt that will never come, looping.
@@ -97,7 +102,7 @@ export const DEFAULTS = Object.freeze({
97
102
  // A repository with a different shape sets its own on its `repos` entry; this is only the fallback. The validation
98
103
  // floor stays at 1, so an operator who deliberately wants a short timeout can have one.
99
104
  run_timeout_minutes: 720,
100
- max_budget_usd: 5,
105
+ max_budget_usd: 50,
101
106
  rereview_minutes: 6,
102
107
  stall_hours: 6,
103
108
  trusted_authors: [],
@@ -278,21 +283,23 @@ export function resolveTimeout({ config, repo }) {
278
283
  */
279
284
  export function validateRepos(list) {
280
285
  if (list === undefined || list === null) return [];
281
- if (!Array.isArray(list)) return ['repos must be an array of "owner/name" strings or { repo, base, run_timeout_minutes } objects'];
286
+ if (!Array.isArray(list)) return ['repos must be an array of "owner/name" strings or { repo, base, run_timeout_minutes, review } objects'];
282
287
  const problems = [];
283
288
  const seen = new Set();
284
289
  for (const raw of list) {
285
290
  if (typeof raw === "string") {
286
- if (!/^[\w.-]+\/[\w.-]+$/.test(raw)) { problems.push(`repos: "${raw}" is not owner/name`); continue; }
291
+ if (!validRepository(raw)) { problems.push("repos: invalid identity is not owner/name or host/owner/name; URL credentials are not accepted"); continue; }
287
292
  } else if (raw && typeof raw === "object" && !Array.isArray(raw)) {
288
- if (typeof raw.repo !== "string" || !/^[\w.-]+\/[\w.-]+$/.test(raw.repo)) { problems.push(`repos: ${JSON.stringify(raw)} needs a "repo" of the form owner/name`); continue; }
293
+ if (typeof raw.repo !== "string" || !validRepository(raw.repo)) { problems.push('repos: needs a "repo" of the form owner/name or host/owner/name, without URL credentials'); continue; }
289
294
  if (raw.base !== undefined && raw.base !== null && (typeof raw.base !== "string" || !raw.base.trim())) { problems.push(`repos: ${raw.repo} base must be a branch name`); continue; }
290
295
  // Refused rather than ignored, for the reason the unknown-key check above exists: a timeout typed as "45" or as 0
291
296
  // would read as "not configured" and the repository would keep the global forever, which is the silent failure.
292
297
  if (raw.run_timeout_minutes !== undefined && raw.run_timeout_minutes !== null && (typeof raw.run_timeout_minutes !== "number" || raw.run_timeout_minutes < 1)) { problems.push(`repos: ${raw.repo} run_timeout_minutes must be a number >= 1`); continue; }
293
- const extra = Object.keys(raw).filter(k => k !== "repo" && k !== "base" && k !== "run_timeout_minutes");
294
- if (extra.length) { problems.push(`repos: ${raw.repo} has unknown key${extra.length === 1 ? "" : "s"} ${extra.join(", ")} (only repo, base and run_timeout_minutes are read)`); continue; }
298
+ if (raw.review != null && (typeof raw.review !== "object" || Array.isArray(raw.review))) { problems.push(`repos: ${raw.repo} review must be an object of review settings`); continue; }
299
+ const extra = Object.keys(raw).filter(k => !["repo", "base", "run_timeout_minutes", "review", "reviewer", "trusted_authors", "required_checks"].includes(k));
300
+ if (extra.length) { problems.push(`repos: ${raw.repo} has unknown key${extra.length === 1 ? "" : "s"} ${extra.join(", ")} (use repo, base, run_timeout_minutes and explicit review policy)`); continue; }
295
301
  } else { problems.push(`repos: ${JSON.stringify(raw)} is neither "owner/name" nor a { repo } object`); continue; }
302
+ if (typeof raw === "object") problems.push(...reviewPolicyProblems(raw, HOST_DEFINITIONS.find(d => d.host === repositoryIdentity(raw.repo).host)?.reviewPolicy));
296
303
  const key = repoEntry(raw).repo;
297
304
  if (seen.has(key)) problems.push(`repos: ${key} is listed twice`);
298
305
  seen.add(key);
@@ -323,6 +330,14 @@ export function merge(raw, home = gallivantHome()) {
323
330
  /** Problems as human sentences; empty means valid. Used by `load` (throws) and by `doctor` (prints). */
324
331
  export function validate(cfg, home = gallivantHome()) {
325
332
  const problems = [];
333
+ if (cfg.git_hosts != null) {
334
+ if (typeof cfg.git_hosts !== "object" || Array.isArray(cfg.git_hosts)) problems.push("git_hosts must be a host-scoped configuration object");
335
+ else for (const [host, settings] of Object.entries(cfg.git_hosts)) {
336
+ const adapter = HOST_DEFINITIONS.find(d => d.host === host);
337
+ if (!adapter?.validateConfig) problems.push("Unsupported git_hosts configuration; use a registered host's documented credential settings");
338
+ else problems.push(...adapter.validateConfig(settings));
339
+ }
340
+ }
326
341
  const num = (k, min) => { if (typeof cfg[k] !== "number" || cfg[k] < min) problems.push(`${k} must be a number >= ${min}`); };
327
342
  num("poll_seconds", 30); num("max_iterations", 1); num("run_timeout_minutes", 1); num("max_budget_usd", 0);
328
343
  num("rereview_minutes", 1); num("stall_hours", 1); num("checks_grace_minutes", 0);
@@ -333,7 +348,21 @@ export function validate(cfg, home = gallivantHome()) {
333
348
  if (!range || !Number.isSafeInteger(value) || value < range[0] || value > range[1]) problems.push(`execution.${key} must be an integer in ${range ? range.join("..") : "the documented execution limits"}`);
334
349
  }
335
350
  }
336
- if (!Array.isArray(cfg.trusted_authors) || cfg.trusted_authors.length === 0) problems.push("trusted_authors must list at least one GitHub login");
351
+ const legacy = !Array.isArray(cfg.repos) || !cfg.repos.length || cfg.repos.some(r => {
352
+ try { return HOST_DEFINITIONS.find(d => d.host === repositoryIdentity(typeof r === "string" ? r : r.repo).host)?.features?.legacyReviewDefaults; } catch { return true; }
353
+ });
354
+ if (legacy && (!Array.isArray(cfg.trusted_authors) || cfg.trusted_authors.length === 0)) problems.push("trusted_authors must list at least one GitHub login");
355
+ for (const repo of Array.isArray(cfg.repos) ? cfg.repos : []) {
356
+ try { const r = repositoryIdentity(typeof repo === "string" ? repo : repo.repo); if (HOST_DEFINITIONS.find(d => d.host === r.host)?.reviewPolicy) repositoryReviewConfig(cfg, r); }
357
+ catch (error) { problems.push(error.message); }
358
+ }
359
+ try {
360
+ for (const value of [cfg.review, ...(cfg.repos || []).map(r => r?.review)]) {
361
+ if (value !== undefined && (value === null || typeof value !== "object" || Array.isArray(value))) throw new Error("review must be a configuration object");
362
+ }
363
+ validateReviewSettings(reviewSettings(cfg));
364
+ for (const repo of cfg.repos || []) if (repo?.review) validateReviewSettings(reviewSettings(cfg, repo.repo));
365
+ } catch (error) { problems.push(error.message); }
337
366
  if (cfg.reviewer !== null) {
338
367
  for (const k of ["login", "rereview_comment", "blocking_pattern"]) if (typeof cfg.reviewer?.[k] !== "string") problems.push(`reviewer.${k} must be a string (or set reviewer to null)`);
339
368
  // `reviewed_commit_pattern` is optional, and that is deliberate rather than an omission. It was added after configs
@@ -350,6 +379,7 @@ export function validate(cfg, home = gallivantHome()) {
350
379
  if (cfg.deliverable !== null && (typeof cfg.deliverable !== "object" || Array.isArray(cfg.deliverable))) problems.push("deliverable must be an object of limits (or null to disable the completion gate)");
351
380
  else if (cfg.deliverable && !["aport", "local"].includes(cfg.deliverable.evaluator)) problems.push('deliverable.evaluator must be "aport" or "local"');
352
381
  else if (cfg.deliverable && !["wait", "local"].includes(cfg.deliverable.on_unreachable)) problems.push('deliverable.on_unreachable must be "wait" or "local"');
382
+ if (cfg.intake_model !== undefined && cfg.intake_model !== null) problems.push(...intakeModelProblems(cfg.intake_model));
353
383
  if (!cfg.worktrees) problems.push("worktrees must be a directory path outside the Gallivant home");
354
384
  else if (isInside(expand(cfg.worktrees), home)) problems.push("worktrees must not be under the Gallivant home directory");
355
385
  problems.push(...validateContextPaths(cfg.context_paths, home));
@@ -401,7 +431,8 @@ export function repoEntry(raw) {
401
431
  if (raw && typeof raw === "object" && typeof raw.repo === "string") {
402
432
  const base = typeof raw.base === "string" && raw.base.trim() ? raw.base.trim() : null;
403
433
  const t = typeof raw.run_timeout_minutes === "number" && raw.run_timeout_minutes >= 1 ? raw.run_timeout_minutes : null;
404
- return { repo: raw.repo.toLowerCase(), base, run_timeout_minutes: t };
434
+ const policy = Object.fromEntries(["review", "reviewer", "trusted_authors", "required_checks"].filter(k => Object.hasOwn(raw, k)).map(k => [k, raw[k]]));
435
+ return { repo: raw.repo.toLowerCase(), base, run_timeout_minutes: t, ...policy };
405
436
  }
406
437
  return null;
407
438
  }
@@ -459,3 +490,5 @@ export function rememberRepo(nameWithOwner, home = gallivantHome(), base = null)
459
490
  writeJsonAtomic(configPath(home), { ...raw, repos: updated }, 0o600);
460
491
  return true;
461
492
  }
493
+
494
+ function validRepository(value) { try { repositoryIdentity(value); return true; } catch { return false; } }
package/lib/context.mjs CHANGED
@@ -5,21 +5,22 @@ import { contentHash } from "./openai_agents_sources.mjs";
5
5
  import { join } from "node:path";
6
6
  import { load as loadConfig } from "./config.mjs";
7
7
  import { makeState, idSlug } from "./state.mjs";
8
- import { makeGh } from "./gh.mjs";
8
+ import { makeHostRegistry } from "./hosts.mjs";
9
9
  import { makeGit } from "./git.mjs";
10
- import { resolveCredential } from "./credential.mjs";
10
+ import { repositoryIdentity } from "./repository.mjs";
11
11
  import { resolveIdentity } from "./identity.mjs";
12
12
  import { makeNotifier } from "./notify.mjs";
13
13
  import { makeLog } from "./log.mjs";
14
14
  import { makeRunner } from "./run.mjs";
15
15
  import { ensureDir, gallivantHome, readText, writeText } from "./fs.mjs";
16
16
  import { aportDirFor, guardrailMode } from "./aport.mjs";
17
- import { makeExecution, assertExecutionDependency } from "./execution.mjs";
17
+ import { makeExecution, executionEpoch, assertExecutionDependency } from "./execution.mjs";
18
18
  import { makeAgentsTransport } from "./openai_agents.mjs";
19
19
  import { capabilitiesFor } from "./harness.mjs";
20
20
  import { createLearningService } from "./learning.mjs";
21
21
  import { makeTrust } from "./events.mjs";
22
22
  import { revokeLearningAttempts } from "./learning_input.mjs";
23
+ import { createReviewService } from "./first_party_review.mjs";
23
24
  import { silentLog } from "./log.mjs";
24
25
  const { createTaskService } = await import("./tasks.mjs").catch(error => {
25
26
  if (error.code === "ERR_MODULE_NOT_FOUND") assertExecutionDependency(null);
@@ -42,41 +43,41 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
42
43
  const log = readOnly ? silentLog : makeLog(home);
43
44
  if (!needConfig) return { home, log };
44
45
  const config = loadConfig(home);
45
- // One credential resolver for the whole process, shared by `gh` and `git` so both act as the same identity.
46
- //
47
- // Per call, not per boot. It costs one `gh auth token` subprocess (only when neither the config file nor the
48
- // daemon's own environment answers, which are both free), and it is the only way an operator who re-authenticates
49
- // under a long-lived daemon gets the new token without restarting it. A value cached at boot would expire under
50
- // exactly the daemon this product is built around.
51
- const credential = (opts = {}) => resolveCredential(config, opts);
46
+ // Each adapter resolves its credentials per operation, so reauthentication is
47
+ // picked up without restarting the coordinator or sharing another host's token.
48
+ const hosts = makeHostRegistry({ config, log });
49
+ const credential = (opts = {}) => hosts.credential(repositoryIdentity(opts.path ?? opts.repository ?? opts.repo ?? "owner/name", opts.host));
52
50
  // The git identity a run commits as, resolved the same way and for the same reason: per call, so an operator who
53
51
  // changes their name or email under a long-lived daemon is committed as who they are now.
54
52
  const identity = () => resolveIdentity();
55
- const gh = makeGh({ credential, log });
53
+ const host = hosts.api;
54
+ const gh = hosts.select("owner/name").api;
56
55
  const state = makeState(home);
57
56
  const context = {
58
- home, log, config, gh, credential, identity,
57
+ home, log, config, host, hosts, gh, credential, identity,
59
58
  state, capabilitiesFor,
60
59
  executionInputFor: (id, entry, input, contextPaths) => remoteInput(context, id, entry, input, contextPaths),
61
60
  // `identity` as well as `credential`, because `git.mergeBase` commits: the merge that brings a base into a branch
62
61
  // is the daemon's own commit, and with no identity pairs it is either attributed to a machine-derived address or
63
62
  // refused outright, depending on whether the host's git can guess one. See makeGit for the two shapes.
64
- git: makeGit({ config, credential, identity }),
63
+ git: makeGit({ config, identity, hosts }),
65
64
  notify: makeNotifier({ config, log }),
66
65
  runnerFor: id => makeRunner(join(home, "runs", idSlug(id))),
67
66
  executionFor: (id, provider) => {
68
67
  const root = join(home, "runs", idSlug(id)), selected = config.harness[provider];
69
68
  const account = selected?.account || "operator";
70
- return makeExecution({ root, taskId: id, provider, account,
69
+ const entry = state.get(id), runner = capabilitiesFor(provider).transport === "remote" ? null : makeRunner(root);
70
+ const epoch = executionEpoch({ root, taskId: id, entry, provider, account, runner });
71
+ return makeExecution({ root, taskId: id, provider, account, ledgerEpoch: epoch, ledgerFile: join(root, epoch ? `execution-${epoch}.json` : "execution.json"),
71
72
  ...(provider === "openai-agents" ? { transport: makeAgentsTransport({ root, taskId: id, account, config: selected }), capabilities: { ...capabilitiesFor(provider), models: [selected?.model] } } : {}),
72
73
  ...(provider === "codex-cloud" ? { transport: makeCloudTransport({ root, taskId: id, account, config: selected, verifyRemote: async input => {
73
- if (await gh.refSha(input.repo, input.sources.inputBranch) !== input.inputHead || await gh.refSha(input.repo, input.branch) !== input.inputHead) throw new Error("Cloud remote input revision moved; reconcile before dispatch");
74
+ if (await host.refSha(input.repo, input.sources.inputBranch) !== input.inputHead || await host.refSha(input.repo, input.branch) !== input.inputHead) throw new Error("Cloud remote input revision moved; reconcile before dispatch");
74
75
  for (const [path, hash] of Object.entries(input.sources.hashes)) {
75
- if (contentHash(await gh.fileAtStrict(input.repo, path, input.inputHead)) !== hash) throw new Error("Cloud remote snapshot hash differs");
76
+ if (contentHash(await host.fileAtStrict(input.repo, path, input.inputHead)) !== hash) throw new Error("Cloud remote snapshot hash differs");
76
77
  }
77
78
  } }), capabilities: { ...capabilitiesFor(provider), environments: [selected?.environment] } } : {}),
78
79
  legacySessionId: state.get(id)?.harness === provider ? state.get(id).session : null,
79
- runner: capabilitiesFor(provider).transport === "remote" ? null : makeRunner(root), limits: config.execution });
80
+ runner, limits: config.execution });
80
81
  },
81
82
  // The APort API key the Claude install recorded, if any, so hosted gate calls count against the same account.
82
83
  aportApiKey: guardrailMode(aportDirFor(config.harness?.claude?.settings)).apiKey,
@@ -90,11 +91,17 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
90
91
  const learningServices = new Map();
91
92
  context.learningFor = (ownLogin = "learning-coordinator", { signerExecutable } = {}) => {
92
93
  const cacheKey = JSON.stringify([ownLogin, signerExecutable ?? null]);
93
- if (!learningServices.has(cacheKey)) learningServices.set(cacheKey, createLearningService({
94
- root: join(home, "learning"), signerExecutable, gh, trust: makeTrust(config, ownLogin),
95
- onRevocation: intent => revokeLearningAttempts(context, intent),
96
- }));
94
+ if (!learningServices.has(cacheKey)) {
95
+ const service = createLearningService({
96
+ root: join(home, "learning"), signerExecutable, gh, trust: makeTrust(config, ownLogin),
97
+ onRevocation: intent => revokeLearningAttempts(context, intent),
98
+ });
99
+ learningServices.set(cacheKey, { ...service,
100
+ identityFor: name => hosts.select(name).adapter.features?.learning ? service.identityFor(name) : null,
101
+ });
102
+ }
97
103
  return learningServices.get(cacheKey);
98
104
  };
105
+ if (!readOnly) context.reviewService = createReviewService(context);
99
106
  return context;
100
107
  }