@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,250 @@
1
+ /**
2
+ * `code-forge report` (plan §6.2, R4): builds the 13 report sections from a run's ledger rows.
3
+ * Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
4
+ *
5
+ * Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
6
+ * answers are "never acted on" — must not move a single aggregate).
7
+ *
8
+ * Row conventions read here: `dispatch` {block,level,lane,lines}; `block.close`
9
+ * {block,status,lines_actual} — ANY `block.close` row marks the block completed (a stop is
10
+ * `block.stop`; B19); `review.plan` {block,tier,depth_unconstrained,depth_chosen,
11
+ * degrade_step}; `session` {block,role,provider,level,tokens_in,tokens_out,cost_usd?} — one row per
12
+ * coder/reviewer/judge/s2/author/facts spend; its dollars are `cost_usd` when the row carries it,
13
+ * else ESTIMATED from its tokens × `./prices.mjs` (B19: live session rows carry tokens only); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
14
+ * {block,question,source}; `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
15
+ * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason}.
16
+ */
17
+
18
+ import { estimateCostUsd } from './prices.mjs';
19
+
20
+ const ROLE_BUCKET = Object.freeze({
21
+ coder: 'coderUsd',
22
+ author: 'coderUsd',
23
+ reviewer: 'reviewUsd',
24
+ judge: 'reviewUsd',
25
+ s2: 's1s2Usd',
26
+ facts: 'factsUsd',
27
+ });
28
+
29
+ /** @param {any[]} rows @param {(row: any) => string|undefined} keyOf */
30
+ function countBy(rows, keyOf) {
31
+ /** @type {Record<string, number>} */
32
+ const out = {};
33
+ for (const row of rows) {
34
+ const key = keyOf(row);
35
+ if (key !== undefined) out[key] = (out[key] ?? 0) + 1;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ /**
41
+ * A session row's dollars: `cost_usd` when present, else tokens × the static price table; null when
42
+ * neither is known (no token count, or a provider/level the table does not price).
43
+ * @param {Record<string, any>} row @returns {number | null}
44
+ */
45
+ export function sessionCostUsd(row) {
46
+ if (typeof row.cost_usd === 'number') return row.cost_usd;
47
+ const tokensIn = row.tokens_in ?? 0;
48
+ const tokensOut = row.tokens_out ?? 0;
49
+ if (row.tokens_in == null && row.tokens_out == null) return null;
50
+ try {
51
+ return estimateCostUsd({ provider: row.provider, level: row.level, tokensIn, tokensOut });
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** Cost per SUCCESSFULLY COMPLETED block: review $ / coder $ / S1+S2 $ / facts $ / proof ms. */
58
+ function costPerBlock(rows) {
59
+ /** @type {Map<string, any>} */
60
+ const byBlock = new Map();
61
+ for (const row of rows) {
62
+ if (!row.block) continue;
63
+ if (!byBlock.has(row.block)) {
64
+ byBlock.set(row.block, {
65
+ block: row.block,
66
+ level: null,
67
+ lane: null,
68
+ completed: false,
69
+ reviewUsd: 0,
70
+ coderUsd: 0,
71
+ s1s2Usd: 0,
72
+ factsUsd: 0,
73
+ otherUsd: 0,
74
+ proofTimeMs: 0,
75
+ totalUsd: 0,
76
+ });
77
+ }
78
+ const e = byBlock.get(row.block);
79
+ if (row.event === 'dispatch') {
80
+ e.level = row.level ?? e.level;
81
+ e.lane = row.lane ?? e.lane;
82
+ }
83
+ if (row.event === 'block.close') e.completed = true;
84
+ const cost = row.event === 'session' ? sessionCostUsd(row) : null;
85
+ if (cost !== null) {
86
+ // An unrecognized role still spent real money — bucket it as `otherUsd` instead of
87
+ // silently dropping it from every total (fix round 1: an unknown role must not make
88
+ // totalUsd under-report actual spend).
89
+ const bucket = ROLE_BUCKET[row.role] ?? 'otherUsd';
90
+ e[bucket] = (e[bucket] ?? 0) + cost;
91
+ e.totalUsd += cost;
92
+ }
93
+ if (row.event === 'proof' && typeof row.duration_ms === 'number') e.proofTimeMs += row.duration_ms;
94
+ }
95
+ const out = [...byBlock.values()].filter((e) => e.completed);
96
+ for (const e of out) {
97
+ for (const k of ['reviewUsd', 'coderUsd', 's1s2Usd', 'factsUsd', 'otherUsd', 'totalUsd']) e[k] = Math.round(e[k] * 10000) / 10000;
98
+ }
99
+ return out;
100
+ }
101
+
102
+ const laneDistribution = (rows) =>
103
+ rows.filter((r) => r.event === 'dispatch').map((r) => ({ block: r.block, lanePicked: r.lane ?? null, levelOpened: r.level ?? null }));
104
+
105
+ const escalationsPerBlock = (rows) => rows.filter((r) => r.event === 'escalation').map((r) => ({ block: r.block, trigger: r.trigger }));
106
+
107
+ const s1CallsPerBlock = (rows) =>
108
+ countBy(
109
+ rows.filter((r) => r.event === 'decision' && (r.source === 'jev' || r.source === 'rules')),
110
+ (r) => r.block,
111
+ );
112
+
113
+ function s2RateAndOverrule(rows) {
114
+ const s2Rows = rows.filter((r) => r.event === 'decision' && (r.source === 's2' || r.source === 's2-fallback'));
115
+ return {
116
+ calls: countBy(s2Rows, (r) => r.question),
117
+ overrules: countBy(
118
+ s2Rows.filter((r) => r.overrule === true),
119
+ (r) => r.question,
120
+ ),
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Review budget table (§4.10, R4 dataset): degrade count, `missed_after_degrade`, per-step plan
126
+ * counts. A file only counts as "degraded" when BOTH depth fields are present and differ — a row
127
+ * missing one of them is incomplete data, not evidence of degradation (`undefined !== 'full'` is
128
+ * true, which would otherwise silently count it). `missedAfterDegrade` counts DISTINCT
129
+ * `block::file` pairs that are BOTH flagged `missed_after_degrade: true` AND actually in the
130
+ * degraded set — this keeps a duplicate outcome row (a re-run `--scan-git`) or a stray flag on a
131
+ * file that was never degraded from inflating the count.
132
+ */
133
+ function reviewBudgetTable(rows) {
134
+ const planRows = rows.filter((r) => r.event === 'review.plan');
135
+ const degradedRows = planRows.filter(
136
+ (r) => r.depth_unconstrained !== undefined && r.depth_chosen !== undefined && r.depth_unconstrained !== r.depth_chosen,
137
+ );
138
+ const degradedKeys = new Set(degradedRows.map((r) => `${r.block}::${r.file}`));
139
+ const missedKeys = new Set(
140
+ rows
141
+ .filter((r) => r.event === 'outcome' && r.missed_after_degrade === true && degradedKeys.has(`${r.block}::${r.file}`))
142
+ .map((r) => `${r.block}::${r.file}`),
143
+ );
144
+ return {
145
+ filesPlanned: planRows.length,
146
+ degradedCount: degradedRows.length,
147
+ missedAfterDegrade: missedKeys.size,
148
+ byDegradeStep: countBy(planRows, (r) => String(r.degrade_step ?? 0)),
149
+ };
150
+ }
151
+
152
+ function reviewDepthAndFindings(rows) {
153
+ const doneRows = rows.filter((r) => r.event === 'review.done');
154
+ /** @type {Record<string, number>} */
155
+ const findingsBySeverity = {};
156
+ for (const r of doneRows) {
157
+ for (const [sev, n] of Object.entries(r.findings_by_severity ?? {})) findingsBySeverity[sev] = (findingsBySeverity[sev] ?? 0) + n;
158
+ }
159
+ return { contextModeDistribution: countBy(doneRows, (r) => r.context_mode), findingsBySeverity };
160
+ }
161
+
162
+ /** Keyed by `<block>::<file>`, not `file` alone — the same path reviewed in two different blocks
163
+ * is two independent fix-round histories, not one collapsed max. */
164
+ function fixRoundsPerFile(rows) {
165
+ /** @type {Record<string, number>} */
166
+ const maxRound = {};
167
+ for (const r of rows.filter((r) => r.event === 'review.done' && r.file)) {
168
+ const key = `${r.block}::${r.file}`;
169
+ maxRound[key] = Math.max(maxRound[key] ?? 0, r.round ?? 0);
170
+ }
171
+ return maxRound;
172
+ }
173
+
174
+ const reviewUnavailableByReason = (rows) =>
175
+ countBy(
176
+ rows.filter((r) => r.event === 'review.unavailable'),
177
+ (r) => r.reason,
178
+ );
179
+
180
+ function proofTimePerBlock(rows) {
181
+ /** @type {Record<string, number>} */
182
+ const out = {};
183
+ for (const r of rows.filter((r) => r.event === 'proof')) out[r.block] = (out[r.block] ?? 0) + (r.duration_ms ?? 0);
184
+ return out;
185
+ }
186
+
187
+ function forecastVsActualLines(rows) {
188
+ const forecasts = new Map(rows.filter((r) => r.event === 'dispatch').map((r) => [r.block, r.lines]));
189
+ return rows
190
+ .filter((r) => r.event === 'block.close' && typeof r.lines_actual === 'number')
191
+ .map((r) => ({ block: r.block, forecast: forecasts.get(r.block) ?? null, actual: r.lines_actual }));
192
+ }
193
+
194
+ const blocksStoppedAtL3 = (rows) => rows.filter((r) => r.event === 'block.stop' && r.level === 'L3').map((r) => r.block);
195
+
196
+ /** Distinct `block` (falling back to `run`, then a shared bucket) values among matching rows —
197
+ * `overspend`/`l3_fallback` can legitimately be stamped on more than one row for the SAME logical
198
+ * event (e.g. a session row and its `run.stop`/`block.stop`), so counting rows would double-count. */
199
+ const distinctScopeCount = (rows) => new Set(rows.map((r) => r.block ?? r.run ?? 'unscoped')).size;
200
+
201
+ const runStopCounts = (rows) => ({
202
+ overspend: distinctScopeCount(rows.filter((r) => r.overspend === true)),
203
+ l3Fallback: distinctScopeCount(rows.filter((r) => r.l3_fallback === true)),
204
+ noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
205
+ });
206
+
207
+ /** The exact, ordered set of section keys `buildReport` produces (plan §6.2: "13 sections") —
208
+ * exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
209
+ export const SECTION_NAMES = Object.freeze([
210
+ 'cost_per_block',
211
+ 'lane_distribution',
212
+ 'escalations',
213
+ 's1_calls_per_block',
214
+ 's2_rate',
215
+ 'review_budget',
216
+ 'review_depth_and_findings',
217
+ 'fix_rounds',
218
+ 'review_unavailable',
219
+ 'proof_time',
220
+ 'forecast_vs_actual_lines',
221
+ 'blocks_stopped_at_l3',
222
+ 'run_stop_counts',
223
+ ]);
224
+
225
+ /**
226
+ * @param {import('./write.mjs').LedgerRow[]} rows - raw ledger rows (`./write.mjs`'s `readAllRows`).
227
+ * @returns {{sections: Record<string, any>}} exactly the `SECTION_NAMES` sections (plan §6.2).
228
+ */
229
+ export function buildReport(rows) {
230
+ const acted = rows.filter((r) => r.source !== 'shadow');
231
+ const builders = {
232
+ cost_per_block: costPerBlock,
233
+ lane_distribution: laneDistribution,
234
+ escalations: escalationsPerBlock,
235
+ s1_calls_per_block: s1CallsPerBlock,
236
+ s2_rate: s2RateAndOverrule,
237
+ review_budget: reviewBudgetTable,
238
+ review_depth_and_findings: reviewDepthAndFindings,
239
+ fix_rounds: fixRoundsPerFile,
240
+ review_unavailable: reviewUnavailableByReason,
241
+ proof_time: proofTimePerBlock,
242
+ forecast_vs_actual_lines: forecastVsActualLines,
243
+ blocks_stopped_at_l3: blocksStoppedAtL3,
244
+ run_stop_counts: runStopCounts,
245
+ };
246
+ /** @type {Record<string, any>} */
247
+ const sections = {};
248
+ for (const name of SECTION_NAMES) sections[name] = builders[name](acted);
249
+ return { sections };
250
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * The one place that appends to the ledger — JSON Lines, append-only, rotated at 10 MB (plan
3
+ * §6.4). Later blocks write rows by importing `appendRow` from here (C17 seam rule: this module is
4
+ * the collaborator B3/B8/B9/... pass around as a parameter, never imported by a batch-mate).
5
+ *
6
+ * Rotation is race-safe two ways (fix round 1, MAJOR): (1) a per-slug, per-process promise chain
7
+ * serializes every `appendRow` for the same slug, so two calls in THIS process never interleave;
8
+ * (2) the rotated name is claimed with `link()` + `unlink()`, not `rename()` — `link` fails atomically
9
+ * with EEXIST if the candidate name is taken, so even a SECOND process racing on the same slug
10
+ * can't silently clobber another rotation (a plain `rename` onto an existing path succeeds and
11
+ * silently destroys whatever was there). Fix round 2 closes the remaining gap in that pair: when
12
+ * TWO racers both successfully `link()` to different candidate names before either `unlink()`s the
13
+ * source (both still see the source as present), the loser now removes its OWN candidate instead
14
+ * of leaving two hardlinks to identical data around (which `readAllRows` would otherwise count
15
+ * twice) — see `claimRotatedName`'s second `catch`.
16
+ */
17
+
18
+ import { appendFile, link, mkdir, open, readdir, readFile, stat, unlink } from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import { ledgerDir, ledgerPath } from './paths.mjs';
21
+
22
+ /** @typedef {Record<string, any>} LedgerRow - a ledger row is intentionally schema-free in 0.1. */
23
+
24
+ const DEFAULT_ROTATE_AT_BYTES = 10 * 1024 * 1024;
25
+
26
+ const hasTokens = (row) => typeof row.tokens_in === 'number' || typeof row.tokens_out === 'number';
27
+ const hasCost = (row) => typeof row.cost_usd === 'number';
28
+
29
+ /**
30
+ * Stamp `ts` and make `tokens_source`/`cost_source` ALWAYS-present keys (plan §6.1: "Tokens are
31
+ * facts, dollars are estimates"). Neither defaults to a non-null value unconditionally: an unset
32
+ * `tokens_source` becomes `'estimated'` ONLY when the row carries a token count, and stays `null`
33
+ * (present, not omitted) otherwise; an unset `cost_source` becomes `'estimated'` ONLY when the row
34
+ * carries `cost_usd` (0.1's only cost source is the static price table, `./prices.mjs`), and stays
35
+ * `null` otherwise. The acceptance bar is "the key exists on every row", not "every row has a
36
+ * non-null value" — a `run.start` row with neither field is `{tokens_source: null, cost_source: null}`.
37
+ */
38
+ function normalizeRow(row) {
39
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) {
40
+ throw new TypeError('appendRow: row must be a plain object');
41
+ }
42
+ if (typeof row.event !== 'string' || row.event.length === 0) {
43
+ throw new TypeError('appendRow: row.event must be a non-empty string');
44
+ }
45
+ return {
46
+ ...row,
47
+ ts: row.ts ?? new Date().toISOString(),
48
+ tokens_source: row.tokens_source ?? (hasTokens(row) ? 'estimated' : null),
49
+ cost_source: row.cost_source ?? (hasCost(row) ? 'estimated' : null),
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Claim `<base>.<n>.jsonl` for `n = 1, 2, ...` atomically via `link()` (hardlink the still-full
55
+ * file under the rotated name, then unlink the original) instead of `rename()`.
56
+ * @param {string} file @param {string} dir @param {string} base
57
+ */
58
+ export async function claimRotatedName(file, dir, base) {
59
+ let n = 1;
60
+ for (;;) {
61
+ const candidate = path.join(dir, `${base}.${n}.jsonl`);
62
+ try {
63
+ await link(file, candidate);
64
+ } catch (err) {
65
+ if (err.code === 'EEXIST') {
66
+ // Another racer already claimed THIS candidate name — try the next one.
67
+ n += 1;
68
+ continue;
69
+ }
70
+ if (err.code === 'ENOENT') return; // `file` vanished before we could link it — a racer already rotated it out
71
+ throw err;
72
+ }
73
+ try {
74
+ await unlink(file);
75
+ return;
76
+ } catch (err) {
77
+ if (err.code === 'ENOENT') {
78
+ // We successfully linked `candidate` to the source's data, but ANOTHER racer's unlink beat
79
+ // ours to removing the source — that racer's own candidate is the one that "owns" this
80
+ // rotation. Ours is now a redundant hardlink to the identical data; remove it so
81
+ // `readAllRows` doesn't read (and count) the same rows twice via two different filenames.
82
+ await unlink(candidate).catch(() => {});
83
+ return;
84
+ }
85
+ throw err;
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Rotate `file` to the next free `<base>.<n>.jsonl` once it reaches `rotateAtBytes`. Rotation
92
+ * only — torn-final-line handling lives in `truncateTornTail`.
93
+ * @param {string} file @param {number} rotateAtBytes
94
+ */
95
+ async function rotateIfNeeded(file, rotateAtBytes) {
96
+ let size;
97
+ try {
98
+ size = (await stat(file)).size;
99
+ } catch (err) {
100
+ if (err.code === 'ENOENT') return;
101
+ throw err;
102
+ }
103
+ if (size < rotateAtBytes) return;
104
+ await claimRotatedName(file, path.dirname(file), path.basename(file, '.jsonl'));
105
+ }
106
+
107
+ /**
108
+ * Recover from a torn final line (a crash or an overlapping write mid-append) by TRUNCATING the
109
+ * file back to the end of its last COMPLETE line, discarding the incomplete fragment — it was
110
+ * never a finished row, so there is nothing to preserve. This runs before every append, not just
111
+ * once: merely prefixing the next write with `\n` (isolating, not discarding, the fragment) fixes
112
+ * the FIRST torn write but leaves the fragment sitting in the middle of the file from then on,
113
+ * which `parseLines` correctly treats as real corruption and throws on — silently re-breaking the
114
+ * exact failure this recovery exists to prevent.
115
+ *
116
+ * Fix round 3: everything here is measured in BYTES. The cut point used to come from
117
+ * `lastIndexOf('\n')` on a UTF-8-DECODED string (UTF-16 code units) and was then passed to
118
+ * `truncate()` (bytes) — any non-ASCII row (`información`, an emoji) made the byte offset larger
119
+ * than the character offset, so the file was cut too early: complete rows lost and a fragment
120
+ * left mid-file. The common case (file ends in `\n`) now reads ONE byte, not the whole file; the
121
+ * full read happens only when the file really ends mid-line.
122
+ *
123
+ * Single-writer recovery: the per-slug chain serializes this process only. If the size changes
124
+ * between the read and the truncate (another process appended), the truncate is skipped rather
125
+ * than cutting that process's row; the next append retries the recovery.
126
+ * @param {string} file
127
+ */
128
+ async function truncateTornTail(file) {
129
+ let handle;
130
+ try {
131
+ handle = await open(file, 'r+');
132
+ } catch (err) {
133
+ if (err.code === 'ENOENT') return;
134
+ throw err;
135
+ }
136
+ try {
137
+ const { size } = await handle.stat();
138
+ if (size === 0) return;
139
+ const last = Buffer.alloc(1);
140
+ await handle.read(last, 0, 1, size - 1);
141
+ if (last[0] === 0x0a) return; // ends with '\n' — nothing torn
142
+ const buf = Buffer.alloc(size);
143
+ const { bytesRead } = await handle.read(buf, 0, size, 0);
144
+ const cleanBytes = buf.subarray(0, bytesRead).lastIndexOf(0x0a) + 1; // 0 when no complete line
145
+ if ((await handle.stat()).size !== size) return; // a concurrent writer moved the tail — leave it
146
+ await handle.truncate(cleanBytes);
147
+ } finally {
148
+ await handle.close();
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Per-slug promise chain (in-process only — `claimRotatedName`'s `link`/`unlink` pair is what
154
+ * protects against a SECOND process). A failed task never wedges the chain: the stored promise
155
+ * always resolves, while the caller's own promise still rejects with the task's real error.
156
+ * @type {Map<string, Promise<any>>}
157
+ */
158
+ const chains = new Map();
159
+
160
+ /** @param {string} slug @param {() => Promise<any>} task */
161
+ function enqueue(slug, task) {
162
+ const previous = (chains.get(slug) ?? Promise.resolve()).catch(() => {});
163
+ const result = previous.then(task);
164
+ chains.set(slug, result.catch(() => {}));
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * @param {LedgerRow} row - must carry a non-empty `event`; everything else is caller-defined.
170
+ * @param {{slug: string, rotateAtBytes?: number}} opts - `rotateAtBytes` defaults to 10 MB; tests
171
+ * pass a tiny value to prove rotation without writing 10 MB of fixture data.
172
+ * @returns {Promise<LedgerRow>} the normalized row that was written.
173
+ */
174
+ export async function appendRow(row, opts) {
175
+ // `async` (not a plain function returning a Promise) so a synchronous validation throw here
176
+ // — before the queue or disk are ever touched — still comes back as a REJECTED promise, not an
177
+ // exception thrown out of the call itself; a caller that always does
178
+ // `await appendRow(...)`/`assert.rejects(() => appendRow(...))` must see one consistent shape.
179
+ const { slug, rotateAtBytes = DEFAULT_ROTATE_AT_BYTES } = opts ?? {};
180
+ const normalized = normalizeRow(row); // validate before ever touching the queue/disk
181
+ ledgerPath(slug); // validates slug BEFORE mkdir/enqueue run at all — a bad slug must create nothing
182
+ if (!Number.isFinite(rotateAtBytes) || rotateAtBytes <= 0) {
183
+ throw new RangeError(`appendRow: rotateAtBytes must be a finite number > 0, got ${rotateAtBytes}`);
184
+ }
185
+ return enqueue(slug, async () => {
186
+ await mkdir(ledgerDir(), { recursive: true });
187
+ const file = ledgerPath(slug);
188
+ await rotateIfNeeded(file, rotateAtBytes);
189
+ await truncateTornTail(file);
190
+ await appendFile(file, `${JSON.stringify(normalized)}\n`, 'utf8');
191
+ return normalized;
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Parse JSONL text tolerant of a TORN final line (a crash or an overlapping write mid-append can
197
+ * leave the last line incomplete) — that one line is skipped silently. A malformed line anywhere
198
+ * else is real corruption and throws, rather than silently dropping data from the middle of the
199
+ * file.
200
+ * @param {string} text
201
+ * @returns {LedgerRow[]}
202
+ */
203
+ function parseLines(text) {
204
+ const lines = text.split('\n').filter((line) => line.length > 0);
205
+ const rows = [];
206
+ for (let i = 0; i < lines.length; i += 1) {
207
+ try {
208
+ rows.push(JSON.parse(lines[i]));
209
+ } catch (err) {
210
+ if (i === lines.length - 1) continue; // torn final line — benign, skip
211
+ throw err; // corruption in the middle — surface it
212
+ }
213
+ }
214
+ return rows;
215
+ }
216
+
217
+ /**
218
+ * Read and parse the CURRENT (non-rotated) ledger file for `slug` only. Missing file ⇒ `[]`. Most
219
+ * callers want `readAllRows` instead — this is the low-level primitive rotation tests use to
220
+ * inspect one file directly.
221
+ * @param {string} slug
222
+ * @returns {Promise<LedgerRow[]>}
223
+ */
224
+ export async function readRows(slug) {
225
+ let text;
226
+ try {
227
+ text = await readFile(ledgerPath(slug), 'utf8');
228
+ } catch (err) {
229
+ if (err.code === 'ENOENT') return [];
230
+ throw err;
231
+ }
232
+ return parseLines(text);
233
+ }
234
+
235
+ /**
236
+ * Read and parse EVERY row for `slug`, across rotated files (`<slug>.1.jsonl`, `<slug>.2.jsonl`,
237
+ * ...) and the live file, oldest first. Rotation moves the oldest rows out of `<slug>.jsonl`, not
238
+ * out of the ledger — a caller building a report, calibration table, or `--scan-git` idempotency
239
+ * check must read the full history, or it silently drops everything before the last rotation.
240
+ *
241
+ * Matches rotated filenames EXACTLY (`^<escaped-slug>\.\d+\.jsonl$`), not by a bare `startsWith`
242
+ * prefix — `SLUG_PATTERN` (`paths.mjs`) forbids `.` in a slug today, so slug `proj` and a
243
+ * hypothetical slug `proj.x` can never collide in practice, but the exact match keeps that true by
244
+ * construction rather than by a coincidence of the current pattern (fix round 2).
245
+ * @param {string} slug
246
+ * @returns {Promise<LedgerRow[]>}
247
+ */
248
+ export async function readAllRows(slug) {
249
+ ledgerPath(slug); // validates slug (throws TypeError on '..', '/', empty, etc.) — return value unused here
250
+ const dir = ledgerDir();
251
+ let entries;
252
+ try {
253
+ entries = await readdir(dir);
254
+ } catch (err) {
255
+ if (err.code === 'ENOENT') return [];
256
+ throw err;
257
+ }
258
+ const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
259
+ const rotatedExact = new RegExp(`^${escapedSlug}\\.(\\d+)\\.jsonl$`);
260
+ const rotated = entries
261
+ .map((f) => ({ f, m: rotatedExact.exec(f) }))
262
+ .filter((x) => x.m)
263
+ .map((x) => ({ f: x.f, n: Number(x.m[1]) }))
264
+ .sort((a, b) => a.n - b.n)
265
+ .map((x) => x.f);
266
+
267
+ const rows = [];
268
+ for (const file of [...rotated, `${slug}.jsonl`]) {
269
+ let text;
270
+ try {
271
+ text = await readFile(path.join(dir, file), 'utf8');
272
+ } catch (err) {
273
+ if (err.code === 'ENOENT') continue;
274
+ throw err;
275
+ }
276
+ rows.push(...parseLines(text));
277
+ }
278
+ return rows;
279
+ }