@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,99 @@
1
+ // Calibration report: join the miner's own predicted gate verdicts with the realized outcomes it later observed
2
+ // (#4849). Read-only aggregation only — it never touches the live scoring/calibration logic that feeds the gate
3
+ // (maintainer-owned). Builds on the types-only scaffolding in calibration-types.js.
4
+ import {
5
+ isCalibrationReport,
6
+ isCalibrationRow,
7
+ isObservedOutcomeRecord,
8
+ isPredictedVerdictRecord,
9
+ } from "./calibration-types.js";
10
+
11
+ export { isCalibrationReport, isCalibrationRow, isObservedOutcomeRecord, isPredictedVerdictRecord };
12
+
13
+ /** Normalize a decision string to the calibration vocabulary (`merge` / `close` / `hold`), or `""` when it is
14
+ * unrecognized. `value` is always the already-validated non-empty string field of a record (the type guards run
15
+ * first), so no non-string handling is needed here. Accepts both the predicted (`merge`/`close`/`hold`) and the
16
+ * realized (`merged`/`closed`) forms. */
17
+ function normalizeDecision(value) {
18
+ const decision = value.trim().toLowerCase();
19
+ if (decision === "merge" || decision === "merged") return "merge";
20
+ if (decision === "close" || decision === "closed") return "close";
21
+ if (decision === "hold") return "hold";
22
+ return "";
23
+ }
24
+
25
+ function emptyRow(project) {
26
+ return {
27
+ project,
28
+ wouldMerge: 0,
29
+ mergeConfirmed: 0,
30
+ mergeFalse: 0,
31
+ wouldClose: 0,
32
+ closeConfirmed: 0,
33
+ closeFalse: 0,
34
+ hold: 0,
35
+ decided: 0,
36
+ mergePrecision: null,
37
+ closePrecision: null,
38
+ };
39
+ }
40
+
41
+ // Key a record by its (project, targetId). Project and targetId are validated non-empty strings; the space
42
+ // separator is fine for keying (collisions across different (project, targetId) pairs are astronomically
43
+ // unlikely and would only merge two projects' tallies, never fabricate a false one).
44
+ function recordKey(project, targetId) {
45
+ return `${project} ${targetId}`;
46
+ }
47
+
48
+ /**
49
+ * Join predicted-verdict records with realized-outcome records into a per-project calibration report. Pure and
50
+ * read-only. A prediction counts as "decided" only when a realized outcome for the SAME `(project, targetId)`
51
+ * exists AND resolves to a clear `merge` or `close`; a still-pending prediction (no outcome) or one whose outcome
52
+ * is unrecognized is skipped. Per project it tallies the confusion matrix (would-merge/close vs confirmed/false,
53
+ * plus holds) and derives merge/close precision (null below one relevant sample). Malformed records on either
54
+ * side are ignored. Rows are sorted by project for a stable render.
55
+ *
56
+ * @param {import("./calibration-types.js").PredictedVerdictRecord[]} predictions
57
+ * @param {import("./calibration-types.js").ObservedOutcomeRecord[]} outcomes
58
+ * @returns {import("./calibration-types.js").CalibrationReport}
59
+ */
60
+ export function buildCalibrationReport(predictions, outcomes) {
61
+ const outcomeByKey = new Map();
62
+ for (const outcome of Array.isArray(outcomes) ? outcomes : []) {
63
+ if (!isObservedOutcomeRecord(outcome)) continue;
64
+ outcomeByKey.set(recordKey(outcome.project, outcome.targetId), normalizeDecision(outcome.outcomeDecision));
65
+ }
66
+
67
+ const byProject = new Map();
68
+ for (const prediction of Array.isArray(predictions) ? predictions : []) {
69
+ if (!isPredictedVerdictRecord(prediction)) continue;
70
+ const observed = outcomeByKey.get(recordKey(prediction.project, prediction.targetId));
71
+ if (observed !== "merge" && observed !== "close") continue; // pending or unclassifiable outcome
72
+ let row = byProject.get(prediction.project);
73
+ if (!row) {
74
+ row = emptyRow(prediction.project);
75
+ byProject.set(prediction.project, row);
76
+ }
77
+ row.decided += 1;
78
+ const predicted = normalizeDecision(prediction.predictedDecision);
79
+ if (predicted === "merge") {
80
+ row.wouldMerge += 1;
81
+ if (observed === "merge") row.mergeConfirmed += 1;
82
+ else row.mergeFalse += 1;
83
+ } else if (predicted === "close") {
84
+ row.wouldClose += 1;
85
+ if (observed === "close") row.closeConfirmed += 1;
86
+ else row.closeFalse += 1;
87
+ } else if (predicted === "hold") {
88
+ row.hold += 1;
89
+ }
90
+ }
91
+
92
+ const rows = [...byProject.values()].sort((a, b) => a.project.localeCompare(b.project));
93
+ for (const row of rows) {
94
+ row.mergePrecision = row.wouldMerge > 0 ? row.mergeConfirmed / row.wouldMerge : null;
95
+ row.closePrecision = row.wouldClose > 0 ? row.closeConfirmed / row.wouldClose : null;
96
+ }
97
+ // Signal exists once any project carries at least one decided (predicted-then-realized) sample.
98
+ return { hasSignal: rows.length > 0, rows };
99
+ }
@@ -0,0 +1,33 @@
1
+ export type CheckRunConclusion = "pending" | "success" | "failure" | "neutral";
2
+
3
+ export type NormalizedCheckRun = {
4
+ name: string;
5
+ status: string;
6
+ conclusion: CheckRunConclusion;
7
+ detailsUrl: string | null;
8
+ startedAt: string | null;
9
+ completedAt: string | null;
10
+ };
11
+
12
+ export type PollCheckRunsResult = {
13
+ conclusion: CheckRunConclusion;
14
+ checks: NormalizedCheckRun[];
15
+ headSha: string;
16
+ attempts: number;
17
+ };
18
+
19
+ export type PollCheckRunsOptions = {
20
+ apiBaseUrl?: string;
21
+ fetchFn?: typeof fetch;
22
+ githubToken?: string;
23
+ maxAttempts?: number;
24
+ minIntervalMs?: number;
25
+ maxIntervalMs?: number;
26
+ sleepFn?: (delayMs: number) => Promise<unknown>;
27
+ };
28
+
29
+ export function pollCheckRuns(
30
+ repoFullName: string,
31
+ prNumber: number,
32
+ options?: PollCheckRunsOptions,
33
+ ): Promise<PollCheckRunsResult>;
@@ -0,0 +1,234 @@
1
+ import { fetchWithRetry } from "./http-retry.js";
2
+
3
+ const defaultApiBaseUrl = "https://api.github.com";
4
+ const defaultMinIntervalMs = 60_000;
5
+ const defaultMaxIntervalMs = 5 * 60_000;
6
+ const defaultMaxAttempts = 1;
7
+ const githubApiVersion = "2022-11-28";
8
+
9
+ function normalizeApiBaseUrl(value) {
10
+ if (value === undefined) return defaultApiBaseUrl;
11
+ if (typeof value !== "string" || !value.trim()) return defaultApiBaseUrl;
12
+ let parsed;
13
+ try {
14
+ parsed = new URL(value.trim());
15
+ } catch {
16
+ throw new Error("invalid_api_base_url");
17
+ }
18
+ if (parsed.protocol !== "https:" || parsed.hostname !== "api.github.com") {
19
+ throw new Error("invalid_api_base_url");
20
+ }
21
+ parsed.pathname = parsed.pathname.replace(/\/+$/, "");
22
+ parsed.search = "";
23
+ parsed.hash = "";
24
+ return parsed.toString().replace(/\/+$/, "");
25
+ }
26
+
27
+ function normalizePositiveInt(value, fallback, min, max) {
28
+ if (!Number.isFinite(value)) return fallback;
29
+ return Math.min(max, Math.max(min, Math.floor(value)));
30
+ }
31
+
32
+ function normalizeOptions(options = {}) {
33
+ return {
34
+ apiBaseUrl: normalizeApiBaseUrl(options.apiBaseUrl),
35
+ fetchFn: options.fetchFn ?? fetch,
36
+ githubToken: typeof options.githubToken === "string" ? options.githubToken.trim() : "",
37
+ maxAttempts: normalizePositiveInt(options.maxAttempts, defaultMaxAttempts, 1, 20),
38
+ minIntervalMs: normalizePositiveInt(options.minIntervalMs, defaultMinIntervalMs, 1, 60 * 60_000),
39
+ maxIntervalMs: normalizePositiveInt(options.maxIntervalMs, defaultMaxIntervalMs, 1, 60 * 60_000),
40
+ sleepFn:
41
+ options.sleepFn ??
42
+ ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))),
43
+ };
44
+ }
45
+
46
+ function parseRepoFullName(repoFullName) {
47
+ if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name");
48
+ const [owner, repo, extra] = repoFullName.split("/");
49
+ if (!owner?.trim() || !repo?.trim() || extra !== undefined) {
50
+ throw new Error("invalid_repo_full_name");
51
+ }
52
+ return { owner: owner.trim(), repo: repo.trim() };
53
+ }
54
+
55
+ function normalizePullNumber(value) {
56
+ if (!Number.isInteger(value) || value <= 0) throw new Error("invalid_pr_number");
57
+ return value;
58
+ }
59
+
60
+ function githubHeaders(githubToken) {
61
+ const headers = {
62
+ accept: "application/vnd.github+json",
63
+ "user-agent": "loopover-miner",
64
+ "x-github-api-version": githubApiVersion,
65
+ };
66
+ if (githubToken) headers.authorization = `Bearer ${githubToken}`;
67
+ return headers;
68
+ }
69
+
70
+ function repoPath(target, suffix) {
71
+ return `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}${suffix}`;
72
+ }
73
+
74
+ function apiUrl(apiBaseUrl, path, query = "") {
75
+ return `${apiBaseUrl}${path}${query}`;
76
+ }
77
+
78
+ function githubError(response, payload) {
79
+ const code = `github_${response.status}`;
80
+ const githubMessage =
81
+ typeof payload?.message === "string" && payload.message.trim() ? payload.message : null;
82
+ const message = githubMessage ? `${code}: ${githubMessage}` : code;
83
+ return Object.assign(new Error(message), { code, githubMessage });
84
+ }
85
+
86
+ async function githubGetJsonResponse(url, options) {
87
+ // Retry transient network errors / 5xx around this single call (#4829), distinct from the poller's own
88
+ // pending-retry loop; the poller's injected sleepFn keeps tests instant.
89
+ const response = await fetchWithRetry(
90
+ options.fetchFn,
91
+ url,
92
+ { method: "GET", headers: githubHeaders(options.githubToken) },
93
+ { sleepFn: options.sleepFn },
94
+ );
95
+ const payload = await response.json().catch(() => null);
96
+ if (!response.ok) {
97
+ throw githubError(response, payload);
98
+ }
99
+ return { payload, response };
100
+ }
101
+
102
+ async function githubGetJson(url, options) {
103
+ const { payload } = await githubGetJsonResponse(url, options);
104
+ return payload;
105
+ }
106
+
107
+ function hasNextLink(response) {
108
+ return /<[^>]+>;\s*rel="next"/.test(response.headers.get("link") ?? "");
109
+ }
110
+
111
+ function payloadTotalCount(payload) {
112
+ const totalCount = Number(payload?.total_count);
113
+ return Number.isInteger(totalCount) && totalCount >= 0 ? totalCount : null;
114
+ }
115
+
116
+ function normalizeConclusion(checkRun) {
117
+ if (!checkRun || typeof checkRun !== "object") return "pending";
118
+ if (checkRun.status !== "completed") return "pending";
119
+ switch (checkRun.conclusion) {
120
+ case "success":
121
+ case "skipped":
122
+ return "success";
123
+ case "neutral":
124
+ return "neutral";
125
+ case "failure":
126
+ case "cancelled":
127
+ case "timed_out":
128
+ case "action_required":
129
+ case "stale":
130
+ case "startup_failure":
131
+ return "failure";
132
+ default:
133
+ return "pending";
134
+ }
135
+ }
136
+
137
+ function normalizeCheckRun(checkRun) {
138
+ return {
139
+ name: typeof checkRun?.name === "string" ? checkRun.name : "",
140
+ status: typeof checkRun?.status === "string" ? checkRun.status : "unknown",
141
+ conclusion: normalizeConclusion(checkRun),
142
+ detailsUrl: typeof checkRun?.details_url === "string" ? checkRun.details_url : null,
143
+ startedAt: typeof checkRun?.started_at === "string" ? checkRun.started_at : null,
144
+ completedAt: typeof checkRun?.completed_at === "string" ? checkRun.completed_at : null,
145
+ };
146
+ }
147
+
148
+ function aggregateConclusion(checks) {
149
+ if (checks.length === 0) return "pending";
150
+ if (checks.some((check) => check.conclusion === "failure")) return "failure";
151
+ if (checks.some((check) => check.conclusion === "pending")) return "pending";
152
+ if (checks.every((check) => check.conclusion === "success")) return "success";
153
+ return "neutral";
154
+ }
155
+
156
+ function backoffDelayMs(attemptIndex, options) {
157
+ const exponent = Math.min(10, Math.max(0, attemptIndex));
158
+ return Math.min(options.maxIntervalMs, options.minIntervalMs * 2 ** exponent);
159
+ }
160
+
161
+ async function fetchHeadSha(target, prNumber, options) {
162
+ const payload = await githubGetJson(
163
+ apiUrl(options.apiBaseUrl, repoPath(target, `/pulls/${prNumber}`)),
164
+ options,
165
+ );
166
+ const headSha = payload?.head?.sha;
167
+ if (typeof headSha !== "string" || !headSha) throw new Error("github_pr_head_sha_missing");
168
+ return headSha;
169
+ }
170
+
171
+ async function fetchCheckRuns(target, headSha, options) {
172
+ const checks = [];
173
+ let page = 1;
174
+ let expectedTotalCount = null;
175
+ while (true) {
176
+ const { payload, response } = await githubGetJsonResponse(
177
+ apiUrl(
178
+ options.apiBaseUrl,
179
+ repoPath(target, `/commits/${encodeURIComponent(headSha)}/check-runs`),
180
+ `?per_page=100&page=${page}`,
181
+ ),
182
+ options,
183
+ );
184
+ if (!Array.isArray(payload?.check_runs)) {
185
+ throw new Error("github_check_runs_malformed");
186
+ }
187
+ const pageChecks = payload.check_runs.map(normalizeCheckRun);
188
+ checks.push(...pageChecks);
189
+ expectedTotalCount = payloadTotalCount(payload) ?? expectedTotalCount;
190
+ if (!hasNextLink(response) && (expectedTotalCount === null || checks.length >= expectedTotalCount)) {
191
+ return checks;
192
+ }
193
+ if (pageChecks.length === 0) {
194
+ throw new Error("github_check_runs_pagination_incomplete");
195
+ }
196
+ page += 1;
197
+ }
198
+ }
199
+
200
+ export async function pollCheckRuns(repoFullName, prNumber, options = {}) {
201
+ const target = parseRepoFullName(repoFullName);
202
+ const normalizedPrNumber = normalizePullNumber(prNumber);
203
+ const normalizedOptions = normalizeOptions(options);
204
+
205
+ let latest = { conclusion: "pending", checks: [], headSha: "", attempts: 0 };
206
+ for (let attempt = 0; attempt < normalizedOptions.maxAttempts; attempt += 1) {
207
+ const headSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions);
208
+ const checks = await fetchCheckRuns(target, headSha, normalizedOptions);
209
+ latest = {
210
+ conclusion: aggregateConclusion(checks),
211
+ checks,
212
+ headSha,
213
+ attempts: attempt + 1,
214
+ };
215
+ if (latest.conclusion !== "pending") {
216
+ const currentHeadSha = await fetchHeadSha(target, normalizedPrNumber, normalizedOptions);
217
+ if (currentHeadSha === headSha) {
218
+ return latest;
219
+ }
220
+ latest = {
221
+ conclusion: "pending",
222
+ checks: [],
223
+ headSha: currentHeadSha,
224
+ attempts: attempt + 1,
225
+ };
226
+ }
227
+ if (attempt === normalizedOptions.maxAttempts - 1) {
228
+ return latest;
229
+ }
230
+ await normalizedOptions.sleepFn(backoffDelayMs(attempt, normalizedOptions));
231
+ }
232
+
233
+ return latest;
234
+ }
@@ -0,0 +1,21 @@
1
+ /** An observed claim on an issue: a PR/claimant number plus when it claimed the linked issue (if known). */
2
+ export type ObservedClaim = {
3
+ number: number;
4
+ claimedAt?: string | null | undefined;
5
+ };
6
+
7
+ /** The engine `DuplicateClaimMember` shape this module bridges an {@link ObservedClaim} to. */
8
+ export type ClaimMember = {
9
+ number: number;
10
+ linkedIssueClaimedAt: string | null;
11
+ };
12
+
13
+ /** The adjudication result: the go/no-go `isWinner`, plus a DISPLAY-only `winnerNumber` (null when not determinable). */
14
+ export type ClaimAdjudication = {
15
+ isWinner: boolean;
16
+ winnerNumber: number | null;
17
+ };
18
+
19
+ export function toClaimMember(claim: ObservedClaim): ClaimMember;
20
+
21
+ export function adjudicateSoftClaim(self: ObservedClaim, competing?: readonly ObservedClaim[]): ClaimAdjudication;
@@ -0,0 +1,36 @@
1
+ // Soft-claim adjudication (#4291). Decides which of several miners claiming the same issue proceeds, by REUSING the
2
+ // maintainer-side duplicate-cluster election (`isDuplicateClusterWinnerByClaim` from @loopover/engine)
3
+ // rather than reimplementing it — so the miner and the maintainer gate agree on exactly one winner by construction.
4
+ //
5
+ // The local claim ledger is 100% client-side and cannot see other miners' claims, so the competing-claim signal
6
+ // must come from something publicly observable: the OPEN PRs that link the same issue (an issue with several open
7
+ // PRs linking it IS the public signal of a contested claim). The caller assembles that set — exactly like the
8
+ // maintainer-side callers in src/ do — and passes it here.
9
+ import { isDuplicateClusterWinnerByClaim, resolveDuplicateClusterWinnerNumber } from "@loopover/engine";
10
+
11
+ /**
12
+ * Map an observed claim record to the engine's `DuplicateClaimMember`. The field names deliberately DIFFER — the
13
+ * local ledger / observed data expose `claimedAt`, the engine election reads `linkedIssueClaimedAt` — so the bridge
14
+ * is explicit (they are not interchangeable by accident of naming). `createdAt` is intentionally omitted: the
15
+ * election ignores it (an older PR can claim a linked issue later by editing its body). Pure.
16
+ */
17
+ export function toClaimMember(claim) {
18
+ return { number: claim.number, linkedIssueClaimedAt: claim.claimedAt ?? null };
19
+ }
20
+
21
+ /**
22
+ * Adjudicate whether THIS miner's soft-claim wins a contested issue. `self` is this miner's claim and `competing`
23
+ * is the publicly-observable set of OTHER open PRs linking the same issue; each entry is `{ number, claimedAt }`.
24
+ * Returns the go/no-go `isWinner` (driven ONLY by `isDuplicateClusterWinnerByClaim`) plus a DISPLAY-only
25
+ * `winnerNumber` (from `resolveDuplicateClusterWinnerNumber`, for surfacing "you lost this claim to PR #N" to the
26
+ * operator — never for the decision). Pure — no IO. Fail-closed: a missing/sparse claim time loses; the winner is
27
+ * `null` when the ordering is too sparse to be sure (it never guesses). An empty `competing` list ⇒ trivial winner.
28
+ */
29
+ export function adjudicateSoftClaim(self, competing = []) {
30
+ const selfMember = toClaimMember(self);
31
+ const siblings = competing.map(toClaimMember);
32
+ return {
33
+ isWinner: isDuplicateClusterWinnerByClaim(selfMember, siblings),
34
+ winnerNumber: resolveDuplicateClusterWinnerNumber(selfMember, siblings),
35
+ };
36
+ }
@@ -0,0 +1,29 @@
1
+ import type { LiveIssueSnapshot } from "./submission-freshness-check.js";
2
+ import type { ObservedClaim } from "./claim-adjudication.js";
3
+ import type { LocalWriteActionSpec } from "@loopover/engine";
4
+
5
+ export function assembleCompetingClaims(
6
+ snapshot: LiveIssueSnapshot | null | undefined,
7
+ selfPrNumber: number,
8
+ minerLogin: string,
9
+ ): ObservedClaim[];
10
+
11
+ export type ClaimConflictInput = {
12
+ repoFullName: string;
13
+ issueNumber: number;
14
+ selfPrNumber: number;
15
+ selfClaimedAt: string | null;
16
+ minerLogin: string;
17
+ };
18
+
19
+ export type ClaimConflictDeps = {
20
+ fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<LiveIssueSnapshot | null>;
21
+ executeLocalWrite: (spec: LocalWriteActionSpec) => Promise<unknown>;
22
+ };
23
+
24
+ export type ClaimConflictResult =
25
+ | { checked: false; reason: "live_state_unavailable" }
26
+ | { checked: true; isWinner: true; winnerNumber: number | null; competingCount: number }
27
+ | { checked: true; isWinner: false; winnerNumber: number | null; competingCount: number; closeResult: unknown };
28
+
29
+ export function resolveClaimConflict(input: ClaimConflictInput, deps: ClaimConflictDeps): Promise<ClaimConflictResult>;
@@ -0,0 +1,101 @@
1
+ // Real claim-conflict resolution (#4848): the missing piece over claim-adjudication.js's own adjudicator,
2
+ // which is correct and well-tested in isolation but has no caller that assembles a REAL competing-claims set.
3
+ // checkSubmissionFreshness (submission-freshness-check.js) already catches the common case pre-submission --
4
+ // aborting before open_pr if another author's PR already references the issue -- but that check can only see
5
+ // what's PUBLIC at the moment it runs. Two miners racing closely enough that BOTH pass their own freshness
6
+ // check before either's PR exists yet is a genuine TOCTOU window freshness cannot close. This module is the
7
+ // POST-submission reconciliation for exactly that window: once THIS miner's PR is real and public, check
8
+ // whether ANOTHER open PR also claims the same issue and, if this miner's claim loses the election, close its
9
+ // own just-opened PR (never anyone else's) -- the write action the contributor-vs-maintainer safety framework
10
+ // keeps maintainer-only (#4833's own scope note), since it means the autonomous loop acts on a race-resolution
11
+ // decision with no human review.
12
+ //
13
+ // CLAIM-TIME ASYMMETRY (documented, not accidental): `self`'s claimedAt is the miner's OWN real local
14
+ // claim-ledger timestamp (claim-ledger.js, recorded before work even started). A competing PR's claimedAt uses
15
+ // its real GitHub `createdAt` instead -- the maintainer gate's own duplicate-winner election uses gittensory
16
+ // server's "first observed this PR's linked-issue set" timestamp, but that requires a continuous, persistent
17
+ // observation history this stateless client-side tool does not have for a PR it doesn't own. `createdAt` is
18
+ // the best real, publicly-observable proxy available for someone else's PR -- live-issue-snapshot.js's own
19
+ // comment on `createdAt` explains this in more detail.
20
+ //
21
+ // EVENTUAL CONSISTENCY: this checks GitHub's live state immediately after submission. A competing PR that
22
+ // exists but hasn't yet propagated through GitHub's own search/GraphQL indexing in that instant would be
23
+ // invisible to this one-shot check -- there is no retry/backoff here, which would be its own separate scope.
24
+
25
+ import { adjudicateSoftClaim } from "./claim-adjudication.js";
26
+ import { buildClosePrSpec } from "@loopover/engine";
27
+
28
+ /**
29
+ * Assemble the real competing-claims set from a fetched LiveIssueSnapshot: every OTHER open PR referencing
30
+ * the issue, excluding `selfPrNumber` and any PR authored by `minerLogin` itself (case-insensitive, mirrors
31
+ * checkSubmissionFreshness's own author comparison -- a login can be echoed back with different casing).
32
+ * Excluding same-author PRs is deliberate, not an edge case slipping through: a miner never competes against
33
+ * its own work, so if this login somehow has ANOTHER open PR on the same issue (e.g. a retry after a crash
34
+ * left a stale one behind), that PR is never treated as a competing claim to lose against -- only a genuinely
35
+ * different claimant's PR can trigger a real close.
36
+ * Pure given its inputs.
37
+ *
38
+ * @param {import("./submission-freshness-check.js").LiveIssueSnapshot | null | undefined} snapshot
39
+ * @param {number} selfPrNumber
40
+ * @param {string} minerLogin
41
+ * @returns {import("./claim-adjudication.js").ObservedClaim[]}
42
+ */
43
+ export function assembleCompetingClaims(snapshot, selfPrNumber, minerLogin) {
44
+ const minerLoginKey = minerLogin.trim().toLowerCase();
45
+ const referencingPrs = Array.isArray(snapshot?.referencingPrs) ? snapshot.referencingPrs : [];
46
+ return referencingPrs
47
+ .filter((pr) => pr.state === "open" && pr.number !== selfPrNumber)
48
+ .filter((pr) => typeof pr.authorLogin !== "string" || pr.authorLogin.trim().toLowerCase() !== minerLoginKey)
49
+ .map((pr) => ({ number: pr.number, claimedAt: pr.createdAt ?? null }));
50
+ }
51
+
52
+ /**
53
+ * Resolve a real claim conflict for an already-submitted PR. Fails OPEN (never closes anything) when the live
54
+ * snapshot can't be fetched -- an unavailable check is not evidence of a lost claim.
55
+ *
56
+ * @param {{ repoFullName: string, issueNumber: number, selfPrNumber: number, selfClaimedAt: string | null, minerLogin: string }} input
57
+ * @param {{
58
+ * fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<import("./submission-freshness-check.js").LiveIssueSnapshot | null>,
59
+ * executeLocalWrite: (spec: import("@loopover/engine").LocalWriteActionSpec) => Promise<unknown>,
60
+ * }} deps
61
+ * @returns {Promise<{
62
+ * checked: boolean,
63
+ * reason?: "live_state_unavailable",
64
+ * isWinner?: boolean,
65
+ * winnerNumber?: number | null,
66
+ * competingCount?: number,
67
+ * closeResult?: unknown,
68
+ * }>}
69
+ */
70
+ export async function resolveClaimConflict(input, deps) {
71
+ let snapshot;
72
+ try {
73
+ snapshot = await deps.fetchLiveIssueSnapshot(input.repoFullName, input.issueNumber);
74
+ } catch {
75
+ snapshot = null;
76
+ }
77
+ if (!snapshot || typeof snapshot !== "object") {
78
+ return { checked: false, reason: "live_state_unavailable" };
79
+ }
80
+
81
+ const competing = assembleCompetingClaims(snapshot, input.selfPrNumber, input.minerLogin);
82
+ const adjudication = adjudicateSoftClaim({ number: input.selfPrNumber, claimedAt: input.selfClaimedAt }, competing);
83
+
84
+ if (adjudication.isWinner) {
85
+ return { checked: true, isWinner: true, winnerNumber: adjudication.winnerNumber, competingCount: competing.length };
86
+ }
87
+
88
+ const comment = adjudication.winnerNumber
89
+ ? `Closing this PR: pull request #${adjudication.winnerNumber} claimed this issue first. This is an automated soft-claim conflict resolution -- no action needed from you.`
90
+ : `Closing this PR: another open pull request already claims this issue. This is an automated soft-claim conflict resolution -- no action needed from you.`;
91
+ const spec = buildClosePrSpec({ repoFullName: input.repoFullName, number: input.selfPrNumber, comment });
92
+ const closeResult = await deps.executeLocalWrite(spec);
93
+
94
+ return {
95
+ checked: true,
96
+ isWinner: false,
97
+ winnerNumber: adjudication.winnerNumber,
98
+ competingCount: competing.length,
99
+ closeResult,
100
+ };
101
+ }
@@ -0,0 +1,59 @@
1
+ import type { ClaimEntry, ClaimLedger, ClaimStatus } from "./claim-ledger.js";
2
+
3
+ export type ParsedClaimClaimArgs =
4
+ | {
5
+ repoFullName: string;
6
+ issueNumber: number;
7
+ note: string | undefined;
8
+ dryRun: boolean;
9
+ json: boolean;
10
+ apiBaseUrl: string | undefined;
11
+ }
12
+ | { error: string };
13
+
14
+ export type ParsedClaimReleaseArgs =
15
+ | {
16
+ repoFullName: string;
17
+ issueNumber: number;
18
+ dryRun: boolean;
19
+ json: boolean;
20
+ apiBaseUrl: string | undefined;
21
+ }
22
+ | { error: string };
23
+
24
+ export type ParsedClaimListArgs =
25
+ | {
26
+ json: boolean;
27
+ repoFullName: string | null;
28
+ status: ClaimStatus | null;
29
+ }
30
+ | { error: string };
31
+
32
+ export function parseClaimClaimArgs(args: string[]): ParsedClaimClaimArgs;
33
+
34
+ export function parseClaimReleaseArgs(args: string[]): ParsedClaimReleaseArgs;
35
+
36
+ export function parseClaimListArgs(args: string[]): ParsedClaimListArgs;
37
+
38
+ export function renderClaimsTable(entries: ClaimEntry[]): string;
39
+
40
+ export function runClaimClaim(
41
+ args: string[],
42
+ options?: { openClaimLedger?: () => ClaimLedger },
43
+ ): number;
44
+
45
+ export function runClaimRelease(
46
+ args: string[],
47
+ options?: { openClaimLedger?: () => ClaimLedger },
48
+ ): number;
49
+
50
+ export function runClaimList(
51
+ args: string[],
52
+ options?: { openClaimLedger?: () => ClaimLedger },
53
+ ): number;
54
+
55
+ export function runClaimCli(
56
+ subcommand: string | undefined,
57
+ args: string[],
58
+ options?: { openClaimLedger?: () => ClaimLedger },
59
+ ): number;