@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
|
@@ -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
|
+
}
|
package/src/util/error-hint.mjs
CHANGED
|
@@ -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;
|
package/src/util/error-kind.mjs
CHANGED
|
@@ -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
|
|
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;
|
package/src/util/error-log.mjs
CHANGED
|
@@ -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
|
|
33
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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).
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -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
|
+
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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) => {
|