@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,231 @@
1
+ import { chmodSync, mkdirSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { applySchemaMigrations } from "./schema-version.js";
6
+
7
+ // Local SQLite persistence for the stateless MCP plan DAG (#2318). `gittensory_build_plan`/`plan_status`/
8
+ // `record_step_result` are stateless — the caller holds the plan and passes it back each call — so a miner running
9
+ // unattended across process restarts needs somewhere to persist the plan object between calls. This is local-only
10
+ // bookkeeping (no plan logic, no network), 100% client-side, mirroring the package's other local stores. Every
11
+ // plan is validated against the `planDagSchema` shape (src/mcp/server.ts) on BOTH save and load, so a corrupted
12
+ // local row fails loudly instead of feeding a malformed plan back into `gittensory_plan_status`.
13
+
14
+ const PLAN_STEP_STATUSES = Object.freeze(["pending", "running", "completed", "failed", "skipped"]);
15
+ /** Derived plan-level status used for `listPlans({ status })`. */
16
+ export const PLAN_STATUSES = Object.freeze(["pending", "running", "completed", "failed"]);
17
+
18
+ const stepStatusSet = new Set(PLAN_STEP_STATUSES);
19
+ const planStatusSet = new Set(PLAN_STATUSES);
20
+ const defaultDbFileName = "plan-store.sqlite3";
21
+ let defaultPlanStore = null;
22
+
23
+ export function resolvePlanStoreDbPath(env = process.env) {
24
+ const explicitPath = typeof env.GITTENSORY_MINER_PLAN_STORE_DB === "string"
25
+ ? env.GITTENSORY_MINER_PLAN_STORE_DB.trim()
26
+ : "";
27
+ if (explicitPath) return explicitPath;
28
+
29
+ const explicitConfigDir = typeof env.GITTENSORY_MINER_CONFIG_DIR === "string"
30
+ ? env.GITTENSORY_MINER_CONFIG_DIR.trim()
31
+ : "";
32
+ if (explicitConfigDir) return join(explicitConfigDir, defaultDbFileName);
33
+
34
+ const configHome = typeof env.XDG_CONFIG_HOME === "string" && env.XDG_CONFIG_HOME.trim()
35
+ ? env.XDG_CONFIG_HOME.trim()
36
+ : join(homedir(), ".config");
37
+ return join(configHome, "gittensory-miner", defaultDbFileName);
38
+ }
39
+
40
+ function normalizeDbPath(dbPath) {
41
+ const raw = dbPath ?? resolvePlanStoreDbPath();
42
+ if (typeof raw !== "string" || !raw.trim()) throw new Error("invalid_plan_store_db_path");
43
+ return raw.trim();
44
+ }
45
+
46
+ function normalizePlanId(planId) {
47
+ if (typeof planId !== "string" || !planId.trim()) throw new Error("invalid_plan_id");
48
+ return planId.trim();
49
+ }
50
+
51
+ function normalizePlanStatusFilter(status) {
52
+ if (status === undefined || status === null) return undefined;
53
+ if (!planStatusSet.has(status)) throw new Error("invalid_status");
54
+ return status;
55
+ }
56
+
57
+ function isBoundedString(value, min, max) {
58
+ return typeof value === "string" && value.length >= min && value.length <= max;
59
+ }
60
+
61
+ function isBoundedInt(value, min, max) {
62
+ return Number.isInteger(value) && value >= min && value <= max;
63
+ }
64
+
65
+ const STEP_KEYS = new Set(["id", "title", "actionClass", "dependsOn", "status", "attempts", "maxAttempts", "lastError"]);
66
+
67
+ function isValidStep(step) {
68
+ if (!step || typeof step !== "object" || Array.isArray(step)) return false;
69
+ for (const key of Object.keys(step)) if (!STEP_KEYS.has(key)) return false; // strict: no unknown keys
70
+ if (!isBoundedString(step.id, 1, 100) || !isBoundedString(step.title, 1, 300)) return false;
71
+ if (step.actionClass !== undefined && !isBoundedString(step.actionClass, 1, 60)) return false;
72
+ if (!Array.isArray(step.dependsOn) || step.dependsOn.length > 50) return false;
73
+ if (!step.dependsOn.every((dep) => isBoundedString(dep, 1, 100))) return false;
74
+ if (!stepStatusSet.has(step.status)) return false;
75
+ if (!isBoundedInt(step.attempts, 0, Number.MAX_SAFE_INTEGER)) return false;
76
+ if (!isBoundedInt(step.maxAttempts, 1, 10)) return false;
77
+ if (step.lastError !== undefined && step.lastError !== null && !isBoundedString(step.lastError, 0, 2000)) return false;
78
+ return true;
79
+ }
80
+
81
+ /** Validate a plan against the `planDagSchema` shape (strict `{ steps: PlanStep[] }`, ≤100 steps). Throws on any
82
+ * malformed field so a bad plan can neither be saved nor read back. */
83
+ function validatePlanDag(plan) {
84
+ if (!plan || typeof plan !== "object" || Array.isArray(plan)) throw new Error("invalid_plan");
85
+ const keys = Object.keys(plan);
86
+ if (keys.length !== 1 || keys[0] !== "steps") throw new Error("invalid_plan");
87
+ if (!Array.isArray(plan.steps) || plan.steps.length === 0 || plan.steps.length > 100) {
88
+ throw new Error("invalid_plan");
89
+ }
90
+ if (!plan.steps.every(isValidStep)) throw new Error("invalid_plan");
91
+ const seenStepIds = new Set();
92
+ for (const step of plan.steps) {
93
+ if (seenStepIds.has(step.id)) throw new Error("invalid_plan");
94
+ seenStepIds.add(step.id);
95
+ }
96
+ for (const step of plan.steps) {
97
+ for (const dep of step.dependsOn) {
98
+ if (dep === step.id || !seenStepIds.has(dep)) throw new Error("invalid_plan");
99
+ }
100
+ }
101
+ const color = new Map();
102
+ const byId = new Map(plan.steps.map((step) => [step.id, step]));
103
+ const hasCycle = (id) => {
104
+ color.set(id, 1);
105
+ for (const dep of byId.get(id)?.dependsOn ?? []) {
106
+ const depColor = color.get(dep) ?? 0;
107
+ if (depColor === 1) return true;
108
+ if (depColor === 0 && byId.has(dep) && hasCycle(dep)) return true;
109
+ }
110
+ color.set(id, 2);
111
+ return false;
112
+ };
113
+ for (const step of plan.steps) {
114
+ if ((color.get(step.id) ?? 0) === 0 && hasCycle(step.id)) {
115
+ throw new Error("invalid_plan");
116
+ }
117
+ }
118
+ return plan;
119
+ }
120
+
121
+ /** Derive a plan-level status from its steps: any failed → failed; else any running → running; else all steps
122
+ * finished (completed/skipped) with at least one step → completed; otherwise pending. */
123
+ function computePlanStatus(plan) {
124
+ const steps = plan.steps;
125
+ if (steps.some((step) => step.status === "failed")) return "failed";
126
+ if (steps.some((step) => step.status === "running")) return "running";
127
+ if (steps.length > 0 && steps.every((step) => step.status === "completed" || step.status === "skipped")) {
128
+ return "completed";
129
+ }
130
+ return "pending";
131
+ }
132
+
133
+ function rowToRecord(row) {
134
+ let plan;
135
+ try {
136
+ plan = validatePlanDag(JSON.parse(row.plan_json));
137
+ } catch {
138
+ throw new Error("corrupted_plan_row"); // stored blob no longer matches the plan shape
139
+ }
140
+ // Also fail closed on the status column: a manually-edited or legacy row (predating the CHECK constraint) could
141
+ // hold a status outside PLAN_STATUSES, which would otherwise violate the exported PlanRecord contract on read.
142
+ if (!planStatusSet.has(row.status)) throw new Error("corrupted_plan_row");
143
+ return { planId: row.plan_id, plan, status: row.status, updatedAt: row.updated_at };
144
+ }
145
+
146
+ /**
147
+ * Opens the local plan store, creating the table on first use. `savePlan` is a single atomic INSERT…ON CONFLICT
148
+ * upsert keyed by `plan_id`; the plan JSON is validated on save AND re-validated on load, so a corrupted row is
149
+ * rejected rather than silently returned. (#2318)
150
+ */
151
+ export function openPlanStore(dbPath = resolvePlanStoreDbPath()) {
152
+ const resolvedPath = normalizeDbPath(dbPath);
153
+ // The store is a persistent local file; the special in-memory path (':memory:') has no file to create or chmod.
154
+ if (resolvedPath !== ":memory:") {
155
+ mkdirSync(dirname(resolvedPath), { recursive: true, mode: 0o700 });
156
+ }
157
+ const db = new DatabaseSync(resolvedPath);
158
+ if (resolvedPath !== ":memory:") chmodSync(resolvedPath, 0o600);
159
+ db.exec("PRAGMA busy_timeout = 5000");
160
+ db.exec(`
161
+ CREATE TABLE IF NOT EXISTS miner_plans (
162
+ plan_id TEXT PRIMARY KEY,
163
+ plan_json TEXT NOT NULL,
164
+ status TEXT NOT NULL CHECK (status IN ('pending', 'running', 'completed', 'failed')),
165
+ updated_at TEXT NOT NULL
166
+ )
167
+ `);
168
+ // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
169
+ applySchemaMigrations(db, []);
170
+
171
+ const saveStatement = db.prepare(`
172
+ INSERT INTO miner_plans (plan_id, plan_json, status, updated_at)
173
+ VALUES (?, ?, ?, ?)
174
+ ON CONFLICT(plan_id) DO UPDATE SET
175
+ plan_json = excluded.plan_json,
176
+ status = excluded.status,
177
+ updated_at = excluded.updated_at
178
+ `);
179
+ const getStatement = db.prepare("SELECT * FROM miner_plans WHERE plan_id = ?");
180
+ const listAllStatement = db.prepare("SELECT * FROM miner_plans ORDER BY plan_id ASC");
181
+ const listStatusStatement = db.prepare("SELECT * FROM miner_plans WHERE status = ? ORDER BY plan_id ASC");
182
+
183
+ return {
184
+ dbPath: resolvedPath,
185
+ savePlan(planId, plan) {
186
+ const id = normalizePlanId(planId);
187
+ validatePlanDag(plan);
188
+ const status = computePlanStatus(plan);
189
+ const updatedAt = new Date().toISOString();
190
+ saveStatement.run(id, JSON.stringify(plan), status, updatedAt);
191
+ return { planId: id, plan, status, updatedAt };
192
+ },
193
+ loadPlan(planId) {
194
+ const row = getStatement.get(normalizePlanId(planId));
195
+ return row ? rowToRecord(row) : null;
196
+ },
197
+ listPlans(filter = {}) {
198
+ const status = normalizePlanStatusFilter(filter.status);
199
+ if (status !== undefined) {
200
+ return listStatusStatement.all(status).map(rowToRecord);
201
+ }
202
+ return listAllStatement.all().map(rowToRecord);
203
+ },
204
+ close() {
205
+ db.close();
206
+ },
207
+ };
208
+ }
209
+
210
+ function getDefaultPlanStore() {
211
+ defaultPlanStore ??= openPlanStore();
212
+ return defaultPlanStore;
213
+ }
214
+
215
+ export function savePlan(planId, plan) {
216
+ return getDefaultPlanStore().savePlan(planId, plan);
217
+ }
218
+
219
+ export function loadPlan(planId) {
220
+ return getDefaultPlanStore().loadPlan(planId);
221
+ }
222
+
223
+ export function listPlans(filter) {
224
+ return getDefaultPlanStore().listPlans(filter);
225
+ }
226
+
227
+ export function closeDefaultPlanStore() {
228
+ if (!defaultPlanStore) return;
229
+ defaultPlanStore.close();
230
+ defaultPlanStore = null;
231
+ }
@@ -0,0 +1,25 @@
1
+ export type PolicyDocCacheEntry = {
2
+ etag: string;
3
+ content: string;
4
+ };
5
+
6
+ export type PolicyDocCacheWrite = {
7
+ url: string;
8
+ etag: string;
9
+ content: string;
10
+ updatedAt: string;
11
+ };
12
+
13
+ export type PolicyDocCacheStore = {
14
+ dbPath: string;
15
+ get(url: string): PolicyDocCacheEntry | null;
16
+ put(url: string, etag: string, content: string): PolicyDocCacheWrite;
17
+ close(): void;
18
+ };
19
+
20
+ /** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */
21
+ export type PolicyDocCache = Pick<PolicyDocCacheStore, "get" | "put">;
22
+
23
+ export function resolvePolicyDocCacheDbPath(env?: Record<string, string | undefined>): string;
24
+
25
+ export function initPolicyDocCacheStore(dbPath?: string): PolicyDocCacheStore;
@@ -0,0 +1,79 @@
1
+ import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
2
+ import { applySchemaMigrations } from "./schema-version.js";
3
+
4
+ // Local ETag cache for discovery's small policy-doc fetches (#4842). `discover` refetches each target repo's
5
+ // AI-USAGE.md/CONTRIBUTING.md on every run even though they rarely change, spending rate-limit budget on static
6
+ // content; this store lets opportunity-fanout.js revalidate with a conditional GET (If-None-Match) instead, and
7
+ // GitHub answers an unchanged doc with a 304 that costs no primary rate-limit budget. A 304 is a GitHub-confirmed
8
+ // unchanged body -- the cached content is only ever served AFTER a same-run revalidation, never blindly -- so this
9
+ // can never surface a stale policy that would wrongly permit autonomous work on an opted-out repo. Same 100%
10
+ // local/client-side discipline (mirrors run-state.js and the other stores this package owns via local-store.js):
11
+ // the file lives only on this machine and is never uploaded, synced, or phoned home with.
12
+
13
+ const defaultDbFileName = "policy-doc-cache.sqlite3";
14
+
15
+ export function resolvePolicyDocCacheDbPath(env = process.env) {
16
+ return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_POLICY_DOC_CACHE_DB", env);
17
+ }
18
+
19
+ function normalizeDbPath(dbPath) {
20
+ return normalizeLocalStoreDbPath(dbPath, resolvePolicyDocCacheDbPath(), "invalid_policy_doc_cache_db_path");
21
+ }
22
+
23
+ function normalizeUrl(url) {
24
+ if (typeof url !== "string") throw new Error("invalid_policy_doc_url");
25
+ const trimmed = url.trim();
26
+ if (!trimmed) throw new Error("invalid_policy_doc_url");
27
+ return trimmed;
28
+ }
29
+
30
+ /**
31
+ * Opens the 100% local/client-side miner policy-doc ETag cache. The database only lives on this machine; this
32
+ * module never uploads, syncs, or phones home with its contents. (#4842)
33
+ */
34
+ export function initPolicyDocCacheStore(dbPath = resolvePolicyDocCacheDbPath()) {
35
+ const resolvedPath = normalizeDbPath(dbPath);
36
+ const db = openLocalStoreDb(resolvedPath);
37
+ db.exec(`
38
+ CREATE TABLE IF NOT EXISTS policy_doc_cache (
39
+ url TEXT PRIMARY KEY,
40
+ etag TEXT NOT NULL,
41
+ content TEXT NOT NULL,
42
+ updated_at TEXT NOT NULL
43
+ )
44
+ `);
45
+ // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
46
+ applySchemaMigrations(db, []);
47
+
48
+ const getStatement = db.prepare("SELECT etag, content FROM policy_doc_cache WHERE url = ?");
49
+ const putStatement = db.prepare(`
50
+ INSERT INTO policy_doc_cache (url, etag, content, updated_at)
51
+ VALUES (?, ?, ?, ?)
52
+ ON CONFLICT(url) DO UPDATE SET
53
+ etag = excluded.etag,
54
+ content = excluded.content,
55
+ updated_at = excluded.updated_at
56
+ `);
57
+
58
+ return {
59
+ dbPath: resolvedPath,
60
+ /** The last-known `{ etag, content }` for a policy-doc URL, or null when it has never been cached. Both columns
61
+ * are `TEXT NOT NULL`, so a present row always carries string values. */
62
+ get(url) {
63
+ const row = getStatement.get(normalizeUrl(url));
64
+ return row ? { etag: row.etag, content: row.content } : null;
65
+ },
66
+ /** Record the fresh ETag + body so the next run can revalidate it with a conditional GET. */
67
+ put(url, etag, content) {
68
+ const normalizedUrl = normalizeUrl(url);
69
+ if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_doc_etag");
70
+ if (typeof content !== "string") throw new Error("invalid_policy_doc_content");
71
+ const updatedAt = new Date().toISOString();
72
+ putStatement.run(normalizedUrl, etag, content, updatedAt);
73
+ return { url: normalizedUrl, etag, content, updatedAt };
74
+ },
75
+ close() {
76
+ db.close();
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,35 @@
1
+ import type { AiPolicyVerdict } from "@loopover/engine";
2
+
3
+ export type PolicyVerdictDecisiveDoc = "AI-USAGE.md" | "CONTRIBUTING.md";
4
+
5
+ export type PolicyVerdictCacheEntry = {
6
+ decisiveDoc: PolicyVerdictDecisiveDoc;
7
+ etag: string;
8
+ verdict: AiPolicyVerdict;
9
+ };
10
+
11
+ export type PolicyVerdictCacheWrite = PolicyVerdictCacheEntry & {
12
+ repoScope: string;
13
+ updatedAt: string;
14
+ };
15
+
16
+ export type PolicyVerdictCacheStore = {
17
+ dbPath: string;
18
+ /** `repoScope` must uniquely identify a tenant forge host + repo (see `policyVerdictCacheKey` in
19
+ * opportunity-fanout.js) -- a bare `owner/repo` is not safe across multiple forge hosts. */
20
+ get(repoScope: string): PolicyVerdictCacheEntry | null;
21
+ put(
22
+ repoScope: string,
23
+ decisiveDoc: PolicyVerdictDecisiveDoc,
24
+ etag: string,
25
+ verdict: AiPolicyVerdict,
26
+ ): PolicyVerdictCacheWrite;
27
+ close(): void;
28
+ };
29
+
30
+ /** The read/write surface opportunity-fanout.js needs to inject a cache without depending on the SQLite store. */
31
+ export type PolicyVerdictCache = Pick<PolicyVerdictCacheStore, "get" | "put">;
32
+
33
+ export function resolvePolicyVerdictCacheDbPath(env?: Record<string, string | undefined>): string;
34
+
35
+ export function initPolicyVerdictCacheStore(dbPath?: string): PolicyVerdictCacheStore;
@@ -0,0 +1,106 @@
1
+ import { normalizeLocalStoreDbPath, openLocalStoreDb, resolveLocalStoreDbPath } from "./local-store.js";
2
+ import { applySchemaMigrations } from "./schema-version.js";
3
+
4
+ // Local cache of resolved AI-usage-policy verdicts (#4843). Even with #4842's conditional-GET doc cache, the small
5
+ // but non-zero cost of resolving `resolveAiPolicyVerdict` from raw doc text was still paid on every discover run.
6
+ // This stores the verdict itself, keyed by repo SCOPE (the tenant's `apiBaseUrl` plus `owner/repo` -- see
7
+ // `policyVerdictCacheKey` in opportunity-fanout.js, same "the caller owns what makes a cache key" precedent as
8
+ // policy-doc-cache.js keying on the full request URL) + the ETag of whichever doc actually decided it, so a
9
+ // repeat run against an unchanged repo reuses the prior verdict outright once opportunity-fanout.js's same-run
10
+ // conditional-GET confirms that doc's ETag hasn't moved -- never served blindly, exactly the same "cheaper, never
11
+ // less correct" discipline as policy-doc-cache.js. `owner/repo` alone is NOT a safe key: two different tenant
12
+ // forge hosts can each have their own unrelated `acme/widgets`, and without the host in the key a verdict
13
+ // resolved against one host's docs could be served for the other's. 100% local/client-side, same as every other
14
+ // store this package owns via local-store.js: the file lives only on this machine and is never uploaded, synced,
15
+ // or phoned home with.
16
+
17
+ const defaultDbFileName = "policy-verdict-cache.sqlite3";
18
+ const DECISIVE_DOCS = new Set(["AI-USAGE.md", "CONTRIBUTING.md"]);
19
+
20
+ export function resolvePolicyVerdictCacheDbPath(env = process.env) {
21
+ return resolveLocalStoreDbPath(defaultDbFileName, "GITTENSORY_MINER_POLICY_VERDICT_CACHE_DB", env);
22
+ }
23
+
24
+ function normalizeDbPath(dbPath) {
25
+ return normalizeLocalStoreDbPath(dbPath, resolvePolicyVerdictCacheDbPath(), "invalid_policy_verdict_cache_db_path");
26
+ }
27
+
28
+ function normalizeRepoScope(repoScope) {
29
+ if (typeof repoScope !== "string") throw new Error("invalid_policy_verdict_repo_scope");
30
+ const trimmed = repoScope.trim();
31
+ if (!trimmed) throw new Error("invalid_policy_verdict_repo_scope");
32
+ return trimmed;
33
+ }
34
+
35
+ function normalizeDecisiveDoc(decisiveDoc) {
36
+ if (!DECISIVE_DOCS.has(decisiveDoc)) throw new Error("invalid_policy_verdict_decisive_doc");
37
+ return decisiveDoc;
38
+ }
39
+
40
+ function normalizeEtag(etag) {
41
+ if (typeof etag !== "string" || !etag.trim()) throw new Error("invalid_policy_verdict_etag");
42
+ return etag;
43
+ }
44
+
45
+ function serializeVerdict(verdict) {
46
+ if (!verdict || typeof verdict !== "object" || Array.isArray(verdict)) {
47
+ throw new Error("invalid_policy_verdict");
48
+ }
49
+ return JSON.stringify(verdict);
50
+ }
51
+
52
+ /**
53
+ * Opens the 100% local/client-side miner policy-verdict cache. The database only lives on this machine; this
54
+ * module never uploads, syncs, or phones home with its contents. (#4843)
55
+ */
56
+ export function initPolicyVerdictCacheStore(dbPath = resolvePolicyVerdictCacheDbPath()) {
57
+ const resolvedPath = normalizeDbPath(dbPath);
58
+ const db = openLocalStoreDb(resolvedPath);
59
+ db.exec(`
60
+ CREATE TABLE IF NOT EXISTS policy_verdict_cache (
61
+ repo_scope TEXT PRIMARY KEY,
62
+ decisive_doc TEXT NOT NULL,
63
+ etag TEXT NOT NULL,
64
+ verdict TEXT NOT NULL,
65
+ updated_at TEXT NOT NULL
66
+ )
67
+ `);
68
+ // Schema-version convention (#4832): stamp the baseline and run any post-baseline migrations (none yet).
69
+ applySchemaMigrations(db, []);
70
+
71
+ const getStatement = db.prepare(
72
+ "SELECT decisive_doc, etag, verdict FROM policy_verdict_cache WHERE repo_scope = ?",
73
+ );
74
+ const putStatement = db.prepare(`
75
+ INSERT INTO policy_verdict_cache (repo_scope, decisive_doc, etag, verdict, updated_at)
76
+ VALUES (?, ?, ?, ?, ?)
77
+ ON CONFLICT(repo_scope) DO UPDATE SET
78
+ decisive_doc = excluded.decisive_doc,
79
+ etag = excluded.etag,
80
+ verdict = excluded.verdict,
81
+ updated_at = excluded.updated_at
82
+ `);
83
+
84
+ return {
85
+ dbPath: resolvedPath,
86
+ /** The last-known `{ decisiveDoc, etag, verdict }` for a repo scope, or null when it has never been cached. */
87
+ get(repoScope) {
88
+ const row = getStatement.get(normalizeRepoScope(repoScope));
89
+ if (!row) return null;
90
+ return { decisiveDoc: row.decisive_doc, etag: row.etag, verdict: JSON.parse(row.verdict) };
91
+ },
92
+ /** Record the resolved verdict against the ETag of the doc that decided it, so the next run can reuse it. */
93
+ put(repoScope, decisiveDoc, etag, verdict) {
94
+ const normalizedRepoScope = normalizeRepoScope(repoScope);
95
+ const normalizedDecisiveDoc = normalizeDecisiveDoc(decisiveDoc);
96
+ const normalizedEtag = normalizeEtag(etag);
97
+ const serializedVerdict = serializeVerdict(verdict);
98
+ const updatedAt = new Date().toISOString();
99
+ putStatement.run(normalizedRepoScope, normalizedDecisiveDoc, normalizedEtag, serializedVerdict, updatedAt);
100
+ return { repoScope: normalizedRepoScope, decisiveDoc: normalizedDecisiveDoc, etag: normalizedEtag, verdict, updatedAt };
101
+ },
102
+ close() {
103
+ db.close();
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,30 @@
1
+ export interface PortfolioRepoSummary {
2
+ repoFullName: string;
3
+ byStatus: { queued: number; in_progress: number; done: number };
4
+ total: number;
5
+ }
6
+
7
+ export interface PortfolioDashboardSummary {
8
+ total: number;
9
+ byStatus: { queued: number; in_progress: number; done: number };
10
+ repos: PortfolioRepoSummary[];
11
+ oldestQueuedAgeMs: number | null;
12
+ }
13
+
14
+ export interface PortfolioDashboardSources {
15
+ portfolioQueue: { listQueue(repoFullName?: string | null): unknown[] };
16
+ }
17
+
18
+ export function collectPortfolioDashboard(
19
+ sources: PortfolioDashboardSources,
20
+ options?: { nowMs?: number },
21
+ ): PortfolioDashboardSummary;
22
+
23
+ export function renderPortfolioDashboardTable(summary: PortfolioDashboardSummary | null | undefined): string;
24
+
25
+ export function parsePortfolioDashboardArgs(args?: string[]): { json: boolean } | { error: string };
26
+
27
+ export function runPortfolioDashboard(
28
+ args?: string[],
29
+ options?: { initPortfolioQueue?: () => { listQueue(repoFullName: string | null): unknown[]; close(): void }; nowMs?: number },
30
+ ): number;
@@ -0,0 +1,105 @@
1
+ // Read-only portfolio-queue dashboard (#4287). Aggregates the miner's OWN local portfolio-queue backlog
2
+ // (packages/gittensory-miner/lib/portfolio-queue.js) into summary stats — counts by status globally and per repo,
3
+ // plus the oldest queued item's age. Same three-layer shape as manage-status.js (pure collect → pure render → thin
4
+ // CLI glue), but scoped to the backlog/queue rather than per-PR manage state. 100% client-side, read-only — it never
5
+ // mutates queue state and never gates or enforces anything.
6
+ //
7
+ // The extension-panel half named in the issue is a forward dependency, not delivered here: the miner's queue is a
8
+ // local SQLite file with no local-reachable channel a GitHub-page content script can read today. The pure
9
+ // collector below is factored so it is directly reusable once such a channel exists.
10
+
11
+ import { initPortfolioQueueStore } from "./portfolio-queue.js";
12
+ import { argsWantJson, reportCliFailure } from "./cli-error.js";
13
+
14
+ const QUEUE_STATUS_KEYS = ["queued", "in_progress", "done"];
15
+
16
+ function emptyCounts() {
17
+ return { queued: 0, in_progress: 0, done: 0 };
18
+ }
19
+
20
+ /**
21
+ * Pure aggregator over an injected portfolio-queue store (mirrors manage-status.js's `collectManageStatus`).
22
+ * Read-only. Returns global + per-repo status counts and, when a clock is supplied via `options.nowMs`, the age in
23
+ * ms of the oldest still-`queued` item (null when no clock is given or nothing is queued).
24
+ */
25
+ export function collectPortfolioDashboard(sources, options = {}) {
26
+ const portfolioQueue = sources?.portfolioQueue;
27
+ if (!portfolioQueue || typeof portfolioQueue.listQueue !== "function") throw new Error("invalid_portfolio_queue");
28
+ const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : null;
29
+
30
+ const byStatus = emptyCounts();
31
+ const perRepo = new Map();
32
+ let total = 0;
33
+ let oldestQueuedMs = null;
34
+
35
+ for (const entry of portfolioQueue.listQueue(null)) {
36
+ const status = entry?.status;
37
+ if (!QUEUE_STATUS_KEYS.includes(status)) continue;
38
+ const repoFullName = typeof entry.repoFullName === "string" ? entry.repoFullName : "";
39
+ total += 1;
40
+ byStatus[status] += 1;
41
+ let repo = perRepo.get(repoFullName);
42
+ if (!repo) {
43
+ repo = { repoFullName, byStatus: emptyCounts(), total: 0 };
44
+ perRepo.set(repoFullName, repo);
45
+ }
46
+ repo.byStatus[status] += 1;
47
+ repo.total += 1;
48
+ if (status === "queued") {
49
+ const ms = Date.parse(entry.enqueuedAt);
50
+ if (Number.isFinite(ms) && (oldestQueuedMs === null || ms < oldestQueuedMs)) oldestQueuedMs = ms;
51
+ }
52
+ }
53
+
54
+ const repos = [...perRepo.values()].sort((left, right) => left.repoFullName.localeCompare(right.repoFullName));
55
+ const oldestQueuedAgeMs = nowMs !== null && oldestQueuedMs !== null ? Math.max(0, nowMs - oldestQueuedMs) : null;
56
+ return { total, byStatus, repos, oldestQueuedAgeMs };
57
+ }
58
+
59
+ /** Plain-text render of a dashboard summary (mirrors manage-status.js's `renderManageStatusTable`). */
60
+ export function renderPortfolioDashboardTable(summary) {
61
+ if (!summary || summary.total === 0) return "portfolio queue is empty";
62
+ const age = summary.oldestQueuedAgeMs !== null ? ` oldest-queued: ${Math.round(summary.oldestQueuedAgeMs / 60000)}m` : "";
63
+ const header = ["repo".padEnd(28), "queued".padStart(7), "in_prog".padStart(8), "done".padStart(6), "total".padStart(6)].join(" ");
64
+ const lines = summary.repos.map((repo) =>
65
+ [
66
+ repo.repoFullName.padEnd(28),
67
+ String(repo.byStatus.queued).padStart(7),
68
+ String(repo.byStatus.in_progress).padStart(8),
69
+ String(repo.byStatus.done).padStart(6),
70
+ String(repo.total).padStart(6),
71
+ ].join(" "),
72
+ );
73
+ return [
74
+ `total: ${summary.total} queued: ${summary.byStatus.queued} in_progress: ${summary.byStatus.in_progress} done: ${summary.byStatus.done}${age}`,
75
+ "",
76
+ header,
77
+ ...lines,
78
+ ].join("\n");
79
+ }
80
+
81
+ export function parsePortfolioDashboardArgs(args = []) {
82
+ for (const token of args) {
83
+ if (token === "--json") continue;
84
+ if (token.startsWith("-")) return { error: `Unknown option: ${token}` };
85
+ return { error: "Usage: gittensory-miner queue dashboard [--json]" };
86
+ }
87
+ return { json: args.includes("--json") };
88
+ }
89
+
90
+ /** CLI glue for `gittensory-miner queue dashboard [--json]` (mirrors manage-status.js's `runManageStatus`). */
91
+ export function runPortfolioDashboard(args = [], options = {}) {
92
+ const parsed = parsePortfolioDashboardArgs(args);
93
+ if ("error" in parsed) {
94
+ return reportCliFailure(argsWantJson(args), parsed.error);
95
+ }
96
+ const ownsQueue = options.initPortfolioQueue === undefined;
97
+ const portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)();
98
+ try {
99
+ const summary = collectPortfolioDashboard({ portfolioQueue }, { nowMs: Number.isFinite(options.nowMs) ? options.nowMs : Date.now() });
100
+ console.log(parsed.json ? JSON.stringify(summary, null, 2) : renderPortfolioDashboardTable(summary));
101
+ return 0;
102
+ } finally {
103
+ if (ownsQueue) portfolioQueue.close();
104
+ }
105
+ }
@@ -0,0 +1,29 @@
1
+ import type { EventLedger } from "./event-ledger.js";
2
+ import type { PortfolioQueueStore } from "./portfolio-queue.js";
3
+
4
+ export type EnqueueRankedDiscoveryInput = {
5
+ repoFullName: string;
6
+ issueNumber: number;
7
+ title: string;
8
+ labels?: string[];
9
+ rankScore: number;
10
+ };
11
+
12
+ export type EnqueueRankedDiscoveryOptions = {
13
+ queueStore: PortfolioQueueStore;
14
+ eventLedger?: EventLedger;
15
+ minRankScore?: number | null;
16
+ apiBaseUrl?: string;
17
+ };
18
+
19
+ export type EnqueueRankedDiscoverySummary = {
20
+ enqueued: number;
21
+ skippedBelowMinRank: number;
22
+ skippedInvalid: number;
23
+ eventsAppended: number;
24
+ };
25
+
26
+ export function enqueueRankedDiscovery(
27
+ rankedIssues: readonly EnqueueRankedDiscoveryInput[],
28
+ options: EnqueueRankedDiscoveryOptions,
29
+ ): EnqueueRankedDiscoverySummary;