@loopover/engine 1.0.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 (272) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +661 -0
  3. package/README.md +676 -0
  4. package/dist/advisory/gate-advisory.d.ts +151 -0
  5. package/dist/advisory/gate-advisory.js +512 -0
  6. package/dist/ai-policy-map.d.ts +97 -0
  7. package/dist/ai-policy-map.js +377 -0
  8. package/dist/ams-policy-spec.d.ts +56 -0
  9. package/dist/ams-policy-spec.js +165 -0
  10. package/dist/calibration-dashboard.d.ts +23 -0
  11. package/dist/calibration-dashboard.js +65 -0
  12. package/dist/calibration-trend.d.ts +40 -0
  13. package/dist/calibration-trend.js +60 -0
  14. package/dist/contributor-fit.d.ts +24 -0
  15. package/dist/contributor-fit.js +50 -0
  16. package/dist/discovery-index-contract.d.ts +74 -0
  17. package/dist/discovery-index-contract.js +184 -0
  18. package/dist/discovery-soft-claim.d.ts +35 -0
  19. package/dist/discovery-soft-claim.js +50 -0
  20. package/dist/duplicate-winner.d.ts +57 -0
  21. package/dist/duplicate-winner.js +98 -0
  22. package/dist/feasibility.d.ts +27 -0
  23. package/dist/feasibility.js +86 -0
  24. package/dist/finding-severity-calibration.d.ts +116 -0
  25. package/dist/finding-severity-calibration.js +457 -0
  26. package/dist/fleet-run-manifest.d.ts +41 -0
  27. package/dist/fleet-run-manifest.js +151 -0
  28. package/dist/focus-manifest/guidance.d.ts +21 -0
  29. package/dist/focus-manifest/guidance.js +222 -0
  30. package/dist/focus-manifest.d.ts +826 -0
  31. package/dist/focus-manifest.js +2730 -0
  32. package/dist/gate-verdict-calibration.d.ts +111 -0
  33. package/dist/gate-verdict-calibration.js +411 -0
  34. package/dist/github/constants.d.ts +1 -0
  35. package/dist/github/constants.js +1 -0
  36. package/dist/github/sanitize-public-comment.d.ts +1 -0
  37. package/dist/github/sanitize-public-comment.js +26 -0
  38. package/dist/goal-model.d.ts +7 -0
  39. package/dist/goal-model.js +87 -0
  40. package/dist/governor/action-mode.d.ts +55 -0
  41. package/dist/governor/action-mode.js +74 -0
  42. package/dist/governor/budget-cap.d.ts +49 -0
  43. package/dist/governor/budget-cap.js +34 -0
  44. package/dist/governor/chokepoint.d.ts +58 -0
  45. package/dist/governor/chokepoint.js +269 -0
  46. package/dist/governor/kill-switch.d.ts +38 -0
  47. package/dist/governor/kill-switch.js +62 -0
  48. package/dist/governor/rate-limit.d.ts +40 -0
  49. package/dist/governor/rate-limit.js +63 -0
  50. package/dist/governor/reputation-throttle.d.ts +50 -0
  51. package/dist/governor/reputation-throttle.js +94 -0
  52. package/dist/governor/run-halt.d.ts +39 -0
  53. package/dist/governor/run-halt.js +83 -0
  54. package/dist/governor/self-plagiarism.d.ts +47 -0
  55. package/dist/governor/self-plagiarism.js +200 -0
  56. package/dist/governor/write-rate-limit.d.ts +52 -0
  57. package/dist/governor/write-rate-limit.js +132 -0
  58. package/dist/governor-ledger.d.ts +25 -0
  59. package/dist/governor-ledger.js +91 -0
  60. package/dist/index.d.ts +99 -0
  61. package/dist/index.js +120 -0
  62. package/dist/issue-plan-decomposition.d.ts +18 -0
  63. package/dist/issue-plan-decomposition.js +56 -0
  64. package/dist/issue-rag-query.d.ts +12 -0
  65. package/dist/issue-rag-query.js +35 -0
  66. package/dist/local-scorer.d.ts +34 -0
  67. package/dist/local-scorer.js +36 -0
  68. package/dist/metadata-best-min-score.d.ts +9 -0
  69. package/dist/metadata-best-min-score.js +9 -0
  70. package/dist/metadata-best-pick.d.ts +9 -0
  71. package/dist/metadata-best-pick.js +9 -0
  72. package/dist/metadata-min-score.d.ts +9 -0
  73. package/dist/metadata-min-score.js +13 -0
  74. package/dist/metadata-top-min-score.d.ts +9 -0
  75. package/dist/metadata-top-min-score.js +13 -0
  76. package/dist/metadata-top-pick.d.ts +9 -0
  77. package/dist/metadata-top-pick.js +13 -0
  78. package/dist/miner/acceptance-criteria.d.ts +43 -0
  79. package/dist/miner/acceptance-criteria.js +77 -0
  80. package/dist/miner/agent-sdk-driver.d.ts +39 -0
  81. package/dist/miner/agent-sdk-driver.js +162 -0
  82. package/dist/miner/attempt-log.d.ts +29 -0
  83. package/dist/miner/attempt-log.js +100 -0
  84. package/dist/miner/attempt-metering.d.ts +39 -0
  85. package/dist/miner/attempt-metering.js +67 -0
  86. package/dist/miner/cli-subprocess-driver.d.ts +68 -0
  87. package/dist/miner/cli-subprocess-driver.js +272 -0
  88. package/dist/miner/coding-agent-driver.d.ts +33 -0
  89. package/dist/miner/coding-agent-driver.js +36 -0
  90. package/dist/miner/coding-agent-invoke.d.ts +11 -0
  91. package/dist/miner/coding-agent-invoke.js +80 -0
  92. package/dist/miner/coding-agent-mode.d.ts +25 -0
  93. package/dist/miner/coding-agent-mode.js +43 -0
  94. package/dist/miner/driver-factory.d.ts +82 -0
  95. package/dist/miner/driver-factory.js +169 -0
  96. package/dist/miner/harness-submission-trigger.d.ts +37 -0
  97. package/dist/miner/harness-submission-trigger.js +46 -0
  98. package/dist/miner/iterate-loop.d.ts +97 -0
  99. package/dist/miner/iterate-loop.js +278 -0
  100. package/dist/miner/iterate-policy.d.ts +98 -0
  101. package/dist/miner/iterate-policy.js +98 -0
  102. package/dist/miner/lint-guard.d.ts +50 -0
  103. package/dist/miner/lint-guard.js +68 -0
  104. package/dist/miner/local-write-tools.d.ts +81 -0
  105. package/dist/miner/local-write-tools.js +113 -0
  106. package/dist/miner/loop-reentry-policy.d.ts +36 -0
  107. package/dist/miner/loop-reentry-policy.js +53 -0
  108. package/dist/miner/repo-map.d.ts +51 -0
  109. package/dist/miner/repo-map.js +268 -0
  110. package/dist/miner/self-review-adapter.d.ts +106 -0
  111. package/dist/miner/self-review-adapter.js +83 -0
  112. package/dist/miner/submission-gate.d.ts +40 -0
  113. package/dist/miner/submission-gate.js +89 -0
  114. package/dist/miner/worktree-allocator.d.ts +59 -0
  115. package/dist/miner/worktree-allocator.js +60 -0
  116. package/dist/miner/worktree-pool.d.ts +55 -0
  117. package/dist/miner/worktree-pool.js +65 -0
  118. package/dist/miner-goal-lane-fit.d.ts +18 -0
  119. package/dist/miner-goal-lane-fit.js +76 -0
  120. package/dist/miner-goal-spec.d.ts +139 -0
  121. package/dist/miner-goal-spec.js +281 -0
  122. package/dist/miner-prediction-metrics.d.ts +16 -0
  123. package/dist/miner-prediction-metrics.js +61 -0
  124. package/dist/miner-telemetry.d.ts +32 -0
  125. package/dist/miner-telemetry.js +87 -0
  126. package/dist/objective-anchor.d.ts +125 -0
  127. package/dist/objective-anchor.js +401 -0
  128. package/dist/opportunity-competition.d.ts +6 -0
  129. package/dist/opportunity-competition.js +29 -0
  130. package/dist/opportunity-freshness.d.ts +18 -0
  131. package/dist/opportunity-freshness.js +55 -0
  132. package/dist/opportunity-metadata.d.ts +53 -0
  133. package/dist/opportunity-metadata.js +204 -0
  134. package/dist/opportunity-ranker.d.ts +46 -0
  135. package/dist/opportunity-ranker.js +74 -0
  136. package/dist/pairwise-calibration.d.ts +49 -0
  137. package/dist/pairwise-calibration.js +106 -0
  138. package/dist/phase7-calibration-loop.d.ts +136 -0
  139. package/dist/phase7-calibration-loop.js +437 -0
  140. package/dist/plan-blocked.d.ts +6 -0
  141. package/dist/plan-blocked.js +24 -0
  142. package/dist/plan-completed.d.ts +5 -0
  143. package/dist/plan-completed.js +6 -0
  144. package/dist/plan-completion.d.ts +6 -0
  145. package/dist/plan-completion.js +7 -0
  146. package/dist/plan-empty.d.ts +5 -0
  147. package/dist/plan-empty.js +6 -0
  148. package/dist/plan-export.d.ts +27 -0
  149. package/dist/plan-export.js +86 -0
  150. package/dist/plan-failure.d.ts +5 -0
  151. package/dist/plan-failure.js +6 -0
  152. package/dist/plan-overall-status.d.ts +6 -0
  153. package/dist/plan-overall-status.js +25 -0
  154. package/dist/plan-pending.d.ts +5 -0
  155. package/dist/plan-pending.js +6 -0
  156. package/dist/plan-progress-complete.d.ts +7 -0
  157. package/dist/plan-progress-complete.js +10 -0
  158. package/dist/plan-ready.d.ts +6 -0
  159. package/dist/plan-ready.js +12 -0
  160. package/dist/plan-running.d.ts +5 -0
  161. package/dist/plan-running.js +6 -0
  162. package/dist/plan-skipped.d.ts +5 -0
  163. package/dist/plan-skipped.js +6 -0
  164. package/dist/plan-step-count.d.ts +5 -0
  165. package/dist/plan-step-count.js +6 -0
  166. package/dist/plan-step-stats.d.ts +5 -0
  167. package/dist/plan-step-stats.js +6 -0
  168. package/dist/plan-templates.d.ts +25 -0
  169. package/dist/plan-templates.js +107 -0
  170. package/dist/plan-terminated.d.ts +6 -0
  171. package/dist/plan-terminated.js +9 -0
  172. package/dist/portfolio/non-convergence.d.ts +37 -0
  173. package/dist/portfolio/non-convergence.js +57 -0
  174. package/dist/portfolio/queue.d.ts +30 -0
  175. package/dist/portfolio/queue.js +150 -0
  176. package/dist/predicted-gate.d.ts +103 -0
  177. package/dist/predicted-gate.js +218 -0
  178. package/dist/prompt-packet.d.ts +11 -0
  179. package/dist/prompt-packet.js +34 -0
  180. package/dist/ranked-opportunity-best-min-score.d.ts +8 -0
  181. package/dist/ranked-opportunity-best-min-score.js +9 -0
  182. package/dist/ranked-opportunity-best-pick.d.ts +8 -0
  183. package/dist/ranked-opportunity-best-pick.js +9 -0
  184. package/dist/ranked-opportunity-min-score.d.ts +8 -0
  185. package/dist/ranked-opportunity-min-score.js +13 -0
  186. package/dist/ranked-opportunity-top-min-score.d.ts +8 -0
  187. package/dist/ranked-opportunity-top-min-score.js +13 -0
  188. package/dist/review/advisory-ai-routing-config.d.ts +8 -0
  189. package/dist/review/advisory-ai-routing-config.js +40 -0
  190. package/dist/review/check-names.d.ts +1 -0
  191. package/dist/review/check-names.js +1 -0
  192. package/dist/review/cla-check.d.ts +49 -0
  193. package/dist/review/cla-check.js +71 -0
  194. package/dist/review/diff-file-priority.d.ts +1 -0
  195. package/dist/review/diff-file-priority.js +12 -0
  196. package/dist/review/enrichment-analyzer-names.d.ts +3 -0
  197. package/dist/review/enrichment-analyzer-names.js +63 -0
  198. package/dist/review/guardrail-config.d.ts +10 -0
  199. package/dist/review/guardrail-config.js +62 -0
  200. package/dist/review/linked-issue-hard-rules-config.d.ts +4 -0
  201. package/dist/review/linked-issue-hard-rules-config.js +79 -0
  202. package/dist/review/linked-issue-label-propagation.d.ts +9 -0
  203. package/dist/review/linked-issue-label-propagation.js +114 -0
  204. package/dist/review/pre-merge-checks.d.ts +28 -0
  205. package/dist/review/pre-merge-checks.js +66 -0
  206. package/dist/review/review-thread-findings.d.ts +1 -0
  207. package/dist/review/review-thread-findings.js +1 -0
  208. package/dist/review/safe-url.d.ts +5 -0
  209. package/dist/review/safe-url.js +137 -0
  210. package/dist/review/screenshot-table-gate.d.ts +87 -0
  211. package/dist/review/screenshot-table-gate.js +340 -0
  212. package/dist/review/unlinked-issue-guardrail-config.d.ts +9 -0
  213. package/dist/review/unlinked-issue-guardrail-config.js +44 -0
  214. package/dist/reviewer-consensus-calibration.d.ts +113 -0
  215. package/dist/reviewer-consensus-calibration.js +493 -0
  216. package/dist/reward-risk.d.ts +223 -0
  217. package/dist/reward-risk.js +618 -0
  218. package/dist/scoring/label-match.d.ts +4 -0
  219. package/dist/scoring/label-match.js +145 -0
  220. package/dist/scoring/model.d.ts +31 -0
  221. package/dist/scoring/model.js +157 -0
  222. package/dist/scoring/pending-pr-scenarios.d.ts +44 -0
  223. package/dist/scoring/pending-pr-scenarios.js +145 -0
  224. package/dist/scoring/preview.d.ts +246 -0
  225. package/dist/scoring/preview.js +1108 -0
  226. package/dist/scoring/types.d.ts +131 -0
  227. package/dist/scoring/types.js +7 -0
  228. package/dist/settings/auto-close-exempt.d.ts +10 -0
  229. package/dist/settings/auto-close-exempt.js +58 -0
  230. package/dist/settings/autonomy.d.ts +33 -0
  231. package/dist/settings/autonomy.js +80 -0
  232. package/dist/settings/command-authorization.d.ts +49 -0
  233. package/dist/settings/command-authorization.js +222 -0
  234. package/dist/settings/contributor-blacklist.d.ts +17 -0
  235. package/dist/settings/contributor-blacklist.js +86 -0
  236. package/dist/settings/moderation-rules.d.ts +51 -0
  237. package/dist/settings/moderation-rules.js +104 -0
  238. package/dist/settings/pr-type-label.d.ts +70 -0
  239. package/dist/settings/pr-type-label.js +149 -0
  240. package/dist/signals/change-guardrail.d.ts +49 -0
  241. package/dist/signals/change-guardrail.js +164 -0
  242. package/dist/signals/check-summary.d.ts +9 -0
  243. package/dist/signals/check-summary.js +12 -0
  244. package/dist/signals/contributor-calibration.d.ts +23 -0
  245. package/dist/signals/contributor-calibration.js +47 -0
  246. package/dist/signals/duplicate-winner.d.ts +9 -0
  247. package/dist/signals/duplicate-winner.js +9 -0
  248. package/dist/signals/path-matchers.d.ts +36 -0
  249. package/dist/signals/path-matchers.js +371 -0
  250. package/dist/signals/predicted-gate-engine.d.ts +62 -0
  251. package/dist/signals/predicted-gate-engine.js +895 -0
  252. package/dist/signals/preflight-limits.d.ts +14 -0
  253. package/dist/signals/preflight-limits.js +14 -0
  254. package/dist/signals/slop.d.ts +56 -0
  255. package/dist/signals/slop.js +324 -0
  256. package/dist/signals/test-evidence.d.ts +25 -0
  257. package/dist/signals/test-evidence.js +108 -0
  258. package/dist/subprocess-env.d.ts +23 -0
  259. package/dist/subprocess-env.js +74 -0
  260. package/dist/track-record-summary.d.ts +92 -0
  261. package/dist/track-record-summary.js +301 -0
  262. package/dist/types/manifest-deps-types.d.ts +514 -0
  263. package/dist/types/manifest-deps-types.js +4 -0
  264. package/dist/types/predicted-gate-types.d.ts +338 -0
  265. package/dist/types/predicted-gate-types.js +3 -0
  266. package/dist/types/reward-risk-types.d.ts +319 -0
  267. package/dist/types/reward-risk-types.js +11 -0
  268. package/dist/utils/json.d.ts +1 -0
  269. package/dist/utils/json.js +3 -0
  270. package/dist/version.d.ts +2 -0
  271. package/dist/version.js +3 -0
  272. package/package.json +80 -0
@@ -0,0 +1,65 @@
1
+ import { DOCUMENTED_CALIBRATION_BASELINE, } from "./phase7-calibration-loop.js";
2
+ /** A whole-number percentage, or an em dash when there is no signal yet. */
3
+ function formatPercent(value) {
4
+ return value === null ? "—" : `${Math.round(value * 100)}%`;
5
+ }
6
+ /** A signed percentage-point delta (e.g. "+6pts" / "-4pts"), or an em dash when unknown. */
7
+ function formatDeltaPoints(value) {
8
+ if (value === null)
9
+ return "—";
10
+ const points = Math.round(value * 100);
11
+ return `${points >= 0 ? "+" : ""}${points}pts`;
12
+ }
13
+ function sourceRow(label, metric) {
14
+ return {
15
+ label,
16
+ value: formatPercent(metric.accuracy),
17
+ detail: `n=${metric.sampleSize} · ${metric.fresh ? "fresh" : "stale"}`,
18
+ };
19
+ }
20
+ /** Classify the overall calibration state for the dashboard's headline banner. */
21
+ export function resolveCalibrationDashboardStatus(result) {
22
+ if (!result.enabled)
23
+ return "disabled";
24
+ if (result.combinedAccuracy === null)
25
+ return "insufficient_signal";
26
+ return result.combinedAccuracy >= result.baselineAccuracy ? "on_track" : "below_baseline";
27
+ }
28
+ /**
29
+ * Project a computed {@link Phase7CalibrationLoopResult} into a read-only dashboard view. Pure and deterministic;
30
+ * adds no computation of its own. `holdReasons` are surfaced verbatim so an operator can see why an autonomy
31
+ * increase is (or isn't) permitted.
32
+ */
33
+ export function buildCalibrationDashboardView(result) {
34
+ const status = resolveCalibrationDashboardStatus(result);
35
+ const rows = [
36
+ {
37
+ label: "Combined accuracy",
38
+ value: formatPercent(result.combinedAccuracy),
39
+ detail: `baseline ${formatPercent(result.baselineAccuracy)}`,
40
+ },
41
+ {
42
+ label: "Delta from baseline",
43
+ value: formatDeltaPoints(result.deltaFromBaseline),
44
+ detail: `documented baseline ${formatPercent(DOCUMENTED_CALIBRATION_BASELINE)}`,
45
+ },
46
+ sourceRow("Historical replay", result.bySource.historical_replay),
47
+ sourceRow("PR outcome", result.bySource.pr_outcome),
48
+ {
49
+ label: "Replay harness",
50
+ value: result.replayHarnessStatus,
51
+ detail: result.replayHarnessHold ? "hold" : "ok",
52
+ },
53
+ {
54
+ label: "Autonomy increase",
55
+ value: result.autonomyIncreasePermitted ? "permitted" : "held",
56
+ detail: result.replayRunDue ? "replay run due" : "up to date",
57
+ },
58
+ ];
59
+ const headline = status === "disabled"
60
+ ? "Calibration loop disabled"
61
+ : status === "insufficient_signal"
62
+ ? "Insufficient signal to score calibration yet"
63
+ : `${formatPercent(result.combinedAccuracy)} combined (${formatDeltaPoints(result.deltaFromBaseline)} vs baseline)`;
64
+ return { status, headline, rows, holdReasons: [...result.holdReasons] };
65
+ }
@@ -0,0 +1,40 @@
1
+ import { type Phase7CalibrationLoopResult } from "./phase7-calibration-loop.js";
2
+ /** One accumulated point in the calibration history: when it was computed and its combined accuracy. */
3
+ export type CalibrationTrendSnapshot = {
4
+ observedAt: string;
5
+ combinedAccuracy: number | null;
6
+ baselineAccuracy: number;
7
+ };
8
+ export type CalibrationTrendDirection = "improving" | "degrading" | "flat" | "insufficient";
9
+ /** One rendered point on the trend line: a data-bearing snapshot with its delta vs the baseline. */
10
+ export type CalibrationTrendPoint = {
11
+ observedAt: string;
12
+ combinedAccuracy: number;
13
+ /** Whole percentage-point delta vs that snapshot's baseline (e.g. +6 / -4). */
14
+ deltaFromBaseline: number;
15
+ aboveBaseline: boolean;
16
+ };
17
+ /** The read-only trend projection of a calibration-snapshot series. */
18
+ export type CalibrationTrendView = {
19
+ direction: CalibrationTrendDirection;
20
+ headline: string;
21
+ /** Unicode sparkline of the data points' combined accuracy, normalized across the window. */
22
+ sparkline: string;
23
+ points: readonly CalibrationTrendPoint[];
24
+ latestAccuracy: number | null;
25
+ /** Latest minus earliest combined accuracy, in whole percentage points; null with < 2 data points. */
26
+ changeOverWindow: number | null;
27
+ /** Snapshots that carry a combined accuracy (an install still warming up contributes none). */
28
+ sampleCount: number;
29
+ baselineAccuracy: number;
30
+ };
31
+ /** Derive a trend snapshot from a computed calibration-loop result observed at a given time. */
32
+ export declare function calibrationSnapshotFromResult(result: Phase7CalibrationLoopResult, observedAt: string): CalibrationTrendSnapshot;
33
+ /**
34
+ * Project an accumulated series of calibration snapshots into a read-only trend view. Pure and
35
+ * deterministic. Snapshots without a combined accuracy (an install still warming up) are dropped from the
36
+ * trend line; if fewer than two data points remain, the view reports an explicit "insufficient" state
37
+ * instead of a misleading flat line. `baselineAccuracy` is taken from the most recent snapshot (or the
38
+ * documented default when there is no history yet).
39
+ */
40
+ export declare function buildCalibrationTrendView(snapshots: readonly CalibrationTrendSnapshot[]): CalibrationTrendView;
@@ -0,0 +1,60 @@
1
+ import { DOCUMENTED_CALIBRATION_BASELINE } from "./phase7-calibration-loop.js";
2
+ const SPARK_TICKS = "▁▂▃▄▅▆▇█";
3
+ /** Derive a trend snapshot from a computed calibration-loop result observed at a given time. */
4
+ export function calibrationSnapshotFromResult(result, observedAt) {
5
+ return { observedAt, combinedAccuracy: result.combinedAccuracy, baselineAccuracy: result.baselineAccuracy };
6
+ }
7
+ function percentPoints(value) {
8
+ return Math.round(value * 100);
9
+ }
10
+ function formatPercent(value) {
11
+ return `${percentPoints(value)}%`;
12
+ }
13
+ function formatDeltaPoints(points) {
14
+ return `${points >= 0 ? "+" : ""}${points}pts`;
15
+ }
16
+ /** Map data-point accuracies onto sparkline ticks, normalized across the window's own min..max. */
17
+ function sparkline(values) {
18
+ if (values.length === 0)
19
+ return "";
20
+ const min = Math.min(...values);
21
+ const span = Math.max(...values) - min;
22
+ return values
23
+ .map((v) => SPARK_TICKS[span === 0 ? 0 : Math.round(((v - min) / span) * (SPARK_TICKS.length - 1))])
24
+ .join("");
25
+ }
26
+ /**
27
+ * Project an accumulated series of calibration snapshots into a read-only trend view. Pure and
28
+ * deterministic. Snapshots without a combined accuracy (an install still warming up) are dropped from the
29
+ * trend line; if fewer than two data points remain, the view reports an explicit "insufficient" state
30
+ * instead of a misleading flat line. `baselineAccuracy` is taken from the most recent snapshot (or the
31
+ * documented default when there is no history yet).
32
+ */
33
+ export function buildCalibrationTrendView(snapshots) {
34
+ const baselineAccuracy = snapshots.length === 0 ? DOCUMENTED_CALIBRATION_BASELINE : snapshots[snapshots.length - 1].baselineAccuracy;
35
+ const points = snapshots
36
+ .filter((s) => s.combinedAccuracy !== null)
37
+ .map((s) => ({
38
+ observedAt: s.observedAt,
39
+ combinedAccuracy: s.combinedAccuracy,
40
+ deltaFromBaseline: percentPoints(s.combinedAccuracy - s.baselineAccuracy),
41
+ aboveBaseline: s.combinedAccuracy >= s.baselineAccuracy,
42
+ }));
43
+ const sampleCount = points.length;
44
+ const spark = sparkline(points.map((p) => p.combinedAccuracy));
45
+ const latestAccuracy = sampleCount === 0 ? null : points[sampleCount - 1].combinedAccuracy;
46
+ if (sampleCount < 2) {
47
+ const headline = sampleCount === 0
48
+ ? "No calibration history yet"
49
+ : `Insufficient history: 1 snapshot (${formatPercent(points[0].combinedAccuracy)})`;
50
+ return { direction: "insufficient", headline, sparkline: spark, points, latestAccuracy, changeOverWindow: null, sampleCount, baselineAccuracy };
51
+ }
52
+ const earliest = points[0].combinedAccuracy;
53
+ const latest = points[sampleCount - 1].combinedAccuracy;
54
+ const changeOverWindow = percentPoints(latest - earliest);
55
+ const direction = changeOverWindow > 0 ? "improving" : changeOverWindow < 0 ? "degrading" : "flat";
56
+ const headline = direction === "flat"
57
+ ? `Flat at ${formatPercent(latest)} over ${sampleCount} snapshots`
58
+ : `${direction === "improving" ? "Improving" : "Degrading"}: ${formatPercent(earliest)} → ${formatPercent(latest)} over ${sampleCount} snapshots (${formatDeltaPoints(changeOverWindow)})`;
59
+ return { direction, headline, sparkline: spark, points, latestAccuracy, changeOverWindow, sampleCount, baselineAccuracy };
60
+ }
@@ -0,0 +1,24 @@
1
+ export type ContributorFit = "strong" | "neutral" | "weak";
2
+ export type ContributorFitCheck = {
3
+ fit: ContributorFit;
4
+ reasons: string[];
5
+ };
6
+ export type ContributorFitProfile = {
7
+ login: string;
8
+ registeredRepoActivity: {
9
+ pullRequests: number;
10
+ mergedPullRequests: number;
11
+ reposTouched: readonly string[];
12
+ };
13
+ trustSignals: {
14
+ level: "new" | "emerging" | "established";
15
+ unlinkedOpenPullRequests: number;
16
+ };
17
+ };
18
+ /**
19
+ * A profile with no prior activity on the target repo is `neutral` (a first attempt is not evidence
20
+ * of poor fit). With prior activity, a personal burden signal (unlinked open PRs, or a `new` profile
21
+ * with a low merge ratio) reads `weak`; established trust with a clean queue and a high merge ratio
22
+ * reads `strong`; anything between reads `neutral`.
23
+ */
24
+ export declare function classifyContributorFit(profile: ContributorFitProfile, targetRepoFullName: string): ContributorFitCheck;
@@ -0,0 +1,50 @@
1
+ // Contributor fit sub-check (#2312): pure classifier over an already-computed contributor profile
2
+ // that answers whether THIS contributor's own track record fits a target repo. No IO, no fetching.
3
+ /**
4
+ * A profile with no prior activity on the target repo is `neutral` (a first attempt is not evidence
5
+ * of poor fit). With prior activity, a personal burden signal (unlinked open PRs, or a `new` profile
6
+ * with a low merge ratio) reads `weak`; established trust with a clean queue and a high merge ratio
7
+ * reads `strong`; anything between reads `neutral`.
8
+ */
9
+ export function classifyContributorFit(profile, targetRepoFullName) {
10
+ const { reposTouched, pullRequests, mergedPullRequests } = profile.registeredRepoActivity;
11
+ const target = targetRepoFullName.toLowerCase();
12
+ if (!reposTouched.some((repo) => repo.toLowerCase() === target)) {
13
+ return {
14
+ fit: "neutral",
15
+ reasons: [`No prior activity on ${targetRepoFullName}; a first attempt is not evidence of poor fit.`],
16
+ };
17
+ }
18
+ const reasons = [];
19
+ const mergeRatio = pullRequests > 0 ? mergedPullRequests / pullRequests : 1;
20
+ const unlinked = profile.trustSignals.unlinkedOpenPullRequests;
21
+ const level = profile.trustSignals.level;
22
+ const hasUnlinkedBurden = unlinked > 0;
23
+ const hasNewProfileBurden = level === "new" && pullRequests > 0 && mergeRatio < 0.5;
24
+ if (hasUnlinkedBurden)
25
+ reasons.push(`${unlinked} unlinked open pull request(s)`);
26
+ if (hasNewProfileBurden) {
27
+ reasons.push(`new profile with low merge ratio (${mergedPullRequests}/${pullRequests})`);
28
+ }
29
+ const cleanQueue = unlinked === 0;
30
+ const strongMerge = pullRequests > 0 && mergeRatio >= 0.8;
31
+ if (level === "established")
32
+ reasons.push("trust level is 'established'");
33
+ if (cleanQueue)
34
+ reasons.push("no unlinked open pull requests");
35
+ if (strongMerge)
36
+ reasons.push(`strong merge ratio (${mergedPullRequests}/${pullRequests})`);
37
+ const burdened = hasUnlinkedBurden || hasNewProfileBurden;
38
+ const proven = level === "established" && cleanQueue && strongMerge;
39
+ let fit;
40
+ if (burdened) {
41
+ fit = "weak";
42
+ }
43
+ else if (proven) {
44
+ fit = "strong";
45
+ }
46
+ else {
47
+ fit = "neutral";
48
+ }
49
+ return { fit, reasons };
50
+ }
@@ -0,0 +1,74 @@
1
+ export declare const DISCOVERY_INDEX_CONTRACT_VERSION = 1;
2
+ /** Query scope for a discovery-index request: which repos/orgs/search-terms to fan out over, plus pagination. */
3
+ export type DiscoveryIndexQuery = {
4
+ /** Canonical `owner/repo` targets. */
5
+ repos: readonly string[];
6
+ /** Bare `owner` (org/user) targets — every open issue across the owner's repos. */
7
+ orgs: readonly string[];
8
+ /** Free-text GitHub issue-search terms. */
9
+ searchTerms: readonly string[];
10
+ /** Page size, clamped to [1, 200]. Default 50. */
11
+ limit: number;
12
+ /** Opaque forward pagination cursor from a previous response's `nextCursor`, or null for the first page. */
13
+ cursor: string | null;
14
+ };
15
+ export type DiscoveryIndexRequest = {
16
+ contractVersion: number;
17
+ query: DiscoveryIndexQuery;
18
+ };
19
+ export type DiscoveryIndexAiPolicySource = "AI-USAGE.md" | "CONTRIBUTING.md" | "none";
20
+ /** One metadata-only candidate issue. Field-for-field compatible with opportunity-ranker.js `normalizeCandidate`
21
+ * output, so `rankCandidateIssues` consumes hosted results exactly like a local fan-out. Public-safe by contract:
22
+ * no scores/rewards/wallet/hotkey/source fields ever appear here (see {@link DISCOVERY_INDEX_FORBIDDEN_FIELDS}). */
23
+ export type DiscoveryIndexCandidate = {
24
+ owner: string;
25
+ repo: string;
26
+ repoFullName: string;
27
+ issueNumber: number;
28
+ title: string;
29
+ labels: readonly string[];
30
+ commentsCount: number;
31
+ createdAt: string | null;
32
+ updatedAt: string | null;
33
+ htmlUrl: string | null;
34
+ aiPolicyAllowed: boolean;
35
+ aiPolicySource: DiscoveryIndexAiPolicySource;
36
+ };
37
+ export type DiscoveryIndexResponse = {
38
+ contractVersion: number;
39
+ candidates: readonly DiscoveryIndexCandidate[];
40
+ /** Forward cursor for the next page, or null when the result set is exhausted. */
41
+ nextCursor: string | null;
42
+ };
43
+ export type ParsedDiscoveryIndexRequest = {
44
+ request: DiscoveryIndexRequest;
45
+ warnings: string[];
46
+ };
47
+ export type ParsedDiscoveryIndexResponse = {
48
+ response: DiscoveryIndexResponse;
49
+ warnings: string[];
50
+ };
51
+ /** Field-name fragments that must NEVER cross the public discovery boundary (Phase 1 acceptance:
52
+ * cross-repo-discovery-phase1.md:13-14,54). A candidate carrying any of these is rejected, not silently trimmed,
53
+ * so a misbehaving server can't smuggle raw economic/identity/source data past the contract. */
54
+ export declare const DISCOVERY_INDEX_FORBIDDEN_FIELDS: readonly string[];
55
+ /** Owner/repo names of forbidden-field violations present on a raw candidate object (own enumerable keys whose
56
+ * lower-cased name contains a forbidden fragment). Empty array = public-safe. */
57
+ export declare function discoveryIndexBoundaryViolations(raw: unknown): string[];
58
+ /**
59
+ * Tolerantly normalize a raw discovery-index request into a canonical {@link DiscoveryIndexRequest}. Never throws:
60
+ * unknown fields are ignored, malformed scope entries are skipped, and the page limit is clamped, accumulating
61
+ * warnings. A non-object raw yields an empty query.
62
+ */
63
+ export declare function normalizeDiscoveryIndexRequest(raw: unknown): ParsedDiscoveryIndexRequest;
64
+ /**
65
+ * Normalize one raw candidate into a public-safe {@link DiscoveryIndexCandidate}, mirroring opportunity-ranker.js
66
+ * `normalizeCandidate`. Returns null when required fields are missing/invalid OR when the raw object carries any
67
+ * forbidden boundary field (a public-safety rejection, not a silent trim).
68
+ */
69
+ export declare function normalizeDiscoveryIndexCandidate(raw: unknown): DiscoveryIndexCandidate | null;
70
+ /**
71
+ * Tolerantly normalize a raw discovery-index response: keep only valid, public-safe candidates (invalid or
72
+ * boundary-violating entries are dropped with a warning), and carry a forward cursor. Never throws.
73
+ */
74
+ export declare function normalizeDiscoveryIndexResponse(raw: unknown): ParsedDiscoveryIndexResponse;
@@ -0,0 +1,184 @@
1
+ // Discovery-index API contract (#4300). The typed request/response shape a miner uses to query the OPTIONAL hosted
2
+ // discovery-index service (the server side is #4250, maintainer-only, explicitly blocked on this contract). The
3
+ // plane exists to mitigate the rate-limit incident (#1936): one shared GitHub-metadata crawler across the fleet
4
+ // instead of every miner independently hammering the same repos' search/listing endpoints.
5
+ //
6
+ // This module is schema/shape ONLY — no server, no deployed endpoint, no client HTTP. It stays inside the Phase 1
7
+ // boundary (packages/gittensory-miner/docs/cross-repo-discovery-phase1.md): metadata-only, GET/list/search-only,
8
+ // and NO raw scores / rewards / wallet / hotkey data / source contents crossing the public boundary. The response
9
+ // candidate shape deliberately maps onto opportunity-ranker.js's `normalizeCandidate` fields so a miner can swap a
10
+ // local fan-out for a hosted query without the ranker changing. Tolerant-parser convention, mirroring
11
+ // miner-goal-spec.ts / fleet-run-manifest.ts: every field optional, malformed input degrades to a documented
12
+ // default with a warning rather than throwing.
13
+ export const DISCOVERY_INDEX_CONTRACT_VERSION = 1;
14
+ const MAX_QUERY_ITEMS = 200;
15
+ const MAX_PAGE_LIMIT = 200;
16
+ const DEFAULT_PAGE_LIMIT = 50;
17
+ /** Field-name fragments that must NEVER cross the public discovery boundary (Phase 1 acceptance:
18
+ * cross-repo-discovery-phase1.md:13-14,54). A candidate carrying any of these is rejected, not silently trimmed,
19
+ * so a misbehaving server can't smuggle raw economic/identity/source data past the contract. */
20
+ export const DISCOVERY_INDEX_FORBIDDEN_FIELDS = Object.freeze([
21
+ "score",
22
+ "reward",
23
+ "wallet",
24
+ "hotkey",
25
+ "coldkey",
26
+ "mnemonic",
27
+ "payout",
28
+ "ranking",
29
+ "rawtrust",
30
+ "trustscore",
31
+ "sourcecontent",
32
+ "diff",
33
+ "patch",
34
+ ]);
35
+ /** Owner/repo names of forbidden-field violations present on a raw candidate object (own enumerable keys whose
36
+ * lower-cased name contains a forbidden fragment). Empty array = public-safe. */
37
+ export function discoveryIndexBoundaryViolations(raw) {
38
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
39
+ return [];
40
+ const violations = [];
41
+ for (const key of Object.keys(raw)) {
42
+ const lower = key.toLowerCase();
43
+ if (DISCOVERY_INDEX_FORBIDDEN_FIELDS.some((fragment) => lower.includes(fragment)))
44
+ violations.push(key);
45
+ }
46
+ return violations;
47
+ }
48
+ function normalizeStringList(value, transform) {
49
+ if (!Array.isArray(value))
50
+ return [];
51
+ const result = [];
52
+ const seen = new Set();
53
+ for (const entry of value) {
54
+ if (typeof entry !== "string")
55
+ continue;
56
+ if (result.length >= MAX_QUERY_ITEMS)
57
+ break;
58
+ const normalized = transform(entry);
59
+ if (normalized === null || seen.has(normalized))
60
+ continue;
61
+ seen.add(normalized);
62
+ result.push(normalized);
63
+ }
64
+ return result;
65
+ }
66
+ /** `owner/repo` with exactly one slash and non-empty halves; anything else → null (mirrors normalizeCandidate). */
67
+ function normalizeRepoFullName(value) {
68
+ const [owner, repo, extra] = value.trim().split("/");
69
+ if (!owner || !repo || extra !== undefined)
70
+ return null;
71
+ return `${owner}/${repo}`;
72
+ }
73
+ function normalizeOwner(value) {
74
+ const trimmed = value.trim();
75
+ if (!trimmed || trimmed.includes("/"))
76
+ return null;
77
+ return trimmed;
78
+ }
79
+ function normalizeSearchTerm(value) {
80
+ const trimmed = value.trim();
81
+ return trimmed || null;
82
+ }
83
+ function clampLimit(value, warnings) {
84
+ if (value === undefined || value === null)
85
+ return DEFAULT_PAGE_LIMIT;
86
+ if (typeof value !== "number" || !Number.isFinite(value)) {
87
+ warnings.push(`DiscoveryIndexRequest "limit" must be a number; falling back to ${DEFAULT_PAGE_LIMIT}.`);
88
+ return DEFAULT_PAGE_LIMIT;
89
+ }
90
+ const floored = Math.floor(value);
91
+ if (floored < 1)
92
+ return 1;
93
+ if (floored > MAX_PAGE_LIMIT)
94
+ return MAX_PAGE_LIMIT;
95
+ return floored;
96
+ }
97
+ function normalizeAiPolicySource(value) {
98
+ return value === "AI-USAGE.md" || value === "CONTRIBUTING.md" ? value : "none";
99
+ }
100
+ /**
101
+ * Tolerantly normalize a raw discovery-index request into a canonical {@link DiscoveryIndexRequest}. Never throws:
102
+ * unknown fields are ignored, malformed scope entries are skipped, and the page limit is clamped, accumulating
103
+ * warnings. A non-object raw yields an empty query.
104
+ */
105
+ export function normalizeDiscoveryIndexRequest(raw) {
106
+ const warnings = [];
107
+ const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
108
+ if (!record) {
109
+ warnings.push("DiscoveryIndexRequest must be a mapping; falling back to an empty query.");
110
+ }
111
+ const source = record ?? {};
112
+ const query = {
113
+ repos: normalizeStringList(source.repos, normalizeRepoFullName),
114
+ orgs: normalizeStringList(source.orgs, normalizeOwner),
115
+ searchTerms: normalizeStringList(source.searchTerms, normalizeSearchTerm),
116
+ limit: clampLimit(source.limit, warnings),
117
+ cursor: typeof source.cursor === "string" && source.cursor.trim() ? source.cursor : null,
118
+ };
119
+ return { request: { contractVersion: DISCOVERY_INDEX_CONTRACT_VERSION, query }, warnings };
120
+ }
121
+ /**
122
+ * Normalize one raw candidate into a public-safe {@link DiscoveryIndexCandidate}, mirroring opportunity-ranker.js
123
+ * `normalizeCandidate`. Returns null when required fields are missing/invalid OR when the raw object carries any
124
+ * forbidden boundary field (a public-safety rejection, not a silent trim).
125
+ */
126
+ export function normalizeDiscoveryIndexCandidate(raw) {
127
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
128
+ return null;
129
+ if (discoveryIndexBoundaryViolations(raw).length > 0)
130
+ return null;
131
+ const candidate = raw;
132
+ const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName : "";
133
+ const canonical = normalizeRepoFullName(repoFullName);
134
+ const issueNumber = candidate.issueNumber;
135
+ const title = typeof candidate.title === "string" ? candidate.title.trim() : "";
136
+ if (canonical === null)
137
+ return null;
138
+ if (typeof issueNumber !== "number" || !Number.isInteger(issueNumber) || issueNumber <= 0 || !title)
139
+ return null;
140
+ // `canonical` is guaranteed `owner/repo` with exactly one slash, so slice yields two non-empty strings.
141
+ const slashIndex = canonical.indexOf("/");
142
+ const owner = canonical.slice(0, slashIndex);
143
+ const repo = canonical.slice(slashIndex + 1);
144
+ const labels = Array.isArray(candidate.labels)
145
+ ? candidate.labels.filter((label) => typeof label === "string" && label.trim() !== "").map((label) => label.trim())
146
+ : [];
147
+ return {
148
+ owner,
149
+ repo,
150
+ repoFullName: canonical,
151
+ issueNumber,
152
+ title,
153
+ labels,
154
+ commentsCount: typeof candidate.commentsCount === "number" && Number.isFinite(candidate.commentsCount) ? candidate.commentsCount : 0,
155
+ createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : null,
156
+ updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : null,
157
+ htmlUrl: typeof candidate.htmlUrl === "string" ? candidate.htmlUrl : null,
158
+ aiPolicyAllowed: candidate.aiPolicyAllowed !== false,
159
+ aiPolicySource: normalizeAiPolicySource(candidate.aiPolicySource),
160
+ };
161
+ }
162
+ /**
163
+ * Tolerantly normalize a raw discovery-index response: keep only valid, public-safe candidates (invalid or
164
+ * boundary-violating entries are dropped with a warning), and carry a forward cursor. Never throws.
165
+ */
166
+ export function normalizeDiscoveryIndexResponse(raw) {
167
+ const warnings = [];
168
+ const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
169
+ if (!record) {
170
+ warnings.push("DiscoveryIndexResponse must be a mapping; falling back to an empty candidate list.");
171
+ }
172
+ const rawCandidates = record && Array.isArray(record.candidates) ? record.candidates : [];
173
+ const candidates = [];
174
+ for (const entry of rawCandidates) {
175
+ const normalized = normalizeDiscoveryIndexCandidate(entry);
176
+ if (normalized === null) {
177
+ warnings.push("DiscoveryIndexResponse dropped an invalid or boundary-violating candidate.");
178
+ continue;
179
+ }
180
+ candidates.push(normalized);
181
+ }
182
+ const nextCursor = record && typeof record.nextCursor === "string" && record.nextCursor.trim() ? record.nextCursor : null;
183
+ return { response: { contractVersion: DISCOVERY_INDEX_CONTRACT_VERSION, candidates, nextCursor }, warnings };
184
+ }
@@ -0,0 +1,35 @@
1
+ /** The three local claim-ledger statuses (claim-ledger.js `CLAIM_STATUSES`). */
2
+ export type SoftClaimStatus = "active" | "released" | "expired";
3
+ /** Outbound coordination actions: announce a claim, or announce that a prior claim is released. */
4
+ export type SoftClaimAction = "claim" | "release";
5
+ /** The local claim-ledger record shape (claim-ledger.js `rowToClaim`) this builder reads from. */
6
+ export type SoftClaimRecord = {
7
+ repoFullName: string;
8
+ issueNumber: number;
9
+ claimedAt: string;
10
+ status: SoftClaimStatus;
11
+ note?: string | null;
12
+ };
13
+ /** Optional caller context. Reserved for future public-safe, anonymized coordination metadata. */
14
+ export type SoftClaimRequestContext = {
15
+ instanceId?: string;
16
+ };
17
+ /** The public-safe soft-claim coordination request payload targeting the discovery-index contract. */
18
+ export type SoftClaimRequest = {
19
+ contractVersion: number;
20
+ action: SoftClaimAction;
21
+ repoFullName: string;
22
+ issueNumber: number;
23
+ claimedAt: string;
24
+ note: string | null;
25
+ instanceId: string | null;
26
+ };
27
+ /** `active` announces a `claim`; `released`/`expired` announce a `release`. */
28
+ export declare function softClaimActionForStatus(status: SoftClaimStatus): SoftClaimAction;
29
+ /**
30
+ * Build a public-safe soft-claim coordination request from a local claim-ledger record. Pure and network-free.
31
+ * Returns null when the claim is missing/invalid or carries an unknown status (only the three claim-ledger
32
+ * statuses map to a request). Only the fixed set of known fields is copied onto the request, so the payload stays
33
+ * metadata-only by construction.
34
+ */
35
+ export declare function buildSoftClaimRequest(claim: unknown, _context?: SoftClaimRequestContext): SoftClaimRequest | null;
@@ -0,0 +1,50 @@
1
+ import { DISCOVERY_INDEX_CONTRACT_VERSION } from "./discovery-index-contract.js";
2
+ /** `active` announces a `claim`; `released`/`expired` announce a `release`. */
3
+ export function softClaimActionForStatus(status) {
4
+ return status === "active" ? "claim" : "release";
5
+ }
6
+ /** `owner/repo` with exactly one slash and non-empty halves; anything else → null (mirrors the discovery-index
7
+ * contract / claim-ledger repo validation). */
8
+ function normalizeRepoFullName(value) {
9
+ if (typeof value !== "string")
10
+ return null;
11
+ const [owner, repo, extra] = value.trim().split("/");
12
+ if (!owner || !repo || extra !== undefined)
13
+ return null;
14
+ return `${owner}/${repo}`;
15
+ }
16
+ function isSoftClaimStatus(value) {
17
+ return value === "active" || value === "released" || value === "expired";
18
+ }
19
+ /**
20
+ * Build a public-safe soft-claim coordination request from a local claim-ledger record. Pure and network-free.
21
+ * Returns null when the claim is missing/invalid or carries an unknown status (only the three claim-ledger
22
+ * statuses map to a request). Only the fixed set of known fields is copied onto the request, so the payload stays
23
+ * metadata-only by construction.
24
+ */
25
+ export function buildSoftClaimRequest(claim, _context = {}) {
26
+ if (!claim || typeof claim !== "object" || Array.isArray(claim))
27
+ return null;
28
+ const record = claim;
29
+ const repoFullName = normalizeRepoFullName(record.repoFullName);
30
+ if (repoFullName === null)
31
+ return null;
32
+ const issueNumber = record.issueNumber;
33
+ if (typeof issueNumber !== "number" || !Number.isInteger(issueNumber) || issueNumber <= 0)
34
+ return null;
35
+ if (typeof record.claimedAt !== "string" || record.claimedAt.trim() === "")
36
+ return null;
37
+ if (!isSoftClaimStatus(record.status))
38
+ return null;
39
+ return {
40
+ contractVersion: DISCOVERY_INDEX_CONTRACT_VERSION,
41
+ action: softClaimActionForStatus(record.status),
42
+ repoFullName,
43
+ issueNumber,
44
+ claimedAt: record.claimedAt,
45
+ // Local claim-ledger notes and caller instance IDs may contain operator prose or private identifiers. The hosted
46
+ // discovery plane only receives fixed public metadata, so keep both fields empty.
47
+ note: null,
48
+ instanceId: null,
49
+ };
50
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Duplicate-winner adjudication (#dup-winner). Flag-gated by GITTENSORY_DUPLICATE_WINNER.
3
+ *
4
+ * When several OPEN PRs link the same issue (a duplicate cluster), the legacy behavior gate-blocks +
5
+ * auto-closes EVERY sibling as a duplicate — no winner survives. With the flag ON, exactly ONE winner is
6
+ * spared: the earliest claimant. Sparse legacy rows that do not yet have claim timing fail closed so unknown
7
+ * ordering cannot arbitrarily suppress duplicate evidence. Only the LOSERS are blocked/closed; the winner
8
+ * still must pass CI / conflict / gate / linked-issue / slop on its OWN merits.
9
+ *
10
+ * This module is PURE — no IO, no Date, no random — so the same inputs always yield the same verdict and the
11
+ * caller can compute the winner ONCE per review run and thread the result boolean consistently into every
12
+ * surface (advisory finding, close reason, slop, panels), so they agree by construction.
13
+ *
14
+ * ELECTION ORDER: compare `linkedIssueClaimedAt`, the time gittensory first observed the PR claiming
15
+ * the issue. GitHub `pull_request.created_at` is intentionally not an ordering signal here: contributors can
16
+ * edit an old placeholder PR to add a linked issue later, so creation time would let backdated claims steal
17
+ * duplicate-winner credit from the PR that actually claimed the issue first. Sparse legacy rows that lack
18
+ * claim timing keep failing closed so unknown ordering cannot suppress duplicate evidence.
19
+ *
20
+ * INVARIANT (the caller MUST honor it): {@link openSiblingNumbers} carries OPEN-only sibling PR numbers. The
21
+ * existing sources already exclude closed/merged PRs. Once the winner closes (e.g. red CI), it leaves the open
22
+ * set and the next-earliest OPEN claimant becomes the winner on re-eval — no permanently-orphaned cluster.
23
+ *
24
+ * SECOND CONSUMER (#2278): this module is intentionally engine-hosted (not `src/`-only) because its election
25
+ * logic is reusable for the miner's own soft-claim adjudication — deciding which of several miners claiming
26
+ * the same issue proceeds. A future contributor wiring the miner's local claim ledger should import this
27
+ * module rather than reimplementing the election rule, so both the maintainer gate and the miner agree on
28
+ * exactly one winner by construction.
29
+ */
30
+ export type DuplicateClaimMember = {
31
+ number: number;
32
+ linkedIssueClaimedAt?: string | null | undefined;
33
+ /** GitHub's true PR creation time. Retained for caller compatibility; not used for winner ordering. */
34
+ createdAt?: string | null | undefined;
35
+ };
36
+ /**
37
+ * True iff `prNumber` is the cluster winner: the minimum of `{prNumber} ∪ openSiblingNumbers`. An empty
38
+ * sibling list ⇒ the PR is alone in (or out of) the cluster ⇒ winner. A sibling list that happens to contain
39
+ * `prNumber` itself is harmless — the comparison is still min-based.
40
+ *
41
+ * @deprecated Use {@link isDuplicateClusterWinnerByClaim}. PR-number election is retained only for legacy
42
+ * compatibility callers that do not have claim timestamps.
43
+ */
44
+ export declare function isDuplicateClusterWinner(prNumber: number, openSiblingNumbers: number[]): boolean;
45
+ /**
46
+ * True iff `pr` is the earliest-elected claimant in the open duplicate cluster (see the module doc's
47
+ * "ELECTION ORDER" note). Sparse legacy rows fail closed; ties between equally-ordered members use PR number.
48
+ */
49
+ export declare function isDuplicateClusterWinnerByClaim(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): boolean;
50
+ /**
51
+ * The winning PR number among `pr` and its open duplicate siblings, or `null` when the election is not
52
+ * determinable (mirrors {@link isDuplicateClusterWinnerByClaim}'s fail-closed semantics — this never guesses a
53
+ * specific winner when the ordering data is too sparse/ambiguous to be sure). Used only for DISPLAY (naming the
54
+ * winner in a loser's close comment, #dup-winner-credit) — the close/hold decision for any given PR is still
55
+ * driven directly by {@link isDuplicateClusterWinnerByClaim}, not by this function's return value.
56
+ */
57
+ export declare function resolveDuplicateClusterWinnerNumber(pr: DuplicateClaimMember, openSiblings: DuplicateClaimMember[]): number | null;