@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
package/lib/hosts.mjs ADDED
@@ -0,0 +1,130 @@
1
+ import { HOST_DEFINITIONS } from "./host_definitions.mjs";
2
+ import { github } from "./hosts/github.mjs";
3
+ import { bitbucket } from "./hosts/bitbucket.mjs";
4
+ import { repositoryIdentity, parseRemote, parsePullRequestUrl, savedRepository } from "./repository.mjs";
5
+ import { requireHostCapabilities, completeItems, validateHostRecord, MAX_DIFF_BYTES } from "./host_contract.mjs";
6
+ import { execFileAsync } from "./process.mjs";
7
+
8
+ const IMPLEMENTATIONS = { [github.id]: github, [bitbucket.id]: bitbucket };
9
+ const DEFAULT_ADAPTERS = HOST_DEFINITIONS.map(definition => ({ ...definition, ...IMPLEMENTATIONS[definition.id] }));
10
+
11
+ const METHODS = ["branchExists", "repoDefaultBranch", "repoPermissions", "refSha", "containsCommit", "fileAt", "fileAtStrict", "commitDate",
12
+ "prView", "prDiff", "reviewEvidence", "prListMine", "prListByHead", "reviews", "inlineComments", "issueComments", "comment", "setBody", "setTitle", "setRecoveryMetadata", "markReady",
13
+ "addLabel", "removeLabel", "ensureLabels"];
14
+ const LISTS = new Set(["prListMine", "prListByHead", "reviews", "inlineComments", "issueComments"]);
15
+ const DECORATIONS = new Set(["addLabel", "removeLabel", "ensureLabels"]);
16
+
17
+ /** Static, injectable registry. Each shipped host owns its own capabilities. */
18
+ export function makeHostRegistry({ adapters = DEFAULT_ADAPTERS, config = {}, credential, exec, log, fetch, limits } = {}) {
19
+ const definitions = new Map(adapters.map(a => [a.host, a]));
20
+ if (definitions.size !== adapters.length) throw new Error("Duplicate host registration");
21
+ const instances = new Map();
22
+ function select(value, { transport = false } = {}) {
23
+ const repository = typeof value === "object" && value.repo ? savedRepository(value) : repositoryIdentity(value);
24
+ const adapter = definitions.get(repository.host);
25
+ if (!adapter) throw new Error(`Unsupported repository host ${repository.host}; this release has no qualified adapter. Use a supported checkout before init, doctor, start or register.`);
26
+ if (!transport) {
27
+ if (adapter.dispatch === false) throw new Error(adapter.dispatchReason);
28
+ requireHostCapabilities(adapter);
29
+ }
30
+ if (value?.repo && value.url) {
31
+ const target = adapter.parsePr(value.url);
32
+ if (!target || repositoryIdentity(target.repo).remoteIdentity !== repository.remoteIdentity || (value.pr != null && target.number !== value.pr)) throw new Error("Ambiguous saved PR identity; back up state and reconcile its repository, URL and PR number before retrying. Do not redispatch.");
33
+ }
34
+ if (!instances.has(repository.remoteIdentity)) instances.set(repository.remoteIdentity, adapter.create({ config, exec, log, fetch, limits, credential: async options => {
35
+ const resolved = await (credential ? credential({ ...options, ...repository }) : adapter.credential(config, { ...options, ...repository }));
36
+ if (!resolved?.token) throw Object.assign(new Error(resolved?.why || "Host API authorization is missing; a Git SSH key cannot authorize API operations"), { code: "NO_CREDENTIAL" });
37
+ if (resolved.host && resolved.host !== adapter.host || resolved.repository && resolved.repository !== repository.path) throw new Error("API credential host/repository mismatch");
38
+ return resolved;
39
+ } }));
40
+ return { repository, adapter, api: instances.get(repository.remoteIdentity) };
41
+ }
42
+ const inspect = value => select(value, { transport: true });
43
+ const registry = {
44
+ select, inspect,
45
+ fromRemote: url => select(parseRemote(url)),
46
+ inspectRemote: url => {
47
+ const remote = parseRemote(url), selected = inspect(remote);
48
+ if (remote.host !== "github.com" && !["https", "ssh"].includes(remote.transport)) throw new Error("Unsupported repository transport; use HTTPS or SSH");
49
+ return selected;
50
+ },
51
+ fromUrl: (url, options) => {
52
+ const envelope = parsePullRequestUrl(url), adapter = definitions.get(envelope.host);
53
+ if (!adapter) throw new Error(`Unsupported repository host ${envelope.host}; no qualified PR adapter is installed`);
54
+ const parsed = adapter.parsePr(url);
55
+ if (!parsed) throw new Error("Invalid pull request URL; use its canonical qualified URL");
56
+ return { ...select(parsed.repo, options), number: parsed.number };
57
+ },
58
+ credential: async value => {
59
+ const { repository, adapter } = select(value);
60
+ const resolved = credential ? await credential(repository) : await adapter.credential(config, repository);
61
+ if (resolved?.host && resolved.host !== repository.host || resolved?.repository && resolved.repository !== repository.path) throw new Error("Credential host mismatch; restore the explicitly bound credential");
62
+ return { ...resolved, host: repository.host, repository: repository.path };
63
+ },
64
+ gitCredential: async value => {
65
+ const { repository, adapter } = inspect(value);
66
+ const resolved = credential ? await credential({ ...repository, operation: "git" }) : await adapter.credential(config, { ...repository, operation: "git" });
67
+ if (resolved?.host && resolved.host !== repository.host || resolved?.repository && resolved.repository !== repository.path) throw new Error("Git credential host/repository mismatch");
68
+ return { ...resolved, host: repository.host, repository: repository.path };
69
+ },
70
+ remote: value => {
71
+ const { repository, adapter } = inspect(value), remote = adapter.remote(repository, config);
72
+ if (parseRemote(remote.url).remoteIdentity !== repository.remoteIdentity || parseRemote(remote.url).url !== remote.url || remote.host !== repository.host || remote.path !== repository.path) throw new Error("Adapter remote identity mismatch");
73
+ return remote;
74
+ },
75
+ };
76
+ const api = Object.fromEntries(METHODS.map(method => [method, async (repo, ...args) => {
77
+ const selected = inspect(repo);
78
+ if (DECORATIONS.has(method) && !selected.adapter.capabilities.labels) return;
79
+ if (typeof selected.api[method] !== "function") throw new Error(`Required host operation ${method} is unavailable; preserve state and recover host access`);
80
+ const value = await selected.api[method](selected.repository.path, ...args);
81
+ if (LISTS.has(method)) {
82
+ const items = completeItems(value, method);
83
+ for (const item of items) validateHostRecord(item, selected.repository.host, { pr: method.startsWith("prList"), event: !method.startsWith("prList") });
84
+ if (method === "prListByHead" && items.filter(pr => !pr.isCrossRepository).length > 1) throw new Error("Ambiguous PR ownership for the saved branch; reconcile its open PRs before retrying. Do not redispatch.");
85
+ if (method.startsWith("prList")) for (const pr of items) {
86
+ const target = registry.fromUrl(pr.url, { transport: true });
87
+ if (target.repository.remoteIdentity !== selected.repository.remoteIdentity || target.number !== pr.number) throw new Error("PR list identity mismatch; preserve recovery state");
88
+ }
89
+ return items;
90
+ }
91
+ if (method === "prView" || method === "setRecoveryMetadata") {
92
+ validateHostRecord(value, selected.repository.host, { pr: true, full: true });
93
+ const target = registry.fromUrl(value?.url, { transport: true });
94
+ if (target.repository.remoteIdentity !== selected.repository.remoteIdentity || target.number !== args[0] || value.number !== args[0]) throw new Error("PR response identity mismatch; preserve state and reconcile the saved PR");
95
+ }
96
+ if (method === "prDiff" && (typeof value !== "string" || Buffer.byteLength(value) >= MAX_DIFF_BYTES)) throw new Error("Unreadable or incomplete host diff");
97
+ return value;
98
+ }]));
99
+ Object.assign(api, {
100
+ user: options => select(config.repos?.[0]?.repo ?? config.repos?.[0] ?? repositoryIdentity("owner/name", adapters[0].host)).api.user(options),
101
+ userFor: (repo, options) => select(repo).api.user(options),
102
+ repoInfo: async dir => {
103
+ const origin = (await (exec ?? execFileAsync)("git", ["remote", "get-url", "origin"], { cwd: dir })).stdout.trim();
104
+ const { repository, api: selected } = registry.inspectRemote(origin);
105
+ const info = await selected.repoInfo(dir, repository.path);
106
+ if (repositoryIdentity(info.nameWithOwner, repository.host).remoteIdentity !== repository.remoteIdentity) throw new Error("Checkout and API repository identities differ; restore origin before dispatch");
107
+ return { ...info, nameWithOwner: repository.key, repository: info.repository ?? repository };
108
+ },
109
+ publishResultPr: async options => {
110
+ const { repository, api: selected } = inspect(options.repo);
111
+ const result = await selected.publishResultPr({ ...options, repo: repository.path });
112
+ const target = registry.fromUrl(result?.url, { transport: true });
113
+ if (target.repository.remoteIdentity !== repository.remoteIdentity || target.number !== result.number) throw new Error("Published PR identity mismatch; reconcile before retrying");
114
+ return result;
115
+ },
116
+ });
117
+ return { ...registry, api };
118
+ }
119
+
120
+ export const hosts = makeHostRegistry();
121
+ // gh is a compatibility injection seam for old integrations and offline fixtures only.
122
+ export const hostApi = ctx => ctx.host ?? ctx.hosts?.api ?? ctx.gh;
123
+ export const repoFromUrl = url => { try { const found = hosts.fromUrl(url); return { repo: found.repository.key, number: found.number }; } catch { return null; } };
124
+ export const hostPrompts = repo => hosts.select(repo || "owner/name").adapter.prompts;
125
+ export const completionAdapter = (repo, registry = hosts) => registry.select(repo || "owner/name").adapter.id;
126
+
127
+ export function pullRequestUrl(repo, number, registry = hosts) {
128
+ if (!Number.isSafeInteger(Number(number)) || Number(number) < 1) return null;
129
+ try { const { adapter, repository } = registry.inspect(repo); return adapter.prUrl(repository, Number(number)); } catch { return null; }
130
+ }
@@ -5,7 +5,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readlinkSync, rmSy
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { verificationSandboxSpec } from "./verification_sandbox.mjs";
8
- import { execFileAsync } from "./gh.mjs";
8
+ import { execFileAsync } from "./process.mjs";
9
9
  import { hash } from "./learning_data.mjs";
10
10
 
11
11
  const PROBE = `const fs=require('node:fs'),cp=require('node:child_process'),net=require('node:net');
package/lib/notify.mjs CHANGED
@@ -12,7 +12,7 @@ import { shortRepo } from "./state.mjs";
12
12
  export const messages = {
13
13
  ready: ({ entry, max, checks, reviewerNote }) => ({
14
14
  title: `${shortRepo(entry.repo)}#${entry.pr} ready for a human`,
15
- body: `Round ${entry.iteration}/${max}. Reviewer: ${reviewerNote}. Checks green (${checks}).`,
15
+ body: `Round ${entry.iteration}/${max}. Reviewer: ${reviewerNote}. ${checks ? `Checks green (${checks}).` : "No checks reported; inspect the recorded CI policy."}${entry.gate_skipped ? ` Completion gate skipped (${entry.gate_skipped}).` : ""}`,
16
16
  priority: "default", tags: "white_check_mark", click: entry.url,
17
17
  }),
18
18
  needs: ({ entry, max, handle }) => ({
@@ -2,14 +2,19 @@
2
2
  import { prepareAgentsSources } from "./openai_agents_sources.mjs";
3
3
  import { executionError } from "./execution_io.mjs";
4
4
  import { taskProviderAllowance } from "./task_allowance.mjs";
5
+ import { parsePrd } from "./prd.mjs";
6
+ import { publicationPolicyOf, workArtifactReason } from "./work_artifacts.mjs";
5
7
  export async function agentsInput(ctx, id, entry, input, contextPaths) {
6
8
  if (contextPaths?.length) throw executionError("EXECUTION_POLICY", "hosted backend cannot enforce configured read-only private context_paths");
7
9
  const config = ctx.config.harness[entry.harness];
8
10
  if (!config) throw executionError("AGENTS_CONFIG", "selected managed API account is not configured");
9
11
  const overlays = [];
10
- if (entry.prd) overlays.push({ path: entry.prd, text: ctx.readPrd(id) });
12
+ const brief = ctx.readPrd(id);
13
+ if (entry.prd) overlays.push({ path: entry.prd, text: brief });
11
14
  for (const path of entry.sources || []) overlays.push({ path, text: ctx.readSource(id, path) });
12
- const source = await prepareAgentsSources({ worktree: entry.worktree, inputHead: input.inputHead, repo: entry.repo, branch: entry.branch, base: entry.base, overlays });
15
+ const policy = publicationPolicyOf(parsePrd(brief || "", entry.prd || `${id}.md`), { sources: entry.sources || [] });
16
+ const deliveryPaths = overlays.filter(item => !workArtifactReason(item.path, policy)).map(item => item.path);
17
+ const source = await prepareAgentsSources({ worktree: entry.worktree, inputHead: input.inputHead, repo: entry.repo, branch: entry.branch, base: entry.base, overlays, deliveryPaths });
13
18
  return { ...input, repo: entry.repo, branch: entry.branch, base: entry.base, allowance: taskProviderAllowance(config.allowance, entry.task), mandatoryPolicy: config.mandatory_policy || [], model: config.model, environment: config.environment,
14
19
  sources: { data: source.bytes.toString("base64"), hash: source.hash, hashes: source.hashes, modes: source.modes, inputHead: source.inputHead, repo: source.repo, branch: source.branch } };
15
20
  }
@@ -15,19 +15,28 @@ export function portablePath(path) {
15
15
  }
16
16
  // Only approved brief/source overlays may differ from HEAD. An explicit resume
17
17
  // must never describe preserved local edits while dispatching stale Git blobs.
18
- export async function assertAgentsCheckout({ worktree, overlays = [], signal }, exec = sourceExec) {
18
+ export async function assertAgentsCheckout({ worktree, overlays = [], immutableOverlays = false, signal }, exec = sourceExec) {
19
19
  const allowed = new Set(overlays.map(item => portablePath(typeof item === "string" ? item : item.path)));
20
20
  const { stdout } = await exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: worktree, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, signal, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, timeout: 20_000 });
21
21
  for (const entry of stdout.split("\0").filter(Boolean)) {
22
- if (entry.length < 4 || entry[2] !== " " || /[RCU]/.test(entry.slice(0, 2)) || !allowed.has(entry.slice(3))) {
22
+ if (entry.length < 4 || entry[2] !== " " || /[RCU]/.test(entry.slice(0, 2)) || !allowed.has(entry.slice(3)) ||
23
+ immutableOverlays && !["??", " M"].includes(entry.slice(0, 2))) {
23
24
  throw executionError("AGENTS_DIRTY_SOURCE", "hosted execution requires unfinished repository edits to be committed before resume; preserve the index/worktree, commit the intended changes on the owned branch, then resume the existing session; only approved brief/source overlays may remain uncommitted");
24
25
  }
25
26
  }
27
+ // Verification may read private overlays, but cannot change, delete, stage or
28
+ // replace them with links. Check bytes even for ignored or Git-clean inputs.
29
+ if (immutableOverlays) for (const item of overlays) {
30
+ if (typeof item.text !== "string" || item.text.includes("\0") || readRepositoryText(worktree, item.path) !== item.text) {
31
+ throw executionError("AGENTS_DIRTY_SOURCE", "private carried input differs from its immutable approved snapshot");
32
+ }
33
+ }
26
34
  }
27
35
  // Immutable Git blobs, plus the foundation's explicitly snapshotted brief and
28
36
  // carried sources. Never upload the checkout's environment or Git history.
29
- export async function prepareAgentsSources({ worktree, inputHead, repo, branch, base, overlays = [], signal }, exec = sourceExec) {
37
+ export async function prepareAgentsSources({ worktree, inputHead, repo, branch, base, overlays = [], deliveryPaths = [], signal }, exec = sourceExec) {
30
38
  if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(inputHead || "") || !/^[\w.-]+\/[\w.-]+$/.test(repo || "") || typeof branch !== "string" || branch === base) throw executionError("AGENTS_SOURCE", "exact source head and owned repository/branch/base are required");
39
+ if (!Array.isArray(deliveryPaths) || new Set(deliveryPaths).size !== deliveryPaths.length || deliveryPaths.some(path => !overlays.some(item => item.path === portablePath(path)))) throw executionError("AGENTS_SOURCE", "delivery paths must name exact selected source overlays");
31
40
  const options = { cwd: worktree, signal, encoding: "utf8", maxBuffer: 10 * 1024 * 1024, timeout: 20_000 };
32
41
  const { stdout: head } = await exec("git", ["rev-parse", "HEAD"], options);
33
42
  if (head.trim() !== inputHead) throw executionError("AGENTS_SOURCE", "source head changed during preparation");
@@ -54,11 +63,10 @@ export async function prepareAgentsSources({ worktree, inputHead, repo, branch,
54
63
  assertRepositoryTargets([...files.keys()], [...files.keys()]);
55
64
  await assertAgentsCheckout({ worktree, overlays, signal }, exec);
56
65
  if ((await exec("git", ["rev-parse", "HEAD"], options)).stdout.trim() !== inputHead) throw executionError("AGENTS_SOURCE", "source head changed during preparation");
57
- // Carried files can be uncommitted on the coordinator's fresh checkout. Include
58
- // them in delivery even when the agent leaves their approved bytes unchanged,
59
- // so the single PR writer commits them with the implementation. Later receipt
60
- // repairs use the same verified files without manufacturing a code change.
61
- const manifest = { version: 1, inputHead, repo, branch, base, deliveryPaths: overlays.map(item => item.path), files: [...files.values()] };
66
+ // Reading an approved input does not authorize publishing it. Only selected
67
+ // output overlays (the brief and exact operator-approved deliverables) are
68
+ // forced into unchanged results; every private input still retains its hash.
69
+ const manifest = { version: 1, inputHead, repo, branch, base, deliveryPaths, files: [...files.values()] };
62
70
  const bytes = gzipSync(Buffer.from(JSON.stringify(manifest)));
63
71
  if (bytes.length > AGENTS_LIMITS.uploadBytes || bytes.length * 4 / 3 > AGENTS_LIMITS.inputBytes - 65_536) throw executionError("AGENTS_SIZE", "portable snapshot exceeds inline request limits; reduce explicitly selected context");
64
72
  return { bytes, hash: contentHash(bytes), inputHead, repo, branch, base, hashes: Object.fromEntries(manifest.files.map(f => [f.path, f.hash])), modes: Object.fromEntries(manifest.files.map(f => [f.path, f.mode])) };
@@ -325,7 +325,19 @@ export function buildPluginPackage({ config, out, coreVersion, sourceCommit, int
325
325
  }
326
326
  const archive = join(out, "aport-task-delivery.zip");
327
327
  execFileSync("zip", ["-X", "-q", archive, ...Object.keys(hashes).sort()], { cwd: stage });
328
- const runtime = { ...contracts, sourceCommit, sourceVerified, uncommittedPayload: payloadChanges, requirements: NODE_RUNTIME_REQUIREMENTS, exports: pkg.exports, endpoint, resource,
328
+ // The shared typed-profile validator ships byte-identical in the runtime
329
+ // tarball and in Agent Passport. The committed pin is the ship gate for both.
330
+ const intakeContract = JSON.parse(readFileSync(join(root, "templates/plugin/task-intake-contract.json"), "utf8"));
331
+ // Every pinned file (validator and fixture corpus) must ship in the tarball
332
+ // with the pinned bytes, so a consumer of the published artifact can run the
333
+ // exact promised corpus rather than only compare a hash.
334
+ for (const [path, pinnedHash] of Object.entries(intakeContract.files || {})) {
335
+ if (!payloadHashes[path]) throw deliveryError("PACKAGE_SOURCE", `Pinned task-intake file ${path} is not in the packed runtime; keep it under a packaged path`);
336
+ if (payloadHashes[path] !== pinnedHash) throw deliveryError("PACKAGE_SOURCE", `Packed ${path} differs from the committed task-intake contract pin; review the change and run npm run contract:intake -- --write before packaging`);
337
+ }
338
+ const validatorHash = payloadHashes[intakeContract.validator];
339
+ const runtime = { ...contracts, intakeContract: { profileVersion: intakeContract.profileVersion, validator: intakeContract.validator, validatorSha256: validatorHash, fixtures: intakeContract.fixtures, fixturesSha256: intakeContract.files[intakeContract.fixtures] },
340
+ sourceCommit, sourceVerified, uncommittedPayload: payloadChanges, requirements: NODE_RUNTIME_REQUIREMENTS, exports: pkg.exports, endpoint, resource,
329
341
  coreArtifact: packed.filename, coreSha256: sha(readFileSync(join(out, packed.filename))), dependencyLockSha256: sha(lock), assetHashes: hashes };
330
342
  writeFileSync(join(out, "runtime.json"), json(runtime));
331
343
  writeFileSync(join(out, "operations.json"), json(TASK_OPERATIONS));
@@ -0,0 +1,41 @@
1
+ import { parseArgs } from "node:util";
2
+ import { readFileSync } from "node:fs";
3
+ import { execFileAsync } from "./process.mjs";
4
+ import { makeHostRegistry } from "./hosts.mjs";
5
+ import { parseRemote, repositoryIdentity, assertBranchName } from "./repository.mjs";
6
+
7
+ // Small, bounded PR operation surface for a local session. No config-directory
8
+ // reads, remote installation, shell expansion, merge or repository settings API.
9
+ export async function prOperation(argv, { env = process.env, cwd = process.cwd(), exec = execFileAsync, registry = null, read = path => readFileSync(path, "utf8") } = {}) {
10
+ const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: Object.fromEntries(
11
+ ["base", "head", "title-file", "body-file", "reply-to"].map(k => [k, { type: "string" }])) });
12
+ const [operation, number] = positionals;
13
+ if (!["create", "view", "diff", "edit", "comment"].includes(operation) || positionals.length !== (operation === "create" ? 1 : 2)) throw new Error("Use task-pr create --base <branch> --head <branch> --title-file <file> --body-file <file>, or view|diff|edit|comment <number>");
14
+ const allowed = { create: ["base", "head", "title-file", "body-file"], view: [], diff: [], edit: ["body-file"], comment: ["body-file", "reply-to"] }[operation];
15
+ if (Object.keys(values).some(k => !allowed.includes(k))) throw new Error("Unsupported option for this PR operation");
16
+ if (!env.HOST_API_HOST || !env.HOST_API_REPOSITORY || !env.HOST_API_TOKEN) throw new Error("No bound host API credential; run this command only in the authorized task session");
17
+ const repository = repositoryIdentity(env.HOST_API_REPOSITORY, env.HOST_API_HOST);
18
+ const git = async args => (await exec("git", args, { cwd })).stdout.trim();
19
+ if (parseRemote(await git(["remote", "get-url", "origin"])).remoteIdentity !== repository.remoteIdentity) throw new Error("Checkout differs from the bound API repository");
20
+ const branch = assertBranchName(await git(["branch", "--show-current"]));
21
+ const settings = env.HOST_API_BINDING ? JSON.parse(env.HOST_API_BINDING) : null;
22
+ registry ??= makeHostRegistry({ config: settings ? { git_hosts: { [repository.host]: { repositories: { [repository.path]: settings } } } } : {},
23
+ credential: async () => ({ host: repository.host, repository: repository.path, token: env.HOST_API_TOKEN, email: env.HOST_API_EMAIL }) });
24
+ const api = registry.api;
25
+ const body = () => { if (!values["body-file"]) throw new Error("--body-file is required"); return read(values["body-file"]); };
26
+ if (operation === "create") {
27
+ if (values.head !== branch || !values["title-file"]) throw new Error("--head must match the current branch and --title-file is required");
28
+ assertBranchName(values.base);
29
+ return api.publishResultPr({ repo: repository.key, branch, base: values.base, title: read(values["title-file"]).trim(), body: body() });
30
+ }
31
+ if (!/^[1-9]\d*$/.test(number ?? "") || !Number.isSafeInteger(Number(number))) throw new Error("Use an exact positive PR number");
32
+ const n = Number(number), pr = await api.prView(repository.key, n);
33
+ if (pr.headRefName !== branch || pr.isCrossRepository) throw new Error("PR is not owned by this checkout branch");
34
+ if (operation === "view") return pr;
35
+ if (operation === "diff") return api.prDiff(repository.key, n);
36
+ if (pr.state !== "OPEN") throw new Error("Only an open PR may be edited or answered");
37
+ if (operation === "edit") return api.setBody(repository.key, n, body());
38
+ const replyTo = values["reply-to"] === undefined ? null : Number(values["reply-to"]);
39
+ if (replyTo !== null && (!/^[1-9]\d*$/.test(values["reply-to"]) || !Number.isSafeInteger(replyTo))) throw new Error("Invalid reply parent");
40
+ return api.comment(repository.key, n, body(), { replyTo });
41
+ }
package/lib/prd.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { publicationPolicyOf } from "./work_artifacts.mjs";
2
+ import { hostApi } from "./hosts.mjs";
1
3
  // The contract. It is the one gate: a brief that fails any rule cannot be marked ready and cannot start. There is no
2
4
  // override flag. The rules are data so `ready --check` can print them as a checklist.
3
5
  //
@@ -11,6 +13,7 @@ import { shortHash } from "./hash.mjs";
11
13
  import { readText } from "./fs.mjs";
12
14
  import { readRepositoryText } from "./repository_files.mjs";
13
15
  import { OWNER_DELIVERY_SECTION, ownerDeliveryFromPrd } from "./owner_delivery.mjs";
16
+ import { DELIVERY_PROFILE_SECTION, deliveryProfileFromPrd } from "./task_profile.mjs";
14
17
 
15
18
  export const RULES = Object.freeze([
16
19
  { id: "path", kinds: ["prd"], text: "lives under docs/prds/ with a lowercase-dash filename" },
@@ -23,6 +26,7 @@ export const RULES = Object.freeze([
23
26
  { id: "look", text: "## Where to look: files or directories to start from, or 'none'" },
24
27
  { id: "scope", text: "## Out of scope: at least one bullet" },
25
28
  { id: "placeholders", text: "no TODO, TBD, FIXME, ???, lorem or <angle-bracket> placeholders anywhere" },
29
+ { id: "publish-files", text: "publish_files names at most 100 exact repository deliverables; no globs or directories" },
26
30
  { id: "size", text: "under 1,500 words; a PRD is one pull request, split anything bigger" },
27
31
  // Scoped to `prd` because it resolves paths against the checkout the brief lives in, and only a PRD source has one.
28
32
  { id: "sources", kinds: ["prd"], text: "every file in `sources` exists, is readable text, and is named inside the repository" },
@@ -239,7 +243,7 @@ export function check(prd) {
239
243
 
240
244
  const criteria = items(s["Acceptance criteria"]);
241
245
  const badCriteria = criteria.filter(x => !GWT.test(x));
242
- let ownershipError = ""; try { ownerDeliveryFromPrd(prd); } catch (error) { ownershipError = error.message; }
246
+ let ownershipError = ""; try { ownerDeliveryFromPrd(prd); deliveryProfileFromPrd(prd); } catch (error) { ownershipError = error.message; }
243
247
  rule("criteria", has("Acceptance criteria") && criteria.length >= Math.max(1, stories.length) && badCriteria.length === 0 && !ownershipError,
244
248
  ownershipError || (!has("Acceptance criteria") ? "section missing" : criteria.length < Math.max(1, stories.length) ? `${criteria.length} criteria for ${stories.length} stories; at least one each` : `${badCriteria.length} bullet(s) lack Given/When/Then: "${badCriteria[0]?.slice(0, 60)}"`));
245
249
 
@@ -256,6 +260,9 @@ export function check(prd) {
256
260
  const ph = findPlaceholder(prd.body);
257
261
  rule("placeholders", !ph, ph ? `found "${ph}"` : "");
258
262
 
263
+ try { publicationPolicyOf(prd); rule("publish-files", true, ""); }
264
+ catch (error) { rule("publish-files", false, error.message); }
265
+
259
266
  rule("size", prd.words <= MAX_WORDS, `${prd.words} words`);
260
267
 
261
268
  // The files the brief carries. Checked here rather than at `start` for the reason `resolveBase` validates the base
@@ -332,6 +339,9 @@ export const contractOf = prd => ({
332
339
  // Typed stage ownership changes what provider receipts must attest. Preserve
333
340
  // legacy fingerprints exactly; new contracts include even malformed edits.
334
341
  ...(prd.sections[OWNER_DELIVERY_SECTION] !== undefined ? { ownerDelivery: prd.sections[OWNER_DELIVERY_SECTION].replaceAll("\r\n", "\n") } : {}),
342
+ // The typed delivery profile is part of the contract for the same reason:
343
+ // editing it changes which external action the coordinator may take.
344
+ ...(prd.sections[DELIVERY_PROFILE_SECTION] !== undefined ? { deliveryProfile: prd.sections[DELIVERY_PROFILE_SECTION].replaceAll("\r\n", "\n") } : {}),
335
345
  // `id: description`, not the descriptions alone, and the id is the load-bearing half. `criteriaFromPrd` numbers
336
346
  // criteria BY POSITION (`ac-1`, `ac-2`), and the completion receipt on the open pull request attests those ids: an
337
347
  // attestation says `ac-1` is met, with evidence, and the gate matches it to whatever the brief's first criterion is
@@ -349,6 +359,8 @@ export const contractOf = prd => ({
349
359
  // The list, not the contents. A carried file's bytes are compared separately by `sourceDivergence`, because a diff of
350
360
  // a document belongs next to that document and not in a summary of the brief's contract.
351
361
  sources: sourcePathsOf(prd),
362
+ // Preserve existing fingerprints when absent; any exception edit changes authority.
363
+ ...(prd.meta?.publish_files !== undefined ? { publish_files: prd.meta.publish_files } : {}),
352
364
  });
353
365
 
354
366
  /**
@@ -394,6 +406,7 @@ export function contractDiff(before, after) {
394
406
  const b = contractOf(after);
395
407
  const out = [];
396
408
  if (a.ownerDelivery !== b.ownerDelivery) out.push("coordinator delivery ownership contract changed");
409
+ if (a.deliveryProfile !== b.deliveryProfile) out.push("typed delivery profile changed");
397
410
  // Multiplicity-aware, so a duplicate that IS reported is reported once per copy rather than swallowed. It still cannot
398
411
  // describe every change on its own, which is `sameContract`'s job; this only makes the description less lossy.
399
412
  const listDiff = (was, now) => {
@@ -422,6 +435,7 @@ export function contractDiff(before, after) {
422
435
  const sources = listDiff(a.sources, b.sources);
423
436
  for (const p of sources.gone) out.push(`source removed: ${p}`);
424
437
  for (const p of sources.added) out.push(`source added: ${p}`);
438
+ if (JSON.stringify(a.publish_files) !== JSON.stringify(b.publish_files)) out.push("publish_files deliverable exceptions changed");
425
439
  return out;
426
440
  }
427
441
 
@@ -573,10 +587,11 @@ export const divergenceReason = d => DIVERGENCE_REASONS[d.verdict] ?? null;
573
587
  * own judgement.
574
588
  */
575
589
  export async function ensurePrdSnapshot(ctx, { id, repo, path, ref }) {
576
- const { gh, log, readPrd, writePrd } = ctx;
590
+ const { log, readPrd, writePrd } = ctx;
591
+ const host = hostApi(ctx);
577
592
  try {
578
593
  if (!path || readPrd(id)) return false;
579
- const text = await gh.fileAt(repo, path, ref);
594
+ const text = await host.fileAt(repo, path, ref);
580
595
  if (!text) { log.warn(`${id}: ${path} not found on ${ref}; the completion gate is skipped until a snapshot exists`); return false; }
581
596
  // A CLI start may have approved its own snapshot while this network read was in flight.
582
597
  if (readPrd(id) || ctx.state?.startClaim?.(id)) return false;
@@ -66,7 +66,9 @@ function effectiveConfig(spec, { cwd, env, signal }) {
66
66
  child.kill("SIGKILL"); child.stdin.destroy();
67
67
  if (error) reject(error); else resolve(config);
68
68
  };
69
- const invalid = () => finish(fail("effective configuration could not be verified; check managed Codex policy and the supported CLI version"));
69
+ const invalid = () => {
70
+ finish(fail("effective configuration could not be verified; check managed Codex policy and the supported CLI version"));
71
+ };
70
72
  const abort = () => finish(fail("configuration inspection was cancelled"));
71
73
  const timer = setTimeout(invalid, 20_000);
72
74
  signal?.addEventListener("abort", abort, { once: true });
@@ -179,6 +181,9 @@ export const codexPreparation = Object.freeze({
179
181
  try { config = parse(readFileSync(configPath, "utf8")); } catch { throw fail("the private provider configuration is invalid TOML"); }
180
182
  if (config.profile) throw fail("a named Codex config profile requires an explicit isolated profile snapshot");
181
183
  delete config.sandbox_mode; delete config.sandbox_workspace_write;
184
+ // The desktop app can persist this client-only flag even when the
185
+ // selected CLI version does not recognize it under --strict-config.
186
+ delete config.daybreak;
182
187
  // Legacy notify spawns outside the tool sandbox independently of hooks.
183
188
  delete config.notify;
184
189
  for (const key of PRIVATE_ONLY_FIELDS) delete config[key];
package/lib/probe.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostApi } from "./hosts.mjs";
1
2
  // Health probes shared by `doctor`, `start` and the daemon: is each harness signed in, is gh authenticated,
2
3
  // does ntfy answer. Each returns { ok, detail } and never throws.
3
4
  import { execFile } from "node:child_process";
@@ -99,8 +100,8 @@ export async function requireHarness(harness, config, options = {}) {
99
100
  if (!result.ok) throw new Error(`${harness} is not ready (${result.detail}); ${result.fix}`);
100
101
  }
101
102
 
102
- export async function checkGh(gh, options = {}) {
103
- try { const login = await gh.user(options); return { ok: true, detail: login }; }
103
+ export async function checkGh(host, options = {}) {
104
+ try { const login = await host.user(options); return { ok: true, detail: login }; }
104
105
  catch (e) {
105
106
  const interrupted = e.code === "EXECUTION_DEADLINE" || e.name === "TimeoutError" || e.killed ? "EXECUTION_DEADLINE"
106
107
  : e.code === "EXECUTION_ABORTED" || e.code === "ABORT_ERR" || options.signal?.aborted ? "EXECUTION_ABORTED" : null;
@@ -111,8 +112,9 @@ export async function checkGh(gh, options = {}) {
111
112
 
112
113
  /** All probes the daemon needs before it dispatches anything. */
113
114
  export async function probeAll(ctx) {
114
- const { config, gh } = ctx;
115
- const pairs = await Promise.all([["gh", () => checkGh(gh)], ...Object.keys(config.harness).map(h => [h, () => checkHarness(h, config)])].map(async ([key, fn]) => [key, await fn()]));
115
+ const { config } = ctx;
116
+ const host = hostApi(ctx);
117
+ const pairs = await Promise.all([["gh", () => checkGh(host)], ...Object.keys(config.harness).map(h => [h, () => checkHarness(h, config)])].map(async ([key, fn]) => [key, await fn()]));
116
118
  const out = Object.fromEntries(pairs);
117
119
  return out;
118
120
  }
@@ -0,0 +1,24 @@
1
+ import { execFile } from "node:child_process";
2
+ import { redact } from "./execution_io.mjs";
3
+
4
+ /** Credentialed transports must sanitize every diagnostic, including cached stacks. */
5
+ export const transportExecutor = execute => async (cmd, args, options = {}) => {
6
+ try { return await execute(cmd, args, options); }
7
+ catch (error) {
8
+ const secrets = Object.entries(options.env ?? {}).filter(([key, value]) => /(?:TOKEN|SECRET|KEY|PASSWORD)/i.test(key) && typeof value === "string" && value.length).map(([, value]) => value);
9
+ if (error && typeof error === "object") for (const field of ["stack", "message", "stderr", "stdout"]) {
10
+ if (typeof error[field] === "string") error[field] = redact(error[field], secrets).replace(/([a-z][a-z0-9+.-]*:\/\/)[^/\s@]*@/gi, "$1");
11
+ }
12
+ throw error;
13
+ }
14
+ };
15
+
16
+ /** Bounded subprocess helper shared by transports, without a host dependency. */
17
+ export function execFileAsync(cmd, args, opts = {}) {
18
+ return new Promise((resolve, reject) => {
19
+ execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, encoding: "utf8", ...opts }, (err, stdout, stderr) => {
20
+ if (err) return reject(Object.assign(new Error(`${cmd} ${args.slice(0, 3).join(" ")}: ${String(stderr || "").trim() || err.message}`), { stdout, stderr, code: err.code, killed: err.killed }));
21
+ resolve({ stdout, stderr });
22
+ });
23
+ });
24
+ }
@@ -0,0 +1,132 @@
1
+ // A deliberately narrow cross-host handoff. Review, credentials and publication
2
+ // remain owned by their existing services; this module never runs an agent.
3
+ import { reviewBinding, reviewDigest, policyConfig, reviewVerdict, assertReviewPolicy } from "./review_policy.mjs";
4
+ import { isGreen, makeTrust, readFeedback, exactReviewerVerdict, requiredFeedback, deriveEvents } from "./events.mjs";
5
+ import { repositoryIdentity, assertBranchName } from "./repository.mjs";
6
+ import { withoutTaskMetadata } from "./block.mjs";
7
+ import { assertPublicText } from "./public_output.mjs";
8
+ import { STATES } from "./state.mjs";
9
+ import { controlPending } from "./control_contract.mjs";
10
+
11
+ const sha = value => {
12
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value || "")) throw new Error("Missing exact Git commit identity");
13
+ return value;
14
+ };
15
+ const requireOpen = pr => {
16
+ if (pr.state !== "OPEN" || pr.isDraft || pr.isCrossRepository !== false) throw new Error("Promotion requires an open, non-draft, same-repository PR; a merged source must retain exact accepted readiness. Merge/squash commits are not substituted.");
17
+ };
18
+ const requireUnstopped = entry => {
19
+ if (entry?.dispatch_stopped || controlPending(entry?.control)) throw new Error("Source dispatch is stopped or an operator control is pending; reconcile that request and acceptance before promotion");
20
+ };
21
+
22
+ /** Read-only live plan. A ready label or PR-authored assertion is not authority. */
23
+ export async function planPromotion(ctx, options, transfer) {
24
+ const selected = ctx.hosts.fromUrl(options.githubPr, { transport: true });
25
+ const sourceRepo = selected.repository.key;
26
+ const targetRepo = repositoryIdentity(options.bitbucketRepo);
27
+ if (selected.repository.host !== "github.com" || targetRepo.host !== "bitbucket.org") throw new Error("Supply an explicit GitHub PR and bitbucket.org/workspace/repository mapping");
28
+ const found = ctx.state.findByPr(sourceRepo, selected.number);
29
+ const entry = found?.[1];
30
+ if (!entry || ![STATES.ready, STATES.done].includes(entry.state)) throw new Error("Source run must have retained accepted readiness");
31
+ requireUnstopped(entry);
32
+ const pr = await ctx.host.prView(sourceRepo, selected.number);
33
+ const accepted = options.acceptedPlan;
34
+ if (accepted) {
35
+ const { digest, ...payload } = accepted;
36
+ if (accepted.version !== 1 || reviewDigest(payload) !== digest || accepted.source?.url !== pr.url || accepted.target?.repo !== targetRepo.key) throw new Error("Accepted plan is invalid or belongs to a different repository mapping");
37
+ }
38
+ if (pr.state === "MERGED") {
39
+ if (pr.isCrossRepository !== false || pr.isDraft || accepted && (accepted.source?.head !== pr.headRefOid || accepted.source?.binding !== entry.review_readiness?.binding)) throw new Error("Merged source must match its retained exact readiness binding and any supplied accepted plan; never substitute the merge/squash commit");
40
+ } else {
41
+ requireOpen(pr);
42
+ if (entry.state !== STATES.ready) throw new Error("Open source run is no longer ready-for-human");
43
+ }
44
+ if (entry.branch !== pr.headRefName || entry.base !== pr.baseRefName) throw new Error("Source PR branch or base changed from the registered run");
45
+ const policy = assertReviewPolicy(entry.reviewPolicy);
46
+ if (policy.version !== 1) throw new Error("Promotion requires an explicit review policy; legacy/no-review clearance is not accepted");
47
+ let reviewedPr = pr, binding = reviewBinding(pr, policy);
48
+ // GitHub normally retains the original base on a merged PR. Use that exact
49
+ // accepted binding directly; an archived plan is only a base-reconstruction
50
+ // fallback and can never supply acceptance absent from coordinator state.
51
+ if (pr.state === "MERGED" && binding !== entry.review_readiness?.binding && accepted) {
52
+ reviewedPr = { ...pr, baseRefOid: sha(accepted.source.baseHead) };
53
+ binding = reviewBinding(reviewedPr, policy);
54
+ }
55
+ if (entry.review_readiness?.binding !== binding || entry.review_readiness?.completion !== "accepted") throw new Error("Source readiness is stale or its completion gate was not accepted; recheck the run first");
56
+ const config = policyConfig(ctx.config, entry);
57
+ if (!isGreen(pr.statusCheckRollup, config.required_checks) || (!pr.statusCheckRollup.length && policy.zeroChecks !== true)) throw new Error("Checks are not green, required checks are missing, or zero checks were not explicitly allowed");
58
+ const trust = makeTrust(config, await ctx.host.userFor(sourceRepo));
59
+ const [reviews, inline, comments] = await readFeedback(ctx.host, sourceRepo, pr);
60
+ if (requiredFeedback({ reviews, inline, trust, headSha: pr.headRefOid }).length) throw new Error("Required feedback remains unresolved");
61
+ if (!entry.cursor || !Number.isFinite(Date.parse(entry.cursor.since))) throw new Error("Source feedback cursor is missing; recheck the registered run");
62
+ if (deriveEvents({ pr, reviews, inline, comments, entry, trust }).events.length) throw new Error("Pending trusted feedback must be processed by the coordinator before promotion");
63
+ const external = await exactReviewerVerdict({ reviews, inline, comments, config, trust, sinceIso: "1970-01-01T00:00:00.000Z", headSha: pr.headRefOid }, ref => ctx.host.refSha(sourceRepo, ref));
64
+ const verdict = reviewVerdict(policy, external, entry.firstPartyReview, binding);
65
+ if (verdict.verdict !== "clear") throw new Error(`Review is not clear: ${verdict.note}`);
66
+ // Local working files are not promotion inputs. Fetching the exact reviewed
67
+ // remote ref into a disposable bare repository preserves untracked carried
68
+ // sources and dirty checkouts without reading or publishing any of them.
69
+
70
+ let existing = null;
71
+ if (options.bitbucketPr !== undefined) {
72
+ if (!Number.isSafeInteger(options.bitbucketPr) || options.bitbucketPr < 1) throw new Error("Invalid --bitbucket-pr number");
73
+ existing = await ctx.host.prView(targetRepo.key, options.bitbucketPr);
74
+ requireOpen(existing);
75
+ }
76
+ const branch = assertBranchName(existing?.headRefName ?? options.branch ?? `work/github-${selected.repository.path.replaceAll("/", "-")}-pr-${selected.number}`);
77
+ const base = assertBranchName(options.base ?? existing?.baseRefName ?? pr.baseRefName);
78
+ if (branch === base || existing && (existing.headRefName !== branch || existing.baseRefName !== base || options.branch && options.branch !== branch)) throw new Error("Bitbucket branch/base mapping conflicts with the selected PR");
79
+ const candidates = await ctx.host.prListByHead(targetRepo.key, branch, { allStates: true });
80
+ if (candidates.length > 1 || candidates.some(p => p.isCrossRepository !== false || p.state !== "OPEN" || p.baseRefName !== base)) throw new Error("Conflicting or duplicate Bitbucket PRs; reconcile the explicit mapping before promotion");
81
+ if (existing && candidates[0]?.number !== existing.number) throw new Error("Selected Bitbucket PR no longer owns this branch");
82
+ if (!existing && candidates.length) {
83
+ existing = await ctx.host.prView(targetRepo.key, candidates[0].number);
84
+ requireOpen(existing);
85
+ // A deterministic name is not permission to overwrite somebody else's PR.
86
+ if (!existing.body?.includes(`Source review: ${pr.url}\n`)) throw new Error("An existing PR needs an explicit --bitbucket-pr mapping");
87
+ }
88
+ const targetHead = await ctx.host.branchExists(targetRepo.key, branch) ? sha(await ctx.host.refSha(targetRepo.key, branch)) : null;
89
+ if (existing && targetHead !== existing.headRefOid) throw new Error("Bitbucket PR head changed during planning");
90
+ const body = `${withoutTaskMetadata(pr.body)}\n\nSource review: ${pr.url}\nReviewed source commit: ${sha(pr.headRefOid)}\n\nThis handoff preserves the source commits. Bitbucket merge and deployment approval remain separate.`.trim();
91
+ const plan = { version: 1,
92
+ source: { repo: sourceRepo, url: pr.url, number: pr.number, branch: pr.headRefName, head: sha(pr.headRefOid), base: pr.baseRefName, baseHead: sha(reviewedPr.baseRefOid), binding },
93
+ target: { repo: targetRepo.key, branch, base, head: targetHead, baseHead: sha(await ctx.host.refSha(targetRepo.key, base)), pr: existing?.number ?? null,
94
+ previousText: existing ? { title: existing.title, body: existing.body } : null },
95
+ title: assertPublicText(pr.title, "PR title"), body: assertPublicText(body, "PR body"),
96
+ review: { mode: policy.mode, verdict: verdict.verdict, checks: pr.statusCheckRollup.length, zeroChecks: policy.zeroChecks === true },
97
+ };
98
+ await transfer.validate(plan);
99
+ // Network and object validation can outlive a newly queued operator request.
100
+ // Re-read local authority before the caller can use this plan for a write.
101
+ const current = ctx.state.findByPr(sourceRepo, selected.number)?.[1];
102
+ requireUnstopped(current);
103
+ if (!current || current.state !== entry.state || current.review_readiness?.binding !== binding || current.review_readiness?.completion !== "accepted") throw new Error("Source acceptance changed during planning; inspect the run before promotion");
104
+ return { ...plan, digest: reviewDigest(plan) };
105
+ }
106
+
107
+ /** The approved digest binds both repositories, all heads and the public text. */
108
+ export async function promote(ctx, options, transfer) {
109
+ const plan = await planPromotion(ctx, options, transfer);
110
+ if (!options.apply) return { applied: false, plan };
111
+ if (!/^[a-f0-9]{64}$/.test(options.expectedPlan || "") || options.expectedPlan !== plan.digest) throw new Error("Plan changed or approval pin missing. Run the dry run, inspect it, then pass --apply --expected-plan <digest>");
112
+ let pushAttempted = false, publicationAttempted = false;
113
+ try {
114
+ // Re-read immediately before the Git operation. The native pre-push hook
115
+ // also checks the actual advertised target old OID, closing that race.
116
+ const fresh = await planPromotion(ctx, options, transfer);
117
+ if (fresh.digest !== plan.digest) throw new Error("Source, target or review changed before push; inspect a new plan");
118
+ if (plan.target.head !== plan.source.head) { pushAttempted = true; await transfer.push(plan); }
119
+ const after = await planPromotion(ctx, options, transfer);
120
+ const expected = { ...plan, target: { ...plan.target, head: plan.source.head } };
121
+ delete expected.digest;
122
+ if (after.digest !== reviewDigest(expected)) throw new Error("Source, target or review changed after push; no PR was published");
123
+ publicationAttempted = true;
124
+ const result = await ctx.host.publishResultPr({ repo: plan.target.repo, branch: plan.target.branch, base: plan.target.base, title: plan.title, body: plan.body,
125
+ expectedNumber: plan.target.pr, expectedPreviousText: plan.target.previousText, expectAbsent: plan.target.pr === null });
126
+ if (result.headRefOid !== plan.source.head || result.baseRefOid !== plan.target.baseHead || result.headRefName !== plan.target.branch || result.baseRefName !== plan.target.base || plan.target.pr !== null && result.number !== plan.target.pr) throw new Error("Published PR moved during publication; inspect it before any merge");
127
+ return { applied: true, url: result.url, head: plan.source.head, plan };
128
+ } catch (error) {
129
+ if (pushAttempted || publicationAttempted) error.message += ` The ${publicationAttempted ? "branch or PR may already be updated for" : "branch may already contain"} ${plan.source.head}; no merge was attempted. Inspect the target and rerun the dry run; do not force-push.`;
130
+ throw error;
131
+ }
132
+ }