@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,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
|
+
}
|