@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,339 @@
1
+ import { buildCollisionReport, MAX_FOCUS_MANIFEST_BYTES, parseFocusManifestContent } from "@loopover/engine";
2
+
3
+ // Real SelfReviewContext fetcher (#5145, Wave 3.5). Builds the context object the miner's self-review pass
4
+ // (packages/gittensory-engine/src/miner/self-review-adapter.ts) needs, at the SAME fidelity the live gate's
5
+ // own DB-backed construction produces (src/db/repositories.ts's toRepositoryRecord/toIssueRecord/
6
+ // toPullRequestRecord) -- just built fresh from live GitHub data instead of a DB round-trip, since the miner
7
+ // has no database. Two of SelfReviewContext's eight fields are DELIBERATELY left undefined, not stubbed:
8
+ //
9
+ // - `bounties`: bounty data is not GitHub-native in this codebase -- it comes from an external "Gitt"
10
+ // system that PUSHES data into the live gate's own internal ingest route (src/api/routes.ts). There is
11
+ // no public endpoint the miner could legitimately pull from instead.
12
+ // - `issueQuality`: built by buildIssueQualityReport, which lives only in root src/signals/engine.ts and
13
+ // has never been extracted into @loopover/engine (the same "not yet extracted" situation
14
+ // src/signals/slop.ts documented before #5133 extracted slop scoring specifically).
15
+ //
16
+ // Both are already optional on SelfReviewContext, and buildPredictedGateVerdict already degrades gracefully
17
+ // without them -- omitting them here is the same tradeoff the live gate itself makes for a repo it hasn't
18
+ // computed bounty/quality data for yet, not a corner cut specific to the miner.
19
+
20
+ const GITHUB_API_VERSION = "2022-11-28";
21
+ const DEFAULT_API_BASE_URL = "https://api.github.com";
22
+ const DEFAULT_RAW_CONTENT_BASE_URL = "https://raw.githubusercontent.com";
23
+ const DEFAULT_GITTENSOR_API_BASE = "https://api.gittensor.io";
24
+ const DEFAULT_PER_PAGE = 100;
25
+ const DEFAULT_MAX_PAGES = 10;
26
+
27
+ // Mirrors src/signals/focus-manifest-loader.ts's MANIFEST_FILE_CANDIDATES exactly -- first candidate that
28
+ // resolves wins, same as the live gate's own lookup order.
29
+ const MANIFEST_FILE_CANDIDATES = [".gittensory.yml", ".github/gittensory.yml", ".gittensory.json", ".github/gittensory.json"];
30
+
31
+ function parseRepoFullName(repoFullName) {
32
+ if (typeof repoFullName !== "string") return null;
33
+ const [owner, repo, extra] = repoFullName.split("/");
34
+ if (!owner || !repo || extra !== undefined) return null;
35
+ return { owner, repo };
36
+ }
37
+
38
+ function githubHeaders(githubToken) {
39
+ const headers = {
40
+ accept: "application/vnd.github+json",
41
+ "user-agent": "loopover-miner",
42
+ "x-github-api-version": GITHUB_API_VERSION,
43
+ };
44
+ const token = typeof githubToken === "string" ? githubToken.trim() : "";
45
+ if (token) headers.authorization = `Bearer ${token}`;
46
+ return headers;
47
+ }
48
+
49
+ function normalizeOptions(options = {}) {
50
+ return {
51
+ githubToken: options.githubToken ?? process.env.GITHUB_TOKEN ?? "",
52
+ apiBaseUrl: typeof options.apiBaseUrl === "string" && options.apiBaseUrl.trim() ? options.apiBaseUrl.trim() : DEFAULT_API_BASE_URL,
53
+ rawContentBaseUrl:
54
+ typeof options.rawContentBaseUrl === "string" && options.rawContentBaseUrl.trim() ? options.rawContentBaseUrl.trim() : DEFAULT_RAW_CONTENT_BASE_URL,
55
+ gittensorApiBase:
56
+ typeof options.gittensorApiBase === "string" && options.gittensorApiBase.trim() ? options.gittensorApiBase.trim() : DEFAULT_GITTENSOR_API_BASE,
57
+ fetchImpl: options.fetchImpl ?? fetch,
58
+ perPage: Number.isInteger(options.perPage) && options.perPage > 0 ? options.perPage : DEFAULT_PER_PAGE,
59
+ maxPages: Number.isInteger(options.maxPages) && options.maxPages > 0 ? options.maxPages : DEFAULT_MAX_PAGES,
60
+ contributorLogin: typeof options.contributorLogin === "string" ? options.contributorLogin.trim() : "",
61
+ linkedIssues: Array.isArray(options.linkedIssues) ? options.linkedIssues.filter((n) => Number.isInteger(n)) : [],
62
+ };
63
+ }
64
+
65
+ async function githubGetJson(url, resolved) {
66
+ const response = await resolved.fetchImpl(url, { method: "GET", headers: githubHeaders(resolved.githubToken) });
67
+ const payload = await response.json().catch(() => null);
68
+ return { response, payload };
69
+ }
70
+
71
+ async function fetchPaginated(pathWithQuery, resolved) {
72
+ const results = [];
73
+ for (let page = 1; page <= resolved.maxPages; page += 1) {
74
+ const separator = pathWithQuery.includes("?") ? "&" : "?";
75
+ const url = `${resolved.apiBaseUrl}${pathWithQuery}${separator}per_page=${resolved.perPage}&page=${page}`;
76
+ const { response, payload } = await githubGetJson(url, resolved);
77
+ if (!response.ok || !Array.isArray(payload)) break;
78
+ results.push(...payload);
79
+ if (payload.length < resolved.perPage) break;
80
+ }
81
+ return results;
82
+ }
83
+
84
+ // Mirrors src/db/repositories.ts's toRepositoryRecord + upsertRepositoryFromGitHub's field mapping. The
85
+ // miner has no App installation/DB, so installationId/isInstalled/isRegistered/registryConfig are honest
86
+ // "unregistered" defaults, not values pulled from GitHub -- GitHub's own repo payload carries none of them.
87
+ async function fetchRepositoryRecord(target, resolved) {
88
+ const url = `${resolved.apiBaseUrl}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`;
89
+ const { response, payload } = await githubGetJson(url, resolved);
90
+ if (!response.ok || !payload || typeof payload !== "object") return null;
91
+ return {
92
+ fullName: `${target.owner}/${target.repo}`,
93
+ owner: payload.owner?.login ?? target.owner,
94
+ name: payload.name ?? target.repo,
95
+ installationId: undefined,
96
+ isInstalled: false,
97
+ isRegistered: false,
98
+ isPrivate: payload.private ?? false,
99
+ htmlUrl: payload.html_url ?? null,
100
+ defaultBranch: payload.default_branch ?? null,
101
+ registryConfig: null,
102
+ };
103
+ }
104
+
105
+ // Mirrors src/db/repositories.ts's extractLinkedPrNumbers exactly.
106
+ const LINKED_PR_PATTERN = /\b(?:PR|pull request)\s+#(\d+)\b/gi;
107
+ function extractLinkedPrNumbers(body) {
108
+ const numbers = [];
109
+ for (const match of body.matchAll(LINKED_PR_PATTERN)) {
110
+ const number = Number(match[1]);
111
+ if (Number.isInteger(number) && number > 0) numbers.push(number);
112
+ }
113
+ return numbers;
114
+ }
115
+
116
+ // Mirrors src/db/repositories.ts's extractLinkedIssueNumbers: GitHub's own closing-keyword vocabulary, only
117
+ // counting a fully-qualified owner/repo#N reference when it targets the SAME repo being fetched.
118
+ const LINKED_ISSUE_PATTERN = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+(?:([\w.-]+\/[\w.-]+)#|#)(\d+)\b/gi;
119
+ function extractLinkedIssueNumbers(body, repoFullName) {
120
+ // Strip backtick code spans first so a closing-keyword pattern quoted as example code doesn't count.
121
+ const withoutCodeSpans = body.replace(/`[^`]*`/g, "");
122
+ const numbers = [];
123
+ const normalizedRepo = repoFullName.toLowerCase();
124
+ for (const match of withoutCodeSpans.matchAll(LINKED_ISSUE_PATTERN)) {
125
+ const qualifiedRepo = match[1];
126
+ if (qualifiedRepo !== undefined && qualifiedRepo.toLowerCase() !== normalizedRepo) continue;
127
+ const number = Number(match[2]);
128
+ if (Number.isInteger(number) && number > 0) numbers.push(number);
129
+ }
130
+ return numbers;
131
+ }
132
+
133
+ function labelNames(labels) {
134
+ if (!Array.isArray(labels)) return [];
135
+ return labels.flatMap((label) => (label && typeof label === "object" && typeof label.name === "string" ? [label.name] : []));
136
+ }
137
+
138
+ // Mirrors src/db/repositories.ts's toIssueRecord, populated straight from the live payload (createdAt/
139
+ // updatedAt/closedAt come from the DB-row read path there only as a caching artifact, not a semantic
140
+ // transform -- the live REST fields are the real source).
141
+ function toIssueRecord(repoFullName, issue) {
142
+ const body = issue.body ?? "";
143
+ return {
144
+ repoFullName,
145
+ number: issue.number,
146
+ title: issue.title,
147
+ state: issue.state,
148
+ authorLogin: issue.user?.login ?? null,
149
+ authorAssociation: issue.author_association ?? null,
150
+ htmlUrl: issue.html_url ?? null,
151
+ body,
152
+ createdAt: issue.created_at ?? null,
153
+ updatedAt: issue.updated_at ?? null,
154
+ closedAt: issue.closed_at ?? null,
155
+ labels: labelNames(issue.labels),
156
+ linkedPrs: extractLinkedPrNumbers(body),
157
+ };
158
+ }
159
+
160
+ async function fetchOpenIssueRecords(target, resolved) {
161
+ const payloads = await fetchPaginated(
162
+ `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues?state=open&sort=created&direction=asc`,
163
+ resolved,
164
+ );
165
+ // GitHub's Issues endpoint also returns pull requests -- filter them out, same as the live gate's own fetch.
166
+ return payloads.filter((issue) => issue && typeof issue === "object" && !issue.pull_request).map((issue) => toIssueRecord(`${target.owner}/${target.repo}`, issue));
167
+ }
168
+
169
+ function mergeableBooleanState(mergeable) {
170
+ if (mergeable === true) return "clean";
171
+ if (mergeable === false) return "dirty";
172
+ return null;
173
+ }
174
+
175
+ // Mirrors src/db/repositories.ts's toPullRequestRecord. Only the fields SelfReviewContext/buildCollisionReport
176
+ // actually consume are populated with real precision; merge/RC3 gate-plumbing fields the live gate's fuller
177
+ // PullRequestRecord carries (mergeAttemptCount, approvedHeadSha, ...) don't exist on the engine package's
178
+ // leaner mirror type and aren't meaningful for a miner attempt anyway.
179
+ function toPullRequestRecord(repoFullName, pr) {
180
+ const body = pr.body ?? "";
181
+ return {
182
+ repoFullName,
183
+ number: pr.number,
184
+ title: pr.title,
185
+ state: pr.state,
186
+ authorLogin: pr.user?.login ?? null,
187
+ authorAssociation: pr.author_association ?? null,
188
+ headSha: pr.head?.sha ?? null,
189
+ headRef: pr.head?.ref ?? null,
190
+ baseRef: pr.base?.ref ?? null,
191
+ htmlUrl: pr.html_url ?? null,
192
+ mergedAt: pr.merged_at ?? null,
193
+ isDraft: pr.draft ?? null,
194
+ mergeableState: pr.mergeable_state ?? mergeableBooleanState(pr.mergeable),
195
+ reviewDecision: null,
196
+ body,
197
+ createdAt: pr.created_at ?? null,
198
+ updatedAt: pr.updated_at ?? null,
199
+ closedAt: pr.closed_at ?? null,
200
+ labels: labelNames(pr.labels),
201
+ linkedIssues: extractLinkedIssueNumbers(body, repoFullName),
202
+ };
203
+ }
204
+
205
+ async function fetchOpenPullRequestRecords(target, resolved) {
206
+ const payloads = await fetchPaginated(
207
+ `/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/pulls?state=open&sort=created&direction=asc`,
208
+ resolved,
209
+ );
210
+ return payloads.map((pr) => toPullRequestRecord(`${target.owner}/${target.repo}`, pr));
211
+ }
212
+
213
+ // Mirrors src/signals/focus-manifest-loader.ts's raw-content lookup order and bounded body read:
214
+ // first candidate path that resolves wins, but hostile manifests never exceed the parser byte cap in memory.
215
+ async function readBoundedManifestResponseText(response) {
216
+ const contentLength = response.headers?.get?.("content-length") ?? null;
217
+ if (contentLength !== null) {
218
+ const parsedLength = Number.parseInt(contentLength, 10);
219
+ if (Number.isFinite(parsedLength) && parsedLength > MAX_FOCUS_MANIFEST_BYTES) return null;
220
+ }
221
+ if (!response.body?.getReader) {
222
+ const text = await response.text();
223
+ if (typeof text !== "string") return null;
224
+ return new TextEncoder().encode(text).byteLength > MAX_FOCUS_MANIFEST_BYTES ? null : text;
225
+ }
226
+
227
+ const reader = response.body.getReader();
228
+ const decoder = new TextDecoder();
229
+ let totalBytes = 0;
230
+ let text = "";
231
+ try {
232
+ while (true) {
233
+ const { done, value } = await reader.read();
234
+ if (done) break;
235
+ totalBytes += value.byteLength;
236
+ if (totalBytes > MAX_FOCUS_MANIFEST_BYTES) {
237
+ await reader.cancel();
238
+ return null;
239
+ }
240
+ text += decoder.decode(value, { stream: true });
241
+ }
242
+ text += decoder.decode();
243
+ return text;
244
+ } finally {
245
+ reader.releaseLock();
246
+ }
247
+ }
248
+
249
+ async function fetchManifestContent(target, resolved) {
250
+ for (const path of MANIFEST_FILE_CANDIDATES) {
251
+ const url = `${resolved.rawContentBaseUrl}/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/HEAD/${path}`;
252
+ try {
253
+ const response = await resolved.fetchImpl(url, { method: "GET", headers: { accept: "application/json", "user-agent": "loopover-miner" } });
254
+ if (response.ok) {
255
+ const text = await readBoundedManifestResponseText(response);
256
+ if (typeof text === "string") return text;
257
+ }
258
+ } catch {
259
+ // Try the next candidate path.
260
+ }
261
+ }
262
+ return null;
263
+ }
264
+
265
+ // Mirrors src/gittensor/api.ts's fetchGittensorContributorSnapshot/fetchOfficialGittensorMiner: a public,
266
+ // unauthenticated GET against the Gittensor API (not GitHub) -- confirmed only when a real entry with a
267
+ // matching GitHub login is found; any transport/parse failure fails closed to "not confirmed", never throws.
268
+ async function fetchConfirmedContributor(login, resolved) {
269
+ if (!login) return false;
270
+ try {
271
+ const response = await resolved.fetchImpl(`${resolved.gittensorApiBase}/miners`, { method: "GET", headers: { accept: "application/json" } });
272
+ if (!response.ok) return false;
273
+ const payload = await response.json().catch(() => null);
274
+ if (!Array.isArray(payload)) return false;
275
+ const normalizedLogin = login.toLowerCase();
276
+ return payload.some((miner) => typeof miner?.githubUsername === "string" && miner.githubUsername.toLowerCase() === normalizedLogin);
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ // Per self-review-adapter.ts's own doc comment: the caller computes inDuplicateCluster "the same way the
283
+ // live gate's collision report would" -- adapted from src/signals/engine.ts's real
284
+ // isPullRequestInDuplicateCluster (root src/, not extracted to the engine package), which requires >= 2
285
+ // PULL REQUEST items in a high-risk cluster, not just any high-risk cluster containing the target. That
286
+ // threshold matters: buildCollisionReport's own pairwise "shared linked issue" rule already marks an
287
+ // issue+its-one-legitimately-closing-PR pair as a HIGH-risk cluster (confirmed empirically) -- without the
288
+ // >= 2 threshold, inDuplicateCluster would fire on the completely normal case of "one PR already closes
289
+ // this issue," not genuine overlapping/duplicate work. Checks the target ISSUE's presence instead of a
290
+ // not-yet-existing PR number, since the miner's own submission doesn't exist as a real PullRequestRecord yet.
291
+ function computeInDuplicateCluster(repoFullName, issues, pullRequests, targetIssueNumbers) {
292
+ if (targetIssueNumbers.length === 0) return false;
293
+ const report = buildCollisionReport(repoFullName, issues, pullRequests);
294
+ return report.clusters.some(
295
+ (cluster) =>
296
+ cluster.risk === "high" &&
297
+ cluster.items.filter((item) => item.type === "pull_request").length >= 2 &&
298
+ cluster.items.some((item) => item.type === "issue" && targetIssueNumbers.includes(item.number)),
299
+ );
300
+ }
301
+
302
+ /**
303
+ * Build a real SelfReviewContext from live GitHub data, at the same fidelity the live gate's own DB-backed
304
+ * construction produces. See this file's header for the two fields (bounties, issueQuality) deliberately
305
+ * left undefined and why.
306
+ *
307
+ * @param {string} repoFullName
308
+ * @param {{
309
+ * githubToken?: string, contributorLogin?: string, linkedIssues?: number[],
310
+ * apiBaseUrl?: string, rawContentBaseUrl?: string, gittensorApiBase?: string,
311
+ * fetchImpl?: typeof fetch, perPage?: number, maxPages?: number,
312
+ * }} [options]
313
+ * @returns {Promise<import("./self-review-context.js").SelfReviewContextResult>}
314
+ */
315
+ export async function fetchSelfReviewContext(repoFullName, options = {}) {
316
+ const target = parseRepoFullName(repoFullName);
317
+ if (!target) throw new Error("invalid_repo_full_name");
318
+ const resolved = normalizeOptions(options);
319
+
320
+ const [repo, issues, pullRequests, manifestContent, confirmedContributor] = await Promise.all([
321
+ fetchRepositoryRecord(target, resolved),
322
+ fetchOpenIssueRecords(target, resolved),
323
+ fetchOpenPullRequestRecords(target, resolved),
324
+ fetchManifestContent(target, resolved),
325
+ fetchConfirmedContributor(resolved.contributorLogin, resolved),
326
+ ]);
327
+
328
+ const manifest = parseFocusManifestContent(manifestContent, "repo_file");
329
+ const inDuplicateCluster = computeInDuplicateCluster(`${target.owner}/${target.repo}`, issues, pullRequests, resolved.linkedIssues);
330
+
331
+ return {
332
+ manifest,
333
+ repo,
334
+ issues,
335
+ pullRequests,
336
+ confirmedContributor,
337
+ inDuplicateCluster,
338
+ };
339
+ }
@@ -0,0 +1,3 @@
1
+ import type { SlopAssessment, SlopAssessmentInput } from "@loopover/engine";
2
+
3
+ export function runSlopAssessment(input: SlopAssessmentInput): SlopAssessment;
@@ -0,0 +1,20 @@
1
+ import { buildSlopAssessment } from "@loopover/engine";
2
+
3
+ // Production runSlopAssessment binding (#5133, Wave 3.5 follow-up to #2334). `attempt-runner.js`'s
4
+ // `deps.runSlopAssessment` (via #2333's iterate-loop -> self-review-adapter's `SelfReviewAdapterDeps`) had
5
+ // no production implementation anywhere in this package -- only the test double in
6
+ // `test/unit/miner-attempt-runner.test.ts` existed. `packages/gittensory-engine/src/miner/self-review-adapter.ts`'s
7
+ // own header comment already anticipated this exact binding: `SelfReviewSlopInput`/`SelfReviewSlopAssessment`
8
+ // are a deliberate, hand-kept STRUCTURAL MIRROR of `buildSlopAssessment`'s own `SlopAssessmentInput`/
9
+ // `SlopAssessment` (down to reusing the SAME canonical `AdvisoryFinding` type for `findings`), specifically so
10
+ // a real binding could be a direct pass-through with no mapping logic once the deterministic scorer itself
11
+ // became portable -- which #5133 did (`src/signals/slop.ts`'s PR-side scorer is now extracted to
12
+ // `packages/gittensory-engine/src/signals/slop.ts`, byte-parity-verified against the live gate's own copy).
13
+
14
+ /**
15
+ * @param {import("@loopover/engine").SlopAssessmentInput} input
16
+ * @returns {import("@loopover/engine").SlopAssessment}
17
+ */
18
+ export function runSlopAssessment(input) {
19
+ return buildSlopAssessment(input);
20
+ }
@@ -0,0 +1,41 @@
1
+ /** Stack auto-detection (#4785). `detectRepoStack` inspects an already-cloned repo's manifest / lockfile / config
2
+ * files and returns a structured stack description, or an explicit fail-closed result when the stack can't be
3
+ * confidently identified (no guessing). */
4
+
5
+ /** Which manifest (and lockfile, when present) drove the detection. */
6
+ export type StackEvidence = {
7
+ manifest: string;
8
+ lockfile: string | null;
9
+ };
10
+
11
+ /** A confidently-detected stack. Command fields are `null` when the command can't be inferred without guessing. */
12
+ export type DetectedRepoStack = {
13
+ detected: true;
14
+ language: string;
15
+ packageManager: string | null;
16
+ buildCommand: string | null;
17
+ testCommand: string | null;
18
+ lintCommand: string | null;
19
+ formatCommand: string | null;
20
+ evidence: StackEvidence;
21
+ };
22
+
23
+ /** A repo whose stack could not be confidently identified. */
24
+ export type UndetectedRepoStack = {
25
+ detected: false;
26
+ reason: string;
27
+ };
28
+
29
+ export type RepoStackResult = DetectedRepoStack | UndetectedRepoStack;
30
+
31
+ export type DetectRepoStackOptions = {
32
+ existsSync?: (path: string) => boolean;
33
+ readFileSync?: (path: string, encoding: "utf8") => string;
34
+ };
35
+
36
+ /** Manifests, in the precedence order detection tries them (first match wins). */
37
+ export const RECOGNIZED_MANIFESTS: readonly string[];
38
+
39
+ export function detectRepoStack(repoPath: string, options?: DetectRepoStackOptions): RepoStackResult;
40
+
41
+ export function renderStackSummary(stack: RepoStackResult): string;
@@ -0,0 +1,248 @@
1
+ /** Stack auto-detection (#4785): inspect an already-cloned target repo's manifest / lockfile / config files and
2
+ * infer a structured description of its stack — language, package manager, and the build / test / lint / format
3
+ * commands — before any code-generation step runs. Like `miner-goal-spec.js` this reads the ALREADY-CLONED repo on
4
+ * disk (attempt-worktree.js's prepareAttemptWorktree runs first), so the injected `existsSync` / `readFileSync`
5
+ * always receive the FULL joined path, mirroring node:fs. It is pure and NEVER throws: an unreadable/unparseable
6
+ * file degrades to "no evidence" rather than crashing, and — per the acceptance criteria — a repo whose stack
7
+ * can't be confidently identified returns an explicit `{ detected: false, reason }` instead of guessing. */
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ /** Manifests, in the precedence order detection tries them; the first matching primary manifest wins. A caller with
12
+ * a known polyglot repo can inspect `evidence.manifest` to see which one was chosen. */
13
+ export const RECOGNIZED_MANIFESTS = Object.freeze([
14
+ "package.json",
15
+ "pyproject.toml",
16
+ "setup.py",
17
+ "setup.cfg",
18
+ "requirements.txt",
19
+ "Pipfile",
20
+ "Cargo.toml",
21
+ "go.mod",
22
+ "pom.xml",
23
+ "build.gradle",
24
+ "build.gradle.kts",
25
+ ]);
26
+
27
+ const NO_MANIFEST_REASON =
28
+ "No recognized dependency manifest (package.json, pyproject.toml, Cargo.toml, go.mod, pom.xml, or build.gradle) was found at the repository root.";
29
+
30
+ const NODE_PACKAGE_MANAGERS = Object.freeze(["npm", "yarn", "pnpm", "bun"]);
31
+ const NODE_LOCKFILES = Object.freeze([
32
+ ["pnpm-lock.yaml", "pnpm"],
33
+ ["yarn.lock", "yarn"],
34
+ ["bun.lockb", "bun"],
35
+ ["package-lock.json", "npm"],
36
+ ]);
37
+
38
+ /** Build a never-throwing accessor over the cloned repo. `exists` and `read` both swallow fs errors so the detector
39
+ * treats an EACCES/ENOENT/binary file as simply "absent" instead of crashing the attempt. */
40
+ function makeAccess(repoPath, options) {
41
+ const existsImpl = options.existsSync ?? existsSync;
42
+ const readImpl = options.readFileSync ?? readFileSync;
43
+ const exists = (relativePath) => {
44
+ try {
45
+ return existsImpl(join(repoPath, relativePath)) === true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+ const read = (relativePath) => {
51
+ try {
52
+ if (!exists(relativePath)) return null;
53
+ const content = readImpl(join(repoPath, relativePath), "utf8");
54
+ return typeof content === "string" ? content : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ };
59
+ return { exists, read };
60
+ }
61
+
62
+ function parseJson(text) {
63
+ if (typeof text !== "string") return null;
64
+ try {
65
+ const parsed = JSON.parse(text);
66
+ return parsed && typeof parsed === "object" ? parsed : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** Pick a package.json script by exact name first, then by pattern, considering only string-valued scripts. */
73
+ function pickScript(scripts, exactName, pattern) {
74
+ const names = Object.keys(scripts).filter((name) => typeof scripts[name] === "string");
75
+ if (names.includes(exactName)) return exactName;
76
+ return names.find((name) => pattern.test(name)) ?? null;
77
+ }
78
+
79
+ function nodeLockfile(exists) {
80
+ const match = NODE_LOCKFILES.find(([file]) => exists(file));
81
+ return match ? match[0] : null;
82
+ }
83
+
84
+ function nodePackageManager(pkg, lockfile) {
85
+ const corepack =
86
+ typeof pkg?.packageManager === "string" ? pkg.packageManager.split("@")[0].trim().toLowerCase() : "";
87
+ if (NODE_PACKAGE_MANAGERS.includes(corepack)) return corepack;
88
+ const byLock = NODE_LOCKFILES.find(([file]) => file === lockfile);
89
+ // A package.json with no lockfile is still a Node project; npm is its default runner (a default, not a guess).
90
+ return byLock ? byLock[1] : "npm";
91
+ }
92
+
93
+ function hasTypescriptDependency(pkg) {
94
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
95
+ return typeof deps.typescript === "string";
96
+ }
97
+
98
+ function detectNode({ exists, read }) {
99
+ if (!exists("package.json")) return null;
100
+ const pkg = parseJson(read("package.json"));
101
+ const scripts =
102
+ pkg && typeof pkg.scripts === "object" && pkg.scripts && !Array.isArray(pkg.scripts) ? pkg.scripts : {};
103
+ const language = exists("tsconfig.json") || hasTypescriptDependency(pkg) ? "typescript" : "javascript";
104
+ const lockfile = nodeLockfile(exists);
105
+ const packageManager = nodePackageManager(pkg, lockfile);
106
+
107
+ const buildName = pickScript(scripts, "build", /^(build|compile|bundle)(:|$)/i);
108
+ const testName = pickScript(scripts, "test", /(^|:)test(:|$)/i);
109
+ const lintName = pickScript(scripts, "lint", /(^|:)lint(:|$)/i);
110
+ const formatName = pickScript(scripts, "format", /(^|:)(format|fmt)(:|$)/i);
111
+
112
+ return {
113
+ language,
114
+ packageManager,
115
+ buildCommand: buildName ? `${packageManager} run ${buildName}` : null,
116
+ // `<pm> test` is the built-in test lifecycle across npm/yarn/pnpm/bun; a non-"test" script uses `run`.
117
+ testCommand: testName ? (testName === "test" ? `${packageManager} test` : `${packageManager} run ${testName}`) : null,
118
+ lintCommand: lintName ? `${packageManager} run ${lintName}` : null,
119
+ formatCommand: formatName ? `${packageManager} run ${formatName}` : null,
120
+ evidence: { manifest: "package.json", lockfile },
121
+ };
122
+ }
123
+
124
+ function detectPython({ exists, read }) {
125
+ const manifest = ["pyproject.toml", "setup.py", "setup.cfg", "requirements.txt", "Pipfile"].find(exists);
126
+ if (manifest === undefined) return null;
127
+ const pyproject = read("pyproject.toml") ?? "";
128
+
129
+ let packageManager;
130
+ let lockfile = null;
131
+ if (exists("poetry.lock") || /\[tool\.poetry\]/.test(pyproject)) {
132
+ packageManager = "poetry";
133
+ lockfile = exists("poetry.lock") ? "poetry.lock" : null;
134
+ } else if (exists("uv.lock")) {
135
+ packageManager = "uv";
136
+ lockfile = "uv.lock";
137
+ } else if (exists("Pipfile") || exists("Pipfile.lock")) {
138
+ packageManager = "pipenv";
139
+ lockfile = exists("Pipfile.lock") ? "Pipfile.lock" : null;
140
+ } else {
141
+ packageManager = "pip";
142
+ }
143
+
144
+ // Commands are inferred only from real config so an undeclared tool is never guessed (acceptance: fail safe).
145
+ const hasRuff = exists("ruff.toml") || exists(".ruff.toml") || /\[tool\.ruff\]/.test(pyproject);
146
+ const hasPytest = exists("pytest.ini") || exists("tox.ini") || /\[tool\.pytest\b/.test(pyproject);
147
+
148
+ return {
149
+ language: "python",
150
+ packageManager,
151
+ buildCommand: /\[build-system\]/.test(pyproject) ? (packageManager === "poetry" ? "poetry build" : "python -m build") : null,
152
+ testCommand: hasPytest ? "pytest" : null,
153
+ lintCommand: hasRuff ? "ruff check ." : null,
154
+ formatCommand: hasRuff ? "ruff format ." : null,
155
+ evidence: { manifest, lockfile },
156
+ };
157
+ }
158
+
159
+ function detectRust({ exists }) {
160
+ if (!exists("Cargo.toml")) return null;
161
+ return {
162
+ language: "rust",
163
+ packageManager: "cargo",
164
+ buildCommand: "cargo build",
165
+ testCommand: "cargo test",
166
+ lintCommand: "cargo clippy",
167
+ formatCommand: "cargo fmt",
168
+ evidence: { manifest: "Cargo.toml", lockfile: exists("Cargo.lock") ? "Cargo.lock" : null },
169
+ };
170
+ }
171
+
172
+ function detectGo({ exists }) {
173
+ if (!exists("go.mod")) return null;
174
+ const hasGolangci = exists(".golangci.yml") || exists(".golangci.yaml") || exists(".golangci.toml");
175
+ return {
176
+ language: "go",
177
+ packageManager: "go",
178
+ buildCommand: "go build ./...",
179
+ testCommand: "go test ./...",
180
+ lintCommand: hasGolangci ? "golangci-lint run" : "go vet ./...",
181
+ formatCommand: "gofmt -l .",
182
+ evidence: { manifest: "go.mod", lockfile: exists("go.sum") ? "go.sum" : null },
183
+ };
184
+ }
185
+
186
+ function detectMaven({ exists }) {
187
+ if (!exists("pom.xml")) return null;
188
+ return {
189
+ language: "java",
190
+ packageManager: "maven",
191
+ buildCommand: "mvn -B package",
192
+ testCommand: "mvn -B test",
193
+ lintCommand: null,
194
+ formatCommand: null,
195
+ evidence: { manifest: "pom.xml", lockfile: null },
196
+ };
197
+ }
198
+
199
+ function detectGradle({ exists }) {
200
+ const manifest = exists("build.gradle") ? "build.gradle" : exists("build.gradle.kts") ? "build.gradle.kts" : null;
201
+ if (manifest === null) return null;
202
+ const runner = exists("gradlew") ? "./gradlew" : "gradle";
203
+ return {
204
+ language: "java",
205
+ packageManager: "gradle",
206
+ buildCommand: `${runner} build`,
207
+ testCommand: `${runner} test`,
208
+ lintCommand: null,
209
+ formatCommand: null,
210
+ evidence: { manifest, lockfile: null },
211
+ };
212
+ }
213
+
214
+ const DETECTORS = Object.freeze([detectNode, detectPython, detectRust, detectGo, detectMaven, detectGradle]);
215
+
216
+ /**
217
+ * Detect the stack of an already-cloned repository at `repoPath`. Returns `{ detected: true, ... }` with the
218
+ * language, package manager, and any confidently-inferred commands, or `{ detected: false, reason }` when no
219
+ * recognized manifest is present. Never throws.
220
+ */
221
+ export function detectRepoStack(repoPath, options = {}) {
222
+ if (typeof repoPath !== "string" || !repoPath.trim()) {
223
+ return { detected: false, reason: "A repository path is required to detect the stack." };
224
+ }
225
+ const access = makeAccess(repoPath, options);
226
+ for (const detector of DETECTORS) {
227
+ const detected = detector(access);
228
+ if (detected !== null) {
229
+ return { detected: true, ...detected };
230
+ }
231
+ }
232
+ return { detected: false, reason: NO_MANIFEST_REASON };
233
+ }
234
+
235
+ /** One-line human summary of a detection result, suitable for a coding-agent prompt or an operator log. */
236
+ export function renderStackSummary(stack) {
237
+ if (!stack || stack.detected !== true) {
238
+ return `stack not detected: ${stack?.reason ?? "unknown reason"}`;
239
+ }
240
+ const commands = [
241
+ stack.buildCommand ? `build=\`${stack.buildCommand}\`` : null,
242
+ stack.testCommand ? `test=\`${stack.testCommand}\`` : null,
243
+ stack.lintCommand ? `lint=\`${stack.lintCommand}\`` : null,
244
+ stack.formatCommand ? `format=\`${stack.formatCommand}\`` : null,
245
+ ].filter((entry) => entry !== null);
246
+ const suffix = commands.length > 0 ? ` (${commands.join(", ")})` : " (no validation commands detected)";
247
+ return `${stack.language} via ${stack.packageManager ?? "unknown"}${suffix}`;
248
+ }