@uchibeke/gallivant 0.0.0-stage → 0.2.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 (168) hide show
  1. package/CONTRIBUTING.md +26 -0
  2. package/LICENSE +204 -0
  3. package/README.md +201 -2
  4. package/SECURITY.md +19 -0
  5. package/bin/gallivant.mjs +257 -0
  6. package/bin/package-plugin.mjs +16 -0
  7. package/bin/release-live.mjs +29 -0
  8. package/bin/release-package.mjs +23 -0
  9. package/bin/session-runtime.mjs +13 -0
  10. package/bin/task-delivery-state.mjs +12 -0
  11. package/docs/README.md +70 -0
  12. package/docs/agent-setup.md +214 -0
  13. package/docs/app.md +111 -0
  14. package/docs/architecture.md +265 -0
  15. package/docs/chains.md +102 -0
  16. package/docs/codex-cloud.md +284 -0
  17. package/docs/commands.md +620 -0
  18. package/docs/config.md +430 -0
  19. package/docs/execution.md +796 -0
  20. package/docs/guardrails.md +311 -0
  21. package/docs/install.md +338 -0
  22. package/docs/launch.md +42 -0
  23. package/docs/learning.md +369 -0
  24. package/docs/network-recovery.md +101 -0
  25. package/docs/openai-agents.md +457 -0
  26. package/docs/prd.md +246 -0
  27. package/docs/preparation.md +110 -0
  28. package/docs/release-qualification.md +196 -0
  29. package/docs/releasing.md +140 -0
  30. package/docs/runtime-environment.md +113 -0
  31. package/docs/task-delivery.md +476 -0
  32. package/docs/tasks.md +739 -0
  33. package/lib/aport.mjs +95 -0
  34. package/lib/attribution.mjs +95 -0
  35. package/lib/block.mjs +89 -0
  36. package/lib/capabilities.mjs +3 -0
  37. package/lib/chains.mjs +222 -0
  38. package/lib/codex_cloud.mjs +160 -0
  39. package/lib/codex_cloud_config.mjs +20 -0
  40. package/lib/codex_cloud_input.mjs +49 -0
  41. package/lib/codex_cloud_result.mjs +74 -0
  42. package/lib/commands/doctor.mjs +457 -0
  43. package/lib/commands/init.mjs +34 -0
  44. package/lib/commands/learn.mjs +54 -0
  45. package/lib/commands/new.mjs +47 -0
  46. package/lib/commands/ready.mjs +45 -0
  47. package/lib/commands/register.mjs +195 -0
  48. package/lib/commands/resume.mjs +224 -0
  49. package/lib/commands/serve.mjs +349 -0
  50. package/lib/commands/start.mjs +247 -0
  51. package/lib/commands/start_services.mjs +22 -0
  52. package/lib/commands/status.mjs +150 -0
  53. package/lib/commands/task.mjs +73 -0
  54. package/lib/commands/up.mjs +228 -0
  55. package/lib/commands/why.mjs +143 -0
  56. package/lib/config.mjs +461 -0
  57. package/lib/context.mjs +100 -0
  58. package/lib/control.mjs +202 -0
  59. package/lib/control_contract.mjs +16 -0
  60. package/lib/coordinator.mjs +19 -0
  61. package/lib/credential.mjs +161 -0
  62. package/lib/daemon.mjs +156 -0
  63. package/lib/deliverable.mjs +357 -0
  64. package/lib/events.mjs +261 -0
  65. package/lib/executable.mjs +31 -0
  66. package/lib/execution.mjs +631 -0
  67. package/lib/execution_contract.mjs +15 -0
  68. package/lib/execution_input.mjs +4 -0
  69. package/lib/execution_io.mjs +94 -0
  70. package/lib/execution_journal.mjs +69 -0
  71. package/lib/execution_preparation.mjs +29 -0
  72. package/lib/execution_result.mjs +266 -0
  73. package/lib/fs.mjs +133 -0
  74. package/lib/gh.mjs +289 -0
  75. package/lib/git.mjs +434 -0
  76. package/lib/harness.mjs +521 -0
  77. package/lib/harness_log.mjs +432 -0
  78. package/lib/hash.mjs +16 -0
  79. package/lib/identity.mjs +98 -0
  80. package/lib/learning.mjs +654 -0
  81. package/lib/learning_contract.mjs +2 -0
  82. package/lib/learning_data.mjs +135 -0
  83. package/lib/learning_input.mjs +111 -0
  84. package/lib/learning_qualification.mjs +67 -0
  85. package/lib/learning_signer.mjs +151 -0
  86. package/lib/learning_store.mjs +88 -0
  87. package/lib/log.mjs +53 -0
  88. package/lib/network_diagnostics.mjs +64 -0
  89. package/lib/network_recovery.mjs +42 -0
  90. package/lib/notify.mjs +58 -0
  91. package/lib/openai_agents.mjs +373 -0
  92. package/lib/openai_agents_config.mjs +14 -0
  93. package/lib/openai_agents_http.mjs +87 -0
  94. package/lib/openai_agents_input.mjs +15 -0
  95. package/lib/openai_agents_sources.mjs +112 -0
  96. package/lib/owner_delivery.mjs +443 -0
  97. package/lib/plugin_package.mjs +340 -0
  98. package/lib/prd.mjs +635 -0
  99. package/lib/preparation_agents.mjs +33 -0
  100. package/lib/preparation_codex.mjs +212 -0
  101. package/lib/probe.mjs +126 -0
  102. package/lib/prompt.mjs +238 -0
  103. package/lib/provider_health.mjs +55 -0
  104. package/lib/public-format.mjs +31 -0
  105. package/lib/public_output.mjs +49 -0
  106. package/lib/release_package.mjs +154 -0
  107. package/lib/repository_files.mjs +86 -0
  108. package/lib/run.mjs +385 -0
  109. package/lib/run_log.mjs +94 -0
  110. package/lib/runtime-environment.mjs +87 -0
  111. package/lib/runtime_dependencies.mjs +15 -0
  112. package/lib/runtime_node.mjs +107 -0
  113. package/lib/runtime_state.mjs +174 -0
  114. package/lib/sources/index.mjs +27 -0
  115. package/lib/sources/prd.mjs +50 -0
  116. package/lib/start_contract.mjs +37 -0
  117. package/lib/state.mjs +246 -0
  118. package/lib/task_allowance.mjs +31 -0
  119. package/lib/task_completion.mjs +61 -0
  120. package/lib/task_contract.mjs +450 -0
  121. package/lib/task_delivery.mjs +219 -0
  122. package/lib/task_delivery_cancel.mjs +34 -0
  123. package/lib/task_delivery_contract.mjs +61 -0
  124. package/lib/task_delivery_mcp.mjs +40 -0
  125. package/lib/task_delivery_store.mjs +43 -0
  126. package/lib/task_delivery_view.mjs +88 -0
  127. package/lib/task_environment.mjs +281 -0
  128. package/lib/task_prepare.mjs +224 -0
  129. package/lib/task_store.mjs +38 -0
  130. package/lib/tasks.mjs +513 -0
  131. package/lib/tick.mjs +1635 -0
  132. package/lib/time.mjs +36 -0
  133. package/lib/tracker/assets.mjs +43 -0
  134. package/lib/tracker/pages.mjs +320 -0
  135. package/lib/tracker/runs.mjs +102 -0
  136. package/lib/tracker/stream.mjs +74 -0
  137. package/lib/tracker_process.mjs +107 -0
  138. package/lib/verification_sandbox.mjs +181 -0
  139. package/node_modules/smol-toml/LICENSE +24 -0
  140. package/node_modules/smol-toml/README.md +605 -0
  141. package/node_modules/smol-toml/dist/date.d.ts +41 -0
  142. package/node_modules/smol-toml/dist/date.js +156 -0
  143. package/node_modules/smol-toml/dist/error.d.ts +37 -0
  144. package/node_modules/smol-toml/dist/error.js +67 -0
  145. package/node_modules/smol-toml/dist/extract.js +289 -0
  146. package/node_modules/smol-toml/dist/index.cjs +770 -0
  147. package/node_modules/smol-toml/dist/index.d.ts +44 -0
  148. package/node_modules/smol-toml/dist/index.js +34 -0
  149. package/node_modules/smol-toml/dist/parse.d.ts +73 -0
  150. package/node_modules/smol-toml/dist/parse.js +164 -0
  151. package/node_modules/smol-toml/dist/primitive.js +151 -0
  152. package/node_modules/smol-toml/dist/stringify.d.ts +32 -0
  153. package/node_modules/smol-toml/dist/stringify.js +219 -0
  154. package/node_modules/smol-toml/dist/struct.js +156 -0
  155. package/node_modules/smol-toml/dist/util.d.ts +43 -0
  156. package/node_modules/smol-toml/dist/util.js +57 -0
  157. package/node_modules/smol-toml/package.json +68 -0
  158. package/npm-shrinkwrap.json +1823 -0
  159. package/package.json +105 -4
  160. package/templates/claude-settings.json +18 -0
  161. package/templates/passport.gallivant.json +83 -0
  162. package/templates/plugin/bundled-runtime.json +26 -0
  163. package/templates/plugin/schemas/mcp.schema.json +120 -0
  164. package/templates/plugin/schemas/plugin.schema.json +65 -0
  165. package/templates/plugin/task-delivery.SKILL.md +60 -0
  166. package/templates/prd.md +32 -0
  167. package/templates/tracker/tracker.css +301 -0
  168. package/templates/tracker/tracker.js +721 -0
@@ -0,0 +1,357 @@
1
+ // The completion gate, shaped after APort's deliverable.task.complete.v1 policy.
2
+ //
3
+ // The PRD's acceptance criteria become passport-style criteria ({ id, description }). The session must leave a
4
+ // completion receipt in the PR body: a fenced JSON block in the policy's own context shape, one attestation with
5
+ // evidence per criterion. Before a PR can be ready for a human, the daemon builds the policy context (taking
6
+ // tests_passing from the checks it observed, never from the agent's claim) and evaluates it. A deny dispatches a
7
+ // fix round whose prompt carries the policy's deny codes, which are written so the agent can self-correct.
8
+ //
9
+ // The same machinery decides PRD readiness: the deliverable is the PRD, the criteria are the contract rules.
10
+ import { createHash } from "node:crypto";
11
+ import { shortHash } from "./hash.mjs";
12
+ import { criteriaFromPrd } from "./prd.mjs";
13
+ import { quote } from "./prompt.mjs";
14
+ import { VERSION } from "./fs.mjs";
15
+ import { DEFAULT_DELIVERABLE as DEFAULT_LIMITS } from "./config.mjs";
16
+
17
+ export const POLICY_ID = "deliverable.task.complete.v1";
18
+ export { RECEIPT_TAG } from "./public-format.mjs";
19
+ import { RECEIPT_TAG, RECEIPT_TAGS } from "./public-format.mjs";
20
+ export { DEFAULT_LIMITS };
21
+
22
+ /**
23
+ * Provenance for `context.meta`: which harness did the work and which Gallivant asked. A machine running both Claude
24
+ * Code and Codex against one passport produces decisions that are otherwise indistinguishable, and the decision
25
+ * object itself is closed (`additionalProperties: false` in the OAP decision schema), so the request context is
26
+ * where this belongs. The policy packs leave `additionalProperties` unset and the hosted validator's context schemas
27
+ * are `.unknown(true)`, so an extra `meta` object needs no specification change: an old verifier ignores it and a
28
+ * new one reads it.
29
+ *
30
+ * SELF-REPORTED, NEVER AN AUTHORIZATION INPUT. Gallivant asserts these values; nothing verifies them. A field the
31
+ * caller controls, used to gate a decision, is a trivially forged capability. Record it for audit and analytics.
32
+ * Do not read it back to decide anything.
33
+ */
34
+ export const provenance = (framework = null) => ({ ...(framework ? { framework } : {}), gallivant_version: VERSION });
35
+
36
+ const RECEIPT_RE = new RegExp("^```(?:" + RECEIPT_TAGS.join("|") + ")\\s*\\n([\\s\\S]*?)\\n```[ \t]*\\r?(?=\\n|$)", "gm");
37
+ const receiptMatches = body => [...String(body || "").matchAll(RECEIPT_RE)];
38
+ export const publicTaskId = taskId => "task-" + createHash("sha256").update(String(taskId)).digest("hex").slice(0, 24);
39
+ const FETCH_TIMEOUT_MS = 15_000;
40
+ // The hosted route limits the whole JSON body to 256 KiB, including the passport and JSON escaping.
41
+ export const MAX_POLICY_BYTES = 240 * 1024;
42
+
43
+ /**
44
+ * Fingerprint of the receipt block, so a round that only rewrote the receipt is recognised as progress. The hashing
45
+ * itself is `shortHash`, shared with the brief's contract fingerprint: one spelling of "twelve hex characters of a
46
+ * sha1" rather than two that happen to agree.
47
+ */
48
+ export function receiptHash(body) {
49
+ const matches = receiptMatches(body);
50
+ if (matches.length !== 1) return null;
51
+ // Migration changes transport names/IDs, not evidence. Ignore task_id (the gate binds its own trusted task ID)
52
+ // in this progress fingerprint, so rewriting only the format never spends a review round.
53
+ try {
54
+ const receipt = JSON.parse(matches[0][1]);
55
+ if (receipt && typeof receipt === "object" && !Array.isArray(receipt)) {
56
+ delete receipt.task_id;
57
+ return shortHash(JSON.stringify(receipt));
58
+ }
59
+ } catch { /* malformed receipts retain a useful fingerprint */ }
60
+ return shortHash(matches[0][1]);
61
+ }
62
+
63
+ /** Accepted stored fingerprints during the one-time transport upgrade; new writes use receiptHash. */
64
+ export function receiptHashes(body) {
65
+ const matches = receiptMatches(body);
66
+ return matches.length === 1 ? [...new Set([receiptHash(body), shortHash(matches[0][1])])] : [];
67
+ }
68
+
69
+ /**
70
+ * `[{ id: "ac-1", description }]` from the PRD's Acceptance criteria bullets, in order, ids stable by position.
71
+ *
72
+ * Re-exported from `prd.mjs`, where it now lives, because it is a fact about the brief's contract and not about this
73
+ * gate: the divergence check in `prd.mjs` compares exactly this list plus `verifyCommands`, and reaching back here for
74
+ * half of that would have made `prd.mjs` import the module that imports it. Every existing caller still takes it from
75
+ * here, since this is where it has always been named.
76
+ */
77
+ export { criteriaFromPrd };
78
+
79
+ /** What the session is told to put in the PR body. Rendered into the start and fix prompts. */
80
+ export function receiptInstructions(criteria, taskId) {
81
+ const example = {
82
+ task_id: publicTaskId(taskId),
83
+ output_type: "code",
84
+ summary: "What changed and how it was verified, at least 20 words.",
85
+ criteria_attestations: criteria.map(c => ({ criterion_id: c.id, met: true, evidence: "file path, test name, command output or CI run id that proves it" })),
86
+ };
87
+ return [
88
+ "The PR body must end with a completion receipt: a fenced code block tagged `" + RECEIPT_TAG + "` holding JSON in exactly this shape.",
89
+ "One attestation per criterion, `met` true only when it holds, `evidence` a concrete pointer (file path, test name, command output, run id), never a restatement of the criterion.",
90
+ "Criteria:",
91
+ ...criteria.map(c => `- ${c.id}: ${c.description}`),
92
+ "",
93
+ "```" + RECEIPT_TAG,
94
+ JSON.stringify(example, null, 2),
95
+ "```",
96
+ ].join("\n");
97
+ }
98
+
99
+ /**
100
+ * The receipt parsed out of a PR body, or null with a reason.
101
+ *
102
+ * The shape is checked here and not left to `evaluate`, because a receipt is agent-written JSON: `[null]` or
103
+ * `["ac-1"]` parses happily and then throws on the first field read, which the tick catches and logs on every poll,
104
+ * so the entry never reaches a corrective round nor a diagnosable stop. Every element must be an object with a
105
+ * `criterion_id` string, which is the only field the rules identify an attestation by; `met` and `evidence` are
106
+ * judged by the rules, so a wrong value there is a deny with a code the agent can act on, not a parse failure.
107
+ */
108
+ export function parseReceipt(body) {
109
+ const matches = receiptMatches(body);
110
+ if (matches.length > 1) return { receipt: null, reason: "multiple completion receipt blocks in the PR body" };
111
+ const m = matches[0];
112
+ if (!m) return { receipt: null, reason: "no completion receipt block in the PR body" };
113
+ let r;
114
+ try { r = JSON.parse(m[1]); }
115
+ catch (e) { return { receipt: null, reason: `completion receipt is not valid JSON: ${e.message}` }; }
116
+ if (!r || typeof r !== "object" || Array.isArray(r)) return { receipt: null, reason: "completion receipt is not a JSON object" };
117
+ if (!Array.isArray(r.criteria_attestations)) return { receipt: null, reason: "criteria_attestations is not an array" };
118
+ const bad = r.criteria_attestations.findIndex(a => !isAttestation(a));
119
+ if (bad >= 0) return { receipt: null, reason: `criteria_attestations[${bad}] is not an attestation object with a criterion_id string` };
120
+ return { receipt: r, reason: null };
121
+ }
122
+
123
+ /** Migrate only the understood receipt transport. Evidence and all other JSON fields keep their exact values. */
124
+ export function normalizeReceipt(body, taskId = null) {
125
+ const text = String(body || "");
126
+ const matches = receiptMatches(text);
127
+ if (matches.length > 1) throw new Error("multiple completion receipt blocks; keep exactly one receipt");
128
+ if (!matches.length) return text;
129
+ const match = matches[0];
130
+ let json = match[1];
131
+ if (taskId !== null) {
132
+ let value;
133
+ try { value = JSON.parse(json); } catch { /* leave malformed evidence for the gate to refuse */ }
134
+ if (value && typeof value === "object" && !Array.isArray(value) && value.task_id === taskId) {
135
+ value.task_id = publicTaskId(taskId);
136
+ json = JSON.stringify(value, null, 2);
137
+ }
138
+ }
139
+ return text.slice(0, match.index) + "```" + RECEIPT_TAG + "\n" + json + "\n```" + text.slice(match.index + match[0].length);
140
+ }
141
+
142
+ /** One attestation: an object carrying the id the rules match it to a criterion by. */
143
+ const isAttestation = a => !!a && typeof a === "object" && !Array.isArray(a) && typeof a.criterion_id === "string";
144
+
145
+ /**
146
+ * The policy's rules, in the policy's order, with the policy's deny codes. Returns { allow, denials: [{ code,
147
+ * message }] }. `context` is the receipt plus the daemon's own observations; `limits` is the task's limits fragment.
148
+ */
149
+ export function evaluate({ context, limits, criteria }) {
150
+ const d = [];
151
+ // No criteria means nothing to judge, and nothing to judge is not a pass. Every criteria rule below is vacuously
152
+ // satisfied by an empty list (`missing`, `unknown`, `notMet` and `noEvidence` are all empty because there are no
153
+ // attestations to be any of those), so the gate allowed on the summary and the checks alone and the PR reached
154
+ // ready with not one acceptance criterion evaluated. Reachable through the snapshot taken from the PR branch, the
155
+ // copy the session could have edited: delete the `## Acceptance criteria` heading and the gate stops checking.
156
+ if (!Array.isArray(criteria) || criteria.length === 0) {
157
+ return { allow: false, denials: [{ code: "oap.criteria_incomplete", message: "The brief this gate reads has no acceptance criteria, so there is nothing to attest. Restore the `## Acceptance criteria` section of the PRD on this branch." }] };
158
+ }
159
+ // Malformed elements are dropped rather than dereferenced: `parseReceipt` refuses them before the gate ever gets
160
+ // here, and a caller that builds a context by hand must not be able to turn a bad field into a thrown exception.
161
+ // They still count as missing attestations, so nothing passes by being unreadable.
162
+ const atts = (Array.isArray(context.criteria_attestations) ? context.criteria_attestations : []).filter(isAttestation);
163
+ const missing = criteria.filter(c => !atts.some(a => a.criterion_id === c.id));
164
+ if (missing.length) d.push({ code: "oap.criteria_incomplete", message: `Missing attestations for: ${missing.map(c => c.id).join(", ")}. Submit one per criterion.` });
165
+ const noEvidence = atts.filter(a => typeof a.evidence !== "string" || !a.evidence.trim());
166
+ if (noEvidence.length) d.push({ code: "oap.evidence_missing", message: `Empty evidence on: ${noEvidence.map(a => a.criterion_id).join(", ")}. Give a file path, test name, command output or run id.` });
167
+ const notMet = atts.filter(a => a.met !== true);
168
+ if (notMet.length) d.push({ code: "oap.criteria_not_met", message: `Not met: ${notMet.map(a => a.criterion_id).join(", ")}. Resolve them or explain in the PR why the criterion is wrong.` });
169
+ const unknown = atts.filter(a => !criteria.some(c => c.id === a.criterion_id));
170
+ if (unknown.length) d.push({ code: "oap.criteria_unknown", message: `Attestations for ids not in the PRD: ${unknown.map(a => a.criterion_id).join(", ")}.` });
171
+ if (limits.require_summary) {
172
+ const words = String(context.summary || "").split(/\s+/).filter(Boolean).length;
173
+ if (words < (limits.min_summary_words ?? 0)) d.push({ code: "oap.summary_insufficient", message: `Summary has ${words} words; at least ${limits.min_summary_words} describing what changed and how it was verified.` });
174
+ }
175
+ if (limits.require_tests_passing && context.tests_passing !== true) d.push({ code: "oap.tests_not_passing", message: "Checks on the head commit are not all green. Fix them; the daemon observes CI itself." });
176
+ if (limits.scan_output && typeof context.output_content === "string") {
177
+ const output = context.output_content.toLowerCase();
178
+ const hit = (limits.blocked_patterns || []).slice(0, 100).find(p => output.includes(p.toLowerCase()));
179
+ if (hit) d.push({ code: "oap.blocked_pattern_detected", message: `The output contains "${hit}". Remove it.` });
180
+ }
181
+ return { allow: d.length === 0, denials: d };
182
+ }
183
+
184
+ /**
185
+ * A per-task passport in OAP shape, carrying this task's criteria as the policy's limits. APort's verify route
186
+ * accepts a passport in the request body ("local mode") and evaluates it as sent, without persisting a decision,
187
+ * which is what lets one hosted evaluator gate many tasks with different criteria.
188
+ */
189
+ export function taskPassport({ taskId, criteria, limits = DEFAULT_LIMITS, ownerId = "gallivant" }) {
190
+ const id = "ap_gallivant_" + createHash("sha1").update(taskId).digest("hex").slice(0, 24);
191
+ const { evaluator, api_url, on_unreachable, ...policyLimits } = limits;
192
+ return {
193
+ passport_id: id, agent_id: id, kind: "template", spec_version: "oap/1.0",
194
+ owner_id: ownerId, owner_type: "user", assurance_level: "L0", status: "active",
195
+ capabilities: [{ id: "deliverable.task.complete", params: {} }],
196
+ limits: { "deliverable.task.complete": { ...policyLimits, acceptance_criteria: criteria } },
197
+ regions: [], version: "1.0.0",
198
+ created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z",
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Ask APort's hosted evaluator. Returns { allow, denials, decisionId }. Throws on transport or non-200 errors so
204
+ * the caller can decide what an unreachable evaluator means.
205
+ */
206
+ const policyBody = (passport, context) => JSON.stringify({ passport, context: {
207
+ ...context, agent_id: passport.agent_id,
208
+ idempotency_key: `${context.task_id}-${createHash("sha1").update(JSON.stringify(context)).digest("hex").slice(0, 12)}`,
209
+ } });
210
+
211
+ /** Full output, in overlapping pieces. Each request retains the complete criteria, receipt and policy limits. */
212
+ function* policyBodies(passport, context) {
213
+ const body = policyBody(passport, context);
214
+ if (Buffer.byteLength(body) <= MAX_POLICY_BYTES) { yield body; return; }
215
+ const content = context.output_content;
216
+ if (typeof content !== "string" || !content.length) throw new Error("APort request exceeds the JSON size limit without splittable output content");
217
+ const patterns = passport.limits["deliverable.task.complete"]?.blocked_patterns ?? [];
218
+ // The hosted matcher uses case-insensitive substrings. Overlap every possible matching span; never drop a tail.
219
+ const overlap = Math.max(0, ...patterns.map(p => p.toLowerCase().length - 1));
220
+ const sha256 = createHash("sha256").update(content).digest("hex");
221
+ let start = 0, part = 0;
222
+ while (start < content.length) {
223
+ const partBody = end => policyBody(passport, { ...context, output_content: content.slice(start, end),
224
+ meta: { ...context.meta, output_scan: { sha256, part, offset: start } } });
225
+ // UTF-16 length bounds the search, actual serialized UTF-8 bytes decide what fits (quotes and control chars too).
226
+ let lo = start, hi = Math.min(content.length, start + MAX_POLICY_BYTES);
227
+ while (lo < hi) {
228
+ const mid = Math.ceil((lo + hi) / 2);
229
+ if (Buffer.byteLength(partBody(mid)) <= MAX_POLICY_BYTES) lo = mid;
230
+ else hi = mid - 1;
231
+ }
232
+ let end = lo;
233
+ if (end < content.length && /[\uDC00-\uDFFF]/.test(content[end]) && /[\uD800-\uDBFF]/.test(content[end - 1] ?? "")) end--;
234
+ if (end <= start || (end < content.length && end - start <= overlap)) {
235
+ throw new Error("APort request metadata or blocked-pattern overlap leaves no room for output; nothing was approved");
236
+ }
237
+ yield partBody(end);
238
+ if (end === content.length) return;
239
+ let next = end - overlap;
240
+ if (/[\uDC00-\uDFFF]/.test(content[next]) && /[\uD800-\uDBFF]/.test(content[next - 1] ?? "")) next--;
241
+ if (next <= start) throw new Error("APort output overlap cannot advance within the request size limit");
242
+ start = next;
243
+ part++;
244
+ }
245
+ }
246
+
247
+ export async function evaluateHosted({ apiUrl = "https://api.aport.io", apiKey = null, passport, context, fetch = globalThis.fetch }) {
248
+ const headers = { "Content-Type": "application/json" };
249
+ if (apiKey) headers["X-API-Key"] = apiKey;
250
+ const decisions = [];
251
+ let result;
252
+ for (const body of policyBodies(passport, context)) {
253
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/api/verify/policy/${POLICY_ID}`, {
254
+ method: "POST", headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), body,
255
+ });
256
+ const data = await res.json().catch(() => ({}));
257
+ if (res.status === 400 && data.error === "context_validation_failed") {
258
+ return { allow: false, decisionId: null, denials: (data.required_context_errors || [{ path: "context", message: data.message }]).map(e => ({ code: "oap.invalid_context", message: `${e.path}: ${e.message}` })) };
259
+ }
260
+ if (!res.ok || !data.decision) throw new Error(`aport verify ${res.status}: ${data.error || data.message || "no decision"}`);
261
+ const d = data.decision;
262
+ result = { allow: d.allow === true, decisionId: d.decision_id || null, denials: d.allow ? [] : (d.reasons || []).map(r => ({ code: r.code, message: r.message })) };
263
+ if (!result.allow) return result;
264
+ decisions.push(result.decisionId);
265
+ }
266
+ return { ...result, ...(decisions.length > 1 ? { decisionIds: decisions } : {}) };
267
+ }
268
+
269
+ /**
270
+ * The one decision procedure for both gates. The built-in rules run first and report every problem at once. When
271
+ * they pass and `limits.evaluator` is "aport", APort's evaluator confirms with a per-task passport; its deny wins.
272
+ * If APort cannot be reached, `limits.on_unreachable` decides: "wait" returns no decision (allow false, code
273
+ * `aport.unreachable`, `pending` true) so the caller tries again later, and "local" lets the built-in verdict stand
274
+ * with `source` saying so. Returns { allow, denials, decisionId, source, pending }.
275
+ */
276
+ export async function decide({ taskId, context, criteria, limits = DEFAULT_LIMITS, apiKey = null, fetch = globalThis.fetch, log = null, label = "gate" }) {
277
+ const local = evaluate({ context, limits, criteria });
278
+ if (!local.allow || limits.evaluator !== "aport") return { ...local, decisionId: null, source: "local" };
279
+ try {
280
+ const hosted = await evaluateHosted({ apiUrl: limits.api_url, apiKey, passport: taskPassport({ taskId, criteria, limits }), context, fetch });
281
+ return { ...hosted, source: "aport" };
282
+ } catch (e) {
283
+ if (limits.on_unreachable === "local") {
284
+ log?.warn(`${label}: APort unreachable (${e.message}); using the local verdict`);
285
+ return { ...local, decisionId: null, source: "local (aport unreachable)" };
286
+ }
287
+ log?.warn(`${label}: APort unreachable (${e.message}); no decision until it answers`);
288
+ return { allow: false, pending: true, denials: [{ code: "aport.unreachable", message: e.message }], decisionId: null, source: "aport (unreachable)" };
289
+ }
290
+ }
291
+
292
+ /**
293
+ * The completion gate for a PR. Builds the policy context from the PR body and the daemon's observations, then
294
+ * decides. `diff` is the PR diff for the output scan. Returns { allow, denials, decisionId, source, context }.
295
+ */
296
+ export async function gate({ taskId, prBody, criteria, checksGreen, diff = null, framework = null, limits = DEFAULT_LIMITS, apiKey = null, fetch = globalThis.fetch, log = null }) {
297
+ const { receipt, reason } = parseReceipt(prBody);
298
+ if (!receipt) return { allow: false, denials: [{ code: "oap.receipt_missing", message: reason }], decisionId: null, context: null, source: "local" };
299
+ const context = {
300
+ task_id: taskId,
301
+ output_type: receipt.output_type || "code",
302
+ summary: receipt.summary,
303
+ criteria_attestations: receipt.criteria_attestations,
304
+ tests_passing: checksGreen === true,
305
+ ...(typeof diff === "string" ? { output_content: diff } : {}),
306
+ meta: provenance(framework),
307
+ };
308
+ const result = await decide({ taskId, context, criteria, limits, apiKey, fetch, log, label: "completion gate" });
309
+ return { ...result, context };
310
+ }
311
+
312
+ /**
313
+ * The deny list as a prompt fragment for the fix round. Messages carry text from the PR body (criterion ids, the
314
+ * JSON parser's error), so they are quoted like everything else that came from GitHub.
315
+ */
316
+ export const denialsPrompt = denials => [
317
+ "The completion receipt on this PR was denied by the task-completion gate. The codes and messages, quoted:",
318
+ quote(denials.map(x => `${x.code}: ${x.message}`).join("\n")),
319
+ "Fix the code or the receipt, then push.",
320
+ ].join("\n");
321
+
322
+ // ---- The PRD contract as a deliverable ------------------------------------------------------------------------
323
+ // `gallivant ready` is a task-completion decision too: the deliverable is the PRD (output_type "document"), the
324
+ // criteria are the contract rules, the deterministic checker supplies met and evidence per rule, and the
325
+ // policy's own output scan catches placeholders. The status flips only on an allow, and the decision id is
326
+ // recorded in the PRD, so "ready" is an auditable APort decision rather than a local flag.
327
+
328
+ export const PRD_BLOCKED_PATTERNS = Object.freeze(["TODO", "TBD", "FIXME", "???", "lorem ipsum", "<who>", "<what>", "<why>", "<a starting state>", "<an action>", "<an observable result>"]);
329
+
330
+ export const PRD_LIMITS = Object.freeze({
331
+ evaluator: "aport", api_url: "https://api.aport.io", on_unreachable: "wait",
332
+ require_summary: true, min_summary_words: 8, require_tests_passing: false,
333
+ scan_output: true, blocked_patterns: PRD_BLOCKED_PATTERNS,
334
+ });
335
+
336
+ /**
337
+ * Criteria and context for a PRD-readiness decision, from the contract rules and the checker's verdict. The harness
338
+ * named in the brief is the provenance here: it is the one that will do the work this readiness decision authorises.
339
+ */
340
+ export function prdContract(prd, verdict, rules) {
341
+ const criteria = rules.map(r => ({ id: `prd-${r.id}`, description: r.text }));
342
+ const context = {
343
+ task_id: `prd:${prd.id}`,
344
+ output_type: "document",
345
+ summary: `PRD ${prd.id}: ${prd.title || "untitled"}. ${verdict.results.filter(r => r.ok).length} of ${verdict.results.length} contract rules pass.`,
346
+ criteria_attestations: verdict.results.map(r => ({ criterion_id: `prd-${r.id}`, met: r.ok === true, evidence: r.ok ? `rule ${r.id} verified by gallivant ready` : (r.detail || "failed") })),
347
+ output_content: prd.body,
348
+ meta: provenance(prd.harness || null),
349
+ };
350
+ return { criteria, context };
351
+ }
352
+
353
+ /** Decide readiness through the shared procedure. Returns { allow, denials, decisionId, source }. */
354
+ export function decidePrdReady({ prd, verdict, rules, limits = PRD_LIMITS, apiKey = null, fetch = globalThis.fetch, log = null }) {
355
+ const { criteria, context } = prdContract(prd, verdict, rules);
356
+ return decide({ taskId: context.task_id, context, criteria, limits, apiKey, fetch, log, label: "PRD gate" });
357
+ }
package/lib/events.mjs ADDED
@@ -0,0 +1,261 @@
1
+ // The rules for what counts as an event, written as pure functions over the data `gh` returns, so every rule in
2
+ // PRD 6.8 has a unit test and the tick only orchestrates.
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";
6
+ import { hasMarker } from "./public-format.mjs";
7
+ import { isAfter, isAtOrAfter, later, floorSecond } from "./time.mjs";
8
+
9
+ const FAILING = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
10
+ const GREEN = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
11
+ const COMMAND = /^\s*(?:\/fix\b|@(?:work|gallivant)\s+fix\b)/i;
12
+ export const isFixCommand = body => COMMAND.test(String(body ?? ""));
13
+
14
+ export function makeTrust(config, ownLogin) {
15
+ if (!ownLogin) throw new Error("trust rules need the daemon's own login");
16
+ const trusted = new Set(config.trusted_authors.map(normalizeLogin));
17
+ const reviewer = config.reviewer ? normalizeLogin(config.reviewer.login) : null;
18
+ const own = normalizeLogin(ownLogin);
19
+ return {
20
+ isTrusted: a => trusted.has(a.login) && !a.bot,
21
+ isReviewer: a => reviewer !== null && a.bot && a.login === reviewer,
22
+ isOwn: a => a.login === own,
23
+ };
24
+ }
25
+
26
+ export const isOwnComment = body => [ROUND_MARKER, NUDGE_MARKER, RESET_MARKER].some(m => hasMarker(body, m));
27
+ export const isRoundComment = body => hasMarker(body, ROUND_MARKER);
28
+ export const isResetComment = body => hasMarker(body, RESET_MARKER);
29
+
30
+ /** The key an event is remembered by in `cursor.dispatched`. Review, inline and issue-comment ids are three id spaces. */
31
+ export const eventKey = e => `${e.kind}:${e.id}`;
32
+
33
+ /**
34
+ * Qualifying events since the cursor. Returns normalised events and the newest event time. Failing checks count
35
+ * against `cursor.checks_sha`, the head whose failures were already dispatched, never against the push cursor.
36
+ *
37
+ * The cursor invariant, for everything that carries a GitHub timestamp: an item is new when it is at or after
38
+ * `cursor.since` and its key is not in `cursor.dispatched`. The comparison is inclusive because GitHub timestamps
39
+ * have one-second precision, so a second comment written in the same second as the one that moved the cursor is
40
+ * not "after" it; `dispatched` holds the keys already sent for exactly that second and is what stops a replay.
41
+ * `tick` writes the pair together and resets `dispatched` whenever the second changes, so the list stays short.
42
+ *
43
+ * Editing an item later does not make it new again: the comparison is on `created_at`, never on `updated_at`.
44
+ */
45
+ export function deriveEvents({ pr, reviews, inline, comments = [], entry, trust }) {
46
+ const since = entry.cursor.since;
47
+ const dispatched = new Set(entry.cursor.dispatched || []);
48
+ const isNew = (iso, kind, id) => isAtOrAfter(iso, since) && !dispatched.has(`${kind}:${id}`);
49
+ const out = [];
50
+
51
+ // Reviews our own login submitted, so an inline note hanging off one is recognised as the harness's own comment on
52
+ // 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));
54
+
55
+ const inlineByReview = new Map();
56
+ for (const c of inline) {
57
+ const rid = c.pull_request_review_id;
58
+ if (!inlineByReview.has(rid)) inlineByReview.set(rid, []);
59
+ inlineByReview.get(rid).push(c);
60
+ }
61
+
62
+ 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);
66
+ const qualifies = (trust.isReviewer(a) && (r.state === "CHANGES_REQUESTED" || (r.state === "COMMENTED" && attached.length > 0)))
67
+ || (trust.isTrusted(a) && r.state === "CHANGES_REQUESTED");
68
+ 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 });
70
+ for (const c of attached) out.push(inlineEvent(c, a.login));
71
+ inlineByReview.delete(r.id);
72
+ }
73
+
74
+ // Inline comments not attached to a dispatched review: standalone trusted comments, or later comments on a review
75
+ // that was already handled. Replies by our own login are the harness's thread replies.
76
+ for (const list of inlineByReview.values()) {
77
+ for (const c of list) {
78
+ if (!isNew(c.created_at, "inline", c.id)) continue;
79
+ const a = actor(c.user);
80
+ // Our own login writes in two capacities and they have to be told apart, because on a one-operator install the
81
+ // operator's login IS the daemon's: config.md says `trusted_authors` is where your own login belongs. The
82
+ // operator leaving a standalone inline comment is the main feedback channel and must dispatch. The harness
83
+ // answering the fix prompt's "reply to each inline thread" is not feedback, and it arrives two ways: as a reply
84
+ // carrying `in_reply_to_id`, and as inline notes hanging off a review of its own, which is what
85
+ // `gh pr review --comment` produces. Both are the daemon's own work explaining itself, and dispatching a round
86
+ // 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;
88
+ if (!(trust.isTrusted(a) || trust.isReviewer(a))) continue;
89
+ out.push(inlineEvent(c, a.login));
90
+ }
91
+ }
92
+
93
+ for (const c of comments) {
94
+ if (!isNew(c.created_at, "command", c.id) || isOwnComment(c.body)) continue;
95
+ const a = actor(c.user);
96
+ 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 });
98
+ }
99
+
100
+ if (pr.headRefOid && pr.headRefOid !== entry.cursor.checks_sha) {
101
+ const seen = new Set();
102
+ for (const c of failingChecks(pr.statusCheckRollup)) {
103
+ if (seen.has(c.name)) continue;
104
+ seen.add(c.name);
105
+ out.push({ kind: "check", ...c, at: c.at || pr.createdAt });
106
+ }
107
+ }
108
+
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 };
111
+ }
112
+
113
+ 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,
116
+ });
117
+
118
+ /** CheckRun and StatusContext entries that failed, with the Actions run id when the URL carries one. */
119
+ 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;
129
+ }
130
+
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));
137
+ }
138
+
139
+ /**
140
+ * The reviewer's verdict since the last request: "none" when no reviewer is configured, "waiting" when it has not
141
+ * reviewed since `sinceIso`, "blocking" when the newest review requests changes or has a blocking inline comment,
142
+ * otherwise "clear". A review that names an older commit than the head does not clear the head.
143
+ *
144
+ * The boundary is inclusive, and `dispatched` deduplicates it, the same pair `deriveEvents` uses and for the same
145
+ * reason: GitHub stamps to the second, so a review submitted in the very second the re-review was requested is not
146
+ * "after" it. A blocking review is dispatched as an event and comes back, but a clear one never is, so under a
147
+ * strict comparison it was excluded permanently and the entry waited until it stopped as `reviewer-silent`.
148
+ * `dispatched` holds the event keys already handled at exactly the cursor's instant, so a review the daemon is
149
+ * already fixing cannot also read as the reviewer clearing this head.
150
+ */
151
+ export function reviewerVerdict({ reviews, inline, comments = [], config, trust, sinceIso, headSha = null, dispatched = [] }) {
152
+ if (!config.reviewer) return { verdict: "none", note: "no reviewer configured" };
153
+ 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 })));
155
+
156
+ // A verdict the reviewer posted as a plain comment rather than as a review, normalised into the shape above so the
157
+ // head guard and the blocking rules below judge it identically. Everything after this line is unchanged.
158
+ //
159
+ // This exists because of how Codex answers. Findings arrive as a REVIEW, which carries `commit_id`. A clean verdict
160
+ // arrives as an issue COMMENT, which carries none: "Codex Review: Didn't find any major issues", with the head named
161
+ // in its body as `Reviewed commit: <sha>`. So a PR that passed review looked identical to one the reviewer had never
162
+ // answered: the newest review stayed the one from two pushes ago, the head guard said "reviewer looked at <old>, head
163
+ // is <new>", and the entry waited out `stall_hours` before stopping as `reviewer-silent`. It reported a silent
164
+ // reviewer that had in fact approved the current head three minutes after being asked.
165
+ //
166
+ // The admission rule is deliberately narrow, and the narrowness is the point. A comment qualifies only when it names
167
+ // a commit in its body, which `reviewedCommit` already parses and which the existing guard then checks against the
168
+ // head. Clean is never INFERRED from the absence of findings, because the reviewer says other things in comments and
169
+ // 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 }));
173
+
174
+ const mine = [...fromReviews, ...fromComments].sort((a, b) => Date.parse(b.submitted_at) - Date.parse(a.submitted_at));
175
+ if (!mine.length) return { verdict: "waiting", note: "waiting on reviewer" };
176
+ const newest = mine[0];
177
+ // Which commit was reviewed, from the reviewer's own body first and its `commit_id` second. A review that names no
178
+ // commit at all is no answer about this head: `commit_id` is null once the reviewed commit is gone from the
179
+ // repository, which is what a force-push plus garbage collection leaves, and a falsy value used to skip the guard
180
+ // 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;
182
+ 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)}` };
184
+ if (newest.state === "CHANGES_REQUESTED") return { verdict: "blocking", note: "changes requested" };
185
+ const re = new RegExp(config.reviewer.blocking_pattern);
186
+ if (re.test(newest.body || "")) return { verdict: "blocking", note: "blocking finding in reviewer message" };
187
+ // Inline findings belong to a REVIEW. A comment-borne verdict has no review to own them, and `newest.id` is a
188
+ // 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 || ""));
190
+ if (hits.length) return { verdict: "blocking", note: `${hits.length} blocking finding${hits.length > 1 ? "s" : ""}` };
191
+ return { verdict: "clear", note: "no blocking findings" };
192
+ }
193
+
194
+ /**
195
+ * The commit a reviewer says it looked at, read out of its own body with the pattern that reviewer uses.
196
+ *
197
+ * The pattern is configuration, not a constant, for the same reason `rereview_comment` is: this ships configured for
198
+ * Codex and must not be built around it. A reviewer whose wording differs sets `reviewer.reviewed_commit_pattern` and
199
+ * needs no code change. One that never names a commit leaves the default unmatched and simply cannot clear a head from
200
+ * a plain comment, which is a narrower loop rather than a broken one.
201
+ *
202
+ * A pattern with no capture group, or one that captures something that is not a sha, yields null rather than throwing:
203
+ * an unparseable answer is no answer about this head, which is what the guard above already does with a missing one.
204
+ */
205
+ export const reviewedCommit = (body, pattern) => {
206
+ if (!pattern) return null;
207
+ let m;
208
+ try { m = String(body || "").match(new RegExp(pattern, "i")); } catch { return null; }
209
+ const sha = m?.[1];
210
+ return typeof sha === "string" && /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null;
211
+ };
212
+
213
+ /**
214
+ * When the reviewer was last asked to look at a head. Three timers measure from it: the verdict's boundary above, the
215
+ * nudge after `rereview_minutes` and the `reviewer-silent` stop after `stall_hours`. So it must be the moment the ask
216
+ * happened, and every writer of `rereview.at` that has a head in hand comes through here for the one answer.
217
+ *
218
+ * The best available reading of "when this head arrived" is its committer date, which is when the commit was made and
219
+ * not when it reached the branch. GitHub does not report the second, and the two differ by however long the commit sat
220
+ * locally: `git commit` on Monday and `git push` on Thursday, or a merge whose tip carries the merged commit's date. A
221
+ * clock older than `stall_hours` stopped the entry as `reviewer-silent` on the very tick that set it, with the reviewer
222
+ * never asked once, and one older than `rereview_minutes` posted the nudge before the first ask.
223
+ *
224
+ * So the committer date is used only as far back as the nudge window: `floorAt` is the current time, and no clock is
225
+ * set further back than `rereview_minutes` before it. Never one before the previous ask either, since the reviewer
226
+ * cannot have been asked earlier than it already was; `previousAsk` is that value and is null when there was none.
227
+ *
228
+ * Pure, and here rather than in the tick, because the daemon is not the only writer. `reconcile` sets the same field on
229
+ * a recovered entry and used to set it raw, so a PR whose last round comment was older than `stall_hours` was stopped
230
+ * as `reviewer-silent` on the first tick after a crash recovery, for silence that accrued while no daemon was running
231
+ * to ask. The floor gives a recovered entry the same grace a freshly nudged one gets.
232
+ */
233
+ export function reviewerClock({ config, pushedAt, previousAsk = null, floorAt }) {
234
+ const floor = new Date(Date.parse(floorAt) - config.rereview_minutes * 60_000).toISOString();
235
+ return floorSecond(later(later(pushedAt, floor), previousAsk));
236
+ }
237
+
238
+ 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));
241
+
242
+ /**
243
+ * The iteration number: the daemon's own round comments since its last reset comment. Every round posts one
244
+ * (pushed rounds carry the re-review request, receipt-only rounds a note), and `register` posts a reset, so the
245
+ * count on GitHub always equals the counter in the registry.
246
+ */
247
+ export function countRounds(issueComments, ownLogin) {
248
+ const mine = ownComments(issueComments, ownLogin);
249
+ const lastReset = mine.map((c, i) => (isResetComment(c.body) ? i : -1)).filter(i => i >= 0).at(-1) ?? -1;
250
+ return mine.slice(lastReset + 1).filter(c => isRoundComment(c.body)).length;
251
+ }
252
+
253
+ export const lastOwnCommentAt = (issueComments, ownLogin) => ownComments(issueComments, ownLogin)
254
+ .filter(c => isOwnComment(c.body)).map(c => c.created_at ?? c.createdAt).at(-1) ?? null;
255
+
256
+ /** Whether the daemon already posted a comment carrying `marker` after `sinceIso`. */
257
+ export const hasOwnComment = (issueComments, ownLogin, marker, sinceIso) => ownComments(issueComments, ownLogin)
258
+ .some(c => hasMarker(c.body, marker) && isAfter(c.created_at ?? c.createdAt, sinceIso));
259
+
260
+ /** Only the lines a diff adds, so the completion gate never judges context or removed text. */
261
+ export const addedLines = diff => String(diff || "").split("\n").filter(l => l.startsWith("+") && !l.startsWith("+++")).map(l => l.slice(1)).join("\n");
@@ -0,0 +1,31 @@
1
+ import { accessSync, constants, statSync } from "node:fs";
2
+ import { delimiter, join, isAbsolute } from "node:path";
3
+ import { execFileSync } from "node:child_process";
4
+
5
+ export function findExecutable(name, env = process.env) {
6
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) return null;
7
+ for (const directory of (env.PATH || "").split(delimiter)) {
8
+ if (!isAbsolute(directory)) continue;
9
+ const path = join(directory, name);
10
+ try { accessSync(path, constants.X_OK); if (statSync(path).isFile()) return path; } catch {}
11
+ }
12
+ // Version managers may be initialized only by the operator's login shell.
13
+ // Linux must not depend on zsh or its non-portable `whence` builtin.
14
+ const shells = [...new Set([env.SHELL, "/bin/sh"].filter(p => p && isAbsolute(p)))];
15
+ // POSIX shells need a usable search path even to locate themselves. Do not
16
+ // introduce the working directory through an empty/relative PATH component.
17
+ const fallbackEnv = { ...env, PATH: (env.PATH || "").split(delimiter).filter(isAbsolute).join(delimiter) || "/usr/bin:/bin" };
18
+ for (const shell of shells) {
19
+ try {
20
+ // The portable fallback must not parse an operator's potentially
21
+ // non-POSIX login profile. Only the explicitly selected shell is a login.
22
+ const path = execFileSync(shell, [shell === "/bin/sh" ? "-c" : "-lc", `command -v ${name}`], {
23
+ env: fallbackEnv, encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"],
24
+ }).trim();
25
+ if (!isAbsolute(path)) continue;
26
+ accessSync(path, constants.X_OK);
27
+ if (statSync(path).isFile()) return path;
28
+ } catch {}
29
+ }
30
+ return null;
31
+ }