@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,540 @@
1
+ // The autonomous supervising loop (#5135, Wave 3.5): the missing daemon/watch layer over the one-shot
2
+ // `discover`/`attempt` subcommands. Every existing piece it composes -- runDiscover, runAttempt,
3
+ // evaluateRunLoopBoundaryGate, attemptLoopReentry, buildLoopClosureSummary, governor-state.js -- already
4
+ // existed; this is the first caller that actually chains them into a real repeat-until-halted run.
5
+ //
6
+ // STRUCTURE (one cycle): kill-switch check -> pause-flag check (#4851, governor-state.js's persisted
7
+ // paused/reason/pausedAt) -> real-per-repo-policy-aware run-loop boundary gate (before claiming) -> real
8
+ // runAttempt -> real CI-status poll (ci-poller.js, #5394) + real PR-disposition poll
9
+ // (pr-disposition-poller.js, on a submitted outcome) -> real loop-closure summary -> real attemptLoopReentry
10
+ // decision. `attemptLoopReentry`'s own dequeue is the
11
+ // AUTHORITATIVE claim for every cycle after the first (its own doc: "if allowed -- dequeues the next
12
+ // candidate") -- this loop does not ALSO call portfolioQueue.dequeueNext() on a successful reentry, which
13
+ // would silently double-claim (the reentry's own claim would then leak as a permanently 'in_progress', never-
14
+ // attempted row). A manual dequeueNext() is used only to prime the very first cycle (no prior outcome exists
15
+ // yet to reenter from) and to refill after an empty queue.
16
+ //
17
+ // REAL, NOT FABRICATED: this loop is the first production caller of governor-state.js's `saveCapUsage`
18
+ // (turnsTaken from runMinerAttempt's own real `loopResult.totalTurnsUsed`, elapsedMs from real wall-clock
19
+ // measurement) and of a genuine per-identifier convergence history (attempts/consecutiveFailures/reenqueues
20
+ // tracked in this process's own memory across its own cycles) -- both were previously honest zero/placeholder
21
+ // literals (see attempt-input-builder.js's own header) because a ONE-SHOT `attempt` CLI invocation has no
22
+ // cross-call history to draw on. A long-running loop genuinely does.
23
+ //
24
+ // DOCUMENTED GAP: convergence/cap-usage history is IN-MEMORY, scoped to this loop process's own lifetime (cap
25
+ // usage itself persists across restarts via governor-state.js; per-identifier convergence counters do not --
26
+ // a durable version needs attempt-log.js to grow a repo+issue index, the same separate schema change
27
+ // attempt-input-builder.js's header already flags as out of scope here).
28
+
29
+ import { checkMinerKillSwitch } from "./governor-kill-switch.js";
30
+ import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
31
+ import { evaluateRunLoopBoundaryGate } from "./governor-run-halt.js";
32
+ import { openGovernorState } from "./governor-state.js";
33
+ import { initGovernorLedger } from "./governor-ledger.js";
34
+ import { initEventLedger } from "./event-ledger.js";
35
+ import { initPortfolioQueueStore } from "./portfolio-queue.js";
36
+ import { initRunStateStore } from "./run-state.js";
37
+ import { runDiscover } from "./discover-cli.js";
38
+ import { runAttempt } from "./attempt-cli.js";
39
+ import { resolveAmsPolicy } from "./ams-policy.js";
40
+ import { pollPrDisposition, classifyPrDisposition } from "./pr-disposition-poller.js";
41
+ import { pollCheckRuns } from "./ci-poller.js";
42
+ import { recordPrOutcomeSnapshot } from "./pr-outcome.js";
43
+ import { buildLoopClosureSummary } from "./loop-closure.js";
44
+ import { attemptLoopReentry } from "./loop-reentry.js";
45
+ import { parsePrNumberFromExecResult } from "./pr-number-parse.js";
46
+ import { DEFAULT_AMS_POLICY_SPEC } from "@loopover/engine";
47
+
48
+ const LOOP_USAGE =
49
+ "Usage: gittensory-miner loop <owner/repo> [<owner/repo>...] | --search <query> --miner-login <login> [--base <branch>] [--live] [--dry-run] [--max-cycles <n>] [--cycle-delay-ms <ms>] [--json]";
50
+ const DEFAULT_CYCLE_DELAY_MS = 60_000;
51
+ const ISSUE_IDENTIFIER_PATTERN = /^issue:(\d+)$/;
52
+
53
+ function parseRepoTarget(value) {
54
+ const trimmed = typeof value === "string" ? value.trim() : "";
55
+ const [owner, repo, extra] = trimmed.split("/");
56
+ if (!owner || !repo || extra !== undefined) return null;
57
+ return `${owner}/${repo}`;
58
+ }
59
+
60
+ function normalizeOptionalPositiveInt(value, label) {
61
+ const parsedValue = Number(value);
62
+ if (!Number.isFinite(parsedValue) || !Number.isInteger(parsedValue) || parsedValue < 0) {
63
+ throw new Error(`${label} must be a non-negative integer: ${value}`);
64
+ }
65
+ return parsedValue;
66
+ }
67
+
68
+ export function parseLoopArgs(args) {
69
+ const options = {
70
+ json: false,
71
+ minerLogin: null,
72
+ base: "main",
73
+ live: false,
74
+ dryRun: false,
75
+ search: null,
76
+ maxCycles: undefined,
77
+ cycleDelayMs: DEFAULT_CYCLE_DELAY_MS,
78
+ };
79
+ const targets = [];
80
+
81
+ for (let index = 0; index < args.length; index += 1) {
82
+ const token = args[index];
83
+ if (token === "--json") {
84
+ options.json = true;
85
+ continue;
86
+ }
87
+ if (token === "--live") {
88
+ options.live = true;
89
+ continue;
90
+ }
91
+ // #4847: see attempt-cli.js's own --dry-run comment -- distinct from --live's absence, this short-circuits
92
+ // BEFORE governor state or any other store is opened, guaranteeing zero discovery/queue/ledger writes.
93
+ if (token === "--dry-run") {
94
+ options.dryRun = true;
95
+ continue;
96
+ }
97
+ if (token === "--search") {
98
+ const value = args[index + 1];
99
+ if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
100
+ options.search = value;
101
+ index += 1;
102
+ continue;
103
+ }
104
+ if (token === "--miner-login") {
105
+ const value = args[index + 1];
106
+ if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
107
+ options.minerLogin = value;
108
+ index += 1;
109
+ continue;
110
+ }
111
+ if (token === "--base") {
112
+ const value = args[index + 1];
113
+ if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
114
+ options.base = value;
115
+ index += 1;
116
+ continue;
117
+ }
118
+ if (token === "--max-cycles") {
119
+ const value = args[index + 1];
120
+ if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
121
+ try {
122
+ options.maxCycles = normalizeOptionalPositiveInt(value, "--max-cycles");
123
+ } catch (error) {
124
+ return { error: error instanceof Error ? error.message : String(error) };
125
+ }
126
+ index += 1;
127
+ continue;
128
+ }
129
+ if (token === "--cycle-delay-ms") {
130
+ const value = args[index + 1];
131
+ if (!value || value.startsWith("-")) return { error: LOOP_USAGE };
132
+ try {
133
+ options.cycleDelayMs = normalizeOptionalPositiveInt(value, "--cycle-delay-ms");
134
+ } catch (error) {
135
+ return { error: error instanceof Error ? error.message : String(error) };
136
+ }
137
+ index += 1;
138
+ continue;
139
+ }
140
+ if (token.startsWith("-")) return { error: `Unknown option: ${token}` };
141
+ const target = parseRepoTarget(token);
142
+ if (!target) return { error: `Repository must be in owner/repo form: ${token}` };
143
+ targets.push(target);
144
+ }
145
+
146
+ if (options.search === null && targets.length === 0) return { error: LOOP_USAGE };
147
+ if (options.search !== null && targets.length > 0) return { error: "Pass either repository targets or --search, not both." };
148
+ if (!options.minerLogin) return { error: `--miner-login is required. ${LOOP_USAGE}` };
149
+
150
+ return {
151
+ targets,
152
+ search: options.search,
153
+ minerLogin: options.minerLogin,
154
+ base: options.base,
155
+ live: options.live,
156
+ dryRun: options.dryRun,
157
+ maxCycles: options.maxCycles,
158
+ cycleDelayMs: options.cycleDelayMs,
159
+ json: options.json,
160
+ };
161
+ }
162
+
163
+ function discoverArgv(parsed) {
164
+ return parsed.search !== null ? ["--search", parsed.search] : [...parsed.targets];
165
+ }
166
+
167
+ function parseIssueNumberFromIdentifier(identifier) {
168
+ const match = typeof identifier === "string" ? identifier.match(ISSUE_IDENTIFIER_PATTERN) : null;
169
+ return match ? Number(match[1]) : null;
170
+ }
171
+
172
+ function convergenceKey(repoFullName, identifier) {
173
+ return `${repoFullName}:${identifier}`;
174
+ }
175
+
176
+ function zeroConvergence() {
177
+ return { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false };
178
+ }
179
+
180
+ /**
181
+ * Run one full discover -> claim -> attempt -> observe -> reenter cycle repeatedly until a kill-switch trips,
182
+ * the run-loop boundary gate halts (non-convergence or a real budget/turn/elapsed cap), re-entry is declined,
183
+ * or `--max-cycles` is reached. Fails closed: refuses to start at all if governor state cannot be loaded.
184
+ *
185
+ * @param {string[]} args
186
+ * @param {{
187
+ * env?: Record<string, string | undefined>,
188
+ * nowMs?: number,
189
+ * githubToken?: string,
190
+ * apiBaseUrl?: string,
191
+ * sleepFn?: (delayMs: number) => Promise<void>,
192
+ * openGovernorState?: typeof openGovernorState,
193
+ * initEventLedger?: typeof initEventLedger,
194
+ * initGovernorLedger?: typeof initGovernorLedger,
195
+ * initPortfolioQueue?: () => import("./portfolio-queue.js").PortfolioQueueStore,
196
+ * initRunStateStore?: typeof initRunStateStore,
197
+ * runDiscover?: typeof runDiscover,
198
+ * runAttempt?: typeof runAttempt,
199
+ * resolveAmsPolicy?: typeof resolveAmsPolicy,
200
+ * checkMinerKillSwitch?: typeof checkMinerKillSwitch,
201
+ * evaluateRunLoopBoundaryGate?: typeof evaluateRunLoopBoundaryGate,
202
+ * pollPrDisposition?: typeof pollPrDisposition,
203
+ * pollCheckRuns?: typeof pollCheckRuns,
204
+ * recordPrOutcomeSnapshot?: typeof recordPrOutcomeSnapshot,
205
+ * buildLoopClosureSummary?: typeof buildLoopClosureSummary,
206
+ * attemptLoopReentry?: typeof attemptLoopReentry,
207
+ * attemptOptions?: Record<string, unknown>,
208
+ * prDispositionOptions?: Record<string, unknown>,
209
+ * ciPollOptions?: Record<string, unknown>,
210
+ * }} [options]
211
+ * @returns {Promise<number>}
212
+ */
213
+ export async function runLoop(args, options = {}) {
214
+ const parsed = parseLoopArgs(args);
215
+ if ("error" in parsed) {
216
+ return reportCliFailure(argsWantJson(args), parsed.error);
217
+ }
218
+
219
+ const env = options.env ?? process.env;
220
+ const sleepFn = options.sleepFn ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
221
+ const nowMsFn = () => options.nowMs ?? Date.now();
222
+ const sessionStartMs = nowMsFn();
223
+
224
+ // #4847: reports what a real loop invocation would target and returns BEFORE governor state or any other
225
+ // store (event/governor ledger, portfolio queue, run state) is opened -- a provable zero-write path, not just
226
+ // "opened but didn't write." The loop's own discovery call enqueues newly-found candidates into the LOCAL
227
+ // portfolio queue even before any attempt happens, so a faithful dry run cannot call it either.
228
+ if (parsed.dryRun) {
229
+ const dryRunResult = {
230
+ outcome: "dry_run",
231
+ targets: parsed.targets,
232
+ search: parsed.search,
233
+ minerLogin: parsed.minerLogin,
234
+ base: parsed.base,
235
+ live: parsed.live,
236
+ maxCycles: parsed.maxCycles ?? null,
237
+ };
238
+ if (parsed.json) {
239
+ console.log(JSON.stringify(dryRunResult, null, 2));
240
+ } else {
241
+ const target = parsed.search !== null ? `--search ${parsed.search}` : parsed.targets.join(", ");
242
+ console.log(
243
+ `DRY RUN: would run an autonomous loop against ${target} for ${parsed.minerLogin} (base: ${parsed.base}, live: ${parsed.live}). No discovery, queue, or ledger writes were made.`,
244
+ );
245
+ }
246
+ return 0;
247
+ }
248
+
249
+ let governorState;
250
+ try {
251
+ governorState = (options.openGovernorState ?? openGovernorState)();
252
+ } catch (error) {
253
+ return reportCliFailure(
254
+ parsed.json,
255
+ `Loop refuses to start: governor state cannot be loaded: ${describeCliError(error)}`,
256
+ 3,
257
+ );
258
+ }
259
+
260
+ const eventLedger = (options.initEventLedger ?? initEventLedger)();
261
+ const governorLedger = (options.initGovernorLedger ?? initGovernorLedger)();
262
+ const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)();
263
+ const runState = (options.initRunStateStore ?? initRunStateStore)();
264
+
265
+ const runDiscoverFn = options.runDiscover ?? runDiscover;
266
+ const runAttemptFn = options.runAttempt ?? runAttempt;
267
+ const resolveAmsPolicyFn = options.resolveAmsPolicy ?? resolveAmsPolicy;
268
+ const checkKillSwitchFn = options.checkMinerKillSwitch ?? checkMinerKillSwitch;
269
+ const evaluateBoundaryGateFn = options.evaluateRunLoopBoundaryGate ?? evaluateRunLoopBoundaryGate;
270
+ const pollPrDispositionFn = options.pollPrDisposition ?? pollPrDisposition;
271
+ const pollCheckRunsFn = options.pollCheckRuns ?? pollCheckRuns;
272
+ const recordPrOutcomeSnapshotFn = options.recordPrOutcomeSnapshot ?? recordPrOutcomeSnapshot;
273
+ const buildLoopClosureSummaryFn = options.buildLoopClosureSummary ?? buildLoopClosureSummary;
274
+ const attemptLoopReentryFn = options.attemptLoopReentry ?? attemptLoopReentry;
275
+
276
+ // Resolved ONCE, at the CLI-entrypoint layer, mirroring manage-poll.js's own runManagePoll (its
277
+ // recordManagePollSnapshot callee has no env fallback of its own either -- the top-level CLI function is
278
+ // where `process.env.GITHUB_TOKEN` gets read, then threaded down explicitly to every real GitHub caller).
279
+ // pollPrDisposition (unlike runDiscover, which falls back to process.env.GITHUB_TOKEN internally) has NO
280
+ // such fallback -- an unresolved githubToken here would silently poll unauthenticated.
281
+ const githubToken = options.githubToken ?? env.GITHUB_TOKEN ?? "";
282
+
283
+ async function runDiscoveryOnce() {
284
+ await runDiscoverFn(discoverArgv(parsed), {
285
+ initPortfolioQueue: () => portfolioQueue,
286
+ githubToken,
287
+ apiBaseUrl: options.apiBaseUrl,
288
+ nowMs: nowMsFn(),
289
+ });
290
+ }
291
+
292
+ let usage = governorState.loadCapUsage();
293
+ const convergenceHistory = new Map();
294
+ const cycles = [];
295
+ let sinceSeq = eventLedger.readEvents({}).at(-1)?.seq ?? 0;
296
+ let haltReason = null;
297
+
298
+ try {
299
+ // Checked BEFORE any work at all -- including the very first discovery call -- so an already-active kill
300
+ // switch OR an already-active pause (#4851) halts the loop without ever touching GitHub or the queue. The
301
+ // pause flag is real, persisted, operator/governor-writable state on governorState (toggled via
302
+ // `gittensory-miner governor pause`/`resume`) -- unlike the kill switch, a paused run resumes simply by being
303
+ // re-invoked: every piece of per-cycle state this loop reads (portfolioQueue, runState, governorState's own
304
+ // cap usage) is already durable, so clearing the flag and restarting continues exactly where it left off.
305
+ const initialKillSwitch = checkKillSwitchFn({ env });
306
+ const initialPauseState = governorState.loadPauseState();
307
+ let claimed = null;
308
+ if (initialKillSwitch.active) {
309
+ haltReason = `kill_switch_${initialKillSwitch.scope}`;
310
+ cycles.push({ cycle: 1, outcome: "halted", reason: haltReason });
311
+ } else if (initialPauseState.paused) {
312
+ haltReason = "paused";
313
+ cycles.push({ cycle: 1, outcome: "halted", reason: haltReason });
314
+ } else {
315
+ await runDiscoveryOnce();
316
+ claimed = portfolioQueue.dequeueNext();
317
+ }
318
+
319
+ let cycleIndex = haltReason !== null ? 1 : 0;
320
+ while (haltReason === null && (parsed.maxCycles === undefined || cycleIndex < parsed.maxCycles)) {
321
+ cycleIndex += 1;
322
+
323
+ const killSwitch = checkKillSwitchFn({ env });
324
+ if (killSwitch.active) {
325
+ haltReason = `kill_switch_${killSwitch.scope}`;
326
+ cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason });
327
+ break;
328
+ }
329
+
330
+ const pauseState = governorState.loadPauseState();
331
+ if (pauseState.paused) {
332
+ haltReason = "paused";
333
+ cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason });
334
+ break;
335
+ }
336
+
337
+ if (!claimed) {
338
+ cycles.push({ cycle: cycleIndex, outcome: "idle_queue_empty" });
339
+ await sleepFn(parsed.cycleDelayMs);
340
+ await runDiscoveryOnce();
341
+ claimed = portfolioQueue.dequeueNext();
342
+ continue;
343
+ }
344
+
345
+ const issueNumber = parseIssueNumberFromIdentifier(claimed.identifier);
346
+ if (issueNumber === null) {
347
+ // Never produced by enqueueRankedDiscovery in practice (always "issue:N") -- fail soft rather than
348
+ // crash the whole run: this exact item can never be attempted, so it will never resolve on retry.
349
+ portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
350
+ cycles.push({ cycle: cycleIndex, outcome: "skipped_malformed_identifier", identifier: claimed.identifier });
351
+ claimed = portfolioQueue.dequeueNext();
352
+ continue;
353
+ }
354
+
355
+ const key = convergenceKey(claimed.repoFullName, claimed.identifier);
356
+ const amsPolicy = await resolveAmsPolicyFn(claimed.repoFullName, { env });
357
+ const convergenceInput = convergenceHistory.get(key) ?? zeroConvergence();
358
+
359
+ const boundary = evaluateBoundaryGateFn(
360
+ {
361
+ runHalted: false,
362
+ usage,
363
+ limits: amsPolicy.spec.capLimits ?? DEFAULT_AMS_POLICY_SPEC.capLimits,
364
+ convergence: convergenceInput,
365
+ convergenceThresholds: amsPolicy.spec.convergenceThresholds ?? DEFAULT_AMS_POLICY_SPEC.convergenceThresholds,
366
+ inFlightItem: { repoFullName: claimed.repoFullName, identifier: claimed.identifier },
367
+ // Echoes claimed.apiBaseUrl (#5563), NOT the callback's own repoFullName/identifier alone -- two forge
368
+ // hosts can share an in-flight item with the same repo name+identifier.
369
+ markFailed: (repoFullName, identifier) => portfolioQueue.markFailed(repoFullName, identifier, claimed.apiBaseUrl),
370
+ },
371
+ { append: (event) => governorLedger.appendGovernorEvent(event) },
372
+ );
373
+
374
+ if (!boundary.canClaimNext) {
375
+ haltReason = `boundary_${boundary.verdict.reason}`;
376
+ cycles.push({ cycle: cycleIndex, outcome: "halted", reason: haltReason, repoFullName: claimed.repoFullName, identifier: claimed.identifier });
377
+ break;
378
+ }
379
+
380
+ convergenceInput.attempts += 1;
381
+ convergenceHistory.set(key, convergenceInput);
382
+
383
+ const cycleStartMs = nowMsFn();
384
+ let lastResult = null;
385
+ const attemptArgv = [
386
+ claimed.repoFullName,
387
+ String(issueNumber),
388
+ "--miner-login",
389
+ parsed.minerLogin,
390
+ "--base",
391
+ parsed.base,
392
+ ...(parsed.live ? ["--live"] : []),
393
+ ];
394
+ await runAttemptFn(attemptArgv, {
395
+ ...(options.attemptOptions ?? {}),
396
+ env,
397
+ onResult: (result) => {
398
+ lastResult = result;
399
+ },
400
+ });
401
+ const cycleElapsedMs = nowMsFn() - cycleStartMs;
402
+
403
+ usage = {
404
+ // Real for the agent-sdk provider (its own SDK result message reports total_cost_usd, wired through
405
+ // runMinerAttempt's real loopResult.totalCostUsd); the CLI-subprocess providers (claude-cli/codex-cli)
406
+ // report no cost signal today, so this contributes 0 for those runs -- an honest absence, not a
407
+ // fabricated number. A capLimits.budget dimension only ever meaningfully trips against agent-sdk spend.
408
+ budgetSpent: usage.budgetSpent + (lastResult?.totalCostUsd ?? 0),
409
+ turnsTaken: usage.turnsTaken + (lastResult?.totalTurnsUsed ?? 0),
410
+ elapsedMs: usage.elapsedMs + cycleElapsedMs,
411
+ };
412
+ governorState.saveCapUsage(usage);
413
+
414
+ const attemptOutcome = lastResult?.outcome ?? "attempt_error";
415
+ const submitted = attemptOutcome === "attempt_submitted";
416
+ // A repo-wide AI-usage-policy ban will never resolve on retry -- stop re-queuing it (matches
417
+ // rejection-signal.js's own "this repo bans automated contributions" semantics). Every other blocked/
418
+ // abandoned/stale/governed outcome MAY resolve on a later retry (transient infra, contention, a
419
+ // different iteration budget) and is requeued -- a genuinely stuck item is caught by non-convergence
420
+ // (reenqueues threshold) rather than silently retried forever.
421
+ const permanentBlock = attemptOutcome === "blocked_rejection_signaled";
422
+
423
+ if (submitted) {
424
+ portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
425
+ convergenceInput.reachedDone = true;
426
+ convergenceInput.consecutiveFailures = 0;
427
+ } else if (permanentBlock) {
428
+ portfolioQueue.markDone(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
429
+ convergenceInput.consecutiveFailures += 1;
430
+ } else {
431
+ portfolioQueue.markFailed(claimed.repoFullName, claimed.identifier, claimed.apiBaseUrl);
432
+ convergenceInput.consecutiveFailures += 1;
433
+ convergenceInput.reenqueues += 1;
434
+ }
435
+ convergenceHistory.set(key, convergenceInput);
436
+
437
+ let reentryOutcome = "other";
438
+ let prNumber = null;
439
+ let prDisposition = null;
440
+ let ciConclusion = null;
441
+ if (submitted) {
442
+ prNumber = parsePrNumberFromExecResult(lastResult?.execResult, claimed.repoFullName);
443
+ if (prNumber !== null) {
444
+ // Real CI-status observation (#5394): recorded BEFORE the disposition poll below, so a submitted
445
+ // PR's check-run state is captured even while it's still open, not just at its eventual merge/close.
446
+ // ci-poller.js's real GitHub check-run polling is a heuristic proxy for the gate verdict; the
447
+ // authoritative terminal merge/close outcome comes from pollPrDispositionFn below, sourced directly
448
+ // from GitHub's own PR state rather than a server-internal endpoint (#5450).
449
+ const ciStatus = await pollCheckRunsFn(claimed.repoFullName, prNumber, {
450
+ githubToken,
451
+ apiBaseUrl: options.apiBaseUrl,
452
+ ...(options.ciPollOptions ?? {}),
453
+ });
454
+ ciConclusion = ciStatus.conclusion;
455
+ eventLedger.appendEvent({
456
+ type: "ci_status_observed",
457
+ repoFullName: claimed.repoFullName,
458
+ payload: { prNumber, conclusion: ciStatus.conclusion, checkCount: ciStatus.checks.length, source: "ci-poller" },
459
+ });
460
+
461
+ prDisposition = await pollPrDispositionFn(claimed.repoFullName, prNumber, {
462
+ githubToken,
463
+ apiBaseUrl: options.apiBaseUrl,
464
+ ...(options.prDispositionOptions ?? {}),
465
+ });
466
+ if (prDisposition.state === "closed") {
467
+ recordPrOutcomeSnapshotFn(
468
+ {
469
+ repoFullName: claimed.repoFullName,
470
+ prNumber,
471
+ decision: prDisposition.merged ? "merged" : "closed",
472
+ closedAt: prDisposition.closedAt,
473
+ },
474
+ { eventLedger },
475
+ );
476
+ reentryOutcome = classifyPrDisposition(prDisposition);
477
+ }
478
+ }
479
+ }
480
+
481
+ const loopSummary = buildLoopClosureSummaryFn(
482
+ { eventLedger, portfolioQueue, runState },
483
+ { sinceSeq, repoFullName: claimed.repoFullName },
484
+ );
485
+ sinceSeq = loopSummary.lastSeq;
486
+
487
+ const reentry = attemptLoopReentryFn(
488
+ { killSwitchScope: killSwitch.scope, repoFullName: claimed.repoFullName, outcome: reentryOutcome },
489
+ { eventLedger, portfolioQueue, runState, nowMs: nowMsFn(), sessionStartMs, loopSummary },
490
+ );
491
+
492
+ cycles.push({
493
+ cycle: cycleIndex,
494
+ outcome: "attempted",
495
+ repoFullName: claimed.repoFullName,
496
+ identifier: claimed.identifier,
497
+ attemptOutcome,
498
+ reentryOutcome,
499
+ prNumber,
500
+ ciConclusion,
501
+ reentered: reentry.decision.reenter,
502
+ reasons: reentry.decision.reasons,
503
+ });
504
+
505
+ if (!reentry.decision.reenter) {
506
+ haltReason = `reentry_declined:${reentry.decision.reasons.join(",")}`;
507
+ break;
508
+ }
509
+
510
+ if (reentry.dequeued) {
511
+ claimed = reentry.dequeued;
512
+ await sleepFn(parsed.cycleDelayMs);
513
+ } else {
514
+ await sleepFn(parsed.cycleDelayMs);
515
+ await runDiscoveryOnce();
516
+ claimed = portfolioQueue.dequeueNext();
517
+ }
518
+ }
519
+
520
+ if (haltReason === null && parsed.maxCycles !== undefined) {
521
+ haltReason = "max_cycles_reached";
522
+ }
523
+
524
+ const summary = { haltReason, cyclesRun: cycles.length, cycles };
525
+ if (parsed.json) {
526
+ console.log(JSON.stringify(summary, null, 2));
527
+ } else {
528
+ console.log(`Loop finished after ${cycles.length} cycle(s): ${haltReason ?? "unknown"}.`);
529
+ }
530
+ return 0;
531
+ } catch (error) {
532
+ return reportCliFailure(parsed.json, describeCliError(error));
533
+ } finally {
534
+ governorState.close();
535
+ eventLedger.close();
536
+ governorLedger.close();
537
+ portfolioQueue.close();
538
+ runState.close();
539
+ }
540
+ }
@@ -0,0 +1,35 @@
1
+ export interface LoopClosureEventLedger {
2
+ readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ seq?: number; type?: unknown; repoFullName?: string | null }>;
3
+ }
4
+
5
+ export interface LoopClosurePortfolioQueue {
6
+ listQueue(repoFullName: string | null): Array<{ status?: unknown }>;
7
+ }
8
+
9
+ export interface LoopClosureRunState {
10
+ getRunState(repoFullName: string): string | null;
11
+ }
12
+
13
+ export interface LoopClosureSources {
14
+ eventLedger: LoopClosureEventLedger;
15
+ portfolioQueue: LoopClosurePortfolioQueue;
16
+ runState?: LoopClosureRunState;
17
+ }
18
+
19
+ export interface LoopClosureOptions {
20
+ /** Event-ledger seq at the END of the prior cycle; events with a strictly greater seq are "this cycle". */
21
+ sinceSeq?: number;
22
+ /** Scope the summary to a single repo (its events and queue entries) when set. */
23
+ repoFullName?: string;
24
+ }
25
+
26
+ export interface LoopClosureSummary {
27
+ sinceSeq: number | null;
28
+ /** Highest event seq observed this cycle (>= sinceSeq); the boundary a caller passes as the next cycle's sinceSeq. */
29
+ lastSeq: number;
30
+ events: { total: number; byType: Record<string, number> };
31
+ queue: { total: number; byStatus: Record<string, number> };
32
+ runState: string | null;
33
+ }
34
+
35
+ export function buildLoopClosureSummary(sources: LoopClosureSources, options?: LoopClosureOptions): LoopClosureSummary;
@@ -0,0 +1,66 @@
1
+ // Loop-closure summary builder (pure, read-only) — #4282, Wave 2 tracker #2353 (miner-manage phase).
2
+ //
3
+ // A pure, read-only aggregator in the spirit of manage-status.js's collectManageStatus: read across the local-state
4
+ // primitives (event ledger, portfolio queue, run-state) and summarize what happened in a completed
5
+ // discover→plan→prepare→manage cycle BEFORE the miner loop considers re-entering (idle → discovering again). It
6
+ // never calls GitHub, never writes a local store, and never decides whether to re-enter or performs the re-entry
7
+ // itself — it only builds the summary a future caller reads before making that call.
8
+ //
9
+ // Cycle boundary is CALLER-SUPPLIED (deliberately, per the issue): `options.sinceSeq` is the event-ledger seq at the
10
+ // END of the prior cycle, so events with a STRICTLY greater seq are "this cycle" — reusing event-ledger.js's own
11
+ // `readEvents({ since })` cursor rather than inventing a new persisted cycle-boundary marker. The ledger stores an
12
+ // OPEN type vocabulary (only the phase writers define concrete types), so events are tallied GENERICALLY by `type`;
13
+ // new phase event types (plans built, PRs prepared/opened, outcomes recorded — landing via sibling issues) surface
14
+ // in the tally automatically without a hardcoded list here.
15
+
16
+ /**
17
+ * Build a read-only loop-closure summary from local-state sources. Pure: reads `sources` + `options` and returns a
18
+ * structured summary, mutating nothing.
19
+ *
20
+ * @param {{ eventLedger: { readEvents: Function }, portfolioQueue: { listQueue: Function }, runState?: { getRunState: Function } }} sources
21
+ * @param {{ sinceSeq?: number, repoFullName?: string }} [options]
22
+ * @returns {{ sinceSeq: number|null, lastSeq: number, events: { total: number, byType: Record<string, number> }, queue: { total: number, byStatus: Record<string, number> }, runState: string|null }}
23
+ */
24
+ export function buildLoopClosureSummary(sources, options = {}) {
25
+ const eventLedger = sources?.eventLedger;
26
+ const portfolioQueue = sources?.portfolioQueue;
27
+ const runState = sources?.runState;
28
+ if (!eventLedger || typeof eventLedger.readEvents !== "function") throw new Error("invalid_event_ledger");
29
+ if (!portfolioQueue || typeof portfolioQueue.listQueue !== "function") throw new Error("invalid_portfolio_queue");
30
+
31
+ const repoFullName = typeof options.repoFullName === "string" && options.repoFullName.length > 0 ? options.repoFullName : null;
32
+ const sinceSeq = Number.isInteger(options.sinceSeq) && options.sinceSeq >= 0 ? options.sinceSeq : null;
33
+
34
+ // Bound "this cycle" to events after the prior cycle's ending seq; event-ledger applies the `since`/repo filter.
35
+ const filter = {};
36
+ if (repoFullName !== null) filter.repoFullName = repoFullName;
37
+ if (sinceSeq !== null) filter.since = sinceSeq;
38
+ const events = eventLedger.readEvents(filter);
39
+
40
+ const byType = {};
41
+ let lastSeq = sinceSeq ?? 0;
42
+ for (const event of events) {
43
+ const type = typeof event?.type === "string" && event.type.length > 0 ? event.type : "unknown";
44
+ byType[type] = (byType[type] ?? 0) + 1;
45
+ if (Number.isInteger(event?.seq) && event.seq > lastSeq) lastSeq = event.seq;
46
+ }
47
+
48
+ const byStatus = {};
49
+ const queueEntries = portfolioQueue.listQueue(repoFullName);
50
+ for (const entry of queueEntries) {
51
+ const status = typeof entry?.status === "string" && entry.status.length > 0 ? entry.status : "unknown";
52
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
53
+ }
54
+
55
+ const currentRunState = runState && typeof runState.getRunState === "function" && repoFullName !== null
56
+ ? runState.getRunState(repoFullName)
57
+ : null;
58
+
59
+ return {
60
+ sinceSeq,
61
+ lastSeq,
62
+ events: { total: events.length, byType },
63
+ queue: { total: queueEntries.length, byStatus },
64
+ runState: currentRunState ?? null,
65
+ };
66
+ }