@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
@@ -0,0 +1,63 @@
1
+ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { execFileAsync, transportExecutor } from "./process.mjs";
5
+ import { runEnv } from "./git.mjs";
6
+ import { shellQuote } from "./runtime-environment.mjs";
7
+
8
+ /** Git's pre-push hook receives the actual advertised old OID. The server then
9
+ * compares that old OID atomically. This closes the planning/push race without
10
+ * --force or a lease that could permit a non-fast-forward update. */
11
+ export function pushGuard({ branch, head, previous }) {
12
+ return `const fs = require('node:fs');
13
+ const rows = fs.readFileSync(0, 'utf8').trim().split('\\n').map(s => s.split(/\\s+/));
14
+ const expected = ${JSON.stringify({ branch, head, previous })};
15
+ if (rows.length !== 1 || rows[0].length !== 4 || rows[0][1] !== expected.head || rows[0][2] !== 'refs/heads/' + expected.branch || rows[0][3] !== (expected.previous || '0'.repeat(expected.head.length))) {
16
+ process.stderr.write('Promotion target changed: inspect a fresh dry-run plan.\\n'); process.exit(1);
17
+ }`;
18
+ }
19
+
20
+ /** Isolated object transfer: never checkout/execute repository files or copy
21
+ * credentials. Each network call gets only that host's existing bound Git auth. */
22
+ export async function makePromotionTransfer(ctx, { execute = execFileAsync } = {}) {
23
+ const dir = await mkdtemp(join(tmpdir(), "branch-promotion-"));
24
+ const exec = transportExecutor(execute);
25
+ const clean = () => Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^(?:GIT_|GH_|GITHUB_|BITBUCKET_|BB_|SSH_|HOST_API_)/.test(key)));
26
+ const git = async (args, repo = null) => {
27
+ const credential = repo ? await ctx.hosts.gitCredential(repo) : null;
28
+ const env = { ...clean(), ...runEnv(ctx.config, credential), GIT_TERMINAL_PROMPT: "0" };
29
+ const { stdout } = await exec("git", args, { cwd: dir, env, timeout: 300_000 });
30
+ return stdout.trim();
31
+ };
32
+ try { await git(["init", "--bare", "."]); }
33
+ catch (error) { await rm(dir, { recursive: true, force: true }); throw error; }
34
+ const fetchRef = async (repo, ref, slot, expected) => {
35
+ const remote = ctx.hosts.remote(repo);
36
+ await git(["fetch", "--no-tags", "--no-recurse-submodules", remote.url, `+${ref}:refs/promotion/${slot}`], repo);
37
+ if (await git(["rev-parse", `refs/promotion/${slot}^{commit}`]) !== expected) throw new Error("Remote Git ref changed or differs from host API evidence; inspect a new plan");
38
+ };
39
+ const ancestor = async (before, after) => {
40
+ try { await git(["merge-base", "--is-ancestor", before, after]); return true; }
41
+ catch (error) { if (error.code === 1) return false; throw error; }
42
+ };
43
+ return {
44
+ async validate(plan) {
45
+ await fetchRef(plan.source.repo, `refs/pull/${plan.source.number}/head`, "source", plan.source.head);
46
+ await fetchRef(plan.source.repo, plan.source.baseHead, "source-base", plan.source.baseHead);
47
+ await fetchRef(plan.target.repo, `refs/heads/${plan.target.base}`, "target-base", plan.target.baseHead);
48
+ if (plan.target.head) await fetchRef(plan.target.repo, `refs/heads/${plan.target.branch}`, "target", plan.target.head);
49
+ if (!await ancestor(plan.source.baseHead, plan.source.head)) throw new Error("Source branch does not contain its reviewed base; reconcile and review before promotion");
50
+ const sourceTree = await git(["rev-parse", `${plan.source.baseHead}^{tree}`]);
51
+ const targetTree = await git(["rev-parse", `${plan.target.baseHead}^{tree}`]);
52
+ if (sourceTree !== targetTree) throw new Error("Source reviewed base and Bitbucket base trees differ; reconcile on GitHub and review again before promotion");
53
+ if (plan.target.head && !await ancestor(plan.target.head, plan.source.head)) throw new Error("Bitbucket branch diverged; promotion never force-pushes or rewrites history");
54
+ },
55
+ async push(plan) {
56
+ const hooks = join(dir, "promotion-hooks");
57
+ await mkdir(hooks, { recursive: true });
58
+ await writeFile(join(hooks, "pre-push"), `#!/bin/sh\nexec ${shellQuote(process.execPath)} --input-type=commonjs -e ${shellQuote(pushGuard({ branch: plan.target.branch, head: plan.source.head, previous: plan.target.head }))}\n`, { mode: 0o700 });
59
+ await git(["-c", `core.hooksPath=${hooks}`, "push", "--no-follow-tags", ctx.hosts.remote(plan.target.repo).url, `${plan.source.head}:refs/heads/${plan.target.branch}`], plan.target.repo);
60
+ },
61
+ close: () => rm(dir, { recursive: true, force: true }),
62
+ };
63
+ }
package/lib/prompt.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostPrompts } from "./host_definitions.mjs";
1
2
  // Prompt builders. Anything that came from GitHub (comment bodies, review text) is quoted inside a four-backtick
2
3
  // fence with internal fences neutralised, and capped, so it reads as data. That is hygiene, not a control; the
3
4
  // controls are the sandbox, the token scope and the ruleset (PRD 6.11).
@@ -38,6 +39,7 @@ const indent = (s, n = 4) => s.split("\n").map(l => " ".repeat(n) + l).join("\n"
38
39
  // Deliberately not worded as distrust: the same session's instinct to verify a pre-existing failure was the right one.
39
40
  const BASE_RULE = "Do not merge. Do not touch the default branch. Do not force-push, and do not rebase: keeping this branch on its base is done for you.";
40
41
  const RULES = [
42
+ "Collect final exit status and verification results before returning. A background task or monitor cannot resume this worker after its process exits. Run finite checks in the foreground with a sufficient explicit timeout within the approved limits; do not substitute a watch or deferred notification for a completed check. If a task needs a temporary development server, keep its lifecycle within this run, collect its test results and stop it before returning. Never report partial output as a passed check. Gallivant owns durable wakeups and remote review polling.",
41
43
  "Open a regular pull request for review, never a draft: do not use --draft or convert the PR to draft. A regular PR is not a claim of completion; retain false acceptance criteria and outstanding verification honestly until they are resolved.",
42
44
  "Public PR titles, descriptions, completion-receipt prose, review replies, issue comments and commit messages must describe the work without mentioning Gallivant (in any letter case). Do not add runner branding or attribution. Use the supplied neutral receipt format; recovery metadata is managed for you. Keep internal paths, session identifiers and logs intact. Do not rewrite evidence URLs: cite a different accurate evidence pointer when needed.",
43
45
  "Before pushing, review the changed source and representative generated output against the approved brief. For each justified finding, identify the underlying rule and check its sibling callers, input/output boundaries and failure/cleanup paths already in scope. Fix related occurrences together, reuse existing validation and runner helpers, and add focused regression coverage that reproduces the defect. Do not expand the brief or add unrelated refactors to satisfy speculative review concerns.",
@@ -47,6 +49,7 @@ const RULES = [
47
49
  "Do not run `git stash` in any form. The stash stack is shared by every worktree of this repository, so a push can sweep up untracked files and a pop can drop another worktree's work into yours, and nothing in this run records that it happened. To check whether a failure is yours or was already there, get a second tree instead of moving this one: `git worktree add <temp path> <base>` and run the check there, or read a single file at the base with `git show <base>:<path>`.",
48
50
  "A denied tool call is a boundary, not an obstacle. If a guardrail blocks something, do not write a wrapper script, re-spell the command, move it into a file, or find another path to the same effect: stop that line of work and say in your final message what was blocked and what you were trying to do. The block may well be wrong, and the operator can only fix it if they hear about it; a way around it leaves them with neither the check nor the report.",
49
51
  "A sandbox permission error is different from a guardrail refusal. Codex protects .git even inside a writable checkout. For an authorized Git operation that needs it, use the tool's supported approval/escalation request; Gallivant routes Codex requests through automatic review. If the reviewer or a guardrail denies it, stop and report the denial. Do not change permissions, move Git metadata, or bypass the sandbox.",
52
+ "Commit only the requested product changes, tests and durable documentation. Do not commit task work files, session logs, scratch files, audit history, review notes or handoff notes. Create scratch space outside the repository with mktemp -d. Summarize verification in the PR receipt; full command/session logs are retained by the runner. Keep durable release and rollback instructions concise. Exact report or evidence deliverables listed in publish_files in the approved brief are allowed; a session must not add its own exceptions or rename work logs to evade the policy. Inspect the complete branch diff as well as staged paths before pushing.",
50
53
  "Commit with clear messages and push to this branch when done. Keep Git writes in separate tool calls from file-generation scripts, and stop on command failures. Check the staged diff before committing. After pushing, verify that the remote branch matches HEAD and that the upstream is origin/this branch; a push can exit zero even when writing local tracking metadata failed. Report any unfinished step explicitly.",
51
54
  // The half a git hook cannot reach. A commit message is a local file the `commit-msg` hook reads before a commit object
52
55
  // exists; a local agent can write a PR body directly over the network. Public-output checks guard coordinator
@@ -60,7 +63,7 @@ const RULES = [
60
63
  * branch, which is what this parameter used to be called and used to assume. A project whose pull requests all go to
61
64
  * `staging` was told to open one against `main`, and the session did as it was told.
62
65
  */
63
- export function startPrompt({ prd, repo, branch, base, verify, receipt = null, carried = [] }) {
66
+ export function startPrompt({ prd, repo, branch, base, verify, receipt = null, carried = [], host = hostPrompts(repo) }) {
64
67
  const verifyBlock = verify.length ? `Before opening the PR, run these Verify commands and make them pass:\n${verify.map(v => `- ${v}`).join("\n")}` : "Run the project's tests before opening the PR.";
65
68
  // What the carried files ARE, said before the brief so a session reads them as given rather than as work. Without
66
69
  // this a session finds documents in its checkout that are in no commit and are not mentioned in the diff it is about
@@ -70,14 +73,14 @@ export function startPrompt({ prd, repo, branch, base, verify, receipt = null, c
70
73
  "",
71
74
  `These files were carried in from the operator's own checkout because the brief names them:`,
72
75
  ...carried.map(f => `- ${f}`),
73
- "They are part of the brief, not work to be done. Read them; do not rewrite them to match what you expected.",
76
+ "They are part of the brief, not work to be done. Read them; do not rewrite them to match what you expected. Keep carried inputs local and untracked unless the approved brief explicitly lists them in publish_files. Never delete a carried input needed by the brief.",
74
77
  ].join("\n") : null;
75
78
  return cap([
76
79
  `You are implementing a PRD in ${repo} on branch \`${branch}\` (base: \`${base}\`). You are in a git worktree for that branch.`,
77
80
  "Read the PRD below, implement every user story so every acceptance criterion holds, and keep the change focused on what the PRD asks.",
78
81
  verifyBlock,
79
82
  ...(carriedBlock ? [carriedBlock] : []),
80
- `Then open one pull request with \`gh pr create --base ${base} --head ${branch}\`. Title it after the PRD. In the body, summarise what changed and how you verified it, and link the PRD file.`,
83
+ `Then open one pull request with \`${host.create({ base, branch })}\`. Title it after the PRD. In the body, summarise what changed and how you verified it, and link the PRD file.`,
81
84
  ...(receipt ? ["", receipt] : []),
82
85
  ...RULES,
83
86
  "",
@@ -86,10 +89,10 @@ export function startPrompt({ prd, repo, branch, base, verify, receipt = null, c
86
89
  ].join("\n"));
87
90
  }
88
91
 
89
- export function registerPrompt({ repo, pr }) {
92
+ export function registerPrompt({ repo, pr, host = hostPrompts(repo) }) {
90
93
  return cap([
91
94
  `You are taking over pull request #${pr} in ${repo}. You are in a git worktree on the PR branch.`,
92
- `Run \`gh pr view ${pr} --repo ${repo}\` and \`gh pr diff ${pr} --repo ${repo}\` to understand it before changing anything.`,
95
+ host.inspect({ repo, pr }),
93
96
  ...RULES,
94
97
  "Do not open new PRs.",
95
98
  ].join("\n"));
@@ -111,10 +114,10 @@ export function registerPrompt({ repo, pr }) {
111
114
  * seen, which reads to the agent as the gate being broken. So each difference is listed and the session is told to reread
112
115
  * the brief in the worktree rather than trust what it remembers.
113
116
  */
114
- export function resumePrompt({ repo, pr, base = null, branch = null, reason, note = null, verify = [], receipt = null, briefChanged = [], unpushed = [], merged = null, uncommitted = null, carried = [] }) {
117
+ export function resumePrompt({ repo, pr, base = null, branch = null, reason, note = null, verify = [], receipt = null, briefChanged = [], unpushed = [], merged = null, uncommitted = null, carried = [], host = hostPrompts(repo) }) {
115
118
  const deferredBaseRule = [
116
119
  "Do not merge a pull request or write to the default/base branch. Do not force-push or rebase.",
117
- `The automatic base merge was deferred to preserve unfinished files. After committing only the task's changes, fetch origin and merge ${base ? `\`origin/${base}\`` : "the PR's target base (confirm it with GitHub first)"} into this task branch before verification and push. This base-into-task merge is the only merge authorized for this run.`,
120
+ `The automatic base merge was deferred to preserve unfinished files. After committing only the task's changes, fetch origin and merge ${base ? `\`origin/${base}\`` : "the PR's target base (confirm it with the host first)"} into this task branch before verification and push. This base-into-task merge is the only merge authorized for this run.`,
118
121
  "Give that merge the neutral message `Merge approved base into task branch` with git merge -m; the default generated message can expose a legacy branch prefix and is refused by the commit-message hook.",
119
122
  "Preserve unrelated edits and carried instructions. If they prevent the merge, stop and report the exact conflicting paths; do not stash, discard or commit those files to force it through. Resolve merge conflicts only within the approved task scope, then rerun the Verify commands on the combined result before pushing.",
120
123
  ].join(" ");
@@ -128,12 +131,12 @@ export function resumePrompt({ repo, pr, base = null, branch = null, reason, not
128
131
  // no-reason-recorded default.
129
132
  "no-push": "your last run ended without pushing anything",
130
133
  "harness-error": "your last run ended with an error",
131
- stuck: "a GitHub write kept failing and has now been dealt with",
134
+ stuck: "a repository-host write kept failing and has now been dealt with",
132
135
  "checks-red": "checks were red and a person has now looked",
133
136
  "reviewer-blocking": "the reviewer was blocking and a person has now looked",
134
137
  "reviewer-silent": "the reviewer went quiet and a person has now looked",
135
138
  "context-paths": "the read directories the brief asked for could not be granted and have now been settled",
136
- "no-credential": "no GitHub credential could be resolved, so the push would have failed; one is available now",
139
+ "no-credential": "no repository credential could be resolved, so the push would have failed; one is available now",
137
140
  "no-identity": "this machine had no git identity, so every commit you made would have failed; one is configured now",
138
141
  uncommitted: "unfinished work remains in the checkout; inspect and preserve it before continuing",
139
142
  }[reason] ?? `your last run stopped (${reason ?? "no reason recorded"})`;
@@ -172,7 +175,7 @@ export function resumePrompt({ repo, pr, base = null, branch = null, reason, not
172
175
  "",
173
176
  "If you were part way through something, finish it. If you were about to run the checks, run them.",
174
177
  verify.length ? `The Verify commands that must pass:\n${verify.map(v => `- ${v}`).join("\n")}` : "Run the project's tests and make them pass.",
175
- pr ? "Push to this branch when the work is done. Do not open new PRs." : `Then open one pull request for this branch${base && branch ? ` with \`gh pr create --base ${base} --head ${branch}\`` : ""}. Title it after the brief, and in the body summarise what changed and how you verified it.`,
178
+ pr ? "Push to this branch when the work is done. Do not open new PRs." : `Then open one pull request for this branch${base && branch ? ` with \`${host.create({ base, branch })}\`` : ""}. Title it after the brief, and in the body summarise what changed and how you verified it.`,
176
179
  ...(receipt ? ["", receipt] : []),
177
180
  ...RULES.map(rule => uncommitted && rule === BASE_RULE ? deferredBaseRule : rule),
178
181
  ].join("\n"));
@@ -182,7 +185,7 @@ export function resumePrompt({ repo, pr, base = null, branch = null, reason, not
182
185
  * One prompt per batch of events. Events are the normalised objects from events.mjs:
183
186
  * { kind: "review"|"inline"|"command"|"check", author, state?, id?, path?, line?, body?, name?, runId? }
184
187
  */
185
- export function fixPrompt({ repo, pr, headSha, events, verify, receipt = null, denials = null, merged = null }) {
188
+ export function fixPrompt({ repo, pr, headSha, events, verify, receipt = null, denials = null, merged = null, host = hostPrompts(repo) }) {
186
189
  const reviews = groupReviews(events.filter(e => e.kind === "review" || e.kind === "inline"));
187
190
  const checks = events.filter(e => e.kind === "check");
188
191
  const commands = events.filter(e => e.kind === "command");
@@ -204,7 +207,7 @@ export function fixPrompt({ repo, pr, headSha, events, verify, receipt = null, d
204
207
  }
205
208
  if (checks.length) {
206
209
  parts.push("", `Checks failing on ${headSha ? headSha.slice(0, 7) : "the head"}:`);
207
- for (const c of checks) parts.push(`- ${c.name}${c.runId ? `, run ${c.runId}. Run \`gh run view ${c.runId} --repo ${repo} --log-failed\` for the log.` : `. See ${c.url || "the checks tab"}.`}`);
210
+ for (const c of checks) parts.push(`- ${c.name}${host.check({ repo, check: c })}`);
208
211
  }
209
212
  if (commands.length) {
210
213
  parts.push("", "Requests from the operator:");
@@ -218,7 +221,7 @@ export function fixPrompt({ repo, pr, headSha, events, verify, receipt = null, d
218
221
  ...RULES,
219
222
  "Reply to each inline thread with one line on what you changed or why you did not.",
220
223
  "Do not open new PRs.",
221
- "If only the completion receipt in the PR body needed fixing, edit the PR body with `gh pr edit` and do not push an empty commit.",
224
+ host.editReceipt,
222
225
  );
223
226
  return cap(parts.join("\n"));
224
227
  }
@@ -1,6 +1,17 @@
1
1
  import { join } from "node:path";
2
2
  import { readJson, writeJsonAtomic } from "./fs.mjs";
3
3
  import { boundedCall, EXECUTION_LIMITS, nextAttempt } from "./execution_io.mjs";
4
+ // A coordinator cycle can outlast the ten-second execution progress cadence.
5
+ // Keep a successful probe usable for five bounded minutes by default; an explicit
6
+ // operator progress interval remains authoritative.
7
+ export const coordinatorHealthLimits = execution => ({ ...execution, progressMs: execution?.progressMs ?? 300_000 });
8
+ // Provider probes need their own lane: an unrelated slow coordinator tick must
9
+ // not prevent a due account check from refreshing. makeProviderHealth coalesces
10
+ // concurrent probes and still fences dispatch while a refresh is pending.
11
+ export function scheduleProviderHealth(health, scopes, { intervalMs, onError = () => {}, schedule = setInterval, cancel = clearInterval } = {}) {
12
+ const timer = schedule(() => { Promise.resolve().then(() => health.refresh(scopes)).catch(onError); }, intervalMs);
13
+ return () => cancel(timer);
14
+ }
4
15
  export function makeProviderHealth({ home, file = join(home, "provider-health.json"), clock = Date, limits = {}, probe, jitter = Math.random }) {
5
16
  const bounds = { ...EXECUTION_LIMITS, ...limits }, pending = new Map();
6
17
  const key = (provider, account) => JSON.stringify([provider, account]);
@@ -12,11 +23,11 @@ export function makeProviderHealth({ home, file = join(home, "provider-health.js
12
23
  const get = (provider, account = "operator") => {
13
24
  const s = read();
14
25
  const id = key(provider, account), saved = s.scopes[id];
15
- if (!saved) return { ok: false, detail: "provider/account check pending" };
26
+ if (!saved) return { ok: false, pending: true, detail: "provider/account check pending" };
16
27
  // Fence due scopes before refresh starts, including those waiting for a
17
28
  // probe worker. A read never probes; restart uses the same saved deadline.
18
- if (saved.ok && (pending.has(id) || clock.now() >= (saved.nextAttemptAt ?? saved.observedAt + bounds.progressMs))) return { ...saved, ok: false, detail: "provider/account health refresh pending; awaiting the coordinator's next probe" };
19
- if (saved.ok && clock.now() > saved.observedAt + bounds.progressMs * 2) return { ...saved, ok: false, detail: "provider/account health is stale; awaiting the coordinator's next probe" };
29
+ if (saved.ok && (pending.has(id) || clock.now() >= (saved.nextAttemptAt ?? saved.observedAt + bounds.progressMs))) return { ...saved, ok: false, pending: true, detail: "provider/account health refresh pending; awaiting the coordinator's next probe" };
30
+ if (saved.ok && clock.now() > saved.observedAt + bounds.progressMs * 2) return { ...saved, ok: false, pending: true, detail: "provider/account health is stale; awaiting the coordinator's next probe" };
20
31
  return saved;
21
32
  };
22
33
  const refresh = async scopes => {
@@ -0,0 +1,68 @@
1
+ // Repository identity is independent of transport, execution provider and harness.
2
+ // Bare owner/name is the persisted v1 GitHub spelling; other hosts are qualified.
3
+ export const LEGACY_HOST = "github.com";
4
+ export function assertBranchName(value) {
5
+ if (typeof value !== "string" || !value || value.startsWith("-") || /[\s~^:?*\[\\\x00-\x1f\x7f]/.test(value) || value.includes("..") || value.includes("@{") || value === "@" || value.split("/").some(p => !p || p.startsWith(".") || p.endsWith(".") || p.endsWith(".lock"))) throw new Error("Invalid Git branch name; select an explicit branch before repository transport");
6
+ return value;
7
+ }
8
+ const segment = /^[A-Za-z0-9_-][A-Za-z0-9_.-]*$/;
9
+ const hostname = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
10
+ const invalid = () => new Error("Invalid repository origin or identity; restore a canonical HTTPS or Git SSH origin and retry. Credentials in URLs are never included in diagnostics.");
11
+ // Cloud's SSH endpoint is a transport alias, never a separate repository identity.
12
+ const sshIdentityHosts = { "ssh.bitbucket.org": "bitbucket.org" };
13
+ const sshTransportHosts = { "bitbucket.org": "ssh.bitbucket.org" };
14
+
15
+ export function repositoryIdentity(value, host = LEGACY_HOST) {
16
+ if (value && typeof value === "object") {
17
+ const identity = repositoryIdentity(value.path ?? value.repo, value.host ?? host);
18
+ if (value.host && identity.host !== value.host.toLowerCase()) throw invalid();
19
+ return identity;
20
+ }
21
+ if (typeof value !== "string") throw invalid();
22
+ const parts = value.split("/");
23
+ if (parts.length === 3) host = parts.shift();
24
+ host = String(host).toLowerCase();
25
+ if (!hostname.test(host) || parts.length !== 2 || parts.some(p => !segment.test(p) || p === "." || p === "..")) throw invalid();
26
+ const path = parts.join("/").toLowerCase();
27
+ return Object.freeze({ host, path, remoteIdentity: `${host}/${path}`, key: host === LEGACY_HOST ? path : `${host}/${path}` });
28
+ }
29
+
30
+ /** Parse without URL normalization hiding traversal, ports, query strings or escapes. */
31
+ export function parseRemote(value) {
32
+ const raw = typeof value === "string" ? value.trim().replace(/\/+$/, "") : "";
33
+ if (!raw || /[\s\\?#%\x00-\x1f\x7f]/.test(raw)) throw invalid();
34
+ let match, transport;
35
+ // HTTP is retained for historical GitHub remotes, but the adapter upgrades it before use.
36
+ if ((match = raw.match(/^(https?):\/\/(?:[^/@]+@)?([^/:@]+)\/([^/]+\/[^/]+)$/i))) transport = match[1].toLowerCase();
37
+ else if ((match = raw.match(/^(ssh):\/\/(?:[A-Za-z0-9._-]+@)?([^/:@]+)\/([^/]+\/[^/]+)$/i))) transport = "ssh";
38
+ else {
39
+ const scp = raw.match(/^(?:[A-Za-z0-9._-]+@)?([^/:@]+):([^/]+\/[^/]+)$/i);
40
+ if (!scp) throw invalid();
41
+ match = [raw, "ssh", scp[1], scp[2]]; transport = "ssh";
42
+ }
43
+ const host = match[2].toLowerCase();
44
+ const identity = repositoryIdentity(match[3].replace(/\.git$/i, ""), transport === "ssh" ? sshIdentityHosts[host] ?? host : host);
45
+ return Object.freeze({ ...identity, transport, url: transport === "ssh" ? `git@${sshTransportHosts[identity.host] ?? identity.host}:${identity.path}.git` : `https://${identity.host}/${identity.path}.git` });
46
+ }
47
+
48
+ export const safeOrigin = value => { try { return parseRemote(value).url; } catch { return "[invalid origin]"; } };
49
+ export const repositoryMatches = (a, b) => { try { return repositoryIdentity(a).remoteIdentity === repositoryIdentity(b).remoteIdentity; } catch { return false; } };
50
+ export const remoteMatches = (url, repo) => { try { return parseRemote(url).remoteIdentity === repositoryIdentity(repo).remoteIdentity; } catch { return false; } };
51
+ export const repoFromRemote = url => { try { return parseRemote(url).key; } catch { return null; } };
52
+
53
+ export function parsePullRequestUrl(value) {
54
+ // Host adapters own the PR route. This parses only the URL envelope, never userinfo.
55
+ const m = typeof value === "string" && value.match(/^https:\/\/([^/:@]+)\/([^?#\s\\%]+)$/i);
56
+ if (!m || !hostname.test(m[1].toLowerCase())) throw invalid();
57
+ return { host: m[1].toLowerCase(), pathname: m[2] };
58
+ }
59
+
60
+ /** Read old records without rewriting IDs, cursors or counters. Contradictions require recovery. */
61
+ export function savedRepository(entry) {
62
+ const identity = repositoryIdentity(entry.repo);
63
+ if ((entry.host && entry.host !== identity.host) || (entry.repository && repositoryIdentity(entry.repository).remoteIdentity !== identity.remoteIdentity) ||
64
+ (entry.url && (parsePullRequestUrl(entry.url).host !== identity.host || !parsePullRequestUrl(entry.url).pathname.toLowerCase().startsWith(`${identity.path}/`)))) {
65
+ throw new Error("Ambiguous saved repository identity; stop the coordinator, back up state, and reconcile its repository, host and PR URL before retrying. Do not redispatch.");
66
+ }
67
+ return identity;
68
+ }
@@ -0,0 +1,11 @@
1
+ import { makeGit } from "./git.mjs";
2
+ import { makeHostRegistry } from "./hosts.mjs";
3
+ import { configuredBase } from "./config.mjs";
4
+
5
+ /** Transport readiness is independent of dispatch qualification. It makes no remote writes. */
6
+ export async function repositoryPreflight(config, repo, { registry = makeHostRegistry({ config }), git = makeGit({ config, hosts: registry }), base = configuredBase(config, repo) } = {}) {
7
+ const selected = registry.inspect(repo);
8
+ if (!selected.api.preflight) throw new Error("This host uses its existing setup diagnostics");
9
+ const report = await selected.api.preflight(selected.repository.path, { base });
10
+ return { ...report, ...await git.probeAccess(repo, report.base, report.baseSha), dispatch: selected.adapter.dispatch !== false, guidance: selected.adapter.dispatchReason ?? null };
11
+ }
@@ -0,0 +1,25 @@
1
+ // Offline metrics over captured, independently adjudicated review outputs.
2
+ // Seed labels alone cannot manufacture live judgement or baseline evidence.
3
+ export function evaluateReviews(corpus, runs, baseline) {
4
+ if (corpus?.version !== 1 || !Array.isArray(corpus.cases) || !corpus.cases.length) throw new Error("versioned held-out corpus required");
5
+ const score = records => {
6
+ if (!Array.isArray(records) || records.length !== corpus.cases.length || new Set(records.map(r=>r.caseId)).size !== records.length) throw new Error("one recorded result per held-out case is required");
7
+ let detected=0, defects=0, falsePositives=0, clean=0, latencyMs=0, costUsd=0, unknownCost=0;
8
+ for (const c of corpus.cases) {
9
+ const r=records.find(r=>r.caseId===c.id);
10
+ if (!r || !r.evidence || !["clear","findings","failed","incomplete"].includes(r.verdict) || !Array.isArray(r.detectedDefects) || !Number.isFinite(r.latencyMs) || r.latencyMs<0 || r.costUsd!==null && (!Number.isFinite(r.costUsd) || r.costUsd<0)) throw new Error("each result needs verifiable evidence, adjudicated defects, latency and explicit cost/unknown");
11
+ const accepted=new Set(r.detectedDefects);
12
+ if (accepted.size!==r.detectedDefects.length || [...accepted].some(id=>!c.defects.includes(id)) || r.verdict!=="findings" && accepted.size) throw new Error("detection IDs must be independently adjudicated against the held-out case");
13
+ defects+=c.defects.length; detected+=accepted.size;
14
+ if (!c.defects.length) { clean++; if (r.verdict==="findings") falsePositives++; }
15
+ latencyMs+=r.latencyMs;
16
+ if (r.costUsd===null) unknownCost++; else costUsd+=r.costUsd;
17
+ }
18
+ return { detected, defects, defectDetection: defects?detected/defects:null, falsePositives, clean, falsePositiveRate:clean?falsePositives/clean:null,
19
+ meanLatencyMs:latencyMs/records.length, knownSpendUsd:costUsd, unknownSpendRuns:unknownCost, failures:records.filter(r=>["failed","incomplete"].includes(r.verdict)).length };
20
+ };
21
+ const measured = score(runs);
22
+ if (!baseline) return { status:"baseline-missing", firstParty:measured, baseline:null, defaultChangeJustified:false };
23
+ if (!["external-review","human-reviewed"].includes(baseline.kind) || !baseline.evidence) throw new Error("an external-review or human-reviewed baseline evidence pointer is required");
24
+ return { status:"measured", firstParty:measured, baseline:{kind:baseline.kind,evidence:baseline.evidence,...score(baseline.runs)}, defaultChangeJustified:false };
25
+ }