@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/events.mjs CHANGED
@@ -1,24 +1,22 @@
1
1
  // The rules for what counts as an event, written as pure functions over the data `gh` returns, so every rule in
2
2
  // PRD 6.8 has a unit test and the tick only orchestrates.
3
3
  //
4
- // Inputs are the raw shapes: `pr` from `gh pr view --json`, `reviews`, `inline` and `comments` from REST.
5
- import { actor, normalizeLogin, ROUND_MARKER, NUDGE_MARKER, RESET_MARKER } from "./gh.mjs";
4
+ // Inputs are normalized host records; legacy event aliases remain readable for saved integrations.
5
+ import { actor, normalizeLogin, ROUND_MARKER, NUDGE_MARKER, RESET_MARKER } from "./host_contract.mjs";
6
6
  import { hasMarker } from "./public-format.mjs";
7
7
  import { isAfter, isAtOrAfter, later, floorSecond } from "./time.mjs";
8
8
 
9
- const FAILING = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
10
- const GREEN = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
11
9
  const COMMAND = /^\s*(?:\/fix\b|@(?:work|gallivant)\s+fix\b)/i;
12
10
  export const isFixCommand = body => COMMAND.test(String(body ?? ""));
13
11
 
14
12
  export function makeTrust(config, ownLogin) {
15
13
  if (!ownLogin) throw new Error("trust rules need the daemon's own login");
16
14
  const trusted = new Set(config.trusted_authors.map(normalizeLogin));
17
- const reviewer = config.reviewer ? normalizeLogin(config.reviewer.login) : null;
15
+ const reviewer = config.reviewer ? normalizeLogin(config.reviewer.actor_id ?? config.reviewer.login) : null;
18
16
  const own = normalizeLogin(ownLogin);
19
17
  return {
20
18
  isTrusted: a => trusted.has(a.login) && !a.bot,
21
- isReviewer: a => reviewer !== null && a.bot && a.login === reviewer,
19
+ isReviewer: a => reviewer !== null && (config.reviewer.actor_id || a.bot) && a.login === reviewer && a.login !== own,
22
20
  isOwn: a => a.login === own,
23
21
  };
24
22
  }
@@ -42,6 +40,27 @@ export const eventKey = e => `${e.kind}:${e.id}`;
42
40
  *
43
41
  * Editing an item later does not make it new again: the comparison is on `created_at`, never on `updated_at`.
44
42
  */
43
+ const recordId = record => record.id ?? record.eventId;
44
+ const currentEvent = (record, head) => !record.deleted && !record.resolved && (record.headBinding !== "exact" || record.headSha === head);
45
+
46
+ // PR-wide unresolved tasks/change requests stay blocking even after their one
47
+ // fix round. An unbound finding must not be silently mistaken for a clean head.
48
+ export function requiredFeedback({ reviews = [], inline = [], trust, headSha }) {
49
+ return [...reviews, ...inline].filter(r => !r.deleted && !r.resolved && !(r.replyTo && trust.isOwn(actor(r.actor ?? r.user))) &&
50
+ (r.required || trust.isTrusted(actor(r.actor ?? r.user)) || trust.isReviewer(actor(r.actor ?? r.user))) &&
51
+ (r.required || r.headBinding === "exact" && !r.headSha) &&
52
+ (r.headBinding !== "exact" || !r.headSha || r.headSha === headSha));
53
+ }
54
+
55
+ export async function readFeedback(host, repo, pr, inlineSince, commentSince) {
56
+ if (pr.feedback !== undefined) {
57
+ const f = pr.feedback;
58
+ if (!f || f.complete !== true || f.headSha !== pr.headRefOid || ![f.reviews, f.inline, f.comments].every(Array.isArray)) throw new Error("Incomplete or mismatched feedback snapshot; preserve cursor and retry");
59
+ return [f.reviews, f.inline, f.comments];
60
+ }
61
+ return Promise.all([host.reviews(repo, pr.number), host.inlineComments(repo, pr.number, inlineSince), host.issueComments(repo, pr.number, commentSince)]);
62
+ }
63
+
45
64
  export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust }) {
46
65
  const since = entry.cursor.since;
47
66
  const dispatched = new Set(entry.cursor.dispatched || []);
@@ -50,33 +69,33 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
50
69
 
51
70
  // Reviews our own login submitted, so an inline note hanging off one is recognised as the harness's own comment on
52
71
  // its own work rather than as feedback on it.
53
- const ownReviews = new Set(reviews.filter(r => trust.isOwn(actor(r.user))).map(r => r.id));
72
+ const ownReviews = new Set(reviews.filter(r => trust.isOwn(actor(r.actor ?? r.user))).map(r => recordId(r)));
54
73
 
55
74
  const inlineByReview = new Map();
56
75
  for (const c of inline) {
57
- const rid = c.pull_request_review_id;
76
+ const rid = (c.reviewId ?? c.pull_request_review_id);
58
77
  if (!inlineByReview.has(rid)) inlineByReview.set(rid, []);
59
78
  inlineByReview.get(rid).push(c);
60
79
  }
61
80
 
62
81
  for (const r of reviews) {
63
- if (!isNew(r.submitted_at, "review", r.id)) continue;
64
- const a = actor(r.user);
65
- const attached = (inlineByReview.get(r.id) || []).filter(c => !c.in_reply_to_id);
82
+ if (!currentEvent(r, pr.headRefOid) || r.headBinding === "unbound" || !isNew((r.at ?? r.submitted_at), "review", recordId(r))) continue;
83
+ const a = actor(r.actor ?? r.user);
84
+ const attached = (inlineByReview.get(recordId(r)) || []).filter(c => currentEvent(c, pr.headRefOid) && (c.headBinding !== "exact" || isNew(c.at ?? c.created_at, "inline", recordId(c))) && !(c.replyTo ?? c.in_reply_to_id));
66
85
  const qualifies = (trust.isReviewer(a) && (r.state === "CHANGES_REQUESTED" || (r.state === "COMMENTED" && attached.length > 0)))
67
86
  || (trust.isTrusted(a) && r.state === "CHANGES_REQUESTED");
68
87
  if (!qualifies) continue;
69
- out.push({ kind: "review", id: r.id, reviewId: r.id, author: a.login, state: r.state, body: r.body || "", at: r.submitted_at });
88
+ out.push({ kind: "review", id: recordId(r), reviewId: recordId(r), author: a.login, state: r.state, body: r.body || "", at: (r.at ?? r.submitted_at) });
70
89
  for (const c of attached) out.push(inlineEvent(c, a.login));
71
- inlineByReview.delete(r.id);
90
+ inlineByReview.delete(recordId(r));
72
91
  }
73
92
 
74
93
  // Inline comments not attached to a dispatched review: standalone trusted comments, or later comments on a review
75
94
  // that was already handled. Replies by our own login are the harness's thread replies.
76
95
  for (const list of inlineByReview.values()) {
77
96
  for (const c of list) {
78
- if (!isNew(c.created_at, "inline", c.id)) continue;
79
- const a = actor(c.user);
97
+ if (!currentEvent(c, pr.headRefOid) || !isNew((c.at ?? c.created_at), "inline", recordId(c))) continue;
98
+ const a = actor(c.actor ?? c.user);
80
99
  // Our own login writes in two capacities and they have to be told apart, because on a one-operator install the
81
100
  // operator's login IS the daemon's: config.md says `trusted_authors` is where your own login belongs. The
82
101
  // operator leaving a standalone inline comment is the main feedback channel and must dispatch. The harness
@@ -84,17 +103,17 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
84
103
  // carrying `in_reply_to_id`, and as inline notes hanging off a review of its own, which is what
85
104
  // `gh pr review --comment` produces. Both are the daemon's own work explaining itself, and dispatching a round
86
105
  // to answer it is the loop feeding itself.
87
- if (trust.isOwn(a) && (c.in_reply_to_id || ownReviews.has(c.pull_request_review_id))) continue;
106
+ if (trust.isOwn(a) && ((c.replyTo ?? c.in_reply_to_id) || ownReviews.has((c.reviewId ?? c.pull_request_review_id)))) continue;
88
107
  if (!(trust.isTrusted(a) || trust.isReviewer(a))) continue;
89
108
  out.push(inlineEvent(c, a.login));
90
109
  }
91
110
  }
92
111
 
93
112
  for (const c of comments) {
94
- if (!isNew(c.created_at, "command", c.id) || isOwnComment(c.body)) continue;
95
- const a = actor(c.user);
113
+ if (!currentEvent(c, pr.headRefOid) || c.replyTo && trust.isOwn(actor(c.actor ?? c.user)) || !isNew((c.at ?? c.created_at), "command", recordId(c)) || isOwnComment(c.body)) continue;
114
+ const a = actor(c.actor ?? c.user);
96
115
  if (!trust.isTrusted(a) || !isFixCommand(c.body)) continue;
97
- out.push({ kind: "command", id: c.id, author: a.login, body: c.body.replace(COMMAND, "").trim(), at: c.created_at });
116
+ out.push({ kind: "command", id: recordId(c), author: a.login, body: c.body.replace(COMMAND, "").trim(), at: (c.at ?? c.created_at) });
98
117
  }
99
118
 
100
119
  if (pr.headRefOid && pr.headRefOid !== entry.cursor.checks_sha) {
@@ -106,36 +125,30 @@ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust
106
125
  }
107
126
  }
108
127
 
109
- out.sort((x, y) => Date.parse(x.at) - Date.parse(y.at));
110
- return { events: out, newest: out.length ? out[out.length - 1].at : null };
128
+ const unique = [...new Map(out.map(e => [e.kind === "check" ? `check:${e.name}` : eventKey(e), e])).values()];
129
+ unique.sort((x, y) => Date.parse(x.at) - Date.parse(y.at));
130
+ return { events: unique, newest: unique.length ? unique[unique.length - 1].at : null };
111
131
  }
112
132
 
113
133
  const inlineEvent = (c, login) => ({
114
- kind: "inline", id: c.id, reviewId: c.pull_request_review_id ?? null, author: login,
115
- path: c.path, line: c.line ?? c.original_line ?? null, body: c.body || "", at: c.created_at,
134
+ kind: "inline", id: recordId(c), reviewId: (c.reviewId ?? c.pull_request_review_id) ?? null, author: login,
135
+ path: c.path, line: c.line ?? c.original_line ?? null, body: c.body || "", at: (c.at ?? c.created_at),
116
136
  });
117
137
 
118
138
  /** CheckRun and StatusContext entries that failed, with the Actions run id when the URL carries one. */
119
139
  export function failingChecks(rollup = []) {
120
- const out = [];
121
- for (const c of rollup || []) {
122
- if (c.__typename === "StatusContext") {
123
- if (["FAILURE", "ERROR"].includes(c.state)) out.push({ name: c.context, url: c.targetUrl, runId: runIdFrom(c.targetUrl), at: c.createdAt });
124
- } else if (c.status === "COMPLETED" && FAILING.has(c.conclusion)) {
125
- out.push({ name: c.workflowName ? `${c.workflowName} / ${c.name}` : c.name, url: c.detailsUrl, runId: runIdFrom(c.detailsUrl), at: c.completedAt });
126
- }
127
- }
128
- return out;
140
+ return (rollup || []).filter(c => c.outcome === "failed").map(c => ({ name: c.name, url: c.url, runId: c.runId, at: c.updatedAt }));
129
141
  }
130
142
 
131
- const runIdFrom = url => (String(url || "").match(/\/actions\/runs\/(\d+)/) || [])[1] ?? null;
132
-
133
- /** Every check concluded green. Pending checks are not green. No checks at all is green. */
134
- export function isGreen(rollup = []) {
135
- const items = rollup || [];
136
- return items.every(c => c.__typename === "StatusContext" ? c.state === "SUCCESS" : c.status === "COMPLETED" && GREEN.has(c.conclusion));
143
+ /** Every complete check concluded green. Empty evidence is subject to the shared grace period. */
144
+ export function isGreen(rollup = [], required = []) {
145
+ return Array.isArray(rollup) && required.every(name => rollup.some(c => c.name === name)) && rollup.every(c => c.outcome === "passed");
137
146
  }
138
147
 
148
+ /** Newest verdict first, with stable exact-head sequence numbers breaking timestamp ties. */
149
+ export const compareReviewRecency = (a, b) => Date.parse(b.at ?? b.submitted_at) - Date.parse(a.at ?? a.submitted_at) ||
150
+ (a.headBinding === "exact" && b.headBinding === "exact" && Number.isSafeInteger(a.sequence) && Number.isSafeInteger(b.sequence) ? b.sequence - a.sequence : 0);
151
+
139
152
  /**
140
153
  * The reviewer's verdict since the last request: "none" when no reviewer is configured, "waiting" when it has not
141
154
  * reviewed since `sinceIso`, "blocking" when the newest review requests changes or has a blocking inline comment,
@@ -148,10 +161,10 @@ export function isGreen(rollup = []) {
148
161
  * `dispatched` holds the event keys already handled at exactly the cursor's instant, so a review the daemon is
149
162
  * already fixing cannot also read as the reviewer clearing this head.
150
163
  */
151
- export function reviewerVerdict({ reviews, inline, comments = [], config, trust, sinceIso, headSha = null, dispatched = [] }) {
164
+ export function reviewerVerdict({ reviews, inline, comments = [], config, trust, sinceIso, headSha = null, dispatched = [], includeIdentity = false }) {
152
165
  if (!config.reviewer) return { verdict: "none", note: "no reviewer configured" };
153
166
  const handled = new Set(dispatched);
154
- const fromReviews = reviews.filter(r => trust.isReviewer(actor(r.user)) && isAtOrAfter(r.submitted_at, sinceIso) && !["PENDING", "DISMISSED"].includes(r.state) && !handled.has(eventKey({ kind: "review", id: r.id })));
167
+ const fromReviews = reviews.filter(r => trust.isReviewer(actor(r.actor ?? r.user)) && isAtOrAfter((r.at ?? r.submitted_at), sinceIso) && !["PENDING", "DISMISSED"].includes(r.state) && !handled.has(eventKey({ kind: "review", id: recordId(r) })));
155
168
 
156
169
  // A verdict the reviewer posted as a plain comment rather than as a review, normalised into the shape above so the
157
170
  // head guard and the blocking rules below judge it identically. Everything after this line is unchanged.
@@ -167,28 +180,58 @@ export function reviewerVerdict({ reviews, inline, comments = [], config, trust,
167
180
  // a commit in its body, which `reviewedCommit` already parses and which the existing guard then checks against the
168
181
  // head. Clean is never INFERRED from the absence of findings, because the reviewer says other things in comments and
169
182
  // a chatty bot must not be able to clear a pull request by mentioning it. No commit named, no verdict.
170
- const fromComments = (comments || [])
171
- .filter(c => trust.isReviewer(actor(c.user)) && isAtOrAfter(c.created_at, sinceIso) && reviewedCommit(c.body, config.reviewer.reviewed_commit_pattern))
172
- .map(c => ({ id: c.id, body: c.body, user: c.user, submitted_at: c.created_at, state: "COMMENTED", commit_id: null, fromComment: true }));
183
+ const fromComments = (comments || []).flatMap(c => {
184
+ if (c.deleted || c.headBinding === "exact" || !trust.isReviewer(actor(c.actor ?? c.user))) return [];
185
+ // The GitHub adapter recognizes authenticated, exact-head structured code
186
+ // review summaries. Required CI and independent review policies remain
187
+ // separate gates; an optional security scan is not a code-review verdict.
188
+ const summary = c.source?.host === "github.com" ? c.reviewSummary : null;
189
+ if (Object.hasOwn(c, "reviewSummary") && !summary) return [];
190
+ const at = summary?.at ?? c.at ?? c.created_at;
191
+ if (!isAtOrAfter(at, sinceIso) || !summary && !reviewedCommit(c.body, config.reviewer.reviewed_commit_pattern)) return [];
192
+ return [{ id: recordId(c), body: c.body, actor: c.actor ?? actor(c.user), submitted_at: at,
193
+ state: "COMMENTED", commit_id: null, fromComment: true, ...summary, fromStructuredSummary: Boolean(summary) }];
194
+ });
173
195
 
174
- const mine = [...fromReviews, ...fromComments].sort((a, b) => Date.parse(b.submitted_at) - Date.parse(a.submitted_at));
196
+ const mine = [...fromReviews, ...fromComments].sort(compareReviewRecency);
175
197
  if (!mine.length) return { verdict: "waiting", note: "waiting on reviewer" };
176
198
  const newest = mine[0];
177
199
  // Which commit was reviewed, from the reviewer's own body first and its `commit_id` second. A review that names no
178
200
  // commit at all is no answer about this head: `commit_id` is null once the reviewed commit is gone from the
179
201
  // repository, which is what a force-push plus garbage collection leaves, and a falsy value used to skip the guard
180
202
  // entirely and clear whatever the head happened to be. So an unknown reviewed commit is `waiting`, not `clear`.
181
- const reviewed = reviewedCommit(newest.body, config.reviewer.reviewed_commit_pattern) || newest.commit_id || null;
203
+ const reviewed = newest.headBinding === "exact" ? newest.headSha : reviewedCommit(newest.body, config.reviewer.reviewed_commit_pattern) || newest.headSha || newest.commit_id || null;
182
204
  if (headSha && !reviewed) return { verdict: "waiting", note: "reviewer named no commit, so it cannot clear this head" };
183
- if (headSha && !headSha.startsWith(reviewed) && !reviewed.startsWith(headSha)) return { verdict: "waiting", note: `reviewer looked at ${reviewed.slice(0, 7)}, head is ${headSha.slice(0, 7)}` };
205
+ if (headSha && (newest.headBinding === "exact" ? headSha !== reviewed : !headSha.startsWith(reviewed) && !reviewed.startsWith(headSha))) return { verdict: "waiting", note: `reviewer looked at ${reviewed.slice(0, 7)}, head is ${headSha.slice(0, 7)}` };
206
+ if (newest.headBinding === "exact" && !["clear", "blocking"].includes(newest.verdict)) return { verdict: "waiting", note: "waiting for an explicit current-head verdict" };
207
+ if (newest.verdict === "blocking") return { verdict: "blocking", note: "explicit blocking verdict" };
184
208
  if (newest.state === "CHANGES_REQUESTED") return { verdict: "blocking", note: "changes requested" };
185
209
  const re = new RegExp(config.reviewer.blocking_pattern);
186
- if (re.test(newest.body || "")) return { verdict: "blocking", note: "blocking finding in reviewer message" };
210
+ // Authenticated structured summaries already carry the code-review verdict.
211
+ // Their body also contains optional security policy metadata (for example a
212
+ // P0 threshold), which is not a code finding. Plain reviews/receipts still
213
+ // use the configured severity pattern; required CI/security gates stay separate.
214
+ const structuredClear = newest.fromStructuredSummary === true && newest.headBinding === "exact" && newest.verdict === "clear";
215
+ if (!structuredClear && re.test(newest.body || "")) return { verdict: "blocking", note: "blocking finding in reviewer message" };
187
216
  // Inline findings belong to a REVIEW. A comment-borne verdict has no review to own them, and `newest.id` is a
188
217
  // comment id from a different id space, so matching on it could pair a verdict with another review's findings.
189
- const hits = newest.fromComment ? [] : inline.filter(c => c.pull_request_review_id === newest.id && re.test(c.body || ""));
218
+ const hits = newest.fromComment ? [] : inline.filter(c => (c.reviewId ?? c.pull_request_review_id) === recordId(newest) && re.test(c.body || ""));
190
219
  if (hits.length) return { verdict: "blocking", note: `${hits.length} blocking finding${hits.length > 1 ? "s" : ""}` };
191
- return { verdict: "clear", note: "no blocking findings" };
220
+ const nativeHead = newest.headSha ?? newest.commit_id;
221
+ return { verdict: "clear", note: "no blocking findings", ...(includeIdentity ? {
222
+ reviewedHead: /^[a-f0-9]{40,64}$/.test(nativeHead || "") && nativeHead.startsWith(reviewed) ? nativeHead : reviewed,
223
+ } : {}) };
224
+ }
225
+
226
+ /** New policies resolve abbreviated bot identities through the host; a prefix
227
+ * collision or unavailable lookup cannot establish exact-head clearance. */
228
+ export async function exactReviewerVerdict(input, resolveHead) {
229
+ const result = reviewerVerdict({ ...input, includeIdentity: true });
230
+ if (result.verdict !== "clear" || result.reviewedHead === input.headSha) return result;
231
+ try {
232
+ if (typeof resolveHead === "function" && await resolveHead(result.reviewedHead) === input.headSha) return { ...result, reviewedHead: input.headSha };
233
+ } catch { /* preserve waiting: lookup failure is not clearance */ }
234
+ return { verdict: "waiting", note: "reviewer commit could not be resolved to the exact current head; restore host access or request a full commit identity" };
192
235
  }
193
236
 
194
237
  /**
@@ -236,8 +279,8 @@ export function reviewerClock({ config, pushedAt, previousAsk = null, floorAt })
236
279
  }
237
280
 
238
281
  const ownComments = (issueComments, ownLogin) => issueComments
239
- .filter(c => actor(c.user ?? c.author).login === normalizeLogin(ownLogin))
240
- .sort((a, b) => Date.parse(a.created_at ?? a.createdAt) - Date.parse(b.created_at ?? b.createdAt));
282
+ .filter(c => actor(c.actor ?? c.user ?? c.author).login === normalizeLogin(ownLogin))
283
+ .sort((a, b) => Date.parse(a.at ?? a.created_at ?? a.createdAt) - Date.parse(b.at ?? b.created_at ?? b.createdAt));
241
284
 
242
285
  /**
243
286
  * The iteration number: the daemon's own round comments since its last reset comment. Every round posts one
@@ -251,11 +294,11 @@ export function countRounds(issueComments, ownLogin) {
251
294
  }
252
295
 
253
296
  export const lastOwnCommentAt = (issueComments, ownLogin) => ownComments(issueComments, ownLogin)
254
- .filter(c => isOwnComment(c.body)).map(c => c.created_at ?? c.createdAt).at(-1) ?? null;
297
+ .filter(c => isOwnComment(c.body)).map(c => (c.at ?? c.created_at) ?? c.createdAt).at(-1) ?? null;
255
298
 
256
299
  /** Whether the daemon already posted a comment carrying `marker` after `sinceIso`. */
257
300
  export const hasOwnComment = (issueComments, ownLogin, marker, sinceIso) => ownComments(issueComments, ownLogin)
258
- .some(c => hasMarker(c.body, marker) && isAfter(c.created_at ?? c.createdAt, sinceIso));
301
+ .some(c => hasMarker(c.body, marker) && isAfter((c.at ?? c.created_at) ?? c.createdAt, sinceIso));
259
302
 
260
303
  /** Only the lines a diff adds, so the completion gate never judges context or removed text. */
261
304
  export const addedLines = diff => String(diff || "").split("\n").filter(l => l.startsWith("+") && !l.startsWith("+++")).map(l => l.slice(1)).join("\n");
package/lib/execution.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { join } from "node:path";
3
3
  import { existsSync, readdirSync, rmSync } from "node:fs";
4
4
  import { randomUUID, createHash } from "node:crypto";
5
+ import { isDeepStrictEqual } from "node:util";
5
6
  import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
6
7
  import { acquireProcessLock } from "./daemon.mjs";
7
8
  import { capabilitiesFor, adapterFor, preparationReceipt, isPreparationReceipt } from "./harness.mjs";
@@ -11,6 +12,7 @@ import { prepareLocalExecution } from "./execution_preparation.mjs";
11
12
  import { EXECUTION_LIMITS, boundedCall, nextAttempt, executionError, assertExecutionCursor, ownsProviderSession, assertTerminalOutcome, redact, redactResultMetadata } from "./execution_io.mjs";
12
13
  import { journalFor } from "./execution_journal.mjs";
13
14
  import { requestRunCancellation } from "./run.mjs";
15
+ import { verifyReviewImageCopy } from "./review_image.mjs";
14
16
 
15
17
  export const EXECUTION_VERSION = 1;
16
18
  const exactIdentity = id => typeof id === "string" && !!id.trim();
@@ -29,6 +31,14 @@ const preparationFingerprint = input => {
29
31
  const { receipt: _receipt, ...prepared } = input.prepared || {};
30
32
  return digest({ environment: input.environment, ...(input.spec ? { spec: input.spec } : { prompt: input.prompt, prepared }) });
31
33
  };
34
+ function verifyPreparedReviewImage(input, transport) {
35
+ const spec = input.spec, args = spec?.args, image = spec?.imageAttachment;
36
+ if (!Array.isArray(args)) return;
37
+ const flags = args.slice(0, -1).filter(arg => arg === "--image" || arg === "-i" || typeof arg === "string" && (arg.startsWith("--image=") || arg.startsWith("-i=")));
38
+ if (!image && flags.length === 0) return;
39
+ if (transport || input.kind !== "intake" || input.preparation?.boundary !== "codex-draft-sandbox-v1" || flags.length !== 1 || args.at(-4) !== "--image" || args.at(-3) !== image?.file || args.at(-2) !== "--" || !spec.options?.cwd) throw executionError("TASK_PREPARATION_BOUNDARY", "image attachment does not match the restricted prepared input");
40
+ verifyReviewImageCopy(spec.options.cwd, image.file, image.expected);
41
+ }
32
42
  export const executionRunner = (ctx, id, provider = ctx.state.get(id)?.harness) => ctx.executionFor && provider ? ctx.executionFor(id, provider) : ctx.runnerFor(id);
33
43
 
34
44
  const epochFile = (root, epoch) => join(root, epoch ? `execution-${epoch}.json` : "execution.json");
@@ -319,6 +329,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
319
329
  if (prior.acceptance !== "accepted" || (transport && !ownedRemote(prior.job?.execution))) throw executionError("EXECUTION_UNKNOWN", `submission ${prior.runId} acceptance is unknown; reconcile its recorded idempotency key; no new dispatch`);
320
330
  return { ...prior.job };
321
331
  }
332
+ if (input.kind === "intake") verifyPreparedReviewImage(input, transport);
322
333
  if (s.learningContinuity?.pending && (method !== "submit" || input.sessionId)) throw executionError("EXECUTION_CONTINUATION", "retired advice requires a new provider session, not continuation");
323
334
  if (s.stopped) throw executionError("EXECUTION_STOPPED", `dispatch stopped: ${s.stopped}; late results remain quarantined`);
324
335
  if (s.current && executionLive(s.current)) throw executionError("BUSY", "previous provider work is not settled");
@@ -356,6 +367,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
356
367
  if (transport) project(s);
357
368
  let accepted;
358
369
  try {
370
+ if (!transport && input.kind === "intake") verifyPreparedReviewImage(input, transport);
359
371
  accepted = transport ? await call(method, { ...input, taskId, provider, account, runId: id, idempotencyKey: intent.idempotencyKey })
360
372
  : await runner.start(input.name, input.spec.cmd, input.spec.args, { ...input.spec.options, runId: id, privateOutput: { taskId, runId: id, provider } });
361
373
  } catch (e) {
@@ -400,6 +412,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
400
412
  if (!transport) runner.observe?.();
401
413
  if (!s.current) return current();
402
414
  if (!transport) {
415
+ const before = structuredClone(s);
403
416
  let job = runner.current();
404
417
  if (!job || job.id !== s.current.id) return current();
405
418
  if (s.current.execution.acceptance === "unknown") {
@@ -418,11 +431,23 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
418
431
  catch (error) { s.current.execution.recoveryAction = error.code || "local shutdown remains unverified"; }
419
432
  if (!job || job.id !== s.current.id) { save(s); return current(); }
420
433
  }
421
- Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results", observedAt: clock.now(), reconciledAt: clock.now() });
434
+ Object.assign(s.current.execution, { providerState: job.running ? s.stopped || job.killed ? "cancellation-requested" : "running" : s.current.execution.collected ? s.current.execution.providerState : "retrieving-results" });
422
435
  if (s.stopped && !job.running) confirmLocalStop(s, job);
423
436
  const { running: _running, ...record } = job;
424
437
  s.current = { ...record, ...(s.current.execution.quarantined ? { exit: s.current.exit, endedAt: s.current.endedAt } : {}), execution: s.current.execution };
425
- save(s); runner.annotate?.(s.current.id, { execution: s.current.execution }); return current();
438
+ // Still observe liveness/output every tick. Persist real changes at
439
+ // once, but coalesce unchanged live observations to one per minute.
440
+ // Settled runs need no heartbeat. Never defer a launch or stop intent.
441
+ const ex = s.current.execution;
442
+ if (!isDeepStrictEqual(before, s) || job.running &&
443
+ (ex.reconciledAt == null || clock.now() - ex.reconciledAt >= 60_000)) {
444
+ Object.assign(ex, { observedAt: clock.now(), reconciledAt: clock.now() });
445
+ save(s);
446
+ }
447
+ // Repair a projection after an interrupted write without rewriting an
448
+ // already matching job.json on every poll.
449
+ if (!isDeepStrictEqual(job.execution, ex)) runner.annotate?.(s.current.id, { execution: ex });
450
+ return current();
426
451
  }
427
452
  const ex = s.current.execution;
428
453
  if (ex.collected && terminal(ex.providerState)) return current();
@@ -626,6 +651,7 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
626
651
  sanitize: (value, id, extraSecrets = []) => redact(value, [...secrets, ...(readJson(join(dirOf(id), "sensitive-redactions.json"))?.secrets || []), ...extraSecrets]),
627
652
  prepare: async (input, { build } = {}) => {
628
653
  authorize(input);
654
+ if (input.reviewImage != null && (transport || input.kind !== "intake" || provider !== "codex")) throw executionError("TASK_PREPARATION_BOUNDARY", "image review requires the restricted local reviewer; no fallback is available");
629
655
  if (input.kind !== "intake") return transport ? call("prepare", input) : input;
630
656
  authorizePreparation(read());
631
657
  const receipt = preparationReceipt(provider, capabilities, account);
@@ -701,7 +727,26 @@ export function makeExecution({ root, taskId, provider, account = "operator", tr
701
727
  return job;
702
728
  } finally { lock.release(); }
703
729
  },
704
- kill: id => { if (transport) throw executionError("EXECUTION_CONTROL", "remote cancellation must be requested and confirmed by the coordinator"); return runner.kill(id); },
730
+ kill: id => {
731
+ if (transport) throw executionError("EXECUTION_CONTROL", "remote cancellation must be requested and confirmed by the coordinator");
732
+ ensureDir(root);
733
+ const lock = acquireProcessLock(join(root, "execution.lock"), "execution");
734
+ try {
735
+ const s = read();
736
+ if (s.current) {
737
+ if (s.current.id !== id) throw executionError("EXECUTION_OWNERSHIP", "interrupt does not own the current local run");
738
+ // First-turn sessions can be discovered in the stream before their
739
+ // terminal result. Retain that verified identity BEFORE stopping and
740
+ // quarantining the run; continuation must not depend on late output.
741
+ const sessionId = localSession(s, id, adapterFor(provider).sessionFrom(runner.lines(id)));
742
+ if (sessionId !== s.current.execution.sessionId) {
743
+ s.current.execution.sessionId = sessionId;
744
+ save(s); runner.annotate?.(id, { execution: s.current.execution });
745
+ }
746
+ }
747
+ return runner.kill(id);
748
+ } finally { lock.release(); }
749
+ },
705
750
  waitForKill: id => runner?.waitForKill(id), dirOf,
706
751
  };
707
752
  }
@@ -8,6 +8,7 @@ import { ensureDir } from "./fs.mjs";
8
8
  import { MAX_PREPARATION_PROMPT_BYTES } from "./task_contract.mjs";
9
9
  import { taskError } from "./task_store.mjs";
10
10
  import { boundedCall } from "./execution_io.mjs";
11
+ import { copyReviewImage, REVIEW_IMAGE_LIMITS } from "./review_image.mjs";
11
12
 
12
13
  export async function prepareLocalExecution(provider, capabilities, account, input, build = buildPreparationRun, limits = {}) {
13
14
  const receipt = preparationReceipt(provider, capabilities, account);
@@ -25,5 +26,19 @@ export async function prepareLocalExecution(provider, capabilities, account, inp
25
26
  if (spec.configure) spec = await boundedCall(signal => spec.configure(spec, { cwd: input.cwd, env, signal }), limits);
26
27
  check(); // An adapter's final configuration cannot replace its authority.
27
28
  assertSafeEnv(env);
28
- return { receipt, environment: input.environment, spec: { cmd: spec.cmd, args: spec.args, options: { cwd: input.cwd, env, inheritEnv: false } } };
29
+ let copy, imageAttachment;
30
+ try {
31
+ if (input.reviewImage != null) {
32
+ if (provider !== "codex" || spec.reviewImageBoundary !== "coordinator-copy-v1" || spec.boundary !== "codex-draft-sandbox-v1") throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment requires the restricted reviewer boundary");
33
+ const { content, ...expected } = input.reviewImage;
34
+ if (typeof content !== "string" || !Number.isSafeInteger(expected.bytes) || expected.bytes < 1 || expected.bytes > REVIEW_IMAGE_LIMITS.individualBytes || content.length !== Math.ceil(expected.bytes / 3) * 4 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(content)) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes are incomplete");
35
+ const bytes = Buffer.from(content, "base64");
36
+ if (bytes.length !== expected.bytes || bytes.toString("base64") !== content) throw taskError("TASK_PREPARATION_BOUNDARY", "image attachment bytes differ");
37
+ copy = copyReviewImage(input.cwd, bytes, expected);
38
+ spec.args = [...spec.args.slice(0, -1), "--image", copy.file, "--", spec.args.at(-1)];
39
+ imageAttachment = { file: copy.file, expected };
40
+ check();
41
+ }
42
+ return { receipt, environment: input.environment, spec: { cmd: spec.cmd, args: spec.args, options: { cwd: input.cwd, env, inheritEnv: false }, ...(imageAttachment ? { imageAttachment } : {}) } };
43
+ } catch (error) { copy?.cleanup(); throw error; }
29
44
  }
@@ -1,3 +1,6 @@
1
+ import { publicationPolicyOf, assertPublicationPaths, workArtifactReason } from "./work_artifacts.mjs";
2
+ import { assertAgentsCheckout } from "./openai_agents_sources.mjs";
3
+ import { hostApi } from "./hosts.mjs";
1
4
  // Result transport ends here. Git/gh own the one publication path; the existing
2
5
  // tick still owns readiness, receipts, review rounds, conflicts and human merge.
3
6
  import { createHash } from "node:crypto";
@@ -7,7 +10,8 @@ import { unlinkSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7
10
  import { repositoryPath, repositoryRelativePath, assertRepositoryTargets, readRepositoryText, writeRepositoryText } from "./repository_files.mjs";
8
11
  import { isOurBranch, runEnv } from "./git.mjs";
9
12
  import { ensureDir, readJson, writeJsonAtomic } from "./fs.mjs";
10
- import { execFileAsync, remoteMatches } from "./gh.mjs";
13
+ import { execFileAsync } from "./process.mjs";
14
+ import { remoteMatches } from "./repository.mjs";
11
15
  import { parsePrd, verifyCommands } from "./prd.mjs";
12
16
  import { normalizePublicBody } from "./block.mjs";
13
17
  import { assertPublicText, assertNoNul } from "./public_output.mjs";
@@ -103,6 +107,8 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
103
107
  // input requires an explicit corrective run, with no repeated application.
104
108
  const fail = (message, retryable = false) => { throw Object.assign(executionError("EXECUTION_RESULT", message), { publicationValidation: !retryable }); };
105
109
  const validate = fn => { try { return fn(); } catch (error) { if (error.code) throw error; fail(error.message); } };
110
+ const approvedBrief = ctx.readPrd(id) || "";
111
+ const publicationPolicy = validate(() => publicationPolicyOf(parsePrd(approvedBrief, `${id}.md`), { sources: entry.sources || [] }));
106
112
  const execution = ctx.executionFor?.(id, entry.harness);
107
113
  const assertNotStopped = () => {
108
114
  const current = ctx.state?.get(id) || entry;
@@ -149,7 +155,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
149
155
  catch (error) { fail(error.message); }
150
156
  };
151
157
  if (result.pushedHead) {
152
- if (result.files?.length || await ctx.gh.refSha(entry.repo, entry.branch) !== result.pushedHead || !await ctx.gh.containsCommit(entry.repo, entry.branch, result.inputHead)) fail("pushed result revision or input ancestry cannot be verified");
158
+ if (result.files?.length || await hostApi(ctx).refSha(entry.repo, entry.branch) !== result.pushedHead || !await hostApi(ctx).containsCommit(entry.repo, entry.branch, result.inputHead)) fail("pushed result revision or input ancestry cannot be verified");
153
159
  if (head !== result.pushedHead) fail("reconcile the exact pushed provider branch into its isolated checkout before collection");
154
160
  await validateTargets();
155
161
  } else {
@@ -161,6 +167,7 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
161
167
  paths.add(path);
162
168
  changes.push({ ...file, path });
163
169
  }
170
+ validate(() => assertPublicationPaths(changes.filter(file => file.text !== null).map(file => file.path), publicationPolicy));
164
171
  await validateTargets(changes.map(file => file.path));
165
172
  let modesMatch;
166
173
  try { modesMatch = await ctx.git.resultModesMatch(entry.worktree, result.inputHead, [...paths]); }
@@ -191,6 +198,15 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
191
198
  }
192
199
  }
193
200
  const appliedHead = verified ? prior.appliedHead : await ctx.git.head(entry.worktree);
201
+ // A provider may return an already-pushed branch or a resumed publication may
202
+ // contain earlier commits. Check the full branch, not only this turn's manifest.
203
+ let publicationPaths;
204
+ try { publicationPaths = await ctx.git.publicationPaths(entry.worktree, entry.base); }
205
+ catch { fail("complete branch publication paths unavailable; retrieve Git base metadata before publication", true); }
206
+ validate(() => assertPublicationPaths(publicationPaths, publicationPolicy));
207
+ const privateInputPaths = [...new Set([...publicationPolicy.inputFiles, ...(entry.prd ? [entry.prd] : [])])]
208
+ .filter(path => workArtifactReason(path, publicationPolicy));
209
+ const privateInputs = privateInputPaths.map(path => ({ path, text: path === entry.prd ? approvedBrief : ctx.readSource?.(id, path) }));
194
210
  const assertVerifiedTree = async () => {
195
211
  let revision, tree;
196
212
  try {
@@ -199,7 +215,12 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
199
215
  } catch { block("verification-state-unavailable", "cannot verify the checkout revision and clean index/worktree", appliedHead); }
200
216
  if (revision !== appliedHead) block("verification-head-changed", "verification changed the input head; review that change before publishing", appliedHead);
201
217
  if (!tree || ![tree.modified, tree.untracked].every(count => Number.isInteger(count) && count >= 0)) block("verification-state-unavailable", "cannot verify clean index/worktree status", appliedHead);
202
- if (tree.modified || tree.untracked) block("verification-dirty", "verification requires a clean index/worktree; preserve uncommitted files before correction", appliedHead);
218
+ if (privateInputs.length) {
219
+ try {
220
+ await assertAgentsCheckout({ worktree: entry.worktree, overlays: privateInputs, immutableOverlays: true });
221
+ if (await ctx.git.resultModesMatch(entry.worktree, result.inputHead, privateInputPaths) !== true) throw new Error("private input modes changed");
222
+ } catch { block("verification-dirty", "verification requires a clean index/worktree apart from unchanged approved private inputs; preserve uncommitted files before correction", appliedHead); }
223
+ } else if (tree.modified || tree.untracked) block("verification-dirty", "verification requires a clean index/worktree; preserve uncommitted files before correction", appliedHead);
203
224
  };
204
225
  await assertVerifiedTree();
205
226
  const evidenceFile = join(dir, "verification.json");
@@ -256,10 +277,10 @@ async function applyExecutionResult(ctx, { id, entry, result, dir, verify = exec
256
277
  try {
257
278
  assertNotStopped();
258
279
  if (execution?.current && execution.current()?.id !== result.runId) fail("publication root changed; reconcile the exact owned result before publishing", true);
259
- await ctx.git.pushResult(entry.worktree, entry.branch);
280
+ await ctx.git.pushResult(entry.worktree, entry.branch, entry.repo);
260
281
  await assertVerifiedTree();
261
282
  assertNotStopped();
262
- const pr = await ctx.gh.publishResultPr({ repo: entry.repo, branch: entry.branch, base: entry.base, title: result.pr.title, body });
283
+ const pr = await hostApi(ctx).publishResultPr({ repo: entry.repo, branch: entry.branch, base: entry.base, title: result.pr.title, body });
263
284
  writeJsonAtomic(checkpoint, { fingerprint, phase: "published", appliedHead, pr: pr.number, url: pr.url });
264
285
  return { head: appliedHead, pr, evidence };
265
286
  } finally { exclusion.release(); }