@loopover/miner 0.1.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 (218) hide show
  1. package/DEPLOYMENT.md +156 -0
  2. package/Dockerfile +38 -0
  3. package/README.md +269 -0
  4. package/bin/gittensory-miner-mcp.d.ts +64 -0
  5. package/bin/gittensory-miner-mcp.js +279 -0
  6. package/bin/gittensory-miner.js +196 -0
  7. package/docs/coding-agent-driver.md +140 -0
  8. package/docs/config-precedence.md +96 -0
  9. package/docs/cross-repo-discovery-phase1.md +55 -0
  10. package/docs/discovery-index-contract.md +60 -0
  11. package/docs/discovery-plane-operator-guide.md +101 -0
  12. package/docs/env-reference.md +30 -0
  13. package/docs/fleet-run-manifest.md +50 -0
  14. package/docs/miner-goal-spec.md +76 -0
  15. package/docs/miner-selfimprove-calibration.md +105 -0
  16. package/docs/observability.md +60 -0
  17. package/docs/operations-runbook.md +209 -0
  18. package/docs/repo-agnostic-capability-audit.md +129 -0
  19. package/docs/unattended-scheduling.md +107 -0
  20. package/expected-engine.version +1 -0
  21. package/lib/ams-policy.d.ts +20 -0
  22. package/lib/ams-policy.js +68 -0
  23. package/lib/attempt-cli.d.ts +99 -0
  24. package/lib/attempt-cli.js +523 -0
  25. package/lib/attempt-input-builder.d.ts +24 -0
  26. package/lib/attempt-input-builder.js +91 -0
  27. package/lib/attempt-log.d.ts +37 -0
  28. package/lib/attempt-log.js +177 -0
  29. package/lib/attempt-runner.d.ts +59 -0
  30. package/lib/attempt-runner.js +164 -0
  31. package/lib/attempt-worktree.d.ts +31 -0
  32. package/lib/attempt-worktree.js +94 -0
  33. package/lib/calibration-cli.d.ts +1 -0
  34. package/lib/calibration-cli.js +92 -0
  35. package/lib/calibration-run.d.ts +161 -0
  36. package/lib/calibration-run.js +232 -0
  37. package/lib/calibration-types.d.ts +46 -0
  38. package/lib/calibration-types.js +74 -0
  39. package/lib/calibration.d.ts +24 -0
  40. package/lib/calibration.js +99 -0
  41. package/lib/ci-poller.d.ts +33 -0
  42. package/lib/ci-poller.js +234 -0
  43. package/lib/claim-adjudication.d.ts +21 -0
  44. package/lib/claim-adjudication.js +36 -0
  45. package/lib/claim-conflict-resolver.d.ts +29 -0
  46. package/lib/claim-conflict-resolver.js +101 -0
  47. package/lib/claim-ledger-cli.d.ts +59 -0
  48. package/lib/claim-ledger-cli.js +320 -0
  49. package/lib/claim-ledger-expiry.d.ts +20 -0
  50. package/lib/claim-ledger-expiry.js +41 -0
  51. package/lib/claim-ledger.d.ts +63 -0
  52. package/lib/claim-ledger.js +303 -0
  53. package/lib/cli-error.d.ts +3 -0
  54. package/lib/cli-error.js +27 -0
  55. package/lib/cli.d.ts +3 -0
  56. package/lib/cli.js +72 -0
  57. package/lib/coding-agent-construction.d.ts +17 -0
  58. package/lib/coding-agent-construction.js +103 -0
  59. package/lib/coding-agent-house-rules.d.ts +24 -0
  60. package/lib/coding-agent-house-rules.js +64 -0
  61. package/lib/coding-task-spec.d.ts +44 -0
  62. package/lib/coding-task-spec.js +183 -0
  63. package/lib/config-precedence.d.ts +25 -0
  64. package/lib/config-precedence.js +38 -0
  65. package/lib/deny-check.d.ts +11 -0
  66. package/lib/deny-check.js +76 -0
  67. package/lib/deny-hook-synthesis.d.ts +93 -0
  68. package/lib/deny-hook-synthesis.js +399 -0
  69. package/lib/deny-hooks.d.ts +28 -0
  70. package/lib/deny-hooks.js +155 -0
  71. package/lib/deployment-docs-audit.d.ts +41 -0
  72. package/lib/deployment-docs-audit.js +115 -0
  73. package/lib/discover-cli.d.ts +94 -0
  74. package/lib/discover-cli.js +284 -0
  75. package/lib/discovery-throttle.d.ts +8 -0
  76. package/lib/discovery-throttle.js +33 -0
  77. package/lib/env-file-indirection.d.ts +4 -0
  78. package/lib/env-file-indirection.js +45 -0
  79. package/lib/event-ledger-cli.d.ts +82 -0
  80. package/lib/event-ledger-cli.js +266 -0
  81. package/lib/event-ledger.d.ts +37 -0
  82. package/lib/event-ledger.js +210 -0
  83. package/lib/execute-local-write.d.ts +14 -0
  84. package/lib/execute-local-write.js +50 -0
  85. package/lib/feasibility-cli.d.ts +25 -0
  86. package/lib/feasibility-cli.js +80 -0
  87. package/lib/forge-config.d.ts +17 -0
  88. package/lib/forge-config.js +37 -0
  89. package/lib/governor-action-mode.d.ts +26 -0
  90. package/lib/governor-action-mode.js +47 -0
  91. package/lib/governor-chokepoint-persisted.d.ts +18 -0
  92. package/lib/governor-chokepoint-persisted.js +46 -0
  93. package/lib/governor-chokepoint.d.ts +14 -0
  94. package/lib/governor-chokepoint.js +52 -0
  95. package/lib/governor-kill-switch.d.ts +26 -0
  96. package/lib/governor-kill-switch.js +47 -0
  97. package/lib/governor-ledger-cli.d.ts +32 -0
  98. package/lib/governor-ledger-cli.js +155 -0
  99. package/lib/governor-ledger.d.ts +46 -0
  100. package/lib/governor-ledger.js +203 -0
  101. package/lib/governor-open-pr.d.ts +21 -0
  102. package/lib/governor-open-pr.js +27 -0
  103. package/lib/governor-pause-cli.d.ts +23 -0
  104. package/lib/governor-pause-cli.js +171 -0
  105. package/lib/governor-run-halt.d.ts +37 -0
  106. package/lib/governor-run-halt.js +58 -0
  107. package/lib/governor-state.d.ts +63 -0
  108. package/lib/governor-state.js +363 -0
  109. package/lib/governor-write-rate-limit.d.ts +30 -0
  110. package/lib/governor-write-rate-limit.js +64 -0
  111. package/lib/harness-submission-trigger.d.ts +69 -0
  112. package/lib/harness-submission-trigger.js +138 -0
  113. package/lib/http-retry.d.ts +12 -0
  114. package/lib/http-retry.js +52 -0
  115. package/lib/laptop-init.d.ts +53 -0
  116. package/lib/laptop-init.js +332 -0
  117. package/lib/live-issue-snapshot.d.ts +16 -0
  118. package/lib/live-issue-snapshot.js +117 -0
  119. package/lib/local-store.d.ts +18 -0
  120. package/lib/local-store.js +62 -0
  121. package/lib/logger.d.ts +58 -0
  122. package/lib/logger.js +167 -0
  123. package/lib/loop-cli.d.ts +66 -0
  124. package/lib/loop-cli.js +540 -0
  125. package/lib/loop-closure.d.ts +35 -0
  126. package/lib/loop-closure.js +66 -0
  127. package/lib/loop-reentry.d.ts +51 -0
  128. package/lib/loop-reentry.js +124 -0
  129. package/lib/manage-poll.d.ts +79 -0
  130. package/lib/manage-poll.js +254 -0
  131. package/lib/manage-status.d.ts +70 -0
  132. package/lib/manage-status.js +241 -0
  133. package/lib/metrics-cli.d.ts +9 -0
  134. package/lib/metrics-cli.js +50 -0
  135. package/lib/migrate-cli.d.ts +24 -0
  136. package/lib/migrate-cli.js +111 -0
  137. package/lib/miner-goal-spec.d.ts +12 -0
  138. package/lib/miner-goal-spec.js +67 -0
  139. package/lib/opportunity-fanout.d.ts +86 -0
  140. package/lib/opportunity-fanout.js +597 -0
  141. package/lib/opportunity-ranker.d.ts +36 -0
  142. package/lib/opportunity-ranker.js +118 -0
  143. package/lib/orb-export.d.ts +54 -0
  144. package/lib/orb-export.js +206 -0
  145. package/lib/plan-store-cli.d.ts +37 -0
  146. package/lib/plan-store-cli.js +151 -0
  147. package/lib/plan-store.d.ts +51 -0
  148. package/lib/plan-store.js +231 -0
  149. package/lib/policy-doc-cache.d.ts +25 -0
  150. package/lib/policy-doc-cache.js +79 -0
  151. package/lib/policy-verdict-cache.d.ts +35 -0
  152. package/lib/policy-verdict-cache.js +106 -0
  153. package/lib/portfolio-dashboard.d.ts +30 -0
  154. package/lib/portfolio-dashboard.js +105 -0
  155. package/lib/portfolio-discovery.d.ts +29 -0
  156. package/lib/portfolio-discovery.js +100 -0
  157. package/lib/portfolio-queue-cli.d.ts +87 -0
  158. package/lib/portfolio-queue-cli.js +509 -0
  159. package/lib/portfolio-queue-expiry.d.ts +20 -0
  160. package/lib/portfolio-queue-expiry.js +51 -0
  161. package/lib/portfolio-queue-manager.d.ts +50 -0
  162. package/lib/portfolio-queue-manager.js +134 -0
  163. package/lib/portfolio-queue.d.ts +62 -0
  164. package/lib/portfolio-queue.js +351 -0
  165. package/lib/pr-disposition-poller.d.ts +26 -0
  166. package/lib/pr-disposition-poller.js +163 -0
  167. package/lib/pr-number-parse.d.ts +4 -0
  168. package/lib/pr-number-parse.js +23 -0
  169. package/lib/pr-outcome.d.ts +41 -0
  170. package/lib/pr-outcome.js +90 -0
  171. package/lib/prediction-ledger.d.ts +47 -0
  172. package/lib/prediction-ledger.js +222 -0
  173. package/lib/pretooluse-hook.d.ts +31 -0
  174. package/lib/pretooluse-hook.js +93 -0
  175. package/lib/process-lifecycle.d.ts +31 -0
  176. package/lib/process-lifecycle.js +106 -0
  177. package/lib/purge-cli.d.ts +44 -0
  178. package/lib/purge-cli.js +198 -0
  179. package/lib/rejection-signal.d.ts +6 -0
  180. package/lib/rejection-signal.js +101 -0
  181. package/lib/rejection-state-machine.d.ts +37 -0
  182. package/lib/rejection-state-machine.js +81 -0
  183. package/lib/rejection-templates.d.ts +12 -0
  184. package/lib/rejection-templates.js +71 -0
  185. package/lib/replay-objective-anchor.d.ts +79 -0
  186. package/lib/replay-objective-anchor.js +179 -0
  187. package/lib/replay-snapshot.d.ts +41 -0
  188. package/lib/replay-snapshot.js +288 -0
  189. package/lib/replay-task-generation.d.ts +126 -0
  190. package/lib/replay-task-generation.js +223 -0
  191. package/lib/repo-clone.d.ts +19 -0
  192. package/lib/repo-clone.js +102 -0
  193. package/lib/run-state-cli.d.ts +27 -0
  194. package/lib/run-state-cli.js +154 -0
  195. package/lib/run-state.d.ts +37 -0
  196. package/lib/run-state.js +150 -0
  197. package/lib/schema-version.d.ts +17 -0
  198. package/lib/schema-version.js +71 -0
  199. package/lib/self-review-context.d.ts +28 -0
  200. package/lib/self-review-context.js +339 -0
  201. package/lib/slop-assessment.d.ts +3 -0
  202. package/lib/slop-assessment.js +20 -0
  203. package/lib/stack-detection.d.ts +41 -0
  204. package/lib/stack-detection.js +248 -0
  205. package/lib/status.d.ts +64 -0
  206. package/lib/status.js +441 -0
  207. package/lib/store-maintenance.d.ts +31 -0
  208. package/lib/store-maintenance.js +183 -0
  209. package/lib/submission-freshness-check.d.ts +32 -0
  210. package/lib/submission-freshness-check.js +93 -0
  211. package/lib/update-check.d.ts +36 -0
  212. package/lib/update-check.js +161 -0
  213. package/lib/version.d.ts +3 -0
  214. package/lib/version.js +10 -0
  215. package/lib/worktree-allocator.d.ts +39 -0
  216. package/lib/worktree-allocator.js +262 -0
  217. package/package.json +50 -0
  218. package/schema/miner-goal-spec.schema.json +111 -0
@@ -0,0 +1,363 @@
1
+ import { DEFAULT_FORGE_CONFIG } from "./forge-config.js";
2
+ import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
3
+
4
+ // Governor cross-attempt state persistence (#5134, Wave 3.5). Every governor-*.js wrapper
5
+ // (governor-write-rate-limit.js, governor-chokepoint.js) is a pure in/out transform: it computes and RETURNS
6
+ // updated rate-limit buckets/backoff attempts, but nothing writes them to disk, so they reset to zero on
7
+ // every process start -- the mutable counters that should gate the NEXT decision never survive past one
8
+ // process. governor-ledger.js already persists the DECISION HISTORY (an append-only audit log); this module
9
+ // persists the DECISION INPUT state instead -- a second, distinct concern, not a duplicate of that log (see
10
+ // its own module doc for the ledger/state split this issue's acceptance criteria requires).
11
+ //
12
+ // This module does not alter evaluateGovernorChokepoint's precedence ladder or any pure calculator's logic --
13
+ // it only gives their existing, already-optional input fields (rateLimitBuckets, rateLimitBackoffAttempts,
14
+ // capUsage, reputationHistory, recentOwnSubmissions) a real load-at-start/save-at-end home. Convergence input
15
+ // (packages/gittensory-engine/src/portfolio/non-convergence.ts's PortfolioConvergenceInput) is NOT persisted
16
+ // here: that module's own doc comment says its counters belong on the portfolio-queue table (a pre-existing
17
+ // store this issue's boundaries don't touch) once that table grows attempt-history columns -- inventing a
18
+ // second, competing store for the same concept here would violate the same non-duplication principle the
19
+ // ledger/state split above is built on.
20
+
21
+ const defaultDbFileName = "governor-state.sqlite3";
22
+ const DEFAULT_RATE_LIMIT_BUCKETS = Object.freeze({ global: {}, perRepo: {} });
23
+ const DEFAULT_RATE_LIMIT_BACKOFF = Object.freeze({});
24
+ const DEFAULT_CAP_USAGE = Object.freeze({ budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 });
25
+ const DEFAULT_REPUTATION_HISTORY = Object.freeze({ decided: 0, unfavorable: 0 });
26
+ let defaultGovernorState = null;
27
+
28
+ export function resolveGovernorStateDbPath(env = process.env) {
29
+ return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_GOVERNOR_STATE_DB", env);
30
+ }
31
+
32
+ function normalizeDbPath(dbPath) {
33
+ return normalizeLocalStoreDbPath(dbPath, resolveGovernorStateDbPath(), "invalid_governor_state_db_path");
34
+ }
35
+
36
+ function normalizeRepoFullName(repoFullName) {
37
+ if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
38
+ const [owner, repo, extra] = repoFullName.trim().split("/");
39
+ if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name");
40
+ return `${owner}/${repo}`;
41
+ }
42
+
43
+ /** Optional forge host, scoping rows so two hosts serving the same owner/repo name never collide (#5563).
44
+ * Omitted/nullish → the github.com default, so every pre-existing single-forge caller is unaffected. */
45
+ function normalizeApiBaseUrl(apiBaseUrl) {
46
+ if (apiBaseUrl === undefined || apiBaseUrl === null) return DEFAULT_FORGE_CONFIG.apiBaseUrl;
47
+ if (typeof apiBaseUrl !== "string" || !apiBaseUrl.trim()) throw new Error("invalid_api_base_url");
48
+ return apiBaseUrl.trim();
49
+ }
50
+
51
+ function parseJsonColumn(value, fallback) {
52
+ if (typeof value !== "string") return fallback;
53
+ try {
54
+ const parsed = JSON.parse(value);
55
+ return parsed && typeof parsed === "object" ? parsed : fallback;
56
+ } catch {
57
+ return fallback;
58
+ }
59
+ }
60
+
61
+ // Add the pause/resume columns (#4851) to an on-disk file created before they existed. `CREATE TABLE IF NOT
62
+ // EXISTS` above is a no-op against an already-existing table, so a pre-#4851 file needs this explicit ALTER --
63
+ // guarded by a per-column presence check (rather than a single `paused`-only check) so a file that somehow
64
+ // has `paused` but not `pause_reason`/`paused_at` still gets the columns it's missing, same technique as
65
+ // portfolio-queue.js's own post-creation column migration.
66
+ function ensurePauseColumns(db) {
67
+ const existingColumns = new Set(
68
+ db
69
+ .prepare("PRAGMA table_info(governor_scalar_state)")
70
+ .all()
71
+ .map((column) => column.name),
72
+ );
73
+ if (!existingColumns.has("paused")) {
74
+ db.exec("ALTER TABLE governor_scalar_state ADD COLUMN paused INTEGER NOT NULL DEFAULT 0");
75
+ }
76
+ if (!existingColumns.has("pause_reason")) {
77
+ db.exec("ALTER TABLE governor_scalar_state ADD COLUMN pause_reason TEXT");
78
+ }
79
+ if (!existingColumns.has("paused_at")) {
80
+ db.exec("ALTER TABLE governor_scalar_state ADD COLUMN paused_at TEXT");
81
+ }
82
+ }
83
+
84
+ // Rebuild governor_reputation_history's bare `repo_full_name` PRIMARY KEY into a (api_base_url, repo_full_name)
85
+ // composite (#5563) -- two forge hosts serving a same-named owner/repo must not share one reputation row.
86
+ // SQLite cannot ALTER a PRIMARY KEY in place, so this rebuilds the table: create the new shape, copy every
87
+ // existing row with the pre-#4784 implicit single-forge default backfilled, drop the old table, rename the new
88
+ // one in. Guarded by a column-presence check (matching ensurePauseColumns' idempotence) so this only runs once
89
+ // per file, same technique as portfolio-queue.js's own post-creation migration.
90
+ function ensureReputationHistoryForgeScope(db) {
91
+ const hasApiBaseUrlColumn = db
92
+ .prepare("PRAGMA table_info(governor_reputation_history)")
93
+ .all()
94
+ .some((column) => column.name === "api_base_url");
95
+ if (hasApiBaseUrlColumn) return;
96
+ db.exec(`
97
+ CREATE TABLE governor_reputation_history_v2 (
98
+ api_base_url TEXT NOT NULL,
99
+ repo_full_name TEXT NOT NULL,
100
+ decided INTEGER NOT NULL,
101
+ unfavorable INTEGER NOT NULL,
102
+ updated_at TEXT NOT NULL,
103
+ PRIMARY KEY (api_base_url, repo_full_name)
104
+ )
105
+ `);
106
+ // OR IGNORE: a source row that somehow violates the rebuilt table's NOT NULL columns (a hand-edited or
107
+ // otherwise corrupted file) is skipped rather than aborting the whole migration -- same fail-closed posture
108
+ // as run-state.js's own #5563 migration.
109
+ db.prepare(
110
+ `INSERT OR IGNORE INTO governor_reputation_history_v2 (api_base_url, repo_full_name, decided, unfavorable, updated_at)
111
+ SELECT ?, repo_full_name, decided, unfavorable, updated_at FROM governor_reputation_history`,
112
+ ).run(DEFAULT_FORGE_CONFIG.apiBaseUrl);
113
+ db.exec("DROP TABLE governor_reputation_history");
114
+ db.exec("ALTER TABLE governor_reputation_history_v2 RENAME TO governor_reputation_history");
115
+ }
116
+
117
+ /** Opens the local governor-state store, creating tables on first use. */
118
+ export function openGovernorState(dbPath = resolveGovernorStateDbPath()) {
119
+ const resolvedPath = normalizeDbPath(dbPath);
120
+ const db = openLocalStoreDb(resolvedPath);
121
+
122
+ // ONE row (id=1) holding the whole-run scalar state: rate-limit buckets/backoff and budget/turn/termination
123
+ // usage have no natural per-repo key of their own beyond what's already encoded inside the JSON blob
124
+ // (WriteRateLimitBucketStore.perRepo is itself keyed by `${actionClass}:${repoFullName}`), so a single
125
+ // UPSERTed row is simpler and more honest than inventing a relational key that doesn't exist upstream.
126
+ db.exec(`
127
+ CREATE TABLE IF NOT EXISTS governor_scalar_state (
128
+ id INTEGER PRIMARY KEY CHECK (id = 1),
129
+ rate_limit_buckets_json TEXT NOT NULL,
130
+ rate_limit_backoff_json TEXT NOT NULL,
131
+ cap_usage_json TEXT NOT NULL,
132
+ paused INTEGER NOT NULL DEFAULT 0,
133
+ pause_reason TEXT,
134
+ paused_at TEXT,
135
+ updated_at TEXT NOT NULL
136
+ )
137
+ `);
138
+ ensurePauseColumns(db);
139
+ db.exec(`
140
+ CREATE TABLE IF NOT EXISTS governor_reputation_history (
141
+ repo_full_name TEXT PRIMARY KEY,
142
+ decided INTEGER NOT NULL,
143
+ unfavorable INTEGER NOT NULL,
144
+ updated_at TEXT NOT NULL
145
+ )
146
+ `);
147
+ ensureReputationHistoryForgeScope(db);
148
+ db.exec(`
149
+ CREATE TABLE IF NOT EXISTS governor_own_submissions (
150
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
151
+ repo_full_name TEXT NOT NULL,
152
+ fingerprint TEXT NOT NULL,
153
+ submitted_at TEXT,
154
+ pull_request_number INTEGER,
155
+ issue_number INTEGER
156
+ )
157
+ `);
158
+ db.exec("CREATE INDEX IF NOT EXISTS idx_governor_own_submissions_repo ON governor_own_submissions (repo_full_name, id)");
159
+
160
+ const getScalarStatement = db.prepare("SELECT * FROM governor_scalar_state WHERE id = 1");
161
+ const upsertScalarStatement = db.prepare(`
162
+ INSERT INTO governor_scalar_state
163
+ (id, rate_limit_buckets_json, rate_limit_backoff_json, cap_usage_json, paused, pause_reason, paused_at, updated_at)
164
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?)
165
+ ON CONFLICT(id) DO UPDATE SET
166
+ rate_limit_buckets_json = excluded.rate_limit_buckets_json,
167
+ rate_limit_backoff_json = excluded.rate_limit_backoff_json,
168
+ cap_usage_json = excluded.cap_usage_json,
169
+ paused = excluded.paused,
170
+ pause_reason = excluded.pause_reason,
171
+ paused_at = excluded.paused_at,
172
+ updated_at = excluded.updated_at
173
+ `);
174
+ const getReputationStatement = db.prepare(
175
+ "SELECT * FROM governor_reputation_history WHERE api_base_url = ? AND repo_full_name = ?",
176
+ );
177
+ const upsertReputationStatement = db.prepare(`
178
+ INSERT INTO governor_reputation_history (api_base_url, repo_full_name, decided, unfavorable, updated_at)
179
+ VALUES (?, ?, ?, ?, ?)
180
+ ON CONFLICT(api_base_url, repo_full_name) DO UPDATE SET
181
+ decided = excluded.decided,
182
+ unfavorable = excluded.unfavorable,
183
+ updated_at = excluded.updated_at
184
+ `);
185
+ const insertSubmissionStatement = db.prepare(`
186
+ INSERT INTO governor_own_submissions (repo_full_name, fingerprint, submitted_at, pull_request_number, issue_number)
187
+ VALUES (?, ?, ?, ?, ?)
188
+ `);
189
+ const listSubmissionsAllStatement = db.prepare(
190
+ "SELECT * FROM governor_own_submissions ORDER BY id DESC LIMIT ?",
191
+ );
192
+ const listSubmissionsByRepoStatement = db.prepare(
193
+ "SELECT * FROM governor_own_submissions WHERE repo_full_name = ? ORDER BY id DESC LIMIT ?",
194
+ );
195
+
196
+ function rowToSubmission(row) {
197
+ return {
198
+ repoFullName: row.repo_full_name,
199
+ fingerprint: row.fingerprint,
200
+ submittedAt: row.submitted_at,
201
+ pullRequestNumber: row.pull_request_number,
202
+ issueNumber: row.issue_number,
203
+ };
204
+ }
205
+
206
+ const state = {
207
+ dbPath: resolvedPath,
208
+ loadRateLimitState() {
209
+ const row = getScalarStatement.get();
210
+ return {
211
+ buckets: parseJsonColumn(row?.rate_limit_buckets_json, DEFAULT_RATE_LIMIT_BUCKETS),
212
+ backoffAttempts: parseJsonColumn(row?.rate_limit_backoff_json, DEFAULT_RATE_LIMIT_BACKOFF),
213
+ };
214
+ },
215
+ saveRateLimitState(rateLimitState) {
216
+ const row = getScalarStatement.get();
217
+ upsertScalarStatement.run(
218
+ JSON.stringify(rateLimitState?.buckets ?? DEFAULT_RATE_LIMIT_BUCKETS),
219
+ JSON.stringify(rateLimitState?.backoffAttempts ?? DEFAULT_RATE_LIMIT_BACKOFF),
220
+ row ? row.cap_usage_json : JSON.stringify(DEFAULT_CAP_USAGE),
221
+ row ? row.paused : 0,
222
+ row ? row.pause_reason : null,
223
+ row ? row.paused_at : null,
224
+ new Date().toISOString(),
225
+ );
226
+ },
227
+ loadCapUsage() {
228
+ const row = getScalarStatement.get();
229
+ return parseJsonColumn(row?.cap_usage_json, DEFAULT_CAP_USAGE);
230
+ },
231
+ saveCapUsage(capUsage) {
232
+ const row = getScalarStatement.get();
233
+ upsertScalarStatement.run(
234
+ row ? row.rate_limit_buckets_json : JSON.stringify(DEFAULT_RATE_LIMIT_BUCKETS),
235
+ row ? row.rate_limit_backoff_json : JSON.stringify(DEFAULT_RATE_LIMIT_BACKOFF),
236
+ JSON.stringify(capUsage ?? DEFAULT_CAP_USAGE),
237
+ row ? row.paused : 0,
238
+ row ? row.pause_reason : null,
239
+ row ? row.paused_at : null,
240
+ new Date().toISOString(),
241
+ );
242
+ },
243
+ // The governor pause/resume control surface (#4851): a real, persisted, operator/governor-writable flag the
244
+ // loop checks before each cycle -- distinct from governor-kill-switch.js (a read-only resolver over env/YAML
245
+ // inputs the miner does not itself write) and governor-run-halt.js (a one-way, run-scoped terminal breaker).
246
+ // `pausedAt` is stamped fresh on every transition INTO paused, and cleared on resume, so a status query can
247
+ // report how long a pause has been in effect without needing a separate history table.
248
+ loadPauseState() {
249
+ const row = getScalarStatement.get();
250
+ return {
251
+ paused: row ? Boolean(row.paused) : false,
252
+ reason: row?.pause_reason ?? null,
253
+ pausedAt: row?.paused_at ?? null,
254
+ };
255
+ },
256
+ savePauseState(pauseState) {
257
+ const row = getScalarStatement.get();
258
+ const paused = Boolean(pauseState?.paused);
259
+ const reason =
260
+ typeof pauseState?.reason === "string" && pauseState.reason.trim() ? pauseState.reason.trim() : null;
261
+ const pausedAt = paused ? new Date().toISOString() : null;
262
+ upsertScalarStatement.run(
263
+ row ? row.rate_limit_buckets_json : JSON.stringify(DEFAULT_RATE_LIMIT_BUCKETS),
264
+ row ? row.rate_limit_backoff_json : JSON.stringify(DEFAULT_RATE_LIMIT_BACKOFF),
265
+ row ? row.cap_usage_json : JSON.stringify(DEFAULT_CAP_USAGE),
266
+ paused ? 1 : 0,
267
+ reason,
268
+ pausedAt,
269
+ new Date().toISOString(),
270
+ );
271
+ return { paused, reason, pausedAt };
272
+ },
273
+ loadReputationHistory(repoFullName, apiBaseUrl) {
274
+ const normalizedForge = normalizeApiBaseUrl(apiBaseUrl);
275
+ const normalizedRepo = normalizeRepoFullName(repoFullName);
276
+ const row = getReputationStatement.get(normalizedForge, normalizedRepo);
277
+ if (!row) return { ...DEFAULT_REPUTATION_HISTORY };
278
+ return { decided: row.decided, unfavorable: row.unfavorable };
279
+ },
280
+ saveReputationHistory(repoFullName, history, apiBaseUrl) {
281
+ const normalizedForge = normalizeApiBaseUrl(apiBaseUrl);
282
+ const normalizedRepo = normalizeRepoFullName(repoFullName);
283
+ const decided = Number.isInteger(history?.decided) ? history.decided : 0;
284
+ const unfavorable = Number.isInteger(history?.unfavorable) ? history.unfavorable : 0;
285
+ upsertReputationStatement.run(normalizedForge, normalizedRepo, decided, unfavorable, new Date().toISOString());
286
+ return { decided, unfavorable };
287
+ },
288
+ recordOwnSubmission(record) {
289
+ const normalized = normalizeRepoFullName(record?.repoFullName);
290
+ if (typeof record?.fingerprint !== "string" || !record.fingerprint.trim()) {
291
+ throw new Error("invalid_fingerprint");
292
+ }
293
+ const submittedAt = typeof record.submittedAt === "string" ? record.submittedAt : new Date().toISOString();
294
+ const pullRequestNumber = Number.isInteger(record.pullRequestNumber) ? record.pullRequestNumber : null;
295
+ const issueNumber = Number.isInteger(record.issueNumber) ? record.issueNumber : null;
296
+ insertSubmissionStatement.run(normalized, record.fingerprint, submittedAt, pullRequestNumber, issueNumber);
297
+ return { repoFullName: normalized, fingerprint: record.fingerprint, submittedAt, pullRequestNumber, issueNumber };
298
+ },
299
+ listRecentOwnSubmissions(filter = {}) {
300
+ const limit = Number.isInteger(filter.limit) && filter.limit > 0 ? filter.limit : 200;
301
+ const rows =
302
+ filter.repoFullName === undefined
303
+ ? listSubmissionsAllStatement.all(limit)
304
+ : listSubmissionsByRepoStatement.all(normalizeRepoFullName(filter.repoFullName), limit);
305
+ return rows.map(rowToSubmission);
306
+ },
307
+ close() {
308
+ db.close();
309
+ },
310
+ };
311
+ return state;
312
+ }
313
+
314
+ function getDefaultGovernorState() {
315
+ defaultGovernorState ??= openGovernorState();
316
+ return defaultGovernorState;
317
+ }
318
+
319
+ export function loadRateLimitState() {
320
+ return getDefaultGovernorState().loadRateLimitState();
321
+ }
322
+
323
+ export function saveRateLimitState(rateLimitState) {
324
+ return getDefaultGovernorState().saveRateLimitState(rateLimitState);
325
+ }
326
+
327
+ export function loadCapUsage() {
328
+ return getDefaultGovernorState().loadCapUsage();
329
+ }
330
+
331
+ export function saveCapUsage(capUsage) {
332
+ return getDefaultGovernorState().saveCapUsage(capUsage);
333
+ }
334
+
335
+ export function loadPauseState() {
336
+ return getDefaultGovernorState().loadPauseState();
337
+ }
338
+
339
+ export function savePauseState(pauseState) {
340
+ return getDefaultGovernorState().savePauseState(pauseState);
341
+ }
342
+
343
+ export function loadReputationHistory(repoFullName, apiBaseUrl) {
344
+ return getDefaultGovernorState().loadReputationHistory(repoFullName, apiBaseUrl);
345
+ }
346
+
347
+ export function saveReputationHistory(repoFullName, history, apiBaseUrl) {
348
+ return getDefaultGovernorState().saveReputationHistory(repoFullName, history, apiBaseUrl);
349
+ }
350
+
351
+ export function recordOwnSubmission(record) {
352
+ return getDefaultGovernorState().recordOwnSubmission(record);
353
+ }
354
+
355
+ export function listRecentOwnSubmissions(filter) {
356
+ return getDefaultGovernorState().listRecentOwnSubmissions(filter);
357
+ }
358
+
359
+ export function closeDefaultGovernorState() {
360
+ if (!defaultGovernorState) return;
361
+ defaultGovernorState.close();
362
+ defaultGovernorState = null;
363
+ }
@@ -0,0 +1,30 @@
1
+ import type {
2
+ WriteRateLimitBackoffStore,
3
+ WriteRateLimitBucketStore,
4
+ WriteRateLimitPolicies,
5
+ WriteRateLimitVerdict,
6
+ } from "@loopover/engine";
7
+ import type { AppendGovernorEventInput, GovernorLedgerEntry } from "./governor-ledger.js";
8
+
9
+ export type EvaluateWriteRateLimitGateInput = {
10
+ actionClass: string;
11
+ repoFullName: string;
12
+ buckets: WriteRateLimitBucketStore;
13
+ backoffAttempts: WriteRateLimitBackoffStore;
14
+ nowMs: number;
15
+ policies?: WriteRateLimitPolicies;
16
+ randomFn?: () => number;
17
+ };
18
+
19
+ export type EvaluateWriteRateLimitGateResult = {
20
+ verdict: WriteRateLimitVerdict;
21
+ recorded: GovernorLedgerEntry;
22
+ buckets: WriteRateLimitBucketStore;
23
+ backoffAttempts: WriteRateLimitBackoffStore;
24
+ retryAtMs: number | null;
25
+ };
26
+
27
+ export function evaluateWriteRateLimitGate(
28
+ input: EvaluateWriteRateLimitGateInput,
29
+ options?: { append?: (event: AppendGovernorEventInput) => GovernorLedgerEntry },
30
+ ): EvaluateWriteRateLimitGateResult;
@@ -0,0 +1,64 @@
1
+ // Governor write-rate-limit gate (#2344). Consults global + per-repo buckets before a write action, schedules
2
+ // jittered retries on throttle, and records outcomes to the append-only governor ledger.
3
+
4
+ import {
5
+ buildWriteRateLimitGovernorLedgerEvent,
6
+ clearWriteRateLimitBackoff,
7
+ evaluateWriteRateLimit,
8
+ recordWriteRateLimitAllowed,
9
+ recordWriteRateLimitDenied,
10
+ } from "@loopover/engine";
11
+ import { appendGovernorEvent } from "./governor-ledger.js";
12
+
13
+ /**
14
+ * Evaluate write-rate limits for a governor write action and persist the decision.
15
+ *
16
+ * @param {object} input
17
+ * @param {string} input.actionClass governor write class (e.g. open_pr, comment)
18
+ * @param {string} input.repoFullName target repo
19
+ * @param {import("@loopover/engine").WriteRateLimitBucketStore} input.buckets
20
+ * @param {import("@loopover/engine").WriteRateLimitBackoffStore} input.backoffAttempts
21
+ * @param {number} input.nowMs clock reading in epoch ms
22
+ * @param {import("@loopover/engine").WriteRateLimitPolicies} [input.policies]
23
+ * @param {() => number} [input.randomFn] injected jitter source (defaults to mid-band draw)
24
+ * @param {{ append?: typeof appendGovernorEvent }} [options]
25
+ */
26
+ export function evaluateWriteRateLimitGate(input, options = {}) {
27
+ const append = options.append ?? appendGovernorEvent;
28
+ const verdict = evaluateWriteRateLimit(input);
29
+ const recorded = append(
30
+ buildWriteRateLimitGovernorLedgerEvent(input.repoFullName, input.actionClass, verdict),
31
+ );
32
+
33
+ if (verdict.allowed) {
34
+ return {
35
+ verdict,
36
+ recorded,
37
+ buckets: recordWriteRateLimitAllowed(
38
+ input.buckets,
39
+ input.actionClass,
40
+ input.repoFullName,
41
+ input.nowMs,
42
+ input.policies,
43
+ ),
44
+ backoffAttempts: clearWriteRateLimitBackoff(
45
+ input.backoffAttempts,
46
+ input.actionClass,
47
+ input.repoFullName,
48
+ ),
49
+ retryAtMs: null,
50
+ };
51
+ }
52
+
53
+ return {
54
+ verdict,
55
+ recorded,
56
+ buckets: input.buckets,
57
+ backoffAttempts: recordWriteRateLimitDenied(
58
+ input.backoffAttempts,
59
+ input.actionClass,
60
+ input.repoFullName,
61
+ ),
62
+ retryAtMs: input.nowMs + verdict.retryAfterMs,
63
+ };
64
+ }
@@ -0,0 +1,69 @@
1
+ export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT: "harness_submission_trigger_decision";
2
+
3
+ export type HarnessSubmissionSlopBand = "clean" | "low" | "elevated" | "high";
4
+ export type HarnessSubmissionMode = "observe" | "enforce";
5
+ export type HarnessSubmissionKillSwitchScope = "global" | "repo" | "none";
6
+
7
+ export type HarnessSubmissionCandidateInput = {
8
+ /** Forwarded to shouldSubmit's own kill-switch check (#2339). */
9
+ killSwitchScope: HarnessSubmissionKillSwitchScope;
10
+ repoFullName: string;
11
+ handoffPacket: {
12
+ worktreePath: string;
13
+ branchRef?: string;
14
+ diffSummary: string;
15
+ selfReviewVerdict: unknown;
16
+ attemptLogReference: string;
17
+ };
18
+ slopThreshold: HarnessSubmissionSlopBand;
19
+ mode: HarnessSubmissionMode;
20
+ maxConsecutiveGateBlocks?: number;
21
+ };
22
+
23
+ export interface HarnessSubmissionEventLedger {
24
+ appendEvent(event: { type: string; repoFullName?: string; payload: Record<string, unknown> }): { id: number; seq: number; type: string; repoFullName: string | null; payload: Record<string, unknown>; createdAt: string };
25
+ readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ type: string; repoFullName?: string | null; payload?: Record<string, unknown>; createdAt: string }>;
26
+ }
27
+
28
+ export type HarnessSubmissionDeps = {
29
+ eventLedger: HarnessSubmissionEventLedger;
30
+ sessionStartMs?: number;
31
+ };
32
+
33
+ export type HarnessSubmissionDecision = {
34
+ allow: boolean;
35
+ reasons: string[];
36
+ circuitBreakerTripped: boolean;
37
+ };
38
+
39
+ export type HarnessSubmissionResult = {
40
+ decision: HarnessSubmissionDecision;
41
+ event: { id: number; seq: number; type: string; repoFullName: string | null; payload: Record<string, unknown>; createdAt: string };
42
+ };
43
+
44
+ export function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number;
45
+
46
+ export function evaluateAndRecordHarnessSubmissionTrigger(candidate: HarnessSubmissionCandidateInput, deps: HarnessSubmissionDeps): HarnessSubmissionResult;
47
+
48
+ /** The exact input shape buildOpenPrSpec (root src/mcp/local-write-tools.ts) expects. */
49
+ export type OpenPrInput = {
50
+ repoFullName: string;
51
+ base: string;
52
+ head: string;
53
+ title: string;
54
+ body: string;
55
+ draft: boolean;
56
+ };
57
+
58
+ export type PrepareOpenPrSubmissionCandidate = HarnessSubmissionCandidateInput & {
59
+ base: string;
60
+ title: string;
61
+ body?: string;
62
+ draft?: boolean;
63
+ };
64
+
65
+ export type PrepareOpenPrSubmissionResult =
66
+ | { ready: true; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"]; openPrInput: OpenPrInput }
67
+ | { ready: false; decision: HarnessSubmissionDecision; event: HarnessSubmissionResult["event"] };
68
+
69
+ export function prepareOpenPrSubmission(candidate: PrepareOpenPrSubmissionCandidate, deps: HarnessSubmissionDeps): PrepareOpenPrSubmissionResult;
@@ -0,0 +1,138 @@
1
+ import { evaluateHarnessSubmissionTrigger } from "@loopover/engine";
2
+
3
+ // Harness submission-gate wiring orchestrator (#2337): the real-IO half of connecting the gated-submission
4
+ // decision (`shouldSubmit`, wrapped by `evaluateHarnessSubmissionTrigger`, @loopover/engine) to a
5
+ // real driving loop's own handoff signal. Reads the session's recent decision history to compute the
6
+ // consecutive-block circuit-breaker tally, consults the pure decision, and always records exactly one audit
7
+ // event -- regardless of outcome, so a paused-pending-human-review session leaves a full trail of why.
8
+ //
9
+ // NOT WIRED INTO ANY AUTOMATIC SCHEDULE: per this issue's own "manual owner sign-off on the wiring before this
10
+ // ships to any default-on profile" deliverable. `prepareOpenPrSubmission` below is the call site up to the
11
+ // cross-package boundary: on `allow: true` it shapes the exact input `buildOpenPrSpec` (root
12
+ // `src/mcp/local-write-tools.ts`) needs -- but does not, and cannot, call that function itself, since the spec
13
+ // builder lives in the private root `src/` tree, unreachable from this package (same cross-package-boundary
14
+ // reason self-review-adapter.ts's slop injection exists). A real root-side/MCP call site (e.g. the existing
15
+ // `gittensory_open_pr` tool, src/mcp/server.ts) takes `openPrInput` from a `ready: true` result and passes it
16
+ // to `buildOpenPrSpec` (or the equivalent tool call) to actually produce the runnable local-write spec. The
17
+ // CLI/driver entrypoint that instantiates a real `CodingAgentDriver` and calls `runIterateLoop` end to end with
18
+ // live credentials does not exist yet in this package -- that is separate, larger scope from this decision-to-
19
+ // payload bridge.
20
+ //
21
+ // SESSION-SCOPED, NOT PER-REPO: the circuit breaker's own "pauses the run entirely" wording means the tally is
22
+ // counted across EVERY repo's decisions this session, not scoped to one repo -- distinct from #2338's loop-
23
+ // reentry circuit breaker, which is deliberately per-repo (a rejection streak on one repo must not pause
24
+ // unrelated repos).
25
+
26
+ export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT = "harness_submission_trigger_decision";
27
+
28
+ /** Count consecutive `allow: false` decisions recorded at or after `sinceMs`, walking backward from the most
29
+ * recent decision until an `allow: true` breaks the streak (or history runs out). Session-scoped (not
30
+ * filtered by repo) to match the circuit breaker's own "pauses the run entirely" semantics. */
31
+ export function countConsecutiveGateBlocks(eventLedger, sinceMs) {
32
+ const decisions = eventLedger
33
+ .readEvents({})
34
+ .filter((event) => event.type === HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT && Date.parse(event.createdAt) >= sinceMs);
35
+ let count = 0;
36
+ for (let i = decisions.length - 1; i >= 0; i -= 1) {
37
+ if (decisions[i].payload?.allow === true) break;
38
+ count += 1;
39
+ }
40
+ return count;
41
+ }
42
+
43
+ /**
44
+ * Evaluate the harness submission trigger for one candidate handoff, reading real session history to compute
45
+ * the circuit-breaker tally, and always appending exactly one audit event. Fails closed (throws) on a
46
+ * malformed candidate or missing required dependency.
47
+ *
48
+ * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: object, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number }} candidate
49
+ * @param {{ eventLedger: object, sessionStartMs?: number }} deps
50
+ */
51
+ export function evaluateAndRecordHarnessSubmissionTrigger(candidate, deps) {
52
+ if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate");
53
+ if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) throw new Error("invalid_kill_switch_scope");
54
+ const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : "";
55
+ if (!repoFullName) throw new Error("invalid_repo_full_name");
56
+ if (!candidate.handoffPacket || typeof candidate.handoffPacket !== "object") throw new Error("invalid_handoff_packet");
57
+
58
+ if (!deps || typeof deps !== "object") throw new Error("invalid_harness_submission_deps");
59
+ const { eventLedger, sessionStartMs = 0 } = deps;
60
+ if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") {
61
+ throw new Error("invalid_event_ledger");
62
+ }
63
+
64
+ const consecutiveGateBlocks = countConsecutiveGateBlocks(eventLedger, sessionStartMs);
65
+
66
+ const decision = evaluateHarnessSubmissionTrigger({
67
+ killSwitchScope: candidate.killSwitchScope,
68
+ handoffPacket: candidate.handoffPacket,
69
+ slopThreshold: candidate.slopThreshold,
70
+ mode: candidate.mode,
71
+ consecutiveGateBlocks,
72
+ maxConsecutiveGateBlocks: candidate.maxConsecutiveGateBlocks,
73
+ });
74
+
75
+ const event = eventLedger.appendEvent({
76
+ type: HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT,
77
+ repoFullName,
78
+ payload: {
79
+ killSwitchScope: candidate.killSwitchScope,
80
+ allow: decision.allow,
81
+ reasons: decision.reasons,
82
+ circuitBreakerTripped: decision.circuitBreakerTripped,
83
+ consecutiveGateBlocks,
84
+ attemptLogReference: candidate.handoffPacket.attemptLogReference ?? null,
85
+ },
86
+ });
87
+
88
+ return { decision, event };
89
+ }
90
+
91
+ /**
92
+ * Bridge one completed handoff through the submission gate to a submission-READY payload -- the exact input
93
+ * shape `buildOpenPrSpec` expects (repoFullName/base/head/title/body/draft). On `allow: true` returns
94
+ * `{ ready: true, decision, event, openPrInput }`; otherwise `{ ready: false, decision, event }` -- the block
95
+ * reasons are on `decision.reasons` and already on the ledger via the wrapped call either way. Does NOT call
96
+ * `buildOpenPrSpec` itself (see this module's own doc comment for why it cannot) -- a real root-side/MCP call
97
+ * site takes `openPrInput` from a `ready: true` result and passes it to that function or the equivalent
98
+ * `gittensory_open_pr` MCP tool.
99
+ *
100
+ * Fails closed (throws) on a malformed candidate, mirroring evaluateAndRecordHarnessSubmissionTrigger's own
101
+ * validation -- a missing PR title/base is a caller bug that must never silently degrade into a garbage spec.
102
+ * The one field evaluateAndRecordHarnessSubmissionTrigger does NOT itself require -- handoffPacket.branchRef,
103
+ * optional there because iterate-loop.ts deliberately does not manage worktrees/branches -- IS required here,
104
+ * but only once the decision is known to be `allow: true`: a PR cannot be opened without a source branch, but a
105
+ * blocked candidate needs no branch at all, and must not throw for a reason unrelated to why it was blocked.
106
+ *
107
+ * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: { branchRef?: string, [key: string]: unknown }, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number, base: string, title: string, body?: string, draft?: boolean }} candidate
108
+ * @param {{ eventLedger: object, sessionStartMs?: number }} deps
109
+ */
110
+ export function prepareOpenPrSubmission(candidate, deps) {
111
+ if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate");
112
+ const base = typeof candidate.base === "string" ? candidate.base.trim() : "";
113
+ if (!base) throw new Error("invalid_pr_base");
114
+ const title = typeof candidate.title === "string" ? candidate.title.trim() : "";
115
+ if (!title) throw new Error("invalid_pr_title");
116
+
117
+ const { decision, event } = evaluateAndRecordHarnessSubmissionTrigger(candidate, deps);
118
+ if (!decision.allow) return { ready: false, decision, event };
119
+
120
+ // Only reached once evaluateAndRecordHarnessSubmissionTrigger has already validated handoffPacket is a
121
+ // well-formed object -- safe to read .branchRef directly.
122
+ const head = typeof candidate.handoffPacket.branchRef === "string" ? candidate.handoffPacket.branchRef.trim() : "";
123
+ if (!head) throw new Error("invalid_pr_head_branch");
124
+
125
+ return {
126
+ ready: true,
127
+ decision,
128
+ event,
129
+ openPrInput: {
130
+ repoFullName: candidate.repoFullName.trim(),
131
+ base,
132
+ head,
133
+ title,
134
+ body: typeof candidate.body === "string" ? candidate.body : "",
135
+ draft: candidate.draft === true,
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,12 @@
1
+ export function defaultRetryBackoffMs(attempt: number): number;
2
+
3
+ export function fetchWithRetry<Response extends { status: number }>(
4
+ fetchFn: (url: unknown, init?: unknown) => Promise<Response>,
5
+ url: unknown,
6
+ init?: unknown,
7
+ options?: {
8
+ maxAttempts?: number;
9
+ sleepFn?: (ms: number) => Promise<unknown>;
10
+ backoffMs?: (attempt: number) => number;
11
+ },
12
+ ): Promise<Response>;