@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,62 @@
1
+ /**
2
+ * Proof tiers (plan §7.1, D14): two tiers, decided per file at `review-file` time and enforced
3
+ * by the block gate as the UNION of its files' tiers.
4
+ *
5
+ * high ⇐ risk ≥ 2, or the path matches `proof.tiers.high.paths`, or `security_sensitive`
6
+ * light ⇐ everything else (the review-depth band `1 ≤ risk < 2` does NOT raise the proof tier)
7
+ *
8
+ * The light tier's proof is red→green (B10b). The high tier's extra proof — tool-made mutants on
9
+ * changed lines — is held on Q16 (block B10c), so `proofRequirements('high')` reports it as
10
+ * `held` and the gate enforces red→green only until Ricardo answers. Pure: no I/O.
11
+ */
12
+
13
+ import { ownsFile } from '../state/registry.mjs';
14
+
15
+ /** @typedef {'light' | 'high'} Tier */
16
+ /** @typedef {'risk' | 'path' | 'security' | 'default'} TierReason */
17
+
18
+ export const TIERS = Object.freeze(/** @type {const} */ (['light', 'high']));
19
+
20
+ /** `risk ≥ HIGH_RISK` ⇒ high tier (§7.1). */
21
+ export const HIGH_RISK = 2;
22
+
23
+ /**
24
+ * @param {{file: string, risk: number, securitySensitive?: boolean, highPaths?: ReadonlyArray<string>}} input
25
+ * `risk` is the S1 `risk` answer (0–3); `highPaths` is `proof.tiers.high.paths` (exact paths or
26
+ * globs in the registry's syntax: `*`, `?`, `**`, `{a,b}`).
27
+ * @returns {{tier: Tier, reason: TierReason}} the FIRST reason that applies, in the order
28
+ * security, risk, path — every one of them yields `high`, so the order only picks the label.
29
+ * @throws {TypeError} on a non-string file, a risk outside 0–3, or a non-array `highPaths`.
30
+ */
31
+ export function tierFor({ file, risk, securitySensitive = false, highPaths = [] }) {
32
+ if (typeof file !== 'string' || file.length === 0) throw new TypeError('tierFor: file must be a non-empty repo-relative path');
33
+ if (typeof risk !== 'number' || !Number.isFinite(risk) || risk < 0 || risk > 3) {
34
+ throw new TypeError('tierFor: risk must be a number from 0 to 3');
35
+ }
36
+ if (!Array.isArray(highPaths)) throw new TypeError('tierFor: proof.tiers.high.paths must be an array');
37
+ if (securitySensitive === true) return { tier: 'high', reason: 'security' };
38
+ if (risk >= HIGH_RISK) return { tier: 'high', reason: 'risk' };
39
+ if (highPaths.length > 0 && ownsFile(highPaths, file)) return { tier: 'high', reason: 'path' };
40
+ return { tier: 'light', reason: 'default' };
41
+ }
42
+
43
+ /**
44
+ * The block's tier: the union of its files' tiers (`high` when any file is `high`).
45
+ * @param {ReadonlyArray<{tier: Tier}>} fileTiers
46
+ * @returns {Tier}
47
+ */
48
+ export function blockTier(fileTiers) {
49
+ if (!Array.isArray(fileTiers)) throw new TypeError('blockTier: fileTiers must be an array');
50
+ return fileTiers.some((t) => t?.tier === 'high') ? 'high' : 'light';
51
+ }
52
+
53
+ /**
54
+ * What the gate requires for a tier. `mutants: 'held'` = the high tier's mutation proof is
55
+ * specified but not enforced until Q16 is answered (B10c).
56
+ * @param {Tier} tier
57
+ * @returns {{red_green: true, mutants: 'none' | 'held'}}
58
+ */
59
+ export function proofRequirements(tier) {
60
+ if (!TIERS.includes(tier)) throw new TypeError(`proofRequirements: tier must be one of ${TIERS.join(', ')}`);
61
+ return { red_green: true, mutants: tier === 'high' ? 'held' : 'none' };
62
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Review budget per block (plan §4.10, R4, O19; block B12b) — the middle point.
3
+ *
4
+ * Budget = `review.block_budget_tokens` (150 000) + whatever the orchestrator raised for the run
5
+ * (`raised`). The forecast is the 0.1 static table (all `estimated`, [A31] for the recheck):
6
+ * quick 7k · full 13k · A+B+judge 34k (26k for the two lenses + 8k judge) · consensus 30k ·
7
+ * recheck 5k.
8
+ * Unconstrained depth per file: tier `light` ⇒ quick, `standard` ⇒ full, `high` ⇒ A+B+judge
9
+ * (`consensus` for every file under `review.multimodel: true`).
10
+ *
11
+ * Degrade order when the forecast exceeds the budget — one step at a time, cheapest first,
12
+ * re-forecasting after each, stopping at the first step that fits:
13
+ * 1. batch small files of the same tier into one packet (≤ `full_in`; one row per file;
14
+ * `review.batch_small_files: false` skips it) — a file is small when its estimated packet
15
+ * is ≤ `full_in / 3`;
16
+ * 2. high tier: the judge runs once per BLOCK instead of per file (lens A + B still per file);
17
+ * 3. standard band: `full` ⇒ `quick`;
18
+ * 4. high tier: `A + B + judge` ⇒ one `full` L2 + judge — the FLOOR for the high tier
19
+ * (consensus floor = the full consensus: steps 2–4 never touch it);
20
+ * (5. light: `quick` stays `quick` — a no-op, never counted.)
21
+ * Still over after step 4 ⇒ `over_budget`: the orchestrator raises the budget once per run or
22
+ * splits the block. Never silently, never below the floors. RECHECKS ARE NEVER DEGRADED: a
23
+ * round ≥ 2 packet is already the minimum (§4.11); they are never batched either.
24
+ *
25
+ * Rows (R4 dataset): per file `review.plan {file, tier, depth_unconstrained, depth_chosen,
26
+ * degrade_step, forecast_tokens, context_mode}` and per block `review.budget {budget,
27
+ * forecast_unconstrained, forecast_chosen, actual, raised, over_budget}`.
28
+ */
29
+
30
+ import { budgetFor } from './packet.mjs';
31
+
32
+ export const SESSION_COST = Object.freeze({ quick: 7000, full: 13000, dual: 34000, consensus: 30000, recheck: 5000 });
33
+
34
+ /** The judge's share of `dual` (A + B = 2 × 13k; the judge reads two reports). */
35
+ export const JUDGE_COST = SESSION_COST.dual - 2 * SESSION_COST.full;
36
+
37
+ export const DEFAULT_BLOCK_BUDGET = 150000;
38
+
39
+ /** Per-file cost of each depth when the judge is billed separately (per block). */
40
+ const PER_FILE = Object.freeze({
41
+ quick: SESSION_COST.quick,
42
+ full: SESSION_COST.full,
43
+ dual: SESSION_COST.dual,
44
+ 'dual-block-judge': 2 * SESSION_COST.full,
45
+ 'full+judge': SESSION_COST.full + JUDGE_COST,
46
+ 'full+block-judge': SESSION_COST.full,
47
+ consensus: SESSION_COST.consensus,
48
+ recheck: SESSION_COST.recheck,
49
+ });
50
+
51
+ /**
52
+ * Tier per file (§7.1): `high` when risk ≥ 2, a high path, or security-sensitive; `standard` for
53
+ * 1 ≤ risk < 2; else `light`.
54
+ * @param {{risk: number, highPath?: boolean, securitySensitive?: boolean}} opts
55
+ * @returns {'light' | 'standard' | 'high'}
56
+ */
57
+ export function tierOf({ risk, highPath = false, securitySensitive = false }) {
58
+ if (highPath || securitySensitive || risk >= 2) return 'high';
59
+ return risk >= 1 ? 'standard' : 'light';
60
+ }
61
+
62
+ /**
63
+ * @typedef {object} BudgetFile
64
+ * @property {string} file
65
+ * @property {'light' | 'standard' | 'high'} tier
66
+ * @property {number} [tokens] - the estimated packet size (for batching); absent ⇒ not small.
67
+ * @property {boolean} [recheck] - a round ≥ 2 packet: forecast at `recheck`, never degraded.
68
+ */
69
+
70
+ /**
71
+ * @typedef {object} PlanEntry
72
+ * @property {string} file @property {string} tier
73
+ * @property {string} depth_unconstrained @property {string} depth_chosen
74
+ * @property {number} degrade_step @property {number} forecast_tokens
75
+ * @property {string | null} context_mode @property {number | null} batch
76
+ */
77
+
78
+ /**
79
+ * @typedef {object} BudgetPlan
80
+ * @property {number} budget @property {number} raised
81
+ * @property {number} forecast_unconstrained @property {number} forecast_chosen
82
+ * @property {number} degrade_step - the last step applied (0 = none).
83
+ * @property {boolean} over_budget
84
+ * @property {boolean} judge_per_block
85
+ * @property {PlanEntry[]} plan
86
+ */
87
+
88
+ /** @param {'light' | 'standard' | 'high'} tier @param {boolean} consensus */
89
+ const unconstrainedDepth = (tier, consensus) => (consensus ? 'consensus' : tier === 'high' ? 'dual' : tier === 'standard' ? 'full' : 'quick');
90
+
91
+ /**
92
+ * Forecast the plan: per-file costs, batches billed once per batch, the block judge once.
93
+ * @param {PlanEntry[]} entries @param {boolean} judgePerBlock @returns {number}
94
+ */
95
+ function forecast(entries, judgePerBlock) {
96
+ let total = 0;
97
+ /** @type {Map<number, number>} */
98
+ const batches = new Map();
99
+ for (const e of entries) {
100
+ const cost = PER_FILE[/** @type {keyof typeof PER_FILE} */ (e.depth_chosen)];
101
+ if (e.batch === null) {
102
+ e.forecast_tokens = cost;
103
+ total += cost;
104
+ } else {
105
+ batches.set(e.batch, Math.max(batches.get(e.batch) ?? 0, cost));
106
+ }
107
+ }
108
+ for (const [id, cost] of batches) {
109
+ const members = entries.filter((e) => e.batch === id);
110
+ for (const e of members) e.forecast_tokens = Math.ceil(cost / members.length);
111
+ total += cost;
112
+ }
113
+ const blockJudge = judgePerBlock && entries.some((e) => e.depth_chosen === 'dual-block-judge' || e.depth_chosen === 'full+block-judge');
114
+ return total + (blockJudge ? JUDGE_COST : 0);
115
+ }
116
+
117
+ /**
118
+ * Plan the block's review depth under its budget.
119
+ * @param {{files: BudgetFile[], cfg?: Record<string, any>, raised?: number}} opts
120
+ * @returns {BudgetPlan}
121
+ */
122
+ export function planBudget({ files, cfg = {}, raised = 0 }) {
123
+ if (!Array.isArray(files)) throw new TypeError('planBudget: files must be an array');
124
+ const base = Number.isInteger(cfg?.review?.block_budget_tokens) && cfg.review.block_budget_tokens > 0 ? cfg.review.block_budget_tokens : DEFAULT_BLOCK_BUDGET;
125
+ if (!Number.isInteger(raised) || raised < 0) throw new TypeError('planBudget: raised must be an integer >= 0');
126
+ const budget = base + raised;
127
+ const consensus = cfg?.review?.multimodel === true;
128
+ const fullIn = budgetFor(cfg, 'full_in');
129
+
130
+ /** @type {PlanEntry[]} */
131
+ const plan = files.map((f) => {
132
+ const depth = f.recheck === true ? 'recheck' : unconstrainedDepth(f.tier, consensus);
133
+ return { file: f.file, tier: f.tier, depth_unconstrained: depth, depth_chosen: depth, degrade_step: 0, forecast_tokens: 0, context_mode: f.recheck === true ? 'recheck' : null, batch: null };
134
+ });
135
+ let judgePerBlock = false;
136
+ const unconstrained = forecast(plan, false);
137
+ let chosen = unconstrained;
138
+ let step = 0;
139
+
140
+ /** @type {Array<() => void>} */
141
+ const steps = [
142
+ // 1. batch small files of the same tier
143
+ () => {
144
+ if (cfg?.review?.batch_small_files === false) return;
145
+ let next = 0;
146
+ for (const tier of ['light', 'standard', 'high']) {
147
+ let load = 0;
148
+ let current = -1;
149
+ files.forEach((f, i) => {
150
+ const e = plan[i];
151
+ if (f.recheck === true || f.tier !== tier || !(typeof f.tokens === 'number' && f.tokens <= fullIn / 3)) return;
152
+ if (current < 0 || load + f.tokens > fullIn) {
153
+ current = next++;
154
+ load = 0;
155
+ }
156
+ load += f.tokens;
157
+ e.batch = current;
158
+ e.degrade_step = 1;
159
+ });
160
+ }
161
+ // a "batch" of one file is no batch
162
+ for (let id = 0; id < next; id += 1) {
163
+ const members = plan.filter((e) => e.batch === id);
164
+ if (members.length === 1) Object.assign(members[0], { batch: null, degrade_step: 0 });
165
+ }
166
+ },
167
+ // 2. judge per block for high-tier files
168
+ () => {
169
+ judgePerBlock = true;
170
+ for (const e of plan) if (e.depth_chosen === 'dual') Object.assign(e, { depth_chosen: 'dual-block-judge', degrade_step: 2 });
171
+ },
172
+ // 3. standard: full ⇒ quick
173
+ () => {
174
+ for (const e of plan) if (e.depth_chosen === 'full' && e.tier === 'standard') Object.assign(e, { depth_chosen: 'quick', degrade_step: 3 });
175
+ },
176
+ // 4. high: A + B (+ judge) ⇒ one full + judge — the floor
177
+ () => {
178
+ for (const e of plan) {
179
+ if (e.depth_chosen === 'dual-block-judge') Object.assign(e, { depth_chosen: 'full+block-judge', degrade_step: 4 });
180
+ else if (e.depth_chosen === 'dual') Object.assign(e, { depth_chosen: 'full+judge', degrade_step: 4 });
181
+ }
182
+ },
183
+ ];
184
+ while (chosen > budget && step < steps.length) {
185
+ steps[step]();
186
+ step += 1;
187
+ chosen = forecast(plan, judgePerBlock);
188
+ }
189
+ return { budget, raised, forecast_unconstrained: unconstrained, forecast_chosen: chosen, degrade_step: step, over_budget: chosen > budget, judge_per_block: judgePerBlock, plan };
190
+ }
191
+
192
+ /**
193
+ * The ledger rows of a plan: one `review.plan` per file, then one `review.budget` for the block.
194
+ * @param {BudgetPlan} result @param {{block: string, actual?: number | null}} opts
195
+ * @returns {Array<Record<string, any>>}
196
+ */
197
+ export function budgetRows(result, { block, actual = null }) {
198
+ /** @type {Array<Record<string, any>>} */
199
+ const rows = result.plan.map((e) => ({
200
+ event: 'review.plan',
201
+ block,
202
+ file: e.file,
203
+ tier: e.tier,
204
+ depth_unconstrained: e.depth_unconstrained,
205
+ depth_chosen: e.depth_chosen,
206
+ degrade_step: e.degrade_step,
207
+ forecast_tokens: e.forecast_tokens,
208
+ context_mode: e.context_mode,
209
+ tokens_source: 'estimated',
210
+ }));
211
+ rows.push({
212
+ event: 'review.budget',
213
+ block,
214
+ budget: result.budget,
215
+ forecast_unconstrained: result.forecast_unconstrained,
216
+ forecast_chosen: result.forecast_chosen,
217
+ actual,
218
+ raised: result.raised,
219
+ over_budget: result.over_budget,
220
+ degrade_step: result.degrade_step,
221
+ });
222
+ if (result.over_budget) rows.push({ event: 'review.over_budget', block, budget: result.budget, forecast_chosen: result.forecast_chosen });
223
+ return rows;
224
+ }
225
+
226
+ /**
227
+ * Plan and write the rows (the ledger writer signs/stamps them). `blockOnly` writes the per-block
228
+ * rows only (`review.budget`, `review.over_budget`): the live path (B19, the worker's first ticket
229
+ * of a block) already gets one `review.plan` per reviewed file from the review engine, and a second
230
+ * set of forecast `review.plan` rows would double the report's planned-file count.
231
+ * @param {{files: BudgetFile[], cfg?: Record<string, any>, raised?: number, block: string, blockOnly?: boolean, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
232
+ * @returns {Promise<BudgetPlan>}
233
+ */
234
+ export async function planAndRecord({ files, cfg, raised, block, blockOnly = false, writeRow }) {
235
+ const result = planBudget({ files, cfg, raised });
236
+ for (const row of budgetRows(result, { block })) {
237
+ if (blockOnly && row.event === 'review.plan') continue;
238
+ await writeRow(row);
239
+ }
240
+ return result;
241
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Packet context (plan §4.3, C10; block B12a).
3
+ *
4
+ * A reviewer sees the current file around the diff, never the project. The rule:
5
+ * - `whole`: the file at its current content has ≤ `review.context.whole_file_max_lines` (400)
6
+ * lines ⇒ the whole file, every line numbered;
7
+ * - `hunks`: otherwise each hunk with ± `review.context.hunk_context_lines` (40) lines of the
8
+ * current file (overlapping windows merge);
9
+ * - `minimal`: the budget fallback ⇒ each hunk with ± `review.context.min_context_lines` (10);
10
+ * - `recheck`: a round ≥ 2 packet (§4.11) ⇒ always the hunk form on the fix hunks, never the
11
+ * whole file.
12
+ * Lines are numbered with their line number in the CURRENT file, so a finding's `line_start`
13
+ * points at a real line. Pure functions: no I/O.
14
+ */
15
+
16
+ export const CONTEXT_MODES = Object.freeze(['whole', 'hunks', 'minimal', 'recheck']);
17
+
18
+ export const DEFAULT_CONTEXT = Object.freeze({ whole_file_max_lines: 400, hunk_context_lines: 40, min_context_lines: 10 });
19
+
20
+ /** `@@ -a[,b] +c[,d] @@` — the part of a hunk header a reviewer must echo (no function context). */
21
+ const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
22
+
23
+ /**
24
+ * @typedef {{header: string, oldStart: number, oldLines: number, newStart: number, newLines: number}} Hunk
25
+ */
26
+
27
+ /**
28
+ * Parse a unified diff of ONE file. The file header (`diff --git`, `index`, `---`, `+++`) is
29
+ * skipped by position — everything before the first `@@` — so a content line that starts with
30
+ * `++` or `--` is never mistaken for a header.
31
+ * @param {string} text
32
+ * @returns {{hunks: Hunk[], plusCount: number, minusCount: number}}
33
+ */
34
+ export function parseDiff(text) {
35
+ const lines = typeof text === 'string' ? text.split('\n') : [];
36
+ const first = lines.findIndex((line) => line.startsWith('@@'));
37
+ /** @type {Hunk[]} */
38
+ const hunks = [];
39
+ let plusCount = 0;
40
+ let minusCount = 0;
41
+ if (first < 0) return { hunks, plusCount, minusCount };
42
+ for (const line of lines.slice(first)) {
43
+ if (line.startsWith('@@')) {
44
+ const m = HUNK_RE.exec(line);
45
+ if (!m) continue;
46
+ hunks.push({
47
+ header: m[0],
48
+ oldStart: Number(m[1]),
49
+ oldLines: m[2] === undefined ? 1 : Number(m[2]),
50
+ newStart: Number(m[3]),
51
+ newLines: m[4] === undefined ? 1 : Number(m[4]),
52
+ });
53
+ } else if (line.startsWith('+')) {
54
+ plusCount += 1;
55
+ } else if (line.startsWith('-')) {
56
+ minusCount += 1;
57
+ }
58
+ }
59
+ return { hunks, plusCount, minusCount };
60
+ }
61
+
62
+ /**
63
+ * The file's lines (a final newline does not make an extra empty line).
64
+ * @param {string | null} content
65
+ * @returns {string[]}
66
+ */
67
+ export function splitLines(content) {
68
+ if (typeof content !== 'string' || content.length === 0) return [];
69
+ const lines = content.split('\n');
70
+ if (lines.at(-1) === '') lines.pop();
71
+ return lines;
72
+ }
73
+
74
+ /**
75
+ * One window per hunk over the CURRENT file: `[newStart − radius, newStart + max(newLines, 1) − 1
76
+ * + radius]`, clipped to the file; overlapping or touching windows merge.
77
+ * @param {ReadonlyArray<Hunk>} hunks @param {number} totalLines @param {number} radius
78
+ * @returns {Array<{start: number, end: number}>} 1-based, inclusive.
79
+ */
80
+ export function hunkWindows(hunks, totalLines, radius) {
81
+ if (totalLines === 0) return [];
82
+ const raw = hunks
83
+ .map((h) => {
84
+ const anchor = Math.max(1, h.newStart);
85
+ const last = anchor + Math.max(h.newLines, 1) - 1;
86
+ return { start: Math.max(1, anchor - radius), end: Math.min(totalLines, last + radius) };
87
+ })
88
+ .filter((w) => w.start <= w.end)
89
+ .sort((a, b) => a.start - b.start);
90
+ /** @type {Array<{start: number, end: number}>} */
91
+ const merged = [];
92
+ for (const w of raw) {
93
+ const prev = merged.at(-1);
94
+ if (prev && w.start <= prev.end + 1) prev.end = Math.max(prev.end, w.end);
95
+ else merged.push({ ...w });
96
+ }
97
+ return merged;
98
+ }
99
+
100
+ /** @param {Record<string, any> | undefined} cfg */
101
+ export function contextSettings(cfg) {
102
+ const c = cfg?.review?.context ?? {};
103
+ const pick = (/** @type {string} */ key) => (Number.isInteger(c[key]) && c[key] >= 0 ? c[key] : DEFAULT_CONTEXT[/** @type {keyof typeof DEFAULT_CONTEXT} */ (key)]);
104
+ return { whole_file_max_lines: pick('whole_file_max_lines'), hunk_context_lines: pick('hunk_context_lines'), min_context_lines: pick('min_context_lines') };
105
+ }
106
+
107
+ /**
108
+ * @typedef {object} Context
109
+ * @property {'whole' | 'hunks' | 'minimal' | 'recheck'} mode
110
+ * @property {string} text - the rendered context section body.
111
+ * @property {Array<{start: number, end: number}>} windows
112
+ * @property {number} lineCount - how many file lines the context carries.
113
+ */
114
+
115
+ /**
116
+ * Build the context for one file.
117
+ * @param {{file: string, content: string | null, hunks: ReadonlyArray<Hunk>, cfg?: Record<string, any>, mode?: 'auto' | 'minimal' | 'recheck'}} opts
118
+ * `mode: 'auto'` (default) applies the whole-file threshold; `minimal` and `recheck` force the
119
+ * hunk form with their radius.
120
+ * @returns {Context}
121
+ */
122
+ export function buildContext({ file, content, hunks, cfg, mode = 'auto' }) {
123
+ const settings = contextSettings(cfg);
124
+ const lines = splitLines(content);
125
+ /** @type {Context['mode']} */
126
+ let chosen;
127
+ if (mode === 'minimal') chosen = 'minimal';
128
+ else if (mode === 'recheck') chosen = 'recheck';
129
+ else chosen = lines.length <= settings.whole_file_max_lines ? 'whole' : 'hunks';
130
+
131
+ if (chosen === 'whole') {
132
+ const windows = lines.length > 0 ? [{ start: 1, end: lines.length }] : [];
133
+ const head = `### ${file} (whole file, ${lines.length} lines)`;
134
+ return { mode: chosen, windows, lineCount: lines.length, text: [head, ...numbered(lines, 1, lines.length)].join('\n') };
135
+ }
136
+ const radius = chosen === 'minimal' ? settings.min_context_lines : settings.hunk_context_lines;
137
+ const windows = hunkWindows(hunks, lines.length, radius);
138
+ const parts = [];
139
+ let lineCount = 0;
140
+ for (const w of windows) {
141
+ parts.push(`### ${file} lines ${w.start}-${w.end}`, ...numbered(lines, w.start, w.end));
142
+ lineCount += w.end - w.start + 1;
143
+ }
144
+ if (windows.length === 0) parts.push(`### ${file} (no current content)`);
145
+ return { mode: chosen, windows, lineCount, text: parts.join('\n') };
146
+ }
147
+
148
+ /**
149
+ * @param {string[]} lines @param {number} start @param {number} end - 1-based, inclusive.
150
+ * @returns {string[]}
151
+ */
152
+ function numbered(lines, start, end) {
153
+ const out = [];
154
+ for (let n = start; n <= end; n += 1) out.push(`${n}| ${lines[n - 1]}`);
155
+ return out;
156
+ }