@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
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Breadcrumbs (B37): the names of the last commands run, for the error log's `before` field.
3
+ *
4
+ * `bin/code-forge.mjs` calls {@link noteCommand} once per known verb, before it runs. It keeps the
5
+ * last {@link BREADCRUMB_MAX} commands in `~/.code-forge/logs/breadcrumbs.json` (a JSON array of
6
+ * strings, oldest first; folder 0700, file 0600, written to a temp file and renamed). A command is
7
+ * the verb name plus its subcommand word only when that word is one of the verb's own words
8
+ * (`keys test`, `init`): never a flag, a flag value or any other positional — the same rule as the
9
+ * error log's `sub`. The list as it was BEFORE this command is kept in memory
10
+ * ({@link previousCommands}), so an error or warning logged by this process names the commands
11
+ * that came before it.
12
+ *
13
+ * Best effort: never throws, never changes the verb's outcome. `CODE_FORGE_NO_ERROR_LOG=1` turns it
14
+ * off (checked here too: nothing is read or written); no HOME means no file. A file that does not
15
+ * parse, or entries that are not command names, are dropped. An existing logs folder is set to
16
+ * 0700 (best effort).
17
+ */
18
+
19
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
20
+ import path from 'node:path';
21
+
22
+ /**
23
+ * The subcommand words each verb knows (the error log's `sub` uses the same table); a word is
24
+ * recorded only when it is one of these (anything else could be a user value: a path, an id, a name).
25
+ * @type {Readonly<Record<string, readonly string[]>>}
26
+ */
27
+ export const SUBCOMMANDS = Object.freeze({
28
+ block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
29
+ gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
30
+ jev: ['ask'],
31
+ keys: ['list', 'set', 'test', 'remove'],
32
+ ledger: ['calibration', 'outcome', 'tail'],
33
+ logs: ['summary', 'clear', 'path', 'report'],
34
+ plan: ['check'],
35
+ proof: ['tier', 'export', 'lock', 'unlock', 'restore', 'red-green'],
36
+ run: ['start', 'status', 'end'],
37
+ tools: ['install'],
38
+ });
39
+
40
+ /** The opt-out variable (the same one the error log reads). */
41
+ const NO_LOG_ENV = 'CODE_FORGE_NO_ERROR_LOG';
42
+
43
+ /** @param {NodeJS.ProcessEnv} env @returns {boolean} */
44
+ function off(env) {
45
+ const flag = env[NO_LOG_ENV];
46
+ return typeof flag === 'string' && flag !== '' && flag !== '0';
47
+ }
48
+
49
+ /** How many commands the file keeps. */
50
+ export const BREADCRUMB_MAX = 5;
51
+
52
+ /** A command name: a verb, optionally one subcommand word (`keys test`). */
53
+ export const CRUMB = /^[a-z][a-z0-9-]{0,39}(?: [a-z][a-z0-9-]{0,39})?$/;
54
+
55
+ /** @type {{verb: string, sub: string|null, before: string[]} | null} */
56
+ let current = null;
57
+
58
+ /**
59
+ * @param {string|null|undefined} home
60
+ * @returns {string|null} the breadcrumb file, or null without a home.
61
+ */
62
+ export function breadcrumbPath(home) {
63
+ return typeof home === 'string' && home.length > 0 ? path.join(home, '.code-forge', 'logs', 'breadcrumbs.json') : null;
64
+ }
65
+
66
+ /**
67
+ * The commands in the file, oldest first: only command-shaped strings, at most the last
68
+ * {@link BREADCRUMB_MAX}; [] when there is no file or it does not parse.
69
+ * @param {string|null} file
70
+ * @returns {Promise<string[]>}
71
+ */
72
+ export async function readBreadcrumbs(file) {
73
+ if (file === null) return [];
74
+ try {
75
+ const parsed = JSON.parse(await readFile(file, 'utf8'));
76
+ return cleanList(parsed);
77
+ } catch {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {unknown} list
84
+ * @returns {string[]} the command-shaped strings of `list`, the last {@link BREADCRUMB_MAX}.
85
+ */
86
+ export function cleanList(list) {
87
+ if (!Array.isArray(list)) return [];
88
+ return list.filter((c) => typeof c === 'string' && CRUMB.test(c)).slice(-BREADCRUMB_MAX);
89
+ }
90
+
91
+ /**
92
+ * Record one command (called by the router before the verb runs). Never throws.
93
+ * @param {object} o
94
+ * @param {string} o.verb - a known verb name.
95
+ * @param {string|null} o.sub - its subcommand word; dropped unless it is one of the verb's own
96
+ * words in {@link SUBCOMMANDS}.
97
+ * @param {NodeJS.ProcessEnv} [o.env]
98
+ * @returns {Promise<boolean>} whether the file was written.
99
+ */
100
+ export async function noteCommand({ verb, sub, env = process.env }) {
101
+ try {
102
+ if (typeof verb !== 'string' || !CRUMB.test(verb)) {
103
+ current = null;
104
+ return false;
105
+ }
106
+ const known = Object.hasOwn(SUBCOMMANDS, verb) ? SUBCOMMANDS[verb] : [];
107
+ const word = typeof sub === 'string' && known.includes(sub) ? sub : null;
108
+ const crumb = word ? `${verb} ${word}` : verb;
109
+ current = { verb, sub: word, before: [] };
110
+ if (off(env)) return false;
111
+ const file = breadcrumbPath(env.HOME);
112
+ if (file === null) return false;
113
+ const before = await readBreadcrumbs(file);
114
+ current.before = before;
115
+ const next = [...before, crumb].slice(-BREADCRUMB_MAX);
116
+ const tmp = `${file}.tmp-${process.pid}`;
117
+ try {
118
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
119
+ await chmod(path.dirname(file), 0o700).catch(() => {}); // an older folder may be 0755
120
+ await writeFile(tmp, `${JSON.stringify(next)}\n`, { encoding: 'utf8', mode: 0o600 });
121
+ await rename(tmp, file);
122
+ return true;
123
+ } catch {
124
+ await rm(tmp, { force: true }).catch(() => {});
125
+ return false;
126
+ }
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ /** @returns {string[]} the commands run before this one (oldest first); [] when none was noted. */
133
+ export function previousCommands() {
134
+ return current ? [...current.before] : [];
135
+ }
136
+
137
+ /** @returns {{verb: string, sub: string|null} | null} the command this process runs, when noted. */
138
+ export function currentCommand() {
139
+ return current ? { verb: current.verb, sub: current.sub } : null;
140
+ }
141
+
142
+ /** Forget the noted command (tests). */
143
+ export function resetBreadcrumbs() {
144
+ current = null;
145
+ }
@@ -5,7 +5,7 @@
5
5
  * {@link AGENT_ENV_VARS}, the same names `src/install/agent-env.mjs` checks — copied here
6
6
  * because the router may only load `src/util`; a test keeps the two lists equal);
7
7
  * - the entry has a well-formed fingerprint (12 hex characters);
8
- * - the exit is not 2 (a usage error says what to fix already);
8
+ * - the exit is not 2 (a usage error says what to fix already) and the entry is no warning (B37);
9
9
  * - the same fingerprint got no hint in the last 24 hours. The time of each hint is kept in
10
10
  * `~/.code-forge/logs/hints.json` (`{"<fp>": "<ISO time>"}`, 0600, older ones dropped).
11
11
  * Never throws; a broken state file only means the hint may show again.
@@ -40,7 +40,7 @@ function agentMode(env) {
40
40
 
41
41
  /**
42
42
  * @param {object} o
43
- * @param {{fp?: string}|null} o.entry - the entry the log just took (null: nothing was logged).
43
+ * @param {{fp?: string, kind?: string}|null} o.entry - the entry the log just took (null: nothing was logged).
44
44
  * @param {number} o.exit
45
45
  * @param {boolean} o.isTTY - whether stderr is a terminal.
46
46
  * @param {NodeJS.ProcessEnv} [o.env]
@@ -52,6 +52,7 @@ export async function maybeHint(o) {
52
52
  try {
53
53
  const env = o.env ?? process.env;
54
54
  const fp = o.entry?.fp;
55
+ if (o.entry?.kind === 'warning') return false;
55
56
  if (typeof fp !== 'string' || !FP.test(fp) || o.exit === 2 || o.isTTY !== true || agentMode(env)) return false;
56
57
  const file = hintStatePath(env.HOME);
57
58
  if (file === null) return false;
@@ -8,8 +8,8 @@
8
8
  /** A kind is a short snake_case word; anything else is ignored. */
9
9
  const KIND = /^[a-z][a-z0-9_]{0,39}$/;
10
10
 
11
- /** Kinds the router sets itself; a verb reporting one of these is ignored. */
12
- const RESERVED = new Set(['crash', 'usage', 'error']);
11
+ /** Kinds the router (and `logWarning`, B37) set themselves; a verb reporting one of these is ignored. */
12
+ const RESERVED = new Set(['crash', 'usage', 'error', 'warning']);
13
13
 
14
14
  /** @type {string|null} */
15
15
  let current = null;
@@ -12,6 +12,12 @@
12
12
  *
13
13
  * Logging never throws and never changes the verb's exit code. `CODE_FORGE_NO_ERROR_LOG=1` turns
14
14
  * it off; no HOME means no log. The file is cut to its newest half when it grows over 1 MB.
15
+ *
16
+ * B37: every entry carries `before`, the names of up to 5 commands run before this one (oldest
17
+ * first; `util/breadcrumbs.mjs`, noted by the router through {@link noteVerb}), and
18
+ * {@link logWarning} writes a `kind: 'warning'` line for a recoverable problem (a 1Password call
19
+ * retried, a review session retried, System 1 falling back to rules, a budget warning) — the same
20
+ * scrub, cap and fingerprint, `warning` naming what happened, once per fingerprint per process.
15
21
  */
16
22
 
17
23
  import { createHash } from 'node:crypto';
@@ -19,6 +25,7 @@ import { readFileSync, realpathSync } from 'node:fs';
19
25
  import { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
20
26
  import path from 'node:path';
21
27
  import { fileURLToPath } from 'node:url';
28
+ import { SUBCOMMANDS, cleanList, currentCommand, noteCommand, previousCommands } from './breadcrumbs.mjs';
22
29
  import { redact } from './redact.mjs';
23
30
 
24
31
  export const ERROR_LOG_MAX_BYTES = 1024 * 1024;
@@ -29,22 +36,10 @@ export const NO_ERROR_LOG_ENV = 'CODE_FORGE_NO_ERROR_LOG';
29
36
  const PACKAGE_JSON = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
30
37
 
31
38
  /**
32
- * The subcommand words each verb knows; the first positional is logged as `sub` only when it is
33
- * one of these (anything else could be a user value: a path, an id, a name).
34
- * @type {Readonly<Record<string, readonly string[]>>}
39
+ * The subcommand words each verb knows (kept in `breadcrumbs.mjs`, which needs the same table); the
40
+ * first positional is logged as `sub` only when it is one of these.
35
41
  */
36
- export const SUBCOMMANDS = Object.freeze({
37
- block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
38
- gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
39
- jev: ['ask'],
40
- keys: ['list', 'set', 'test', 'remove'],
41
- ledger: ['calibration', 'outcome', 'tail'],
42
- logs: ['summary', 'clear', 'path', 'report'],
43
- plan: ['check'],
44
- proof: ['tier', 'export', 'lock', 'unlock', 'restore', 'red-green'],
45
- run: ['start', 'status', 'end'],
46
- tools: ['install'],
47
- });
42
+ export { SUBCOMMANDS };
48
43
 
49
44
  /**
50
45
  * The scrub rules, in the order a summary lists them. `label` is singular and plural.
@@ -84,6 +79,8 @@ export const SCRUB_PLACEHOLDERS = Object.freeze(['<project>', '<slug>', '<email>
84
79
  * @property {string|null} stack
85
80
  * @property {CleanCount[]} cleaned
86
81
  * @property {string} [fp] - the fingerprint (B28); entries written before B28 have none.
82
+ * @property {string[]} [before] - the commands run before this one, oldest first (B37).
83
+ * @property {string} [warning] - for `kind: 'warning'`: what happened (`op_retry`…) (B37).
87
84
  */
88
85
 
89
86
  /** @param {string} s */
@@ -353,6 +350,7 @@ export async function appendLine(file, line, maxBytes = ERROR_LOG_MAX_BYTES) {
353
350
  * @property {NodeJS.ProcessEnv} [env]
354
351
  * @property {string} [cwd]
355
352
  * @property {Date} [now]
353
+ * @property {string[]} [before] - the commands run before (default: the ones the router noted).
356
354
  */
357
355
 
358
356
  const KIND_WORD = /^[a-z][a-z0-9_]{0,39}$/;
@@ -394,12 +392,15 @@ export function normalizeMessage(message) {
394
392
  }
395
393
 
396
394
  /**
397
- * The first 12 hex characters of sha256(verb, sub, kind, normalized message) (B28).
398
- * @param {{verb: string, sub?: string|null, kind: string, message?: string|null}} e
395
+ * The first 12 hex characters of sha256(verb, sub, kind, normalized message) (B28); a warning
396
+ * (B37) adds what happened, so two warnings with the same text stay apart.
397
+ * @param {{verb: string, sub?: string|null, kind: string, message?: string|null, warning?: string}} e
399
398
  * @returns {string}
400
399
  */
401
400
  export function fingerprint(e) {
402
- const text = [e.verb, e.sub ?? '', e.kind, normalizeMessage(e.message)].join('\n');
401
+ const parts = [e.verb, e.sub ?? '', e.kind, normalizeMessage(e.message)];
402
+ if (typeof e.warning === 'string') parts.push(e.warning);
403
+ const text = parts.join('\n');
403
404
  return createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 12);
404
405
  }
405
406
 
@@ -455,9 +456,99 @@ export async function buildEntry(input) {
455
456
  stack,
456
457
  cleaned: scrubber.counts(),
457
458
  fp: fingerprint({ verb: verbName, sub, kind, message }),
459
+ before: cleanList(input.before ?? previousCommands()),
458
460
  };
459
461
  }
460
462
 
463
+ /**
464
+ * Note the command this process runs (the router, once, before the verb): its name and known
465
+ * subcommand word go to the breadcrumb file — never a flag or a value. Async: always a Promise,
466
+ * which never rejects.
467
+ * @param {string} verb @param {string[]} args @param {NodeJS.ProcessEnv} [env]
468
+ * @returns {Promise<boolean>} whether the breadcrumb file was written.
469
+ */
470
+ export async function noteVerb(verb, args, env = process.env) {
471
+ try {
472
+ return await noteCommand({ verb, sub: subcommandOf(verb, args), env });
473
+ } catch {
474
+ return false;
475
+ }
476
+ }
477
+
478
+ /** Fingerprints of the warnings this process already logged (one line each per process). */
479
+ const warned = new Set();
480
+
481
+ /** Forget which warnings were logged (tests). */
482
+ export function resetWarnings() {
483
+ warned.clear();
484
+ }
485
+
486
+ /**
487
+ * @typedef {object} WarningInput
488
+ * @property {string} [verb] - default: the command the router noted, else `unknown`.
489
+ * @property {string} warning - what happened: a short snake_case word (`op_retry`); else `warning`.
490
+ * @property {string} message - plain text; never a value (it is cleaned and capped anyway).
491
+ * @property {NodeJS.ProcessEnv} [env]
492
+ * @property {string} [cwd]
493
+ * @property {Date} [now]
494
+ */
495
+
496
+ /**
497
+ * Log one recoverable problem as a `kind: 'warning'` line (B37): the same cleaning, 2 KB cap and
498
+ * fingerprint as an error, `exit: 0`, no flags, no stack, `warning` naming what happened. The same
499
+ * warning (fingerprint) is written once per process (remembered only after the line was
500
+ * appended). Never throws or rejects; never shows a hint.
501
+ * @param {WarningInput} input
502
+ * @returns {Promise<ErrorEntry|null>} the entry written, or null (logging off, no HOME, a repeat, an error).
503
+ */
504
+ export async function logWarning(input) {
505
+ try {
506
+ const env = input?.env ?? process.env;
507
+ if (loggingOff(env)) return null;
508
+ const file = errorLogPath(env.HOME);
509
+ if (file === null) return null;
510
+ const cwd = input.cwd ?? process.cwd();
511
+ const scrubber = createScrubber({ home: env.HOME ?? null, cwd, slug: await readProjectSlug(cwd) });
512
+ const clean = (/** @type {string} */ s) => scrubber.scrub(redact(s));
513
+ const noted = currentCommand();
514
+ const verbName = typeof input.verb === 'string' && input.verb.length > 0 ? clean(input.verb) : noted?.verb ?? 'unknown';
515
+ const sub = typeof input.verb === 'string' && input.verb.length > 0 && input.verb !== noted?.verb ? null : noted?.sub ?? null;
516
+ const warning = typeof input?.warning === 'string' && KIND_WORD.test(input.warning) ? input.warning : 'warning';
517
+ const message = capBytes(clean(safeString(() => input.message).trim()), MESSAGE_MAX_BYTES);
518
+ const fp = fingerprint({ verb: verbName, sub, kind: 'warning', message, warning });
519
+ if (warned.has(fp)) return null;
520
+ /** @type {ErrorEntry} */
521
+ const entry = {
522
+ ts: (input.now ?? new Date()).toISOString(),
523
+ version: packageVersion(),
524
+ node: process.version,
525
+ platform: process.platform,
526
+ arch: process.arch,
527
+ verb: verbName,
528
+ sub,
529
+ flags: [],
530
+ exit: 0,
531
+ kind: 'warning',
532
+ warning,
533
+ message,
534
+ stack: null,
535
+ cleaned: scrubber.counts(),
536
+ fp,
537
+ before: cleanList(previousCommands()),
538
+ };
539
+ await appendLine(file, JSON.stringify(entry));
540
+ warned.add(fp); // only after the line is written: a failed write may be retried later
541
+ return entry;
542
+ } catch {
543
+ return null;
544
+ }
545
+ }
546
+
547
+ /** @param {{kind?: unknown}|null|undefined} e @returns {boolean} whether a log entry is a warning (B37). */
548
+ export function isWarning(e) {
549
+ return e?.kind === 'warning';
550
+ }
551
+
461
552
  /**
462
553
  * Log one failed verb. Never throws; returns the entry written, or null when nothing was written
463
554
  * (exit 0, logging off, no HOME, or a write error).
@@ -256,8 +256,10 @@ export const FORBIDDEN = Object.freeze(
256
256
  ['forge', 'worker'],
257
257
  ['code-forge', 'run', 'start'],
258
258
  ['forge', 'run', 'start'],
259
+ ['code-forge', 'run', 'reload'],
260
+ ['forge', 'run', 'reload'],
259
261
  ],
260
- description: 'Start a worker or a run (forge worker / code-forge worker / run start) from a coder — the orchestrator only',
262
+ description: 'Start a worker or a run, or reload its config (forge worker / code-forge worker / run start / run reload) from a coder — the orchestrator only',
261
263
  },
262
264
  {
263
265
  id: 'code-forge-reviews-write',
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Known fixes (B38): a small table shipped in the package, `src/util/known-fixes.json`, that maps
3
+ * an error fingerprint (`util/error-log.mjs` `fingerprint`) to the version that fixed it.
4
+ *
5
+ * [{ "fp": "<12 hex>", "fixed_in": "x.y.z", "summary": "<one plain line>", "issue": <number|null> }]
6
+ *
7
+ * `code-forge logs` marks a known-fixed line `[fixed in x.y.z]`; `logs report` does not file an
8
+ * error that is fixed in a newer version than the one running (unless `--force`) and notes a
9
+ * possible regression when the running version already has the fix. The maintainer adds entries
10
+ * with `npm run known-fix -- add …` (`scripts/known-fix.mjs`, which uses {@link validateKnownFixes});
11
+ * `.github/workflows/error-triage.yml` reads the same file to comment on new error reports.
12
+ *
13
+ * Reading never throws: a missing or unreadable table is empty, and an entry that does not
14
+ * validate is left out (a broken table must never hide a report).
15
+ */
16
+
17
+ import { readFileSync } from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ /** The shipped table. */
22
+ export const KNOWN_FIXES_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'known-fixes.json');
23
+
24
+ /** A summary longer than this is refused (it is one plain line). */
25
+ export const SUMMARY_MAX_CHARS = 200;
26
+
27
+ const FP_RE = /^[0-9a-f]{12}$/;
28
+ const VERSION_RE = /^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/;
29
+ const KEYS = Object.freeze(['fp', 'fixed_in', 'summary', 'issue']);
30
+
31
+ /** @typedef {{fp: string, fixed_in: string, summary: string, issue: number|null}} KnownFix */
32
+
33
+ /**
34
+ * Compare two semver versions: `x.y.z`, then a pre-release is older than its release
35
+ * (`1.2.0-beta.1` < `1.2.0`) and pre-releases compare part by part (numbers as numbers, numbers
36
+ * before words). Build metadata (`+…`) is ignored.
37
+ * @param {string} a @param {string} b
38
+ * @returns {number|null} >0 when a is newer, <0 when older, 0 when equal; null when unreadable.
39
+ */
40
+ export function compareVersions(a, b) {
41
+ const re = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
42
+ const pa = typeof a === 'string' ? re.exec(a.trim()) : null;
43
+ const pb = typeof b === 'string' ? re.exec(b.trim()) : null;
44
+ if (!pa || !pb) return null;
45
+ for (let i = 1; i <= 3; i += 1) {
46
+ const d = Number(pa[i]) - Number(pb[i]);
47
+ if (d !== 0) return d;
48
+ }
49
+ if (pa[4] === undefined || pb[4] === undefined) return pa[4] === pb[4] ? 0 : pa[4] === undefined ? 1 : -1;
50
+ const xa = pa[4].split('.');
51
+ const xb = pb[4].split('.');
52
+ for (let i = 0; i < Math.max(xa.length, xb.length); i += 1) {
53
+ if (xa[i] === undefined) return -1;
54
+ if (xb[i] === undefined) return 1;
55
+ const na = /^\d+$/.test(xa[i]);
56
+ const nb = /^\d+$/.test(xb[i]);
57
+ if (na && nb) {
58
+ const d = Number(xa[i]) - Number(xb[i]);
59
+ if (d !== 0) return d;
60
+ } else if (na !== nb) {
61
+ return na ? -1 : 1;
62
+ } else if (xa[i] !== xb[i]) {
63
+ return xa[i] < xb[i] ? -1 : 1;
64
+ }
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ /**
70
+ * The problems of one entry, each naming the key only (never its value).
71
+ * @param {unknown} e
72
+ * @returns {string[]} empty when the entry is valid.
73
+ */
74
+ export function entryProblems(e) {
75
+ if (e === null || typeof e !== 'object' || Array.isArray(e)) return ['not an object'];
76
+ const o = /** @type {Record<string, unknown>} */ (e);
77
+ const problems = [];
78
+ for (const k of Object.keys(o)) if (!KEYS.includes(k)) problems.push(`unknown key ${JSON.stringify(k)}`);
79
+ if (typeof o.fp !== 'string' || !FP_RE.test(o.fp)) problems.push('fp must be 12 lowercase hex characters');
80
+ if (typeof o.fixed_in !== 'string' || !VERSION_RE.test(o.fixed_in)) problems.push('fixed_in must be a version x.y.z');
81
+ if (typeof o.summary !== 'string' || o.summary.trim().length === 0) problems.push('summary must be a non-empty string');
82
+ else if (o.summary !== o.summary.trim() || /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/.test(o.summary)) problems.push('summary must be one plain line (no line breaks, tabs, control or text-direction characters, or leading/trailing spaces)');
83
+ else if ([...o.summary].length > SUMMARY_MAX_CHARS) problems.push(`summary must be at most ${SUMMARY_MAX_CHARS} characters`);
84
+ if (!Object.hasOwn(o, 'issue') || !(o.issue === null || (Number.isSafeInteger(o.issue) && /** @type {number} */ (o.issue) > 0))) {
85
+ problems.push('issue must be a positive issue number or null');
86
+ }
87
+ return problems;
88
+ }
89
+
90
+ /**
91
+ * Validate a whole table: an array of valid entries, each fingerprint once.
92
+ * @param {unknown} data
93
+ * @returns {string[]} the problems (`entry 2: fp must be …`); empty when the table is valid.
94
+ */
95
+ export function validateKnownFixes(data) {
96
+ if (!Array.isArray(data)) return ['the table must be a JSON array'];
97
+ const problems = [];
98
+ /** @type {Map<string, number>} */
99
+ const seen = new Map();
100
+ data.forEach((e, i) => {
101
+ for (const p of entryProblems(e)) problems.push(`entry ${i + 1}: ${p}`);
102
+ const fp = /** @type {any} */ (e)?.fp;
103
+ if (typeof fp === 'string' && FP_RE.test(fp)) {
104
+ if (seen.has(fp)) problems.push(`entry ${i + 1}: fp is already listed in entry ${seen.get(fp)}`);
105
+ else seen.set(fp, i + 1);
106
+ }
107
+ });
108
+ return problems;
109
+ }
110
+
111
+ /**
112
+ * The valid entries of the table at `file`, by fingerprint (first one wins). Never throws.
113
+ * @param {string} [file]
114
+ * @returns {Map<string, KnownFix>}
115
+ */
116
+ export function loadKnownFixes(file = KNOWN_FIXES_FILE) {
117
+ let data;
118
+ try {
119
+ data = JSON.parse(readFileSync(file, 'utf8'));
120
+ } catch {
121
+ return new Map();
122
+ }
123
+ return knownFixMap(data);
124
+ }
125
+
126
+ /**
127
+ * The valid entries of `data`, by fingerprint (first one wins); anything else is left out.
128
+ * @param {unknown} data
129
+ * @returns {Map<string, KnownFix>}
130
+ */
131
+ export function knownFixMap(data) {
132
+ /** @type {Map<string, KnownFix>} */
133
+ const out = new Map();
134
+ if (!Array.isArray(data)) return out;
135
+ for (const e of data) {
136
+ if (entryProblems(e).length > 0 || out.has(e.fp)) continue;
137
+ out.set(e.fp, { fp: e.fp, fixed_in: e.fixed_in, summary: e.summary, issue: e.issue });
138
+ }
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * How a known fix relates to the running version: `older` (the fix is in a newer version than the
144
+ * one running), `has-fix` (the running version is the fixed one or newer: a possible regression),
145
+ * or `unknown` (the running version cannot be read).
146
+ * @param {KnownFix} fix @param {string} running
147
+ * @returns {'older'|'has-fix'|'unknown'}
148
+ */
149
+ export function fixStatus(fix, running) {
150
+ const c = compareVersions(running, fix.fixed_in);
151
+ if (c === null) return 'unknown';
152
+ return c < 0 ? 'older' : 'has-fix';
153
+ }
@@ -36,6 +36,7 @@
36
36
 
37
37
  import { rmSync } from 'node:fs';
38
38
  import path from 'node:path';
39
+ import { blockKind } from '../decide/escalation.mjs';
39
40
  import { askJev } from '../decide/jev-client.mjs';
40
41
  import { planAndRecord, tierOf } from '../review/budget.mjs';
41
42
  import { budgetFor } from '../review/packet.mjs';
@@ -89,14 +90,17 @@ import { assertTicketId } from './ticket.mjs';
89
90
  * `block-unknown`; a record that cannot be read or parsed throws `run-record-unreadable`, and a
90
91
  * block entry without a base `block-base-missing` — a guessed base could let a change escape review.
91
92
  * @param {string} runId @param {string} block
92
- * @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown}>}
93
+ * The block's kind (B34) is the recorded `kind` when `block open` stored one, else `blockKind`
94
+ * of its owned files.
95
+ * With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
96
+ * @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown, kind: string | null}>}
93
97
  */
94
98
  export async function blockEntryFor(runId, block) {
95
99
  let record;
96
100
  try {
97
101
  record = await readRun(runId);
98
102
  } catch (err) {
99
- if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined };
103
+ if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined, kind: null };
100
104
  throw new Error('run-record-unreadable');
101
105
  }
102
106
  if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
@@ -105,7 +109,7 @@ export async function blockEntryFor(runId, block) {
105
109
  const sha = entry?.base_sha;
106
110
  if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
107
111
  const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
108
- return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance };
112
+ return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
109
113
  }
110
114
 
111
115
  /** The acceptance line written when the block's clauses cannot be read. */
@@ -189,14 +193,15 @@ export async function reviewTicket(ticket, ctx) {
189
193
  } catch (err) {
190
194
  return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
191
195
  }
196
+ if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
192
197
  try {
193
198
  if (!ctx.key) {
194
199
  return await reviewFile(
195
- { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
200
+ { repoRoot: ctx.repoRoot, file: ticket.file, base: entry.base, cfg: ctx.cfg, kind: entry.kind, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) },
196
201
  { spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
197
202
  );
198
203
  }
199
- return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
204
+ return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg) });
200
205
  } finally {
201
206
  rmSync(workDir, { recursive: true, force: true });
202
207
  }
@@ -205,10 +210,10 @@ export async function reviewTicket(ticket, ctx) {
205
210
  /**
206
211
  * One fix-loop round for the ticket (see the module doc).
207
212
  * @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
208
- * @param {{base: string | null, level: string, owned: string[], key: Buffer, workDir: string, factsExcerpt: string}} opts
213
+ * @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string}} opts
209
214
  * @returns {Promise<ReviewOutcome>}
210
215
  */
211
- async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, factsExcerpt }) {
216
+ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt }) {
212
217
  if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
213
218
  let rows;
214
219
  try {
@@ -276,7 +281,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, key, workDir, fac
276
281
  factsExcerpt,
277
282
  ...(jev ? { jev } : {}),
278
283
  review: async () => {
279
- engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
284
+ engineOutcome = await reviewFile({ repoRoot: ctx.repoRoot, file: ticket.file, base, cfg: ctx.cfg, kind: blockKindOf, workDir, factsExcerpt }, { spawn: ctx.spawn, writeRow });
280
285
  return engineOutcome;
281
286
  },
282
287
  spawn: async (opts) => {