@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,278 @@
1
+ // Local create->score->self-review->decide iterate-loop orchestrator (#2333): the actual autonomous control
2
+ // flow Phase 3 exists to build. Repeatedly invokes a `CodingAgentDriver` (coding-agent-driver.ts), self-reviews
3
+ // the resulting diff against the byte-identical predicted-gate target (self-review-adapter.ts, #2334), and
4
+ // consults the pure policy (iterate-policy.ts, #2335) to decide -- autonomously, no human in the loop at this
5
+ // stage -- whether to keep iterating, hand off to Phase 4 submission, or abandon.
6
+ //
7
+ // TAGGED maintainer (not contributor) per the phase brief: this orchestration control flow is the precise
8
+ // chokepoint the fixed architecture skeleton's safety-tier system reserves for the owner -- it is the trigger
9
+ // surface for "does the system keep trying, or does it eventually open a PR" without a human approving each
10
+ // step, and is adjacent to the #1 slop-at-scale strategic risk (an autonomous fleet maximizing gate-pass rate
11
+ // can mass-produce gate-passing-but-low-value PRs).
12
+ //
13
+ // FAIL CLOSED ON AMBIGUITY: a driver run that does not complete successfully, or a self-review call that
14
+ // itself throws, is treated identically to a `SelfReviewOutcome` of `"ambiguous"` -- iterate-policy.ts's own
15
+ // precedence then abandons rather than optimistically continuing or handing off. The loop never fabricates a
16
+ // "pass" from anything other than a genuinely successful `runSelfReview` call.
17
+ //
18
+ // BOUNDED INSIDE THE LOOP: both the iteration ceiling (`input.maxIterations`) and the optional cumulative
19
+ // budget (`input.budget`, evaluated every iteration via attempt-metering.ts's `accumulateAttemptUsage`/
20
+ // `evaluateAttemptBudget` against real per-iteration turns/costUsd/wallClockMs -- tokens stays an honest 0,
21
+ // no driver reports a real token count today, #5395) are enforced here every iteration -- not left to an
22
+ // external caller to remember, and not just capped after the fact between loop cycles (loop-cli.js's own
23
+ // governor cap usage). A `maxIterations <= 0` input abandons immediately, before ever invoking the driver.
24
+ //
25
+ // AUDITABLE: every iteration's decision (continue / handoff / abandon) is recorded via the injected
26
+ // `appendAttemptLogEvent` dependency (attempt-log.ts's normalized event shape) before this function returns
27
+ // control to its caller for that iteration -- the decision trail survives independently of this function's own
28
+ // return value. A logging failure never alters the loop's decision (mirrors the governor-ledger and
29
+ // pretooluse-hook append-failure handling elsewhere in this package).
30
+ import { codingAgentModeExecutes } from "./coding-agent-mode.js";
31
+ import { invokeCodingAgentDriver } from "./coding-agent-invoke.js";
32
+ import { runSelfReview } from "./self-review-adapter.js";
33
+ import { decideNextActionWithReason, deriveSelfReviewOutcome } from "./iterate-policy.js";
34
+ import { accumulateAttemptUsage, evaluateAttemptBudget } from "./attempt-metering.js";
35
+ function buildAttemptDiffState(input, driverResult) {
36
+ return {
37
+ repoFullName: input.repoFullName,
38
+ contributorLogin: input.contributorLogin,
39
+ title: input.title,
40
+ ...(input.body !== undefined ? { body: input.body } : {}),
41
+ ...(input.labels !== undefined ? { labels: input.labels } : {}),
42
+ ...(input.linkedIssues !== undefined ? { linkedIssues: input.linkedIssues } : {}),
43
+ ...(input.authorAssociation !== undefined ? { authorAssociation: input.authorAssociation } : {}),
44
+ changedFiles: driverResult.changedFiles.map((path) => ({ path })),
45
+ };
46
+ }
47
+ /** Turn one iteration's driver result into a policy-ready {@link SelfReviewOutcome}. A driver run that did not
48
+ * complete successfully, or a `runSelfReview` call that itself throws, both become `"ambiguous"` -- this loop
49
+ * never fabricates a pass/fail from anything other than a genuinely successful self-review call. */
50
+ function evaluateSelfReviewOutcome(input, driverResult, deps) {
51
+ if (!driverResult.ok) {
52
+ return {
53
+ outcome: { kind: "ambiguous", reason: `driver run did not complete successfully${driverResult.error ? `: ${driverResult.error}` : "."}` },
54
+ };
55
+ }
56
+ try {
57
+ const verdict = runSelfReview(buildAttemptDiffState(input, driverResult), input.reviewContext, { runSlopAssessment: deps.runSlopAssessment });
58
+ return { outcome: deriveSelfReviewOutcome(verdict), verdict };
59
+ }
60
+ catch (error) {
61
+ return { outcome: { kind: "ambiguous", reason: `self_review_error: ${error instanceof Error ? error.message : String(error)}` } };
62
+ }
63
+ }
64
+ /** A thrown driver error is normalized into the same `{ ok: false }` shape a driver returning gracefully would
65
+ * produce, so {@link evaluateSelfReviewOutcome} has exactly one failure path to handle, not two. Non-live modes
66
+ * are also resolved here, at the driver boundary, so paused/dry-run attempts never spawn the underlying agent. */
67
+ async function runDriverSafely(input, deps, task) {
68
+ if (!codingAgentModeExecutes(input.mode)) {
69
+ return invokeCodingAgentDriver(deps.driver, input.mode, task, {
70
+ append: (event) => safeAppendAttemptLogEvent(deps, event),
71
+ });
72
+ }
73
+ try {
74
+ return await deps.driver.run(task);
75
+ }
76
+ catch (error) {
77
+ return { ok: false, changedFiles: [], summary: "", error: `driver_threw: ${error instanceof Error ? error.message : String(error)}` };
78
+ }
79
+ }
80
+ function attemptLogEventTypeForDecision(decision) {
81
+ if (decision.action === "continue")
82
+ return "attempt_tool_edit";
83
+ if (decision.action === "handoff")
84
+ return "attempt_succeeded";
85
+ // abandon: a deliberate early disengagement (rejection signaled, or the self-review itself was inconclusive)
86
+ // reads as aborted; a genuine failure to converge (ceiling reached, or stuck with no progress) reads as
87
+ // failed. Both are still `action: "abandon"` in the decision itself -- this is only a coarser attempt-log
88
+ // classification layered on top, for the fixed six-value ATTEMPT_LOG_EVENT_TYPES vocabulary.
89
+ if (decision.abandonReason === "rejection_signaled" || decision.abandonReason === "self_review_ambiguous")
90
+ return "attempt_aborted";
91
+ return "attempt_failed";
92
+ }
93
+ /** A logging failure must never crash the loop or alter its decision -- mirrors the governor-ledger and
94
+ * pretooluse-hook append-failure handling elsewhere in this package. */
95
+ function safeAppendAttemptLogEvent(deps, event) {
96
+ try {
97
+ deps.appendAttemptLogEvent(event);
98
+ }
99
+ catch {
100
+ // Deliberately swallowed -- see doc comment above.
101
+ }
102
+ }
103
+ function logDecision(input, deps, iterationNumber, decision, budgetBreaches) {
104
+ safeAppendAttemptLogEvent(deps, {
105
+ eventType: attemptLogEventTypeForDecision(decision),
106
+ attemptId: input.attemptId,
107
+ actionClass: "iterate_loop",
108
+ mode: input.mode,
109
+ reason: decision.reason,
110
+ payload: {
111
+ iterationNumber,
112
+ action: decision.action,
113
+ ...(decision.abandonReason !== undefined ? { abandonReason: decision.abandonReason } : {}),
114
+ // Which real axis (or axes) breached, on the hard-budget-ceiling abandon path (#5395, checked directly
115
+ // in the loop body before self-review even runs -- see that check site's own comment) -- an operator
116
+ // reading the attempt log back otherwise has no way to see which axis actually tripped.
117
+ ...(budgetBreaches.length > 0 ? { budgetBreaches } : {}),
118
+ },
119
+ });
120
+ }
121
+ /**
122
+ * Extract the blocker codes to carry into the next iteration's no-progress comparison. Only ever called after
123
+ * `decideNextActionWithReason` has returned `"continue"` for this exact `outcome` -- that function's own
124
+ * precedence ladder short-circuits BOTH the `"ambiguous"` and `"pass"` variants (to abandon and handoff
125
+ * respectively) before ever reaching its `"continue"` fallthrough, so `outcome.kind === "fail"` is guaranteed
126
+ * whenever this is reached from the real call site below, not just the common case.
127
+ */
128
+ function blockerCodesFromContinuingOutcome(outcome) {
129
+ if (outcome.kind === "fail")
130
+ return outcome.blockerCodes;
131
+ /* v8 ignore next -- unreachable: see this function's own doc comment above. */
132
+ return [];
133
+ }
134
+ function buildHandoffPacket(input, verdict, driverResult) {
135
+ return {
136
+ worktreePath: input.workingDirectory,
137
+ ...(input.branchRef !== undefined ? { branchRef: input.branchRef } : {}),
138
+ diffSummary: driverResult.summary,
139
+ selfReviewVerdict: verdict,
140
+ attemptLogReference: input.attemptId,
141
+ };
142
+ }
143
+ const ZERO_METER_TOTALS = { tokens: 0, turns: 0, wallClockMs: 0, costUsd: 0 };
144
+ function immediateAbandonNoIterationsPermitted(input, deps) {
145
+ const decision = {
146
+ action: "abandon",
147
+ abandonReason: "max_iterations_reached",
148
+ reason: `maxIterations (${input.maxIterations}) permits no iterations; abandoning without invoking the driver.`,
149
+ };
150
+ safeAppendAttemptLogEvent(deps, {
151
+ eventType: "attempt_aborted",
152
+ attemptId: input.attemptId,
153
+ actionClass: "iterate_loop",
154
+ mode: input.mode,
155
+ reason: decision.reason,
156
+ payload: { iterationNumber: 0, action: decision.action, abandonReason: decision.abandonReason },
157
+ });
158
+ return { outcome: "abandon", finalDecision: decision, iterationsUsed: 0, totalTurnsUsed: 0, totalCostUsd: 0, iterations: [] };
159
+ }
160
+ /**
161
+ * Run the full create->score->self-review->decide loop for one attempt, iteration by iteration, until
162
+ * iterate-policy.ts's {@link decideNextActionWithReason} reaches a terminal `"handoff"` or `"abandon"`.
163
+ *
164
+ * Every iteration: invoke the driver, self-review the resulting diff (never fabricating a pass from a failed
165
+ * or errored driver/self-review run), consult the policy with the running iteration/cost/no-progress state,
166
+ * and record the decision via the attempt-log. `"continue"` decisions loop again; `"handoff"`/`"abandon"`
167
+ * return immediately. See {@link IterateLoopCoreResult}'s own doc comment for why this doesn't carry
168
+ * `finalMeterTotals`/`budgetBreaches` itself -- {@link runIterateLoop} attaches those.
169
+ */
170
+ async function runIterateLoopCore(input, deps, tracker) {
171
+ // Truncated toward zero rather than used as-is: a fractional maxIterations (a caller bug -- "how many times
172
+ // to run a coding agent" has no fractional meaning) would otherwise let this loop's own `for` bound and
173
+ // iterate-policy.ts's `iterationNumber >= maxIterations` ceiling check disagree by less than one iteration
174
+ // (e.g. 2.5 lets the `for` loop run a 3rd time that the ceiling check, comparing against 2.5, would not yet
175
+ // reject), silently permitting one extra iteration beyond the caller's intent. Normalizing once here keeps
176
+ // both checks watching the exact same integer ceiling.
177
+ const maxIterations = Math.max(0, Math.trunc(input.maxIterations));
178
+ if (maxIterations <= 0)
179
+ return immediateAbandonNoIterationsPermitted(input, deps);
180
+ safeAppendAttemptLogEvent(deps, {
181
+ eventType: "attempt_started",
182
+ attemptId: input.attemptId,
183
+ actionClass: "iterate_loop",
184
+ mode: input.mode,
185
+ reason: "iterate_loop_started",
186
+ payload: { maxIterations, maxTurnsPerIteration: input.maxTurnsPerIteration },
187
+ });
188
+ const nowMs = deps.nowMs ?? Date.now;
189
+ const iterations = [];
190
+ let previousBlockerCodes = null;
191
+ let totalTurnsUsed = 0;
192
+ let totalCostUsd = 0;
193
+ for (let iterationNumber = 1; iterationNumber <= maxIterations; iterationNumber += 1) {
194
+ const iterationStartMs = nowMs();
195
+ const driverResult = await runDriverSafely(input, deps, {
196
+ attemptId: input.attemptId,
197
+ workingDirectory: input.workingDirectory,
198
+ acceptanceCriteriaPath: input.acceptanceCriteriaPath,
199
+ instructions: input.instructions,
200
+ maxTurns: input.maxTurnsPerIteration,
201
+ });
202
+ const iterationElapsedMs = Math.max(0, nowMs() - iterationStartMs);
203
+ totalTurnsUsed += driverResult.turnsUsed ?? 0;
204
+ totalCostUsd += driverResult.costUsd ?? 0;
205
+ // tokens stays an honest 0: no CodingAgentDriver reports a real per-iteration token count today (#5395) --
206
+ // an absence, never a fabricated number, matching this package's costUsd discipline elsewhere.
207
+ tracker.totals = accumulateAttemptUsage(tracker.totals, {
208
+ tokens: 0,
209
+ turns: driverResult.turnsUsed ?? 0,
210
+ wallClockMs: iterationElapsedMs,
211
+ costUsd: driverResult.costUsd ?? 0,
212
+ });
213
+ const budgetVerdict = input.budget !== undefined ? evaluateAttemptBudget(tracker.totals, input.budget) : undefined;
214
+ tracker.breaches = budgetVerdict?.breaches ?? [];
215
+ // A reached budget ceiling is a HARD, unconditional stop -- checked and, if breached, acted on BEFORE
216
+ // self-review even runs, so a same-iteration pass can never bypass it. The spend/turns/time on this
217
+ // iteration are sunk either way (the driver already ran), but handing off anyway would let a single
218
+ // over-budget iteration silently defeat the entire point of wiring a ceiling in (#5395's own mid-attempt
219
+ // abort goal) -- so this abandons regardless of what this iteration's own result looks like.
220
+ if (budgetVerdict !== undefined && !budgetVerdict.withinBudget) {
221
+ const decision = {
222
+ action: "abandon",
223
+ abandonReason: "cost_ceiling_reached",
224
+ reason: `Reached the attempt's budget ceiling (${tracker.breaches.join(", ")}) on iteration ${iterationNumber}; abandoning regardless of this iteration's own result.`,
225
+ };
226
+ logDecision(input, deps, iterationNumber, decision, tracker.breaches);
227
+ iterations.push({ iterationNumber, driverResult, decision });
228
+ return { outcome: "abandon", finalDecision: decision, iterationsUsed: iterationNumber, totalTurnsUsed, totalCostUsd, iterations };
229
+ }
230
+ const { outcome: selfReview, verdict } = evaluateSelfReviewOutcome(input, driverResult, deps);
231
+ const state = {
232
+ iterationNumber,
233
+ maxIterations,
234
+ selfReview,
235
+ previousBlockerCodes,
236
+ rejectionSignaled: input.rejectionSignaled,
237
+ };
238
+ const decision = decideNextActionWithReason(state);
239
+ logDecision(input, deps, iterationNumber, decision, []);
240
+ iterations.push({ iterationNumber, driverResult, decision });
241
+ if (decision.action === "handoff") {
242
+ // Guaranteed defined: decideNextActionWithReason only reaches `"handoff"` from `selfReview.kind ===
243
+ // "pass"`, which evaluateSelfReviewOutcome only ever returns alongside a real, successfully computed
244
+ // verdict (never from the ambiguous/driver-failure path).
245
+ return {
246
+ outcome: "handoff",
247
+ finalDecision: decision,
248
+ iterationsUsed: iterationNumber,
249
+ totalTurnsUsed,
250
+ totalCostUsd,
251
+ iterations,
252
+ handoffPacket: buildHandoffPacket(input, verdict, driverResult),
253
+ };
254
+ }
255
+ if (decision.action === "abandon") {
256
+ return { outcome: "abandon", finalDecision: decision, iterationsUsed: iterationNumber, totalTurnsUsed, totalCostUsd, iterations };
257
+ }
258
+ previousBlockerCodes = blockerCodesFromContinuingOutcome(selfReview);
259
+ }
260
+ /* v8 ignore next 8 -- unreachable in practice: decideNextActionWithReason's own `iterationNumber >=
261
+ * maxIterations` check guarantees an abandon by the time iterationNumber reaches the (now-integer, per the
262
+ * truncation above) maxIterations ceiling inside the loop above, so the for-loop above always returns.
263
+ * Retained as an explicit fail-closed fallback rather than an implicit `undefined` return, consistent with
264
+ * this package's fail-closed discipline, in case a future edit to the precedence ladder ever removes that
265
+ * guarantee. */
266
+ const fallbackDecision = { action: "abandon", abandonReason: "max_iterations_reached", reason: "Iterate loop exhausted its iteration budget." };
267
+ return { outcome: "abandon", finalDecision: fallbackDecision, iterationsUsed: maxIterations, totalTurnsUsed, totalCostUsd, iterations };
268
+ }
269
+ /**
270
+ * Thin wrapper over {@link runIterateLoopCore} that attaches the real accumulated
271
+ * {@link AttemptMeterTotals}/breached axes (#5395) once, at this single always-reached return point -- see
272
+ * {@link IterateLoopCoreResult}'s own doc comment for why the core itself doesn't carry these fields.
273
+ */
274
+ export async function runIterateLoop(input, deps) {
275
+ const tracker = { totals: ZERO_METER_TOTALS, breaches: [] };
276
+ const core = await runIterateLoopCore(input, deps, tracker);
277
+ return { ...core, finalMeterTotals: tracker.totals, budgetBreaches: tracker.breaches };
278
+ }
@@ -0,0 +1,98 @@
1
+ import type { SelfReviewVerdict } from "./self-review-adapter.js";
2
+ /** The three outcomes `decideNextAction` may reach. */
3
+ export type IterateLoopAction = "continue" | "handoff" | "abandon";
4
+ /** Every distinct reason `decideNextAction` can abandon for -- kept as a closed literal union so a caller
5
+ * recording the decision (the attempt-log primitive, per #2333) has a stable, exhaustive vocabulary. */
6
+ export type AbandonReason = "rejection_signaled" | "self_review_ambiguous" | "max_iterations_reached" | "cost_ceiling_reached" | "no_progress";
7
+ /**
8
+ * The self-review outcome as the policy needs it -- narrower than the full {@link SelfReviewVerdict} (self-
9
+ * review-adapter.ts, #2334) so `IterationState` stays a minimal, cheap-to-construct synthetic fixture in
10
+ * tests. `"ambiguous"` is NOT something {@link deriveSelfReviewOutcome} ever produces from a successfully
11
+ * returned verdict (a real verdict object always has a definite conclusion) -- it is constructed directly by
12
+ * the caller's own error handling when the self-review call itself throws (e.g. a calculator inside it errors),
13
+ * per #2333's own "self-review itself errors" abandon trigger.
14
+ */
15
+ export type SelfReviewOutcome = {
16
+ readonly kind: "pass";
17
+ } | {
18
+ readonly kind: "fail";
19
+ readonly blockerCodes: readonly string[];
20
+ } | {
21
+ readonly kind: "ambiguous";
22
+ readonly reason?: string | undefined;
23
+ };
24
+ /** Derive the policy-relevant {@link SelfReviewOutcome} from a real, successfully computed
25
+ * {@link SelfReviewVerdict}. Only ever returns `"pass"` or `"fail"` -- see that variant's own doc comment for
26
+ * why `"ambiguous"` is constructed elsewhere. */
27
+ export declare function deriveSelfReviewOutcome(verdict: SelfReviewVerdict): SelfReviewOutcome;
28
+ /**
29
+ * Everything `decideNextAction` needs for one iteration's decision. Deliberately minimal and synthetic-
30
+ * fixture-friendly -- no driver, no worktree, no IO.
31
+ */
32
+ export type IterationState = {
33
+ /** 1-indexed count of iterations attempted so far, INCLUDING this one. */
34
+ iterationNumber: number;
35
+ /** Hard ceiling enforced INSIDE this policy (#2333's own deliverable: not left to an external caller to
36
+ * remember to enforce). `iterationNumber >= maxIterations` abandons regardless of self-review outcome. */
37
+ maxIterations: number;
38
+ /** True when the loop's own cumulative cost ceiling (e.g. total driver turns spent across every iteration of
39
+ * this attempt so far, not just this one) has been reached or exceeded -- the loop mechanics' (#2333) OWN
40
+ * "max-cost ceiling enforced inside the loop" deliverable, alongside the iteration ceiling above. This
41
+ * policy has no notion of what "cost" means; the caller computes the boolean from whatever cost signal it
42
+ * tracks. Optional and defaults to not-reached, so `IterationState` fixtures that predate this field remain
43
+ * valid. */
44
+ costCeilingReached?: boolean | undefined;
45
+ selfReview: SelfReviewOutcome;
46
+ /** The prior iteration's `fail` blocker codes, for the no-progress detector -- `null` when there is no prior
47
+ * iteration to compare (the first iteration, or the prior iteration did not reach a `fail` outcome). */
48
+ previousBlockerCodes: readonly string[] | null;
49
+ /** True when the target repo (or this contributor's history with it) has signaled it does not want
50
+ * automated/AI-authored contributions -- an explicit AI-usage-policy ban, or a prior submission from this
51
+ * same miner was closed/rejected on this exact repo. The caller resolves this (e.g. via the AI-policy-map
52
+ * signals or the rejection-state-machine primitive already shipped in `packages/gittensory-miner/lib/`) and
53
+ * passes it in; this policy does not compute it itself. */
54
+ rejectionSignaled: boolean;
55
+ };
56
+ /** Forward-looking INTERFACE for Phase 4 (submission), not an implementation of it -- Phase 4 lands as a later,
57
+ * separate issue. Gives it a stable target instead of reverse-engineering the shape from the loop's internals. */
58
+ export type HandoffPacket = {
59
+ /** Absolute path to (or a branch ref identifying) the worktree holding the passing attempt's changes. */
60
+ worktreePath: string;
61
+ branchRef?: string | undefined;
62
+ /** Human-readable summary of the final diff, for the submission's own PR description. */
63
+ diffSummary: string;
64
+ /** The PASSING self-review verdict that authorized this handoff -- always has `passesPredictedGate: true`
65
+ * (constructing a packet from anything else is a caller bug, not something this type can prevent statically,
66
+ * since `decideNextAction` is the actual enforcement point). */
67
+ selfReviewVerdict: SelfReviewVerdict;
68
+ /** Reference into the attempt-log primitive (`packages/gittensory-engine/src/miner/attempt-log.ts`) for this
69
+ * attempt's full decision trail. */
70
+ attemptLogReference: string;
71
+ };
72
+ export type IterateLoopDecision = {
73
+ action: IterateLoopAction;
74
+ /** Machine-stable, human-readable reason -- always populated, including for `"continue"` and `"handoff"`, so
75
+ * every decision (not just abandons) has an auditable reason string for the attempt-log. */
76
+ reason: string;
77
+ /** Populated only when `action === "abandon"`. */
78
+ abandonReason?: AbandonReason | undefined;
79
+ };
80
+ /**
81
+ * Decide the next action for one iteration. Pure; identical inputs always yield the identical decision.
82
+ *
83
+ * Precedence (each check short-circuits the ones below it):
84
+ * 1. `rejectionSignaled` -- ALWAYS abandons, even over an otherwise-passing self-review (disengage silently).
85
+ * 2. `selfReview.kind === "ambiguous"` -- abandons; never optimistically continues or hands off on ambiguity.
86
+ * 3. `selfReview.kind === "pass"` -- the ONLY path to `"handoff"`.
87
+ * 4. `iterationNumber >= maxIterations` -- abandons at the hard ceiling regardless of whether the blocker set
88
+ * was still changing (genuine incremental progress does not buy unlimited iterations).
89
+ * 5. `costCeilingReached` -- abandons at the hard cost ceiling, same rationale as the iteration ceiling above.
90
+ * 6. The current `fail` blocker set is identical to `previousBlockerCodes` -- abandons (no progress, stop
91
+ * wasting turns).
92
+ * 7. Otherwise -- continue.
93
+ */
94
+ export declare function decideNextActionWithReason(state: IterationState): IterateLoopDecision;
95
+ /** The bare `decideNextAction(state) -> "continue" | "handoff" | "abandon"` signature this issue's deliverable
96
+ * calls for. For the WHY behind a decision (the attempt-log needs a reason string, not just the action), use
97
+ * {@link decideNextActionWithReason} -- this is a thin projection over the same logic. */
98
+ export declare function decideNextAction(state: IterationState): IterateLoopAction;
@@ -0,0 +1,98 @@
1
+ // Iterate-loop stop/abandon/handoff policy (#2335): the explicit POLICY the orchestration loop's control flow
2
+ // (#2333, sibling issue) consults each iteration to decide among exactly three outcomes. Deliberately split
3
+ // from the loop MECHANICS (#2333) so the actual thresholds/rules are one small, individually-reviewable, pure
4
+ // artifact -- `decideNextAction` needs no driver, no worktree, no IO to test.
5
+ //
6
+ // STRATEGIC CONSTRAINTS this policy encodes (gittensory-miner-autonomy-roadmap):
7
+ // - "never auto-submit (P4) before governor+caps (P5)" -- a mandatory clean predicted-gate PASS is the ONLY
8
+ // path to `"handoff"`; an ambiguous or errored self-review downgrades to abandon, never optimistically
9
+ // hands off.
10
+ // - "disengage SILENTLY on rejection" (the Matplotlib "MJ Rathbun" cautionary tale) -- a rejection signal
11
+ // wins over EVERYTHING else, including a self-review that would otherwise pass. Continuing to submit to a
12
+ // repo that has already shown it does not want automated contributions is the exact anti-pattern this
13
+ // guards against, regardless of how good any individual attempt looks.
14
+ // - "reward MERGED net-positive (never submission volume)" -- the no-progress detector and the iteration/cost
15
+ // ceilings exist so a stuck loop stops wasting turns (or spend) chasing a submission that was never going
16
+ // to land, rather than grinding toward *a* submission for its own sake.
17
+ //
18
+ // AUTONOMY DIAL (not yet wired): `src/settings/autonomy.ts`'s `resolveAutonomy`/`isActingAutonomyLevel` is the
19
+ // existing reusable deny-by-default pattern this policy's eventual live autonomy-level check should consult
20
+ // once `.gittensory-miner.yml` defines autonomy fields -- that wiring is explicitly left to a later phase; this
21
+ // module's `decideNextAction` is autonomy-level-agnostic today.
22
+ /** Derive the policy-relevant {@link SelfReviewOutcome} from a real, successfully computed
23
+ * {@link SelfReviewVerdict}. Only ever returns `"pass"` or `"fail"` -- see that variant's own doc comment for
24
+ * why `"ambiguous"` is constructed elsewhere. */
25
+ export function deriveSelfReviewOutcome(verdict) {
26
+ if (verdict.passesPredictedGate)
27
+ return { kind: "pass" };
28
+ return { kind: "fail", blockerCodes: verdict.predictedGateVerdict.blockers.map((blocker) => blocker.code) };
29
+ }
30
+ function blockerSetsEqual(current, previous) {
31
+ if (current.length !== previous.length)
32
+ return false;
33
+ const currentSet = new Set(current);
34
+ const previousSet = new Set(previous);
35
+ if (currentSet.size !== previousSet.size)
36
+ return false;
37
+ for (const code of currentSet)
38
+ if (!previousSet.has(code))
39
+ return false;
40
+ return true;
41
+ }
42
+ /**
43
+ * Decide the next action for one iteration. Pure; identical inputs always yield the identical decision.
44
+ *
45
+ * Precedence (each check short-circuits the ones below it):
46
+ * 1. `rejectionSignaled` -- ALWAYS abandons, even over an otherwise-passing self-review (disengage silently).
47
+ * 2. `selfReview.kind === "ambiguous"` -- abandons; never optimistically continues or hands off on ambiguity.
48
+ * 3. `selfReview.kind === "pass"` -- the ONLY path to `"handoff"`.
49
+ * 4. `iterationNumber >= maxIterations` -- abandons at the hard ceiling regardless of whether the blocker set
50
+ * was still changing (genuine incremental progress does not buy unlimited iterations).
51
+ * 5. `costCeilingReached` -- abandons at the hard cost ceiling, same rationale as the iteration ceiling above.
52
+ * 6. The current `fail` blocker set is identical to `previousBlockerCodes` -- abandons (no progress, stop
53
+ * wasting turns).
54
+ * 7. Otherwise -- continue.
55
+ */
56
+ export function decideNextActionWithReason(state) {
57
+ if (state.rejectionSignaled) {
58
+ return { action: "abandon", abandonReason: "rejection_signaled", reason: "Repo or contributor has signaled it does not want automated contributions; disengaging silently rather than retry-hammering." };
59
+ }
60
+ if (state.selfReview.kind === "ambiguous") {
61
+ return {
62
+ action: "abandon",
63
+ abandonReason: "self_review_ambiguous",
64
+ reason: `Self-review could not conclusively determine pass/fail${state.selfReview.reason ? `: ${state.selfReview.reason}` : "."} Downgrading to abandon rather than optimistically handing off.`,
65
+ };
66
+ }
67
+ if (state.selfReview.kind === "pass") {
68
+ return { action: "handoff", reason: "Self-review reached a clean predicted-gate pass." };
69
+ }
70
+ if (state.iterationNumber >= state.maxIterations) {
71
+ return {
72
+ action: "abandon",
73
+ abandonReason: "max_iterations_reached",
74
+ reason: `Reached the iteration ceiling (${state.maxIterations}) without a clean predicted-gate pass.`,
75
+ };
76
+ }
77
+ if (state.costCeilingReached === true) {
78
+ return {
79
+ action: "abandon",
80
+ abandonReason: "cost_ceiling_reached",
81
+ reason: "Reached the attempt's cost ceiling (cumulative driver spend across every iteration so far) without a clean predicted-gate pass.",
82
+ };
83
+ }
84
+ if (state.previousBlockerCodes !== null && blockerSetsEqual(state.selfReview.blockerCodes, state.previousBlockerCodes)) {
85
+ return {
86
+ action: "abandon",
87
+ abandonReason: "no_progress",
88
+ reason: `Blocker set unchanged from the prior iteration (${state.selfReview.blockerCodes.join(", ") || "no blockers listed"}); stopping rather than repeating an attempt that is not converging.`,
89
+ };
90
+ }
91
+ return { action: "continue", reason: "Self-review still failing but the blocker set changed since the prior iteration; continuing." };
92
+ }
93
+ /** The bare `decideNextAction(state) -> "continue" | "handoff" | "abandon"` signature this issue's deliverable
94
+ * calls for. For the WHY behind a decision (the attempt-log needs a reason string, not just the action), use
95
+ * {@link decideNextActionWithReason} -- this is a thin projection over the same logic. */
96
+ export function decideNextAction(state) {
97
+ return decideNextActionWithReason(state).action;
98
+ }
@@ -0,0 +1,50 @@
1
+ import type { CodingAgentDriverResult } from "./coding-agent-driver.js";
2
+ /** Which existing check governs a changed file. `root` covers `src/**` and any non-`.js` file under
3
+ * `packages/gittensory-miner`/`packages/gittensory-mcp` (e.g. a hand-written `.d.ts`), since those are
4
+ * type-checked by the root `tsc --noEmit`, not `node --check`. */
5
+ export type LintGuardPackage = "ui" | "engine" | "miner-js" | "mcp-js" | "root";
6
+ /** Classify a changed file path (POSIX or Windows separators) into the package whose existing check governs
7
+ * it. Pure path matching -- no filesystem access. */
8
+ export declare function classifyLintGuardPackage(path: string): LintGuardPackage;
9
+ /** Injected process runner -- real IO lives here, not in `guardChangedFiles`, so tests never spawn a real
10
+ * subprocess. `ok` is derived from the exit code by the caller of this function, not by this type. */
11
+ export type LintGuardSpawnFn = (cmd: string, args: readonly string[], opts: {
12
+ cwd: string;
13
+ }) => Promise<{
14
+ code: number | null;
15
+ output: string;
16
+ }>;
17
+ export type LintGuardCheckResult = {
18
+ package: LintGuardPackage;
19
+ file: string;
20
+ command: string;
21
+ ok: boolean;
22
+ output: string;
23
+ };
24
+ /** Structured result -- never a thrown exception -- so a caller (the self-review loop, #2333) can
25
+ * distinguish "the edit doesn't typecheck" (a `checks` entry with `ok: false`) from "the coding agent
26
+ * itself failed" (a separate concern entirely, see {@link guardCodingAgentDriverResult}). */
27
+ export type LintGuardResult = {
28
+ ok: boolean;
29
+ checks: readonly LintGuardCheckResult[];
30
+ };
31
+ export type LintGuardOptions = {
32
+ spawn: LintGuardSpawnFn;
33
+ /** Repo root the checks run from. Default: `process.cwd()`. */
34
+ cwd?: string | undefined;
35
+ };
36
+ /**
37
+ * Run the existing check for every package a changed file belongs to. One check per package group, not one
38
+ * per file (except `node --check`, which is inherently per-file) -- `tsc`/`ui:typecheck` validate a whole
39
+ * package at once, so re-running them per file would be redundant work, not extra coverage.
40
+ */
41
+ export declare function guardChangedFiles(changedFiles: readonly string[], options: LintGuardOptions): Promise<LintGuardResult>;
42
+ export type LintGuardedDriverResult = CodingAgentDriverResult & {
43
+ lintGuard: LintGuardResult;
44
+ };
45
+ /**
46
+ * Decorate a `CodingAgentDriver` result with its lint-guard verdict. Skips the guard entirely (an empty,
47
+ * passing `lintGuard`) when the driver itself failed or reported no changed files -- there is nothing to
48
+ * check, and running checks against an untouched tree would only produce a misleading unrelated result.
49
+ */
50
+ export declare function guardCodingAgentDriverResult(result: CodingAgentDriverResult, options: LintGuardOptions): Promise<LintGuardedDriverResult>;
@@ -0,0 +1,68 @@
1
+ const MINER_JS_EXTENSION = /\.(js|mjs|cjs)$/;
2
+ /** Classify a changed file path (POSIX or Windows separators) into the package whose existing check governs
3
+ * it. Pure path matching -- no filesystem access. */
4
+ export function classifyLintGuardPackage(path) {
5
+ const normalized = path.replace(/\\/g, "/").replace(/^\.\//, "");
6
+ if (normalized.startsWith("apps/gittensory-ui/"))
7
+ return "ui";
8
+ if (normalized.startsWith("packages/gittensory-engine/"))
9
+ return "engine";
10
+ if (normalized.startsWith("packages/gittensory-miner/") && MINER_JS_EXTENSION.test(normalized))
11
+ return "miner-js";
12
+ if (normalized.startsWith("packages/gittensory-mcp/") && MINER_JS_EXTENSION.test(normalized))
13
+ return "mcp-js";
14
+ return "root";
15
+ }
16
+ const PACKAGE_COMMAND = Object.freeze({
17
+ root: Object.freeze(["npm", "run", "typecheck"]),
18
+ engine: Object.freeze(["npm", "run", "build", "--workspace", "@loopover/engine"]),
19
+ ui: Object.freeze(["npm", "run", "ui:typecheck"]),
20
+ });
21
+ async function runPackageCheck(pkg, files, spawn, cwd) {
22
+ if (pkg === "miner-js" || pkg === "mcp-js") {
23
+ // node --check is inherently per-file, unlike the whole-package tsc/ui:lint commands below.
24
+ const results = [];
25
+ for (const file of files) {
26
+ const { code, output } = await spawn("node", ["--check", file], { cwd });
27
+ results.push({ package: pkg, file, command: `node --check ${file}`, ok: code === 0, output });
28
+ }
29
+ return results;
30
+ }
31
+ const command = PACKAGE_COMMAND[pkg];
32
+ const { code, output } = await spawn(command[0], command.slice(1), { cwd });
33
+ return [{ package: pkg, file: files.join(", "), command: command.join(" "), ok: code === 0, output }];
34
+ }
35
+ /**
36
+ * Run the existing check for every package a changed file belongs to. One check per package group, not one
37
+ * per file (except `node --check`, which is inherently per-file) -- `tsc`/`ui:typecheck` validate a whole
38
+ * package at once, so re-running them per file would be redundant work, not extra coverage.
39
+ */
40
+ export async function guardChangedFiles(changedFiles, options) {
41
+ const cwd = options.cwd ?? process.cwd();
42
+ const byPackage = new Map();
43
+ for (const file of changedFiles) {
44
+ const pkg = classifyLintGuardPackage(file);
45
+ const list = byPackage.get(pkg);
46
+ if (list)
47
+ list.push(file);
48
+ else
49
+ byPackage.set(pkg, [file]);
50
+ }
51
+ const checks = [];
52
+ for (const [pkg, files] of byPackage) {
53
+ checks.push(...(await runPackageCheck(pkg, files, options.spawn, cwd)));
54
+ }
55
+ return { ok: checks.every((check) => check.ok), checks };
56
+ }
57
+ /**
58
+ * Decorate a `CodingAgentDriver` result with its lint-guard verdict. Skips the guard entirely (an empty,
59
+ * passing `lintGuard`) when the driver itself failed or reported no changed files -- there is nothing to
60
+ * check, and running checks against an untouched tree would only produce a misleading unrelated result.
61
+ */
62
+ export async function guardCodingAgentDriverResult(result, options) {
63
+ if (!result.ok || result.changedFiles.length === 0) {
64
+ return { ...result, lintGuard: { ok: true, checks: [] } };
65
+ }
66
+ const lintGuard = await guardChangedFiles(result.changedFiles, options);
67
+ return { ...result, ok: result.ok && lintGuard.ok, lintGuard };
68
+ }