@codedology/code-forge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +21 -0
  3. package/README.md +127 -0
  4. package/bin/code-forge.mjs +145 -0
  5. package/docs/README.md +37 -0
  6. package/docs/reference/blocks.json +288 -0
  7. package/docs/reference/config.md +162 -0
  8. package/examples/first-use/README.md +11 -0
  9. package/examples/first-use/clamp.acceptance.yml +8 -0
  10. package/examples/first-use/clamp.brief.md +16 -0
  11. package/examples/first-use/coder/src/math.mjs +31 -0
  12. package/examples/first-use/coder/test/clamp.test.mjs +26 -0
  13. package/examples/node-lib/.env.example +3 -0
  14. package/examples/node-lib/.gitattributes +3 -0
  15. package/examples/node-lib/.github/workflows/test.yml +11 -0
  16. package/examples/node-lib/CHANGELOG.md +13 -0
  17. package/examples/node-lib/README.md +17 -0
  18. package/examples/node-lib/gitignore +3 -0
  19. package/examples/node-lib/package.json +19 -0
  20. package/examples/node-lib/src/list.mjs +23 -0
  21. package/examples/node-lib/src/math.mjs +21 -0
  22. package/examples/node-lib/src/strings.mjs +34 -0
  23. package/examples/node-lib/test/list.test.mjs +12 -0
  24. package/examples/node-lib/test/math.test.mjs +13 -0
  25. package/examples/node-lib/test/package.test.mjs +18 -0
  26. package/examples/node-lib/test/strings.test.mjs +28 -0
  27. package/package.json +52 -0
  28. package/schema/code-forge.schema.json +331 -0
  29. package/schema/facts.schema.json +28 -0
  30. package/scripts/first-use.mjs +517 -0
  31. package/scripts/gen-config-doc.mjs +241 -0
  32. package/skill/SKILL.md +79 -0
  33. package/skill/references/adapters/claude-code.md +19 -0
  34. package/skill/references/adapters/solo.md +34 -0
  35. package/skill/references/adapters/subprocess.md +15 -0
  36. package/skill/references/code.md +64 -0
  37. package/skill/references/continuity.md +31 -0
  38. package/skill/references/decisions.md +50 -0
  39. package/skill/references/degraded.md +40 -0
  40. package/skill/references/harden.md +26 -0
  41. package/skill/references/ledger.md +23 -0
  42. package/skill/references/plan.md +43 -0
  43. package/skill/references/proof.md +34 -0
  44. package/skill/references/review.md +42 -0
  45. package/skill/references/rules-core.md +72 -0
  46. package/skill/references/security.md +29 -0
  47. package/skill/scripts/forge +40 -0
  48. package/skill/templates/block-record.md +19 -0
  49. package/skill/templates/facts-sheet.md +22 -0
  50. package/skill/templates/plan.md +53 -0
  51. package/src/cli/author.mjs +87 -0
  52. package/src/cli/block.mjs +262 -0
  53. package/src/cli/doctor.mjs +74 -0
  54. package/src/cli/facts.mjs +124 -0
  55. package/src/cli/gates.mjs +280 -0
  56. package/src/cli/help.mjs +48 -0
  57. package/src/cli/init.mjs +14 -0
  58. package/src/cli/jev.mjs +175 -0
  59. package/src/cli/keys.mjs +170 -0
  60. package/src/cli/ledger.mjs +160 -0
  61. package/src/cli/list.mjs +68 -0
  62. package/src/cli/models.mjs +131 -0
  63. package/src/cli/plan.mjs +70 -0
  64. package/src/cli/proof.mjs +316 -0
  65. package/src/cli/remove.mjs +76 -0
  66. package/src/cli/report.mjs +73 -0
  67. package/src/cli/resolve.mjs +79 -0
  68. package/src/cli/review-file.mjs +152 -0
  69. package/src/cli/run.mjs +159 -0
  70. package/src/cli/s2.mjs +93 -0
  71. package/src/cli/spawn.mjs +104 -0
  72. package/src/cli/upgrade.mjs +103 -0
  73. package/src/cli/validate.mjs +108 -0
  74. package/src/cli/version.mjs +18 -0
  75. package/src/cli/worker.mjs +76 -0
  76. package/src/config/defaults/anthropic.mjs +18 -0
  77. package/src/config/defaults/index.mjs +24 -0
  78. package/src/config/defaults/openai.mjs +16 -0
  79. package/src/config/defaults/xai.mjs +18 -0
  80. package/src/config/known-ids.mjs +171 -0
  81. package/src/config/load.mjs +135 -0
  82. package/src/config/migrate.mjs +46 -0
  83. package/src/config/refresh.mjs +276 -0
  84. package/src/config/schema-compile.mjs +211 -0
  85. package/src/config/schema-paths.mjs +84 -0
  86. package/src/config/secret-patterns.mjs +85 -0
  87. package/src/config/validate.mjs +694 -0
  88. package/src/decide/author-loop.mjs +90 -0
  89. package/src/decide/escalation.mjs +262 -0
  90. package/src/decide/fallback-rules.mjs +172 -0
  91. package/src/decide/jev-client.mjs +216 -0
  92. package/src/decide/questions.mjs +161 -0
  93. package/src/decide/thresholds.mjs +199 -0
  94. package/src/doctor/index.mjs +105 -0
  95. package/src/doctor/local.mjs +162 -0
  96. package/src/doctor/probes.mjs +358 -0
  97. package/src/doctor/rows.mjs +51 -0
  98. package/src/doctor/worker-probe.mjs +125 -0
  99. package/src/engines/alias-map.mjs +78 -0
  100. package/src/engines/builders/claude.mjs +224 -0
  101. package/src/engines/builders/codex.mjs +242 -0
  102. package/src/engines/builders/grok.mjs +132 -0
  103. package/src/engines/builders/index.mjs +35 -0
  104. package/src/engines/builders/validate-params.mjs +34 -0
  105. package/src/engines/codex-home.mjs +230 -0
  106. package/src/engines/isolation.mjs +84 -0
  107. package/src/engines/probe.mjs +116 -0
  108. package/src/engines/provider-cli.mjs +24 -0
  109. package/src/engines/render-rules.mjs +41 -0
  110. package/src/engines/sentinel.mjs +133 -0
  111. package/src/engines/usage-parse.mjs +149 -0
  112. package/src/gates/acceptance.mjs +95 -0
  113. package/src/gates/detect.mjs +187 -0
  114. package/src/gates/invariants.mjs +43 -0
  115. package/src/gates/run.mjs +72 -0
  116. package/src/gates/safe-edit.mjs +266 -0
  117. package/src/gates/scope.mjs +154 -0
  118. package/src/gates/secret-scan.mjs +79 -0
  119. package/src/gates/transcript-grep.mjs +27 -0
  120. package/src/install/agent-env.mjs +76 -0
  121. package/src/install/detect.mjs +121 -0
  122. package/src/install/harnesses.mjs +131 -0
  123. package/src/install/link.mjs +463 -0
  124. package/src/install/wizard/answers.mjs +389 -0
  125. package/src/install/wizard/flags.mjs +248 -0
  126. package/src/install/wizard/project-file.mjs +128 -0
  127. package/src/install/wizard/run.mjs +366 -0
  128. package/src/install/wizard/steps.mjs +201 -0
  129. package/src/keys/backends/constants.mjs +7 -0
  130. package/src/keys/backends/file.mjs +99 -0
  131. package/src/keys/backends/keychain.mjs +81 -0
  132. package/src/keys/backends/security-cli.mjs +105 -0
  133. package/src/keys/onepassword.mjs +46 -0
  134. package/src/keys/store.mjs +347 -0
  135. package/src/ledger/calibration.mjs +109 -0
  136. package/src/ledger/export.mjs +52 -0
  137. package/src/ledger/outcome.mjs +397 -0
  138. package/src/ledger/paths.mjs +30 -0
  139. package/src/ledger/prices.mjs +49 -0
  140. package/src/ledger/report.mjs +250 -0
  141. package/src/ledger/write.mjs +279 -0
  142. package/src/proof/export.mjs +358 -0
  143. package/src/proof/lock.mjs +138 -0
  144. package/src/proof/red-green.mjs +519 -0
  145. package/src/proof/red-parse.mjs +103 -0
  146. package/src/proof/tiers.mjs +62 -0
  147. package/src/review/budget.mjs +241 -0
  148. package/src/review/context.mjs +156 -0
  149. package/src/review/engine.mjs +264 -0
  150. package/src/review/finding.schema.json +47 -0
  151. package/src/review/fixloop.mjs +467 -0
  152. package/src/review/gate-check.mjs +249 -0
  153. package/src/review/lenses/A.md +22 -0
  154. package/src/review/lenses/B.md +23 -0
  155. package/src/review/lenses/full.md +23 -0
  156. package/src/review/lenses/judge.md +21 -0
  157. package/src/review/lenses/quick.md +21 -0
  158. package/src/review/lenses/recheck.md +23 -0
  159. package/src/review/packet.mjs +355 -0
  160. package/src/review/triage.mjs +149 -0
  161. package/src/review/validate-review.mjs +70 -0
  162. package/src/session/author.mjs +228 -0
  163. package/src/session/facts.mjs +855 -0
  164. package/src/session/plan-check.mjs +294 -0
  165. package/src/session/s2.mjs +164 -0
  166. package/src/session/spawn.mjs +610 -0
  167. package/src/state/block.mjs +289 -0
  168. package/src/state/cli-args.mjs +67 -0
  169. package/src/state/paths.mjs +92 -0
  170. package/src/state/registry.mjs +189 -0
  171. package/src/state/run.mjs +266 -0
  172. package/src/state/signer.mjs +132 -0
  173. package/src/util/exec.mjs +315 -0
  174. package/src/util/forbidden.mjs +787 -0
  175. package/src/util/git.mjs +381 -0
  176. package/src/util/log.mjs +100 -0
  177. package/src/util/reaper.mjs +300 -0
  178. package/src/util/redact.mjs +212 -0
  179. package/src/util/tmp.mjs +409 -0
  180. package/src/worker/engine.mjs +338 -0
  181. package/src/worker/loop.mjs +392 -0
  182. package/src/worker/queue.mjs +186 -0
  183. package/src/worker/review-state.mjs +145 -0
  184. package/src/worker/ticket.mjs +195 -0
@@ -0,0 +1,467 @@
1
+ /**
2
+ * The fix loop — the §4.11 convergence rule, per file, per block (plan §4.2 step 5, §4.11, §3.6
3
+ * rules 2b/2/6, R1; block B12b).
4
+ *
5
+ * 1. Round 1 is the tiered review of §4.2 (`deps.review`, the B12a engine) triaged by
6
+ * `triage.mjs`; its `fix_now` set is the open set.
7
+ * 2. Round n ≥ 2 reviews ONLY the fix hunks: the diff between the content reviewed in round n−1
8
+ * and the current content (`fixHunkDiff`), in the hunk form (`contextMode: 'recheck'`,
9
+ * ± `hunk_context_lines`), with the open findings listed by id and the `recheck` lens
10
+ * (`review.recheck_scope: file` re-sends the whole packet against the block base instead).
11
+ * Per open finding S1 `resolved` ≥ 0.90 closes it; when any stays open, ONE fresh L2 recheck
12
+ * session decides. A new finding inside a fix hunk is triaged and may join the open set; one
13
+ * OUTSIDE every fix hunk is a `late_finding` (ledger `review.late_finding`), never blocks the
14
+ * round (`review.late_findings: sweep`, default; `block` makes it an open finding instead).
15
+ * 3. Strictly shrinking: open(n) ≥ open(n−1) ⇒ `trigger: review_stall` ⇒ +1 level now (rule 2b).
16
+ * 4. Ladder: `escalation.review_rounds_per_level` (2) exhausted with an open finding ⇒ +1 level
17
+ * (rule 2); the next rounds are coded at that level by a fresh session (`deps.fix`).
18
+ * 5. Cap: `review.max_rounds_per_file` (4) reached with an open finding ⇒ the L3 patch rung
19
+ * (rule 6; once per block — shared with rule 5's ceiling), whose `patch_check` round is
20
+ * OUTSIDE the cap. The rung is also rule 5: rule 2/2b at the running ceiling (L2 — L3 is never
21
+ * a coding level, R1) takes it, so for an L2 block the rung IS the +1 at round 2. After the
22
+ * patch the block continues at L2 (§3.6, Fable ruling A): `l3_rung_used = true`,
23
+ * `rounds_at_level = 0`, and the `patch_check` goes through `decideNext` like any recheck —
24
+ * clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted`
25
+ * (`trigger: review_stall`); round ≥ cap ⇒ `stopped: review_cap`; else `fix` at L2 with
26
+ * `l3_patch: true` in the brief. A later stall ⇒ `l3_patch_exhausted`; round 4 still open ⇒
27
+ * `review_cap` (the rung is spent). Either way the ledger `review.cap` row lists the open
28
+ * ids — the human decides (fix by hand, `block waive`, or re-decompose).
29
+ * 6. Ledger per round: `review.round {file, round, level, kind, open_before, closed,
30
+ * new_in_hunks, late, open_after, tokens_in, tokens_out}`; a converged file gets
31
+ * `review.approved {file, content_hash, round}` (the worker signs every row it writes).
32
+ *
33
+ * Over budget (B12a follow-up, decided here): a recheck packet over `review.budgets.full_in`
34
+ * falls to `contextMode: 'minimal'` (± `min_context_lines` around the SAME fix hunks) exactly as
35
+ * `assemblePacket` does for any packet. The budget is a hard ceiling on what one session reads;
36
+ * `minimal` is still fix hunks only, and the recheck's DEPTH (one session, the recheck lens) is
37
+ * never degraded (§4.10). A packet that is still not `ok` (`split_required`: the fix hunks alone,
38
+ * with no context at all, exceed the budget) is deterministic — re-running the round would build
39
+ * the same packet — so it is TERMINAL, never a retry: `stopped`, `next: {action: 'stop', reason:
40
+ * <packet.status>}`, and the gate-relevant `review.cap` row (the gate then refuses the file's open
41
+ * findings until a human waives them or the file is reviewed again).
42
+ *
43
+ * A session that fails the stub guard is `unavailable`, and a failed write of a gate-relevant row
44
+ * (`review.late_finding`, `review.round`, `review.cap`) is `ledger_write_failed`: either way the
45
+ * round is not counted, nothing closes, and `next.action: 'retry'` with `pending_kind` set —
46
+ * `converge()` re-runs that round on its next call.
47
+ */
48
+
49
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
50
+ import { randomBytes } from 'node:crypto';
51
+ import path from 'node:path';
52
+ import { escalationAfterAttempt, reviewRoundStalled } from '../decide/escalation.mjs';
53
+ import { exec } from '../util/exec.mjs';
54
+ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
55
+ import { parseDiff } from './context.mjs';
56
+ import { assemblePacket, assertPacketPath, readFileDiff } from './packet.mjs';
57
+ import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
58
+ import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
59
+
60
+ /** @typedef {import('./triage.mjs').Finding} Finding */
61
+ /** @typedef {import('./context.mjs').Hunk} Hunk */
62
+
63
+ /**
64
+ * @typedef {object} NextStep
65
+ * @property {'fix' | 'patch' | 'complete' | 'stop' | 'retry'} action
66
+ * @property {string} [level] - the level the next fix is coded at.
67
+ * @property {string | null} [trigger] - the escalation trigger that moved the level, if any.
68
+ * @property {string} [reason] - `review_cap`, `l3_patch_exhausted`, a non-ok packet status (`split_required`), … for `stop`; the stub-guard reason for `retry`.
69
+ */
70
+
71
+ /**
72
+ * @typedef {object} FileState - JSON-serialisable; the caller keeps it between `review-file` calls.
73
+ * @property {string} file
74
+ * @property {string} level - the level the file's fixes are coded at now.
75
+ * @property {number} round - rounds counted so far (patch_check rounds excluded).
76
+ * @property {number} rounds_at_level
77
+ * @property {Finding[]} open - the open `fix_now` set.
78
+ * @property {Finding[]} late
79
+ * @property {string | null} reviewed_content - the content the last counted round reviewed.
80
+ * @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
81
+ * @property {'open' | 'complete' | 'stopped'} status
82
+ * @property {NextStep | null} next
83
+ * @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck packet's context.
84
+ * @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
85
+ */
86
+
87
+ /**
88
+ * @typedef {object} LoopDeps
89
+ * @property {string} repoRoot - realpath'd.
90
+ * @property {Record<string, any>} [cfg]
91
+ * @property {string} workDir - packet and diff scratch (under the run's temp root).
92
+ * @property {() => Promise<Record<string, any>>} review - round 1: the B12a engine (`reviewFile`).
93
+ * @property {(opts: Record<string, any>) => Promise<Record<string, any>>} [spawn] - one isolated session.
94
+ * @property {import('./triage.mjs').JevAsk} [jev]
95
+ * @property {import('./triage.mjs').RuleFn} [rule]
96
+ * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow]
97
+ * @property {(req: {file: string, round: number, level: string, open: Finding[], l3_patch: boolean}) => Promise<unknown>} [fix] - a fresh coder session at `level`; `l3_patch` = the block's L3 patch is in the tree (§3.6).
98
+ * @property {(req: {file: string, level: 'L3', open: Finding[]}) => Promise<unknown>} [patch] - the L3 patch rung (≤ 80 lines, owned files only).
99
+ * @property {string | null} [base] - the block base (only for `recheck_scope: file`).
100
+ * @property {string} [rulesDigest] @property {string} [factsExcerpt]
101
+ */
102
+
103
+ /** @param {LoopDeps} deps @param {Record<string, any>} row */
104
+ async function note(deps, row) {
105
+ try {
106
+ await deps.writeRow?.(row);
107
+ } catch {
108
+ // the loop's verdict stands; the ledger row is best-effort
109
+ }
110
+ }
111
+
112
+ /** @param {Record<string, any> | undefined} cfg */
113
+ function settings(cfg) {
114
+ const r = cfg?.review ?? {};
115
+ const e = cfg?.escalation ?? {};
116
+ const int = (/** @type {unknown} */ v, /** @type {number} */ d) => (Number.isInteger(v) && /** @type {number} */ (v) >= 0 ? /** @type {number} */ (v) : d);
117
+ return {
118
+ maxRounds: int(r.max_rounds_per_file, 4),
119
+ scope: r.recheck_scope === 'file' ? 'file' : 'fix_hunks',
120
+ late: r.late_findings === 'block' ? 'block' : 'sweep',
121
+ perLevel: int(e.review_rounds_per_level, 2),
122
+ stopAt: typeof e.stop_at === 'string' ? e.stop_at : 'L3',
123
+ l3Mode: e.l3_mode === 'code' ? 'code' : 'patch',
124
+ };
125
+ }
126
+
127
+ /**
128
+ * @param {{file: string, level: string, l3RungUsed?: boolean}} opts
129
+ * @returns {FileState}
130
+ */
131
+ export function newFileState({ file, level, l3RungUsed = false }) {
132
+ return { file, level, round: 0, rounds_at_level: 0, open: [], late: [], reviewed_content: null, l3_rung_used: l3RungUsed, status: 'open', next: null, last_packet: null, pending_kind: null };
133
+ }
134
+
135
+ /** @param {string} repoRoot @param {string} file @returns {{rel: string, content: string, hash: string}} */
136
+ function readCurrent(repoRoot, file) {
137
+ const rel = assertPacketPath(repoRoot, file);
138
+ const hash = contentHash(repoRoot, rel);
139
+ let content = '';
140
+ try {
141
+ content = readFileSync(path.join(repoRoot, rel), 'utf8');
142
+ } catch {
143
+ content = ''; // deleted: the fix diff is all `-` lines
144
+ }
145
+ return { rel, content, hash };
146
+ }
147
+
148
+ /**
149
+ * The fix hunks: `git diff --no-index` between the content reviewed last round and the current
150
+ * content, rewritten to name `file` on both sides. Unchanged content ⇒ no hunks.
151
+ * @param {{file: string, previous: string, current: string, workDir: string}} opts
152
+ * @returns {Promise<import('./packet.mjs').FileDiff>}
153
+ */
154
+ export async function fixHunkDiff({ file, previous, current, workDir }) {
155
+ mkdirSync(workDir, { recursive: true, mode: 0o700 });
156
+ const tag = randomBytes(6).toString('hex');
157
+ const before = path.join(workDir, `prev-${tag}`);
158
+ const after = path.join(workDir, `cur-${tag}`);
159
+ writeFileSync(before, previous, { mode: 0o600 });
160
+ writeFileSync(after, current, { mode: 0o600 });
161
+ try {
162
+ const res = await exec(['git', 'diff', '--no-index', '--no-color', '--no-ext-diff', '--no-textconv', '-U3', '--', before, after], {
163
+ cwd: workDir,
164
+ env: gitChildEnv(),
165
+ timeoutMs: 30000,
166
+ okExitCodes: [0, 1],
167
+ });
168
+ if (res.result !== 'ok') throw new Error(`git diff --no-index failed (exit ${res.code})`);
169
+ const at = res.stdout.indexOf('\n@@');
170
+ const body = res.stdout.startsWith('@@') ? res.stdout : at >= 0 ? res.stdout.slice(at + 1) : '';
171
+ const diffText = body.length > 0 ? `--- a/${file}\n+++ b/${file}\n${body}` : '';
172
+ return { file, kind: 'tracked', diffText, content: current, ...parseDiff(diffText) };
173
+ } finally {
174
+ rmSync(before, { force: true });
175
+ rmSync(after, { force: true });
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The recheck packet: the `recheck` lens on the fix hunks (hunk form, never the whole file), then
181
+ * the open findings by id. Over budget ⇒ `minimal` (see the module doc).
182
+ * @param {{diff: import('./packet.mjs').FileDiff, open: Finding[], cfg?: Record<string, any>, contextMode?: 'auto' | 'recheck', rulesDigest?: string, factsExcerpt?: string}} opts
183
+ */
184
+ export function buildRecheckPacket({ diff, open, cfg, contextMode = 'recheck', rulesDigest, factsExcerpt }) {
185
+ const packet = assemblePacket({ diff, lens: 'recheck', cfg, contextMode, rulesDigest, factsExcerpt });
186
+ if (packet.status !== 'ok') return packet;
187
+ const listed = open.map((f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`);
188
+ const text = `${packet.text}## open findings\n${listed.length > 0 ? listed.join('\n') : '(none)'}\n`;
189
+ return { ...packet, text, tokensIn: packet.tokensIn + Math.ceil(Buffer.byteLength(text.slice(packet.text.length)) / 4) };
190
+ }
191
+
192
+ /** @param {Finding} f @param {ReadonlyArray<Hunk>} hunks @returns {boolean} */
193
+ export function insideHunks(f, hunks) {
194
+ return hunks.some((h) => {
195
+ const last = h.newStart + Math.max(h.newLines, 1) - 1;
196
+ return f.line_start >= h.newStart && f.line_end <= last && f.line_start <= f.line_end;
197
+ });
198
+ }
199
+
200
+ /**
201
+ * One recheck session through the stub guard.
202
+ * @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
203
+ * @param {LoopDeps} deps
204
+ */
205
+ async function recheckSession(packet, deps) {
206
+ mkdirSync(deps.workDir, { recursive: true, mode: 0o700 });
207
+ const promptPath = path.join(deps.workDir, `recheck-${randomBytes(6).toString('hex')}.md`);
208
+ writeFileSync(promptPath, packet.text, { mode: 0o600 });
209
+ /** @type {Record<string, any> | null} */
210
+ let res = null;
211
+ try {
212
+ res = deps.spawn
213
+ ? await deps.spawn({ level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn } })
214
+ : null;
215
+ } catch {
216
+ res = null;
217
+ } finally {
218
+ rmSync(promptPath, { force: true });
219
+ }
220
+ const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: minTokensOut(deps.cfg) });
221
+ return { verdict, tokens_in: res?.usage?.tokens_in ?? null, tokens_out: res?.usage?.tokens_out ?? null };
222
+ }
223
+
224
+ /**
225
+ * After a counted round with open findings: cap first (rule 6), else stall / ladder (2b, 2).
226
+ * @param {FileState} state @param {number} openBefore @param {ReturnType<typeof settings>} s
227
+ * @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check` compares against the open
228
+ * set before the patch (the rung's own shrink test), whatever the round number.
229
+ * @returns {NextStep}
230
+ */
231
+ function decideNext(state, openBefore, s, kind) {
232
+ if (state.open.length === 0) return { action: 'complete' };
233
+ if (kind === 'patch_check') {
234
+ // the block's one rung is spent: a patch that did not shrink the set stops FIRST — even when
235
+ // the rung was taken at the cap — then the cap; else the block continues at L2 below
236
+ if (reviewRoundStalled(openBefore, state.open.length)) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
237
+ if (state.round >= s.maxRounds) return { action: 'stop', reason: 'review_cap', trigger: 'review_cap' };
238
+ }
239
+ if (state.round >= s.maxRounds) {
240
+ const esc = escalationAfterAttempt({ currentLevel: state.level, roundsAtFile: state.round, maxRoundsPerFile: s.maxRounds, openFixNowFinding: true, l3RungAlreadyUsed: state.l3_rung_used });
241
+ return esc.action === 'l3_rung' ? { action: 'patch', level: 'L3', trigger: 'review_cap' } : { action: 'stop', reason: 'review_cap', trigger: 'review_cap' };
242
+ }
243
+ const esc = escalationAfterAttempt({
244
+ currentLevel: state.level,
245
+ reviewStall: (kind === 'patch_check' || state.round >= 2) && reviewRoundStalled(openBefore, state.open.length),
246
+ openFixNowFinding: true,
247
+ reviewRoundsAtLevel: state.rounds_at_level,
248
+ reviewRoundsPerLevel: s.perLevel,
249
+ stopAt: s.stopAt,
250
+ l3Mode: /** @type {'patch' | 'code'} */ (s.l3Mode),
251
+ l3RungAlreadyUsed: state.l3_rung_used,
252
+ });
253
+ if (esc.action === 'escalate' && esc.level) {
254
+ if (esc.level !== state.level) state.rounds_at_level = 0;
255
+ state.level = esc.level;
256
+ return { action: 'fix', level: state.level, trigger: esc.trigger };
257
+ }
258
+ if (esc.action === 'l3_rung') return { action: 'patch', level: 'L3', trigger: esc.trigger };
259
+ if (esc.action === 'stop') return { action: 'stop', reason: esc.reason ?? 'stop', trigger: esc.trigger };
260
+ return { action: 'fix', level: state.level, trigger: null };
261
+ }
262
+
263
+ /**
264
+ * Write a row the gate or the convergence rule depends on (`review.late_finding`, `review.round`,
265
+ * `review.cap`). No writer ⇒ nothing to write; a writer that throws ⇒ false.
266
+ * @param {LoopDeps} deps @param {Record<string, any>} row @returns {Promise<boolean>}
267
+ */
268
+ async function mustNote(deps, row) {
269
+ if (!deps.writeRow) return true;
270
+ try {
271
+ await deps.writeRow(row);
272
+ return true;
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * @param {FileState} state @param {'full' | 'recheck' | 'patch_check'} kind @param {string} reason
280
+ * @returns {FileState}
281
+ */
282
+ function retry(state, kind, reason) {
283
+ state.next = { action: 'retry', reason };
284
+ state.pending_kind = kind;
285
+ return state;
286
+ }
287
+
288
+ /**
289
+ * A terminal stop that is NOT a counted round (a non-ok recheck packet): the round's rows are
290
+ * not written, the open set stays as it was, and the gate-relevant `review.cap` row goes through
291
+ * the must-write path — a failed write is a `retry`, never a silent stop.
292
+ * @param {FileState} state @param {LoopDeps} deps
293
+ * @param {'full' | 'recheck' | 'patch_check'} kind @param {string} file @param {string} reason
294
+ * @returns {Promise<FileState>}
295
+ */
296
+ async function stopBeforeRound(state, deps, kind, file, reason) {
297
+ const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id) };
298
+ if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
299
+ state.status = 'stopped';
300
+ state.next = { action: 'stop', reason };
301
+ state.pending_kind = null;
302
+ return state;
303
+ }
304
+
305
+ /**
306
+ * Run the file's next round (round 1, a recheck, or the `patch_check` after the L3 patch).
307
+ * The outcome is staged on a copy of `state` and committed only after its gate-relevant rows
308
+ * (`review.late_finding`, `review.round`, `review.cap`) are written; a failed write leaves the
309
+ * state as it was with `next: {action: 'retry', reason: 'ledger_write_failed'}` (the round is
310
+ * re-run, never closed as clean). `state.next` says what the caller does now.
311
+ * @param {FileState} state @param {LoopDeps} deps
312
+ * @param {{kind?: 'full' | 'recheck' | 'patch_check'}} [opts]
313
+ * @returns {Promise<FileState>}
314
+ */
315
+ export async function runRound(state, deps, opts = {}) {
316
+ const s = settings(deps.cfg);
317
+ const kind = opts.kind ?? (state.round === 0 ? 'full' : 'recheck');
318
+ const current = readCurrent(deps.repoRoot, state.file);
319
+ const openBefore = state.open.length;
320
+ /** @type {FileState} */
321
+ const draft = structuredClone({ ...state, next: null, pending_kind: null });
322
+ let closed = 0;
323
+ let newInHunks = 0;
324
+ /** @type {Array<Record<string, any>>} */
325
+ const lateRows = [];
326
+ /** @type {number | null} */
327
+ let tokensIn = null;
328
+ /** @type {number | null} */
329
+ let tokensOut = null;
330
+ /** @type {{context_mode: string, context_lines: number} | null} */
331
+ let packetInfo = null;
332
+
333
+ if (kind === 'full') {
334
+ const result = await deps.review();
335
+ if (result?.status !== 'reviewed') return retry(state, kind, result?.reason ?? result?.status ?? 'unavailable');
336
+ const triaged = await triageFindings({ file: current.rel, findings: result.findings ?? [], fromJudge: cameFromJudge(result), cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
337
+ draft.open = triaged.fix_now;
338
+ newInHunks = triaged.fix_now.length;
339
+ } else {
340
+ const diff =
341
+ s.scope === 'file'
342
+ ? await readFileDiff({ repoRoot: deps.repoRoot, file: current.rel, base: deps.base ?? null })
343
+ : await fixHunkDiff({ file: current.rel, previous: state.reviewed_content ?? '', current: current.content, workDir: deps.workDir });
344
+ if (diff.hunks.length > 0) {
345
+ /** @type {Finding[]} */
346
+ const pending = [];
347
+ for (const finding of state.open) {
348
+ const { band } = await resolvedBand({ finding, fixHunk: diff.diffText, jev: deps.jev, cfg: deps.cfg });
349
+ if (band === 'closed') closed += 1;
350
+ else pending.push(finding);
351
+ }
352
+ draft.open = [];
353
+ if (pending.length > 0) {
354
+ const packet = buildRecheckPacket({ diff, open: pending, cfg: deps.cfg, contextMode: s.scope === 'file' ? 'auto' : 'recheck', rulesDigest: deps.rulesDigest, factsExcerpt: deps.factsExcerpt });
355
+ // deterministic (the same hunks build the same packet): terminal, never a retry
356
+ if (packet.status !== 'ok') return stopBeforeRound(state, deps, kind, current.rel, packet.status);
357
+ packetInfo = { context_mode: packet.contextMode, context_lines: packet.contextLines };
358
+ const session = await recheckSession(packet, deps);
359
+ tokensIn = session.tokens_in;
360
+ tokensOut = session.tokens_out;
361
+ if (!session.verdict.ok) {
362
+ await note(deps, { event: 'review.unavailable', file: current.rel, lens: 'recheck', reason: session.verdict.reason });
363
+ return retry(state, kind, session.verdict.reason ?? 'unavailable');
364
+ }
365
+ const review = /** @type {Record<string, any>} */ (session.verdict.review);
366
+ const resolvedIds = new Set((review.resolved ?? []).filter((/** @type {any} */ r) => r?.resolved === true).map((/** @type {any} */ r) => r.id));
367
+ const stillOpen = pending.filter((f) => !resolvedIds.has(f.id));
368
+ closed += pending.length - stillOpen.length;
369
+ /** @type {Finding[]} */
370
+ const inside = [];
371
+ /** @type {Finding[]} */
372
+ const blocking = [];
373
+ for (const f of /** @type {Finding[]} */ (review.findings ?? [])) {
374
+ if (insideHunks(f, diff.hunks)) {
375
+ inside.push(f);
376
+ continue;
377
+ }
378
+ draft.late.push(f);
379
+ lateRows.push({ event: 'review.late_finding', file: current.rel, round: state.round + 1, finding: f.id, severity: f.severity, line_start: f.line_start, line_end: f.line_end, mode: s.late });
380
+ if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
381
+ }
382
+ const triaged = await triageFindings({ file: current.rel, findings: inside, fromJudge: false, diffText: diff.diffText, cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
383
+ newInHunks = triaged.fix_now.length;
384
+ draft.open = [...stillOpen, ...triaged.fix_now, ...blocking];
385
+ }
386
+ }
387
+ // no fix hunks (the content did not change): nothing closes — the round stalls
388
+ }
389
+
390
+ if (kind !== 'patch_check') {
391
+ draft.round += 1;
392
+ draft.rounds_at_level += 1;
393
+ } else {
394
+ // the block continues at L2 after the rung with a fresh round counter (§3.6) — also here, not
395
+ // only in `converge`, because the worker runs the patch_check from its own ticket
396
+ draft.rounds_at_level = 0;
397
+ }
398
+ draft.reviewed_content = current.content;
399
+ draft.last_packet = packetInfo;
400
+ const roundRow = {
401
+ event: 'review.round',
402
+ file: current.rel,
403
+ round: draft.round,
404
+ level: draft.level,
405
+ kind,
406
+ open_before: openBefore,
407
+ closed,
408
+ new_in_hunks: newInHunks,
409
+ late: lateRows.length,
410
+ open_after: draft.open.length,
411
+ tokens_in: tokensIn,
412
+ tokens_out: tokensOut,
413
+ ...(packetInfo ? { context_mode: packetInfo.context_mode } : {}),
414
+ };
415
+
416
+ if (draft.open.length === 0) {
417
+ draft.status = 'complete';
418
+ draft.next = { action: 'complete' };
419
+ } else {
420
+ // after a patch_check the block continues at L2 (§3.6): the rung is spent, so a stall now
421
+ // stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a shrunk set is fixed at L2
422
+ draft.next = decideNext(draft, openBefore, s, kind);
423
+ if (draft.next.action === 'stop') draft.status = 'stopped';
424
+ }
425
+ const rows = [...lateRows, roundRow];
426
+ if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
427
+ for (const row of rows) {
428
+ if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
429
+ }
430
+ Object.assign(state, draft);
431
+ // best-effort: a missing approval fails closed at the block gate (`unreviewed`)
432
+ if (state.status === 'complete') await note(deps, { event: 'review.approved', file: current.rel, content_hash: current.hash, round: state.round });
433
+ return state;
434
+ }
435
+
436
+ /**
437
+ * Drive one file to `complete` or `stopped` as `state.next` says: round 1, then fix ⇒ recheck
438
+ * rounds, the L3 patch ⇒ `patch_check` once. A `retry` left by an earlier call (an unavailable
439
+ * session, a failed ledger write) re-runs the pending round first — the fix is not re-coded; a
440
+ * new `retry` in this call returns to the caller.
441
+ * @param {FileState} state @param {LoopDeps} deps
442
+ * @returns {Promise<FileState>}
443
+ */
444
+ export async function converge(state, deps) {
445
+ if (state.next?.action === 'retry') {
446
+ const kind = state.pending_kind ?? (state.round === 0 ? 'full' : 'recheck');
447
+ state.next = null;
448
+ state.pending_kind = null;
449
+ await runRound(state, deps, { kind });
450
+ } else if (state.round === 0 && state.next === null) {
451
+ await runRound(state, deps);
452
+ }
453
+ for (let guard = 0; guard < 32; guard += 1) {
454
+ const next = state.next;
455
+ if (!next || next.action === 'complete' || next.action === 'stop' || next.action === 'retry') return state;
456
+ if (next.action === 'patch') {
457
+ state.l3_rung_used = true;
458
+ state.rounds_at_level = 0;
459
+ await deps.patch?.({ file: state.file, level: 'L3', open: [...state.open] });
460
+ await runRound(state, deps, { kind: 'patch_check' });
461
+ continue;
462
+ }
463
+ await deps.fix?.({ file: state.file, round: state.round + 1, level: state.level, open: [...state.open], l3_patch: state.l3_rung_used });
464
+ await runRound(state, deps);
465
+ }
466
+ return state;
467
+ }