@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +79 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +153 -1
  6. package/docs/reference/config.md +8 -1
  7. package/package.json +4 -2
  8. package/schema/code-forge.schema.json +15 -4
  9. package/skill/SKILL.md +7 -7
  10. package/skill/references/adapters/claude-code.md +1 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +8 -2
  17. package/skill/references/plan.md +3 -3
  18. package/skill/references/review.md +8 -5
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/jev.mjs +114 -17
  25. package/src/cli/ledger.mjs +99 -8
  26. package/src/cli/logs.mjs +193 -58
  27. package/src/cli/plan.mjs +27 -11
  28. package/src/cli/report.mjs +15 -1
  29. package/src/cli/resolve.mjs +16 -1
  30. package/src/cli/review-file.mjs +10 -5
  31. package/src/cli/run.mjs +109 -8
  32. package/src/config/closed-book.mjs +75 -0
  33. package/src/config/validate.mjs +128 -7
  34. package/src/decide/escalation.mjs +112 -11
  35. package/src/decide/questions.mjs +27 -4
  36. package/src/doctor/probes.mjs +48 -6
  37. package/src/engines/builders/claude.mjs +4 -1
  38. package/src/engines/builders/codex.mjs +26 -11
  39. package/src/engines/builders/grok.mjs +3 -3
  40. package/src/engines/efforts.mjs +109 -0
  41. package/src/engines/sentinel.mjs +34 -0
  42. package/src/keys/onepassword.mjs +3 -0
  43. package/src/ledger/report.mjs +66 -29
  44. package/src/ledger/spend.mjs +171 -0
  45. package/src/review/engine.mjs +57 -16
  46. package/src/review/fixloop.mjs +29 -6
  47. package/src/review/session-retry.mjs +69 -0
  48. package/src/review/triage.mjs +26 -1
  49. package/src/review/validate-review.mjs +6 -3
  50. package/src/session/author.mjs +6 -2
  51. package/src/session/plan-check.mjs +71 -6
  52. package/src/session/plan-sections.mjs +98 -0
  53. package/src/session/spawn.mjs +217 -8
  54. package/src/state/block.mjs +13 -4
  55. package/src/state/config-snapshot.mjs +150 -0
  56. package/src/state/run.mjs +86 -0
  57. package/src/util/breadcrumbs.mjs +145 -0
  58. package/src/util/error-hint.mjs +3 -2
  59. package/src/util/error-kind.mjs +2 -2
  60. package/src/util/error-log.mjs +109 -18
  61. package/src/util/forbidden.mjs +3 -1
  62. package/src/util/known-fixes.json +1 -0
  63. package/src/util/known-fixes.mjs +153 -0
  64. package/src/worker/engine.mjs +13 -8
  65. package/src/worker/loop.mjs +135 -39
  66. package/src/worker/queue.mjs +68 -7
@@ -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 { readAllRows } from '../ledger/write.mjs';
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
- const nFlag = readOptionalFlag(args, '--n');
130
- if (!nFlag.ok) {
131
- err('ledger tail: --n requires a value\n');
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 = nFlag.value ?? '20';
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 { writeSafe } from '../util/redact.mjs';
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
- /** Printed before every report preview. */
47
- export const DISCLOSURE =
48
- 'What you will share (public on GitHub): code-forge, Node and OS versions; command names and flag NAMES; exit codes; error types; error messages and crash reports after cleaning.\n' +
49
- 'Never shared: flag values, file contents, your code, keys or tokens, your home folder, project folder or project name.\n';
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
- * Compare two semver versions: `x.y.z`, then a pre-release is older than its release
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 for (const e of shown) out(`${e.ts} ${command(e)} ${e.exit} ${e.kind} ${fingerprintOf(e)} ${firstLine(e.message)}\n`);
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
- } else if (rows.length === 0) {
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} seen ${r.count} ${r.count === 1 ? 'time' : 'times'}\n`);
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
- const selected = newest.filter((e) => (kind === null || e.kind === kind) && (verb === null || e.verb === verb)).slice(0, last);
515
- if (selected.length === 0) {
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(DISCLOSURE);
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(DISCLOSURE);
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; block B9b) —
3
- * the deterministic harden exit. Prints every failing row on stderr, one per line, and exits 1
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 result = checkPlan(text, { planPath, factsPath, factsText, cfg: loaded.ok ? loaded.config : undefined });
52
- if (!result.ok) {
53
- for (const line of result.errors) writeSafe(stderr, `${line}\n`);
54
- return 1;
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
- writeSafe(stdout, `plan check: ok (${result.blocks} blocks)\n`);
57
- return 0;
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`);
@@ -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);