@codedology/code-forge 0.3.2 → 0.4.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 +50 -0
- package/README.md +79 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +153 -1
- package/docs/reference/config.md +8 -1
- package/package.json +4 -2
- package/schema/code-forge.schema.json +15 -4
- package/skill/SKILL.md +7 -7
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +8 -2
- package/skill/references/plan.md +3 -3
- package/skill/references/review.md +8 -5
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/questions.mjs +27 -4
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +171 -0
- package/src/review/engine.mjs +57 -16
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +6 -3
- package/src/session/author.mjs +6 -2
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +217 -8
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/worker/engine.mjs +13 -8
- package/src/worker/loop.mjs +135 -39
- package/src/worker/queue.mjs +68 -7
package/src/cli/ledger.mjs
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
* ledger calibration --slug <slug> [--question <id>]
|
|
7
7
|
* ledger outcome --scan-git --slug <slug> [--cwd <dir>] [--days <n>]
|
|
8
8
|
* ledger outcome --pr <n> --ci red|green|reverted --slug <slug>
|
|
9
|
-
* ledger tail --slug <slug> [--n <count>]
|
|
9
|
+
* ledger tail --slug <slug> [--n <count> | -n <count>]
|
|
10
|
+
* ledger add coder --run <r> --block <b> --usd <n> [--note "..."]
|
|
11
|
+
*
|
|
12
|
+
* B33: `ledger add coder` records manual/cloud coder spend (a session code-forge never saw) as a
|
|
13
|
+
* signed `session` row (`role: coder, manual: true, usd, cost_source: manual`) in the run's
|
|
14
|
+
* ledger, so `report` and the `budget.usd` check count it. The slug is the run record's project.
|
|
10
15
|
*/
|
|
11
16
|
|
|
12
17
|
import { writeSafe } from '../util/redact.mjs';
|
|
13
18
|
import { buildCalibration } from '../ledger/calibration.mjs';
|
|
14
19
|
import { recordCiOutcome, reviewedEntriesFromRows, scanGitAndRecord } from '../ledger/outcome.mjs';
|
|
15
|
-
import {
|
|
20
|
+
import { roundUsd, runSpend } from '../ledger/spend.mjs';
|
|
21
|
+
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
22
|
+
import { readRun, writeSigned } from '../state/run.mjs';
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* @param {string[]} args @param {string} flag
|
|
@@ -56,9 +63,10 @@ export async function runLedger(args, deps = {}) {
|
|
|
56
63
|
const { stdout = process.stdout, stderr = process.stderr } = deps;
|
|
57
64
|
const out = (/** @type {string} */ s) => writeSafe(stdout, s);
|
|
58
65
|
const err = (/** @type {string} */ s) => writeSafe(stderr, s);
|
|
59
|
-
const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...]\n');
|
|
66
|
+
const usage = () => err('usage: code-forge ledger calibration|outcome|tail --slug <slug> [...] (tail: [--n|-n <count>]) | add coder --run <r> --block <b> --usd <n> [--note "..."]\n');
|
|
60
67
|
|
|
61
68
|
const [sub] = args;
|
|
69
|
+
if (sub === 'add') return addSpend(args.slice(1), { out, err });
|
|
62
70
|
const slug = flagValue(args, '--slug');
|
|
63
71
|
if (!slug) {
|
|
64
72
|
usage();
|
|
@@ -126,15 +134,22 @@ export async function runLedger(args, deps = {}) {
|
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
if (sub === 'tail') {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
137
|
+
// `-n` is the spelling everyone types from `tail(1)`; before B31 it was not recognised, so
|
|
138
|
+
// `ledger tail -n 3` silently printed the default 20 rows (issue #2, field note 8).
|
|
139
|
+
const longN = readOptionalFlag(args, '--n');
|
|
140
|
+
const shortN = readOptionalFlag(args, '-n');
|
|
141
|
+
if (!longN.ok || !shortN.ok) {
|
|
142
|
+
err(`ledger tail: ${longN.ok ? '-n' : '--n'} requires a value\n`);
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
if (longN.value !== undefined && shortN.value !== undefined) {
|
|
146
|
+
err('ledger tail: give the count once, as --n <count> or -n <count>\n');
|
|
132
147
|
return 2;
|
|
133
148
|
}
|
|
134
|
-
const nArg =
|
|
149
|
+
const nArg = longN.value ?? shortN.value ?? '20';
|
|
135
150
|
const n = parseNonNegativeInt(nArg);
|
|
136
151
|
if (n === null) {
|
|
137
|
-
err(`ledger tail: --n must be a non-negative integer, got "${nArg}"\n`);
|
|
152
|
+
err(`ledger tail: --n/-n must be a non-negative integer, got "${nArg}"\n`);
|
|
138
153
|
return 2;
|
|
139
154
|
}
|
|
140
155
|
const rows = await readAllRows(slug);
|
|
@@ -154,6 +169,82 @@ export async function runLedger(args, deps = {}) {
|
|
|
154
169
|
return 2;
|
|
155
170
|
}
|
|
156
171
|
|
|
172
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
173
|
+
const MAX_NOTE_CHARS = 500;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `ledger add coder --run <r> --block <b> --usd <n> [--note "..."]` (B33).
|
|
177
|
+
* @param {string[]} args - after `add`.
|
|
178
|
+
* @param {{out: (s: string) => void, err: (s: string) => void}} io
|
|
179
|
+
* @returns {Promise<number>}
|
|
180
|
+
*/
|
|
181
|
+
async function addSpend(args, { out, err }) {
|
|
182
|
+
const usage = () => err('usage: code-forge ledger add coder --run <r> --block <b> --usd <n> [--note "..."]\n');
|
|
183
|
+
const [role] = args;
|
|
184
|
+
const run = flagValue(args, '--run');
|
|
185
|
+
const block = flagValue(args, '--block');
|
|
186
|
+
const usdArg = flagValue(args, '--usd');
|
|
187
|
+
if (role !== 'coder' || !run || !block || usdArg === undefined) {
|
|
188
|
+
usage();
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
const note = readOptionalFlag(args, '--note');
|
|
192
|
+
if (!note.ok) {
|
|
193
|
+
err('ledger add: --note requires a value\n');
|
|
194
|
+
return 2;
|
|
195
|
+
}
|
|
196
|
+
if (!ID.test(run)) {
|
|
197
|
+
err('ledger add: --run must be a run id (letters, digits, ".", "_", "-")\n');
|
|
198
|
+
return 2;
|
|
199
|
+
}
|
|
200
|
+
if (!ID.test(block)) {
|
|
201
|
+
err('ledger add: --block must be a block id (letters, digits, ".", "_", "-")\n');
|
|
202
|
+
return 2;
|
|
203
|
+
}
|
|
204
|
+
// rounded to the ledger's 4 decimals FIRST, then checked: 0.00001 rounds to 0 and is refused
|
|
205
|
+
const usd = /^\d+(\.\d+)?$/.test(usdArg) ? roundUsd(Number(usdArg)) : Number.NaN;
|
|
206
|
+
if (!Number.isFinite(usd) || usd <= 0) {
|
|
207
|
+
err(`ledger add: --usd must be a number > 0 (at 4 decimals), got "${usdArg}"\n`);
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
if (note.value !== undefined && note.value.length > MAX_NOTE_CHARS) {
|
|
211
|
+
err(`ledger add: --note is longer than ${MAX_NOTE_CHARS} characters\n`);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const { project } = await readRun(run);
|
|
216
|
+
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: project });
|
|
217
|
+
const row = {
|
|
218
|
+
event: 'session',
|
|
219
|
+
role: 'coder',
|
|
220
|
+
manual: true,
|
|
221
|
+
block,
|
|
222
|
+
provider: null,
|
|
223
|
+
level: null,
|
|
224
|
+
tokens_in: null,
|
|
225
|
+
tokens_out: null,
|
|
226
|
+
usd,
|
|
227
|
+
cost_source: 'manual',
|
|
228
|
+
...(note.value !== undefined ? { note: note.value } : {}),
|
|
229
|
+
};
|
|
230
|
+
await writeSigned(run, writeRow, row);
|
|
231
|
+
// the row is written: from here on nothing may throw or change the exit code
|
|
232
|
+
/** @type {number | null} */
|
|
233
|
+
let total = null;
|
|
234
|
+
try {
|
|
235
|
+
const spent = runSpend(await readAllRows(project), run).usd;
|
|
236
|
+
total = Number.isFinite(spent) ? spent : null;
|
|
237
|
+
} catch {
|
|
238
|
+
total = null;
|
|
239
|
+
}
|
|
240
|
+
out(`recorded coder spend $${usd.toFixed(4)} for run ${run} block ${block} · run total ${total === null ? 'unknown' : `$${total.toFixed(4)}`}\n`);
|
|
241
|
+
return 0;
|
|
242
|
+
} catch (thrown) {
|
|
243
|
+
err(`ledger add: ${thrown?.message ?? String(thrown)}\n`);
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
157
248
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
158
249
|
export default async function ledger(args) {
|
|
159
250
|
return runLedger(args);
|
package/src/cli/logs.mjs
CHANGED
|
@@ -6,7 +6,19 @@
|
|
|
6
6
|
* logs summary [--days N] [--json] one row per fingerprint over the last N days (default 30): "seen N times"
|
|
7
7
|
* logs clear [--yes] delete the log after one yes
|
|
8
8
|
* logs path print the log path
|
|
9
|
-
* logs report [--last N] [--kind K] [--verb V] [--note "text"] [--no-ai] [--allow-old] [--dry-run] [--yes]
|
|
9
|
+
* logs report [--last N] [--kind K] [--verb V] [--note "text"] [--include-warnings] [--with-doctor] [--no-ai] [--allow-old] [--force] [--dry-run] [--yes]
|
|
10
|
+
*
|
|
11
|
+
* B38: known fixes (`util/known-fixes.mjs`, the shipped `known-fixes.json`). `logs` marks a
|
|
12
|
+
* known-fixed line `[fixed in x.y.z]`. `report`, before anything else (no npm, no AI, nothing
|
|
13
|
+
* built): a selected error whose fix shipped in a newer version than the one running is printed
|
|
14
|
+
* with the upgrade command and left out of the report — none left is exit 0 — unless `--force`;
|
|
15
|
+
* when the running version already has the fix, the report notes a possible regression.
|
|
16
|
+
*
|
|
17
|
+
* B37: warnings (`kind: 'warning'`, recoverable problems) are listed marked `[warning]`, counted
|
|
18
|
+
* apart in `summary`, and reported only with `--include-warnings`. Each reported error names the
|
|
19
|
+
* commands run before it (`before`, names only). `--with-doctor` adds a "Setup check" section:
|
|
20
|
+
* `code-forge doctor --json` run as a child (60 s at most, error logging off), each row's id,
|
|
21
|
+
* status and a short detail only, cleaned like the rest; a doctor that cannot run is a note.
|
|
10
22
|
*
|
|
11
23
|
* `report` (B28 order): a version check (`npm view`; an old version is warned and asked about,
|
|
12
24
|
* default no, `--yes` included; without a terminal it stops unless `--allow-old`) → the
|
|
@@ -27,8 +39,9 @@ import os from 'node:os';
|
|
|
27
39
|
import path from 'node:path';
|
|
28
40
|
import { fileURLToPath } from 'node:url';
|
|
29
41
|
import { exec as realExec } from '../util/exec.mjs';
|
|
30
|
-
import { capBytes, createScrubber, describeCounts, errorLogPath, fingerprintOf, mergeCounts, packageVersion, readErrorLog, readProjectSlug } from '../util/error-log.mjs';
|
|
31
|
-
import {
|
|
42
|
+
import { NO_ERROR_LOG_ENV, capBytes, createScrubber, describeCounts, errorLogPath, fingerprintOf, isWarning, mergeCounts, packageVersion, readErrorLog, readProjectSlug } from '../util/error-log.mjs';
|
|
43
|
+
import { compareVersions, fixStatus, loadKnownFixes } from '../util/known-fixes.mjs';
|
|
44
|
+
import { redact, writeSafe } from '../util/redact.mjs';
|
|
32
45
|
import { currentRunRoot } from '../util/tmp.mjs';
|
|
33
46
|
import { SECRET_LOOKING_PATTERNS } from '../config/secret-patterns.mjs';
|
|
34
47
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
@@ -36,22 +49,48 @@ import { AI_REPORT_MAX_BYTES, applyItems, describeAiCounts, runAiScrub } from '.
|
|
|
36
49
|
|
|
37
50
|
const USAGE =
|
|
38
51
|
'usage: code-forge logs [--last N] [--json] | logs summary [--days N] [--json] | logs clear [--yes] | logs path\n' +
|
|
39
|
-
' code-forge logs report [--last N] [--kind K] [--verb V] [--note "text"] [--no-ai] [--allow-old] [--dry-run] [--yes]\n';
|
|
52
|
+
' code-forge logs report [--last N] [--kind K] [--verb V] [--note "text"] [--include-warnings] [--with-doctor] [--no-ai] [--allow-old] [--force] [--dry-run] [--yes]\n';
|
|
40
53
|
|
|
41
54
|
const PACKAGE_JSON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
|
|
42
55
|
|
|
43
56
|
/** A prefilled issue link longer than this is cut (browsers and GitHub refuse very long URLs). */
|
|
44
57
|
export const MAX_URL_LENGTH = 7500;
|
|
45
58
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Printed before every report preview: every kind of data the report holds.
|
|
61
|
+
* @param {{warnings?: boolean, doctor?: boolean}} [o] - whether the report holds warnings / setup check results.
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function disclosure({ warnings = false, doctor = false } = {}) {
|
|
65
|
+
const shared = [
|
|
66
|
+
'code-forge, Node and OS versions',
|
|
67
|
+
'command names and flag NAMES',
|
|
68
|
+
'the names of the last commands you ran (no flags or values)',
|
|
69
|
+
'exit codes',
|
|
70
|
+
'error types',
|
|
71
|
+
'error messages and crash reports after cleaning',
|
|
72
|
+
...(warnings ? ['warnings (problems code-forge recovered from) after cleaning'] : []),
|
|
73
|
+
...(doctor ? ["the setup check results (each check's name, status and a short detail, after cleaning)"] : []),
|
|
74
|
+
];
|
|
75
|
+
return (
|
|
76
|
+
`What you will share (public on GitHub): ${shared.join('; ')}.\n` +
|
|
77
|
+
'Never shared: flag values, file contents, your code, keys or tokens, your home folder, project folder or project name.\n'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The disclosure of a report with errors only. */
|
|
82
|
+
export const DISCLOSURE = disclosure();
|
|
50
83
|
|
|
51
84
|
export const CONFIRM_MESSAGE = 'This will be public on GitHub. Send it?';
|
|
52
85
|
|
|
53
86
|
const GH_TIMEOUT_MS = 60_000;
|
|
54
87
|
const NPM_TIMEOUT_MS = 10_000;
|
|
88
|
+
/** `code-forge doctor --json` for `--with-doctor` gets at most this long. */
|
|
89
|
+
export const DOCTOR_TIMEOUT_MS = 60_000;
|
|
90
|
+
/** A setup check row's detail is cut to this many bytes. */
|
|
91
|
+
export const DOCTOR_DETAIL_MAX_BYTES = 120;
|
|
92
|
+
|
|
93
|
+
const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'code-forge.mjs');
|
|
55
94
|
|
|
56
95
|
/** The npm package a version check looks up. */
|
|
57
96
|
export const PACKAGE_NAME = '@codedology/code-forge';
|
|
@@ -72,6 +111,8 @@ export const PACKAGE_NAME = '@codedology/code-forge';
|
|
|
72
111
|
* @property {() => Date} [now]
|
|
73
112
|
* @property {any} [pkg] - the package.json object the issue repository is read from (default: this package's).
|
|
74
113
|
* @property {{version: string, node: string, os: string}} [system] - the versions a report names.
|
|
114
|
+
* @property {Map<string, import('../util/known-fixes.mjs').KnownFix>} [knownFixes] - the known-fix table
|
|
115
|
+
* by fingerprint (default: the shipped `util/known-fixes.json`).
|
|
75
116
|
* @property {(argv: string[], env: NodeJS.ProcessEnv) => Promise<{code: number|null, error?: string}>} [runEditor] - runs
|
|
76
117
|
* the editor on the terminal (default: argv only, stdio inherited, never a shell).
|
|
77
118
|
*/
|
|
@@ -196,13 +237,17 @@ function fenced(text) {
|
|
|
196
237
|
/**
|
|
197
238
|
* The issue title and Markdown body for `entries` (newest first), with the sections of the issue
|
|
198
239
|
* form (`.github/ISSUE_TEMPLATE/error-report.yml`): what you were doing, versions, error details.
|
|
199
|
-
* The fingerprint section is {@link reportFooter}, added after cleaning.
|
|
240
|
+
* The fingerprint section is {@link reportFooter}, added after cleaning. With `setup` (B37) a
|
|
241
|
+
* "Setup check" section follows the error details: the rows, or why the check could not run.
|
|
200
242
|
* @param {ErrorEntry[]} entries
|
|
201
243
|
* @param {{version: string, node: string, os: string, latest?: string|null}} system
|
|
202
244
|
* @param {string|null} note
|
|
245
|
+
* @param {SetupCheck|null} [setup]
|
|
246
|
+
* @param {Map<string, string>} [fixNotes] - by fingerprint: a line added to that error's section
|
|
247
|
+
* (B38: a known fix; see {@link fixNote}).
|
|
203
248
|
* @returns {{title: string, body: string, fps: string[]}} `fps`: distinct fingerprints, newest first.
|
|
204
249
|
*/
|
|
205
|
-
export function buildReport(entries, system, note) {
|
|
250
|
+
export function buildReport(entries, system, note, setup = null, fixNotes = new Map()) {
|
|
206
251
|
const pairs = new Set(entries.map((e) => `${e.verb}\u0000${e.kind}`));
|
|
207
252
|
const title = pairs.size === 1 ? `[error report] ${entries[0].verb} ${entries[0].kind}` : `[error report] ${entries.length} errors`;
|
|
208
253
|
const fps = entries.map(fingerprintOf);
|
|
@@ -233,10 +278,13 @@ export function buildReport(entries, system, note) {
|
|
|
233
278
|
'',
|
|
234
279
|
`- when: ${e.ts}`,
|
|
235
280
|
`- flags: ${Array.isArray(e.flags) && e.flags.length > 0 ? e.flags.map((f) => `\`${f}\``).join(' ') : 'none'}`,
|
|
281
|
+
...(Array.isArray(e.before) ? [`- commands before (oldest first): ${e.before.length > 0 ? e.before.map((c) => `\`${c}\``).join(', ') : 'none'}`] : []),
|
|
236
282
|
`- exit: ${e.exit}`,
|
|
237
283
|
`- kind: ${e.kind}`,
|
|
284
|
+
...(isWarning(e) && typeof e.warning === 'string' ? [`- warning: ${e.warning}`] : []),
|
|
238
285
|
`- fingerprint: ${fps[i]}`,
|
|
239
286
|
`- code-forge ${e.version}, Node ${e.node}, ${e.platform} ${e.arch}`,
|
|
287
|
+
...(fixNotes.has(fps[i]) ? ['', /** @type {string} */ (fixNotes.get(fps[i]))] : []),
|
|
240
288
|
'',
|
|
241
289
|
'message:',
|
|
242
290
|
'',
|
|
@@ -244,9 +292,58 @@ export function buildReport(entries, system, note) {
|
|
|
244
292
|
);
|
|
245
293
|
if (e.stack) out.push('', 'stack:', '', fenced(e.stack));
|
|
246
294
|
});
|
|
295
|
+
if (setup !== null) {
|
|
296
|
+
out.push('', '### Setup check', '');
|
|
297
|
+
if ('why' in setup) {
|
|
298
|
+
out.push(`(the setup check could not run: ${setup.why})`);
|
|
299
|
+
} else {
|
|
300
|
+
out.push('| check | status | detail |', '|---|---|---|');
|
|
301
|
+
for (const r of setup.rows) out.push(`| ${cell(r.id)} | ${cell(r.status)} | ${cell(r.detail)} |`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
247
304
|
return { title, body: `${out.join('\n')}\n`, fps: [...new Set(fps)] };
|
|
248
305
|
}
|
|
249
306
|
|
|
307
|
+
/** @typedef {{rows: Array<{id: string, status: string, detail: string}>} | {why: string}} SetupCheck */
|
|
308
|
+
|
|
309
|
+
/** The statuses a doctor row may have. */
|
|
310
|
+
const DOCTOR_STATUSES = new Set(['OK', 'WARN', 'FAIL', 'INFO']);
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Run `code-forge doctor --json` as a child (argv only, 60 s at most, with error logging off so
|
|
314
|
+
* the check adds no log line or breadcrumb) and keep only each row's `id`, `status` and the first
|
|
315
|
+
* line of its `detail`, redacted, cleaned by `scrub` and then cut to {@link DOCTOR_DETAIL_MAX_BYTES}
|
|
316
|
+
* — never its label or anything else. A doctor that finds a FAIL exits 1 and still prints its rows; no rows at all is
|
|
317
|
+
* `{why}` (`timed out`, `doctor exit n`, `doctor gave no result`, `doctor did not run`).
|
|
318
|
+
* @param {typeof realExec} exec @param {NodeJS.ProcessEnv} env @param {string} cwd
|
|
319
|
+
* @param {(text: string) => string} scrub - the report's deterministic scrub.
|
|
320
|
+
* @returns {Promise<SetupCheck>}
|
|
321
|
+
*/
|
|
322
|
+
export async function runSetupCheck(exec, env, cwd, scrub) {
|
|
323
|
+
let res;
|
|
324
|
+
try {
|
|
325
|
+
res = await exec([process.execPath, BIN, 'doctor', '--json'], { cwd, env: { ...env, [NO_ERROR_LOG_ENV]: '1' }, timeoutMs: DOCTOR_TIMEOUT_MS });
|
|
326
|
+
} catch {
|
|
327
|
+
return { why: 'doctor did not run' };
|
|
328
|
+
}
|
|
329
|
+
if (res.timedOut) return { why: 'timed out' };
|
|
330
|
+
let doc;
|
|
331
|
+
try {
|
|
332
|
+
doc = JSON.parse(res.stdout);
|
|
333
|
+
} catch {
|
|
334
|
+
doc = null;
|
|
335
|
+
}
|
|
336
|
+
if (!doc || typeof doc !== 'object' || !Array.isArray(doc.rows)) {
|
|
337
|
+
return { why: res.code === null ? 'doctor did not run' : res.code === 0 ? 'doctor gave no result' : `doctor exit ${res.code}` };
|
|
338
|
+
}
|
|
339
|
+
const rows = [];
|
|
340
|
+
for (const r of doc.rows) {
|
|
341
|
+
if (typeof r?.id !== 'string' || !/^[A-Za-z0-9_.:-]{1,64}$/.test(r.id) || !DOCTOR_STATUSES.has(r.status)) continue;
|
|
342
|
+
rows.push({ id: r.id, status: r.status, detail: capBytes(scrub(redact(firstLine(typeof r.detail === 'string' ? r.detail : ''))), DOCTOR_DETAIL_MAX_BYTES) });
|
|
343
|
+
}
|
|
344
|
+
return { rows };
|
|
345
|
+
}
|
|
346
|
+
|
|
250
347
|
/**
|
|
251
348
|
* The last section of every report: `Fingerprint: <fp>` (the newest error's) and one hidden
|
|
252
349
|
* `<!-- code-forge-fp: <fp> -->` line per distinct fingerprint, so a later report finds it.
|
|
@@ -276,41 +373,8 @@ export function capReport(title, body, maxBytes) {
|
|
|
276
373
|
return { body: `${capBytes(body, Math.max(0, room))}${note}`, cut: true };
|
|
277
374
|
}
|
|
278
375
|
|
|
279
|
-
/**
|
|
280
|
-
|
|
281
|
-
* (`1.2.0-beta.1` < `1.2.0`) and pre-releases compare part by part (numbers as numbers, numbers
|
|
282
|
-
* before words). Build metadata (`+…`) is ignored.
|
|
283
|
-
* @param {string} a @param {string} b
|
|
284
|
-
* @returns {number|null} >0 when a is newer, <0 when older, 0 when equal; null when unreadable.
|
|
285
|
-
*/
|
|
286
|
-
export function compareVersions(a, b) {
|
|
287
|
-
const re = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
288
|
-
const pa = re.exec(a.trim());
|
|
289
|
-
const pb = re.exec(b.trim());
|
|
290
|
-
if (!pa || !pb) return null;
|
|
291
|
-
for (let i = 1; i <= 3; i += 1) {
|
|
292
|
-
const d = Number(pa[i]) - Number(pb[i]);
|
|
293
|
-
if (d !== 0) return d;
|
|
294
|
-
}
|
|
295
|
-
if (pa[4] === undefined || pb[4] === undefined) return pa[4] === pb[4] ? 0 : pa[4] === undefined ? 1 : -1;
|
|
296
|
-
const xa = pa[4].split('.');
|
|
297
|
-
const xb = pb[4].split('.');
|
|
298
|
-
for (let i = 0; i < Math.max(xa.length, xb.length); i += 1) {
|
|
299
|
-
if (xa[i] === undefined) return -1;
|
|
300
|
-
if (xb[i] === undefined) return 1;
|
|
301
|
-
const na = /^\d+$/.test(xa[i]);
|
|
302
|
-
const nb = /^\d+$/.test(xb[i]);
|
|
303
|
-
if (na && nb) {
|
|
304
|
-
const d = Number(xa[i]) - Number(xb[i]);
|
|
305
|
-
if (d !== 0) return d;
|
|
306
|
-
} else if (na !== nb) {
|
|
307
|
-
return na ? -1 : 1;
|
|
308
|
-
} else if (xa[i] !== xb[i]) {
|
|
309
|
-
return xa[i] < xb[i] ? -1 : 1;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
return 0;
|
|
313
|
-
}
|
|
376
|
+
/** Re-exported: the version comparison lives in `util/known-fixes.mjs` (B38). */
|
|
377
|
+
export { compareVersions };
|
|
314
378
|
|
|
315
379
|
/** @returns {{version: string, node: string, os: string}} */
|
|
316
380
|
function currentSystem() {
|
|
@@ -336,7 +400,7 @@ export async function runLogs(args, deps = {}) {
|
|
|
336
400
|
summary: { values: ['days'], booleans: ['json'] },
|
|
337
401
|
clear: { booleans: ['yes'] },
|
|
338
402
|
path: {},
|
|
339
|
-
report: { values: ['last', 'kind', 'verb', 'note'], booleans: ['dry-run', 'yes', 'no-ai', 'allow-old'] },
|
|
403
|
+
report: { values: ['last', 'kind', 'verb', 'note'], booleans: ['dry-run', 'yes', 'no-ai', 'allow-old', 'include-warnings', 'with-doctor', 'force'] },
|
|
340
404
|
});
|
|
341
405
|
const name = sub ?? 'list';
|
|
342
406
|
if (!Object.hasOwn(specs, name) || (name === 'list' && sub !== null)) {
|
|
@@ -367,7 +431,7 @@ export async function runLogs(args, deps = {}) {
|
|
|
367
431
|
const entries = await readErrorLog(file);
|
|
368
432
|
if (name === 'clear') return clear(file, entries, flags, deps, out, err);
|
|
369
433
|
if (entries === null || entries.length === 0) {
|
|
370
|
-
out(flags.json ? `${JSON.stringify(name === 'summary' ? { days: n ?? 30, counts: [] } : { errors: [] })}\n` : 'no errors logged\n');
|
|
434
|
+
out(flags.json ? `${JSON.stringify(name === 'summary' ? { days: n ?? 30, counts: [], warnings: [] } : { errors: [] })}\n` : 'no errors logged\n');
|
|
371
435
|
return 0;
|
|
372
436
|
}
|
|
373
437
|
const newest = [...entries].reverse();
|
|
@@ -375,7 +439,19 @@ export async function runLogs(args, deps = {}) {
|
|
|
375
439
|
if (name === 'list') {
|
|
376
440
|
const shown = newest.slice(0, n ?? 10);
|
|
377
441
|
if (flags.json) out(`${JSON.stringify({ errors: shown })}\n`);
|
|
378
|
-
else
|
|
442
|
+
else {
|
|
443
|
+
const fixes = deps.knownFixes ?? loadKnownFixes();
|
|
444
|
+
for (const e of shown) {
|
|
445
|
+
const fp = fingerprintOf(e);
|
|
446
|
+
const fix = fixes.get(fp);
|
|
447
|
+
const fpCell = fix ? `${fp} [fixed in ${fix.fixed_in}]` : fp;
|
|
448
|
+
out(
|
|
449
|
+
isWarning(e)
|
|
450
|
+
? `${e.ts} [warning] ${command(e)} ${typeof e.warning === 'string' ? e.warning : 'warning'} ${fpCell} ${firstLine(e.message)}\n`
|
|
451
|
+
: `${e.ts} ${command(e)} ${e.exit} ${e.kind} ${fpCell} ${firstLine(e.message)}\n`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
379
455
|
return 0;
|
|
380
456
|
}
|
|
381
457
|
|
|
@@ -384,22 +460,38 @@ export async function runLogs(args, deps = {}) {
|
|
|
384
460
|
const since = (deps.now ?? (() => new Date()))().getTime() - days * 86_400_000;
|
|
385
461
|
/** @type {Map<string, {fp: string, verb: string, sub: string|null, kind: string, count: number}>} */
|
|
386
462
|
const counts = new Map();
|
|
463
|
+
/** @type {Map<string, {fp: string, verb: string, sub: string|null, warning: string, count: number}>} */
|
|
464
|
+
const warns = new Map();
|
|
387
465
|
for (const e of newest) {
|
|
388
466
|
const t = Date.parse(e.ts);
|
|
389
467
|
if (!(t >= since)) continue;
|
|
390
468
|
const fp = fingerprintOf(e);
|
|
469
|
+
if (isWarning(e)) {
|
|
470
|
+
const w = warns.get(fp) ?? { fp, verb: e.verb, sub: e.sub ?? null, warning: typeof e.warning === 'string' ? e.warning : 'warning', count: 0 };
|
|
471
|
+
w.count += 1;
|
|
472
|
+
warns.set(fp, w);
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
391
475
|
const c = counts.get(fp) ?? { fp, verb: e.verb, sub: e.sub ?? null, kind: e.kind, count: 0 };
|
|
392
476
|
c.count += 1;
|
|
393
477
|
counts.set(fp, c);
|
|
394
478
|
}
|
|
395
479
|
const rows = [...counts.values()].sort((a, b) => b.count - a.count || a.verb.localeCompare(b.verb) || a.kind.localeCompare(b.kind) || a.fp.localeCompare(b.fp));
|
|
480
|
+
const warnRows = [...warns.values()].sort((a, b) => b.count - a.count || a.verb.localeCompare(b.verb) || a.warning.localeCompare(b.warning) || a.fp.localeCompare(b.fp));
|
|
481
|
+
const times = (/** @type {number} */ c) => `seen ${c} ${c === 1 ? 'time' : 'times'}`;
|
|
396
482
|
if (flags.json) {
|
|
397
|
-
out(`${JSON.stringify({ days, counts: rows })}\n`);
|
|
398
|
-
|
|
483
|
+
out(`${JSON.stringify({ days, counts: rows, warnings: warnRows })}\n`);
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
if (rows.length === 0) {
|
|
399
487
|
out(`no errors in the last ${days} days\n`);
|
|
400
488
|
} else {
|
|
401
489
|
out(`errors in the last ${days} days:\n`);
|
|
402
|
-
for (const r of rows) out(`${r.fp} ${r.sub ? `${r.verb} ${r.sub}` : r.verb} ${r.kind}
|
|
490
|
+
for (const r of rows) out(`${r.fp} ${r.sub ? `${r.verb} ${r.sub}` : r.verb} ${r.kind} ${times(r.count)}\n`);
|
|
491
|
+
}
|
|
492
|
+
if (warnRows.length > 0) {
|
|
493
|
+
out(`warnings in the last ${days} days:\n`);
|
|
494
|
+
for (const r of warnRows) out(`${r.fp} ${r.sub ? `${r.verb} ${r.sub}` : r.verb} ${r.warning} ${times(r.count)}\n`);
|
|
403
495
|
}
|
|
404
496
|
return 0;
|
|
405
497
|
}
|
|
@@ -511,11 +603,45 @@ async function report(newest, last, flags, deps, out, err) {
|
|
|
511
603
|
const home = env.HOME ?? '';
|
|
512
604
|
const kind = typeof flags.kind === 'string' ? flags.kind : null;
|
|
513
605
|
const verb = typeof flags.verb === 'string' ? flags.verb : null;
|
|
514
|
-
|
|
515
|
-
|
|
606
|
+
// warnings only with --include-warnings (B37)
|
|
607
|
+
const withWarnings = flags['include-warnings'] === true;
|
|
608
|
+
const picked = newest.filter((e) => (withWarnings || !isWarning(e)) && (kind === null || e.kind === kind) && (verb === null || e.verb === verb)).slice(0, last);
|
|
609
|
+
if (picked.length === 0) {
|
|
516
610
|
out('no matching errors to report\n');
|
|
517
611
|
return 0;
|
|
518
612
|
}
|
|
613
|
+
const system = deps.system ?? currentSystem();
|
|
614
|
+
|
|
615
|
+
// 0. known fixes (B38), before anything is asked or built: an error fixed in a newer version
|
|
616
|
+
// than the one running is left out (unless --force); one the running version already has the
|
|
617
|
+
// fix for gets a "may be a regression" note
|
|
618
|
+
const fixes = deps.knownFixes ?? loadKnownFixes();
|
|
619
|
+
/** @type {Map<string, string>} */
|
|
620
|
+
const fixNotes = new Map();
|
|
621
|
+
/** @type {Set<string>} */
|
|
622
|
+
const fixedAway = new Set();
|
|
623
|
+
for (const fp of new Set(picked.map(fingerprintOf))) {
|
|
624
|
+
const fix = fixes.get(fp);
|
|
625
|
+
if (!fix) continue;
|
|
626
|
+
const status = fixStatus(fix, system.version);
|
|
627
|
+
if (status === 'older') {
|
|
628
|
+
out(`This error is fixed in ${fix.fixed_in}: ${fix.summary.replace(/\.$/, '')}. Upgrade with: npm install -g ${PACKAGE_NAME}@latest\n`);
|
|
629
|
+
if (flags.force === true) {
|
|
630
|
+
out(`--force: reporting ${fp} anyway.\n`);
|
|
631
|
+
fixNotes.set(fp, `Note: a fix for this shipped in ${fix.fixed_in}; this report is from an older version (sent with --force).`);
|
|
632
|
+
} else {
|
|
633
|
+
fixedAway.add(fp);
|
|
634
|
+
}
|
|
635
|
+
} else if (status === 'has-fix') {
|
|
636
|
+
fixNotes.set(fp, `Note: a fix for this shipped in ${fix.fixed_in}; it may be a regression.`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const selected = picked.filter((e) => !fixedAway.has(fingerprintOf(e)));
|
|
640
|
+
if (selected.length === 0) {
|
|
641
|
+
out('not filed: upgrade first (pass --force to report it anyway)\n');
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
if (fixedAway.size > 0) out(`left out of the report (fixed in a newer version): ${[...fixedAway].join(', ')}\n`);
|
|
519
645
|
const repo = issueRepo(deps.pkg);
|
|
520
646
|
if (repo === null) {
|
|
521
647
|
err('logs: package.json names no GitHub repository to report to\n');
|
|
@@ -525,7 +651,6 @@ async function report(newest, last, flags, deps, out, err) {
|
|
|
525
651
|
const tty = deps.isTTY ?? process.stdin.isTTY === true;
|
|
526
652
|
const dryRun = flags['dry-run'] === true;
|
|
527
653
|
const yes = flags.yes === true;
|
|
528
|
-
const system = deps.system ?? currentSystem();
|
|
529
654
|
|
|
530
655
|
// 1. version check: an old version gets a warning and a default-no question
|
|
531
656
|
const npm = await latestOnNpm(exec, env);
|
|
@@ -548,17 +673,27 @@ async function report(newest, last, flags, deps, out, err) {
|
|
|
548
673
|
}
|
|
549
674
|
}
|
|
550
675
|
|
|
551
|
-
// 2. deterministic scrub, the 16 KB cap, the AI pass, the deterministic scrub again
|
|
552
676
|
const cwd = deps.cwd ?? process.cwd();
|
|
553
677
|
const scrubber = createScrubber({ home, cwd, slug: await readProjectSlug(cwd) });
|
|
678
|
+
|
|
679
|
+
// 1b. the setup check (--with-doctor, B37): a doctor that cannot run is a note, never a stop
|
|
680
|
+
/** @type {SetupCheck|null} */
|
|
681
|
+
let setup = null;
|
|
682
|
+
if (flags['with-doctor'] === true) {
|
|
683
|
+
setup = await runSetupCheck(exec, env, cwd, (t) => scrubber.scrub(t));
|
|
684
|
+
if ('why' in setup) out(`Could not run the setup check (${setup.why}); the report goes on without it.\n`);
|
|
685
|
+
}
|
|
686
|
+
const shared = disclosure({ warnings: selected.some(isWarning), doctor: setup !== null && 'rows' in setup });
|
|
687
|
+
|
|
688
|
+
// 2. deterministic scrub, the 16 KB cap, the AI pass, the deterministic scrub again
|
|
554
689
|
const note = typeof flags.note === 'string' ? flags.note : null;
|
|
555
|
-
const built = buildReport(selected, { ...system, latest }, note);
|
|
690
|
+
const built = buildReport(selected, { ...system, latest }, note, setup, fixNotes);
|
|
556
691
|
let title = scrubber.scrub(built.title);
|
|
557
692
|
let body = capReport(title, scrubber.scrub(built.body), AI_REPORT_MAX_BYTES).body;
|
|
558
693
|
const blocked = () => {
|
|
559
694
|
const hit = findSecret(`${title}\n${body}`);
|
|
560
695
|
if (hit === null) return false;
|
|
561
|
-
out(
|
|
696
|
+
out(shared);
|
|
562
697
|
err(`Possible secret found in the report (${hit.rule}, report line ${hit.line}; the title is line 1); not sent. Find it with code-forge logs --json and remove it with code-forge logs clear.\n`);
|
|
563
698
|
return true;
|
|
564
699
|
};
|
|
@@ -583,7 +718,7 @@ async function report(newest, last, flags, deps, out, err) {
|
|
|
583
718
|
|
|
584
719
|
// 3. the final secret check, then what is shared and the full text
|
|
585
720
|
if (blocked()) return 1;
|
|
586
|
-
out(
|
|
721
|
+
out(shared);
|
|
587
722
|
const aiLine =
|
|
588
723
|
ai.status === 'ok'
|
|
589
724
|
? describeAiCounts(ai.counts)
|
package/src/cli/plan.mjs
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge plan check <plan-file> [--facts <sheet>]` (plan §3.8 rule 4, §3.2;
|
|
3
|
-
* the deterministic harden exit. Prints every failing row on stderr, one per line, and
|
|
4
|
-
* when there is any; prints `plan check: ok (<n> blocks)` and exits 0 otherwise; 2 on usage.
|
|
2
|
+
* `code-forge plan check <plan-file> [--facts <sheet>] [--slug <slug>]` (plan §3.8 rule 4, §3.2;
|
|
3
|
+
* block B9b) — the deterministic harden exit. Prints every failing row on stderr, one per line, and
|
|
4
|
+
* exits 1 when there is any; prints `plan check: ok (<n> blocks)` and exits 0 otherwise; 2 on usage.
|
|
5
|
+
* WARN lines (a required section found by its position under a near-miss heading) go to stderr and
|
|
6
|
+
* never fail the check. Either way stdout ends with the lane recorded per block (B36):
|
|
7
|
+
* `lanes: B1 L1 (jev) · B2 none` — read from the project's ledger (`--slug`, else `project.slug`,
|
|
8
|
+
* else the directory-name slug `run`, `author` and `jev ask` use).
|
|
5
9
|
* Limits come from `.code-forge.yml` when there is one (`caps.coders`, `budget.block_cases`,
|
|
6
10
|
* `budget.block_lines`), else the plan defaults.
|
|
7
11
|
*/
|
|
@@ -9,12 +13,15 @@
|
|
|
9
13
|
import { readFileSync } from 'node:fs';
|
|
10
14
|
import path from 'node:path';
|
|
11
15
|
import { loadProjectConfig } from '../config/load.mjs';
|
|
16
|
+
import { ledgerPath } from '../ledger/paths.mjs';
|
|
17
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
12
18
|
import { checkPlan } from '../session/plan-check.mjs';
|
|
13
19
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
14
20
|
import { StateError } from '../state/paths.mjs';
|
|
15
21
|
import { writeSafe } from '../util/redact.mjs';
|
|
22
|
+
import { slugFor } from './run.mjs';
|
|
16
23
|
|
|
17
|
-
const USAGE = 'usage: code-forge plan check <plan-file> [--facts <sheet>]\n';
|
|
24
|
+
const USAGE = 'usage: code-forge plan check <plan-file> [--facts <sheet>] [--slug <slug>]\n';
|
|
18
25
|
|
|
19
26
|
/**
|
|
20
27
|
* @param {string[]} args
|
|
@@ -26,7 +33,7 @@ export async function runPlanVerb(args, deps = {}) {
|
|
|
26
33
|
const stderr = deps.stderr ?? process.stderr;
|
|
27
34
|
const cwd = deps.cwd ?? process.cwd();
|
|
28
35
|
try {
|
|
29
|
-
const { flags, positionals } = parseFlags(args, { values: ['facts'] });
|
|
36
|
+
const { flags, positionals } = parseFlags(args, { values: ['facts', 'slug'] });
|
|
30
37
|
if (positionals.length !== 2 || positionals[0] !== 'check') {
|
|
31
38
|
writeSafe(stderr, USAGE);
|
|
32
39
|
return 2;
|
|
@@ -48,13 +55,22 @@ export async function runPlanVerb(args, deps = {}) {
|
|
|
48
55
|
writeSafe(stderr, `plan: ${loaded.message}\n`);
|
|
49
56
|
return 2;
|
|
50
57
|
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
const cfg = loaded.ok ? loaded.config : undefined;
|
|
59
|
+
// the same ledger as the rest of the tool (and `jev ask`): --slug, else project.slug, else the directory name
|
|
60
|
+
const slug = typeof flags.slug === 'string' ? flags.slug : slugFor(cfg, cwd);
|
|
61
|
+
try {
|
|
62
|
+
ledgerPath(slug);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new StateError('usage', '--slug must be lowercase letters, digits and hyphens');
|
|
55
65
|
}
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// a missing ledger is no rows (every block `none`); any other read error propagates as itself
|
|
67
|
+
const decisions = await readAllRows(slug);
|
|
68
|
+
const result = checkPlan(text, { planPath, factsPath, factsText, cfg, decisions });
|
|
69
|
+
for (const line of result.warnings) writeSafe(stderr, `${line}\n`);
|
|
70
|
+
for (const line of result.errors) writeSafe(stderr, `${line}\n`);
|
|
71
|
+
if (result.ok) writeSafe(stdout, `plan check: ok (${result.blocks} blocks)\n`);
|
|
72
|
+
if (result.lanes.length > 0) writeSafe(stdout, `lanes: ${result.lanes.map((l) => `${l.block} ${l.lane === null ? 'none' : `${l.lane} (${l.source})`}`).join(' · ')}\n`);
|
|
73
|
+
return result.ok ? 0 : 1;
|
|
58
74
|
} catch (err) {
|
|
59
75
|
if (err instanceof StateError) {
|
|
60
76
|
writeSafe(stderr, `plan: ${err.message}\n`);
|
package/src/cli/report.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { writeSafe } from '../util/redact.mjs';
|
|
7
|
-
import { buildReport } from '../ledger/report.mjs';
|
|
7
|
+
import { buildReport, buildSpend } from '../ledger/report.mjs';
|
|
8
8
|
import { exportReportSections } from '../ledger/export.mjs';
|
|
9
9
|
import { readAllRows } from '../ledger/write.mjs';
|
|
10
10
|
|
|
@@ -60,6 +60,7 @@ export async function runReport(args, deps = {}) {
|
|
|
60
60
|
for (const [name, data] of Object.entries(sections)) {
|
|
61
61
|
out(`== ${name} ==\n${JSON.stringify(data, null, 2)}\n`);
|
|
62
62
|
}
|
|
63
|
+
out(spendLines(buildSpend(rows)));
|
|
63
64
|
return 0;
|
|
64
65
|
} catch (err_) {
|
|
65
66
|
err(`report: ${err_?.message ?? String(err_)}\n`);
|
|
@@ -67,6 +68,19 @@ export async function runReport(args, deps = {}) {
|
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* B33: the running spend per run (open blocks included), after the 13 sections. Unknown-price
|
|
73
|
+
* sessions are named, never priced.
|
|
74
|
+
* @param {ReturnType<typeof buildSpend>} spend @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
export function spendLines({ runs, total }) {
|
|
77
|
+
const unknown = (/** @type {number} */ n) => (n > 0 ? ` · ${n} with unknown price (not counted)` : '');
|
|
78
|
+
const lines = ['spend per run (estimated USD, open blocks included):'];
|
|
79
|
+
for (const r of runs) lines.push(` run ${r.run ?? '(none)'}: $${r.usd.toFixed(4)} · ${r.sessions} session(s)${unknown(r.unknown)}`);
|
|
80
|
+
lines.push(` total: $${total.usd.toFixed(4)} · ${total.sessions} session(s)${unknown(total.unknown)}`);
|
|
81
|
+
return `${lines.join('\n')}\n`;
|
|
82
|
+
}
|
|
83
|
+
|
|
70
84
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
71
85
|
export default async function report(args) {
|
|
72
86
|
return runReport(args);
|