@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,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The review-engine hook (plan §4.2–§4.4, §4.11; B11 seam, body by B12a, fix loop wired by B12c).
|
|
3
|
+
*
|
|
4
|
+
* `reviewTicket(ticket, ctx)` is the ONE seam between the worker (queue, keys, signing, pid
|
|
5
|
+
* hygiene — B11) and the review (`src/review/`). The worker signs whatever it returns. The hook
|
|
6
|
+
* looks up the block's base SHA and level in the run record and gives the review a packet
|
|
7
|
+
* directory under the run's temp root (removed afterwards). A run record that exists but cannot be
|
|
8
|
+
* read is `unavailable`, never a review against a guessed base.
|
|
9
|
+
*
|
|
10
|
+
* With the run key in `ctx.key` (the worker always passes it), one ticket = ONE round of B12b's
|
|
11
|
+
* fix loop (`fixloop.mjs`, §4.11) on the file's persisted state (`review-state.mjs`):
|
|
12
|
+
* - no state, or the last cycle `complete` (the file changed after its approval) ⇒ round 1: the
|
|
13
|
+
* B12a engine (`reviewFile`), then triage (`triage.mjs`);
|
|
14
|
+
* - open findings ⇒ the recheck round on the fix hunks only (the diff between the content the
|
|
15
|
+
* last round reviewed and the current content), with the open findings by id;
|
|
16
|
+
* - `next: patch` (the L3 rung at the cap) ⇒ the `patch_check` round; a `retry` re-runs its round;
|
|
17
|
+
* - `stopped` (`review_cap`, `l3_patch_exhausted`, `split_required`) ⇒ no session: the result
|
|
18
|
+
* repeats the stop — only the human moves on (§4.11 rule 5).
|
|
19
|
+
* The round's outcome: `approved: true` ONLY when the loop is `complete` AND its signed
|
|
20
|
+
* `review.approved` row (keyed `run, block, file, content_hash` = the ticket's hash) was written;
|
|
21
|
+
* else `findings` = the open fix list, `next` = what the orchestrator does (`fix` at `level`,
|
|
22
|
+
* `trigger: review_stall | review_rounds`, `patch`), `stopped` = the stop reason.
|
|
23
|
+
* Without `ctx.key` (a direct call) the hook is the engine alone and writes no approval row.
|
|
24
|
+
* The first keyed ticket of a block (no `review.budget` row for it in the run yet) records the
|
|
25
|
+
* block's review budget (§4.10, B19): the forecast over the block's changed owned files (tier from
|
|
26
|
+
* the path floors, before S1), one `review.budget` row per block; a failure there never fails the
|
|
27
|
+
* review.
|
|
28
|
+
* The review NEVER approves an empty or failed session: that is `unavailable` (§4.2 stub guard).
|
|
29
|
+
* A throw inside the round is `unavailable` too, with the state saved as `next: retry` (see
|
|
30
|
+
* `fixLoopRound`): the next ticket re-runs that round, never a fresh round 1.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { rmSync } from 'node:fs';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { askJev } from '../decide/jev-client.mjs';
|
|
36
|
+
import { planAndRecord, tierOf } from '../review/budget.mjs';
|
|
37
|
+
import { reviewFile, rulesRisk } from '../review/engine.mjs';
|
|
38
|
+
import { computeFileSet, ownsFile } from '../gates/scope.mjs';
|
|
39
|
+
import { newFileState, runRound } from '../review/fixloop.mjs';
|
|
40
|
+
import { readRun } from '../state/run.mjs';
|
|
41
|
+
import { redact } from '../util/redact.mjs';
|
|
42
|
+
import { blockRungUsed, loadState, saveState } from './review-state.mjs';
|
|
43
|
+
import { assertTicketId } from './ticket.mjs';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @typedef {object} ReviewContext
|
|
47
|
+
* @property {string} repoRoot
|
|
48
|
+
* @property {string} runId
|
|
49
|
+
* @property {string} runRootDir - the run's temp root (packets and session dirs live under it).
|
|
50
|
+
* @property {Record<string, any>} cfg - the project config.
|
|
51
|
+
* @property {string | null} jevKey - resolved once by the worker through B2's chain; never print.
|
|
52
|
+
* @property {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn - one isolated
|
|
53
|
+
* session (`spawnSession` opts minus `runRoot`, `run`, `block`, `timeoutMs`; `cfg` defaults to
|
|
54
|
+
* the worker's and a per-call `cfg` wins).
|
|
55
|
+
* @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - a signed ledger row
|
|
56
|
+
* (the worker adds `run`, `block`, `file`, `content_hash`).
|
|
57
|
+
* @property {Buffer} [key] - the run's HMAC key: signs the fix-loop state. Absent ⇒ engine only.
|
|
58
|
+
* @property {() => Promise<Array<Record<string, any>>>} [readRows] - the run's ledger rows (the
|
|
59
|
+
* `review.state` anchors the state is checked against); required with `key`.
|
|
60
|
+
* @property {import('../review/triage.mjs').JevAsk} [jev] - S1 with the key bound; default: `askJev`
|
|
61
|
+
* with `jevKey` when one was resolved, else none (§3.5 fallback).
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {object} ReviewOutcome
|
|
66
|
+
* @property {string} status - `reviewed`, `unavailable`, `no_change`, `split_required`, `refused`.
|
|
67
|
+
* @property {boolean} approved
|
|
68
|
+
* @property {string} engine - `adaptive` or `consensus`.
|
|
69
|
+
* @property {Array<Record<string, any>>} sessions - one summary per session run.
|
|
70
|
+
* @property {string} [reason] - why it is not `reviewed` (stub-guard reason, `needs_file-refused`, …).
|
|
71
|
+
* @property {Array<Record<string, any>>} [findings] - the open fix list (`reviewed` / `stopped`).
|
|
72
|
+
* @property {number} [round] - the fix-loop round this ticket ran (patch_check rounds not counted).
|
|
73
|
+
* @property {string} [kind] - `full`, `recheck` or `patch_check`.
|
|
74
|
+
* @property {string} [level] - the level the file's fixes are coded at now.
|
|
75
|
+
* @property {Record<string, any> | null} [next] - the loop's next step (`fix`, `patch`, `complete`, `stop`, `retry`).
|
|
76
|
+
* @property {string | null} [trigger] - `review_stall`, `review_rounds`, `review_cap`, or null.
|
|
77
|
+
* @property {string} [stopped] - the stop reason (`review_cap`, `l3_patch_exhausted`, …).
|
|
78
|
+
* @property {number} [late] - late findings recorded so far for the file.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The block's entry in the run record: `null` (⇒ HEAD, level L2) ONLY when no run record exists
|
|
83
|
+
* (`no-run`). Once a record exists, a ticket naming a block it does not list throws
|
|
84
|
+
* `block-unknown`; a record that cannot be read or parsed throws `run-record-unreadable`, and a
|
|
85
|
+
* block entry without a base `block-base-missing` — a guessed base could let a change escape review.
|
|
86
|
+
* @param {string} runId @param {string} block
|
|
87
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[]}>}
|
|
88
|
+
*/
|
|
89
|
+
export async function blockEntryFor(runId, block) {
|
|
90
|
+
let record;
|
|
91
|
+
try {
|
|
92
|
+
record = await readRun(runId);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [] };
|
|
95
|
+
throw new Error('run-record-unreadable');
|
|
96
|
+
}
|
|
97
|
+
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
98
|
+
if (typeof block !== 'string' || !Object.hasOwn(record.blocks, block)) throw new Error('block-unknown');
|
|
99
|
+
const entry = record.blocks[block];
|
|
100
|
+
const sha = entry?.base_sha;
|
|
101
|
+
if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
|
|
102
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
103
|
+
return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The block's base SHA (`blockEntryFor(...).base`).
|
|
108
|
+
* @param {string} runId @param {string} block
|
|
109
|
+
* @returns {Promise<string | null>}
|
|
110
|
+
*/
|
|
111
|
+
export async function baseFor(runId, block) {
|
|
112
|
+
return (await blockEntryFor(runId, block)).base;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The packet directory for a ticket, or null when the id cannot name one. The directory is
|
|
117
|
+
* removed with `rmSync({recursive: true})` afterwards, and tickets are coder-writable, so the id
|
|
118
|
+
* must be the 24-hex form B11 generates AND the resolved path must sit directly under
|
|
119
|
+
* `<runRootDir>/packets` — never the run root itself (`''`), a parent (`../..`) or a nested path.
|
|
120
|
+
* @param {string} runRootDir @param {unknown} id
|
|
121
|
+
* @returns {string | null}
|
|
122
|
+
*/
|
|
123
|
+
export function packetDirFor(runRootDir, id) {
|
|
124
|
+
const packets = path.resolve(runRootDir, 'packets');
|
|
125
|
+
let workDir;
|
|
126
|
+
try {
|
|
127
|
+
workDir = path.resolve(packets, assertTicketId(id));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const rel = path.relative(packets, workDir);
|
|
132
|
+
if (rel.length === 0 || rel.includes(path.sep) || rel === '..' || path.isAbsolute(rel) || path.dirname(workDir) !== packets) return null;
|
|
133
|
+
return workDir;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {import('./queue.mjs').Ticket} ticket
|
|
139
|
+
* @param {ReviewContext} ctx
|
|
140
|
+
* @returns {Promise<ReviewOutcome>}
|
|
141
|
+
*/
|
|
142
|
+
export async function reviewTicket(ticket, ctx) {
|
|
143
|
+
const workDir = packetDirFor(ctx.runRootDir, ticket.ticket);
|
|
144
|
+
if (workDir === null) return { status: 'unavailable', reason: 'ticket-id-invalid', approved: false, engine: 'adaptive', sessions: [] };
|
|
145
|
+
let entry;
|
|
146
|
+
try {
|
|
147
|
+
entry = await blockEntryFor(ctx.runId, ticket.block);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
if (!ctx.key) {
|
|
153
|
+
return await reviewFile(
|
|
154
|
+
{ repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir },
|
|
155
|
+
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir });
|
|
159
|
+
} finally {
|
|
160
|
+
rmSync(workDir, { recursive: true, force: true });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* One fix-loop round for the ticket (see the module doc).
|
|
166
|
+
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
167
|
+
* @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string}} opts
|
|
168
|
+
* @returns {Promise<ReviewOutcome>}
|
|
169
|
+
*/
|
|
170
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir }) {
|
|
171
|
+
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
172
|
+
let rows;
|
|
173
|
+
try {
|
|
174
|
+
rows = (await ctx.readRows()).filter((r) => r?.run === ctx.runId);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return { status: 'unavailable', reason: failureReason(err), approved: false, engine: 'adaptive', sessions: [] };
|
|
177
|
+
}
|
|
178
|
+
// Check-then-write is safe here: a run has exactly ONE worker, and it processes its tickets one
|
|
179
|
+
// at a time (`loop.mjs` awaits each `processTicket`), so no second ticket of this block can read
|
|
180
|
+
// the rows between this check and the budget row's write.
|
|
181
|
+
if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
|
|
182
|
+
await recordBlockBudget(ticket, ctx, { base, owned });
|
|
183
|
+
}
|
|
184
|
+
const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
|
|
185
|
+
const loaded = loadState(where);
|
|
186
|
+
const rung = blockRungUsed(where);
|
|
187
|
+
if (loaded.status === 'tampered' || rung.status === 'tampered') return { status: 'refused', reason: 'review-state-tampered', approved: false, engine: 'adaptive', sessions: [] };
|
|
188
|
+
const rungUsed = rung.used;
|
|
189
|
+
let state = loaded.state;
|
|
190
|
+
if (state === null || state.status === 'complete') state = newFileState({ file: ticket.file, level, l3RungUsed: rungUsed });
|
|
191
|
+
state.l3_rung_used = state.l3_rung_used || rungUsed;
|
|
192
|
+
|
|
193
|
+
if (state.status === 'stopped') {
|
|
194
|
+
return { ...loopFields(state, null), status: 'stopped', stopped: state.next?.reason ?? 'stopped', approved: false, engine: 'adaptive', sessions: [], findings: state.open };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @type {'full' | 'recheck' | 'patch_check'} */
|
|
198
|
+
let kind = state.round === 0 ? 'full' : 'recheck';
|
|
199
|
+
if (state.next?.action === 'retry') kind = state.pending_kind ?? kind;
|
|
200
|
+
else if (state.next?.action === 'patch') {
|
|
201
|
+
kind = 'patch_check';
|
|
202
|
+
state.l3_rung_used = true; // the orchestrator ran the block's one L3 patch before this ticket
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** @type {Record<string, any> | null} */
|
|
206
|
+
let engineOutcome = null;
|
|
207
|
+
let approvalWritten = false;
|
|
208
|
+
/** @type {Array<Record<string, any>>} */
|
|
209
|
+
const rechecks = [];
|
|
210
|
+
/** @param {Record<string, any>} row */
|
|
211
|
+
const writeRow = async (row) => {
|
|
212
|
+
if (!ctx.writeRow) throw new Error('no-ledger');
|
|
213
|
+
if (row.event === 'review.approved') {
|
|
214
|
+
// the approval names the reviewed bytes: a file that moved since the ticket is not approved
|
|
215
|
+
if (row.content_hash !== ticket.content_hash) throw new Error('hash-moved');
|
|
216
|
+
await ctx.writeRow(row);
|
|
217
|
+
approvalWritten = true;
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
return ctx.writeRow(row);
|
|
221
|
+
};
|
|
222
|
+
const jev = ctx.jev ?? (typeof ctx.jevKey === 'string' && ctx.jevKey.length > 0 ? jevWith(ctx.jevKey) : undefined);
|
|
223
|
+
const before = JSON.stringify(state);
|
|
224
|
+
/** @type {string | null} */
|
|
225
|
+
let crashed = null;
|
|
226
|
+
try {
|
|
227
|
+
await runRound(
|
|
228
|
+
state,
|
|
229
|
+
{
|
|
230
|
+
repoRoot: ctx.repoRoot,
|
|
231
|
+
cfg: ctx.cfg,
|
|
232
|
+
workDir,
|
|
233
|
+
base,
|
|
234
|
+
writeRow,
|
|
235
|
+
...(jev ? { jev } : {}),
|
|
236
|
+
review: async () => {
|
|
237
|
+
engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir }, { spawn: ctx.spawn, writeRow });
|
|
238
|
+
return engineOutcome;
|
|
239
|
+
},
|
|
240
|
+
spawn: async (opts) => {
|
|
241
|
+
const res = await ctx.spawn(opts);
|
|
242
|
+
rechecks.push({ lens: opts?.rowExtra?.lens ?? null, role: opts?.role ?? null, level: opts?.level ?? null, provider: res?.provider ?? null, model: res?.model ?? null });
|
|
243
|
+
return res;
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
{ kind },
|
|
247
|
+
);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
// A throw mid-round (the engine, a session, S1, `git diff`) is `unavailable`, never an
|
|
250
|
+
// exception the worker turns into `engine-error` with the state UNSAVED. `runRound` stages
|
|
251
|
+
// the round on a draft and commits it only after its gate rows (`review.round`, `review.cap`)
|
|
252
|
+
// are written, so a throw leaves `state` as it was and no `review.round` row exists for the
|
|
253
|
+
// round that threw: the state is saved as `next: retry` with the pending kind — exactly what
|
|
254
|
+
// `fixloop` records for an unavailable session — so its anchor stays the latest and the next
|
|
255
|
+
// ticket re-runs THIS round. (An unsaved state would re-run the earlier rounds' rows too; a
|
|
256
|
+
// state that was already committed is kept as committed, its rows are in the ledger.)
|
|
257
|
+
crashed = failureReason(err);
|
|
258
|
+
if (JSON.stringify(state) === before) {
|
|
259
|
+
state.next = { action: 'retry', reason: crashed };
|
|
260
|
+
state.pending_kind = kind;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
await saveState({ ...where, state, seq: loaded.seq, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
|
|
264
|
+
|
|
265
|
+
const eng = /** @type {Record<string, any> | null} */ (engineOutcome);
|
|
266
|
+
const head = { ...loopFields(state, kind), engine: eng?.engine ?? 'adaptive', sessions: eng?.sessions ?? rechecks };
|
|
267
|
+
if (crashed !== null) return { ...head, status: 'unavailable', reason: crashed, approved: false };
|
|
268
|
+
const next = state.next;
|
|
269
|
+
// `runRound` moved the state on: re-read its status (not the narrowed pre-round value)
|
|
270
|
+
const status = /** @type {'open' | 'complete' | 'stopped'} */ (state.status);
|
|
271
|
+
if (next?.action === 'retry') {
|
|
272
|
+
if (eng && eng.status !== 'reviewed') return /** @type {ReviewOutcome} */ ({ ...eng, ...loopFields(state, kind), approved: false });
|
|
273
|
+
return { ...head, status: 'unavailable', reason: next.reason ?? 'unavailable', approved: false };
|
|
274
|
+
}
|
|
275
|
+
if (status === 'complete') {
|
|
276
|
+
if (!approvalWritten) return { ...head, status: 'unavailable', reason: 'approval-not-written', approved: false };
|
|
277
|
+
return { ...head, status: 'reviewed', approved: true, findings: [] };
|
|
278
|
+
}
|
|
279
|
+
if (status === 'stopped') return { ...head, status: 'stopped', stopped: next?.reason ?? 'stopped', approved: false, findings: state.open };
|
|
280
|
+
return { ...head, status: 'reviewed', approved: false, reason: 'findings-open', findings: state.open };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The block's review budget (§4.10, B19): the forecast over the block's changed owned files (the
|
|
285
|
+
* ticket's file always included), each tiered by its path floor (`rulesRisk` with no diff — S1 has
|
|
286
|
+
* not run yet), written as ONE `review.budget` row (+ `review.over_budget` when over). Best effort:
|
|
287
|
+
* a git or ledger failure is swallowed, the review goes on.
|
|
288
|
+
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
289
|
+
* @param {{base: string | null, owned: string[]}} opts
|
|
290
|
+
*/
|
|
291
|
+
async function recordBlockBudget(ticket, ctx, { base, owned }) {
|
|
292
|
+
try {
|
|
293
|
+
/** @type {string[]} */
|
|
294
|
+
let changed = [];
|
|
295
|
+
if (base !== null && owned.length > 0) {
|
|
296
|
+
try {
|
|
297
|
+
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f));
|
|
298
|
+
} catch {
|
|
299
|
+
// no row on a git failure: a ticket-only forecast would be permanent (the row is written
|
|
300
|
+
// once per block); the block's next keyed ticket retries
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const files = [...new Set([...changed, ticket.file])].sort().map((file) => ({ file, tier: tierOf({ risk: rulesRisk({ file, plusCount: 0, cfg: ctx.cfg }) }) }));
|
|
305
|
+
await planAndRecord({ files, cfg: ctx.cfg, block: ticket.block, blockOnly: true, writeRow: /** @type {(row: Record<string, any>) => Promise<unknown>} */ (ctx.writeRow) });
|
|
306
|
+
} catch {
|
|
307
|
+
// the budget row is a report dataset, never a reason to fail the review
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The `unavailable` reason for a throw: the error's code when it has one, else its message
|
|
313
|
+
* through B0 `redact` (never a secret; a message can quote a path, never config values).
|
|
314
|
+
* @param {unknown} err @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
function failureReason(err) {
|
|
317
|
+
const code = /** @type {any} */ (err)?.code;
|
|
318
|
+
if (typeof code === 'string' && code.length > 0) return code;
|
|
319
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
320
|
+
const safe = /** @type {string} */ (redact(text));
|
|
321
|
+
return safe.length > 0 ? safe : 'round-failed';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* @param {import('../review/fixloop.mjs').FileState} state @param {string | null} kind
|
|
326
|
+
* @returns {Record<string, any>}
|
|
327
|
+
*/
|
|
328
|
+
function loopFields(state, kind) {
|
|
329
|
+
return { round: state.round, ...(kind ? { kind } : {}), level: state.level, next: state.next, trigger: state.next?.trigger ?? null, late: state.late.length };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* S1 through `askJev` with the worker's key bound (the key stays in this process).
|
|
334
|
+
* @param {string} key @returns {import('../review/triage.mjs').JevAsk}
|
|
335
|
+
*/
|
|
336
|
+
function jevWith(key) {
|
|
337
|
+
return (req) => askJev({ ...req, key });
|
|
338
|
+
}
|