@codedology/code-forge 0.7.7 → 0.9.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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
|
@@ -58,8 +58,9 @@ export function linkDirsFromManifests(cwd) {
|
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
60
|
* The proof profile is decided from the project, not from the gate detector's stack name alone:
|
|
61
|
-
* `detectGates`
|
|
62
|
-
* (its Vite `package.json`) or `unknown` —
|
|
61
|
+
* `detectGates` names a Laravel app `php-pest` or `php-phpunit` only once its runner is installed
|
|
62
|
+
* (`vendor/bin/…`); before that it reaches here as `node` (its Vite `package.json`) or `unknown` —
|
|
63
|
+
* `composer.json` + `artisan` is Laravel either way.
|
|
63
64
|
* @param {string} stack - `detectGates().stack`
|
|
64
65
|
* @param {string} cwd
|
|
65
66
|
* @returns {string} the proof profile: `laravel`, `php`, `node`, `python`, `rust`, `go` or `unknown`.
|
|
@@ -274,6 +275,11 @@ function gateArgv(gate) {
|
|
|
274
275
|
return null;
|
|
275
276
|
}
|
|
276
277
|
|
|
278
|
+
/**
|
|
279
|
+
* A gate as the config holds it: an argv, `{argv, env}` (B60, kept from an existing config), or none.
|
|
280
|
+
* @typedef {string[] | {argv: string[], env?: Record<string, string>} | null} GateValue
|
|
281
|
+
*/
|
|
282
|
+
|
|
277
283
|
/**
|
|
278
284
|
* @typedef {object} Answers
|
|
279
285
|
* @property {'current'|'recommended'} tools
|
|
@@ -289,9 +295,13 @@ function gateArgv(gate) {
|
|
|
289
295
|
* @property {{mode: 'auto'} | {mode: 'ref', ref: string} | {mode: 'none'}} jev
|
|
290
296
|
* @property {string} engine
|
|
291
297
|
* @property {number|null} solo_project
|
|
292
|
-
* @property {{test:
|
|
298
|
+
* @property {{test: GateValue, lint: GateValue, types: GateValue, format: GateValue}} gates
|
|
293
299
|
* @property {{high: string[], isolation: string, link_dirs: string[], copy_untracked: string[]}} proof
|
|
294
300
|
* @property {boolean} doctor
|
|
301
|
+
* @property {boolean} coder_may_push - `autonomy.coder_may_push` (B65; default false).
|
|
302
|
+
* @property {boolean} coder_may_open_draft_pr - `autonomy.coder_may_open_draft_pr` (B65; default
|
|
303
|
+
* false). `resolveAnswers` sets it false whenever `coder_may_push` is false, except for an
|
|
304
|
+
* explicit `--coder-may-open-draft-pr yes`, which `runInit` refuses (exit 2): a draft PR needs a push.
|
|
295
305
|
*/
|
|
296
306
|
|
|
297
307
|
/**
|
|
@@ -300,10 +310,15 @@ function gateArgv(gate) {
|
|
|
300
310
|
* @typedef {{gates: Record<string, 'flag'|'existing'|'detected'>, proof: Record<string, 'flag'|'existing'|'detected'|'default'>}} Origins
|
|
301
311
|
*/
|
|
302
312
|
|
|
303
|
-
/**
|
|
313
|
+
/**
|
|
314
|
+
* The answer keys in the order they are settled: `askAnswers` asks the two autonomy keys last of
|
|
315
|
+
* its questions (after `solo_project`); gates and proof come later, in the settings step, and
|
|
316
|
+
* `doctor` is never asked (only `--skip-doctor` sets it).
|
|
317
|
+
*/
|
|
304
318
|
export const ANSWER_KEYS = Object.freeze([
|
|
305
319
|
'tools', 'yes_tools', 'harnesses', 'scope', 'method', 'provider', 'levels', 'refresh_models',
|
|
306
|
-
'multimodel', 'second_provider', 'jev', 'engine', 'solo_project', '
|
|
320
|
+
'multimodel', 'second_provider', 'jev', 'engine', 'solo_project', 'coder_may_push',
|
|
321
|
+
'coder_may_open_draft_pr', 'gates', 'proof', 'doctor',
|
|
307
322
|
]);
|
|
308
323
|
|
|
309
324
|
/**
|
|
@@ -382,6 +397,20 @@ export function resolveAnswers(ctx, existing, user, given) {
|
|
|
382
397
|
set('solo_project', user?.solo?.project_id, () => null);
|
|
383
398
|
if (lastFlag('solo_project')) override('solo_project', lastFlag('solo_project').value);
|
|
384
399
|
|
|
400
|
+
// B65: push and draft-PR autonomy default to no; a re-run proposes the existing booleans.
|
|
401
|
+
const exAutonomy = ex.autonomy && typeof ex.autonomy === 'object' ? ex.autonomy : {};
|
|
402
|
+
/** @param {unknown} b @returns {boolean|undefined} */
|
|
403
|
+
const bool = (b) => (typeof b === 'boolean' ? b : undefined);
|
|
404
|
+
set('coder_may_push', bool(exAutonomy.coder_may_push), () => false);
|
|
405
|
+
const pushFlag = lastFlag('coder_may_push');
|
|
406
|
+
if (pushFlag) override('coder_may_push', pushFlag.value);
|
|
407
|
+
set('coder_may_open_draft_pr', bool(exAutonomy.coder_may_open_draft_pr), () => false);
|
|
408
|
+
const draftFlag = lastFlag('coder_may_open_draft_pr');
|
|
409
|
+
if (draftFlag) override('coder_may_open_draft_pr', draftFlag.value);
|
|
410
|
+
// A draft PR needs a push: with push no, a kept draft-PR yes follows to no. Only an explicit
|
|
411
|
+
// `--coder-may-open-draft-pr yes` stays true here — `runInit` refuses that combination.
|
|
412
|
+
if (!v.coder_may_push && sources.coder_may_open_draft_pr !== 'flag') v.coder_may_open_draft_pr = false;
|
|
413
|
+
|
|
385
414
|
// Gates and proof are never asked (B24): each value comes from a flag, else the existing
|
|
386
415
|
// config, else the project. `origins` records which, per gate and per proof key. A gate the
|
|
387
416
|
// existing config left blank is filled when the project now shows one; a set gate is kept.
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* **Q16 = cut (Ricardo, 2026-09-25):** step 8 asks no mutation-tool or nightly question, so the
|
|
7
7
|
* v1.2 `--no-nightly` flag is gone and `--proof` takes only the keys the schema still has
|
|
8
8
|
* (`isolation`, `high`, `link_dirs`, `copy_untracked`). With `-g` and `-p` counted as two flags
|
|
9
|
-
* (two tokens that each set the scope answer) there are
|
|
9
|
+
* (two tokens that each set the scope answer) there are 21 answer flags: B65 added
|
|
10
|
+
* `--coder-may-push` and `--coder-may-open-draft-pr` (`yes`/`no`, default no).
|
|
10
11
|
*
|
|
11
12
|
* Parsing is strict: an unknown flag, a missing value, a repeated single-value flag or a bad value
|
|
12
13
|
* is a usage error. Error messages name the flag, never the value given (a user can paste a key
|
|
@@ -46,6 +47,8 @@ export const ANSWER_FLAGS = Object.freeze({
|
|
|
46
47
|
'--gate': 'gates',
|
|
47
48
|
'--proof': 'proof',
|
|
48
49
|
'--skip-doctor': 'doctor',
|
|
50
|
+
'--coder-may-push': 'coder_may_push',
|
|
51
|
+
'--coder-may-open-draft-pr': 'coder_may_open_draft_pr',
|
|
49
52
|
});
|
|
50
53
|
|
|
51
54
|
const BOOLEAN_FLAGS = new Set(['-g', '-p', '--copy', '--refresh-models', '--no-jev', '--skip-doctor', '--no-interaction']);
|
|
@@ -190,6 +193,10 @@ function convert(flag, value) {
|
|
|
190
193
|
if (argv.length === 0) throw new UsageError('--gate: empty command');
|
|
191
194
|
return { name, argv };
|
|
192
195
|
}
|
|
196
|
+
case '--coder-may-push':
|
|
197
|
+
case '--coder-may-open-draft-pr':
|
|
198
|
+
if (value !== 'yes' && value !== 'no') throw new UsageError(`${flag} must be yes or no`);
|
|
199
|
+
return value === 'yes';
|
|
193
200
|
case '--proof': {
|
|
194
201
|
const [key, raw] = keyValue('--proof', value);
|
|
195
202
|
if (!PROOF_KEYS.includes(key)) throw new UsageError(`--proof: key must be one of ${PROOF_KEYS.join(', ')}`);
|
|
@@ -55,6 +55,8 @@ export function generatedConfig(a, ctx, jevRef) {
|
|
|
55
55
|
isolation: a.proof.isolation,
|
|
56
56
|
export: { link_dirs: [...a.proof.link_dirs], copy_untracked: [...a.proof.copy_untracked] },
|
|
57
57
|
};
|
|
58
|
+
// B65: always both keys, also when both are false, so the file states the choice
|
|
59
|
+
cfg.autonomy = { coder_may_push: a.coder_may_push === true, coder_may_open_draft_pr: a.coder_may_push === true && a.coder_may_open_draft_pr === true };
|
|
58
60
|
return cfg;
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -59,6 +59,7 @@ export const USAGE = [
|
|
|
59
59
|
' [--harness a,b] [-g|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]…',
|
|
60
60
|
' [--refresh-models] [--multimodel on|off] [--second-provider P]',
|
|
61
61
|
' [--jev-ref <item-id|link|op://…> | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
|
|
62
|
+
' [--coder-may-push yes|no] [--coder-may-open-draft-pr yes|no]',
|
|
62
63
|
' [--gate name=cmd]… [--proof isolation=export|lock | high=a,b | link_dirs=a,b | copy_untracked=a,b]… [--skip-doctor]',
|
|
63
64
|
].join('\n');
|
|
64
65
|
|
|
@@ -230,6 +231,11 @@ export async function runInit(args, deps = {}) {
|
|
|
230
231
|
ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
231
232
|
await askAnswers(values, sources, ctx, ui);
|
|
232
233
|
}
|
|
234
|
+
// B65: a draft PR needs a push. `resolveAnswers` and `askAnswers` turn a kept draft-PR yes to
|
|
235
|
+
// no when push is no, so a yes left here came from the flag: refused.
|
|
236
|
+
if (values.coder_may_open_draft_pr && !values.coder_may_push) {
|
|
237
|
+
throw new InitFailure(2, '--coder-may-open-draft-pr yes needs autonomy.coder_may_push yes — a draft PR needs a push; pass --coder-may-push yes or drop the flag. Nothing written.');
|
|
238
|
+
}
|
|
233
239
|
|
|
234
240
|
// Gates and proof are never asked (B24): the summary says what was found and what is blank.
|
|
235
241
|
// A person sees it now, before anything is written; agent mode gets it in the JSON line.
|
|
@@ -62,6 +62,8 @@ export const HINTS = Object.freeze({
|
|
|
62
62
|
judge: 'The judge settles disagreements between the two reviewers.',
|
|
63
63
|
engine: 'auto uses Solo when present, otherwise the subagents of the current harness.',
|
|
64
64
|
solo_project: 'The Solo project that coder processes run in; leave blank for none.',
|
|
65
|
+
coder_may_push: 'No: the coder hands back its branch and you (or the orchestrator) push it.',
|
|
66
|
+
coder_may_open_draft_pr: 'Only after an independent review passes; marking it ready and merging stay with you.',
|
|
65
67
|
jev: 'Jev is the small fast classifier behind System 1; skip uses rules only.',
|
|
66
68
|
env: 'That variable must hold the key whenever code-forge runs.',
|
|
67
69
|
paste: 'The key goes to the OS key store; the config keeps only a reference.',
|
|
@@ -69,6 +71,10 @@ export const HINTS = Object.freeze({
|
|
|
69
71
|
apply: 'Only the listed keys change; your other edits stay.',
|
|
70
72
|
});
|
|
71
73
|
|
|
74
|
+
/** B65: the two autonomy questions, asked last among the answers (default no). */
|
|
75
|
+
export const PUSH_QUESTION = 'May a coder push its block branch on its own?';
|
|
76
|
+
export const DRAFT_PR_QUESTION = 'May a coder open a draft PR on its own?';
|
|
77
|
+
|
|
72
78
|
/** The one-line hints of the "Customize now" questions, in the order they are asked (B24). */
|
|
73
79
|
export const SETTING_HINTS = Object.freeze({
|
|
74
80
|
test: 'the command that runs your tests; the block cannot close if it fails (blank = no test gate)',
|
|
@@ -205,6 +211,16 @@ export async function askAnswers(values, sources, ctx, ui) {
|
|
|
205
211
|
const typed = await answer(ui, ui.text({ message: withHint('Solo project id (blank: none)', HINTS.solo_project), initialValue: values.solo_project === null ? '' : String(values.solo_project) }));
|
|
206
212
|
values.solo_project = /^[1-9]\d{0,9}$/.test(String(typed).trim()) ? Number(String(typed).trim()) : null;
|
|
207
213
|
}
|
|
214
|
+
// B65: push first; a draft PR needs a push, so with push no the draft-PR question is not asked
|
|
215
|
+
// and its answer is no (a `--coder-may-open-draft-pr yes` flag is then refused by `runInit`).
|
|
216
|
+
if (open('coder_may_push')) {
|
|
217
|
+
values.coder_may_push = (await answer(ui, ui.confirm({ message: withHint(PUSH_QUESTION, HINTS.coder_may_push), initialValue: values.coder_may_push === true }))) === true;
|
|
218
|
+
}
|
|
219
|
+
if (values.coder_may_push && open('coder_may_open_draft_pr')) {
|
|
220
|
+
values.coder_may_open_draft_pr = (await answer(ui, ui.confirm({ message: withHint(DRAFT_PR_QUESTION, HINTS.coder_may_open_draft_pr), initialValue: values.coder_may_open_draft_pr === true }))) === true;
|
|
221
|
+
} else if (open('coder_may_open_draft_pr')) {
|
|
222
|
+
values.coder_may_open_draft_pr = false;
|
|
223
|
+
}
|
|
208
224
|
return values;
|
|
209
225
|
}
|
|
210
226
|
|
|
@@ -240,16 +256,20 @@ export async function askProjectSettings(values, origins, ui) {
|
|
|
240
256
|
const changed = [];
|
|
241
257
|
for (const name of GATE_NAMES) {
|
|
242
258
|
if (origins.gates[name] === 'flag') continue;
|
|
243
|
-
const gates = /** @type {Record<string,
|
|
259
|
+
const gates = /** @type {Record<string, import('./answers.mjs').GateValue>} */ (values.gates);
|
|
244
260
|
const current = gates[name];
|
|
261
|
+
// B60: a gate kept as {argv, env} is edited by its argv; its env is kept (and never shown).
|
|
262
|
+
const currentArgv = /** @type {string[] | null} */ (Array.isArray(current) || !current ? current : current.argv);
|
|
245
263
|
const typed = await answer(ui, ui.text({
|
|
246
264
|
message: withHint(`Gate ${name} (blank or "none": no gate)`, SETTING_HINTS[/** @type {keyof typeof SETTING_HINTS} */ (name)]),
|
|
247
|
-
initialValue:
|
|
265
|
+
initialValue: currentArgv ? currentArgv.join(' ') : '',
|
|
248
266
|
}));
|
|
249
267
|
const text = String(typed ?? '').trim();
|
|
268
|
+
// blank or "none" turns the gate off: a gate kept as {argv, env} then loses its env too
|
|
250
269
|
const next = text === '' || text === 'none' ? null : shellWords(text);
|
|
251
|
-
if (JSON.stringify(next)
|
|
252
|
-
gates
|
|
270
|
+
if (JSON.stringify(next) === JSON.stringify(currentArgv)) continue;
|
|
271
|
+
changed.push(`gates.${name}`);
|
|
272
|
+
gates[name] = next && current && !Array.isArray(current) ? { ...current, argv: next } : next;
|
|
253
273
|
}
|
|
254
274
|
const proof = values.proof;
|
|
255
275
|
/** @param {'high'|'link_dirs'|'copy_untracked'} key @param {string} message @param {string} path */
|
|
@@ -46,7 +46,10 @@ const STACK_MARKER = Object.freeze({ rust: 'Cargo.toml', python: 'pyproject.toml
|
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* @typedef {object} Setting
|
|
49
|
-
* @property {unknown} value -
|
|
49
|
+
* @property {unknown} value - the value shown (a gate: its argv). The config file is written from
|
|
50
|
+
* `values.gates`, not from here, so a `{argv, env}` gate keeps its env.
|
|
51
|
+
* @property {string[]} [env_keys] - B60: a gate given as `{argv, env}` with a non-empty env: the
|
|
52
|
+
* variable NAMES, sorted (never the values).
|
|
50
53
|
* @property {'flag'|'existing'|'detected'|'default'} source
|
|
51
54
|
* @property {string} note - where it came from, or why it is blank, in the summary's words.
|
|
52
55
|
*/
|
|
@@ -115,7 +118,12 @@ export function summarizeSettings(values, origins, ctx) {
|
|
|
115
118
|
|
|
116
119
|
for (const name of GATE_NAMES) {
|
|
117
120
|
const key = SETTING_KEYS[/** @type {keyof typeof SETTING_KEYS} */ (name)];
|
|
118
|
-
const
|
|
121
|
+
const command = values.gates[/** @type {keyof typeof values.gates} */ (name)];
|
|
122
|
+
// B60: a gate given as {argv, env} is summarised by its argv and its env NAMES, never the values;
|
|
123
|
+
// an empty env counts as none
|
|
124
|
+
const envKeys = command && !Array.isArray(command) && command.env && Object.keys(command.env).length > 0 ? Object.keys(command.env).sort() : null;
|
|
125
|
+
const envNote = envKeys ? ` (env: ${envKeys.join(', ')})` : '';
|
|
126
|
+
const argv = /** @type {string[] | null} */ (Array.isArray(command) || !command ? command : command.argv);
|
|
119
127
|
const source = origins.gates[name] ?? 'detected';
|
|
120
128
|
let note;
|
|
121
129
|
if (argv === null || argv === undefined || argv.length === 0) {
|
|
@@ -123,12 +131,12 @@ export function summarizeSettings(values, origins, ctx) {
|
|
|
123
131
|
else if (source === 'existing') note = 'empty in .code-forge.yml';
|
|
124
132
|
else note = GATE_MISSING[/** @type {keyof typeof GATE_MISSING} */ (name)];
|
|
125
133
|
blank.push(key);
|
|
126
|
-
lines.push(` ${name}: blank — ${note}`);
|
|
134
|
+
lines.push(` ${name}: blank${envNote} — ${note}`);
|
|
127
135
|
} else {
|
|
128
136
|
note = source === 'detected' ? gateEvidence(ctx.detected.stack, name, argv, ctx.cwd) : keptFrom(source, '--gate');
|
|
129
|
-
lines.push(` ${name}: ${shown(argv)} (from ${note})`);
|
|
137
|
+
lines.push(` ${name}: ${shown(argv)}${envNote} (from ${note})`);
|
|
130
138
|
}
|
|
131
|
-
settings[key] = { value: argv, source, note };
|
|
139
|
+
settings[key] = { value: argv, source, note, ...(envKeys ? { env_keys: envKeys } : {}) };
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
const found = new Map(linkDirsFromManifests(ctx.cwd).map((e) => [e.dir, e.manifests]));
|
package/src/ledger/export.mjs
CHANGED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B66 (issue #13 item 8): outside review rounds per PR, next to what code-forge spent on the PR's
|
|
3
|
+
* blocks. Pure helpers, no I/O:
|
|
4
|
+
*
|
|
5
|
+
* - `parseOutsideReviewArgs` reads `ledger outside-review --run <r> --pr <n> --rounds <n>
|
|
6
|
+
* --findings <n> [--link <url>] [--block <id>]…` (the verb in `src/cli/ledger.mjs` does the
|
|
7
|
+
* run lookup, the block check, the redaction and the signed write);
|
|
8
|
+
* - `buildOutsideReview` builds the `outside_review` report section from the ledger rows and the
|
|
9
|
+
* `cost_per_block` entries (`src/ledger/report.mjs` passes them in, so the PR cost is computed
|
|
10
|
+
* by the SAME function the `cost_per_block` section uses);
|
|
11
|
+
* - `outsideReviewLines` renders the section as terminal text.
|
|
12
|
+
*
|
|
13
|
+
* Row: `outside_review {run, pr, rounds, findings, link (or null), blocks: [ids]}`. A later row for
|
|
14
|
+
* the same PR replaces the earlier one in every reader (the latest row in ledger order wins); the
|
|
15
|
+
* ledger itself stays append-only.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { roundUsd } from './spend.mjs';
|
|
19
|
+
|
|
20
|
+
export const OUTSIDE_REVIEW_EVENT = 'outside_review';
|
|
21
|
+
|
|
22
|
+
export const OUTSIDE_REVIEW_USAGE =
|
|
23
|
+
'usage: code-forge ledger outside-review --run <r> --pr <n> --rounds <n> --findings <n> [--link <url>] [--block <id>]...';
|
|
24
|
+
|
|
25
|
+
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
26
|
+
const POSITIVE_INT = /^[1-9]\d*$/;
|
|
27
|
+
const NON_NEGATIVE_INT = /^(0|[1-9]\d*)$/;
|
|
28
|
+
const LINK = /^https?:\/\/\S+$/;
|
|
29
|
+
const MAX_LINK_CHARS = 2048;
|
|
30
|
+
|
|
31
|
+
/** @param {unknown} n @returns {boolean} */
|
|
32
|
+
const isCount = (n) => Number.isSafeInteger(n) && /** @type {number} */ (n) >= 0;
|
|
33
|
+
|
|
34
|
+
const SINGLE_FLAGS = new Set(['--run', '--pr', '--rounds', '--findings', '--link']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {{run: string, pr: number, rounds: number, findings: number, link: string | null, blocks: string[]}} OutsideReviewArgs
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} raw @param {RegExp} shape @returns {number | null}
|
|
42
|
+
*/
|
|
43
|
+
function parseShapedInt(raw, shape) {
|
|
44
|
+
if (!shape.test(raw)) return null;
|
|
45
|
+
const n = Number(raw);
|
|
46
|
+
return Number.isSafeInteger(n) ? n : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse the verb's flags (everything after `outside-review`). Every flag but `--block` is given at
|
|
51
|
+
* most once; `--block` may repeat (duplicates collapsed, first-seen order kept). Any other token,
|
|
52
|
+
* a flag with no value, a missing required flag or a value of the wrong shape is a usage error.
|
|
53
|
+
* The link is returned as given — the caller redacts it once the run key is registered.
|
|
54
|
+
* @param {string[]} args
|
|
55
|
+
* @returns {{ok: true, value: OutsideReviewArgs} | {ok: false, message: string}}
|
|
56
|
+
*/
|
|
57
|
+
export function parseOutsideReviewArgs(args) {
|
|
58
|
+
/** @type {Record<string, string>} */
|
|
59
|
+
const single = {};
|
|
60
|
+
/** @type {string[]} */
|
|
61
|
+
const blocks = [];
|
|
62
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
63
|
+
const flag = args[i];
|
|
64
|
+
const value = args[i + 1];
|
|
65
|
+
// the position only, never the token: a stray value (a loose URL) may carry a secret
|
|
66
|
+
if (flag !== '--block' && !SINGLE_FLAGS.has(flag)) return { ok: false, message: `unknown argument at position ${i + 1}` };
|
|
67
|
+
if (value === undefined || value.startsWith('--')) return { ok: false, message: `${flag} requires a value` };
|
|
68
|
+
if (flag === '--block') {
|
|
69
|
+
if (!blocks.includes(value)) blocks.push(value);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (Object.hasOwn(single, flag)) return { ok: false, message: `${flag} is given more than once` };
|
|
73
|
+
single[flag] = value;
|
|
74
|
+
}
|
|
75
|
+
for (const flag of ['--run', '--pr', '--rounds', '--findings']) {
|
|
76
|
+
if (!Object.hasOwn(single, flag)) return { ok: false, message: `${flag} is required` };
|
|
77
|
+
}
|
|
78
|
+
const run = single['--run'];
|
|
79
|
+
if (!RUN_ID.test(run)) return { ok: false, message: '--run must be a run id (letters, digits, ".", "_", "-")' };
|
|
80
|
+
const pr = parseShapedInt(single['--pr'], POSITIVE_INT);
|
|
81
|
+
if (pr === null) return { ok: false, message: '--pr must be a positive integer' };
|
|
82
|
+
const rounds = parseShapedInt(single['--rounds'], NON_NEGATIVE_INT);
|
|
83
|
+
if (rounds === null) return { ok: false, message: '--rounds must be a non-negative integer' };
|
|
84
|
+
const findings = parseShapedInt(single['--findings'], NON_NEGATIVE_INT);
|
|
85
|
+
if (findings === null) return { ok: false, message: '--findings must be a non-negative integer' };
|
|
86
|
+
let link = null;
|
|
87
|
+
if (Object.hasOwn(single, '--link')) {
|
|
88
|
+
link = single['--link'];
|
|
89
|
+
if (link.length > MAX_LINK_CHARS) return { ok: false, message: `--link is longer than ${MAX_LINK_CHARS} characters` };
|
|
90
|
+
if (!LINK.test(link)) return { ok: false, message: '--link must be an https:// or http:// URL with no whitespace' };
|
|
91
|
+
}
|
|
92
|
+
return { ok: true, value: { run, pr, rounds, findings, link, blocks } };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {string[]} blocks @returns {string} */
|
|
96
|
+
const blockList = (blocks) => (blocks.length > 0 ? blocks.join(', ') : '-');
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The verb's one stdout line.
|
|
100
|
+
* @param {{pr: number, rounds: number, findings: number, blocks: string[]}} row @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
export function outsideReviewRecordedLine({ pr, rounds, findings, blocks }) {
|
|
103
|
+
return `outside_review pr ${pr} · rounds ${rounds} · findings ${findings} · blocks ${blockList(blocks)}\n`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @typedef {{pr: number, rounds: number, findings: number, link: string | null, blocks: string[], code_forge_usd: number, unknown_usd_sessions: number}} OutsideReviewEntry
|
|
108
|
+
*/
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The `outside_review` report section: one entry per PR (the latest `outside_review` row for it,
|
|
112
|
+
* by ledger order), sorted by PR number ascending. `code_forge_usd` sums `totalUsd` of the
|
|
113
|
+
* `cost_per_block` entries of the row's run whose block the row names; `unknown_usd_sessions` sums
|
|
114
|
+
* their `unknownUsdSessions` (those sessions count 0 in the dollars). `[]` with no rows.
|
|
115
|
+
* @param {Array<Record<string, any>>} rows - shadow rows already removed.
|
|
116
|
+
* @param {Array<{run: string | null, block: string, totalUsd: number, unknownUsdSessions: number}>} costEntries - the `cost_per_block` section.
|
|
117
|
+
* @returns {OutsideReviewEntry[]}
|
|
118
|
+
*/
|
|
119
|
+
export function buildOutsideReview(rows, costEntries) {
|
|
120
|
+
/** @type {Map<number, Record<string, any>>} */
|
|
121
|
+
const latest = new Map();
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
if (row.event !== OUTSIDE_REVIEW_EVENT || !Number.isSafeInteger(row.pr) || row.pr <= 0) continue;
|
|
124
|
+
// a row whose counts are not 0-or-more safe integers is skipped (the verb never writes one)
|
|
125
|
+
if (!isCount(row.rounds) || !isCount(row.findings)) continue;
|
|
126
|
+
latest.set(row.pr, row);
|
|
127
|
+
}
|
|
128
|
+
return [...latest.values()]
|
|
129
|
+
.sort((a, b) => a.pr - b.pr)
|
|
130
|
+
.map((row) => {
|
|
131
|
+
const blocks = Array.isArray(row.blocks) ? row.blocks.filter((b) => typeof b === 'string') : [];
|
|
132
|
+
const run = typeof row.run === 'string' ? row.run : null;
|
|
133
|
+
// a row with no string run matches no cost entry (never the null-run entries): 0 and 0 unknown
|
|
134
|
+
const mine = run === null ? [] : costEntries.filter((e) => e.run === run && blocks.includes(e.block));
|
|
135
|
+
const usd = mine.reduce((sum, e) => sum + e.totalUsd, 0);
|
|
136
|
+
return {
|
|
137
|
+
pr: row.pr,
|
|
138
|
+
rounds: row.rounds,
|
|
139
|
+
findings: row.findings,
|
|
140
|
+
link: typeof row.link === 'string' ? row.link : null,
|
|
141
|
+
blocks,
|
|
142
|
+
// rounded as `cost_per_block` rounds its dollars (4 decimals)
|
|
143
|
+
code_forge_usd: roundUsd(usd),
|
|
144
|
+
unknown_usd_sessions: mine.reduce((sum, e) => sum + e.unknownUsdSessions, 0),
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @param {number} n @param {string} word @returns {string} */
|
|
150
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Terminal text of the section: `outside review: none` with no entry, else one line per PR.
|
|
154
|
+
* @param {OutsideReviewEntry[]} section @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function outsideReviewLines(section) {
|
|
157
|
+
if (section.length === 0) return 'outside review: none\n';
|
|
158
|
+
return section
|
|
159
|
+
.map((e) => {
|
|
160
|
+
const unknown = e.unknown_usd_sessions > 0 ? ` (+${e.unknown_usd_sessions} unknown)` : '';
|
|
161
|
+
return `PR #${e.pr} · ${plural(e.rounds, 'outside round')} · ${plural(e.findings, 'finding')} · code-forge $${e.code_forge_usd.toFixed(2)}${unknown} · ${blockList(e.blocks)}\n`;
|
|
162
|
+
})
|
|
163
|
+
.join('');
|
|
164
|
+
}
|
package/src/ledger/report.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge report` (plan §6.2, R4): builds the
|
|
2
|
+
* `code-forge report` (plan §6.2, R4): builds the 14 report sections from a run's ledger rows.
|
|
3
3
|
* Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
|
|
4
4
|
*
|
|
5
5
|
* Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
|
|
@@ -13,9 +13,14 @@
|
|
|
13
13
|
* `session.background` rows, whose price is unknown); its dollars come from `./spend.mjs`
|
|
14
14
|
* (`usd`, else `cost_usd`, else ESTIMATED from its tokens × `./prices.mjs`); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
|
|
15
15
|
* {block,question,source}; `run.start` {run}; `run.mode` {run,mode,plan} (B59, `buildRunModes`); `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
|
|
16
|
-
* `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason}
|
|
16
|
+
* `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason};
|
|
17
|
+
* `outside_review` {run,pr,rounds,findings,link,blocks} (B66, `./outside-review.mjs`).
|
|
18
|
+
*
|
|
19
|
+
* Signatures are not verified here: like every other section, `outside_review` reads the rows as
|
|
20
|
+
* the ledger holds them (an unsigned or tampered row is counted, not dropped).
|
|
17
21
|
*/
|
|
18
22
|
|
|
23
|
+
import { buildOutsideReview } from './outside-review.mjs';
|
|
19
24
|
import { isSpendRow, rowUsd, spendByRun, sumSpend } from './spend.mjs';
|
|
20
25
|
|
|
21
26
|
const ROLE_BUCKET = Object.freeze({
|
|
@@ -51,7 +56,7 @@ export function sessionCostUsd(row) {
|
|
|
51
56
|
|
|
52
57
|
/**
|
|
53
58
|
* B33: spend per run (running totals, open blocks included) and over the whole ledger — kept
|
|
54
|
-
* beside the
|
|
59
|
+
* beside the sections, never one of them (text only, not in `SECTION_NAMES`).
|
|
55
60
|
* @param {Array<Record<string, any>>} rows
|
|
56
61
|
* @returns {{runs: Array<{run: string | null, usd: number, unknown: number, sessions: number}>, total: {usd: number, unknown: number, sessions: number}}}
|
|
57
62
|
*/
|
|
@@ -257,7 +262,8 @@ const runStopCounts = (rows) => ({
|
|
|
257
262
|
noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
|
|
258
263
|
});
|
|
259
264
|
|
|
260
|
-
/** The exact, ordered set of section keys `buildReport` produces (plan §6.2
|
|
265
|
+
/** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended the
|
|
266
|
+
* 14th, `outside_review` — append new sections, never reorder) —
|
|
261
267
|
* exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
|
|
262
268
|
export const SECTION_NAMES = Object.freeze([
|
|
263
269
|
'cost_per_block',
|
|
@@ -273,6 +279,7 @@ export const SECTION_NAMES = Object.freeze([
|
|
|
273
279
|
'forecast_vs_actual_lines',
|
|
274
280
|
'blocks_stopped_at_l3',
|
|
275
281
|
'run_stop_counts',
|
|
282
|
+
'outside_review',
|
|
276
283
|
]);
|
|
277
284
|
|
|
278
285
|
/**
|
|
@@ -281,8 +288,10 @@ export const SECTION_NAMES = Object.freeze([
|
|
|
281
288
|
*/
|
|
282
289
|
export function buildReport(rows) {
|
|
283
290
|
const acted = rows.filter((r) => r.source !== 'shadow');
|
|
291
|
+
// computed once: `cost_per_block` and `outside_review` share the same entries
|
|
292
|
+
const costs = costPerBlock(acted);
|
|
284
293
|
const builders = {
|
|
285
|
-
cost_per_block:
|
|
294
|
+
cost_per_block: () => costs,
|
|
286
295
|
lane_distribution: laneDistribution,
|
|
287
296
|
escalations: escalationsPerBlock,
|
|
288
297
|
s1_calls_per_block: s1CallsPerBlock,
|
|
@@ -295,6 +304,9 @@ export function buildReport(rows) {
|
|
|
295
304
|
forecast_vs_actual_lines: forecastVsActualLines,
|
|
296
305
|
blocks_stopped_at_l3: blocksStoppedAtL3,
|
|
297
306
|
run_stop_counts: runStopCounts,
|
|
307
|
+
// B66: per PR, the outside review rounds next to the cost of its blocks — the SAME
|
|
308
|
+
// `costPerBlock` entries the `cost_per_block` section shows
|
|
309
|
+
outside_review: (/** @type {any[]} */ r) => buildOutsideReview(r, costs),
|
|
298
310
|
};
|
|
299
311
|
/** @type {Record<string, any>} */
|
|
300
312
|
const sections = {};
|
package/src/ledger/write.mjs
CHANGED
|
@@ -53,9 +53,14 @@ function normalizeRow(row) {
|
|
|
53
53
|
/**
|
|
54
54
|
* Claim `<base>.<n>.jsonl` for `n = 1, 2, ...` atomically via `link()` (hardlink the still-full
|
|
55
55
|
* file under the rotated name, then unlink the original) instead of `rename()`.
|
|
56
|
+
*
|
|
57
|
+
* `hooks.afterLink` is a test seam only: when given, it is awaited with the claimed candidate path
|
|
58
|
+
* right after a successful `link()` and before this call's `unlink()` of the source, so a test can
|
|
59
|
+
* force another racer's unlink into exactly that window. Production callers pass nothing.
|
|
56
60
|
* @param {string} file @param {string} dir @param {string} base
|
|
61
|
+
* @param {{ afterLink?: (candidate: string) => Promise<void> | void }} [hooks]
|
|
57
62
|
*/
|
|
58
|
-
export async function claimRotatedName(file, dir, base) {
|
|
63
|
+
export async function claimRotatedName(file, dir, base, hooks = {}) {
|
|
59
64
|
let n = 1;
|
|
60
65
|
for (;;) {
|
|
61
66
|
const candidate = path.join(dir, `${base}.${n}.jsonl`);
|
|
@@ -70,6 +75,7 @@ export async function claimRotatedName(file, dir, base) {
|
|
|
70
75
|
if (err.code === 'ENOENT') return; // `file` vanished before we could link it — a racer already rotated it out
|
|
71
76
|
throw err;
|
|
72
77
|
}
|
|
78
|
+
if (hooks.afterLink) await hooks.afterLink(candidate);
|
|
73
79
|
try {
|
|
74
80
|
await unlink(file);
|
|
75
81
|
return;
|