@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.
- package/CHANGELOG.md +64 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/bin/code-forge.mjs +145 -0
- package/docs/README.md +37 -0
- package/docs/reference/blocks.json +288 -0
- package/docs/reference/config.md +162 -0
- package/examples/first-use/README.md +11 -0
- package/examples/first-use/clamp.acceptance.yml +8 -0
- package/examples/first-use/clamp.brief.md +16 -0
- package/examples/first-use/coder/src/math.mjs +31 -0
- package/examples/first-use/coder/test/clamp.test.mjs +26 -0
- package/examples/node-lib/.env.example +3 -0
- package/examples/node-lib/.gitattributes +3 -0
- package/examples/node-lib/.github/workflows/test.yml +11 -0
- package/examples/node-lib/CHANGELOG.md +13 -0
- package/examples/node-lib/README.md +17 -0
- package/examples/node-lib/gitignore +3 -0
- package/examples/node-lib/package.json +19 -0
- package/examples/node-lib/src/list.mjs +23 -0
- package/examples/node-lib/src/math.mjs +21 -0
- package/examples/node-lib/src/strings.mjs +34 -0
- package/examples/node-lib/test/list.test.mjs +12 -0
- package/examples/node-lib/test/math.test.mjs +13 -0
- package/examples/node-lib/test/package.test.mjs +18 -0
- package/examples/node-lib/test/strings.test.mjs +28 -0
- package/package.json +52 -0
- package/schema/code-forge.schema.json +331 -0
- package/schema/facts.schema.json +28 -0
- package/scripts/first-use.mjs +517 -0
- package/scripts/gen-config-doc.mjs +241 -0
- package/skill/SKILL.md +79 -0
- package/skill/references/adapters/claude-code.md +19 -0
- package/skill/references/adapters/solo.md +34 -0
- package/skill/references/adapters/subprocess.md +15 -0
- package/skill/references/code.md +64 -0
- package/skill/references/continuity.md +31 -0
- package/skill/references/decisions.md +50 -0
- package/skill/references/degraded.md +40 -0
- package/skill/references/harden.md +26 -0
- package/skill/references/ledger.md +23 -0
- package/skill/references/plan.md +43 -0
- package/skill/references/proof.md +34 -0
- package/skill/references/review.md +42 -0
- package/skill/references/rules-core.md +72 -0
- package/skill/references/security.md +29 -0
- package/skill/scripts/forge +40 -0
- package/skill/templates/block-record.md +19 -0
- package/skill/templates/facts-sheet.md +22 -0
- package/skill/templates/plan.md +53 -0
- package/src/cli/author.mjs +87 -0
- package/src/cli/block.mjs +262 -0
- package/src/cli/doctor.mjs +74 -0
- package/src/cli/facts.mjs +124 -0
- package/src/cli/gates.mjs +280 -0
- package/src/cli/help.mjs +48 -0
- package/src/cli/init.mjs +14 -0
- package/src/cli/jev.mjs +175 -0
- package/src/cli/keys.mjs +170 -0
- package/src/cli/ledger.mjs +160 -0
- package/src/cli/list.mjs +68 -0
- package/src/cli/models.mjs +131 -0
- package/src/cli/plan.mjs +70 -0
- package/src/cli/proof.mjs +316 -0
- package/src/cli/remove.mjs +76 -0
- package/src/cli/report.mjs +73 -0
- package/src/cli/resolve.mjs +79 -0
- package/src/cli/review-file.mjs +152 -0
- package/src/cli/run.mjs +159 -0
- package/src/cli/s2.mjs +93 -0
- package/src/cli/spawn.mjs +104 -0
- package/src/cli/upgrade.mjs +103 -0
- package/src/cli/validate.mjs +108 -0
- package/src/cli/version.mjs +18 -0
- package/src/cli/worker.mjs +76 -0
- package/src/config/defaults/anthropic.mjs +18 -0
- package/src/config/defaults/index.mjs +24 -0
- package/src/config/defaults/openai.mjs +16 -0
- package/src/config/defaults/xai.mjs +18 -0
- package/src/config/known-ids.mjs +171 -0
- package/src/config/load.mjs +135 -0
- package/src/config/migrate.mjs +46 -0
- package/src/config/refresh.mjs +276 -0
- package/src/config/schema-compile.mjs +211 -0
- package/src/config/schema-paths.mjs +84 -0
- package/src/config/secret-patterns.mjs +85 -0
- package/src/config/validate.mjs +694 -0
- package/src/decide/author-loop.mjs +90 -0
- package/src/decide/escalation.mjs +262 -0
- package/src/decide/fallback-rules.mjs +172 -0
- package/src/decide/jev-client.mjs +216 -0
- package/src/decide/questions.mjs +161 -0
- package/src/decide/thresholds.mjs +199 -0
- package/src/doctor/index.mjs +105 -0
- package/src/doctor/local.mjs +162 -0
- package/src/doctor/probes.mjs +358 -0
- package/src/doctor/rows.mjs +51 -0
- package/src/doctor/worker-probe.mjs +125 -0
- package/src/engines/alias-map.mjs +78 -0
- package/src/engines/builders/claude.mjs +224 -0
- package/src/engines/builders/codex.mjs +242 -0
- package/src/engines/builders/grok.mjs +132 -0
- package/src/engines/builders/index.mjs +35 -0
- package/src/engines/builders/validate-params.mjs +34 -0
- package/src/engines/codex-home.mjs +230 -0
- package/src/engines/isolation.mjs +84 -0
- package/src/engines/probe.mjs +116 -0
- package/src/engines/provider-cli.mjs +24 -0
- package/src/engines/render-rules.mjs +41 -0
- package/src/engines/sentinel.mjs +133 -0
- package/src/engines/usage-parse.mjs +149 -0
- package/src/gates/acceptance.mjs +95 -0
- package/src/gates/detect.mjs +187 -0
- package/src/gates/invariants.mjs +43 -0
- package/src/gates/run.mjs +72 -0
- package/src/gates/safe-edit.mjs +266 -0
- package/src/gates/scope.mjs +154 -0
- package/src/gates/secret-scan.mjs +79 -0
- package/src/gates/transcript-grep.mjs +27 -0
- package/src/install/agent-env.mjs +76 -0
- package/src/install/detect.mjs +121 -0
- package/src/install/harnesses.mjs +131 -0
- package/src/install/link.mjs +463 -0
- package/src/install/wizard/answers.mjs +389 -0
- package/src/install/wizard/flags.mjs +248 -0
- package/src/install/wizard/project-file.mjs +128 -0
- package/src/install/wizard/run.mjs +366 -0
- package/src/install/wizard/steps.mjs +201 -0
- package/src/keys/backends/constants.mjs +7 -0
- package/src/keys/backends/file.mjs +99 -0
- package/src/keys/backends/keychain.mjs +81 -0
- package/src/keys/backends/security-cli.mjs +105 -0
- package/src/keys/onepassword.mjs +46 -0
- package/src/keys/store.mjs +347 -0
- package/src/ledger/calibration.mjs +109 -0
- package/src/ledger/export.mjs +52 -0
- package/src/ledger/outcome.mjs +397 -0
- package/src/ledger/paths.mjs +30 -0
- package/src/ledger/prices.mjs +49 -0
- package/src/ledger/report.mjs +250 -0
- package/src/ledger/write.mjs +279 -0
- package/src/proof/export.mjs +358 -0
- package/src/proof/lock.mjs +138 -0
- package/src/proof/red-green.mjs +519 -0
- package/src/proof/red-parse.mjs +103 -0
- package/src/proof/tiers.mjs +62 -0
- package/src/review/budget.mjs +241 -0
- package/src/review/context.mjs +156 -0
- package/src/review/engine.mjs +264 -0
- package/src/review/finding.schema.json +47 -0
- package/src/review/fixloop.mjs +467 -0
- package/src/review/gate-check.mjs +249 -0
- package/src/review/lenses/A.md +22 -0
- package/src/review/lenses/B.md +23 -0
- package/src/review/lenses/full.md +23 -0
- package/src/review/lenses/judge.md +21 -0
- package/src/review/lenses/quick.md +21 -0
- package/src/review/lenses/recheck.md +23 -0
- package/src/review/packet.mjs +355 -0
- package/src/review/triage.mjs +149 -0
- package/src/review/validate-review.mjs +70 -0
- package/src/session/author.mjs +228 -0
- package/src/session/facts.mjs +855 -0
- package/src/session/plan-check.mjs +294 -0
- package/src/session/s2.mjs +164 -0
- package/src/session/spawn.mjs +610 -0
- package/src/state/block.mjs +289 -0
- package/src/state/cli-args.mjs +67 -0
- package/src/state/paths.mjs +92 -0
- package/src/state/registry.mjs +189 -0
- package/src/state/run.mjs +266 -0
- package/src/state/signer.mjs +132 -0
- package/src/util/exec.mjs +315 -0
- package/src/util/forbidden.mjs +787 -0
- package/src/util/git.mjs +381 -0
- package/src/util/log.mjs +100 -0
- package/src/util/reaper.mjs +300 -0
- package/src/util/redact.mjs +212 -0
- package/src/util/tmp.mjs +409 -0
- package/src/worker/engine.mjs +338 -0
- package/src/worker/loop.mjs +392 -0
- package/src/worker/queue.mjs +186 -0
- package/src/worker/review-state.mjs +145 -0
- 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
|
+
}
|