@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,115 @@
1
+ // Docs-accuracy audit for the miner's DEPLOYMENT.md (#5180). Mirrors the self-host docs audit
2
+ // (apps/gittensory-ui/src/lib/selfhost-docs-audit.ts): parse the deployment doc, then assert every
3
+ // GITTENSORY_MINER_* / MINER_* env var, repo-relative file path, and `gittensory-miner <subcommand>`
4
+ // it documents still exists under packages/gittensory-miner/**. A rename or move that leaves the doc
5
+ // stale then fails CI with a message naming the exact stale claim, instead of misleading operators.
6
+
7
+ /** The miner's own env-var namespace: GITTENSORY_MINER_* and the shorter MINER_* aliases it reads. */
8
+ const ENV_VAR_PATTERN = /\b(?:GITTENSORY_MINER|MINER)_[A-Z0-9_]+\b/g;
9
+
10
+ /** `gittensory-miner <subcommand>` CLI invocations, excluding the `@loopover/miner` package spelling. */
11
+ const SUBCOMMAND_PATTERN = /(?<![\w./@-])gittensory-miner\s+([a-z][a-z0-9-]*)/g;
12
+
13
+ /** Markdown inline-link targets: the `target` in `](target)`. */
14
+ const MARKDOWN_LINK_PATTERN = /\]\(([^)]+)\)/g;
15
+
16
+ /** Link targets the audit ignores: URLs, in-page anchors, and runtime-generated (~ or absolute) paths. */
17
+ const NON_REPO_LINK_PATTERN = /^(?:https?:\/\/|mailto:|#|~|\/)/;
18
+
19
+ /** `cliArgs[0] === "<name>"` guards in the miner bin — the CLI's registered top-level command table. */
20
+ const CLI_DISPATCH_PATTERN = /cliArgs\[0\]\s*===\s*"([a-z][a-z0-9-]*)"/g;
21
+
22
+ /** Collect every GITTENSORY_MINER_* / MINER_* token that appears in `text` (doc prose/code or source). */
23
+ export function scanEnvVarTokens(text) {
24
+ const tokens = new Set();
25
+ for (const match of text.matchAll(ENV_VAR_PATTERN)) {
26
+ tokens.add(match[0]);
27
+ }
28
+ return tokens;
29
+ }
30
+
31
+ /** Sorted, de-duplicated env-var names DEPLOYMENT.md claims the miner honors. */
32
+ export function extractEnvVarClaims(markdown) {
33
+ return [...scanEnvVarTokens(markdown)].sort();
34
+ }
35
+
36
+ /** Sorted, de-duplicated `gittensory-miner <subcommand>` subcommands DEPLOYMENT.md documents. */
37
+ export function extractSubcommandClaims(markdown) {
38
+ const commands = new Set();
39
+ for (const match of markdown.matchAll(SUBCOMMAND_PATTERN)) {
40
+ commands.add(match[1]);
41
+ }
42
+ return [...commands].sort();
43
+ }
44
+
45
+ /** True when a markdown link target is an on-disk repo path (not a URL, anchor, or runtime path). */
46
+ export function isRepoRelativePath(target) {
47
+ return !NON_REPO_LINK_PATTERN.test(target);
48
+ }
49
+
50
+ /** Sorted, de-duplicated repo-relative file paths DEPLOYMENT.md links to (external issue links excluded).
51
+ * An in-file anchor fragment (`file.md#heading`) is stripped before the path is recorded -- the fragment
52
+ * names a heading inside the target file, not a filesystem entry, so checking it against `pathExists`
53
+ * verbatim would always fail even when the linked file (and heading) both genuinely exist. */
54
+ export function extractFilePathClaims(markdown) {
55
+ const paths = new Set();
56
+ for (const match of markdown.matchAll(MARKDOWN_LINK_PATTERN)) {
57
+ const target = match[1].trim();
58
+ if (isRepoRelativePath(target)) {
59
+ const [pathOnly] = target.split("#");
60
+ paths.add(pathOnly);
61
+ }
62
+ }
63
+ return [...paths].sort();
64
+ }
65
+
66
+ /** The set of top-level subcommands the miner CLI dispatches, parsed from its bin entry source. */
67
+ export function scanRegisteredCommands(binSource) {
68
+ const commands = new Set();
69
+ for (const match of binSource.matchAll(CLI_DISPATCH_PATTERN)) {
70
+ commands.add(match[1]);
71
+ }
72
+ return commands;
73
+ }
74
+
75
+ /**
76
+ * Cross-check parsed DEPLOYMENT.md claims against reality. `reality` supplies three predicates so this
77
+ * comparison stays pure and filesystem-independent: `hasEnvRead(name)` (a read of that env var exists
78
+ * under packages/gittensory-miner/**), `pathExists(relativePath)` (the doc-relative path is on disk),
79
+ * and `isRegisteredCommand(name)` (the subcommand is dispatched by the CLI). Returns the drift findings,
80
+ * each failure naming the specific stale claim rather than a generic mismatch.
81
+ */
82
+ export function auditDeploymentDocs(claims, reality) {
83
+ const failures = [];
84
+ for (const name of claims.envVars) {
85
+ if (!reality.hasEnvRead(name)) {
86
+ failures.push(
87
+ `env var "${name}" is documented in DEPLOYMENT.md but no read of it exists under packages/gittensory-miner/**`,
88
+ );
89
+ }
90
+ }
91
+ for (const path of claims.filePaths) {
92
+ if (!reality.pathExists(path)) {
93
+ failures.push(`file path "${path}" is linked from DEPLOYMENT.md but no longer exists on disk`);
94
+ }
95
+ }
96
+ for (const command of claims.subcommands) {
97
+ if (!reality.isRegisteredCommand(command)) {
98
+ failures.push(
99
+ `CLI subcommand "gittensory-miner ${command}" is documented in DEPLOYMENT.md but is not registered in the CLI command table`,
100
+ );
101
+ }
102
+ }
103
+ return { ok: failures.length === 0, failures };
104
+ }
105
+
106
+ /** Run the audit and throw a build-failing error naming every stale claim; returns the result when in sync. */
107
+ export function assertDeploymentDocsInSync(claims, reality) {
108
+ const result = auditDeploymentDocs(claims, reality);
109
+ if (!result.ok) {
110
+ throw new Error(
111
+ `DEPLOYMENT.md is out of sync with packages/gittensory-miner/**:\n- ${result.failures.join("\n- ")}`,
112
+ );
113
+ }
114
+ return result;
115
+ }
@@ -0,0 +1,94 @@
1
+ import type { ForgeConfig } from "./forge-config.js";
2
+ import type {
3
+ CandidateIssueWarning,
4
+ FanoutOptions,
5
+ FanoutTarget,
6
+ RawCandidateIssue,
7
+ } from "./opportunity-fanout.js";
8
+ import type {
9
+ RankCandidateIssuesOptions,
10
+ RankedCandidateIssue,
11
+ RankedCandidateSummary,
12
+ } from "./opportunity-ranker.js";
13
+ import type { PolicyDocCacheStore } from "./policy-doc-cache.js";
14
+ import type { PolicyVerdictCacheStore } from "./policy-verdict-cache.js";
15
+ import type { EnqueueRankedDiscoverySummary } from "./portfolio-discovery.js";
16
+ import type { PortfolioQueueStore } from "./portfolio-queue.js";
17
+
18
+ export type ParsedDiscoverArgs =
19
+ | {
20
+ targets: FanoutTarget[];
21
+ search: string | null;
22
+ dryRun: boolean;
23
+ json: boolean;
24
+ /** Present only when `--api-base-url` is supplied (#4784); threads the tenant's forge host to the fan-out. */
25
+ apiBaseUrl?: string;
26
+ /** Present only when `--token-env` is supplied (#4784); names the credential env var to read. */
27
+ tokenEnv?: string;
28
+ }
29
+ | { error: string };
30
+
31
+ /** The subset of `CandidateIssueSummary` runDiscover actually reads. It surfaces the rate-limit telemetry (#4837),
32
+ * so a fake must supply it. A real `fetchCandidateIssuesWithSummary` result satisfies this, since it is a superset. */
33
+ export type DiscoverFanOutSummary = {
34
+ issues: RawCandidateIssue[];
35
+ warnings: CandidateIssueWarning[];
36
+ rateLimitRemaining: number | null;
37
+ rateLimitResetAt: string | null;
38
+ };
39
+
40
+ /** The subset of a ranked entry that `renderDiscoverSummary` reads for its top-candidates listing. */
41
+ export type DiscoverRankedEntry = Pick<RankedCandidateIssue, "repoFullName" | "issueNumber" | "title" | "rankScore">;
42
+
43
+ export type DiscoverResult = {
44
+ fanOutCount: number;
45
+ warnings: CandidateIssueWarning[];
46
+ rateLimitRemaining: number | null;
47
+ rateLimitResetAt: string | null;
48
+ ranked: DiscoverRankedEntry[];
49
+ /** True when ranking fell back to the built-in default goal spec because no per-tenant spec was supplied (#4784). */
50
+ usedDefaultGoalSpec?: boolean;
51
+ enqueueSummary: EnqueueRankedDiscoverySummary;
52
+ };
53
+
54
+ export type RunDiscoverOptions = {
55
+ githubToken?: string;
56
+ apiBaseUrl?: string;
57
+ /** Per-tenant credential env var name (#4784); defaults to GITHUB_TOKEN. Overridden by a `--token-env` flag. */
58
+ tokenEnv?: string;
59
+ /** Per-tenant forge knobs beyond the host (#4784), forwarded to the fan-out. */
60
+ forge?: Partial<ForgeConfig>;
61
+ nowMs?: number;
62
+ /** Per-tenant goal specs threaded to the ranker so lane fit uses the tenant's conventions, not the defaults (#4784). */
63
+ goalSpecsByRepo?: RankCandidateIssuesOptions["goalSpecsByRepo"];
64
+ goalSpecContentByRepo?: RankCandidateIssuesOptions["goalSpecContentByRepo"];
65
+ initPortfolioQueue?: () => PortfolioQueueStore;
66
+ initPolicyDocCache?: () => PolicyDocCacheStore;
67
+ initPolicyVerdictCache?: () => PolicyVerdictCacheStore;
68
+ fetchCandidateIssuesWithSummary?: (
69
+ targets: FanoutTarget[],
70
+ githubToken: string,
71
+ options?: FanoutOptions,
72
+ ) => Promise<DiscoverFanOutSummary>;
73
+ searchCandidateIssuesWithSummary?: (
74
+ searchQuery: string,
75
+ githubToken: string,
76
+ options?: FanoutOptions,
77
+ ) => Promise<DiscoverFanOutSummary>;
78
+ rankCandidateIssuesWithSummary?: (
79
+ candidates: RawCandidateIssue[],
80
+ options?: RankCandidateIssuesOptions,
81
+ ) => RankedCandidateSummary;
82
+ enqueueRankedDiscovery?: (
83
+ rankedIssues: RankedCandidateIssue[],
84
+ options: { queueStore: PortfolioQueueStore },
85
+ ) => EnqueueRankedDiscoverySummary;
86
+ };
87
+
88
+ export function parseDiscoverArgs(args: string[]): ParsedDiscoverArgs;
89
+
90
+ export function sanitizeDiscoverDisplayText(value: unknown): string;
91
+
92
+ export function renderDiscoverSummary(result: DiscoverResult): string;
93
+
94
+ export function runDiscover(args: string[], options?: RunDiscoverOptions): Promise<number>;
@@ -0,0 +1,284 @@
1
+ /** `discover` CLI command (#4247): wires the existing fanout -> rank -> enqueue pipeline together so a miner
2
+ * can actually run it. Every piece already exists and is independently tested; this module only composes them. */
3
+ import { resolveForgeConfig } from "./forge-config.js";
4
+ import {
5
+ fetchCandidateIssuesWithSummary,
6
+ searchCandidateIssuesWithSummary,
7
+ } from "./opportunity-fanout.js";
8
+ import { rankCandidateIssuesWithSummary } from "./opportunity-ranker.js";
9
+ import { initPolicyDocCacheStore } from "./policy-doc-cache.js";
10
+ import { initPolicyVerdictCacheStore } from "./policy-verdict-cache.js";
11
+ import { enqueueRankedDiscovery } from "./portfolio-discovery.js";
12
+ import { initPortfolioQueueStore } from "./portfolio-queue.js";
13
+ import { argsWantJson, describeCliError, reportCliFailure } from "./cli-error.js";
14
+
15
+ const DISCOVER_USAGE =
16
+ "Usage: gittensory-miner discover <owner/repo> [<owner/repo>...] | --search <query> [--dry-run] [--json] [--api-base-url <url>] [--token-env <VAR>]";
17
+
18
+ const MAX_DISCOVER_TITLE_DISPLAY_LENGTH = 240;
19
+ const OSC_SEQUENCE_PATTERN = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/g;
20
+ const ANSI_ESCAPE_PATTERN = /\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g;
21
+ const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f-\u009f]/g;
22
+ const BIDI_CONTROL_PATTERN = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
23
+
24
+ export function sanitizeDiscoverDisplayText(value) {
25
+ return String(value ?? "")
26
+ .replace(OSC_SEQUENCE_PATTERN, "")
27
+ .replace(ANSI_ESCAPE_PATTERN, "")
28
+ .replace(CONTROL_CHARACTER_PATTERN, " ")
29
+ .replace(BIDI_CONTROL_PATTERN, "")
30
+ .replace(/\s+/g, " ")
31
+ .trim()
32
+ .slice(0, MAX_DISCOVER_TITLE_DISPLAY_LENGTH);
33
+ }
34
+
35
+ function parseRepoTarget(value) {
36
+ const trimmed = typeof value === "string" ? value.trim() : "";
37
+ const [owner, repo, extra] = trimmed.split("/");
38
+ if (!owner || !repo || extra !== undefined) return null;
39
+ return { owner, repo };
40
+ }
41
+
42
+ export function parseDiscoverArgs(args) {
43
+ // `--api-base-url` and `--token-env` (#4784) thread the tenant's forge host and credential env var into the
44
+ // fan-out; they are kept off the parsed result unless supplied, so callers that pass neither see the exact
45
+ // pre-#4784 `{ targets, search, json }` shape.
46
+ const options = { json: false, dryRun: false, search: null, apiBaseUrl: null, tokenEnv: null };
47
+ const targets = [];
48
+
49
+ for (let index = 0; index < args.length; index += 1) {
50
+ const token = args[index];
51
+ if (token === "--json") {
52
+ options.json = true;
53
+ continue;
54
+ }
55
+ // #4847: fetches + ranks exactly as a real run, but skips opening any local store and makes zero writes.
56
+ if (token === "--dry-run") {
57
+ options.dryRun = true;
58
+ continue;
59
+ }
60
+ if (token === "--search") {
61
+ const query = args[index + 1];
62
+ if (!query || query.startsWith("-")) return { error: DISCOVER_USAGE };
63
+ options.search = query;
64
+ index += 1;
65
+ continue;
66
+ }
67
+ if (token === "--api-base-url") {
68
+ const value = args[index + 1];
69
+ if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE };
70
+ options.apiBaseUrl = value;
71
+ index += 1;
72
+ continue;
73
+ }
74
+ if (token === "--token-env") {
75
+ const value = args[index + 1];
76
+ if (!value || value.startsWith("-")) return { error: DISCOVER_USAGE };
77
+ options.tokenEnv = value;
78
+ index += 1;
79
+ continue;
80
+ }
81
+ if (token.startsWith("-")) {
82
+ return { error: `Unknown option: ${token}` };
83
+ }
84
+ const target = parseRepoTarget(token);
85
+ if (!target) return { error: `Repository must be in owner/repo form: ${token}` };
86
+ targets.push(target);
87
+ }
88
+
89
+ if (options.search === null && targets.length === 0) {
90
+ return { error: DISCOVER_USAGE };
91
+ }
92
+ if (options.search !== null && targets.length > 0) {
93
+ return { error: "Pass either repository targets or --search, not both." };
94
+ }
95
+
96
+ return {
97
+ targets,
98
+ search: options.search,
99
+ dryRun: options.dryRun,
100
+ json: options.json,
101
+ ...(options.apiBaseUrl !== null ? { apiBaseUrl: options.apiBaseUrl } : {}),
102
+ ...(options.tokenEnv !== null ? { tokenEnv: options.tokenEnv } : {}),
103
+ };
104
+ }
105
+
106
+ // The rate-limit line surfaces the telemetry the fanout already records (#4837) so an operator sees how close a
107
+ // `discover` run is to being throttled without running a separate command. `unknown` covers the no-fetch/no-header
108
+ // case where the fanout captured no remaining count.
109
+ function renderRateLimitLine(result) {
110
+ const remaining = result.rateLimitRemaining === null ? "unknown" : String(result.rateLimitRemaining);
111
+ const resetSuffix = result.rateLimitResetAt === null ? "" : ` (resets ${result.rateLimitResetAt})`;
112
+ return `rate-limit remaining: ${remaining}${resetSuffix}`;
113
+ }
114
+
115
+ export function renderDiscoverSummary(result) {
116
+ const lines = [
117
+ `fanned out: ${result.fanOutCount} candidate issue(s)`,
118
+ `ai-policy warnings: ${result.warnings.length}`,
119
+ `ranked: ${result.ranked.length}`,
120
+ `enqueued: ${result.enqueueSummary.enqueued}`,
121
+ renderRateLimitLine(result),
122
+ ];
123
+ if (result.enqueueSummary.skippedBelowMinRank > 0) {
124
+ lines.push(`skipped (below min rank): ${result.enqueueSummary.skippedBelowMinRank}`);
125
+ }
126
+ // Make the fall-back to gittensory's built-in rubric explicit instead of silent (#4784): when no per-tenant goal
127
+ // spec is supplied, lane fit reflects gittensory's defaults, not the target repo's own conventions.
128
+ if (result.usedDefaultGoalSpec) {
129
+ lines.push(
130
+ "note: ranked with the built-in default goal spec (no per-tenant .gittensory-miner.yml supplied)",
131
+ );
132
+ }
133
+ if (result.ranked.length === 0) {
134
+ lines.push("", "no candidates found.");
135
+ return lines.join("\n");
136
+ }
137
+ lines.push("", "top candidates:");
138
+ for (const entry of result.ranked.slice(0, 10)) {
139
+ const title = sanitizeDiscoverDisplayText(entry.title);
140
+ lines.push(` ${entry.repoFullName}#${entry.issueNumber} score=${entry.rankScore.toFixed(4)} ${title}`);
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+
145
+ export async function runDiscover(args, options = {}) {
146
+ const parsed = parseDiscoverArgs(args);
147
+ if ("error" in parsed) {
148
+ return reportCliFailure(argsWantJson(args), parsed.error);
149
+ }
150
+
151
+ // Credential env var is per-tenant (#4784): a `--token-env FORGE_PAT` flag (or `options.tokenEnv`) reads a
152
+ // non-`GITHUB_TOKEN` variable so a non-github.com forge's token is reachable. The default falls through to the
153
+ // forge adapter's own `tokenEnvVar` (github.com's `GITHUB_TOKEN`), so there's a single source of truth for the
154
+ // default credential env instead of a second hardcoded literal that could drift from `DEFAULT_FORGE_CONFIG`.
155
+ const tokenEnv = parsed.tokenEnv ?? options.tokenEnv ?? resolveForgeConfig(options.forge).tokenEnvVar;
156
+ const githubToken = options.githubToken ?? process.env[tokenEnv] ?? "";
157
+ // A `--api-base-url` flag (or `options.apiBaseUrl`) surfaces the fan-out's existing forge-host override at the CLI
158
+ // (#4784); `options.forge` carries any remaining per-tenant forge knobs for a programmatic caller.
159
+ const apiBaseUrl = parsed.apiBaseUrl ?? options.apiBaseUrl;
160
+ const fetchTargets = options.fetchCandidateIssuesWithSummary ?? fetchCandidateIssuesWithSummary;
161
+ const searchTargets = options.searchCandidateIssuesWithSummary ?? searchCandidateIssuesWithSummary;
162
+ const rankIssues = options.rankCandidateIssuesWithSummary ?? rankCandidateIssuesWithSummary;
163
+ const enqueue = options.enqueueRankedDiscovery ?? enqueueRankedDiscovery;
164
+
165
+ // #4847: fetch + rank are read-only GitHub GETs and pure local computation, so a dry run still does them for
166
+ // real (that's the useful "what would this discover?" output) -- but it never opens any local store (portfolio
167
+ // queue, policy-doc cache, policy-verdict cache), since opening a not-yet-existing SQLite store file is itself
168
+ // a write. The ranked issues are fed through a no-op queue stub so enqueueRankedDiscovery's own classification
169
+ // logic (valid/invalid, below-min-rank) still runs for real, just without ever touching the real queue.
170
+ if (parsed.dryRun) {
171
+ const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache: null, policyVerdictCache: null };
172
+ try {
173
+ const fanOut =
174
+ parsed.search !== null
175
+ ? await searchTargets(parsed.search, githubToken, fanOutOptions)
176
+ : await fetchTargets(parsed.targets, githubToken, fanOutOptions);
177
+ const rankedSummary = rankIssues(fanOut.issues, {
178
+ nowMs: options.nowMs,
179
+ goalSpecsByRepo: options.goalSpecsByRepo,
180
+ goalSpecContentByRepo: options.goalSpecContentByRepo,
181
+ });
182
+ const noopQueueStore = { enqueue: () => {} };
183
+ const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: noopQueueStore });
184
+ const result = {
185
+ outcome: "dry_run",
186
+ fanOutCount: fanOut.issues.length,
187
+ warnings: fanOut.warnings,
188
+ rateLimitRemaining: fanOut.rateLimitRemaining,
189
+ rateLimitResetAt: fanOut.rateLimitResetAt,
190
+ ranked: rankedSummary.issues,
191
+ usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec,
192
+ enqueueSummary,
193
+ };
194
+ if (parsed.json) {
195
+ console.log(JSON.stringify(result, null, 2));
196
+ } else {
197
+ console.log(renderDiscoverSummary(result));
198
+ console.log("\nDRY RUN: no portfolio-queue write was made.");
199
+ }
200
+ return 0;
201
+ } catch (error) {
202
+ console.error(error instanceof Error ? error.message : String(error));
203
+ return 2;
204
+ }
205
+ }
206
+
207
+ const ownsPortfolioQueue = options.initPortfolioQueue === undefined;
208
+ let portfolioQueue;
209
+ try {
210
+ portfolioQueue = (options.initPortfolioQueue ?? initPortfolioQueueStore)();
211
+ } catch (error) {
212
+ return reportCliFailure(parsed.json, describeCliError(error));
213
+ }
214
+
215
+ // Local ETag cache so a repeated discover revalidates each repo's policy docs with a conditional GET instead of
216
+ // re-downloading them (#4842). Opened inside its OWN try/catch, separate from the portfolio queue above: the
217
+ // queue is required infrastructure (discovery genuinely cannot enqueue anything without it, so a real open
218
+ // failure should abort the run), but the policy-doc cache is a pure performance optimization -- a corrupt or
219
+ // unwritable cache DB must degrade to "no cache" (every doc fetched in full, exactly as before #4842) rather
220
+ // than fail discovery outright.
221
+ let policyDocCache = null;
222
+ let ownsPolicyDocCache = false;
223
+ try {
224
+ ownsPolicyDocCache = options.initPolicyDocCache === undefined;
225
+ policyDocCache = (options.initPolicyDocCache ?? initPolicyDocCacheStore)();
226
+ } catch {
227
+ policyDocCache = null;
228
+ ownsPolicyDocCache = false;
229
+ }
230
+
231
+ // Persisted cache of resolved policy verdicts (#4843), same "own try/catch, degrade to null" discipline as the
232
+ // doc cache above and for the same reason: purely a performance optimization the feature is inert without, so a
233
+ // corrupt/unwritable cache DB must never abort a run.
234
+ let policyVerdictCache = null;
235
+ let ownsPolicyVerdictCache = false;
236
+ try {
237
+ ownsPolicyVerdictCache = options.initPolicyVerdictCache === undefined;
238
+ policyVerdictCache = (options.initPolicyVerdictCache ?? initPolicyVerdictCacheStore)();
239
+ } catch {
240
+ policyVerdictCache = null;
241
+ ownsPolicyVerdictCache = false;
242
+ }
243
+ const fanOutOptions = { apiBaseUrl, forge: options.forge, policyDocCache, policyVerdictCache };
244
+
245
+ try {
246
+ const fanOut =
247
+ parsed.search !== null
248
+ ? await searchTargets(parsed.search, githubToken, fanOutOptions)
249
+ : await fetchTargets(parsed.targets, githubToken, fanOutOptions);
250
+
251
+ // Pass any caller-supplied per-tenant goal specs through to the ranker so lane fit uses the tenant's
252
+ // conventions instead of silently falling back to gittensory's defaults (#4784); the fallback is surfaced via
253
+ // `usedDefaultGoalSpec` below rather than hidden.
254
+ const rankedSummary = rankIssues(fanOut.issues, {
255
+ nowMs: options.nowMs,
256
+ goalSpecsByRepo: options.goalSpecsByRepo,
257
+ goalSpecContentByRepo: options.goalSpecContentByRepo,
258
+ });
259
+ const enqueueSummary = enqueue(rankedSummary.issues, { queueStore: portfolioQueue, apiBaseUrl });
260
+
261
+ const result = {
262
+ fanOutCount: fanOut.issues.length,
263
+ warnings: fanOut.warnings,
264
+ rateLimitRemaining: fanOut.rateLimitRemaining,
265
+ rateLimitResetAt: fanOut.rateLimitResetAt,
266
+ ranked: rankedSummary.issues,
267
+ usedDefaultGoalSpec: rankedSummary.usedDefaultGoalSpec,
268
+ enqueueSummary,
269
+ };
270
+
271
+ if (parsed.json) {
272
+ console.log(JSON.stringify(result, null, 2));
273
+ } else {
274
+ console.log(renderDiscoverSummary(result));
275
+ }
276
+ return 0;
277
+ } catch (error) {
278
+ return reportCliFailure(parsed.json, describeCliError(error));
279
+ } finally {
280
+ if (ownsPortfolioQueue && portfolioQueue) portfolioQueue.close();
281
+ if (ownsPolicyDocCache && policyDocCache) policyDocCache.close();
282
+ if (ownsPolicyVerdictCache && policyVerdictCache) policyVerdictCache.close();
283
+ }
284
+ }
@@ -0,0 +1,8 @@
1
+ export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK: number;
2
+ export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK: number;
3
+ export function resolveThrottledConcurrency(
4
+ baseConcurrency: number,
5
+ rateLimitRemaining: number | null,
6
+ lowWaterMark: number,
7
+ highWaterMark: number,
8
+ ): number;
@@ -0,0 +1,33 @@
1
+ // Dynamic discovery back-off (#4844): the fanout already records GitHub's `x-ratelimit-remaining`, but nothing
2
+ // slowed its own concurrent fetching in response — a `discover` run could sprint at full concurrency straight
3
+ // into a 403. This pure helper maps the recorded remaining budget to an allowed in-flight concurrency so the
4
+ // fanout tapers off as the budget approaches zero. It only decides *how many* requests may run; it never changes
5
+ // which docs are fetched or how a policy verdict is derived from them.
6
+
7
+ /** At or below this remaining budget, serialize discovery to a single in-flight request. */
8
+ export const DEFAULT_RATE_LIMIT_LOW_WATER_MARK = 50;
9
+ /** At or above this remaining budget, run at the full configured concurrency. */
10
+ export const DEFAULT_RATE_LIMIT_HIGH_WATER_MARK = 250;
11
+
12
+ /**
13
+ * Resolve the concurrency the fanout may run at for the currently-recorded rate-limit budget. Returns an integer
14
+ * in `[1, baseConcurrency]`:
15
+ * - an unknown budget (`null`/non-finite — nothing recorded yet) runs at full `baseConcurrency`;
16
+ * - at or below `lowWaterMark` it clamps to a single in-flight request;
17
+ * - at or above `highWaterMark` it runs at full `baseConcurrency`;
18
+ * - in between it scales linearly with the remaining fraction of the low→high band.
19
+ * @param {number} baseConcurrency
20
+ * @param {number|null} rateLimitRemaining
21
+ * @param {number} lowWaterMark
22
+ * @param {number} highWaterMark
23
+ * @returns {number}
24
+ */
25
+ export function resolveThrottledConcurrency(baseConcurrency, rateLimitRemaining, lowWaterMark, highWaterMark) {
26
+ if (!Number.isFinite(rateLimitRemaining)) return baseConcurrency;
27
+ if (rateLimitRemaining <= lowWaterMark) return 1;
28
+ if (rateLimitRemaining >= highWaterMark) return baseConcurrency;
29
+ // remaining is strictly inside the (low, high) band, so the fraction is in (0, 1) and the ceil lands in
30
+ // [1, baseConcurrency] without any further clamping.
31
+ const fraction = (rateLimitRemaining - lowWaterMark) / (highWaterMark - lowWaterMark);
32
+ return Math.ceil(fraction * baseConcurrency);
33
+ }
@@ -0,0 +1,4 @@
1
+ export function loadMinerFileSecrets(
2
+ env?: Record<string, string | undefined>,
3
+ readFile?: (path: string) => string,
4
+ ): void;
@@ -0,0 +1,45 @@
1
+ // Resolve `<NAME>_FILE` env vars (Docker/Swarm/K8s secret mounts) into `<NAME>` at miner startup (#5178).
2
+ // Ports src/selfhost/load-file-secrets.ts's pattern into the miner package -- the miner is a separate
3
+ // deployable (its own process/container per DEPLOYMENT.md's fleet mode), so it never runs through ORB's own
4
+ // server-startup resolver. Deliberately diverges from that analogue in one way: an unreadable/missing
5
+ // `<NAME>_FILE` here THROWS rather than logging and continuing, so a broken secret mount fails a miner
6
+ // container fast and loud (never silently proceeds with an unset/empty credential the next real GitHub call
7
+ // would then fail on anyway, with a far less specific error).
8
+ import { readFileSync } from "node:fs";
9
+
10
+ // Docker Compose's OWN reserved `_FILE`-suffixed environment variables -- never gittensory's secret-file
11
+ // convention, so they must never be dereferenced below (mirrors src/selfhost/load-file-secrets.ts's own
12
+ // exclusion and rationale: `COMPOSE_FILE` is a colon-delimited list of compose file paths, never a single
13
+ // readable file itself, and `COMPOSE_ENV_FILE` points at an operator's own .env file, not a secret).
14
+ const COMPOSE_RESERVED_FILE_VARS = new Set(["COMPOSE_FILE", "COMPOSE_ENV_FILE"]);
15
+
16
+ /**
17
+ * Scan `env` for `<NAME>_FILE` vars and resolve each into `<NAME>` in place, reading the referenced file's
18
+ * contents (trimmed). An explicit `<NAME>` value always wins over `<NAME>_FILE` (mirrors the ORB analogue's
19
+ * precedence rule exactly) -- a `_FILE` var is only consulted when its plain counterpart is unset. Throws a
20
+ * clear, actionable error identifying the offending `<NAME>_FILE` var and its file path when the file is
21
+ * missing or unreadable -- this never silently leaves a credential empty/undefined. Never logs or returns any
22
+ * resolved secret value itself; only the (non-secret) var name and file path ever appear in a thrown message.
23
+ *
24
+ * `env` and `readFile` are injectable purely for testability -- every real caller uses the defaults
25
+ * (`process.env`, `node:fs`'s `readFileSync`), so this is byte-identical to a hardcoded version at runtime.
26
+ *
27
+ * @param {Record<string, string | undefined>} [env]
28
+ * @param {(path: string) => string} [readFile]
29
+ */
30
+ export function loadMinerFileSecrets(env = process.env, readFile = (path) => readFileSync(path, "utf8")) {
31
+ for (const key of Object.keys(env)) {
32
+ if (!key.endsWith("_FILE") || !env[key] || COMPOSE_RESERVED_FILE_VARS.has(key)) continue;
33
+ const target = key.slice(0, -"_FILE".length);
34
+ if (env[target]) continue; // an explicit <NAME> value always wins over <NAME>_FILE
35
+ try {
36
+ env[target] = readFile(env[key]).trim();
37
+ } catch (error) {
38
+ throw new Error(
39
+ `Failed to read secret file for ${key} (${env[key]}): ${
40
+ error instanceof Error ? error.message : String(error)
41
+ }`,
42
+ );
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,82 @@
1
+ import type { EventLedger, LedgerEntry } from "./event-ledger.js";
2
+
3
+ export type ParsedLedgerListArgs =
4
+ | {
5
+ json: boolean;
6
+ repoFullName: string | null;
7
+ since: number | null;
8
+ type: string | null;
9
+ }
10
+ | { error: string };
11
+
12
+ export function parseLedgerListArgs(args: string[]): ParsedLedgerListArgs;
13
+
14
+ export function filterLedgerEvents(
15
+ events: LedgerEntry[],
16
+ options?: { type?: string | null },
17
+ ): LedgerEntry[];
18
+
19
+ export const AUDIT_FEED_ENTRY_FIELDS: readonly [
20
+ "eventType",
21
+ "repoFullName",
22
+ "outcome",
23
+ "actor",
24
+ "detail",
25
+ "createdAt",
26
+ ];
27
+
28
+ export function projectLedgerEventToAuditFeedEntry(entry: LedgerEntry): {
29
+ eventType: string;
30
+ repoFullName: string | null;
31
+ outcome: string | null;
32
+ actor: string | null;
33
+ detail: string | null;
34
+ createdAt: string;
35
+ };
36
+
37
+ export type AuditFeedMcpFilterInput = {
38
+ repoFullName?: string | null;
39
+ since?: number | null;
40
+ type?: string | null;
41
+ };
42
+
43
+ export function normalizeAuditFeedMcpFilter(input?: AuditFeedMcpFilterInput): {
44
+ repoFullName: string | null;
45
+ since: number | null;
46
+ type: string | null;
47
+ };
48
+
49
+ export function collectEventLedgerAuditFeed(
50
+ eventLedger: EventLedger,
51
+ filter?: { repoFullName?: string | null; since?: number | null; type?: string | null },
52
+ ): {
53
+ repoFullName?: string;
54
+ events: Array<{
55
+ eventType: string;
56
+ repoFullName: string | null;
57
+ outcome: string | null;
58
+ actor: string | null;
59
+ detail: string | null;
60
+ createdAt: string;
61
+ }>;
62
+ };
63
+
64
+ export function renderLedgerTable(events: LedgerEntry[]): string;
65
+
66
+ export function renderEventLedgerMetrics(events: readonly LedgerEntry[]): string;
67
+
68
+ export function runLedgerList(
69
+ args: string[],
70
+ options?: { initEventLedger?: () => EventLedger },
71
+ ): number;
72
+
73
+ export function runLedgerMetrics(
74
+ args: string[],
75
+ options?: { initEventLedger?: () => EventLedger },
76
+ ): number;
77
+
78
+ export function runLedgerCli(
79
+ subcommand: string | undefined,
80
+ args: string[],
81
+ options?: { initEventLedger?: () => EventLedger },
82
+ ): number;