@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
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B64a (issue #13 item 6): the short status of one run — the data `code-forge status` prints, as
|
|
3
|
+
* text or as one JSON line. A pure function: it reads the run record and the ledger rows it is
|
|
4
|
+
* handed and nothing else (no file, no lock, no row written); the CLI (`src/cli/status.mjs`) reads
|
|
5
|
+
* them and renders the result. The object it returns has exactly the keys of the `--json` run
|
|
6
|
+
* object, in order: run_id, status, mode, spent_usd, unknown_usd_sessions, budget_usd, blocks,
|
|
7
|
+
* more, closed, stopped.
|
|
8
|
+
*
|
|
9
|
+
* Per run: its status (null when the record has none — `run status` then prints no status either;
|
|
10
|
+
* the text shows `-`), its mode (`brief` for a `run start --mode brief` run, else `plan`), the
|
|
11
|
+
* spend (`runSpend`, the same numbers `run status` prints as spent_usd and unknown_usd_sessions;
|
|
12
|
+
* both null when the caller could not read the ledger, `rows: null`) and the budget the caller
|
|
13
|
+
* read, then one entry per block that is neither closed nor stopped — closed and stopped blocks are
|
|
14
|
+
* only counted. A block entry holds:
|
|
15
|
+
* - `state`: `open` on its first attempt, `attempt-<n>` after `block attempt` raised it to n; a
|
|
16
|
+
* block whose record status is anything other than open shows that status as its state;
|
|
17
|
+
* - `level`: the level it was dispatched at (the run record);
|
|
18
|
+
* - `model`: `<provider>/<model>` (or the model alone) of the block's latest coder `session` /
|
|
19
|
+
* `session.background` row of the run that names a model — the rows `src/session/spawn.mjs`
|
|
20
|
+
* writes carry `role`, `provider`, `model`; a `ledger add coder` row (`manual`, no model) is
|
|
21
|
+
* skipped — null when the ledger names none;
|
|
22
|
+
* - `gate`: the block's LAST gate run — `gate.done` says pass (`all_ok: true`) or fail, a
|
|
23
|
+
* `gate.red` with no `gate.done` after it is a fail; the name is the failing gate (the last
|
|
24
|
+
* `gate.red` of that run), null on a pass; `{result: 'none', name: null}` when no gate ran;
|
|
25
|
+
* - `findings`: the open review findings of the block, counted by the caller's `findingsOf`
|
|
26
|
+
* (the CLI counts what the block close gate would refuse, `src/review/gate-check.mjs`).
|
|
27
|
+
* Blocks are in block id order (numeric-aware: B2 before B10).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { runSpend } from '../ledger/spend.mjs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {{id: string, state: string, level: string | null, model: string | null, gate: {result: 'pass' | 'fail' | 'none', name: string | null}, findings: number}} BlockStatus
|
|
34
|
+
* @typedef {{run_id: string, status: string | null, mode: 'plan' | 'brief', spent_usd: number | null, unknown_usd_sessions: number | null, budget_usd: number | null, blocks: BlockStatus[], more: number, closed: number, stopped: number}} RunStatus
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** @param {string} a @param {string} b @returns {number} */
|
|
38
|
+
const byBlockId = (a, b) => a.localeCompare(b, 'en', { numeric: true }) || (a < b ? -1 : a > b ? 1 : 0);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The block's last gate run (see the module doc).
|
|
42
|
+
* @param {Array<Record<string, any>>} mine - the block's rows of the run, in ledger order.
|
|
43
|
+
* @returns {{result: 'pass' | 'fail' | 'none', name: string | null}}
|
|
44
|
+
*/
|
|
45
|
+
export function lastGate(mine) {
|
|
46
|
+
/** @type {string | null} */
|
|
47
|
+
let red = null;
|
|
48
|
+
/** @type {{result: 'pass' | 'fail' | 'none', name: string | null}} */
|
|
49
|
+
let last = { result: 'none', name: null };
|
|
50
|
+
for (const r of mine) {
|
|
51
|
+
if (r.event === 'gate.red') {
|
|
52
|
+
red = typeof r.gate === 'string' ? r.gate : null;
|
|
53
|
+
last = { result: 'fail', name: red };
|
|
54
|
+
} else if (r.event === 'gate.done') {
|
|
55
|
+
last = r.all_ok === true ? { result: 'pass', name: null } : { result: 'fail', name: red };
|
|
56
|
+
red = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return last;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The engine/model of the block's latest coder session row, or null.
|
|
64
|
+
* @param {Array<Record<string, any>>} mine @returns {string | null}
|
|
65
|
+
*/
|
|
66
|
+
export function coderModel(mine) {
|
|
67
|
+
for (let i = mine.length - 1; i >= 0; i -= 1) {
|
|
68
|
+
const r = mine[i];
|
|
69
|
+
if ((r.event === 'session' || r.event === 'session.background') && r.role === 'coder' && typeof r.model === 'string' && r.model.length > 0) {
|
|
70
|
+
return typeof r.provider === 'string' && r.provider.length > 0 ? `${r.provider}/${r.model}` : r.model;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {{
|
|
78
|
+
* record: Record<string, any>, rows: Array<Record<string, any>> | null, budget: number | null,
|
|
79
|
+
* findingsOf: (blockId: string) => number,
|
|
80
|
+
* }} opts - `rows`: the project's ledger rows (other runs are filtered out here), null when the
|
|
81
|
+
* ledger could not be read; `budget`: the workspace's `budget.usd` (null when unset), as
|
|
82
|
+
* `run status` reads it.
|
|
83
|
+
* @returns {RunStatus}
|
|
84
|
+
*/
|
|
85
|
+
export function briefStatus({ record, rows, budget, findingsOf }) {
|
|
86
|
+
const runId = String(record.run_id);
|
|
87
|
+
/** @type {Map<string, Array<Record<string, any>>>} the run's rows, grouped by block once */
|
|
88
|
+
const byBlock = new Map();
|
|
89
|
+
for (const r of rows ?? []) {
|
|
90
|
+
if (r?.run !== runId || typeof r.block !== 'string') continue;
|
|
91
|
+
const list = byBlock.get(r.block);
|
|
92
|
+
if (list) list.push(r);
|
|
93
|
+
else byBlock.set(r.block, [r]);
|
|
94
|
+
}
|
|
95
|
+
const spend = rows == null ? null : runSpend(rows, runId);
|
|
96
|
+
const entries = record.blocks && typeof record.blocks === 'object' ? record.blocks : {};
|
|
97
|
+
const ids = Object.keys(entries).sort(byBlockId);
|
|
98
|
+
/** @type {BlockStatus[]} */
|
|
99
|
+
const blocks = [];
|
|
100
|
+
let closed = 0;
|
|
101
|
+
let stopped = 0;
|
|
102
|
+
for (const id of ids) {
|
|
103
|
+
const entry = entries[id];
|
|
104
|
+
const status = typeof entry?.status === 'string' ? entry.status : 'open';
|
|
105
|
+
if (status === 'closed') closed += 1;
|
|
106
|
+
else if (status === 'stopped') stopped += 1;
|
|
107
|
+
if (status === 'closed' || status === 'stopped') continue;
|
|
108
|
+
const mine = byBlock.get(id) ?? [];
|
|
109
|
+
const attempt = Number.isInteger(entry?.attempt) ? entry.attempt : 1;
|
|
110
|
+
blocks.push({
|
|
111
|
+
id,
|
|
112
|
+
state: status !== 'open' ? status : attempt > 1 ? `attempt-${attempt}` : 'open',
|
|
113
|
+
level: typeof entry?.level === 'string' ? entry.level : null,
|
|
114
|
+
model: coderModel(mine),
|
|
115
|
+
gate: lastGate(mine),
|
|
116
|
+
findings: findingsOf(id),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
run_id: runId,
|
|
121
|
+
status: typeof record.status === 'string' ? record.status : null,
|
|
122
|
+
mode: record.mode === 'brief' ? 'brief' : 'plan',
|
|
123
|
+
spent_usd: spend === null ? null : spend.usd,
|
|
124
|
+
unknown_usd_sessions: spend === null ? null : spend.unknown,
|
|
125
|
+
budget_usd: budget,
|
|
126
|
+
blocks,
|
|
127
|
+
more: 0,
|
|
128
|
+
closed,
|
|
129
|
+
stopped,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -34,6 +34,16 @@ export const IMMUTABLE_KEYS = Object.freeze({
|
|
|
34
34
|
// B58: the owner's switch past the local per-file review is set before the run starts or not at
|
|
35
35
|
// all, so nobody turns it on (or off) mid-run
|
|
36
36
|
'review.external': 'the outside reviewer is fixed by the owner before the run starts',
|
|
37
|
+
// B62a: every block of the run is isolated the same way — only two blocks that each have their
|
|
38
|
+
// own worktree may share owned paths — so neither key changes mid-run
|
|
39
|
+
'proof.isolation': 'every block of the run is isolated the same way',
|
|
40
|
+
'proof.worktree': 'the worktrees and branches of the open blocks were made with it',
|
|
41
|
+
// B63b: it tightens `block close` (the coder's green test run), so it is authority like
|
|
42
|
+
// `review.external`; the rest of `context` stays live
|
|
43
|
+
'context.require_coder_tests': 'whether close needs the coder\'s green test run is fixed by the owner before the run starts',
|
|
44
|
+
// B64b: the block-end hook runs a command on the owner's machine at every block end, so only
|
|
45
|
+
// the owner sets it, before the run starts
|
|
46
|
+
'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
|
|
37
47
|
});
|
|
38
48
|
|
|
39
49
|
/** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
|
|
@@ -154,24 +164,74 @@ export function snapshotFor(record, ticket) {
|
|
|
154
164
|
}
|
|
155
165
|
|
|
156
166
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* is immutable ({@link IMMUTABLE_KEYS}), so this is the value `run start` stored.
|
|
167
|
+
* The config snapshot in force for the run (no ticket pin), or null for a record from before
|
|
168
|
+
* snapshots.
|
|
160
169
|
* @param {Record<string, any>} record
|
|
161
|
-
* @returns {string | null}
|
|
170
|
+
* @returns {Record<string, any> | null}
|
|
162
171
|
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
163
172
|
*/
|
|
164
|
-
export function
|
|
165
|
-
let snap;
|
|
173
|
+
export function configInForce(record) {
|
|
166
174
|
try {
|
|
167
|
-
|
|
175
|
+
return snapshotFor(record, '')?.config ?? null;
|
|
168
176
|
} catch (thrown) {
|
|
169
177
|
if (!(thrown instanceof Error) || thrown.message !== 'config-snapshot') throw thrown;
|
|
170
178
|
throw new StateError('config-snapshot', `run ${record.run_id}: the config snapshot in force does not match its hash`);
|
|
171
179
|
}
|
|
172
|
-
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* B58: the outside reviewer named by the snapshot in force when `review.external.required` is
|
|
184
|
+
* true; null otherwise (key absent, `required` false, or a record from before snapshots). The key
|
|
185
|
+
* is immutable ({@link IMMUTABLE_KEYS}), so this is the value `run start` stored.
|
|
186
|
+
* @param {Record<string, any>} record
|
|
187
|
+
* @returns {string | null}
|
|
188
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
189
|
+
*/
|
|
190
|
+
export function externalReviewer(record) {
|
|
191
|
+
const external = configInForce(record)?.review?.external;
|
|
173
192
|
if (!isMapping(external) || external.required !== true) return null;
|
|
174
193
|
// required without a usable name is no switch at all (the schema refuses it; this is the floor)
|
|
175
194
|
const name = typeof external.name === 'string' ? external.name.trim() : '';
|
|
176
195
|
return name === '' ? null : name; // trimmed: no row ever carries padding
|
|
177
196
|
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* B63b: whether `block close` needs the coder's `TESTS <command> exit=0` line — true only when the
|
|
200
|
+
* snapshot in force sets `context.require_coder_tests: true` (false for the key absent or false,
|
|
201
|
+
* or a record from before snapshots). The key is immutable ({@link IMMUTABLE_KEYS}), so this is
|
|
202
|
+
* the value `run start` stored; the live config is never read.
|
|
203
|
+
* @param {Record<string, any>} record
|
|
204
|
+
* @returns {boolean}
|
|
205
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
206
|
+
*/
|
|
207
|
+
export function requireCoderTests(record) {
|
|
208
|
+
const context = configInForce(record)?.context;
|
|
209
|
+
return isMapping(context) && context.require_coder_tests === true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The default `hooks.on_block_end.timeout_s`. */
|
|
213
|
+
export const HOOK_TIMEOUT_DEFAULT_S = 60;
|
|
214
|
+
/** The schema's bounds of `hooks.on_block_end.timeout_s`. */
|
|
215
|
+
export const HOOK_TIMEOUT_MIN_S = 1;
|
|
216
|
+
export const HOOK_TIMEOUT_MAX_S = 600;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* `hooks.on_block_end` of the run-start config snapshot (never the live config), or null when it
|
|
220
|
+
* is absent or malformed (the schema refuses a malformed one at `run start`).
|
|
221
|
+
* @param {Record<string, any>} record
|
|
222
|
+
* @returns {{argv: string[], timeoutS: number} | null}
|
|
223
|
+
*/
|
|
224
|
+
export function blockEndHook(record) {
|
|
225
|
+
let hook;
|
|
226
|
+
try {
|
|
227
|
+
hook = configInForce(record)?.hooks?.on_block_end;
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (hook === null || typeof hook !== 'object') return null;
|
|
232
|
+
const { argv, timeout_s: timeout } = hook;
|
|
233
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return null;
|
|
234
|
+
const timeoutS = timeout === undefined ? HOOK_TIMEOUT_DEFAULT_S : timeout;
|
|
235
|
+
if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
|
|
236
|
+
return { argv: [...argv], timeoutS };
|
|
237
|
+
}
|
package/src/state/registry.mjs
CHANGED
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
* vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
|
|
35
35
|
* a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
|
|
36
36
|
* `src/**\/*.mjs` does not).
|
|
37
|
+
*
|
|
38
|
+
* B62a — between two OPEN BLOCKS the rule is `blocksOverlap`: two blocks that each have their own
|
|
39
|
+
* worktree (`proof.isolation: worktree`) may own the same path; any pair with a shared-tree block
|
|
40
|
+
* in it is checked with `findOverlap` exactly as above.
|
|
37
41
|
*/
|
|
38
42
|
|
|
39
43
|
import { StateError } from './paths.mjs';
|
|
@@ -99,21 +103,24 @@ export function expandBraces(pattern) {
|
|
|
99
103
|
|
|
100
104
|
/**
|
|
101
105
|
* @param {unknown} owned
|
|
106
|
+
* @param {{label?: string, code?: string}} [as] - B60: `block open --tests` checks its test files by
|
|
107
|
+
* the same rules, named `test files` with the code `bad-tests`.
|
|
102
108
|
* @returns {string[]}
|
|
103
109
|
* An EXACT entry (no `*`, `?` or `{`) may contain `[ ] ( ) ! + @ }` — real routes such as
|
|
104
110
|
* `pages/[id].vue`, `app/(group)/page.tsx`, `@types/x.d.ts` — and is matched literally by
|
|
105
111
|
* string equality. A GLOB entry may not: there those characters would be character-class or
|
|
106
112
|
* extglob syntax this matcher does not implement, so it is refused with that reason rather than
|
|
107
113
|
* silently read one way by us and another way by git or a shell.
|
|
108
|
-
* @throws {StateError} `bad-owned`
|
|
114
|
+
* @throws {StateError} with code `as.code` (default `bad-owned`) and a message that starts with
|
|
115
|
+
* `as.label` (default `owned files`) — empty, absolute, `.`/`..`/empty segments (checked on every
|
|
109
116
|
* brace alternative), backslashes, unbalanced braces, or unsupported glob syntax in a glob.
|
|
110
117
|
*/
|
|
111
|
-
export function assertOwned(owned) {
|
|
112
|
-
if (!Array.isArray(owned) || owned.length === 0) throw new StateError(
|
|
118
|
+
export function assertOwned(owned, { label = 'owned files', code = 'bad-owned' } = {}) {
|
|
119
|
+
if (!Array.isArray(owned) || owned.length === 0) throw new StateError(code, `${label}: at least one path is required`);
|
|
113
120
|
for (const entry of owned) {
|
|
114
121
|
/** @param {string} why */
|
|
115
122
|
const refuse = (why) => {
|
|
116
|
-
throw new StateError(
|
|
123
|
+
throw new StateError(code, `${label}: invalid path ${JSON.stringify(entry)}: ${why}`);
|
|
117
124
|
};
|
|
118
125
|
if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
|
|
119
126
|
if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
|
|
@@ -240,6 +247,30 @@ export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
|
|
|
240
247
|
return null;
|
|
241
248
|
}
|
|
242
249
|
|
|
250
|
+
/**
|
|
251
|
+
* One open block as the overlap rule sees it: its owned entries, their kinds (B57) and its own
|
|
252
|
+
* worktree, if any (B62a). `worktree` is the run record's `{path, branch, base_sha}` (or, for a
|
|
253
|
+
* block being opened, `{path}` of the tree it is about to get); `null` or absent means it works in
|
|
254
|
+
* the shared tree (export or lock isolation). Every caller passes this one shape.
|
|
255
|
+
* @typedef {{owned_files: ReadonlyArray<string>, owned_kinds?: OwnedKinds, worktree?: {path: string} | null}} OverlapBlock
|
|
256
|
+
*/
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* B62a: the owned-files overlap rule between two open blocks. Two blocks that EACH have their own
|
|
260
|
+
* worktree, at different paths, may own the same path — each edits its own copy on its own branch,
|
|
261
|
+
* and nothing is merged by code-forge — so the pair never overlaps. Two blocks on the SAME tree
|
|
262
|
+
* path edit one tree, and a block without a worktree (export or lock isolation) edits the shared
|
|
263
|
+
* tree: both are checked exactly as before B62a ({@link findOverlap}, B57's kinds included).
|
|
264
|
+
* @param {OverlapBlock} a @param {OverlapBlock} b
|
|
265
|
+
* @returns {[string, string] | null} the first overlapping pair (a's entry, b's entry), or null.
|
|
266
|
+
*/
|
|
267
|
+
export function blocksOverlap(a, b) {
|
|
268
|
+
const treeA = a.worktree ?? null;
|
|
269
|
+
const treeB = b.worktree ?? null;
|
|
270
|
+
if (treeA !== null && treeB !== null && treeA.path !== treeB.path) return null;
|
|
271
|
+
return findOverlap(a.owned_files, b.owned_files, { kindsA: a.owned_kinds, kindsB: b.owned_kinds });
|
|
272
|
+
}
|
|
273
|
+
|
|
243
274
|
/**
|
|
244
275
|
* Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
|
|
245
276
|
* below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
|
package/src/state/run.mjs
CHANGED
|
@@ -132,11 +132,17 @@ async function pinWorker(workerPid, probe) {
|
|
|
132
132
|
return { pid: workerPid, started_at: startedAt };
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/** B59: the run modes `run start --mode` takes; `plan` is the default and is never recorded. */
|
|
136
|
+
export const RUN_MODES = Object.freeze(['plan', 'brief']);
|
|
137
|
+
|
|
135
138
|
/**
|
|
136
139
|
* `run start`: record + key + worker pin + ledger `run.start`, all under the run lock. An
|
|
137
140
|
* existing run id is refused before anything is written (its key and record stay untouched).
|
|
141
|
+
* B59: `mode: 'brief'` (a run with briefs and no plan) is stored in the record as `mode` and
|
|
142
|
+
* written as a signed `run.mode {mode: 'brief', plan: 'none'}` row right after `run.start`; `plan`
|
|
143
|
+
* or no mode leaves the record and the rows exactly as before. Nothing changes the mode later.
|
|
138
144
|
* @param {{
|
|
139
|
-
* workspace: string, project: string, config?: Record<string, any>, engine?: string,
|
|
145
|
+
* workspace: string, project: string, config?: Record<string, any>, engine?: string, mode?: string,
|
|
140
146
|
* runId?: string, workerPid?: number, orchestratorPid?: number, soloPid?: string | null,
|
|
141
147
|
* writeRow: WriteRow, probe?: StartTimeProbe, now?: Date,
|
|
142
148
|
* }} opts
|
|
@@ -149,6 +155,8 @@ export async function startRun(opts) {
|
|
|
149
155
|
throw new StateError('bad-project', 'project slug must match /^[a-z0-9][a-z0-9-]*$/ (config key project.slug)');
|
|
150
156
|
}
|
|
151
157
|
const workspace = assertWorkspaceOutsideRuns(opts.workspace);
|
|
158
|
+
if (opts.mode !== undefined && !RUN_MODES.includes(opts.mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
|
|
159
|
+
const brief = opts.mode === 'brief';
|
|
152
160
|
// B58: the schema lets `review.external` stand without a name while `required` is off; on, the
|
|
153
161
|
// reviewer must be named — refused before anything is written
|
|
154
162
|
const external = config.review?.external;
|
|
@@ -172,6 +180,7 @@ export async function startRun(opts) {
|
|
|
172
180
|
status: 'active',
|
|
173
181
|
blocks: {},
|
|
174
182
|
orphans: [],
|
|
183
|
+
...(brief ? { mode: 'brief' } : {}),
|
|
175
184
|
// B35: the config snapshot the worker reviews with (`run reload` replaces it). Only when the
|
|
176
185
|
// caller passed the loaded config: a record without it keeps the worker on its boot config.
|
|
177
186
|
...(opts.config !== undefined ? { config: initialConfigState(config) } : {}),
|
|
@@ -185,6 +194,7 @@ export async function startRun(opts) {
|
|
|
185
194
|
await generateKey(runId);
|
|
186
195
|
await saveRun(record);
|
|
187
196
|
await writeSigned(runId, writeRow, { event: 'run.start', engine: record.engine, worker_pinned: worker !== null });
|
|
197
|
+
if (brief) await writeSigned(runId, writeRow, { event: 'run.mode', mode: 'brief', plan: 'none' });
|
|
188
198
|
// B58: the owner's outside reviewer is on the ledger from the first row of the run on
|
|
189
199
|
const reviewer = externalReviewer(record);
|
|
190
200
|
if (reviewer !== null) await writeSigned(runId, writeRow, { event: 'review.external', required: true, reviewer });
|
|
@@ -207,7 +217,9 @@ export async function startRun(opts) {
|
|
|
207
217
|
* Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
|
|
208
218
|
* (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
|
|
209
219
|
* against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
|
|
210
|
-
* caller the same way `run start` did),
|
|
220
|
+
* caller the same way `run start` did), B58's `review.external.required` must not turn true,
|
|
221
|
+
* B62a's `proof.isolation` must not turn to `worktree`, and B63b's `context.require_coder_tests`
|
|
222
|
+
* must not turn true;
|
|
211
223
|
* it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
|
|
212
224
|
*
|
|
213
225
|
* No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
|
|
@@ -250,6 +262,13 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
250
262
|
// B58: a run started without the key has none to keep — turning it on now is a change; an
|
|
251
263
|
// entry that leaves it off (`required` false or absent, as the schema default) is not
|
|
252
264
|
if (config.review?.external?.required === true) frozen.push('review.external');
|
|
265
|
+
// B62a: a run started without a snapshot was isolated by export (or lock); turning worktree
|
|
266
|
+
// isolation on now is a change
|
|
267
|
+
if (config.proof?.isolation === 'worktree') frozen.push('proof.isolation');
|
|
268
|
+
// B63b: likewise, a run started without a snapshot never required the coder's tests
|
|
269
|
+
if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
|
|
270
|
+
// B64b: and never had a block-end hook
|
|
271
|
+
if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
|
|
253
272
|
}
|
|
254
273
|
if (frozen.length > 0) {
|
|
255
274
|
const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
|
package/src/util/breadcrumbs.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import path from 'node:path';
|
|
|
27
27
|
export const SUBCOMMANDS = Object.freeze({
|
|
28
28
|
autopilot: ['start', 'status', 'stop'],
|
|
29
29
|
block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
|
|
30
|
+
context: ['pack'],
|
|
30
31
|
gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
|
|
31
32
|
jev: ['ask'],
|
|
32
33
|
keys: ['list', 'set', 'test', 'remove'],
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -277,7 +277,9 @@ export const FORBIDDEN = Object.freeze(
|
|
|
277
277
|
* always appends them, so every list built for a coder carries them.
|
|
278
278
|
* - `code-forge block waive` — waiving a review finding is human-only;
|
|
279
279
|
* - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
|
|
280
|
-
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check
|
|
280
|
+
* - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
|
|
281
|
+
* - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
|
|
282
|
+
* alone. `block close` itself is not on this list, so the flag is listed on its own.
|
|
281
283
|
* @type {ReadonlyArray<ForbiddenEntry>}
|
|
282
284
|
*/
|
|
283
285
|
export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
@@ -306,6 +308,12 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
306
308
|
tokens: ['--no-require-reviews'],
|
|
307
309
|
description: 'Close a block without the per-file review check (--no-require-reviews) — human-only',
|
|
308
310
|
},
|
|
311
|
+
{
|
|
312
|
+
id: 'code-forge-remove-worktree',
|
|
313
|
+
kind: 'contains',
|
|
314
|
+
tokens: ['--remove-worktree'],
|
|
315
|
+
description: "Remove a block's worktree at close (block close --remove-worktree, B62a) — the orchestrator only",
|
|
316
|
+
},
|
|
309
317
|
].map(freezeEntry),
|
|
310
318
|
);
|
|
311
319
|
|
|
@@ -342,6 +350,33 @@ export function mergeForbidden(extraTokens = []) {
|
|
|
342
350
|
return [...FORBIDDEN, ...CODER_ONLY_FORBIDDEN, ...extras];
|
|
343
351
|
}
|
|
344
352
|
|
|
353
|
+
/** B64b: the id of the entry {@link hookForbidden} builds. */
|
|
354
|
+
export const HOOK_FORBIDDEN_ID = 'code-forge-on-block-end-hook';
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* B64b: the owner's `hooks.on_block_end` command as a coder-forbidden `prefix` entry — the hook
|
|
358
|
+
* runs only from the orchestrator's block-end verbs, never for a coder. The tokens are TRIMMED:
|
|
359
|
+
* the command name of `argv[0]` (its basename, as every reading normalizes it), plus `argv[1]`
|
|
360
|
+
* when there is one that is not blank (clause 3, "if any"). `argv[1]` stays ONE token even with
|
|
361
|
+
* inner whitespace — a literal match, as ruled. No entry (`[]`, so a list built with it is
|
|
362
|
+
* unchanged) when there is no hook (`argv` absent, empty, or not all non-empty strings), or when
|
|
363
|
+
* `argv[0]` is blank, names a directory (`/`, `dir/`), or is `.` or `..`. Appended by the caller
|
|
364
|
+
* after the static entries; it never replaces or weakens one.
|
|
365
|
+
* @param {unknown} argv
|
|
366
|
+
* @returns {ForbiddenEntry[]}
|
|
367
|
+
*/
|
|
368
|
+
export function hookForbidden(argv) {
|
|
369
|
+
if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return [];
|
|
370
|
+
const first = argv[0].trim();
|
|
371
|
+
// a command name that is blank, a directory (`/`, `dir/`), `.` or `..` matches nothing a coder runs
|
|
372
|
+
if (first.length === 0 || /[/\\]$/.test(first)) return [];
|
|
373
|
+
const name = commandName(first).trim();
|
|
374
|
+
if (name.length === 0 || name === '.' || name === '..') return [];
|
|
375
|
+
const arg = argv.length > 1 ? argv[1].trim() : '';
|
|
376
|
+
const tokens = arg.length > 0 ? [name, arg] : [name];
|
|
377
|
+
return [freezeEntry({ id: HOOK_FORBIDDEN_ID, kind: 'prefix', tokens, description: 'Run the configured on_block_end hook (hooks.on_block_end) — it runs only at a block end, from the orchestrator\'s verbs' })];
|
|
378
|
+
}
|
|
379
|
+
|
|
345
380
|
// ── Normalization ────────────────────────────────────────────────────────────
|
|
346
381
|
|
|
347
382
|
/** git global options that take the NEXT argv token as their value (when not written `--opt=value`). */
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one argv item a gate uses for the block's related tests. It lives in `util` because two
|
|
3
|
+
* layers need the same value: `gates/expand.mjs` expands it, and `config/validate.mjs` refuses it as
|
|
4
|
+
* a gate's command (`config` may not import `gates`).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** A gate argv item that is EXACTLY this string expands to the block's test files. */
|
|
8
|
+
export const TEST_FILES_TOKEN = '{test_files}';
|