@uchibeke/gallivant 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +120 -60
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +41 -14
  4. package/bin/promote-bitbucket.mjs +40 -0
  5. package/bin/task-pr.mjs +9 -0
  6. package/bin/task-review.mjs +15 -0
  7. package/docs/README.md +6 -2
  8. package/docs/agent-setup.md +55 -41
  9. package/docs/architecture.md +11 -3
  10. package/docs/bitbucket-promotion.md +69 -0
  11. package/docs/bitbucket-review.md +330 -0
  12. package/docs/chains.md +30 -1
  13. package/docs/codex-cloud.md +7 -0
  14. package/docs/codex-review.md +50 -0
  15. package/docs/commands.md +103 -14
  16. package/docs/config.md +45 -13
  17. package/docs/execution.md +1 -1
  18. package/docs/first-party-review.md +266 -0
  19. package/docs/git-hosts.md +294 -0
  20. package/docs/guardrails.md +56 -0
  21. package/docs/install.md +137 -67
  22. package/docs/launch.md +7 -4
  23. package/docs/network-recovery.md +9 -0
  24. package/docs/openai-agents.md +3 -2
  25. package/docs/prd.md +23 -2
  26. package/docs/preparation.md +2 -2
  27. package/docs/release-qualification.md +1 -1
  28. package/docs/releasing.md +46 -22
  29. package/docs/task-delivery.md +5 -5
  30. package/docs/task-profiles.md +537 -0
  31. package/docs/tasks.md +59 -197
  32. package/lib/attribution.mjs +7 -3
  33. package/lib/block.mjs +10 -1
  34. package/lib/chains.mjs +101 -30
  35. package/lib/codex_cloud.mjs +2 -1
  36. package/lib/codex_cloud_input.mjs +15 -6
  37. package/lib/codex_cloud_result.mjs +1 -1
  38. package/lib/commands/doctor.mjs +22 -453
  39. package/lib/commands/init.mjs +281 -28
  40. package/lib/commands/register.mjs +81 -13
  41. package/lib/commands/resume.mjs +5 -1
  42. package/lib/commands/review.mjs +66 -0
  43. package/lib/commands/serve.mjs +8 -0
  44. package/lib/commands/start.mjs +30 -11
  45. package/lib/commands/status.mjs +18 -2
  46. package/lib/commands/task.mjs +9 -1
  47. package/lib/commands/up.mjs +19 -11
  48. package/lib/commands/why.mjs +7 -3
  49. package/lib/config.mjs +42 -9
  50. package/lib/context.mjs +28 -21
  51. package/lib/control.mjs +21 -6
  52. package/lib/credential.mjs +20 -14
  53. package/lib/deliverable.mjs +2 -0
  54. package/lib/events.mjs +96 -53
  55. package/lib/execution.mjs +132 -11
  56. package/lib/execution_preparation.mjs +16 -1
  57. package/lib/execution_result.mjs +26 -5
  58. package/lib/first_party_review.mjs +618 -0
  59. package/lib/fs.mjs +10 -3
  60. package/lib/gh.mjs +144 -56
  61. package/lib/git.mjs +133 -32
  62. package/lib/git_diff.mjs +121 -0
  63. package/lib/harness.mjs +14 -10
  64. package/lib/host_contract.mjs +71 -0
  65. package/lib/host_definitions.mjs +22 -0
  66. package/lib/hosts/bitbucket.mjs +553 -0
  67. package/lib/hosts/bitbucket_contract.mjs +57 -0
  68. package/lib/hosts/bitbucket_credential.mjs +62 -0
  69. package/lib/hosts/bitbucket_review.mjs +44 -0
  70. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  71. package/lib/hosts/github.mjs +57 -0
  72. package/lib/hosts/github_contract.mjs +27 -0
  73. package/lib/hosts/github_doctor.mjs +482 -0
  74. package/lib/hosts/github_review_summary.mjs +38 -0
  75. package/lib/hosts/transport_doctor.mjs +35 -0
  76. package/lib/hosts.mjs +130 -0
  77. package/lib/learning_qualification.mjs +1 -1
  78. package/lib/notify.mjs +1 -1
  79. package/lib/openai_agents_input.mjs +7 -2
  80. package/lib/openai_agents_sources.mjs +16 -8
  81. package/lib/plugin_package.mjs +13 -1
  82. package/lib/pr_operation.mjs +41 -0
  83. package/lib/prd.mjs +18 -3
  84. package/lib/preparation_codex.mjs +6 -1
  85. package/lib/probe.mjs +6 -4
  86. package/lib/process.mjs +24 -0
  87. package/lib/promotion.mjs +132 -0
  88. package/lib/promotion_git.mjs +63 -0
  89. package/lib/prompt.mjs +16 -13
  90. package/lib/provider_health.mjs +14 -3
  91. package/lib/repository.mjs +68 -0
  92. package/lib/repository_preflight.mjs +11 -0
  93. package/lib/review_evaluation.mjs +25 -0
  94. package/lib/review_evidence.mjs +500 -0
  95. package/lib/review_history.mjs +73 -0
  96. package/lib/review_image.mjs +145 -0
  97. package/lib/review_link.mjs +12 -0
  98. package/lib/review_policy.mjs +132 -0
  99. package/lib/review_policy_contract.mjs +18 -0
  100. package/lib/review_state.mjs +13 -0
  101. package/lib/review_visual.mjs +94 -0
  102. package/lib/runtime-environment.mjs +6 -0
  103. package/lib/runtime_node.mjs +3 -2
  104. package/lib/runtime_state.mjs +39 -5
  105. package/lib/sources/prd.mjs +6 -3
  106. package/lib/state.mjs +137 -24
  107. package/lib/task_completion.mjs +7 -3
  108. package/lib/task_contract.mjs +201 -27
  109. package/lib/task_delivery.mjs +9 -2
  110. package/lib/task_delivery_contract.mjs +13 -9
  111. package/lib/task_delivery_view.mjs +18 -2
  112. package/lib/task_environment.mjs +13 -1
  113. package/lib/task_limits.mjs +13 -0
  114. package/lib/task_model_config.mjs +99 -0
  115. package/lib/task_model_route.mjs +243 -0
  116. package/lib/task_prepare.mjs +21 -8
  117. package/lib/task_profile.mjs +1875 -0
  118. package/lib/task_repository_context.mjs +65 -0
  119. package/lib/tasks.mjs +252 -51
  120. package/lib/tick.mjs +373 -98
  121. package/lib/time.mjs +1 -0
  122. package/lib/tracker/pages.mjs +19 -5
  123. package/lib/tracker/review.mjs +21 -0
  124. package/lib/tracker_process.mjs +8 -1
  125. package/lib/verification_sandbox.mjs +2 -1
  126. package/lib/work_artifacts.mjs +79 -0
  127. package/npm-shrinkwrap.json +12 -3
  128. package/package.json +9 -3
  129. package/templates/plugin/task-delivery.SKILL.md +17 -10
  130. package/templates/plugin/task-intake-contract.json +18 -0
  131. package/templates/plugin/task-profile-requests.mjs +1448 -0
  132. package/templates/tracker/tracker.css +3 -0
  133. package/templates/tracker/tracker.js +38 -0
package/lib/control.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostApi } from "./hosts.mjs";
1
2
  // Local operator requests. UI and CLI enqueue; the daemon is the only consumer.
2
3
  // One outstanding request per entry avoids competing resumes and needs no second runner.
3
4
  import { randomUUID } from "node:crypto";
@@ -5,7 +6,7 @@ import { needsAgentResume } from "./network_recovery.mjs";
5
6
  import { resolveHandle, STATES } from "./state.mjs";
6
7
  import { readDaemon, assertDaemonUsable } from "./daemon.mjs";
7
8
  import { nowIso } from "./time.mjs";
8
- import { NEEDS_LABEL, READY_LABEL } from "./gh.mjs";
9
+ import { NEEDS_LABEL, READY_LABEL } from "./host_contract.mjs";
9
10
  import { adapterFor } from "./harness.mjs";
10
11
  import { executionRunner, unsubmittedInitialDispatch, PROVIDER_STATES } from "./execution.mjs";
11
12
  import { requestRunCancellation } from "./run.mjs";
@@ -163,21 +164,28 @@ export async function applyControl(ctx, id) {
163
164
  }
164
165
  const runner = executionRunner(ctx, id);
165
166
  if (runner.current()?.running) return false;
167
+ if (["message", "resume"].includes(request.action)) {
168
+ const health = ctx.providerHealth?.get(entry.harness, ctx.config.harness[entry.harness]?.account || "operator");
169
+ if (health?.pending) {
170
+ if (request.message !== "Waiting for the provider readiness check.") ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, message: "Waiting for the provider readiness check." } } : {});
171
+ return true;
172
+ }
173
+ }
166
174
  ctx.state.update(id, { control: { ...request, status: "applying" } });
167
175
  try {
168
176
  if (entry.state === STATES.done) throw new Error("The task was closed or merged before this request ran.");
169
177
  if (entry.pr) {
170
- const pr = await ctx.gh.prView(entry.repo, entry.pr);
178
+ const pr = await hostApi(ctx).prView(entry.repo, entry.pr);
171
179
  if (pr.state !== "OPEN") throw new Error("The PR is no longer open.");
172
180
  }
173
181
  let run = null, initialLaunch = false;
174
182
  if (request.action === "recheck") {
175
183
  if (needsAgentResume(entry)) throw Object.assign(new Error("The agent failure requires resume, not PR recheck."), { code: "AGENT_RESUME_REQUIRED" });
176
184
  if (executionResultCheckpoint(runner)?.phase === "blocked") throw Object.assign(new Error("Result publication needs an explicit corrective resume."), { code: "EXECUTION_RESULT_BLOCKED" });
177
- await ctx.gh.removeLabel(entry.repo, entry.pr, READY_LABEL);
178
- await ctx.gh.removeLabel(entry.repo, entry.pr, NEEDS_LABEL);
185
+ await hostApi(ctx).removeLabel(entry.repo, entry.pr, READY_LABEL);
186
+ await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL);
179
187
  ctx.state.update(id, {
180
- state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null,
188
+ state: STATES.prOpen, reason: null, retry: null, labels_pending: false, public_fix: null, review_next_at: 0,
181
189
  last: { ...entry.last, event: "operator requested PR recheck" }, updated_at: nowIso(ctx.clock),
182
190
  });
183
191
  // Preserve budgets, dispatch markers and the brief. Only the normal tick judges readiness.
@@ -187,13 +195,20 @@ export async function applyControl(ctx, id) {
187
195
  run = result.run;
188
196
  initialLaunch = result.initialLaunch === true;
189
197
  // A cosmetic label failure after spawning must not invite another resume.
190
- if (entry.pr) await ctx.gh.removeLabel(entry.repo, entry.pr, NEEDS_LABEL).catch(e => ctx.log.warn(`${id}: could not remove stopped label: ${e.message}`));
198
+ if (entry.pr) await hostApi(ctx).removeLabel(entry.repo, entry.pr, NEEDS_LABEL).catch(e => ctx.log.warn(`${id}: could not remove stopped label: ${e.message}`));
191
199
  }
192
200
  finish({ status: "applied", run, message: initialLaunch ? "Started the initial run after repairing launch prerequisites." : request.action === "message"
193
201
  ? "Started the next run in the same session with your message."
194
202
  : run ? "Resumed the same agent session." : "Watching PR checks and review again." });
195
203
  return request.action !== "recheck"; // A recheck continues through normal judgement in this same tick.
196
204
  } catch (e) {
205
+ // PROVIDER_UNAVAILABLE is raised only before launch. Preserve the exact
206
+ // request when a due probe races with preparation; do not replay an
207
+ // ambiguous dispatch or turn a failed/auth check into automatic retries.
208
+ if (e.code === "PROVIDER_UNAVAILABLE" && e.retryableProviderPending === true) {
209
+ ctx.state.update(id, current => current.control?.id === request.id ? { control: { ...request, status: "queued", message: "Waiting for the provider readiness check." } } : {});
210
+ return true;
211
+ }
197
212
  ctx.log.warn(`${id}: operator ${request.action} failed: ${e.message}`);
198
213
  // Subprocess errors can carry credentials. Details stay in the local daemon log.
199
214
  finish({ status: "failed", message: controlFailure(e) });
@@ -1,3 +1,4 @@
1
+ import { parseRemote, repositoryIdentity } from "./repository.mjs";
1
2
  // The one place a GitHub credential for a run is resolved.
2
3
  //
3
4
  // The defect this exists for. A run is sandboxed: it cannot reach the operator's macOS keyring, their Linux secret
@@ -46,15 +47,11 @@ export const SOURCES = Object.freeze({
46
47
  * The host a remote URL's credential belongs to. `gh` can be logged in to an enterprise host as well as
47
48
  * github.com, and the token for github.com is the wrong one for `github.acme.com`.
48
49
  *
49
- * Only the host is taken, and only from a URL shape we recognise. An SSH remote has no useful host for a token
50
- * (a token cannot authenticate SSH), so it reads as github.com and the caller's own remote check is what fails.
50
+ * The strict repository parser supplies the host for every accepted transport. Malformed input throws
51
+ * without reflecting URL userinfo. The host adapter decides which transport may actually use a token.
51
52
  */
52
53
  export function hostFromRemote(url) {
53
- const s = String(url || "").trim();
54
- const m = s.match(/^https?:\/\/(?:[^@/]*@)?([a-z0-9.-]+)/i);
55
- if (m) return m[1].toLowerCase();
56
- const ssh = s.match(/^(?:ssh:\/\/)?(?:[^@/]+@)([a-z0-9.-]+)[:/]/i);
57
- return ssh ? ssh[1].toLowerCase() : "github.com";
54
+ return parseRemote(url).host;
58
55
  }
59
56
 
60
57
  /** True when a host is github.com itself rather than an enterprise install. */
@@ -111,6 +108,7 @@ export function noCredentialMessage(host = "github.com") {
111
108
  * putting a real token anywhere near a fixture.
112
109
  */
113
110
  export async function resolveCredential(config, { host = "github.com", env = process.env, exec = execFileAsyncQuiet } = {}) {
111
+ if (host.toLowerCase() === "bitbucket.org") throw new Error("GitHub credentials cannot authorize Bitbucket; resolve its selected credential through the host registry");
114
112
  // A configured file is an authority boundary, not merely the first candidate.
115
113
  // Missing, unreadable or blank must not silently widen to an ambient login.
116
114
  if (config?.github_token_file) {
@@ -134,12 +132,11 @@ export const describe = ({ token, source, host }) =>
134
132
  token ? `${source}${isDotCom(host) ? "" : ` for ${host}`} (${token.length} characters)` : "none";
135
133
 
136
134
  /**
137
- * The host a checkout's credential belongs to, from its `origin` remote. Failure reads as github.com, because a
138
- * checkout whose remote cannot be read has a bigger problem than which host its token is for and the caller's own
139
- * git operations will say so.
135
+ * The host a checkout's credential belongs to, from its verified origin. Unreadable origins throw before
136
+ * any credential is resolved; they never fall back to another host.
140
137
  */
141
138
  export async function hostForWorktree(git, dir) {
142
- try { return hostFromRemote(await git.originOf(dir)); } catch { return "github.com"; }
139
+ return hostFromRemote(await git.originOf(dir));
143
140
  }
144
141
 
145
142
  /**
@@ -154,8 +151,17 @@ export async function hostForWorktree(git, dir) {
154
151
  * from the config alone, so a test that wants a credential sets `github_token_file` and one that wants none sets
155
152
  * nothing, and no test needs a `gh` on the machine.
156
153
  */
157
- export async function resolveCredentialFor(ctx, { worktree = null } = {}) {
158
- const host = worktree && ctx.git ? await hostForWorktree(ctx.git, worktree) : "github.com";
154
+ export async function resolveCredentialFor(ctx, { worktree = null, repo = null } = {}) {
155
+ let repository = repo ? repositoryIdentity(repo) : null;
156
+ if (worktree && ctx.git?.originOf) {
157
+ const actual = parseRemote(await ctx.git.originOf(worktree));
158
+ if (repository && actual.remoteIdentity !== repository.remoteIdentity) throw new Error("Checkout host/repository mismatch; restore the bound origin before resolving credentials");
159
+ repository = actual;
160
+ }
161
+ repository ??= repositoryIdentity("owner/name");
162
+ if (ctx.hosts) return ctx.hosts.credential(repository);
159
163
  const resolve = ctx.credential ?? (opts => resolveCredential(ctx.config, opts));
160
- return resolve({ host });
164
+ const resolved = await resolve({ host: repository.host });
165
+ if (resolved?.host && resolved.host !== repository.host) throw new Error("Credential host mismatch");
166
+ return { ...resolved, host: repository.host, ...(repo || worktree ? { repository: repository.path } : {}) };
161
167
  }
@@ -35,6 +35,8 @@ export const provenance = (framework = null) => ({ ...(framework ? { framework }
35
35
 
36
36
  const RECEIPT_RE = new RegExp("^```(?:" + RECEIPT_TAGS.join("|") + ")\\s*\\n([\\s\\S]*?)\\n```[ \t]*\\r?(?=\\n|$)", "gm");
37
37
  const receiptMatches = body => [...String(body || "").matchAll(RECEIPT_RE)];
38
+ /** A cross-host handoff must not carry the source task's machine receipt. */
39
+ export const withoutReceipt = body => String(body || "").replace(RECEIPT_RE, "");
38
40
  export const publicTaskId = taskId => "task-" + createHash("sha256").update(String(taskId)).digest("hex").slice(0, 24);
39
41
  const FETCH_TIMEOUT_MS = 15_000;
40
42
  // The hosted route limits the whole JSON body to 256 KiB, including the passport and JSON escaping.
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");