@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,183 @@
1
+ // Local-store maintenance for the miner (#4834): SQLite integrity checks + append-only ledger retention.
2
+ //
3
+ // Three independent, side-effect-light helpers used by `doctor`, the ledgers, and `purge-cli.js`:
4
+ // 1. checkStoreIntegrity — run `PRAGMA integrity_check` on one store file and report health, so `doctor` can
5
+ // flag a corrupted store instead of only probing a single one with `SELECT 1`.
6
+ // 2. resolveLedgerRetentionPolicy / pruneLedgerByRetention — an opt-in, age- and/or size-based retention
7
+ // policy for the unbounded append-only ledgers (event, governor, prediction), which otherwise grow forever.
8
+ // OFF by default: retention only runs when an operator sets the env opt-in.
9
+ // 3. purgeStoreByRepo — an explicit, operator-invoked delete of every row for one repo (#5564, right-to-be-
10
+ // forgotten). Distinct from retention pruning: never runs automatically, always caller-initiated via
11
+ // `purge-cli.js`, and always reports how many rows it removed so a purge is never silent.
12
+ // Pure control flow over injected inputs (a DB handle, an env object, a caller-supplied clock) — no network, and
13
+ // no internal clock read in the prune path so it stays deterministic and unit-testable.
14
+ import { existsSync } from "node:fs";
15
+ import { DatabaseSync } from "node:sqlite";
16
+
17
+ /** Env opt-ins for ledger retention (unset ⇒ retention disabled). */
18
+ export const LEDGER_RETENTION_DAYS_ENV = "GITTENSORY_MINER_LEDGER_RETENTION_DAYS";
19
+ export const LEDGER_RETENTION_MAX_ROWS_ENV = "GITTENSORY_MINER_LEDGER_RETENTION_MAX_ROWS";
20
+
21
+ /** Fixed retention specs for the three append-only ledgers. These identifiers are INTERNAL constants — never
22
+ * caller/user text — and are validated as plain identifiers before interpolation as defence in depth. */
23
+ export const EVENT_LEDGER_RETENTION_SPEC = { table: "miner_event_ledger", timestampColumn: "created_at", orderColumn: "id" };
24
+ export const GOVERNOR_LEDGER_RETENTION_SPEC = { table: "governor_events", timestampColumn: "ts", orderColumn: "id" };
25
+ export const PREDICTION_LEDGER_RETENTION_SPEC = { table: "predictions", timestampColumn: "ts", orderColumn: "id" };
26
+
27
+ /** Fixed purge specs (#5564) for the four stores whose rows are directly scoped by a `repoColumn`. Same
28
+ * internal-constant-only discipline as the retention specs above. `attempt-log.js` is deliberately absent: its
29
+ * payload is a free-form `Record<string, unknown>` with no dedicated repo column, so a precise per-repo purge
30
+ * isn't possible there without risking false matches — `purge-cli.js` reports it as not-purgeable instead. */
31
+ export const CLAIM_LEDGER_PURGE_SPEC = { table: "miner_claims", repoColumn: "repo_full_name" };
32
+ export const EVENT_LEDGER_PURGE_SPEC = { table: "miner_event_ledger", repoColumn: "repo_full_name" };
33
+ export const GOVERNOR_LEDGER_PURGE_SPEC = { table: "governor_events", repoColumn: "repo_full_name" };
34
+ export const PREDICTION_LEDGER_PURGE_SPEC = { table: "predictions", repoColumn: "repo_full_name" };
35
+
36
+ const SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
37
+
38
+ /** A readable message for a caught value, whether or not it is an Error. */
39
+ export function describeError(error) {
40
+ return error instanceof Error ? error.message : String(error);
41
+ }
42
+
43
+ /**
44
+ * Classify raw `PRAGMA integrity_check` rows. A healthy database yields a single `"ok"` row; a corrupt one yields
45
+ * one row per problem. Pure — extracted so both the healthy and problem paths are testable without a genuinely
46
+ * corrupt file (which SQLite typically refuses to open at all, i.e. the catch path below).
47
+ * @param {Array<{ integrity_check?: unknown }>} rows
48
+ * @returns {{ ok: boolean, note: string }}
49
+ */
50
+ export function classifyIntegrityRows(rows) {
51
+ const problems = rows.map((row) => String(row.integrity_check)).filter((value) => value !== "ok");
52
+ return problems.length === 0 ? { ok: true, note: "ok" } : { ok: false, note: problems.join("; ") };
53
+ }
54
+
55
+ /**
56
+ * Run `PRAGMA integrity_check` on a single store file. A store that does not exist yet is healthy by absence
57
+ * (nothing to corrupt). Never throws: a store that cannot be opened or read is reported as not-ok, so one bad
58
+ * store cannot abort the whole doctor sweep. Opens the connection driver-enforced read-only -- `readOnly`
59
+ * (camelCase) is the only option key node:sqlite recognizes for this; the lowercase `readonly` is silently
60
+ * ignored and opens read-write instead (the exact gotcha claim-ledger.js's own openClaimLedgerReadOnly already
61
+ * documents), which would defeat the read-only guarantee this function's own docs claim.
62
+ * @param {string} name - the check label (e.g. "event-ledger").
63
+ * @param {string} dbPath - the store file path.
64
+ * @returns {{ name: string, ok: boolean, detail: string }}
65
+ */
66
+ export function checkStoreIntegrity(name, dbPath) {
67
+ if (!existsSync(dbPath)) {
68
+ return { name, ok: true, detail: `${dbPath}: not created yet` };
69
+ }
70
+ let db;
71
+ try {
72
+ db = new DatabaseSync(dbPath, { readOnly: true });
73
+ const { ok, note } = classifyIntegrityRows(db.prepare("PRAGMA integrity_check").all());
74
+ return { name, ok, detail: `${dbPath}: ${note}` };
75
+ } catch (error) {
76
+ return { name, ok: false, detail: `${dbPath}: ${describeError(error)}` };
77
+ } finally {
78
+ db?.close();
79
+ }
80
+ }
81
+
82
+ /** Coerce an env value to a positive integer, or null (unset/blank/zero/negative/non-finite ⇒ null ⇒ disabled).
83
+ * Floors BEFORE the positivity test, so a fractional value below 1 (e.g. "0.5") floors to 0 and disables the
84
+ * bound rather than becoming a dangerous 0 that would prune the whole ledger. */
85
+ function positiveIntOrNull(raw) {
86
+ if (raw === undefined || raw === null || String(raw).trim() === "") return null;
87
+ const numeric = Math.floor(Number(raw));
88
+ return Number.isFinite(numeric) && numeric > 0 ? numeric : null;
89
+ }
90
+
91
+ /**
92
+ * Resolve the opt-in ledger retention policy from an env object. OFF by default: returns null unless at least
93
+ * one bound is set to a positive value. A zero/negative/non-numeric value is treated as unset. When set, returns
94
+ * `{ maxAgeMs? }` (from a day count) and/or `{ maxRows? }`.
95
+ * @param {NodeJS.ProcessEnv} [env]
96
+ * @returns {{ maxAgeMs?: number, maxRows?: number } | null}
97
+ */
98
+ export function resolveLedgerRetentionPolicy(env = process.env) {
99
+ const maxAgeDays = positiveIntOrNull(env[LEDGER_RETENTION_DAYS_ENV]);
100
+ const maxRows = positiveIntOrNull(env[LEDGER_RETENTION_MAX_ROWS_ENV]);
101
+ if (maxAgeDays === null && maxRows === null) return null;
102
+ const policy = {};
103
+ if (maxAgeDays !== null) policy.maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
104
+ if (maxRows !== null) policy.maxRows = maxRows;
105
+ return policy;
106
+ }
107
+
108
+ /**
109
+ * Prune one append-only ledger per a resolved retention policy: delete rows older than the age bound AND rows
110
+ * beyond the row-count bound (keeping the newest `maxRows` by `orderColumn`), atomically. A null policy is a
111
+ * no-op. `nowMs` is caller-supplied (no internal clock). Timestamp columns are UTC ISO-8601 strings, which sort
112
+ * lexicographically in chronological order, so a string comparison against the ISO cutoff selects older rows.
113
+ * @param {import("node:sqlite").DatabaseSync} db
114
+ * @param {{ table: string, timestampColumn: string, orderColumn: string }} spec
115
+ * @param {{ maxAgeMs?: number, maxRows?: number } | null} policy
116
+ * @param {number} nowMs
117
+ * @returns {number} rows deleted
118
+ */
119
+ export function pruneLedgerByRetention(db, spec, policy, nowMs) {
120
+ if (!policy) return 0;
121
+ for (const identifier of [spec.table, spec.timestampColumn, spec.orderColumn]) {
122
+ if (!SQL_IDENTIFIER.test(identifier)) throw new Error(`unsafe SQL identifier: ${identifier}`);
123
+ }
124
+ let deleted = 0;
125
+ db.exec("BEGIN");
126
+ try {
127
+ // Both bounds are guarded to be strictly positive as defence in depth: a 0 age would prune everything older
128
+ // than `now`, and a 0 row-cap makes `LIMIT 0` match no rows so `NOT IN (empty)` would delete the whole ledger.
129
+ if (policy.maxAgeMs !== undefined && policy.maxAgeMs > 0) {
130
+ const cutoff = new Date(nowMs - policy.maxAgeMs).toISOString();
131
+ const info = db.prepare(`DELETE FROM ${spec.table} WHERE ${spec.timestampColumn} < ?`).run(cutoff);
132
+ deleted += Number(info.changes);
133
+ }
134
+ if (policy.maxRows !== undefined && policy.maxRows >= 1) {
135
+ const info = db
136
+ .prepare(
137
+ `DELETE FROM ${spec.table} WHERE ${spec.orderColumn} NOT IN ` +
138
+ `(SELECT ${spec.orderColumn} FROM ${spec.table} ORDER BY ${spec.orderColumn} DESC LIMIT ?)`,
139
+ )
140
+ .run(policy.maxRows);
141
+ deleted += Number(info.changes);
142
+ }
143
+ db.exec("COMMIT");
144
+ } catch (error) {
145
+ db.exec("ROLLBACK");
146
+ throw error;
147
+ }
148
+ return deleted;
149
+ }
150
+
151
+ /**
152
+ * Delete every row for one repo from a store (#5564). Unlike `pruneLedgerByRetention`, this never runs
153
+ * automatically — it exists solely so `purge-cli.js` can give an operator a real right-to-be-forgotten path.
154
+ * `repoFullName` is caller-normalized (owner/repo) before reaching here; this function only guards the SQL
155
+ * identifiers, matching `pruneLedgerByRetention`'s own defence-in-depth discipline.
156
+ * @param {import("node:sqlite").DatabaseSync} db
157
+ * @param {{ table: string, repoColumn: string }} spec
158
+ * @param {string} repoFullName
159
+ * @returns {number} rows deleted
160
+ */
161
+ export function purgeStoreByRepo(db, spec, repoFullName) {
162
+ for (const identifier of [spec.table, spec.repoColumn]) {
163
+ if (!SQL_IDENTIFIER.test(identifier)) throw new Error(`unsafe SQL identifier: ${identifier}`);
164
+ }
165
+ const info = db.prepare(`DELETE FROM ${spec.table} WHERE ${spec.repoColumn} = ?`).run(repoFullName);
166
+ return Number(info.changes);
167
+ }
168
+
169
+ /**
170
+ * Count rows for one repo in a store without deleting anything (#5564) — the read-only counterpart to
171
+ * `purgeStoreByRepo`, used by `purge-cli.js --dry-run` to report what a real purge would remove.
172
+ * @param {import("node:sqlite").DatabaseSync} db
173
+ * @param {{ table: string, repoColumn: string }} spec
174
+ * @param {string} repoFullName
175
+ * @returns {number} matching row count
176
+ */
177
+ export function countStoreByRepo(db, spec, repoFullName) {
178
+ for (const identifier of [spec.table, spec.repoColumn]) {
179
+ if (!SQL_IDENTIFIER.test(identifier)) throw new Error(`unsafe SQL identifier: ${identifier}`);
180
+ }
181
+ const row = db.prepare(`SELECT COUNT(*) AS count FROM ${spec.table} WHERE ${spec.repoColumn} = ?`).get(repoFullName);
182
+ return Number(row.count);
183
+ }
@@ -0,0 +1,32 @@
1
+ export const SUBMISSION_FRESHNESS_ABORT_EVENT: "submission_freshness_abort";
2
+
3
+ export type FreshnessAbortReason = "issue_closed" | "already_addressed" | "claim_superseded" | "live_state_unavailable";
4
+
5
+ export type SubmissionFreshnessCandidate = {
6
+ repoFullName: string;
7
+ issueNumber: number;
8
+ minerLogin: string;
9
+ };
10
+
11
+ export type LiveIssueSnapshot = {
12
+ state: "open" | "closed";
13
+ referencingPrs: Array<{ number: number; state: "open" | "closed" | "merged"; authorLogin: string; createdAt: string | null }>;
14
+ };
15
+
16
+ export type SubmissionFreshnessClaimLedger = {
17
+ listClaims(filter: { repoFullName?: string; status?: string }): Array<{ repoFullName: string; issueNumber: number; status: string }>;
18
+ };
19
+
20
+ export type SubmissionFreshnessEventLedger = {
21
+ appendEvent(event: { type: string; repoFullName?: string; payload: Record<string, unknown> }): unknown;
22
+ };
23
+
24
+ export type SubmissionFreshnessDeps = {
25
+ claimLedger: SubmissionFreshnessClaimLedger;
26
+ fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<LiveIssueSnapshot | null>;
27
+ eventLedger: SubmissionFreshnessEventLedger;
28
+ };
29
+
30
+ export type SubmissionFreshnessResult = { fresh: true } | { fresh: false; reason: FreshnessAbortReason };
31
+
32
+ export function checkSubmissionFreshness(candidate: SubmissionFreshnessCandidate, deps: SubmissionFreshnessDeps): Promise<SubmissionFreshnessResult>;
@@ -0,0 +1,93 @@
1
+ // Late-binding freshness check before open_pr fires (#3007). A soft-claim made at the start of a long
2
+ // create/iterate loop can go stale by the time a candidate reaches submission: the target issue may have been
3
+ // closed, already fixed by another author, or the miner's own claim may have been released/expired in the
4
+ // interim. This is a FINAL, read-only check immediately before open_pr spec construction -- complementing, not
5
+ // replacing, the claim-time check (src/miner/soft-claim.ts) -- so a stale submission never reaches the Governor
6
+ // chokepoint (governor-chokepoint.js) as a live write attempt: check freshness first, THEN prepareOpenPrSubmission
7
+ // (harness-submission-trigger.js), THEN the Governor. These are separate, sequentially-composed units, not nested
8
+ // calls -- a future real call site wires them together in that order.
9
+ //
10
+ // READ-ONLY BY CONTRACT: never writes anything except its own abort-reason audit event (on staleness only, not
11
+ // on every check -- mirrors this issue's own "log the abort reason" wording, not a per-decision audit trail).
12
+ // The live-state fetch is an injected dependency so this stays testable without real network I/O and agnostic
13
+ // to HOW the caller sources issue/PR state (raw GitHub API, gittensory's own cached MCP data, etc.).
14
+ //
15
+ // FAIL CLOSED: an unreachable/failed live-state fetch is treated as stale (aborts), never as "no evidence of
16
+ // staleness, so proceed" -- mirrors this package's fail-closed convention elsewhere (harness-submission-
17
+ // trigger.js's predicted_gate_unavailable/slop_assessment_unavailable, iterate-loop.ts's ambiguous-on-error).
18
+ //
19
+ // NOT a rejection outcome: staleness is caught BEFORE any PR exists, so it is not the same lifecycle event as
20
+ // rejection-state-machine.js's DISENGAGED_OUTCOME (which handles an EXISTING PR a maintainer closed). "No PR,
21
+ // no noisy failure" here just means: return a quiet not-fresh result, same shape as any other blocked gate
22
+ // decision in this package -- never throw, never surface anything to the target repo.
23
+
24
+ export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort";
25
+
26
+ /**
27
+ * Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr.
28
+ * Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the
29
+ * live issue/PR snapshot. Fails closed (throws) on a malformed candidate or missing dependency.
30
+ *
31
+ * @param {{ repoFullName: string, issueNumber: number, minerLogin: string }} candidate
32
+ * @param {{
33
+ * claimLedger: { listClaims(filter: { repoFullName?: string, status?: string }): Array<{ repoFullName: string, issueNumber: number, status: string }> },
34
+ * fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<{ state: "open"|"closed", referencingPrs: Array<{ number: number, state: "open"|"closed"|"merged", authorLogin: string }> } | null>,
35
+ * eventLedger: { appendEvent(event: { type: string, repoFullName?: string, payload: Record<string, unknown> }): unknown },
36
+ * }} deps
37
+ */
38
+ export async function checkSubmissionFreshness(candidate, deps) {
39
+ if (!candidate || typeof candidate !== "object") throw new Error("invalid_freshness_candidate");
40
+ const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : "";
41
+ if (!repoFullName) throw new Error("invalid_repo_full_name");
42
+ if (!Number.isInteger(candidate.issueNumber) || candidate.issueNumber < 1) throw new Error("invalid_issue_number");
43
+ const minerLogin = typeof candidate.minerLogin === "string" ? candidate.minerLogin.trim() : "";
44
+ if (!minerLogin) throw new Error("invalid_miner_login");
45
+
46
+ if (!deps || typeof deps !== "object") throw new Error("invalid_freshness_deps");
47
+ const { claimLedger, fetchLiveIssueSnapshot, eventLedger } = deps;
48
+ if (!claimLedger || typeof claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger");
49
+ if (typeof fetchLiveIssueSnapshot !== "function") throw new Error("invalid_live_state_fetcher");
50
+ if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger");
51
+
52
+ const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber);
53
+ if (!claim || claim.status !== "active") {
54
+ return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded");
55
+ }
56
+
57
+ let snapshot;
58
+ try {
59
+ snapshot = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber);
60
+ } catch {
61
+ snapshot = null;
62
+ }
63
+ if (!snapshot || typeof snapshot !== "object") {
64
+ return abort(eventLedger, repoFullName, candidate.issueNumber, "live_state_unavailable");
65
+ }
66
+
67
+ if (snapshot.state === "closed") {
68
+ return abort(eventLedger, repoFullName, candidate.issueNumber, "issue_closed");
69
+ }
70
+
71
+ // GitHub logins are case-insensitive for identity purposes (the same account can be echoed back with
72
+ // different casing by different API responses), so a strict `!==` would misclassify the miner's own
73
+ // referencing PR as "another author" whenever the casing happens to differ -- compare case-normalized.
74
+ const minerLoginKey = minerLogin.toLowerCase();
75
+ const referencingPrs = Array.isArray(snapshot.referencingPrs) ? snapshot.referencingPrs : [];
76
+ const addressedByAnotherAuthor = referencingPrs.some(
77
+ (pr) => typeof pr.authorLogin === "string" && pr.authorLogin.trim().toLowerCase() !== minerLoginKey && (pr.state === "merged" || pr.state === "open"),
78
+ );
79
+ if (addressedByAnotherAuthor) {
80
+ return abort(eventLedger, repoFullName, candidate.issueNumber, "already_addressed");
81
+ }
82
+
83
+ return { fresh: true };
84
+ }
85
+
86
+ function abort(eventLedger, repoFullName, issueNumber, reason) {
87
+ eventLedger.appendEvent({
88
+ type: SUBMISSION_FRESHNESS_ABORT_EVENT,
89
+ repoFullName,
90
+ payload: { issueNumber, reason },
91
+ });
92
+ return { fresh: false, reason };
93
+ }
@@ -0,0 +1,36 @@
1
+ export function resolveNpmRegistryUrl(
2
+ env?: Record<string, string | undefined>,
3
+ ): string;
4
+ export function resolveUpgradeCommand(packageName?: string): string;
5
+ export function shouldSkipUpdateCheck(
6
+ cliArgs: string[],
7
+ env?: Record<string, string | undefined>,
8
+ ): boolean;
9
+ export function compareSemver(a: string, b: string): -1 | 0 | 1 | null;
10
+ export function fetchLatestPackageVersion(input: {
11
+ packageName: string;
12
+ npmRegistryUrl: string;
13
+ timeoutMs?: number;
14
+ }): Promise<string>;
15
+ export function maybePrintUpdateNudge(input: {
16
+ packageName: string;
17
+ packageVersion: string;
18
+ npmRegistryUrl: string;
19
+ upgradeCommand: string;
20
+ timeoutMs?: number;
21
+ }): Promise<void>;
22
+ export function startUpdateCheck(
23
+ cliArgs: string[],
24
+ input: {
25
+ packageName: string;
26
+ packageVersion: string;
27
+ upgradeCommand?: string;
28
+ env?: Record<string, string | undefined>;
29
+ timeoutMs?: number;
30
+ },
31
+ ): Promise<void>;
32
+ export const updateCheckExitGraceMs: number;
33
+ export function awaitOpportunisticUpdateCheck(
34
+ updateCheck: Promise<void>,
35
+ graceMs?: number,
36
+ ): Promise<void>;
@@ -0,0 +1,161 @@
1
+ const defaultPackageName = "@loopover/miner";
2
+ const defaultNpmRegistryUrl = "https://registry.npmjs.org";
3
+
4
+ function isLocalRegistryHost(hostname) {
5
+ const normalized = hostname.toLowerCase().replace(/\.$/, "");
6
+ return (
7
+ normalized === "localhost" ||
8
+ normalized === "127.0.0.1" ||
9
+ normalized === "::1" ||
10
+ normalized === "[::1]"
11
+ );
12
+ }
13
+
14
+ export function resolveNpmRegistryUrl(env = process.env) {
15
+ const raw = env.GITTENSORY_NPM_REGISTRY_URL?.trim();
16
+ if (!raw) return defaultNpmRegistryUrl;
17
+
18
+ let url;
19
+ try {
20
+ url = new URL(raw);
21
+ } catch {
22
+ return defaultNpmRegistryUrl;
23
+ }
24
+
25
+ if (url.username || url.password || url.search || url.hash || !url.hostname) {
26
+ return defaultNpmRegistryUrl;
27
+ }
28
+
29
+ const local = isLocalRegistryHost(url.hostname);
30
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && local)) {
31
+ return defaultNpmRegistryUrl;
32
+ }
33
+
34
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/, "");
35
+ return `${url.origin}${path}`;
36
+ }
37
+
38
+ export function resolveUpgradeCommand(packageName = defaultPackageName) {
39
+ return `npm install -g ${packageName}@latest`;
40
+ }
41
+
42
+ export function shouldSkipUpdateCheck(cliArgs, env = process.env) {
43
+ if (/^(1|true|yes)$/i.test(env.GITTENSORY_MINER_NO_UPDATE_CHECK ?? ""))
44
+ return true;
45
+ return cliArgs.includes("--no-update-check");
46
+ }
47
+
48
+ function parseSemver(version) {
49
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(
50
+ String(version ?? "").trim(),
51
+ );
52
+ if (!match) return null;
53
+ return {
54
+ major: Number(match[1]),
55
+ minor: Number(match[2]),
56
+ patch: Number(match[3]),
57
+ prerelease: match[4] ?? null,
58
+ };
59
+ }
60
+
61
+ // Numeric identifiers are compared as decimal strings, not via Number(), which loses precision beyond
62
+ // Number.MAX_SAFE_INTEGER (2^53-1): two distinct digit strings past that width can round to the SAME float,
63
+ // making Number(leftId) !== Number(rightId) wrongly report them as equal (mirrors the same fix already applied
64
+ // to compareMcpSemver's comparePrerelease in src/services/mcp-compatibility.ts, #3049). With no leading zeros
65
+ // (semver's own numeric-identifier rule), a longer digit string is the larger number, and equal-length strings
66
+ // compare lexicographically.
67
+ function comparePrerelease(a, b) {
68
+ const left = a.split(".");
69
+ const right = b.split(".");
70
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
71
+ const leftId = left[index];
72
+ const rightId = right[index];
73
+ if (leftId === undefined) return -1;
74
+ if (rightId === undefined) return 1;
75
+ const leftNumeric = /^\d+$/.test(leftId);
76
+ const rightNumeric = /^\d+$/.test(rightId);
77
+ if (leftNumeric && rightNumeric) {
78
+ if (leftId.length !== rightId.length) return leftId.length < rightId.length ? -1 : 1;
79
+ if (leftId !== rightId) return leftId < rightId ? -1 : 1;
80
+ } else if (leftNumeric !== rightNumeric) {
81
+ return leftNumeric ? -1 : 1;
82
+ } else if (leftId !== rightId) {
83
+ return leftId < rightId ? -1 : 1;
84
+ }
85
+ }
86
+ return 0;
87
+ }
88
+
89
+ export function compareSemver(a, b) {
90
+ const left = parseSemver(a);
91
+ const right = parseSemver(b);
92
+ if (!left || !right) return null;
93
+ for (const part of ["major", "minor", "patch"]) {
94
+ if (left[part] !== right[part]) return left[part] < right[part] ? -1 : 1;
95
+ }
96
+ if (left.prerelease === right.prerelease) return 0;
97
+ if (left.prerelease === null) return 1;
98
+ if (right.prerelease === null) return -1;
99
+ return comparePrerelease(left.prerelease, right.prerelease);
100
+ }
101
+
102
+ export async function fetchLatestPackageVersion(input) {
103
+ const controller = new AbortController();
104
+ const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 5000);
105
+ const registrySlug = input.packageName.startsWith("@")
106
+ ? input.packageName.replace("/", "%2F")
107
+ : input.packageName;
108
+ const registryPath = `${input.npmRegistryUrl}/${registrySlug}/latest`;
109
+ try {
110
+ const response = await fetch(registryPath, {
111
+ signal: controller.signal,
112
+ headers: { accept: "application/json" },
113
+ });
114
+ const payload = await response.json().catch(() => ({}));
115
+ if (!response.ok || typeof payload.version !== "string")
116
+ throw new Error("npm_latest_version_unavailable");
117
+ return payload.version;
118
+ } finally {
119
+ clearTimeout(timeout);
120
+ }
121
+ }
122
+
123
+ // Non-blocking startup nudge: prints one upgrade line when local is behind npm latest.
124
+ // Mirrors packages/gittensory-mcp/bin/gittensory-mcp.js packageVersion/npmRegistryUrl/upgradeCommand (#2331).
125
+ export async function maybePrintUpdateNudge(input) {
126
+ try {
127
+ const latestVersion = await fetchLatestPackageVersion(input);
128
+ const comparison = compareSemver(input.packageVersion, latestVersion);
129
+ if (comparison !== null && comparison < 0) {
130
+ process.stderr.write(`${input.upgradeCommand}\n`);
131
+ }
132
+ } catch {
133
+ // Offline or unreachable registry — never block or fail the CLI.
134
+ }
135
+ }
136
+
137
+ export function startUpdateCheck(cliArgs, input) {
138
+ if (shouldSkipUpdateCheck(cliArgs, input.env)) return Promise.resolve();
139
+ return maybePrintUpdateNudge({
140
+ packageName: input.packageName,
141
+ packageVersion: input.packageVersion,
142
+ npmRegistryUrl: resolveNpmRegistryUrl(input.env),
143
+ upgradeCommand:
144
+ input.upgradeCommand ?? resolveUpgradeCommand(input.packageName),
145
+ timeoutMs: input.timeoutMs,
146
+ });
147
+ }
148
+
149
+ export const updateCheckExitGraceMs = 250;
150
+
151
+ // After command output is printed, give a fast registry response time to emit the nudge
152
+ // without waiting for the full lookup timeout on slow/offline registries.
153
+ export async function awaitOpportunisticUpdateCheck(
154
+ updateCheck,
155
+ graceMs = updateCheckExitGraceMs,
156
+ ) {
157
+ await Promise.race([
158
+ updateCheck.catch(() => undefined),
159
+ new Promise((resolve) => setTimeout(resolve, graceMs)),
160
+ ]);
161
+ }
@@ -0,0 +1,3 @@
1
+ export const MINER_PACKAGE_VERSION: string;
2
+
3
+ export function resolveMinerVersion(env?: Record<string, string | undefined>): string;
package/lib/version.js ADDED
@@ -0,0 +1,10 @@
1
+ import ownPackageJson from "../package.json" with { type: "json" };
2
+
3
+ /** Package.json semver at import time — the laptop npm-install default. */
4
+ export const MINER_PACKAGE_VERSION = ownPackageJson.version;
5
+
6
+ /** Resolved miner release id: `GITTENSORY_MINER_VERSION` wins when set (fleet Docker image builds). */
7
+ export function resolveMinerVersion(env = process.env) {
8
+ const override = typeof env.GITTENSORY_MINER_VERSION === "string" ? env.GITTENSORY_MINER_VERSION.trim() : "";
9
+ return override || MINER_PACKAGE_VERSION;
10
+ }
@@ -0,0 +1,39 @@
1
+ export type WorktreeAllocation = {
2
+ slotIndex: number;
3
+ worktreePath: string;
4
+ attemptId: string | null;
5
+ repoFullName: string | null;
6
+ status: "free" | "active";
7
+ ownerPid: number | null;
8
+ allocatedAt: string | null;
9
+ };
10
+
11
+ export type WorktreeAllocator = {
12
+ dbPath: string;
13
+ worktreeBaseDir: string;
14
+ maxConcurrency: number;
15
+ processPid: number;
16
+ acquire(attemptId: string, repoFullName: string): WorktreeAllocation;
17
+ release(attemptId: string): WorktreeAllocation | null;
18
+ listSlots(): WorktreeAllocation[];
19
+ close(): void;
20
+ };
21
+
22
+ export function resolveWorktreeAllocatorDbPath(env?: Record<string, string | undefined>): string;
23
+
24
+ export function resolveWorktreeBaseDir(env?: Record<string, string | undefined>): string;
25
+
26
+ export function isProcessAlive(pid: number): boolean;
27
+
28
+ export function openWorktreeAllocator(options?: {
29
+ dbPath?: string;
30
+ worktreeBaseDir?: string;
31
+ maxConcurrency?: number;
32
+ processPid?: number;
33
+ }): WorktreeAllocator;
34
+
35
+ export function acquireWorktree(attemptId: string, repoFullName: string): WorktreeAllocation;
36
+
37
+ export function releaseWorktree(attemptId: string): WorktreeAllocation | null;
38
+
39
+ export function closeDefaultWorktreeAllocator(): void;