@codedology/code-forge 0.7.6 → 0.8.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 +51 -0
- package/README.md +57 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +171 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +33 -4
- package/skill/references/ledger.md +8 -4
- package/skill/references/plan.md +12 -0
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +4 -2
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +18 -1
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +337 -37
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +21 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/run.mjs +16 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/decide/lane-facts.mjs +41 -0
- package/src/doctor/local.mjs +2 -1
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +32 -6
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/engine.mjs +3 -2
- package/src/review/gate-check.mjs +9 -5
- package/src/review/open-findings.mjs +59 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +111 -37
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +68 -8
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +21 -2
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +36 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/worker/engine.mjs +157 -17
- package/src/worker/loop.mjs +26 -7
- package/src/worker/queue.mjs +36 -10
- package/src/worker/ticket.mjs +46 -6
package/src/cli/report.mjs
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
|
|
3
|
-
* only, plus an optional `--export <dir>` that writes the same
|
|
3
|
+
* only, plus an optional `--export <dir>` that writes the same 14 sections as JSON files. The
|
|
4
|
+
* terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
|
|
5
|
+
* both are text only, never in `--json` or `--export`. The 14th section, `outside_review` (B66), is
|
|
6
|
+
* printed as text lines under its header (`outside review: none`, or one line per PR).
|
|
4
7
|
*/
|
|
5
8
|
|
|
6
9
|
import { writeSafe } from '../util/redact.mjs';
|
|
7
|
-
import { buildReport, buildSpend } from '../ledger/report.mjs';
|
|
10
|
+
import { buildReport, buildRunModes, buildSpend } from '../ledger/report.mjs';
|
|
8
11
|
import { exportReportSections } from '../ledger/export.mjs';
|
|
12
|
+
import { outsideReviewLines } from '../ledger/outside-review.mjs';
|
|
9
13
|
import { readAllRows } from '../ledger/write.mjs';
|
|
10
14
|
|
|
11
15
|
/**
|
|
@@ -58,9 +62,11 @@ export async function runReport(args, deps = {}) {
|
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
for (const [name, data] of Object.entries(sections)) {
|
|
61
|
-
|
|
65
|
+
const body = name === 'outside_review' ? outsideReviewLines(data) : `${JSON.stringify(data, null, 2)}\n`;
|
|
66
|
+
out(`== ${name} ==\n${body}`);
|
|
62
67
|
}
|
|
63
68
|
out(spendLines(buildSpend(rows)));
|
|
69
|
+
out(modeLines(buildRunModes(rows)));
|
|
64
70
|
return 0;
|
|
65
71
|
} catch (err_) {
|
|
66
72
|
err(`report: ${err_?.message ?? String(err_)}\n`);
|
|
@@ -69,7 +75,7 @@ export async function runReport(args, deps = {}) {
|
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
/**
|
|
72
|
-
* B33: the running spend per run (open blocks included), after the
|
|
78
|
+
* B33: the running spend per run (open blocks included), after the 14 sections. Unknown-price
|
|
73
79
|
* sessions are named, never priced.
|
|
74
80
|
* @param {ReturnType<typeof buildSpend>} spend @returns {string}
|
|
75
81
|
*/
|
|
@@ -81,6 +87,17 @@ export function spendLines({ runs, total }) {
|
|
|
81
87
|
return `${lines.join('\n')}\n`;
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
/**
|
|
91
|
+
* B59: the mode of each run (`brief` or `plan`), after the spend — text only, never in `--json`
|
|
92
|
+
* or `--export`. Empty (no header) when the ledger has no run.
|
|
93
|
+
* @param {ReturnType<typeof buildRunModes>} modes
|
|
94
|
+
* @returns {string}
|
|
95
|
+
*/
|
|
96
|
+
export function modeLines(modes) {
|
|
97
|
+
if (modes.length === 0) return '';
|
|
98
|
+
return `${['run mode:', ...modes.map((m) => ` run ${m.run}: ${m.mode}`)].join('\n')}\n`;
|
|
99
|
+
}
|
|
100
|
+
|
|
84
101
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
85
102
|
export default async function report(args) {
|
|
86
103
|
return runReport(args);
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -23,13 +23,28 @@
|
|
|
23
23
|
* down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
|
|
24
24
|
* (`liveWorker`, B30). Output is one JSON line on stdout.
|
|
25
25
|
* Exit codes: 0 queued/done/pending, 1 bad-path or another refusal, 2 usage, 3 worker_down.
|
|
26
|
+
*
|
|
27
|
+
* B62b: the queue and the worker are always the MAIN tree's (`mainRootOf`), also when the coder
|
|
28
|
+
* runs `review-file` inside a block's worktree. For a block the run record gives a worktree
|
|
29
|
+
* (`proof.isolation: worktree`), the content is hashed in that tree — never in the main tree — and
|
|
30
|
+
* the ticket and the answer carry `tree: <tree top level>`; the worker reviews the same tree. The
|
|
31
|
+
* path is still given relative to the current directory, inside the tree it is called from, and is
|
|
32
|
+
* re-checked in the tree the content is read from. A block the record gives no worktree is hashed
|
|
33
|
+
* in the main tree, wherever `review-file` is called. When the run record does not exist here or
|
|
34
|
+
* cannot be opened (a sandboxed coder), the content is hashed in the tree `review-file` is called
|
|
35
|
+
* from, which is the block's tree when the coder works there. A record naming a worktree that is
|
|
36
|
+
* gone or malformed is refused (exit 1, `reason: worktree-missing|worktree-malformed|…`), and
|
|
37
|
+
* nothing is queued.
|
|
26
38
|
*/
|
|
27
39
|
|
|
28
|
-
import { readFileSync, rmSync } from 'node:fs';
|
|
40
|
+
import { readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
29
41
|
import path from 'node:path';
|
|
30
42
|
import { enqueue, isDone, liveWorker, queueDir } from '../worker/queue.mjs';
|
|
31
|
-
import { assertBlockId, assertTicketId, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
43
|
+
import { assertBlockId, assertInsideRoot, assertTicketId, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
|
|
32
44
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
45
|
+
import { StateError } from '../state/paths.mjs';
|
|
46
|
+
import { readRun } from '../state/run.mjs';
|
|
47
|
+
import { blockTree } from '../proof/worktree.mjs';
|
|
33
48
|
import { writeSafe } from '../util/redact.mjs';
|
|
34
49
|
|
|
35
50
|
const USAGE = 'usage: code-forge review-file <path> --block <id> [--run <id>] | review-file --wait <ticket> [--max <seconds>s]\n';
|
|
@@ -63,6 +78,39 @@ function doneResult(repoRoot, ticket) {
|
|
|
63
78
|
}
|
|
64
79
|
}
|
|
65
80
|
|
|
81
|
+
/**
|
|
82
|
+
* B62b: where the block `block` of run `runId` keeps its content, from the run record (read from
|
|
83
|
+
* the user's state dir, `~/.code-forge/runs`, whatever the current directory): `{top}` with the
|
|
84
|
+
* top level of its worktree, realpath'd; `{top: null}` when the record gives the block no worktree
|
|
85
|
+
* (then the main tree, never the tree `review-file` is called from); null when the record does
|
|
86
|
+
* not exist here or cannot be opened (a sandboxed coder: no such file, or no permission) — only
|
|
87
|
+
* then is the content hashed in the tree `review-file` is called from, and the worker refuses a
|
|
88
|
+
* ticket hashed in the wrong tree. A record that names a worktree which cannot be used (gone,
|
|
89
|
+
* moved, malformed, off its branch) is refused, and so is a record that cannot be parsed or is not
|
|
90
|
+
* a record (`run-record-unreadable`): nothing is queued. Any other failure is rethrown.
|
|
91
|
+
* @param {string} runId @param {string} block
|
|
92
|
+
* @returns {Promise<{top: string | null} | null>}
|
|
93
|
+
* @throws {WorkerError} a `worktree-*` code, or `run-record-unreadable`.
|
|
94
|
+
*/
|
|
95
|
+
async function blockTreeOf(runId, block) {
|
|
96
|
+
let record;
|
|
97
|
+
try {
|
|
98
|
+
record = await readRun(runId);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const code = /** @type {{code?: unknown}} */ (err)?.code;
|
|
101
|
+
if (code === 'no-run' || code === 'EACCES' || code === 'EPERM') return null;
|
|
102
|
+
if (!(err instanceof SyntaxError)) throw err;
|
|
103
|
+
record = null;
|
|
104
|
+
}
|
|
105
|
+
if (record === null || typeof record !== 'object') throw new WorkerError('run-record-unreadable', `the run record of ${runId} cannot be read (corrupt or not a record); nothing was queued`);
|
|
106
|
+
try {
|
|
107
|
+
return { top: (await blockTree(record.workspace, record.blocks?.[block]))?.top ?? null };
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err instanceof StateError) throw new WorkerError(err.code, `block ${block}: ${err.message}; nothing was queued`);
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
66
114
|
/**
|
|
67
115
|
* The coder-facing summary of a done result: the fix list and the stop reason, when any.
|
|
68
116
|
* @param {Record<string, any> | null} result
|
|
@@ -103,7 +151,8 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
103
151
|
const cwd = deps.cwd ?? process.cwd();
|
|
104
152
|
try {
|
|
105
153
|
const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max'] });
|
|
106
|
-
|
|
154
|
+
// the queue and the worker: the main tree, also from inside a block's worktree
|
|
155
|
+
const repoRoot = await mainRootOf(cwd);
|
|
107
156
|
|
|
108
157
|
if (typeof flags.wait === 'string') {
|
|
109
158
|
if (positionals.length > 0 || flags.block !== undefined) throw new WorkerError('usage', '--wait takes only a ticket and --max');
|
|
@@ -132,18 +181,24 @@ export async function runReviewFile(args, deps = {}) {
|
|
|
132
181
|
|
|
133
182
|
if (positionals.length !== 1 || typeof flags.block !== 'string' || flags.max !== undefined) throw new WorkerError('usage', 'give one path and --block');
|
|
134
183
|
const block = assertBlockId(flags.block);
|
|
135
|
-
const file = normalizeRequestPath(positionals[0], cwd,
|
|
184
|
+
const file = normalizeRequestPath(positionals[0], cwd, await repoRootOf(cwd));
|
|
136
185
|
const worker = liveWorker(repoRoot);
|
|
137
186
|
if (!worker || (typeof flags.run === 'string' && flags.run !== worker.run)) {
|
|
138
187
|
say({ status: 'worker_down', file });
|
|
139
188
|
return 3;
|
|
140
189
|
}
|
|
141
|
-
|
|
190
|
+
// the content: the block's worktree when the record gives one, the main tree when it gives
|
|
191
|
+
// none; only a record that cannot be opened here falls back to the tree review-file is called from
|
|
192
|
+
const recorded = await blockTreeOf(worker.run, block);
|
|
193
|
+
const tree = recorded === null ? await repoRootOf(cwd) : (recorded.top ?? repoRoot);
|
|
194
|
+
assertInsideRoot(tree, file); // the path, re-checked in the tree its content is read from
|
|
195
|
+
const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
|
|
196
|
+
const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
|
|
142
197
|
if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
|
|
143
198
|
// `unavailable` is never approval AND never final: drop the done marker so the worker runs
|
|
144
199
|
// the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
|
|
145
200
|
rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
|
|
146
|
-
say({ ...enqueue({ repoRoot, run: worker.run, block, file }), retry: true });
|
|
201
|
+
say({ ...enqueue({ repoRoot, run: worker.run, block, file, ...where }), retry: true });
|
|
147
202
|
return 0;
|
|
148
203
|
}
|
|
149
204
|
say(queued);
|
package/src/cli/run.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `code-forge run start|status|end` (plan §4.9). The orchestrator runs these; a coder never does
|
|
3
3
|
* (`run start` is on the forbidden list, ★ `code-forge-worker-from-coder`).
|
|
4
4
|
*
|
|
5
|
-
* run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]
|
|
5
|
+
* run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief]
|
|
6
6
|
* run start --reattach --run <id> [--worker-pid <pid>]
|
|
7
7
|
* run status --run <id> (B33: includes spent_usd / budget_usd / unknown_usd_sessions)
|
|
8
8
|
* run reload --run <id>
|
|
@@ -14,6 +14,12 @@
|
|
|
14
14
|
* queue announces (`repinWorker`). Called as `runRun(args, deps)` without `deps.startWorker` (the
|
|
15
15
|
* state tests), no worker is launched and none is pinned unless `--worker-pid` is given.
|
|
16
16
|
*
|
|
17
|
+
* B59 `--mode brief`: a run with briefs and no plan. The mode is stored in the run record and on a
|
|
18
|
+
* signed `run.mode {mode: brief, plan: none}` row, and it is fixed for the run (`run reload` never
|
|
19
|
+
* touches it); `block open` without `--level` then takes the lane from the rules. `--mode plan`
|
|
20
|
+
* (the default) changes nothing; any other value, or `--mode` on any other verb or with
|
|
21
|
+
* `--reattach`, is a usage error (exit 2).
|
|
22
|
+
*
|
|
17
23
|
* B35 `run reload`: re-read and validate the workspace's `.code-forge.yml` and replace the run's
|
|
18
24
|
* config snapshot (`reloadRun`) — no `block stop` / `run end` / `run start` cycle. An invalid
|
|
19
25
|
* config or a change to a key that is fixed for the run is refused with nothing changed. It prints
|
|
@@ -29,7 +35,7 @@ import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
|
|
|
29
35
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
30
36
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
31
37
|
import { StateError } from '../state/paths.mjs';
|
|
32
|
-
import { endRun, processStartTime, readRun, reattachWorker, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
38
|
+
import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
|
|
33
39
|
import { writeSafe } from '../util/redact.mjs';
|
|
34
40
|
import { sweepRoots, tmpBase } from '../util/tmp.mjs';
|
|
35
41
|
import { launchWorker, repinWorker } from '../worker/loop.mjs';
|
|
@@ -101,9 +107,12 @@ export async function runRun(args, deps = {}) {
|
|
|
101
107
|
return 2;
|
|
102
108
|
}
|
|
103
109
|
try {
|
|
104
|
-
const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid'], booleans: ['reattach'] });
|
|
110
|
+
const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach'] });
|
|
105
111
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
106
112
|
const workerPid = intFlag(flags['worker-pid'], 'worker-pid');
|
|
113
|
+
const mode = typeof flags.mode === 'string' ? flags.mode : undefined;
|
|
114
|
+
if (mode !== undefined && (sub !== 'start' || flags.reattach)) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
|
|
115
|
+
if (mode !== undefined && !RUN_MODES.includes(mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
|
|
107
116
|
|
|
108
117
|
if (sub === 'start' && flags.reattach) {
|
|
109
118
|
if (!runId) throw new StateError('usage', 'run start --reattach needs --run');
|
|
@@ -129,7 +138,7 @@ export async function runRun(args, deps = {}) {
|
|
|
129
138
|
const engine = typeof flags.engine === 'string' ? flags.engine : undefined;
|
|
130
139
|
const tmpRoot = typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
|
|
131
140
|
await sweepRoots({ root: tmpBase(tmpRoot) }); // dead runs' temp roots and orphans (§9.6, V12)
|
|
132
|
-
let record = await startRun({ workspace, project, config: loaded.config, engine, runId, workerPid, writeRow: ledgerWriter(project), probe });
|
|
141
|
+
let record = await startRun({ workspace, project, config: loaded.config, engine, mode, runId, workerPid, writeRow: ledgerWriter(project), probe });
|
|
133
142
|
if (!record.worker && startWorker) {
|
|
134
143
|
const launched = await startWorker({ runId: record.run_id, workspace });
|
|
135
144
|
const startedAt = await (probe ?? processStartTime)(launched.pid); // what the stop below re-checks against
|
|
@@ -141,7 +150,8 @@ export async function runRun(args, deps = {}) {
|
|
|
141
150
|
throw thrown;
|
|
142
151
|
}
|
|
143
152
|
}
|
|
144
|
-
|
|
153
|
+
const brief = record.mode === 'brief' ? ' · mode brief (no plan)' : '';
|
|
154
|
+
out(`run ${record.run_id} started · engine ${record.engine}${brief} · worker ${record.worker ? `pid ${record.worker.pid}` : 'not pinned'}\n`);
|
|
145
155
|
if (!record.worker) err('run start: no worker pinned — start the worker, then `run start --reattach --run <id> --worker-pid <pid>`\n');
|
|
146
156
|
return 0;
|
|
147
157
|
}
|
|
@@ -211,7 +221,7 @@ async function reloadVerb(runId, { out, err }) {
|
|
|
211
221
|
}
|
|
212
222
|
|
|
213
223
|
const USAGE =
|
|
214
|
-
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
|
|
224
|
+
'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
|
|
215
225
|
|
|
216
226
|
/** @param {string[]} args @returns {Promise<number>} */
|
|
217
227
|
export default async function run(args) {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `code-forge status [--run <id>] [--json]` (B64a, issue #13 item 6): a short status of a run, so
|
|
3
|
+
* the orchestrator checks progress with one command instead of reading logs or polling
|
|
4
|
+
* transcripts.
|
|
5
|
+
*
|
|
6
|
+
* Read-only: it reads the run record (`~/.code-forge/runs/<id>.json`, as `run status` does — no
|
|
7
|
+
* lock), the project's ledger rows and the workspace config (for `budget.usd`), and writes
|
|
8
|
+
* nothing — no ledger row, no file, no lock. The data comes from `briefStatus`
|
|
9
|
+
* (`src/state/brief-status.mjs`); this file finds the runs, counts open findings and renders.
|
|
10
|
+
*
|
|
11
|
+
* With `--run`, that run (ended or not). Without it, every run that is not ended whose workspace
|
|
12
|
+
* is the current repository (`projectRootFor(cwd)`, compared on realpaths), newest first (by
|
|
13
|
+
* `started_at`, then run id); with none, `no active run` (exit 0; `{"runs":[]}` with `--json`).
|
|
14
|
+
* An unknown `--run` exits 1; a usage error (unknown flag, a positional) exits 2.
|
|
15
|
+
*
|
|
16
|
+
* Text: at most {@link STATUS_MAX_LINES} lines per run, runs separated by one blank line:
|
|
17
|
+
* run <id> · <status> · <plan|brief> · spent $<x>[ of $<budget>]
|
|
18
|
+
* <id> <state> <level> · <model or -> · gate <pass|fail|none>[ (<gate>)] · findings <n>
|
|
19
|
+
* … +<k> more blocks (only when the open blocks do not fit)
|
|
20
|
+
* closed <a> · stopped <b> (only when any exist; printed last, but block lines are
|
|
21
|
+
* dropped first to keep it inside the cap)
|
|
22
|
+
* The gate field is `gate pass`, `gate fail (<gate>)`, `gate fail` (no gate named) or `gate none`.
|
|
23
|
+
* `<x>` is `run status`'s spent_usd with 2 decimals, followed by `+?` when some session has no
|
|
24
|
+
* known price (`run status`'s unknown_usd_sessions > 0); the whole amount is `$?` when the ledger
|
|
25
|
+
* cannot be read. A run record with no status shows `-`.
|
|
26
|
+
*
|
|
27
|
+
* `--json`: exactly one line, `{"runs":[{run_id, status, mode, spent_usd, unknown_usd_sessions,
|
|
28
|
+
* budget_usd, blocks, more, closed, stopped}]}` — every block that is neither closed nor stopped
|
|
29
|
+
* (no cap), `more` always 0, `spent_usd` and `unknown_usd_sessions` null when the ledger cannot be
|
|
30
|
+
* read, `status` null when the record has none.
|
|
31
|
+
*
|
|
32
|
+
* The run lock (`<runs>/<id>.lock`, taken by the worker and the block verbs) is never taken nor
|
|
33
|
+
* waited on: a held lock does not delay `status`.
|
|
34
|
+
*
|
|
35
|
+
* Findings: the number of distinct (file, finding) pairs the block gate would refuse today
|
|
36
|
+
* (`checkBlockReviews`, `src/review/gate-check.mjs` — the same rules `block close` applies: the
|
|
37
|
+
* latest review of each file left it open and no valid waiver, ruling or later approval cleared
|
|
38
|
+
* it). The files are the ones the block's review rows name (`reviewedFiles`, the selection
|
|
39
|
+
* `block close` uses for reviewed files), each at the content hash of its LAST review row (only
|
|
40
|
+
* review rows — the events `reviewedFiles` counts — supply a hash; other rows never do). A file
|
|
41
|
+
* whose review rows carry no hash is checked at the empty hash: the gate counts a refusal row
|
|
42
|
+
* with no `content_hash` for every hash of its file (fail closed), so its open findings count.
|
|
43
|
+
* The gate's other refusals (unreviewed, unsigned, proof, rule breaks) are not findings and are
|
|
44
|
+
* not counted. No transcript, diff or review text is ever read or printed.
|
|
45
|
+
*
|
|
46
|
+
* The run key is only read (`loadKey`: a stat and a read, never a create or a write); with no
|
|
47
|
+
* key, a zero key stands in, so no signed waiver or approval verifies (fail closed).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { readdir, realpath } from 'node:fs/promises';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import { loadProjectConfig, projectRootFor } from '../config/load.mjs';
|
|
53
|
+
import { budgetUsdOf } from '../ledger/spend.mjs';
|
|
54
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
55
|
+
import { findingsKey, openFindingsOf } from '../review/open-findings.mjs';
|
|
56
|
+
import { briefStatus } from '../state/brief-status.mjs';
|
|
57
|
+
import { parseFlags } from '../state/cli-args.mjs';
|
|
58
|
+
import { runsDir, StateError } from '../state/paths.mjs';
|
|
59
|
+
import { readRun } from '../state/run.mjs';
|
|
60
|
+
import { writeSafe } from '../util/redact.mjs';
|
|
61
|
+
|
|
62
|
+
const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
63
|
+
|
|
64
|
+
/** The hard cap on text lines per run. */
|
|
65
|
+
const STATUS_MAX_LINES = 10;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {import('../state/brief-status.mjs').RunStatus} RunStatus
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
/** @param {string} p @returns {Promise<string>} */
|
|
72
|
+
async function realOr(p) {
|
|
73
|
+
try {
|
|
74
|
+
return await realpath(p);
|
|
75
|
+
} catch {
|
|
76
|
+
return path.resolve(p);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The status data of one run.
|
|
82
|
+
* @param {Record<string, any>} record
|
|
83
|
+
* @returns {Promise<RunStatus>}
|
|
84
|
+
*/
|
|
85
|
+
async function statusOf(record) {
|
|
86
|
+
/** @type {Array<Record<string, any>> | null} null: the ledger cannot be read */
|
|
87
|
+
let rows = null;
|
|
88
|
+
try {
|
|
89
|
+
rows = await readAllRows(record.project);
|
|
90
|
+
} catch {
|
|
91
|
+
rows = null;
|
|
92
|
+
}
|
|
93
|
+
let budget = null;
|
|
94
|
+
try {
|
|
95
|
+
const loaded = await loadProjectConfig(record.workspace);
|
|
96
|
+
budget = loaded.ok ? budgetUsdOf(loaded.config) : null;
|
|
97
|
+
} catch {
|
|
98
|
+
budget = null;
|
|
99
|
+
}
|
|
100
|
+
const key = await findingsKey(record);
|
|
101
|
+
const runId = String(record.run_id);
|
|
102
|
+
const all = rows ?? [];
|
|
103
|
+
/** @type {Map<string, Array<Record<string, any>>>} the run's rows, grouped by block once */
|
|
104
|
+
const byBlock = new Map();
|
|
105
|
+
for (const r of all) {
|
|
106
|
+
if (r?.run !== runId || typeof r.block !== 'string') continue;
|
|
107
|
+
const list = byBlock.get(r.block);
|
|
108
|
+
if (list) list.push(r);
|
|
109
|
+
else byBlock.set(r.block, [r]);
|
|
110
|
+
}
|
|
111
|
+
return briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Every run that is not ended whose workspace is `root`, newest first.
|
|
116
|
+
* @param {string} root
|
|
117
|
+
* @returns {Promise<Array<Record<string, any>>>}
|
|
118
|
+
*/
|
|
119
|
+
async function activeRunsFor(root) {
|
|
120
|
+
const want = await realOr(root);
|
|
121
|
+
let names;
|
|
122
|
+
try {
|
|
123
|
+
names = await readdir(runsDir());
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const records = [];
|
|
128
|
+
for (const name of names) {
|
|
129
|
+
if (!name.endsWith('.json')) continue;
|
|
130
|
+
let record;
|
|
131
|
+
try {
|
|
132
|
+
record = await readRun(name.slice(0, -'.json'.length));
|
|
133
|
+
} catch {
|
|
134
|
+
continue; // not a run record (a bad id, unparsable): not a run to show
|
|
135
|
+
}
|
|
136
|
+
if (!record || typeof record !== 'object' || record.status === 'ended' || typeof record.workspace !== 'string') continue;
|
|
137
|
+
if ((await realOr(record.workspace)) !== want) continue;
|
|
138
|
+
records.push(record);
|
|
139
|
+
}
|
|
140
|
+
const at = (/** @type {Record<string, any>} */ r) => (typeof r.started_at === 'string' ? r.started_at : '');
|
|
141
|
+
return records.sort((a, b) => at(b).localeCompare(at(a)) || String(b.run_id).localeCompare(String(a.run_id)));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {number} n @returns {string} */
|
|
145
|
+
const usd = (n) => `$${n.toFixed(2)}`;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The text lines of one run, at most {@link STATUS_MAX_LINES}.
|
|
149
|
+
* @param {RunStatus} s @returns {string[]}
|
|
150
|
+
*/
|
|
151
|
+
export function runLines(s) {
|
|
152
|
+
const spent = s.spent_usd === null ? '$?' : `${usd(s.spent_usd)}${(s.unknown_usd_sessions ?? 0) > 0 ? '+?' : ''}`;
|
|
153
|
+
const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
|
|
154
|
+
const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
|
|
155
|
+
const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1);
|
|
156
|
+
const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
|
|
157
|
+
for (const b of shown) {
|
|
158
|
+
lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
|
|
159
|
+
}
|
|
160
|
+
if (shown.length < s.blocks.length) lines.push(`… +${s.blocks.length - shown.length} more blocks`);
|
|
161
|
+
if (tally !== null) lines.push(tally);
|
|
162
|
+
return lines;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string[]} args
|
|
167
|
+
* @returns {Promise<number>}
|
|
168
|
+
*/
|
|
169
|
+
export default async function status(args) {
|
|
170
|
+
const out = (/** @type {string} */ s) => writeSafe(process.stdout, s);
|
|
171
|
+
const err = (/** @type {string} */ s) => writeSafe(process.stderr, s);
|
|
172
|
+
let flags;
|
|
173
|
+
try {
|
|
174
|
+
const parsed = parseFlags(args, { values: ['run'], booleans: ['json'] });
|
|
175
|
+
if (parsed.positionals.length > 0) throw new StateError('usage', `unexpected argument ${JSON.stringify(parsed.positionals[0])}`);
|
|
176
|
+
flags = parsed.flags;
|
|
177
|
+
} catch (thrown) {
|
|
178
|
+
err(`status: ${thrown?.message ?? String(thrown)}\n${USAGE}`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
const json = flags.json === true;
|
|
182
|
+
try {
|
|
183
|
+
/** @type {Array<Record<string, any>>} */
|
|
184
|
+
let records;
|
|
185
|
+
if (typeof flags.run === 'string') {
|
|
186
|
+
const id = flags.run;
|
|
187
|
+
const record = await readRun(id).catch((thrown) => (thrown?.code === 'no-run' ? null : Promise.reject(thrown)));
|
|
188
|
+
if (!record || typeof record !== 'object') {
|
|
189
|
+
err(`status: no run record for ${id}\n`); // the wording every verb uses for an unknown run
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
records = [record];
|
|
193
|
+
} else {
|
|
194
|
+
records = await activeRunsFor(projectRootFor(process.cwd()));
|
|
195
|
+
}
|
|
196
|
+
const shown = [];
|
|
197
|
+
for (const record of records) shown.push(await statusOf(record));
|
|
198
|
+
if (json) {
|
|
199
|
+
out(`${JSON.stringify({ runs: shown })}\n`); // briefStatus builds exactly the clause-3 run object
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
if (shown.length === 0) {
|
|
203
|
+
out('no active run\n');
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
out(`${shown.map((s) => runLines(s).join('\n')).join('\n\n')}\n`);
|
|
207
|
+
return 0;
|
|
208
|
+
} catch (thrown) {
|
|
209
|
+
err(`status: ${thrown?.message ?? String(thrown)}\n`);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/config/validate.mjs
CHANGED
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
63
63
|
import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
|
|
64
64
|
import { redact } from '../util/redact.mjs';
|
|
65
|
+
import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
|
|
65
66
|
import { checkEffort, displayEffort } from '../engines/efforts.mjs';
|
|
66
67
|
import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
|
|
67
68
|
import { isKnownId, resolveLevel } from './known-ids.mjs';
|
|
@@ -458,19 +459,52 @@ function isArgvArray(value) {
|
|
|
458
459
|
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string') && value[0].length > 0;
|
|
459
460
|
}
|
|
460
461
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
462
|
+
/** B60: the keys a gate given as an object may have. */
|
|
463
|
+
const GATE_OBJECT_KEYS = new Set(['argv', 'env']);
|
|
464
|
+
/** B60: an environment variable name. */
|
|
465
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Rule 9's messages for one gate: an argv array, `{argv, env}` (B60), or null. A message names a
|
|
469
|
+
* bad key or env variable NAME, never an env value.
|
|
470
|
+
* @param {string} key @param {unknown} value @returns {string[]}
|
|
471
|
+
*/
|
|
472
|
+
function gateMessages(key, value) {
|
|
473
|
+
if (value === undefined || value === null) return [];
|
|
474
|
+
// B60: `{test_files}` may expand to nothing, so it cannot be the command — and as the only item a
|
|
475
|
+
// full-suite run would be an empty argv
|
|
476
|
+
/** @param {string[]} argv @returns {string[]} */
|
|
477
|
+
const tokenMessages = (argv) =>
|
|
478
|
+
argv.length === 1 && argv[0] === TEST_FILES_TOKEN
|
|
479
|
+
? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the only item (the full suite would run an empty command)`]
|
|
480
|
+
: argv[0] === TEST_FILES_TOKEN
|
|
481
|
+
? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the command (argv[0])`]
|
|
482
|
+
: [];
|
|
483
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
484
|
+
if (!isArgvArray(value)) return [`gates.${key} must be a non-empty argv array of strings, an {argv, env} object, or null`];
|
|
485
|
+
return tokenMessages(/** @type {string[]} */ (value));
|
|
486
|
+
}
|
|
487
|
+
const gate = /** @type {Record<string, unknown>} */ (value);
|
|
488
|
+
const messages = Object.keys(gate)
|
|
489
|
+
.filter((k) => !GATE_OBJECT_KEYS.has(k))
|
|
490
|
+
.map((k) => `gates.${key}: unknown key ${JSON.stringify(k)} (an object gate has only argv and env)`);
|
|
491
|
+
if (!isArgvArray(gate.argv)) messages.push(`gates.${key}.argv must be a non-empty argv array of strings`);
|
|
492
|
+
else messages.push(...tokenMessages(/** @type {string[]} */ (gate.argv)));
|
|
493
|
+
if (gate.env !== undefined) {
|
|
494
|
+
if (gate.env === null || typeof gate.env !== 'object' || Array.isArray(gate.env)) {
|
|
495
|
+
messages.push(`gates.${key}.env must be a map of variable names to string values`);
|
|
496
|
+
} else {
|
|
497
|
+
for (const [name, envValue] of Object.entries(gate.env)) {
|
|
498
|
+
if (!ENV_NAME.test(name)) messages.push(`gates.${key}.env: ${JSON.stringify(name)} is not a variable name (letters, digits and _, not starting with a digit)`);
|
|
499
|
+
else if (typeof envValue !== 'string') messages.push(`gates.${key}.env.${name} must be a string`);
|
|
500
|
+
}
|
|
471
501
|
}
|
|
472
502
|
}
|
|
473
|
-
return
|
|
503
|
+
return messages;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function checkGatesNotArgvArray(cfg) {
|
|
507
|
+
return GATE_KEYS.flatMap((key) => gateMessages(key, cfg?.gates?.[key]).map((message) => ({ rule: 'gates-not-argv-array', severity: 'error', message })));
|
|
474
508
|
}
|
|
475
509
|
|
|
476
510
|
// ── 10. secret-looking-value (ERROR) ────────────────────────────────────────
|