@codedology/code-forge 0.7.7 → 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 +41 -0
- package/README.md +50 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +157 -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/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 +292 -33
- 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 +7 -4
- package/src/cli/review-file.mjs +61 -6
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -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 +17 -5
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- 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 +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- 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/block.mjs
CHANGED
|
@@ -4,13 +4,31 @@
|
|
|
4
4
|
*
|
|
5
5
|
* block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
|
|
6
6
|
* [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract]
|
|
7
|
+
* [--tests <paths…>]
|
|
7
8
|
* block attempt <id> --run <r>
|
|
8
9
|
* block rebase <id> --run <r> [--head <ref>]
|
|
9
10
|
* block claim <id> <path> --run <r>
|
|
10
|
-
* block close <id> --run <r> [--worker-pid <pid>]
|
|
11
|
+
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
11
12
|
* block stop <id> --run <r> --reason <text>
|
|
12
13
|
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
|
|
13
14
|
*
|
|
15
|
+
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
16
|
+
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
17
|
+
* kind, attempt, base_sha, tests, worktree, brief; attempt: attempt; rebase (`rebased`): old_base,
|
|
18
|
+
* new_base; claim (`claimed`): path; stop (`stopped`); waive (`waived`): finding, file, by;
|
|
19
|
+
* close (`closed`): status, reason, close_event (the close result's own event word, as the text
|
|
20
|
+
* prints it in parentheses, else null), and `worktree {removed, path, branch}` with
|
|
21
|
+
* `--remove-worktree`). On
|
|
22
|
+
* failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
|
|
23
|
+
* refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
|
|
24
|
+
* error, a thrown refusal, a cancelled `--no-require-reviews` and a failed `--remove-worktree`
|
|
25
|
+
* print nothing on stdout. Without `--json` every output is byte-identical to before.
|
|
26
|
+
*
|
|
27
|
+
* B64b block end: a close that closes or stops the block and `block stop` write
|
|
28
|
+
* `<runs>/<run>/blocks/<id>.done.json` after the end's rows (`state/block-end.mjs`), then, with
|
|
29
|
+
* `hooks.on_block_end` in the run's snapshot, run that command outside the lock and add
|
|
30
|
+
* `done_file` to `close`/`stop --json`. Without the key the output is unchanged.
|
|
31
|
+
*
|
|
14
32
|
* `block close [--transcript <file>] [--report <file>] [--no-require-reviews]` also runs B12b's review rows
|
|
15
33
|
* (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
|
|
16
34
|
* changed owned file (deletions included) that no later review of the same content made stale
|
|
@@ -59,6 +77,16 @@
|
|
|
59
77
|
* else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
|
|
60
78
|
* `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
|
|
61
79
|
*
|
|
80
|
+
* B63b: `context.require_coder_tests: true` in the run's config snapshot (immutable like
|
|
81
|
+
* `review.external`; the live config is never read for it) adds one check after the report check:
|
|
82
|
+
* `--report` must be given and readable, its last sentinel for the block must be COMPLETE, and a
|
|
83
|
+
* `TESTS <command> exit=<n>` line must come before it with `n` 0 — else `coder report FAILED:
|
|
84
|
+
* context.require_coder_tests needs --report` / `--report cannot be read` / `no ===BLOCK <id>
|
|
85
|
+
* COMPLETE=== sentinel` / `no TESTS line` / `TESTS exit=<n>`. Waived reviews (the human flag or
|
|
86
|
+
* `review.external`) do not lift it. On a pass a signed `coder.tests {block, command, exit}` row
|
|
87
|
+
* (the command redacted) is one of `closeBlock`'s close rows, after any waiver row. Without the key
|
|
88
|
+
* (or false) nothing is added.
|
|
89
|
+
*
|
|
62
90
|
* B47: `block open` without `--level` takes the level `autopilot level` chose for the block (the
|
|
63
91
|
* latest `autopilot.level` row of the run for it whose MAC verifies, `autopilotLevel`); with
|
|
64
92
|
* neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
|
|
@@ -72,6 +100,34 @@
|
|
|
72
100
|
* `--level` still wins, with no decision row. Outside brief mode `--lines` stays the forecast it
|
|
73
101
|
* always was, and no `--level` without an autopilot level is the usage error above.
|
|
74
102
|
*
|
|
103
|
+
* B60: `--tests <paths…>` names the block's test files (the same path rules as `--owned`); they are
|
|
104
|
+
* recorded as the block's `test_files` and on the dispatch row, and a gate argv item that is
|
|
105
|
+
* exactly `{test_files}` runs them (`gates run --run --block`). Without it the gate takes the owned
|
|
106
|
+
* entries that look like tests.
|
|
107
|
+
*
|
|
108
|
+
* B62a: under `proof.isolation: worktree` (the run's config snapshot, `proof/worktree.mjs`) `block
|
|
109
|
+
* open` makes the block's own worktree and branch before the block is recorded — or reuses the one
|
|
110
|
+
* an earlier attempt of the same block made — and prints a `worktree <path> · branch <branch>` line
|
|
111
|
+
* between the open line and the brief pointer. A refused `git worktree add` refuses the open (exit
|
|
112
|
+
* 1) with nothing recorded. `block close --remove-worktree` runs `git worktree remove <path>` (no
|
|
113
|
+
* `--force`; the branch is kept) only after the block closed, and writes a signed
|
|
114
|
+
* `worktree.removed` or `worktree.remove_failed` row; a failed removal is exit 1 with the block
|
|
115
|
+
* still closed. The flag on a block without a worktree, or on another subcommand, is a usage error
|
|
116
|
+
* (exit 2). It is coder-forbidden (`CODER_ONLY_FORBIDDEN`).
|
|
117
|
+
*
|
|
118
|
+
* B62b: `block close` of a block with a worktree measures that tree (`blockTree`), not the main
|
|
119
|
+
* one: the changed files (committed on the block's branch and uncommitted, against
|
|
120
|
+
* `worktree.base_sha`), the file set and its content hashes, and the unowned changes all come from
|
|
121
|
+
* the tree, by the same rules as for the main tree. A change in the main tree or in another
|
|
122
|
+
* block's tree never counts for the block. A fresh tree holds nothing but the block's own work,
|
|
123
|
+
* so every change in it is the block's: the `tree_at_open` stamps (taken in the main tree) do not
|
|
124
|
+
* exempt anything there, and a file another block owns is not left to that block (its close
|
|
125
|
+
* measures only its own tree) — a change in the tree to any file this block does not own is
|
|
126
|
+
* `unowned_change`. The whole close uses one base, the tree's `base_sha`. A tree that is gone,
|
|
127
|
+
* not a directory, without the workspace's subdirectory, or recorded malformed refuses the close
|
|
128
|
+
* before anything is asked or written. The ledger, the signed result files and the high paths stay
|
|
129
|
+
* in the main tree. Nothing is checked out, pushed or merged.
|
|
130
|
+
*
|
|
75
131
|
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
76
132
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
77
133
|
* through `closeBlock`'s `extraChecks`.
|
|
@@ -86,24 +142,29 @@ import { laneFacts } from '../decide/lane-facts.mjs';
|
|
|
86
142
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
87
143
|
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
88
144
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
89
|
-
import { configInForce, externalReviewer } from '../state/config-snapshot.mjs';
|
|
145
|
+
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
90
146
|
import { StateError } from '../state/paths.mjs';
|
|
91
147
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
92
148
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
93
149
|
import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
94
150
|
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
95
|
-
import { writeSafe } from '../util/redact.mjs';
|
|
151
|
+
import { redact, writeSafe } from '../util/redact.mjs';
|
|
152
|
+
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
96
153
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
97
154
|
import { validateConfig } from '../config/validate.mjs';
|
|
98
155
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
99
156
|
import { exec } from '../util/exec.mjs';
|
|
100
157
|
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
101
|
-
import { missingReviewTickets } from '../engines/sentinel.mjs';
|
|
158
|
+
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
159
|
+
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
160
|
+
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
161
|
+
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
102
162
|
|
|
103
163
|
const USAGE =
|
|
104
|
-
'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract]\n' +
|
|
105
|
-
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
106
|
-
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n'
|
|
164
|
+
'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract] [--tests <paths…>]\n' +
|
|
165
|
+
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
166
|
+
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
167
|
+
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
107
168
|
|
|
108
169
|
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
|
|
109
170
|
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
|
|
@@ -114,9 +175,11 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
114
175
|
* stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date,
|
|
115
176
|
* isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
|
|
116
177
|
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
178
|
+
* removeBlockTree?: typeof removeBlockTree,
|
|
117
179
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
118
180
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
119
181
|
* `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
|
|
182
|
+
* `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
|
|
120
183
|
* @returns {Promise<number>}
|
|
121
184
|
*/
|
|
122
185
|
export async function runBlock(args, deps = {}) {
|
|
@@ -129,19 +192,66 @@ export async function runBlock(args, deps = {}) {
|
|
|
129
192
|
return 2;
|
|
130
193
|
}
|
|
131
194
|
try {
|
|
132
|
-
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned'], booleans: ['no-require-reviews'] });
|
|
195
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
|
|
196
|
+
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
197
|
+
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
198
|
+
const json = flags.json === true;
|
|
199
|
+
const say = json ? (/** @type {string} */ _s) => {} : out;
|
|
200
|
+
const emit = (/** @type {Record<string, unknown>} */ obj) => out(`${JSON.stringify(obj)}\n`);
|
|
201
|
+
// B60: --tests names exact paths (a file or a directory — PHPUnit and Pest take directories).
|
|
202
|
+
// `*` and `?` are refused: a glob would reach the gate unexpanded. `[` and `{` are allowed,
|
|
203
|
+
// since real paths use them (`app/[id]/page.test.tsx`) and argv never reaches a shell. A
|
|
204
|
+
// leading "-" would be read as an option.
|
|
205
|
+
for (const t of sub === 'open' && Array.isArray(flags.tests) ? flags.tests : []) {
|
|
206
|
+
if (/[*?]/.test(t)) throw new StateError('usage', `--tests takes exact paths (a file or a directory); "*" and "?" are refused: ${JSON.stringify(t)}`);
|
|
207
|
+
if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
|
|
208
|
+
}
|
|
133
209
|
const [id, extra] = positionals;
|
|
134
210
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
135
211
|
if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
|
|
136
212
|
if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
|
|
137
213
|
throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
|
|
138
214
|
}
|
|
215
|
+
if (sub !== 'close' && flags['remove-worktree'] === true) throw new StateError('usage', '--remove-worktree is a block close flag');
|
|
139
216
|
if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
|
|
140
217
|
if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
141
218
|
if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
|
|
142
219
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
143
220
|
const record = await readRun(runId);
|
|
144
221
|
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
222
|
+
// B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
|
|
223
|
+
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
224
|
+
// done file is the only addition: no row, and every output stays exactly as before.
|
|
225
|
+
const hook = blockEndHook(record);
|
|
226
|
+
const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason) => {
|
|
227
|
+
// ruling (e): `ended` never throws — any failure here is the done-file warning and a null
|
|
228
|
+
// `doneFile`, so the caller's try/finally still reaches the hook
|
|
229
|
+
/** @type {string | null} */
|
|
230
|
+
let doneFile = null;
|
|
231
|
+
try {
|
|
232
|
+
doneFile = await writeDoneFile({
|
|
233
|
+
record,
|
|
234
|
+
id,
|
|
235
|
+
status,
|
|
236
|
+
event,
|
|
237
|
+
reason: typeof reason === 'string' ? redact(reason) : null,
|
|
238
|
+
openFindings: (rows) => blockOpenFindings(record, rows, id),
|
|
239
|
+
err,
|
|
240
|
+
});
|
|
241
|
+
} catch (thrown) {
|
|
242
|
+
const code = /** @type {any} */ (thrown)?.code;
|
|
243
|
+
err(`warning: done file not written: ${typeof code === 'string' && /^[A-Z0-9_]{1,32}$/.test(code) ? code : 'UNKNOWN'}\n`);
|
|
244
|
+
doneFile = null;
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
json: hook === null ? {} : { done_file: doneFile },
|
|
248
|
+
runHook: async () => {
|
|
249
|
+
if (hook === null) return;
|
|
250
|
+
const top = await repoTop(record.workspace).catch(() => record.workspace);
|
|
251
|
+
await runBlockEndHook({ hook, runId, id, status, doneFile: doneFile ?? '', repoTop: top, writeRow, err });
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
};
|
|
145
255
|
// B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
|
|
146
256
|
// a failed check is a warning, never a reason to stop the block command
|
|
147
257
|
try {
|
|
@@ -179,7 +289,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
179
289
|
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new StateError('usage', '--brief must be a file inside the workspace');
|
|
180
290
|
brief = { path: relative, content: await readFile(absolute) };
|
|
181
291
|
}
|
|
182
|
-
|
|
292
|
+
// B62a: the run's isolation, from its config snapshot (fixed for the run)
|
|
293
|
+
const isolated = worktreeSettings(record);
|
|
294
|
+
const { block, pointer, worktreeReused } = await openBlock({
|
|
183
295
|
runId,
|
|
184
296
|
id,
|
|
185
297
|
level,
|
|
@@ -191,37 +303,68 @@ export async function runBlock(args, deps = {}) {
|
|
|
191
303
|
brief,
|
|
192
304
|
...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
|
|
193
305
|
...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
|
|
306
|
+
...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
|
|
194
307
|
cfg: await openConfig(record.workspace),
|
|
195
308
|
writeRow,
|
|
309
|
+
...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
|
|
196
310
|
});
|
|
197
311
|
const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
|
|
198
312
|
const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
|
|
199
|
-
|
|
200
|
-
|
|
313
|
+
const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
|
|
314
|
+
const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
|
|
315
|
+
say(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}${tests}\n`);
|
|
316
|
+
if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
|
|
317
|
+
say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
|
|
318
|
+
if (json) {
|
|
319
|
+
emit({
|
|
320
|
+
block: id,
|
|
321
|
+
event: 'open',
|
|
322
|
+
level: block.level,
|
|
323
|
+
asked: level ?? null,
|
|
324
|
+
source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
|
|
325
|
+
kind: block.kind,
|
|
326
|
+
attempt: block.attempt,
|
|
327
|
+
base_sha: block.base_sha,
|
|
328
|
+
tests: testCount,
|
|
329
|
+
worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
|
|
330
|
+
brief: pointer ?? null,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
201
333
|
return 0;
|
|
202
334
|
}
|
|
203
335
|
if (sub === 'attempt') {
|
|
204
|
-
|
|
336
|
+
const n = await attemptBlock({ runId, id, writeRow });
|
|
337
|
+
say(`block ${id} attempt ${n}\n`);
|
|
338
|
+
if (json) emit({ block: id, event: 'attempt', attempt: n });
|
|
205
339
|
return 0;
|
|
206
340
|
}
|
|
207
341
|
if (sub === 'rebase') {
|
|
208
342
|
const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow });
|
|
209
|
-
|
|
343
|
+
say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
|
|
344
|
+
if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
|
|
210
345
|
return 0;
|
|
211
346
|
}
|
|
212
347
|
if (sub === 'claim') {
|
|
213
348
|
await claimPath({ runId, id, file: extra, writeRow });
|
|
214
|
-
|
|
349
|
+
say(`block ${id} claimed ${extra}\n`);
|
|
350
|
+
if (json) emit({ block: id, event: 'claimed', path: extra });
|
|
215
351
|
return 0;
|
|
216
352
|
}
|
|
217
353
|
if (sub === 'stop') {
|
|
218
354
|
await stopBlock({ runId, id, reason: /** @type {string} */ (flags.reason), writeRow });
|
|
219
|
-
|
|
220
|
-
|
|
355
|
+
const end = await ended('stopped', 'block.stop', /** @type {string} */ (flags.reason));
|
|
356
|
+
try {
|
|
357
|
+
say(`block ${id} stopped\n`);
|
|
358
|
+
if (json) emit({ block: id, event: 'stopped', ...end.json });
|
|
359
|
+
return 0;
|
|
360
|
+
} finally {
|
|
361
|
+
await end.runHook();
|
|
362
|
+
}
|
|
221
363
|
}
|
|
222
364
|
if (sub === 'waive') {
|
|
223
365
|
await waiveFinding({ runId, id, finding: extra, reason: /** @type {string} */ (flags.reason), file: /** @type {string} */ (flags.file), writeRow });
|
|
224
|
-
|
|
366
|
+
say(`block ${id} waived ${extra} in ${flags.file} (by: human)\n`);
|
|
367
|
+
if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'human' });
|
|
225
368
|
return 0;
|
|
226
369
|
}
|
|
227
370
|
if (sub === 'close') {
|
|
@@ -230,6 +373,21 @@ export async function runBlock(args, deps = {}) {
|
|
|
230
373
|
// B52: a block that is not open is refused here, with or without --no-require-reviews, by
|
|
231
374
|
// the same check and message as `closeBlock` — and the owner is never asked about it
|
|
232
375
|
if (entry.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
376
|
+
// B62a: removing a tree the block does not have is a usage error, before anything runs
|
|
377
|
+
const removeTree = flags['remove-worktree'] === true;
|
|
378
|
+
if (removeTree && !entry.worktree) throw new StateError('usage', `block ${id} has no worktree (proof.isolation is not worktree); --remove-worktree does not apply`);
|
|
379
|
+
// B62b: the block's tree is where its work is measured — a tree that is gone refuses the close
|
|
380
|
+
// before anything is asked or written
|
|
381
|
+
const wt = await blockTree(record.workspace, entry).catch((thrown) => {
|
|
382
|
+
if (thrown?.code === 'worktree-missing') {
|
|
383
|
+
throw new StateError('worktree-missing', `block ${id}: its worktree ${entry.worktree.path} is gone (removed or moved), so its work cannot be measured — restore it on branch ${entry.worktree.branch} (git worktree prune, then git worktree add ${entry.worktree.path} ${entry.worktree.branch}) and close again; nothing was recorded`);
|
|
384
|
+
}
|
|
385
|
+
if (typeof thrown?.code === 'string' && thrown.code.startsWith('worktree-')) throw new StateError(thrown.code, `block ${id}: ${thrown.message}, so its work cannot be measured; nothing was recorded`);
|
|
386
|
+
throw thrown;
|
|
387
|
+
});
|
|
388
|
+
// ONE base for the whole close: a worktree block is measured against the commit its tree was
|
|
389
|
+
// made from (B62a keeps it equal to the block's base_sha: a re-open keeps it, a rebase is refused)
|
|
390
|
+
const base = wt !== null ? wt.base_sha : entry.base_sha;
|
|
233
391
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
234
392
|
// B52: the human's way out is confirmed by the human, at a terminal, before anything is written
|
|
235
393
|
if (reviewsWaived) {
|
|
@@ -248,25 +406,29 @@ export async function runBlock(args, deps = {}) {
|
|
|
248
406
|
// relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
|
|
249
407
|
// (`repoRootOf`), and `tree_at_open` is stamped there too. An owned entry matches a path
|
|
250
408
|
// as given or, for a workspace in a subdirectory, relative to the workspace (`ownsAt`).
|
|
251
|
-
// A failure here is the usual `git-failed` refusal.
|
|
252
|
-
|
|
409
|
+
// A failure here is the usual `git-failed` refusal. B62b: for a block with a worktree the
|
|
410
|
+
// listing and the content are the tree's (`top`); `mainTop` keeps the run's state.
|
|
411
|
+
/** @type {Promise<{top: string, mainTop: string, where: string, inTree: boolean, wsPrefix: string, owns: (owned: ReadonlyArray<string>, file: string) => boolean, changed: string[], fileSet: Array<{file: string, content_hash: string}>, reviewed: string[]}> | undefined} */
|
|
253
412
|
let treeOnce;
|
|
254
413
|
const tree = () =>
|
|
255
414
|
(treeOnce ??= (async () => {
|
|
256
|
-
const
|
|
415
|
+
const mainTop = await repoTop(record.workspace);
|
|
416
|
+
const inTree = wt !== null;
|
|
417
|
+
const top = inTree ? wt.top : mainTop;
|
|
418
|
+
const where = inTree ? `the worktree ${wt.path}` : 'the workspace';
|
|
257
419
|
// both sides as realpaths: git prints the top resolved (/private/var on macOS) while the
|
|
258
420
|
// run record may keep a symlinked spelling (/var)
|
|
259
421
|
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
260
|
-
const wsPrefix = path.relative(await real(
|
|
422
|
+
const wsPrefix = path.relative(await real(mainTop), await real(record.workspace)).split(path.sep).join('/');
|
|
261
423
|
const owns = ownsAt(wsPrefix);
|
|
262
424
|
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
263
|
-
const changed = await changedFiles({ repoRoot: top, base
|
|
425
|
+
const changed = await changedFiles({ repoRoot: top, base });
|
|
264
426
|
// the file set is the diff since base ∪ every owned file with review rows of this block —
|
|
265
427
|
// work the diff cannot see (committed before `block open`, done in another worktree) is
|
|
266
|
-
// still checked, at its content in the workspace
|
|
428
|
+
// still checked, at its content in the workspace (in the block's worktree when it has one)
|
|
267
429
|
const reviewed = reviewedFiles(rows, id).filter(isMine);
|
|
268
|
-
const fileSet = await blockFileSet({ repoRoot: top, base
|
|
269
|
-
return { top, wsPrefix, owns, changed, fileSet, reviewed };
|
|
430
|
+
const fileSet = await blockFileSet({ repoRoot: top, base, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
|
|
431
|
+
return { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed };
|
|
270
432
|
})());
|
|
271
433
|
const reviews = reviewGateCheck(async () => {
|
|
272
434
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
@@ -277,13 +439,14 @@ export async function runBlock(args, deps = {}) {
|
|
|
277
439
|
// the human's way out of the per-file review check leaves a signed audit row before the close
|
|
278
440
|
if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
279
441
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
280
|
-
const highPaths = await highPathsAt(record.workspace,
|
|
281
|
-
const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
442
|
+
const highPaths = await highPathsAt(record.workspace, base);
|
|
443
|
+
const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
282
444
|
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
283
445
|
const key = await loadKey(runId);
|
|
284
446
|
// B52: an empty diff with review rows is never silent — name the likely cause
|
|
285
447
|
if (reviewed.length > 0 && !changed.some(isMine)) {
|
|
286
|
-
|
|
448
|
+
const likely = inTree ? 'done outside the block\'s worktree (in the main checkout or another tree)' : 'committed before block open or done in another worktree';
|
|
449
|
+
err(`WARN block ${id}: no owned file differs from the block base in ${where}, but ${reviewed.length} owned file(s) have review rows — the work was likely ${likely}; they are checked at their content in ${where}\n`);
|
|
287
450
|
}
|
|
288
451
|
// B52: only code-forge's own state is exempt — `<top>/.code-forge/`, where the worker keeps
|
|
289
452
|
// its queue and signed results — never a `--transcript` or `--report` path in the source
|
|
@@ -293,7 +456,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
293
456
|
// `.code-forge/runs/<run>/<id>.log`)
|
|
294
457
|
const wsRel = (/** @type {string} */ p) => path.posix.join(wsPrefix, p);
|
|
295
458
|
const ownState = new Set([wsRel(`.code-forge/runs/${runId}.json`), wsRel(`.code-forge/runs/${runId}/${id}.log`)]);
|
|
296
|
-
|
|
459
|
+
// B62b: a block's own tree started clean at its base and no other block works in it — no
|
|
460
|
+
// stamp from the main tree and no other block's ownership exempts a change there
|
|
461
|
+
const scope = await unownedChanges({ top, changed: changed.filter((f) => !ownState.has(f)), id, blocks: record.blocks, rows, key, isMine, owns, stateDir: '.code-forge', ownTree: inTree });
|
|
297
462
|
if (scope === null) err(`WARN block ${id}: opened before code-forge recorded the tree at block open; unowned changes were not checked\n`);
|
|
298
463
|
for (const { file, block: other } of scope?.siblings ?? []) err(`WARN block ${id}: ${file} changed and is owned by open block ${other}; not checked here (its own gate reviews it)\n`);
|
|
299
464
|
return {
|
|
@@ -305,7 +470,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
305
470
|
// B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
|
|
306
471
|
unowned: scope?.unowned ?? [],
|
|
307
472
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
308
|
-
resultFiles: await resultFilesFor({ repoRoot:
|
|
473
|
+
resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
|
|
309
474
|
files: reviewsWaived || external !== null ? [] : fileSet,
|
|
310
475
|
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
|
|
311
476
|
// --no-require-reviews); B58: `review.external` does not lift it
|
|
@@ -330,10 +495,60 @@ export async function runBlock(args, deps = {}) {
|
|
|
330
495
|
};
|
|
331
496
|
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
332
497
|
// `block.close` and only when every other check passed — never by a refused close
|
|
498
|
+
/** @type {Record<string, any>[]} */
|
|
333
499
|
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
500
|
+
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
501
|
+
const extraChecks = [reviews, report];
|
|
502
|
+
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
503
|
+
// needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
|
|
504
|
+
// reads the report, so a refusal here leaves no row (not even the human waiver row the review
|
|
505
|
+
// check writes). Absent or false: nothing is added, the close is exactly as before.
|
|
506
|
+
if (requireCoderTests(record)) {
|
|
507
|
+
extraChecks.unshift(async () => {
|
|
508
|
+
const tests = await coderTests(flags.report, id);
|
|
509
|
+
if (!tests.ok) return tests;
|
|
510
|
+
// signed under the run lock by `closeBlock`, after the waiver row, only when the block closes
|
|
511
|
+
closeRows.push({ event: 'coder.tests', block: id, command: redact(tests.command), exit: tests.exit });
|
|
512
|
+
return { ok: true };
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
// B64b: the hook command is the orchestrator's, never a coder's — a coder transcript that runs
|
|
516
|
+
// it refuses the close. Only with the hook configured (the list is otherwise unchanged); it
|
|
517
|
+
// only reads the transcript, so it runs first and a refusal here leaves no row.
|
|
518
|
+
if (hook !== null) {
|
|
519
|
+
extraChecks.unshift(async () => {
|
|
520
|
+
const text = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
521
|
+
const [hit] = text === null ? [] : scanTranscript(text, hookForbidden(hook.argv));
|
|
522
|
+
return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
|
|
526
|
+
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
527
|
+
// are written and the run lock released; the done file is written before the tree is removed
|
|
528
|
+
const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null) : null;
|
|
529
|
+
// B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
|
|
530
|
+
// does — also when a later step (the output, `--remove-worktree`) throws; it never changes
|
|
531
|
+
// the exit code or stdout (`runHook` never throws)
|
|
532
|
+
try {
|
|
533
|
+
(result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
534
|
+
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
535
|
+
if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
|
|
536
|
+
const closedJson = { block: id, event: result.status, status: result.status, reason: result.reason ?? null, close_event: result.event ?? null, ...(end?.json ?? {}) };
|
|
537
|
+
if (!result.ok || !removeTree) {
|
|
538
|
+
if (json && result.ok) emit(closedJson);
|
|
539
|
+
return result.ok ? 0 : 1;
|
|
540
|
+
}
|
|
541
|
+
const removed = await (deps.removeBlockTree ?? removeBlockTree)(runId, id, entry.worktree, record.workspace, writeRow, say, err);
|
|
542
|
+
// a failed removal is exit 1 with its stderr text and nothing on stdout, like every failure
|
|
543
|
+
if (json && removed === 0) {
|
|
544
|
+
// built from the block's own tree entry; `removed` is what removeBlockTree reported (0 = removed)
|
|
545
|
+
const treeJson = entry.worktree ? { removed: removed === 0, path: entry.worktree.path, branch: entry.worktree.branch } : null;
|
|
546
|
+
emit({ ...closedJson, worktree: treeJson });
|
|
547
|
+
}
|
|
548
|
+
return removed;
|
|
549
|
+
} finally {
|
|
550
|
+
await end?.runHook();
|
|
551
|
+
}
|
|
337
552
|
}
|
|
338
553
|
} catch (thrown) {
|
|
339
554
|
err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
|
|
@@ -343,6 +558,50 @@ export async function runBlock(args, deps = {}) {
|
|
|
343
558
|
return 2;
|
|
344
559
|
}
|
|
345
560
|
|
|
561
|
+
/**
|
|
562
|
+
* B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
|
|
563
|
+
* `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
|
|
564
|
+
* `worktree.remove_failed {block, path, reason}` row. A failed removal (a tree with modified or
|
|
565
|
+
* untracked files) leaves the tree where it is and the block closed, and is exit 1.
|
|
566
|
+
* @param {string} runId @param {string} id @param {{path: string, branch: string, base_sha: string}} worktree
|
|
567
|
+
* @param {string} workspace @param {(row: Record<string, any>) => Promise<unknown>} writeRow
|
|
568
|
+
* @param {(s: string) => unknown} out @param {(s: string) => unknown} err
|
|
569
|
+
* @returns {Promise<number>}
|
|
570
|
+
*/
|
|
571
|
+
async function removeBlockTree(runId, id, worktree, workspace, writeRow, out, err) {
|
|
572
|
+
const failed = await removeWorktree({ cwd: workspace, worktree });
|
|
573
|
+
if (failed === null) {
|
|
574
|
+
await writeSigned(runId, writeRow, { event: 'worktree.removed', block: id, path: worktree.path, branch: worktree.branch });
|
|
575
|
+
out(`block ${id} worktree removed: ${worktree.path} (branch ${worktree.branch} kept)\n`);
|
|
576
|
+
return 0;
|
|
577
|
+
}
|
|
578
|
+
await writeSigned(runId, writeRow, { event: 'worktree.remove_failed', block: id, path: worktree.path, reason: failed });
|
|
579
|
+
err(`block ${id} worktree NOT removed: ${failed} — the block stays closed; the tree stays at ${worktree.path} (commit or clean it, then git worktree remove it)\n`);
|
|
580
|
+
return 1;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* B63b: the coder's green test run, read from its final report — the block's LAST sentinel must
|
|
585
|
+
* be COMPLETE, with a `TESTS <command> exit=0` line before it (`parseSentinel`).
|
|
586
|
+
* @param {unknown} reportFlag - the `--report` value (absent, or `true` for a valueless flag).
|
|
587
|
+
* @param {string} id
|
|
588
|
+
* @returns {Promise<{ok: true, command: string, exit: number} | {ok: false, reason: string}>}
|
|
589
|
+
*/
|
|
590
|
+
async function coderTests(reportFlag, id) {
|
|
591
|
+
if (typeof reportFlag !== 'string') return { ok: false, reason: 'coder report FAILED: context.require_coder_tests needs --report' };
|
|
592
|
+
let text;
|
|
593
|
+
try {
|
|
594
|
+
text = await readFile(reportFlag, 'utf8');
|
|
595
|
+
} catch {
|
|
596
|
+
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
597
|
+
}
|
|
598
|
+
const sentinel = parseSentinel(text, id);
|
|
599
|
+
if (sentinel?.status !== 'complete') return { ok: false, reason: `coder report FAILED: no ===BLOCK ${id} COMPLETE=== sentinel` };
|
|
600
|
+
if (!sentinel.tests) return { ok: false, reason: 'coder report FAILED: no TESTS line' };
|
|
601
|
+
if (sentinel.tests.exit !== 0) return { ok: false, reason: `coder report FAILED: TESTS exit=${sentinel.tests.exit}` };
|
|
602
|
+
return { ok: true, command: sentinel.tests.command, exit: 0 };
|
|
603
|
+
}
|
|
604
|
+
|
|
346
605
|
/** B52: the refusal when `--no-require-reviews` has no terminal to ask the human at. */
|
|
347
606
|
export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
|
|
348
607
|
|