@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
@@ -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,9 +5,9 @@ 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";
@@ -20,6 +20,7 @@ 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,26 +43,24 @@ 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) => {
@@ -72,9 +71,9 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
72
71
  return makeExecution({ root, taskId: id, provider, account, ledgerEpoch: epoch, ledgerFile: join(root, epoch ? `execution-${epoch}.json` : "execution.json"),
73
72
  ...(provider === "openai-agents" ? { transport: makeAgentsTransport({ root, taskId: id, account, config: selected }), capabilities: { ...capabilitiesFor(provider), models: [selected?.model] } } : {}),
74
73
  ...(provider === "codex-cloud" ? { transport: makeCloudTransport({ root, taskId: id, account, config: selected, verifyRemote: async input => {
75
- 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");
76
75
  for (const [path, hash] of Object.entries(input.sources.hashes)) {
77
- 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");
78
77
  }
79
78
  } }), capabilities: { ...capabilitiesFor(provider), environments: [selected?.environment] } } : {}),
80
79
  legacySessionId: state.get(id)?.harness === provider ? state.get(id).session : null,
@@ -92,11 +91,17 @@ export function makeContext({ home = gallivantHome(), needConfig = true, readOnl
92
91
  const learningServices = new Map();
93
92
  context.learningFor = (ownLogin = "learning-coordinator", { signerExecutable } = {}) => {
94
93
  const cacheKey = JSON.stringify([ownLogin, signerExecutable ?? null]);
95
- if (!learningServices.has(cacheKey)) learningServices.set(cacheKey, createLearningService({
96
- root: join(home, "learning"), signerExecutable, gh, trust: makeTrust(config, ownLogin),
97
- onRevocation: intent => revokeLearningAttempts(context, intent),
98
- }));
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
+ }
99
103
  return learningServices.get(cacheKey);
100
104
  };
105
+ if (!readOnly) context.reviewService = createReviewService(context);
101
106
  return context;
102
107
  }
package/lib/control.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostApi } from "./hosts.mjs";
1
2
  // Local operator requests. UI and CLI enqueue; the daemon is the only consumer.
2
3
  // One outstanding request per entry avoids competing resumes and needs no second runner.
3
4
  import { randomUUID } from "node:crypto";
@@ -5,7 +6,7 @@ import { needsAgentResume } from "./network_recovery.mjs";
5
6
  import { resolveHandle, STATES } from "./state.mjs";
6
7
  import { readDaemon, assertDaemonUsable } from "./daemon.mjs";
7
8
  import { nowIso } from "./time.mjs";
8
- import { NEEDS_LABEL, READY_LABEL } from "./gh.mjs";
9
+ import { NEEDS_LABEL, READY_LABEL } from "./host_contract.mjs";
9
10
  import { adapterFor } from "./harness.mjs";
10
11
  import { executionRunner, unsubmittedInitialDispatch, PROVIDER_STATES } from "./execution.mjs";
11
12
  import { requestRunCancellation } from "./run.mjs";
@@ -163,21 +164,28 @@ export async function applyControl(ctx, id) {
163
164
  }
164
165
  const runner = executionRunner(ctx, id);
165
166
  if (runner.current()?.running) return false;
167
+ if (["message", "resume"].includes(request.action)) {
168
+ const health = ctx.providerHealth?.get(entry.harness, ctx.config.harness[entry.harness]?.account || "operator");
169
+ if (health?.pending) {
170
+ if (request.message !== "Waiting for the provider readiness check.") ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, message: "Waiting for the provider readiness check." } } : {});
171
+ return true;
172
+ }
173
+ }
166
174
  ctx.state.update(id, { control: { ...request, status: "applying" } });
167
175
  try {
168
176
  if (entry.state === STATES.done) throw new Error("The task was closed or merged before this request ran.");
169
177
  if (entry.pr) {
170
- const pr = await ctx.gh.prView(entry.repo, entry.pr);
178
+ const pr = await hostApi(ctx).prView(entry.repo, entry.pr);
171
179
  if (pr.state !== "OPEN") throw new Error("The PR is no longer open.");
172
180
  }
173
181
  let run = null, initialLaunch = false;
174
182
  if (request.action === "recheck") {
175
183
  if (needsAgentResume(entry)) throw Object.assign(new Error("The agent failure requires resume, not PR recheck."), { code: "AGENT_RESUME_REQUIRED" });
176
184
  if (executionResultCheckpoint(runner)?.phase === "blocked") throw Object.assign(new Error("Result publication needs an explicit corrective resume."), { code: "EXECUTION_RESULT_BLOCKED" });
177
- await ctx.gh.removeLabel(entry.repo, entry.pr, READY_LABEL);
178
- await ctx.gh.removeLabel(entry.repo, entry.pr, NEEDS_LABEL);
185
+ await hostApi(ctx).removeLabel(entry.repo, entry.pr, READY_LABEL);
186
+ await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL);
179
187
  ctx.state.update(id, {
180
- state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null,
188
+ state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null, review_next_at: 0,
181
189
  last: { ...entry.last, event: "operator requested PR recheck" }, updated_at: nowIso(ctx.clock),
182
190
  });
183
191
  // Preserve budgets, dispatch markers and the brief. Only the normal tick judges readiness.
@@ -187,13 +195,20 @@ export async function applyControl(ctx, id) {
187
195
  run = result.run;
188
196
  initialLaunch = result.initialLaunch === true;
189
197
  // A cosmetic label failure after spawning must not invite another resume.
190
- if (entry.pr) await ctx.gh.removeLabel(entry.repo, entry.pr, NEEDS_LABEL).catch(e => ctx.log.warn(`${id}: could not remove stopped label: ${e.message}`));
198
+ if (entry.pr) await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL).catch(e => ctx.log.warn(`${id}: could not remove stopped label: ${e.message}`));
191
199
  }
192
200
  finish({ status: "applied", run, message: initialLaunch ? "Started the initial run after repairing launch prerequisites." : request.action === "message"
193
201
  ? "Started the next run in the same session with your message."
194
202
  : run ? "Resumed the same agent session." : "Watching PR checks and review again." });
195
203
  return request.action !== "recheck"; // A recheck continues through normal judgement in this same tick.
196
204
  } catch (e) {
205
+ // PROVIDER_UNAVAILABLE is raised only before launch. Preserve the exact
206
+ // request when a due probe races with preparation; do not replay an
207
+ // ambiguous dispatch or turn a failed/auth check into automatic retries.
208
+ if (e.code === "PROVIDER_UNAVAILABLE" && e.retryableProviderPending === true) {
209
+ ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, status: "queued", message: "Waiting for the provider readiness check." } } : {});
210
+ return true;
211
+ }
197
212
  ctx.log.warn(`${id}: operator ${request.action} failed: ${e.message}`);
198
213
  // Subprocess errors can carry credentials. Details stay in the local daemon log.
199
214
  finish({ status: "failed", message: controlFailure(e) });
@@ -1,3 +1,4 @@
1
+ import { parseRemote, repositoryIdentity } from "./repository.mjs";
1
2
  // The one place a GitHub credential for a run is resolved.
2
3
  //
3
4
  // The defect this exists for. A run is sandboxed: it cannot reach the operator's macOS keyring, their Linux secret
@@ -46,15 +47,11 @@ export const SOURCES = Object.freeze({
46
47
  * The host a remote URL's credential belongs to. `gh` can be logged in to an enterprise host as well as
47
48
  * github.com, and the token for github.com is the wrong one for `github.acme.com`.
48
49
  *
49
- * Only the host is taken, and only from a URL shape we recognise. An SSH remote has no useful host for a token
50
- * (a token cannot authenticate SSH), so it reads as github.com and the caller's own remote check is what fails.
50
+ * The strict repository parser supplies the host for every accepted transport. Malformed input throws
51
+ * without reflecting URL userinfo. The host adapter decides which transport may actually use a token.
51
52
  */
52
53
  export function hostFromRemote(url) {
53
- const s = String(url || "").trim();
54
- const m = s.match(/^https?:\/\/(?:[^@/]*@)?([a-z0-9.-]+)/i);
55
- if (m) return m[1].toLowerCase();
56
- const ssh = s.match(/^(?:ssh:\/\/)?(?:[^@/]+@)([a-z0-9.-]+)[:/]/i);
57
- return ssh ? ssh[1].toLowerCase() : "github.com";
54
+ return parseRemote(url).host;
58
55
  }
59
56
 
60
57
  /** True when a host is github.com itself rather than an enterprise install. */
@@ -111,6 +108,7 @@ export function noCredentialMessage(host = "github.com") {
111
108
  * putting a real token anywhere near a fixture.
112
109
  */
113
110
  export async function resolveCredential(config, { host = "github.com", env = process.env, exec = execFileAsyncQuiet } = {}) {
111
+ if (host.toLowerCase() === "bitbucket.org") throw new Error("GitHub credentials cannot authorize Bitbucket; resolve its selected credential through the host registry");
114
112
  // A configured file is an authority boundary, not merely the first candidate.
115
113
  // Missing, unreadable or blank must not silently widen to an ambient login.
116
114
  if (config?.github_token_file) {
@@ -134,12 +132,11 @@ export const describe = ({ token, source, host }) =>
134
132
  token ? `${source}${isDotCom(host) ? "" : ` for ${host}`} (${token.length} characters)` : "none";
135
133
 
136
134
  /**
137
- * The host a checkout's credential belongs to, from its `origin` remote. Failure reads as github.com, because a
138
- * checkout whose remote cannot be read has a bigger problem than which host its token is for and the caller's own
139
- * git operations will say so.
135
+ * The host a checkout's credential belongs to, from its verified origin. Unreadable origins throw before
136
+ * any credential is resolved; they never fall back to another host.
140
137
  */
141
138
  export async function hostForWorktree(git, dir) {
142
- try { return hostFromRemote(await git.originOf(dir)); } catch { return "github.com"; }
139
+ return hostFromRemote(await git.originOf(dir));
143
140
  }
144
141
 
145
142
  /**
@@ -154,8 +151,17 @@ export async function hostForWorktree(git, dir) {
154
151
  * from the config alone, so a test that wants a credential sets `github_token_file` and one that wants none sets
155
152
  * nothing, and no test needs a `gh` on the machine.
156
153
  */
157
- export async function resolveCredentialFor(ctx, { worktree = null } = {}) {
158
- const host = worktree && ctx.git ? await hostForWorktree(ctx.git, worktree) : "github.com";
154
+ export async function resolveCredentialFor(ctx, { worktree = null, repo = null } = {}) {
155
+ let repository = repo ? repositoryIdentity(repo) : null;
156
+ if (worktree && ctx.git?.originOf) {
157
+ const actual = parseRemote(await ctx.git.originOf(worktree));
158
+ if (repository && actual.remoteIdentity !== repository.remoteIdentity) throw new Error("Checkout host/repository mismatch; restore the bound origin before resolving credentials");
159
+ repository = actual;
160
+ }
161
+ repository ??= repositoryIdentity("owner/name");
162
+ if (ctx.hosts) return ctx.hosts.credential(repository);
159
163
  const resolve = ctx.credential ?? (opts => resolveCredential(ctx.config, opts));
160
- return resolve({ host });
164
+ const resolved = await resolve({ host: repository.host });
165
+ if (resolved?.host && resolved.host !== repository.host) throw new Error("Credential host mismatch");
166
+ return { ...resolved, host: repository.host, ...(repo || worktree ? { repository: repository.path } : {}) };
161
167
  }
@@ -35,6 +35,8 @@ export const provenance = (framework = null) => ({ ...(framework ? { framework }
35
35
 
36
36
  const RECEIPT_RE = new RegExp("^```(?:" + RECEIPT_TAGS.join("|") + ")\\s*\\n([\\s\\S]*?)\\n```[ \t]*\\r?(?=\\n|$)", "gm");
37
37
  const receiptMatches = body => [...String(body || "").matchAll(RECEIPT_RE)];
38
+ /** A cross-host handoff must not carry the source task's machine receipt. */
39
+ export const withoutReceipt = body => String(body || "").replace(RECEIPT_RE, "");
38
40
  export const publicTaskId = taskId => "task-" + createHash("sha256").update(String(taskId)).digest("hex").slice(0, 24);
39
41
  const FETCH_TIMEOUT_MS = 15_000;
40
42
  // The hosted route limits the whole JSON body to 256 KiB, including the passport and JSON escaping.