opencode-feature-factory 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +877 -0
  3. package/assets/agent/backend-builder.md +73 -0
  4. package/assets/agent/codebase-researcher.md +56 -0
  5. package/assets/agent/design-interpreter.md +37 -0
  6. package/assets/agent/frontend-builder.md +74 -0
  7. package/assets/agent/implementation-validator.md +73 -0
  8. package/assets/agent/security-reviewer.md +60 -0
  9. package/assets/agent/spec-writer.md +94 -0
  10. package/assets/agent/story-reader.md +38 -0
  11. package/assets/agent/story-writer.md +39 -0
  12. package/assets/agent/test-verifier.md +73 -0
  13. package/assets/agent/work-decomposer.md +102 -0
  14. package/assets/agent/work-reviewer.md +77 -0
  15. package/assets/command/feature.md +68 -0
  16. package/assets/skills/feature/SCHEMA.md +990 -0
  17. package/assets/skills/feature/SKILL.md +620 -0
  18. package/dist/tui.js +3641 -0
  19. package/package.json +65 -0
  20. package/src/cleanup-sweep-command.js +75 -0
  21. package/src/cleanup-sweep-eligibility.js +581 -0
  22. package/src/cleanup-sweep-output.js +139 -0
  23. package/src/cleanup-sweep-report.js +548 -0
  24. package/src/cleanup-sweep.js +546 -0
  25. package/src/cli-output.js +251 -0
  26. package/src/cli.js +1152 -0
  27. package/src/config.js +231 -0
  28. package/src/cost-attribution.js +327 -0
  29. package/src/cost-report.js +185 -0
  30. package/src/detached-log-supervisor.js +178 -0
  31. package/src/doctor.js +598 -0
  32. package/src/env-snapshot.js +211 -0
  33. package/src/factory-diagnostics.js +429 -0
  34. package/src/factory-paths.js +40 -0
  35. package/src/factory.js +4769 -0
  36. package/src/feature-command-payload.js +378 -0
  37. package/src/git.js +110 -0
  38. package/src/github.js +252 -0
  39. package/src/hardening/atomic-write.js +954 -0
  40. package/src/hardening/line-output.js +139 -0
  41. package/src/hardening/output-policy.js +365 -0
  42. package/src/hardening/process-verification.js +542 -0
  43. package/src/hardening/sensitive-data.js +341 -0
  44. package/src/hardening/terminal-encoding.js +224 -0
  45. package/src/plugin.js +246 -0
  46. package/src/post-pr-ci.js +754 -0
  47. package/src/process-evidence.js +1139 -0
  48. package/src/refs.js +144 -0
  49. package/src/run-state.js +2411 -0
  50. package/src/steering-conflicts.js +77 -0
  51. package/src/telemetry.js +419 -0
  52. package/src/tui-data.js +499 -0
  53. package/src/tui-rendering.js +148 -0
  54. package/src/utils.js +35 -0
  55. package/src/validate.js +1655 -0
  56. package/src/worktrees.js +56 -0
@@ -0,0 +1,754 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { link, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { hostname } from "node:os";
5
+ import { dirname, join, posix, relative, resolve } from "node:path";
6
+ import { types as utilTypes } from "node:util";
7
+
8
+ export const GITHUB_LIMITS = Object.freeze({
9
+ switch: Object.freeze({ timeoutMs: 10_000, stdoutCap: 16 * 1024, stderrCap: 16 * 1024 }),
10
+ verdict: Object.freeze({ timeoutMs: 30_000, stdoutCap: 1024 * 1024, stderrCap: 64 * 1024 }),
11
+ reviewer: Object.freeze({ timeoutMs: 30_000, stdoutCap: 1024 * 1024, stderrCap: 64 * 1024 }),
12
+ ownershipPage: Object.freeze({ timeoutMs: 20_000, stdoutCap: 512 * 1024, stderrCap: 64 * 1024 }),
13
+ });
14
+
15
+ const CHECK_PENDING = new Set(["QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED"]);
16
+ const CHECK_PASS = new Set(["SUCCESS", "NEUTRAL", "SKIPPED"]);
17
+ const CHECK_RED = new Set(["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STARTUP_FAILURE"]);
18
+ const HTTP_TRANSIENT = new Set([408, 429, 500, 502, 503, 504]);
19
+ const TEST_PREFIXES = ["test/", ".github/workflows/"];
20
+ const UNSAFE_RUNTIME_PATHS = new Set([
21
+ "package.json", "package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb",
22
+ ".nvmrc", ".node-version", ".npmrc", ".yarnrc", ".yarnrc.yml", "Dockerfile", "docker-compose.yml", "docker-compose.yaml",
23
+ ]);
24
+ const STACK_ROUTES = Object.freeze({ backend: "backend-builder", frontend: "frontend-builder" });
25
+ const OWNERSHIP_REASONS = new Set(["review-changes-requested", "check-owner-ambiguous", "check-owner-conflict", "changed-files-incomplete",
26
+ "unsafe-path-or-change", "check-file-conflict", "unknown-slice-stack", "path-owner-ambiguous", "integration-fallback", "check-slice-id", "changed-files"]);
27
+ const PANEL_VERDICTS = Object.freeze({ validator: new Set(["GO", "GO-WITH-NITS", "NO-GO"]), security: new Set(["PASS", "BLOCK"]) });
28
+ const AFFECTED_LIMITS = Object.freeze({ depth: 32, occurrences: 8_192, entries: 8_192, arrayLength: 4_096, stringBytes: 4_096, totalStringBytes: 1_048_576, emittedBytes: 1_048_576 });
29
+ export const EMPTY_AFFECTED_PATHS_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
30
+
31
+ export class PostPrCiError extends Error {
32
+ constructor(errorClass, message, options = {}) {
33
+ super(message, options.cause ? { cause: options.cause } : undefined);
34
+ this.name = "PostPrCiError";
35
+ this.errorClass = errorClass;
36
+ this.transient = Boolean(options.transient);
37
+ this.rateLimited = Boolean(options.rateLimited);
38
+ this.exitCode = Number.isInteger(options.exitCode) ? options.exitCode : null;
39
+ this.retryAfterMs = Number.isInteger(options.retryAfterMs) && options.retryAfterMs >= 0 ? options.retryAfterMs : 0;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Inspect a successful panel transport without reading inherited properties or
45
+ * invoking accessors/proxy traps. `absent` is intentionally distinct from a
46
+ * present malformed value because only the former has an unknown dispatch
47
+ * outcome.
48
+ */
49
+ export function inspectPanelRunnerResult(outer, activity) {
50
+ panelVocabulary(activity);
51
+ if (!isReflectableRecord(outer)) return { disposition: "absent" };
52
+ let descriptor;
53
+ try { descriptor = Object.getOwnPropertyDescriptor(outer, "result"); } catch { return { disposition: "absent" }; }
54
+ if (descriptor === undefined) return { disposition: "absent" };
55
+ if (!("value" in descriptor)) return { disposition: "malformed", issue: "non-object" };
56
+ const classified = classifyPanelResult(descriptor.value, activity);
57
+ return classified.ok ? { disposition: "valid", ...classified } : { disposition: "malformed", issue: classified.issue };
58
+ }
59
+
60
+ export function inspectPanelRunnerReturn(outer, activity) {
61
+ panelVocabulary(activity);
62
+ if (!isReflectableRecord(outer)) return { disposition: "transport-unknown" };
63
+ try {
64
+ const started = Object.getOwnPropertyDescriptor(outer, "started");
65
+ const exitCode = Object.getOwnPropertyDescriptor(outer, "exit_code");
66
+ const signal = Object.getOwnPropertyDescriptor(outer, "signal");
67
+ if (!started || !("value" in started) || started.value !== true || !exitCode || !("value" in exitCode) || exitCode.value !== 0 || !signal || !("value" in signal) || signal.value !== null) return { disposition: "transport-unknown" };
68
+ } catch { return { disposition: "transport-unknown" }; }
69
+ return inspectPanelRunnerResult(outer, activity);
70
+ }
71
+
72
+ /** Side-effect-free own-descriptor classifier for validator/security results. */
73
+ export function classifyPanelResult(value, activity) {
74
+ const vocabulary = panelVocabulary(activity);
75
+ if (!isAdmissibleRecord(value)) return { ok: false, issue: "non-object" };
76
+ let keys;
77
+ try { keys = Reflect.ownKeys(value); } catch { return { ok: false, issue: "non-object" }; }
78
+ const hasVerdict = keys.some((key) => key === "verdict");
79
+ if (!hasVerdict) return { ok: false, issue: "missing-verdict" };
80
+ let verdictDescriptor;
81
+ let affectedDescriptor;
82
+ try {
83
+ for (const key of keys) {
84
+ if (typeof key === "symbol" || key !== "verdict" && key !== "affected_paths") return { ok: false, issue: "unexpected-result-keys" };
85
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
86
+ if (!descriptor || !("value" in descriptor)) return { ok: false, issue: "unexpected-result-keys" };
87
+ if (key === "verdict") verdictDescriptor = descriptor;
88
+ else affectedDescriptor = descriptor;
89
+ }
90
+ } catch { return { ok: false, issue: "unexpected-result-keys" }; }
91
+ if (typeof verdictDescriptor?.value !== "string" || !vocabulary.has(verdictDescriptor.value)) return { ok: false, issue: "invalid-verdict" };
92
+ return { ok: true, verdict: verdictDescriptor.value, affectedDescriptor: affectedDescriptor ?? null };
93
+ }
94
+
95
+ /**
96
+ * Copy an untrusted affected_paths value into a bounded primitive graph. The
97
+ * returned graph has no accessors, proxies, prototypes with behavior, or
98
+ * callable JSON hooks and is therefore safe to serialize.
99
+ */
100
+ export function snapshotPanelAffectedValue(descriptor) {
101
+ if (descriptor === null || descriptor === undefined) return { ok: false, category: "missing-paths" };
102
+ if (!("value" in descriptor)) return { ok: false, category: "missing-paths" };
103
+ const counters = { occurrences: 0, entries: 0, stringBytes: 0 };
104
+ try {
105
+ const value = copyAffectedValue(descriptor.value, 0, new Set(), counters);
106
+ const json = emitAffectedJson(value, { pretty: true, byteLimit: AFFECTED_LIMITS.emittedBytes });
107
+ return { ok: true, value, json };
108
+ } catch { return { ok: false, category: "missing-paths" }; }
109
+ }
110
+
111
+ /** Validate/canonicalize a snapshotted affected_paths value. */
112
+ export function canonicalizePanelAffectedPaths(value, worktree) {
113
+ if (!Array.isArray(value)) return affectedClassification("invalid-paths", []);
114
+ if (value.length === 0) return affectedClassification("empty-paths", []);
115
+ const canonical = [];
116
+ try {
117
+ for (const item of value) {
118
+ if (typeof item !== "string") throw new Error("non-string path");
119
+ canonical.push(canonicalAffectedPath(item, worktree));
120
+ }
121
+ } catch { return affectedClassification("invalid-paths", []); }
122
+ const paths = [...new Set(canonical)].sort(byteSort);
123
+ return { ok: true, category: null, paths, hash: affectedPathsHash(paths) };
124
+ }
125
+
126
+ export function affectedPathsHash(paths) {
127
+ const bytes = Buffer.from(emitAffectedJson(paths, { pretty: false, byteLimit: AFFECTED_LIMITS.emittedBytes }), "utf8");
128
+ return createHash("sha256").update(bytes).digest("hex");
129
+ }
130
+
131
+ export function emitAffectedJson(value, { pretty = true, byteLimit = AFFECTED_LIMITS.emittedBytes } = {}) {
132
+ let output = ""; let bytes = 0;
133
+ const append = (chunk) => { bytes += Buffer.byteLength(chunk, "utf8"); if (bytes > byteLimit) throw new Error("affected JSON limit"); output += chunk; };
134
+ const write = (item, depth) => {
135
+ if (item === null) return append("null");
136
+ if (typeof item === "boolean") return append(item ? "true" : "false");
137
+ if (typeof item === "number") { if (!Number.isFinite(item)) throw new Error("unsupported emitted number"); return append(Object.is(item, -0) ? "0" : String(item)); }
138
+ if (typeof item === "string") return append(quotedJsonString(item));
139
+ const indent = pretty ? " ".repeat(depth) : ""; const childIndent = pretty ? " ".repeat(depth + 1) : ""; const separator = pretty ? ",\n" : ",";
140
+ if (Array.isArray(item)) {
141
+ append("["); if (!item.length) return append("]"); if (pretty) append("\n");
142
+ item.forEach((child, index) => { if (pretty) append(childIndent); write(child, depth + 1); if (index + 1 < item.length) append(separator); });
143
+ if (pretty) append(`\n${indent}`); return append("]");
144
+ }
145
+ if (!item || typeof item !== "object") throw new Error("unsupported emitted value");
146
+ const keys = Object.keys(item); append("{"); if (!keys.length) return append("}"); if (pretty) append("\n");
147
+ keys.forEach((key, index) => { if (pretty) append(childIndent); append(quotedJsonString(key)); append(pretty ? ": " : ":"); write(item[key], depth + 1); if (index + 1 < keys.length) append(separator); });
148
+ if (pretty) append(`\n${indent}`); append("}");
149
+ };
150
+ write(value, 0); return output;
151
+ }
152
+
153
+ function quotedJsonString(value) {
154
+ let output = '"';
155
+ for (let index = 0; index < value.length; index += 1) {
156
+ const code = value.charCodeAt(index); const character = value[index];
157
+ if (character === '"' || character === "\\") output += `\\${character}`;
158
+ else if (character === "\b") output += "\\b";
159
+ else if (character === "\f") output += "\\f";
160
+ else if (character === "\n") output += "\\n";
161
+ else if (character === "\r") output += "\\r";
162
+ else if (character === "\t") output += "\\t";
163
+ else if (code < 0x20) output += `\\u${code.toString(16).padStart(4, "0")}`;
164
+ else output += character;
165
+ }
166
+ return `${output}"`;
167
+ }
168
+
169
+ function affectedClassification(category, paths) { return { ok: false, category, paths, hash: affectedPathsHash(paths) }; }
170
+ function byteSort(left, right) { return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); }
171
+ function panelVocabulary(activity) { const value = PANEL_VERDICTS[activity]; if (!value) throw new Error("panel activity must be validator or security"); return value; }
172
+ function isReflectableRecord(value) { return value !== null && typeof value === "object" && !utilTypes.isProxy(value); }
173
+ function isAdmissibleRecord(value) {
174
+ if (!isReflectableRecord(value) || Array.isArray(value)) return false;
175
+ try { const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; } catch { return false; }
176
+ }
177
+ function hasWellFormedUnicode(value) { return !/(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/u.test(value); }
178
+ function copyString(value, counters) {
179
+ if (!hasWellFormedUnicode(value)) throw new Error("malformed Unicode");
180
+ const bytes = Buffer.byteLength(value, "utf8");
181
+ if (bytes > AFFECTED_LIMITS.stringBytes || counters.stringBytes + bytes > AFFECTED_LIMITS.totalStringBytes) throw new Error("string limit");
182
+ counters.stringBytes += bytes;
183
+ return value;
184
+ }
185
+ function copyAffectedValue(value, depth, ancestors, counters) {
186
+ if (value === null || typeof value === "boolean") return value;
187
+ if (typeof value === "string") return copyString(value, counters);
188
+ if (typeof value === "number") { if (!Number.isFinite(value)) throw new Error("non-finite"); return Object.is(value, -0) ? 0 : value; }
189
+ if (typeof value !== "object" || utilTypes.isProxy(value)) throw new Error("unsupported value");
190
+ if (depth >= AFFECTED_LIMITS.depth) throw new Error("depth limit");
191
+ if (++counters.occurrences > AFFECTED_LIMITS.occurrences) throw new Error("occurrence limit");
192
+ let prototype;
193
+ let keys;
194
+ try { prototype = Object.getPrototypeOf(value); keys = Reflect.ownKeys(value); } catch { throw new Error("reflection failed"); }
195
+ const array = Array.isArray(value);
196
+ if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw new Error("unsupported prototype");
197
+ if (ancestors.has(value)) throw new Error("cycle");
198
+ ancestors.add(value);
199
+ try {
200
+ if (array) {
201
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
202
+ const length = lengthDescriptor?.value;
203
+ if (!Number.isInteger(length) || length < 0 || length > AFFECTED_LIMITS.arrayLength || keys.length !== length + 1) throw new Error("sparse or extra array key");
204
+ const result = new Array(length);
205
+ for (let index = 0; index < length; index += 1) {
206
+ const key = String(index);
207
+ if (keys[index] !== key && !keys.includes(key)) throw new Error("sparse array");
208
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
209
+ if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true || ++counters.entries > AFFECTED_LIMITS.entries) throw new Error("invalid array descriptor");
210
+ result[index] = copyAffectedValue(descriptor.value, depth + 1, ancestors, counters);
211
+ }
212
+ return result;
213
+ }
214
+ if (keys.some((key) => typeof key === "symbol")) throw new Error("symbol key");
215
+ const names = keys.sort(byteSort);
216
+ const result = Object.create(null);
217
+ for (const key of names) {
218
+ copyString(key, counters);
219
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
220
+ if (!descriptor || !("value" in descriptor) || descriptor.enumerable !== true || ++counters.entries > AFFECTED_LIMITS.entries) throw new Error("invalid record descriptor");
221
+ result[key] = copyAffectedValue(descriptor.value, depth + 1, ancestors, counters);
222
+ }
223
+ return result;
224
+ } finally { ancestors.delete(value); }
225
+ }
226
+ function canonicalAffectedPath(value, worktree) {
227
+ if (!hasWellFormedUnicode(value)) throw new Error("malformed path Unicode");
228
+ const normalized = value.normalize("NFC");
229
+ if (!normalized || Buffer.byteLength(normalized, "utf8") > 4_096 || /[\0-\x1f\x7f\\]/u.test(normalized) || normalized.startsWith("/") || /^[A-Za-z]:/u.test(normalized)) throw new Error("invalid path");
230
+ const segments = normalized.split("/");
231
+ if (segments.some((segment) => !segment || segment === "." || segment === "..") || posix.normalize(normalized) !== normalized) throw new Error("invalid path segments");
232
+ const root = resolve(worktree);
233
+ const candidate = resolve(root, normalized);
234
+ const contained = relative(root, candidate);
235
+ if (!contained || contained.startsWith("..") || contained.startsWith("/") || contained.includes("\\")) throw new Error("path escapes worktree");
236
+ return normalized;
237
+ }
238
+
239
+ export function normalizeCheck(entry) {
240
+ if (!entry || typeof entry !== "object") return "indeterminate";
241
+ const kind = upper(entry.__typename || entry.type);
242
+ if (kind === "STATUSCONTEXT" || Object.hasOwn(entry, "state")) {
243
+ const state = upper(entry.state);
244
+ if (state === "SUCCESS") return "pass";
245
+ if (state === "FAILURE" || state === "ERROR") return "red";
246
+ if (state === "PENDING" || state === "EXPECTED") return "pending";
247
+ return "indeterminate";
248
+ }
249
+ const status = upper(entry.status);
250
+ const conclusion = upper(entry.conclusion);
251
+ if (status && status !== "COMPLETED") return CHECK_PENDING.has(status) ? "pending" : "indeterminate";
252
+ if (CHECK_PASS.has(conclusion)) return "pass";
253
+ if (CHECK_RED.has(conclusion)) return "red";
254
+ return "indeterminate";
255
+ }
256
+
257
+ export function normalizeChecks(entries, options = {}) {
258
+ if (!Array.isArray(entries)) throw protocol("statusCheckRollup must be an array");
259
+ if (entries.length > 200) throw protocol("statusCheckRollup exceeds 200 entries");
260
+ const elapsedMs = nonNegativeInteger(options.elapsedMs ?? 0, "elapsedMs");
261
+ const graceMs = nonNegativeInteger(options.graceMs ?? 300_000, "graceMs");
262
+ const checks = entries.map((entry, index) => ({
263
+ index,
264
+ verdict: normalizeCheck(entry),
265
+ name: encodeUntrustedMetadata(checkName(entry)),
266
+ }));
267
+ if (checks.length === 0) return { verdict: elapsedMs < graceMs ? "not_started" : "not_applicable", checks };
268
+ const verdicts = new Set(checks.map((check) => check.verdict));
269
+ const verdict = verdicts.has("red") ? "red"
270
+ : verdicts.has("pending") ? "pending"
271
+ : verdicts.has("indeterminate") ? "indeterminate" : "pass";
272
+ return { verdict, checks };
273
+ }
274
+
275
+ export function normalizeReview(input = {}) {
276
+ const reviews = input.reviews === undefined ? [] : input.reviews;
277
+ if (!Array.isArray(reviews)) throw protocol("reviews must be an array");
278
+ if (reviews.length > 200) throw protocol("reviews exceeds 200 entries");
279
+ if (input.isDraft !== undefined && typeof input.isDraft !== "boolean") throw protocol("isDraft must be a boolean");
280
+ const expectedHeadSha = fullSha(input.expectedHeadSha, "expectedHeadSha");
281
+ const reviewerLogin = optionalLogin(input.reviewerLogin);
282
+ const required = Boolean(input.required || reviewerLogin);
283
+ const latest = latestApplicableReviews(reviews, expectedHeadSha);
284
+ const changeRequest = [...latest.values()].filter((review) => review.state === "CHANGES_REQUESTED").sort(compareReviewLatest)[0];
285
+ if (changeRequest) return { verdict: "red", review: safeReview(changeRequest) };
286
+ if (input.isDraft) return { verdict: "not_required", review: null };
287
+ if (reviewerLogin) {
288
+ const configured = latest.get(reviewerLogin.toLowerCase());
289
+ return configured?.state === "APPROVED"
290
+ ? { verdict: "pass", review: safeReview(configured) }
291
+ : { verdict: "pending", review: null };
292
+ }
293
+ const decision = upper(input.reviewDecision);
294
+ if (input.reviewDecision != null && !["CHANGES_REQUESTED", "APPROVED", "REVIEW_REQUIRED"].includes(decision)) return { verdict: "indeterminate", review: null };
295
+ if (decision === "CHANGES_REQUESTED") return { verdict: "red", review: null };
296
+ if (!required) return { verdict: "not_required", review: null };
297
+ if (decision === "APPROVED") return { verdict: "pass", review: null };
298
+ if (decision === "REVIEW_REQUIRED" || input.reviewDecision == null) return { verdict: "pending", review: null };
299
+ return { verdict: "indeterminate", review: null };
300
+ }
301
+
302
+ export function aggregateObservation(input) {
303
+ const expected = fullSha(input.expectedHeadSha, "expectedHeadSha");
304
+ const observed = fullSha(input.headRefOid, "headRefOid");
305
+ const state = upper(input.state);
306
+ if (state === "MERGED") return terminal("green", "external-merge");
307
+ if (state === "CLOSED") return terminal("blocked", "external-state");
308
+ if (observed !== expected) return terminal("needs-human", "head-mismatch");
309
+ const checks = input.checkVerdict;
310
+ const review = input.reviewVerdict;
311
+ if (review === "red") return terminal("needs-human", "review-red", "review");
312
+ if (checks === "red") return terminal("red", "check-red", "checks");
313
+ if (["pending", "not_started", "indeterminate"].includes(checks)) return terminal("pending", "checks-pending");
314
+ if (["pass", "not_applicable"].includes(checks) && ["pass", "not_required"].includes(review)) {
315
+ return terminal("green", input.isDraft ? "draft-ci-green" : "ci-green");
316
+ }
317
+ return terminal("pending", "review-pending");
318
+ }
319
+
320
+ export function normalizePullRequestResponse(response, options) {
321
+ if (!response || typeof response !== "object" || Array.isArray(response)) throw protocol("PR response must be an object");
322
+ if (typeof response.isDraft !== "boolean") throw protocol("isDraft must be a boolean");
323
+ const state = upper(response.state);
324
+ if (!["OPEN", "CLOSED", "MERGED"].includes(state)) throw protocol("PR state is invalid");
325
+ if (!(response.reviewDecision === null || ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"].includes(upper(response.reviewDecision)))) {
326
+ throw protocol("reviewDecision is invalid");
327
+ }
328
+ const started = timeMs(options.startedAt, "startedAt");
329
+ const now = timeMs(options.now, "now");
330
+ const checks = normalizeChecks(response.statusCheckRollup, {
331
+ elapsedMs: Math.max(0, now - started), graceMs: options.checkStartGraceMs,
332
+ });
333
+ const review = normalizeReview({
334
+ reviews: response.reviews, reviewDecision: response.reviewDecision,
335
+ reviewerLogin: options.reviewerLogin, required: options.reviewRequired,
336
+ expectedHeadSha: options.expectedHeadSha, isDraft: response.isDraft,
337
+ });
338
+ return {
339
+ head_sha: fullSha(response.headRefOid, "headRefOid"), state, is_draft: response.isDraft,
340
+ checks, review,
341
+ aggregate: aggregateObservation({ expectedHeadSha: options.expectedHeadSha, headRefOid: response.headRefOid,
342
+ state, isDraft: response.isDraft, checkVerdict: checks.verdict, reviewVerdict: review.verdict }),
343
+ };
344
+ }
345
+
346
+ export function decideObservationSchedule(input) {
347
+ const now = timeMs(input.now, "now");
348
+ const deadline = timeMs(input.deadlineAt, "deadlineAt");
349
+ if (now >= deadline) return { action: "block", reason: "deadline", next_poll_at: null };
350
+ if (input.changed) return { ...schedule(now, deadline, positiveInteger(input.initialPollMs, "initialPollMs"), 0), consecutive_transient_errors: 0, last_error: null };
351
+ const previous = positiveInteger(input.currentIntervalMs, "currentIntervalMs");
352
+ const maximum = positiveInteger(input.maxPollMs, "maxPollMs");
353
+ const backedOff = Math.ceil((previous * 1.5) / 1000) * 1000;
354
+ return { ...schedule(now, deadline, Math.min(maximum, backedOff), nonNegativeInteger(input.unchangedCount ?? 0, "unchangedCount") + 1), consecutive_transient_errors: 0, last_error: null };
355
+ }
356
+
357
+ export function decideTransientSchedule(input) {
358
+ const now = timeMs(input.now, "now");
359
+ const deadline = timeMs(input.deadlineAt, "deadlineAt");
360
+ const count = positiveInteger(input.consecutiveErrors, "consecutiveErrors");
361
+ const max = positiveInteger(input.maxTransientErrors, "maxTransientErrors");
362
+ if (now >= deadline) return { action: "block", reason: "deadline", next_poll_at: null };
363
+ if (count >= max) return { action: "block", reason: "transient-exhausted", next_poll_at: null };
364
+ const ordinary = Math.min(600_000, 60_000 * (2 ** Math.min(count - 1, 4)));
365
+ const retryAfter = nonNegativeInteger(input.retryAfterMs ?? 0, "retryAfterMs");
366
+ const delay = input.rateLimited ? Math.max(600_000, retryAfter) : Math.max(ordinary, retryAfter);
367
+ return { ...schedule(now, deadline, delay, input.unchangedCount ?? 0), consecutive_transient_errors: count };
368
+ }
369
+
370
+ export function parseRetryDelay(headers, now = Date.now()) {
371
+ const normalized = normalizeHeaders(headers);
372
+ const current = timeMs(now, "now");
373
+ const candidates = [];
374
+ const retryAfter = normalized.get("retry-after");
375
+ if (retryAfter !== undefined) {
376
+ if (/^\d+$/u.test(retryAfter)) candidates.push(Number(retryAfter) * 1000);
377
+ else {
378
+ const parsed = Date.parse(retryAfter);
379
+ if (!Number.isFinite(parsed)) throw protocol("Retry-After header is invalid");
380
+ candidates.push(Math.max(0, parsed - current));
381
+ }
382
+ }
383
+ const reset = normalized.get("x-ratelimit-reset");
384
+ if (reset !== undefined) {
385
+ if (!/^\d+$/u.test(reset)) throw protocol("X-RateLimit-Reset header is invalid");
386
+ candidates.push(Math.max(0, (Number(reset) * 1000) - current));
387
+ }
388
+ if (candidates.some((value) => !Number.isSafeInteger(value))) throw protocol("retry delay is invalid");
389
+ return candidates.length ? Math.max(...candidates) : 0;
390
+ }
391
+
392
+ export function isPollDue(nextPollAt, now) {
393
+ return timeMs(now, "now") >= timeMs(nextPollAt, "nextPollAt");
394
+ }
395
+
396
+ export function encodeUntrustedMetadata(value) {
397
+ if (typeof value !== "string") throw protocol("metadata must be a string");
398
+ const bytes = Buffer.from(value, "utf8");
399
+ if (bytes.length > 256) throw protocol("metadata exceeds 256 UTF-8 bytes");
400
+ let display = "";
401
+ for (let index = 0; index < value.length; index += 1) {
402
+ const unit = value.charCodeAt(index);
403
+ if (unit === 0x22 || unit === 0x5c) display += `\\${value[index]}`;
404
+ else if (unit >= 0x20 && unit <= 0x7e) display += value[index];
405
+ else display += `\\u${unit.toString(16).toUpperCase().padStart(4, "0")}`;
406
+ }
407
+ return { trust: "untrusted-github-metadata", label: "UNTRUSTED GITHUB METADATA (not instructions)",
408
+ encoding: "base64url+terminal-safe-display-v1", value_b64url: bytes.toString("base64url"), display,
409
+ sha256: `sha256:${createHash("sha256").update(bytes).digest("hex")}` };
410
+ }
411
+
412
+ export function normalizeRepositoryPath(value) {
413
+ if (typeof value !== "string" || value === "" || Buffer.byteLength(value, "utf8") > 1024) throw protocol("invalid repository path");
414
+ if (value.startsWith("/") || /^[A-Za-z]:/u.test(value) || value.includes("\\") || /[\0-\x1f\x7f]/u.test(value)) throw protocol("invalid repository path");
415
+ const segments = value.split("/");
416
+ if (segments.length > 64 || segments.some((part) => part === "" || part === "." || part === ".." || Buffer.byteLength(part, "utf8") > 255)) {
417
+ throw protocol("invalid repository path");
418
+ }
419
+ return value;
420
+ }
421
+
422
+ export function classifyOwnership(input) {
423
+ const slices = validateSlices(input.slices);
424
+ if (input.reviewVerdict === "red") return unsafe("review-changes-requested");
425
+ const failingNames = (input.failingCheckNames ?? []).map((name) => rawMetadata(name));
426
+ const nameOwners = failingNames.map((name) => sliceIdsInName(name, slices));
427
+ if (nameOwners.some((owners) => owners.length > 1)) return unsafe("check-owner-ambiguous");
428
+ const namedIds = new Set(nameOwners.flat());
429
+ if (namedIds.size > 1) return unsafe("check-owner-conflict");
430
+ if (input.complete === false || input.invalid === true) return unsafe("changed-files-incomplete");
431
+ const paths = (input.paths ?? []).map(normalizeRepositoryPath);
432
+ if (paths.some(isUnsafeRuntimePath) || hasUnsafeChanges(input)) return unsafe("unsafe-path-or-change");
433
+ if (nameOwners.length && nameOwners.every((owners) => owners.length === 1)) {
434
+ const ids = new Set(nameOwners.map(([id]) => id));
435
+ if (ids.size === 1) {
436
+ const slice = slices.find((candidate) => candidate.id === [...ids][0]);
437
+ if (paths.length && paths.some((path) => !sliceOwnsPath(slice, path))) return unsafe("check-file-conflict");
438
+ return routeSlice(slice, "check-slice-id", paths);
439
+ }
440
+ return unsafe("check-owner-conflict");
441
+ }
442
+ if (paths.length) {
443
+ const owners = paths.map((path) => slices.filter((slice) => sliceOwnsPath(slice, path)).map((slice) => slice.id));
444
+ if (owners.every((matches) => matches.length === 1) && new Set(owners.map(([id]) => id)).size === 1) {
445
+ const slice = slices.find((candidate) => candidate.id === owners[0][0]);
446
+ if (nameOwners.some((matches) => matches.length && !matches.includes(slice.id))) return unsafe("check-file-conflict");
447
+ return routeSlice(slice, "changed-files", paths);
448
+ }
449
+ if (paths.every(isTestLanePath) && nameOwners.every((matches) => matches.length === 0)) return integration(paths);
450
+ return unsafe("path-owner-ambiguous");
451
+ }
452
+ return nameOwners.every((matches) => matches.length === 0) ? integration(paths) : unsafe("check-owner-ambiguous");
453
+ }
454
+
455
+ export function validateLane(input) {
456
+ const paths = (input.paths ?? []).map(normalizeRepositoryPath);
457
+ const changes = input.changes ?? [];
458
+ if (input.hasRename || input.hasDelete || input.hasGenerated || input.hasSymlink || changes.some((change) => !["modified", "added", "untracked", "deleted", "renamed", "copied"].includes(change?.status))) return { ok: false, reason: "unsafe-change-kind" };
459
+ if (input.lane === "test") return paths.every(isTestLanePath) ? { ok: true } : { ok: false, reason: "path-outside-test-lane" };
460
+ if (input.lane !== "slice" || !input.slice) return { ok: false, reason: "invalid-lane" };
461
+ const slice = validateSlices([input.slice])[0];
462
+ return paths.every((path) => sliceOwnsPath(slice, path)) ? { ok: true } : { ok: false, reason: "path-outside-slice-lane" };
463
+ }
464
+
465
+ export function buildFailureEvidenceInput(input) {
466
+ const failing = (input.failingChecks ?? []).map((check) => {
467
+ if (!check || check.verdict !== undefined && check.verdict !== "red") throw protocol("failing check verdict is invalid");
468
+ return { name: canonicalMetadata(check?.name), verdict: "red" };
469
+ }).sort((a, b) => compareBytes(a.name.value_b64url, b.name.value_b64url));
470
+ const repository = validRepository(input.repository);
471
+ const prNumber = positiveInteger(input.prNumber, "prNumber");
472
+ const canonicalPrUrl = `https://github.com/${repository}/pull/${prNumber}`;
473
+ if (input.prUrl !== canonicalPrUrl) throw new Error("prUrl must match canonical PR identity");
474
+ const review = canonicalEvidenceReview(input.review);
475
+ const ownership = canonicalEvidenceOwnership(input.ownership);
476
+ if (!failing.length && review === null) throw protocol("failure evidence requires a red check or review");
477
+ if (review !== null && ownership.disposition !== "needs-human") throw protocol("review-red evidence cannot have a remediation route");
478
+ const evidence = {
479
+ schema_version: 1, kind: "post-pr-ci-failure", run_id: String(input.runId), attempt: positiveInteger(input.attempt, "attempt"),
480
+ source: "github", verdict: "red", observed_at: new Date(timeMs(input.observedAt, "observedAt")).toISOString(),
481
+ pr: { url: canonicalPrUrl, number: prNumber, repository },
482
+ expected_head_sha: fullSha(input.expectedHeadSha, "expectedHeadSha"), observed_head_sha: fullSha(input.observedHeadSha, "observedHeadSha"),
483
+ failing_checks: failing, review, primary_failure: review !== null ? "review-red" : "check-red",
484
+ ownership, command: { program: "gh", args: ["pr", "view", String(prNumber), "--repo", repository, "--json", "headRefOid,isDraft,reviewDecision,reviews,state,statusCheckRollup"], exit_code: nonNegativeInteger(input.exitCode ?? 0, "exitCode") },
485
+ };
486
+ return { ...evidence, failure_fingerprint: `sha256:${createHash("sha256").update(stableJson(evidence)).digest("hex")}` };
487
+ }
488
+
489
+ export async function runGitHubOperation(input) {
490
+ const repositoryRoot = resolve(input.repositoryRoot);
491
+ const account = optionalLogin(input.account);
492
+ if (!account) throw new PostPrCiError("account-auth", "a persisted GitHub account is required");
493
+ return withGitHubOperationLock(repositoryRoot, async () => {
494
+ const execute = input.execute ?? runBoundedProcess;
495
+ const common = { executable: input.executable ?? "gh", cwd: input.cwd ?? repositoryRoot, spawnImpl: input.spawnImpl };
496
+ const switched = await execute({ ...common, args: ["auth", "switch", "-h", "github.com", "-u", account], ...GITHUB_LIMITS.switch });
497
+ requireSuccessful(switched, "account-switch");
498
+ const result = await execute({ ...common, args: [...input.args], ...(input.limits ?? GITHUB_LIMITS.verdict) });
499
+ requireSuccessful(result, "operation");
500
+ return { exitCode: result.exitCode, signal: result.signal ?? null, stdout: result.stdout ?? "" };
501
+ }, input.lockOptions);
502
+ }
503
+
504
+ export async function queryPullRequest(input) {
505
+ const identity = checkedIdentity(input.repository, input.prNumber);
506
+ const args = ["pr", "view", String(identity.number), "--repo", identity.repository, "--json", "headRefOid,isDraft,reviewDecision,reviews,state,statusCheckRollup"];
507
+ const result = await runGitHubOperation({ ...input, args, limits: GITHUB_LIMITS.verdict });
508
+ return parseJsonObject(result.stdout);
509
+ }
510
+
511
+ export async function requestReviewer(input) {
512
+ const identity = checkedIdentity(input.repository, input.prNumber);
513
+ const login = optionalLogin(input.reviewerLogin);
514
+ if (!login) throw protocol("reviewerLogin is required");
515
+ return runGitHubOperation({ ...input, args: ["pr", "edit", String(identity.number), "--repo", identity.repository, "--add-reviewer", login], limits: GITHUB_LIMITS.reviewer });
516
+ }
517
+
518
+ export async function fetchChangedFiles(input) {
519
+ const identity = checkedIdentity(input.repository, input.prNumber);
520
+ const paths = []; const changes = [];
521
+ for (let page = 1; page <= 3; page += 1) {
522
+ const endpoint = `repos/${identity.repository}/pulls/${identity.number}/files?per_page=100&page=${page}`;
523
+ const result = await runGitHubOperation({ ...input, args: ["api", "--include", endpoint], limits: GITHUB_LIMITS.ownershipPage });
524
+ const included = parseIncludedArray(result.stdout);
525
+ const body = included.body;
526
+ if (body.length > 100) throw protocol("changed-file page exceeds 100 entries");
527
+ for (const file of body) {
528
+ if (!file || typeof file !== "object") throw protocol("changed-file entry must be an object");
529
+ const path = normalizeRepositoryPath(file.filename);
530
+ const previousPath = file.previous_filename === undefined ? null : normalizeRepositoryPath(file.previous_filename);
531
+ const status = typeof file.status === "string" ? file.status.toLowerCase() : null;
532
+ if (!["added", "modified", "removed", "renamed", "copied", "changed", "unchanged"].includes(status)) throw protocol("changed-file status is invalid");
533
+ paths.push(path);
534
+ if (previousPath !== null) paths.push(previousPath);
535
+ changes.push({ path, previous_path: previousPath, status });
536
+ }
537
+ if (!included.hasNext) return { paths, changes, complete: true, pages: page };
538
+ if (page === 3) return { paths, changes, complete: false, pages: page };
539
+ }
540
+ return { paths, changes, complete: true, pages: 3 };
541
+ }
542
+
543
+ export async function runBoundedProcess(input) {
544
+ const child = (input.spawnImpl ?? spawn)(input.executable, input.args, { cwd: input.cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
545
+ return new Promise((resolvePromise, rejectPromise) => {
546
+ const stdout = []; const stderr = []; let stdoutBytes = 0; let stderrBytes = 0; let settled = false; let timedOut = false; let overflow = null;
547
+ const finishError = (error) => { if (!settled) { settled = true; clearTimeout(timer); rejectPromise(error); } };
548
+ const kill = (reason) => { if (overflow || timedOut) return; if (reason === "timeout") timedOut = true; else overflow = reason; child.kill("SIGKILL"); };
549
+ child.stdout.on("data", (chunk) => { stdoutBytes += chunk.length; if (stdoutBytes > input.stdoutCap) kill("stdout"); else stdout.push(chunk); });
550
+ child.stderr.on("data", (chunk) => { stderrBytes += chunk.length; if (stderrBytes > input.stderrCap) kill("stderr"); else stderr.push(chunk); });
551
+ child.on("error", (error) => finishError(classifyGitHubFailure({ error })));
552
+ child.on("close", (code, signal) => {
553
+ if (settled) return; settled = true; clearTimeout(timer);
554
+ if (timedOut) return rejectPromise(new PostPrCiError("timeout", "GitHub command timed out", { transient: true }));
555
+ if (overflow) return rejectPromise(new PostPrCiError("protocol", `GitHub command exceeded ${overflow} cap`));
556
+ resolvePromise({ exitCode: Number.isInteger(code) ? code : null, signal: signal ?? null,
557
+ stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") });
558
+ });
559
+ const timer = setTimeout(() => kill("timeout"), input.timeoutMs);
560
+ timer.unref?.();
561
+ });
562
+ }
563
+
564
+ export function classifyGitHubFailure(input = {}) {
565
+ if (input.error instanceof PostPrCiError) return input.error;
566
+ const text = `${input.message ?? ""} ${input.stderr ?? ""} ${input.error?.code ?? ""}`.toLowerCase();
567
+ const exitCode = Number.isInteger(input.exitCode) ? input.exitCode : null;
568
+ const embeddedStatus = text.match(/(?:http(?:\/\d(?:\.\d)?)?\s+|status[=: ]+)(401|403|404|408|429|500|502|503|504)\b/u)?.[1];
569
+ const status = Number(input.httpStatus ?? embeddedStatus);
570
+ let retryAfterMs = 0;
571
+ try { retryAfterMs = parseRetryDelay(input.headers, input.now); } catch (error) { return error; }
572
+ if (input.timedOut) return new PostPrCiError("timeout", "GitHub command timed out", { transient: true, exitCode, retryAfterMs });
573
+ if (status === 429 || /\b(?:api )?rate limit exceeded\b|\bsecondary rate limit\b/u.test(text)) return new PostPrCiError("rate-limit", "GitHub rate limit", { transient: true, rateLimited: true, exitCode, retryAfterMs });
574
+ if (HTTP_TRANSIENT.has(status)) return new PostPrCiError("http-transient", `GitHub HTTP ${status}`, { transient: true, exitCode, retryAfterMs });
575
+ if (/\b(?:econnreset|enotfound|eai_again|etimedout)\b/u.test(text)) return new PostPrCiError("network", "GitHub network failure", { transient: true, exitCode, retryAfterMs });
576
+ if (status === 401 || /bad credentials|authentication failed/u.test(text)) return new PostPrCiError("account-auth", "GitHub authentication failure", { exitCode });
577
+ if (status === 403) return new PostPrCiError("permission", "GitHub permission failure", { exitCode });
578
+ if (status === 404) return new PostPrCiError("not-found", "GitHub resource not found", { exitCode });
579
+ return new PostPrCiError("command", "GitHub command failed", { exitCode });
580
+ }
581
+
582
+ async function withGitHubOperationLock(repositoryRoot, fn, options = {}) {
583
+ const factoryDir = join(repositoryRoot, ".opencode", "factory");
584
+ const lockFile = join(factoryDir, "github-operation.lock");
585
+ await mkdir(factoryDir, { recursive: true });
586
+ const now = options?.now ?? Date.now;
587
+ const sleep = options?.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
588
+ const deadline = now() + (options?.timeoutMs ?? 10_000);
589
+ let owner;
590
+ while (!owner) {
591
+ const candidate = { pid: process.pid, hostname: hostname(), nonce: randomUUID() };
592
+ const claimFile = join(factoryDir, `.github-operation.lock-claim-${candidate.nonce}.json`);
593
+ try {
594
+ await writeFile(claimFile, `${JSON.stringify(candidate)}\n`, { flag: "wx" });
595
+ if (options?.onClaimPublished) await options.onClaimPublished({ claimFile, lockFile, owner: candidate });
596
+ await link(claimFile, lockFile);
597
+ await rm(claimFile, { force: true });
598
+ owner = candidate;
599
+ } catch (error) {
600
+ await rm(claimFile, { force: true }).catch(() => {});
601
+ if (error.code !== "EEXIST") throw error;
602
+ if (await reclaimDeadLocalLock(lockFile, options)) continue;
603
+ if (now() >= deadline) throw new PostPrCiError("lock-timeout", "GitHub operation lock timed out", { transient: true });
604
+ await sleep(Math.min(25, Math.max(1, deadline - now())));
605
+ }
606
+ }
607
+ try { return await fn(); } finally {
608
+ try {
609
+ const current = JSON.parse(await readFile(lockFile, "utf8"));
610
+ if (current.nonce === owner.nonce) await rm(lockFile, { force: true });
611
+ } catch { /* Never remove a lock whose ownership cannot be confirmed. */ }
612
+ }
613
+ }
614
+
615
+ async function reclaimDeadLocalLock(lockFile, options = {}) {
616
+ let owner;
617
+ try { owner = JSON.parse(await readFile(lockFile, "utf8")); } catch { return false; }
618
+ if (owner.hostname !== hostname() || !Number.isInteger(owner.pid) || owner.pid <= 0 || typeof owner.nonce !== "string") return false;
619
+ if (options.isProcessAlive) { if (await options.isProcessAlive(owner.pid)) return false; }
620
+ else {
621
+ try { process.kill(owner.pid, 0); return false; } catch (error) { if (error.code !== "ESRCH") return false; }
622
+ }
623
+ const quarantine = `${lockFile}.dead-${randomUUID()}`;
624
+ try {
625
+ await rename(lockFile, quarantine);
626
+ const confirmed = JSON.parse(await readFile(quarantine, "utf8"));
627
+ if (confirmed.nonce !== owner.nonce || confirmed.pid !== owner.pid || confirmed.hostname !== owner.hostname) {
628
+ throw new PostPrCiError("lock-identity", "GitHub operation lock identity changed");
629
+ }
630
+ await rm(quarantine, { force: true });
631
+ await rm(join(dirname(lockFile), `.github-operation.lock-claim-${owner.nonce}.json`), { force: true });
632
+ return true;
633
+ } catch (error) {
634
+ if (error instanceof PostPrCiError) throw error;
635
+ return false;
636
+ }
637
+ }
638
+
639
+ function latestApplicableReviews(reviews, expectedHeadSha) {
640
+ const result = new Map();
641
+ reviews.forEach((review, index) => {
642
+ if (!review || typeof review !== "object") throw protocol("review entry must be an object");
643
+ const login = optionalLogin(review.author?.login);
644
+ const state = upper(review.state);
645
+ if (!login || !["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(state)) return;
646
+ if (review.commit?.oid !== expectedHeadSha) return;
647
+ const submitted = Date.parse(review.submittedAt);
648
+ if (!Number.isFinite(submitted)) throw protocol("review submittedAt must be a timestamp");
649
+ const key = login.toLowerCase(); const current = result.get(key);
650
+ if (!current || submitted > current._submitted || (submitted === current._submitted && index > current._index)) {
651
+ result.set(key, { login, state, submittedAt: new Date(submitted).toISOString(), commitId: expectedHeadSha, _submitted: submitted, _index: index });
652
+ }
653
+ });
654
+ for (const [key, review] of result) if (review.state === "DISMISSED") result.delete(key);
655
+ return result;
656
+ }
657
+
658
+ function safeReview(review) { return { author: encodeUntrustedMetadata(review.login), state: review.state, submitted_at: review.submittedAt, commit_id: review.commitId }; }
659
+ function compareReviewLatest(left, right) { return right._submitted - left._submitted || right._index - left._index; }
660
+ function checkName(entry) { const name = entry?.name ?? entry?.context; if (typeof name !== "string" || name === "") throw protocol("check name is required"); return name; }
661
+ function rawMetadata(value) { return typeof value === "string" ? value : decodeCanonicalMetadata(value); }
662
+ function sliceIdsInName(name, slices) { return slices.filter((slice) => new RegExp(`(^|[^A-Za-z0-9_-])${escapeRegex(slice.id)}(?=$|[^A-Za-z0-9_-])`, "u").test(name)).map((slice) => slice.id); }
663
+ function validateSlices(slices) { if (!Array.isArray(slices)) throw new Error("slices must be an array"); return slices.map((slice) => { if (!slice || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(slice.id) || !Array.isArray(slice.paths)) throw new Error("invalid slice"); return { ...slice, paths: slice.paths.map(validatePlanPath) }; }); }
664
+ function validatePlanPath(path) { if (typeof path !== "string") return null; if (path.endsWith("/**") && !/[*?[\]{}]/u.test(path.slice(0, -3))) return `${normalizeRepositoryPath(path.slice(0, -3))}/**`; if (/[*?[\]{}]/u.test(path)) return null; return normalizeRepositoryPath(path); }
665
+ function sliceOwnsPath(slice, path) { return slice.paths.filter(Boolean).some((accepted) => accepted.endsWith("/**") ? path.startsWith(accepted.slice(0, -2)) : path === accepted); }
666
+ function routeSlice(slice, method, paths = []) { const route = STACK_ROUTES[slice.stack]; if (!route) return unsafe("unknown-slice-stack"); const selected = paths.length ? sortPaths(paths)[0] : null; return { disposition: "route", owner: { kind: "slice", slice_id: slice.id, stack: slice.stack, path_b64url: selected === null ? null : Buffer.from(selected).toString("base64url"), method }, route, lane: "slice", reason: method }; }
667
+ function integration(paths = []) { const selected = paths.length ? sortPaths(paths)[0] : null; return { disposition: "route", owner: { kind: "integration", slice_id: null, stack: "test", path_b64url: selected === null ? null : Buffer.from(selected).toString("base64url"), method: "integration" }, route: "test-verifier", lane: "test", reason: "integration-fallback" }; }
668
+ function unsafe(reason) { return { disposition: "needs-human", owner: null, route: null, lane: null, reason }; }
669
+ function isTestLanePath(path) { return TEST_PREFIXES.some((prefix) => path.startsWith(prefix)); }
670
+ function isUnsafeRuntimePath(path) { return UNSAFE_RUNTIME_PATHS.has(path) || path.startsWith(".yarn/") || path.startsWith("node_modules/")
671
+ || (!path.includes("/") && (/^(?:tsconfig(?:\.[^.]+)?\.json|(?:eslint|prettier|babel|webpack|vite|vitest|jest)\.config\.[A-Za-z0-9]+)$/u.test(path) || /^\.env(?:\.|$)/u.test(path))); }
672
+ function hasUnsafeChanges(input) { return Boolean(input.hasRename || input.hasDelete || input.hasGenerated || input.hasSymlink || input.changes?.some((change) => change?.previous_path || ["renamed", "removed", "deleted"].includes(String(change?.status).toLowerCase()))); }
673
+ function requireSuccessful(result, phase) { if (!result || result.exitCode !== 0) { const classified = classifyGitHubFailure(result ?? {}); if (phase === "account-switch") throw new PostPrCiError("account-switch", "GitHub account switch failed", { exitCode: classified.exitCode }); throw classified; } }
674
+ function parseJsonObject(text) { try { const value = JSON.parse(text); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(); return value; } catch { throw protocol("malformed GitHub JSON response"); } }
675
+ function parseIncludedArray(text) {
676
+ if (typeof text !== "string") throw protocol("malformed GitHub included response");
677
+ const separator = text.match(/\r?\n\r?\n/u);
678
+ if (!separator?.index) throw protocol("malformed GitHub included response");
679
+ const headerText = text.slice(0, separator.index);
680
+ const lines = headerText.split(/\r?\n/u);
681
+ if (!/^HTTP\/\d(?:\.\d)?\s+\d{3}(?:\s|$)/u.test(lines.shift() ?? "")) throw protocol("malformed GitHub included response");
682
+ const headers = normalizeHeaders(lines.map((line) => {
683
+ const colon = line.indexOf(":");
684
+ if (colon <= 0) throw protocol("malformed GitHub response header");
685
+ return [line.slice(0, colon), line.slice(colon + 1).trim()];
686
+ }));
687
+ let body;
688
+ try { body = JSON.parse(text.slice(separator.index + separator[0].length)); } catch { throw protocol("malformed GitHub included response"); }
689
+ if (!Array.isArray(body)) throw protocol("malformed GitHub included response");
690
+ const link = headers.get("link");
691
+ if (link !== undefined && !/^<[^<>\r\n]+>;\s*rel="[a-z]+"(?:,\s*<[^<>\r\n]+>;\s*rel="[a-z]+")*$/u.test(link)) throw protocol("malformed Link header");
692
+ return { body, hasNext: link?.split(",").some((part) => /;\s*rel="next"$/u.test(part.trim())) ?? false };
693
+ }
694
+ function canonicalMetadata(value) { return encodeUntrustedMetadata(typeof value === "string" ? value : decodeCanonicalMetadata(value)); }
695
+ function decodeCanonicalMetadata(value) {
696
+ if (!value || typeof value !== "object" || value.encoding !== "base64url+terminal-safe-display-v1" || typeof value.value_b64url !== "string" || !/^[A-Za-z0-9_-]*$/u.test(value.value_b64url)) throw protocol("invalid encoded metadata");
697
+ const bytes = Buffer.from(value.value_b64url, "base64url");
698
+ if (bytes.toString("base64url") !== value.value_b64url) throw protocol("non-canonical encoded metadata");
699
+ const raw = bytes.toString("utf8");
700
+ if (!Buffer.from(raw).equals(bytes)) throw protocol("metadata is not valid UTF-8");
701
+ return raw;
702
+ }
703
+ function canonicalEvidenceReview(review) {
704
+ if (review == null) return null;
705
+ if (!review || typeof review !== "object" || review.state !== "CHANGES_REQUESTED") throw protocol("evidence review is invalid");
706
+ const submitted = new Date(timeMs(review.submitted_at, "review.submitted_at")).toISOString();
707
+ return { author: canonicalMetadata(review.author), state: "CHANGES_REQUESTED", submitted_at: submitted, commit_id: fullSha(review.commit_id, "review.commit_id") };
708
+ }
709
+ function canonicalEvidenceOwnership(value) {
710
+ if (!value || typeof value !== "object" || !["route", "needs-human"].includes(value.disposition)) throw protocol("evidence ownership is invalid");
711
+ if (value.disposition === "needs-human") return { disposition: "needs-human", owner: null, route: null, lane: null, reason: safeReason(value.reason) };
712
+ if (!value.owner || !["slice", "integration"].includes(value.owner.kind)) throw protocol("evidence owner is invalid");
713
+ const route = value.owner.kind === "integration" ? "test-verifier" : STACK_ROUTES[value.owner.stack];
714
+ const lane = value.owner.kind === "integration" ? "test" : "slice";
715
+ if (!route || value.route !== route || value.lane !== lane) throw protocol("evidence owner route is invalid");
716
+ const pathB64 = value.owner.path_b64url;
717
+ if (pathB64 !== null) normalizeRepositoryPath(decodeBase64Url(pathB64, "owner path"));
718
+ const method = safeMethod(value.owner.method);
719
+ const expectedReason = method === "integration" ? "integration-fallback" : method;
720
+ if (value.reason !== expectedReason) throw protocol("ownership reason is invalid");
721
+ return { disposition: "route", owner: { kind: value.owner.kind, slice_id: value.owner.kind === "slice" ? safeSliceId(value.owner.slice_id) : null,
722
+ stack: value.owner.kind === "slice" ? value.owner.stack : "test", path_b64url: pathB64, method }, route, lane, reason: expectedReason };
723
+ }
724
+ function decodeBase64Url(value, label) { if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/u.test(value)) throw protocol(`${label} is invalid`); const bytes = Buffer.from(value, "base64url"); if (bytes.toString("base64url") !== value) throw protocol(`${label} is invalid`); const decoded = bytes.toString("utf8"); if (!Buffer.from(decoded).equals(bytes)) throw protocol(`${label} is invalid`); return decoded; }
725
+ function safeSliceId(value) { if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(value)) throw protocol("slice id is invalid"); return value; }
726
+ function safeMethod(value) { if (!["check-slice-id", "changed-files", "integration"].includes(value)) throw protocol("owner method is invalid"); return value; }
727
+ function safeReason(value) { if (!OWNERSHIP_REASONS.has(value)) throw protocol("ownership reason is invalid"); return value; }
728
+ function compareBytes(left, right) { return Buffer.compare(Buffer.from(left, "base64url"), Buffer.from(right, "base64url")); }
729
+ function sortPaths(paths) { return [...paths].sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right))); }
730
+ function normalizeHeaders(headers) {
731
+ if (headers == null) return new Map();
732
+ const entries = headers instanceof Map ? [...headers] : Array.isArray(headers) ? headers : Object.entries(headers);
733
+ const result = new Map();
734
+ for (const entry of entries) {
735
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string" || typeof entry[1] !== "string" || !/^[A-Za-z0-9-]+$/u.test(entry[0]) || /[\r\n]/u.test(entry[1])) throw protocol("invalid response headers");
736
+ const key = entry[0].toLowerCase();
737
+ if (result.has(key)) throw protocol("duplicate response header");
738
+ result.set(key, entry[1].trim());
739
+ }
740
+ return result;
741
+ }
742
+ function checkedIdentity(repository, number) { return { repository: validRepository(repository), number: positiveInteger(number, "prNumber") }; }
743
+ function validRepository(value) { if (typeof value !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(value)) throw new Error("invalid repository identity"); return value; }
744
+ function optionalLogin(value) { if (value == null || value === "") return null; if (typeof value !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/u.test(value)) throw new Error("invalid GitHub login"); return value; }
745
+ function fullSha(value, label) { if (typeof value !== "string" || !/^[0-9a-f]{40}$/u.test(value)) throw protocol(`${label} must be a full lowercase SHA`); return value; }
746
+ function protocol(message) { return new PostPrCiError("protocol", message); }
747
+ function upper(value) { return typeof value === "string" ? value.toUpperCase() : ""; }
748
+ function positiveInteger(value, label) { const number = Number(value); if (!Number.isInteger(number) || number <= 0) throw new Error(`${label} must be a positive integer`); return number; }
749
+ function nonNegativeInteger(value, label) { const number = Number(value); if (!Number.isInteger(number) || number < 0) throw new Error(`${label} must be a non-negative integer`); return number; }
750
+ function timeMs(value, label) { const parsed = typeof value === "number" ? value : Date.parse(value); if (!Number.isFinite(parsed)) throw new Error(`${label} must be a timestamp`); return parsed; }
751
+ function schedule(now, deadline, delay, unchangedCount) { const at = Math.min(deadline, now + delay); return { action: "schedule", interval_ms: at - now, next_poll_at: new Date(at).toISOString(), unchanged_count: unchangedCount }; }
752
+ function terminal(verdict, reason, primary = null) { return { verdict, reason, primary }; }
753
+ function escapeRegex(value) { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); }
754
+ function stableJson(value) { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; return JSON.stringify(value); }