@codedology/code-forge 0.3.2 → 0.5.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 (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +88 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +236 -1
  6. package/docs/reference/config.md +13 -1
  7. package/package.json +6 -2
  8. package/schema/code-forge.schema.json +26 -4
  9. package/skill/SKILL.md +8 -7
  10. package/skill/references/adapters/claude-code.md +5 -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 +10 -2
  17. package/skill/references/plan.md +12 -3
  18. package/skill/references/review.md +13 -6
  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/facts.mjs +16 -1
  25. package/src/cli/jev.mjs +114 -17
  26. package/src/cli/ledger.mjs +99 -8
  27. package/src/cli/logs.mjs +193 -58
  28. package/src/cli/plan.mjs +27 -11
  29. package/src/cli/report.mjs +15 -1
  30. package/src/cli/resolve.mjs +16 -1
  31. package/src/cli/review-file.mjs +10 -5
  32. package/src/cli/review.mjs +40 -9
  33. package/src/cli/run.mjs +109 -8
  34. package/src/config/closed-book.mjs +75 -0
  35. package/src/config/validate.mjs +128 -7
  36. package/src/decide/escalation.mjs +112 -11
  37. package/src/decide/questions.mjs +27 -4
  38. package/src/doctor/index.mjs +2 -1
  39. package/src/doctor/local.mjs +15 -0
  40. package/src/doctor/probes.mjs +48 -6
  41. package/src/engines/builders/claude.mjs +4 -1
  42. package/src/engines/builders/codex.mjs +26 -11
  43. package/src/engines/builders/grok.mjs +3 -3
  44. package/src/engines/efforts.mjs +109 -0
  45. package/src/engines/sentinel.mjs +34 -0
  46. package/src/keys/onepassword.mjs +3 -0
  47. package/src/ledger/report.mjs +66 -29
  48. package/src/ledger/spend.mjs +288 -0
  49. package/src/review/engine.mjs +57 -16
  50. package/src/review/fixloop.mjs +29 -6
  51. package/src/review/session-retry.mjs +69 -0
  52. package/src/review/triage.mjs +26 -1
  53. package/src/review/validate-review.mjs +6 -3
  54. package/src/session/author.mjs +6 -2
  55. package/src/session/facts.mjs +218 -53
  56. package/src/session/plan-check.mjs +71 -6
  57. package/src/session/plan-sections.mjs +98 -0
  58. package/src/session/spawn.mjs +373 -20
  59. package/src/state/block.mjs +13 -4
  60. package/src/state/config-snapshot.mjs +150 -0
  61. package/src/state/run.mjs +86 -0
  62. package/src/util/breadcrumbs.mjs +145 -0
  63. package/src/util/error-hint.mjs +3 -2
  64. package/src/util/error-kind.mjs +2 -2
  65. package/src/util/error-log.mjs +109 -18
  66. package/src/util/forbidden.mjs +3 -1
  67. package/src/util/known-fixes.json +1 -0
  68. package/src/util/known-fixes.mjs +153 -0
  69. package/src/util/locks.mjs +104 -0
  70. package/src/worker/engine.mjs +122 -40
  71. package/src/worker/loop.mjs +326 -78
  72. package/src/worker/queue.mjs +68 -7
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The run's config snapshot (issue #2, block B35): `run start` stores the loaded
3
+ * `.code-forge.yml` in the run record, `run reload` replaces it mid-run, and the worker reviews
4
+ * every ticket with the snapshot it was enqueued under.
5
+ *
6
+ * Shape, in the authoritative run record (`~/.code-forge/runs/<run>.json`):
7
+ *
8
+ * config: {
9
+ * hash: sha256 of the canonical JSON of the snapshot in force,
10
+ * snapshots: {<hash>: <config>} every snapshot a queued ticket may still need,
11
+ * pins: {<ticket id>: <hash>} tickets that were pending at a reload keep the old snapshot,
12
+ * reloaded_at?: ISO time of the last reload,
13
+ * }
14
+ *
15
+ * A ticket is pinned by `run reload` itself (the orchestrator, under the run lock), never by the
16
+ * coder-writable ticket file, so a coder cannot pick an older (weaker) config for a new ticket.
17
+ * Messages here name key PATHS only, never values.
18
+ */
19
+
20
+ import { createHash } from 'node:crypto';
21
+
22
+ /**
23
+ * Keys that cannot change mid-run, each with why. A change to one is refused by `run reload`
24
+ * (end the run and start a new one).
25
+ */
26
+ export const IMMUTABLE_KEYS = Object.freeze({
27
+ version: 'the config format is fixed for the run',
28
+ 'project.slug': 'the ledger file of the run is chosen at run start',
29
+ engine: 'the run engine is fixed at run start',
30
+ 'tmp.root': "the worker's run temp root is created at run start",
31
+ keys: 'the worker resolves keys once, at start',
32
+ 'system1.key': 'the worker resolves the Jev key once, at start',
33
+ });
34
+
35
+ /** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
36
+ const dropped = (v) => v === undefined || typeof v === 'function' || typeof v === 'symbol';
37
+
38
+ /**
39
+ * JSON with object keys sorted at every level (arrays keep their order), so the hash does not
40
+ * depend on key order in the YAML. JSON semantics otherwise: a value with `toJSON` (a `Date` from
41
+ * a YAML timestamp ⇒ its ISO string) is converted first, and what JSON drops is dropped (`null` in
42
+ * a list) — so the hash of a config equals the hash of the same config after the run record's
43
+ * JSON save/read round trip.
44
+ * @param {unknown} value
45
+ * @returns {string}
46
+ */
47
+ export function canonicalJSON(value) {
48
+ const v = value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value).toJSON) === 'function' ? /** @type {any} */ (value).toJSON() : value;
49
+ if (Array.isArray(v)) return `[${v.map((item) => canonicalJSON(dropped(item) ? null : item)).join(',')}]`;
50
+ if (v !== null && typeof v === 'object') {
51
+ const obj = /** @type {Record<string, unknown>} */ (v);
52
+ const keys = Object.keys(obj)
53
+ .filter((k) => !dropped(obj[k]))
54
+ .sort();
55
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJSON(obj[k])}`).join(',')}}`;
56
+ }
57
+ return dropped(v) ? 'null' : JSON.stringify(v);
58
+ }
59
+
60
+ /** @param {unknown} cfg @returns {string} sha256 hex of the canonical JSON. */
61
+ export function configHash(cfg) {
62
+ return createHash('sha256').update(canonicalJSON(cfg)).digest('hex');
63
+ }
64
+
65
+ /** @param {unknown} v @returns {v is Record<string, unknown>} */
66
+ const isMapping = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
67
+
68
+ /**
69
+ * The dotted key paths whose value differs between `before` and `after`, sorted. Mappings are
70
+ * walked key by key; a key present on one side only, a list, or a scalar is one path. A path
71
+ * segment that is not a plain identifier is written in brackets (`keys["a.b"]`).
72
+ * @param {unknown} before @param {unknown} after
73
+ * @returns {string[]}
74
+ */
75
+ export function changedKeyPaths(before, after) {
76
+ /** @type {string[]} */
77
+ const out = [];
78
+ /** @param {unknown} a @param {unknown} b @param {string} at */
79
+ const walk = (a, b, at) => {
80
+ if (isMapping(a) && isMapping(b)) {
81
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
82
+ for (const k of keys) walk(own(a, k), own(b, k), join(at, k));
83
+ return;
84
+ }
85
+ if (canonicalJSON(a ?? null) !== canonicalJSON(b ?? null) || (a === undefined) !== (b === undefined)) out.push(at);
86
+ };
87
+ walk(before, after, '');
88
+ return out.sort();
89
+ }
90
+
91
+ /** @param {Record<string, unknown>} obj @param {string} key @returns {unknown} an OWN property only. */
92
+ const own = (obj, key) => (Object.hasOwn(obj, key) ? obj[key] : undefined);
93
+
94
+ /** @param {string} at @param {string} key */
95
+ function join(at, key) {
96
+ const seg = /^[A-Za-z_][A-Za-z0-9_-]*$/.test(key) ? key : `[${JSON.stringify(key)}]`;
97
+ if (at === '') return seg;
98
+ return seg.startsWith('[') ? `${at}${seg}` : `${at}.${seg}`;
99
+ }
100
+
101
+ /**
102
+ * @param {unknown} cfg @param {string} dotted
103
+ * @returns {unknown} the value at `dotted`, or undefined.
104
+ */
105
+ function valueAt(cfg, dotted) {
106
+ /** @type {unknown} */
107
+ let cur = cfg;
108
+ for (const seg of dotted.split('.')) {
109
+ if (!isMapping(cur)) return undefined;
110
+ cur = own(cur, seg);
111
+ }
112
+ return cur;
113
+ }
114
+
115
+ /**
116
+ * The immutable keys whose value differs between the two configs, in {@link IMMUTABLE_KEYS} order.
117
+ * @param {unknown} before @param {unknown} after
118
+ * @returns {string[]}
119
+ */
120
+ export function immutableChanges(before, after) {
121
+ return Object.keys(IMMUTABLE_KEYS).filter((k) => canonicalJSON(valueAt(before, k) ?? null) !== canonicalJSON(valueAt(after, k) ?? null));
122
+ }
123
+
124
+ /**
125
+ * The `config` block for a new run record.
126
+ * @param {Record<string, any>} cfg
127
+ */
128
+ export function initialConfigState(cfg) {
129
+ const hash = configHash(cfg);
130
+ return { hash, snapshots: { [hash]: cfg }, pins: {} };
131
+ }
132
+
133
+ /**
134
+ * The snapshot a ticket is reviewed with: its pin when `run reload` pinned it, else the current
135
+ * one. Null when the record predates snapshots (the caller keeps the config it booted with).
136
+ * A snapshot that is missing or does not match its hash throws `config-snapshot`.
137
+ * @param {Record<string, any>} record @param {string} ticket
138
+ * @returns {{hash: string, config: Record<string, any>} | null}
139
+ */
140
+ export function snapshotFor(record, ticket) {
141
+ const state = record?.config;
142
+ if (!isMapping(state) || typeof state.hash !== 'string') return null;
143
+ const pins = isMapping(state.pins) ? state.pins : {};
144
+ const pin = own(pins, ticket);
145
+ const hash = typeof pin === 'string' ? pin : state.hash;
146
+ const snaps = isMapping(state.snapshots) ? state.snapshots : {};
147
+ const config = own(snaps, hash);
148
+ if (!isMapping(config) || configHash(config) !== hash) throw new Error('config-snapshot');
149
+ return { hash, config };
150
+ }
package/src/state/run.mjs CHANGED
@@ -16,6 +16,7 @@ import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { exec } from '../util/exec.mjs';
18
18
  import { StateError, assertRunId, assertWorkspaceOutsideRuns, mirrorPath, newRunId, runRecordPath, runsDir } from './paths.mjs';
19
+ import { changedKeyPaths, configHash, IMMUTABLE_KEYS, immutableChanges, initialConfigState } from './config-snapshot.mjs';
19
20
  import { generateKey, loadKey, signRow } from './signer.mjs';
20
21
 
21
22
  /** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
@@ -165,6 +166,9 @@ export async function startRun(opts) {
165
166
  status: 'active',
166
167
  blocks: {},
167
168
  orphans: [],
169
+ // B35: the config snapshot the worker reviews with (`run reload` replaces it). Only when the
170
+ // caller passed the loaded config: a record without it keeps the worker on its boot config.
171
+ ...(opts.config !== undefined ? { config: initialConfigState(config) } : {}),
168
172
  };
169
173
  return withRunLock(runId, async () => {
170
174
  const exists = await readRun(runId).then(
@@ -179,6 +183,88 @@ export async function startRun(opts) {
179
183
  });
180
184
  }
181
185
 
186
+ /**
187
+ * `run reload` (B35): replace the run's config snapshot with `config` (already loaded and
188
+ * validated by the caller), entirely under the run lock. Open blocks, their attempts and the
189
+ * worker pin are untouched.
190
+ *
191
+ * `readPending()` (the queue's tickets without a done marker) is called UNDER the lock, right
192
+ * before the record is saved, so the window in which a ticket could be enqueued unpinned is as
193
+ * short as it can be (enqueueing itself does not take the run lock). Each pending ticket keeps a
194
+ * pin whose snapshot is still stored; any other pending ticket — new, or pinned to a snapshot that
195
+ * has gone missing (never left dangling) — is pinned to the snapshot in force until now. Pins of
196
+ * tickets no longer pending are dropped, and so are snapshots no pin or the current hash names.
197
+ *
198
+ * Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
199
+ * (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
200
+ * against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
201
+ * caller the same way `run start` did); it then gets its first snapshot, `changed_keys: null`,
202
+ * `old_hash: null`.
203
+ *
204
+ * No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
205
+ * source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
206
+ * the old/new snapshot hashes. A row that cannot be written leaves the reload in place and is
207
+ * reported as `rowError`.
208
+ * @param {{
209
+ * runId: string, config: Record<string, any>, readPending?: () => string[], effectiveSlug?: string,
210
+ * writeRow: WriteRow, now?: Date,
211
+ * }} opts
212
+ * @returns {Promise<{changed: string[] | null, oldHash: string | null, newHash: string, pinned: number, rowError: string | null}>}
213
+ * `changed` is `[]` when nothing changed.
214
+ */
215
+ export async function reloadRun({ runId, config, readPending = () => [], effectiveSlug, writeRow, now = new Date() }) {
216
+ return withRunLock(runId, async () => {
217
+ const current = await readRun(runId);
218
+ if (current.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
219
+ const state = current.config && typeof current.config === 'object' && typeof current.config.hash === 'string' ? current.config : null;
220
+ const stored = state && state.snapshots && typeof state.snapshots === 'object' ? state.snapshots : {};
221
+ const newHash = configHash(config);
222
+ const oldHash = state ? state.hash : null;
223
+ if (oldHash === newHash) return { changed: [], oldHash, newHash, pinned: 0, rowError: null };
224
+ const before = state && Object.hasOwn(stored, state.hash) ? stored[state.hash] : undefined;
225
+ if (state && (before === null || typeof before !== 'object')) throw new StateError('config-snapshot', `run ${runId}: the current config snapshot is missing from the run record`);
226
+ /** @type {string[]} */
227
+ let frozen;
228
+ if (state) {
229
+ frozen = immutableChanges(before, config);
230
+ } else {
231
+ // no snapshot: compare what the record stores (`run start` wrote engine and the slug)
232
+ frozen = [];
233
+ if (effectiveSlug !== undefined && effectiveSlug !== current.project) frozen.push('project.slug');
234
+ if (typeof current.engine === 'string' && (config.engine ?? 'auto') !== current.engine) frozen.push('engine');
235
+ }
236
+ if (frozen.length > 0) {
237
+ const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
238
+ throw new StateError('immutable-key', `${why.join('; ')} — put ${frozen.length === 1 ? 'it' : 'them'} back, or end the run and start a new one`);
239
+ }
240
+ const changed = state ? changedKeyPaths(before, config) : null;
241
+ /** @type {Record<string, string>} */
242
+ const pins = {};
243
+ const oldPins = state?.pins && typeof state.pins === 'object' ? state.pins : {};
244
+ for (const ticket of new Set(readPending())) {
245
+ const hash = Object.hasOwn(oldPins, ticket) ? oldPins[ticket] : undefined;
246
+ if (typeof hash === 'string' && Object.hasOwn(stored, hash)) pins[ticket] = hash;
247
+ else if (oldHash !== null) pins[ticket] = oldHash;
248
+ }
249
+ /** @type {Record<string, any>} */
250
+ const snapshots = { [newHash]: config };
251
+ for (const hash of new Set(Object.values(pins))) snapshots[hash] = stored[hash];
252
+ current.config = { hash: newHash, snapshots, pins, reloaded_at: now.toISOString() };
253
+ current.provider = config.provider ?? null;
254
+ current.levels = config.levels ?? null;
255
+ current.fallback_mode = config.system1?.fallback ?? 'rules';
256
+ await saveRun(current);
257
+ const pinned = Object.keys(pins).length;
258
+ let rowError = null;
259
+ try {
260
+ await writeSigned(runId, writeRow, { event: 'run.reload', changed_keys: changed, old_hash: oldHash, new_hash: newHash, pinned_tickets: pinned });
261
+ } catch (err) {
262
+ rowError = err?.code ?? err?.name ?? 'error';
263
+ }
264
+ return { changed, oldHash, newHash, pinned, rowError };
265
+ });
266
+ }
267
+
182
268
  /**
183
269
  * `run start --reattach`: re-pin a restarted worker (the orchestrator only — §4.8).
184
270
  * @param {{runId: string, workerPid: number, writeRow: WriteRow, probe?: StartTimeProbe}} opts
@@ -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
+ []