@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.
- package/CHANGELOG.md +85 -0
- package/README.md +88 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +236 -1
- package/docs/reference/config.md +13 -1
- package/package.json +6 -2
- package/schema/code-forge.schema.json +26 -4
- package/skill/SKILL.md +8 -7
- package/skill/references/adapters/claude-code.md +5 -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 +10 -2
- package/skill/references/plan.md +12 -3
- package/skill/references/review.md +13 -6
- 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/facts.mjs +16 -1
- 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/review.mjs +40 -9
- 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/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- 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 +288 -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/facts.mjs +218 -53
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +373 -20
- 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/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +122 -40
- package/src/worker/loop.mjs +326 -78
- 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
|
+
}
|
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
|
+
[]
|