@codedology/code-forge 0.7.7 → 0.9.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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -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 +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- 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 +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- 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/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -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 +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
package/src/cli/block.mjs
CHANGED
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `code-forge block open|attempt|rebase|claim|close|stop` (plan §4.9). The skill calls
|
|
2
|
+
* `code-forge block open|attempt|rebase|claim|close|stop|waive|reopen` (plan §4.9). The skill calls
|
|
3
3
|
* `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
|
|
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)
|
|
14
|
+
* block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72)
|
|
15
|
+
*
|
|
16
|
+
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
17
|
+
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
18
|
+
* kind, attempt, base_sha, tests, worktree, brief; attempt: attempt; rebase (`rebased`): old_base,
|
|
19
|
+
* new_base; claim (`claimed`): path; stop (`stopped`); waive (`waived`): finding, file, by;
|
|
20
|
+
* close (`closed`): status, reason, close_event (the close result's own event word, as the text
|
|
21
|
+
* prints it in parentheses, else null), and `worktree {removed, path, branch}` with
|
|
22
|
+
* `--remove-worktree`). On
|
|
23
|
+
* failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
|
|
24
|
+
* refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
|
|
25
|
+
* error, a thrown refusal, a cancelled `--no-require-reviews` and a failed `--remove-worktree`
|
|
26
|
+
* print nothing on stdout. Without `--json` every output is byte-identical to before.
|
|
27
|
+
*
|
|
28
|
+
* B64b block end: a close that closes or stops the block and `block stop` write
|
|
29
|
+
* `<runs>/<run>/blocks/<id>.done.json` after the end's rows (`state/block-end.mjs`), then, with
|
|
30
|
+
* `hooks.on_block_end` in the run's snapshot, run that command outside the lock and add
|
|
31
|
+
* `done_file` to `close`/`stop --json`. Without the key the output is unchanged.
|
|
13
32
|
*
|
|
14
33
|
* `block close [--transcript <file>] [--report <file>] [--no-require-reviews]` also runs B12b's review rows
|
|
15
34
|
* (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
|
|
@@ -59,6 +78,29 @@
|
|
|
59
78
|
* else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
|
|
60
79
|
* `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
|
|
61
80
|
*
|
|
81
|
+
* B70 (issue #9 a): a file with no approval for its current hash whose real review (a signed
|
|
82
|
+
* `review.round` at that hash) left findings that are all waived — `block waive`, or an autopilot
|
|
83
|
+
* waiver its grant covered for a non-critical — passes as `reviewed_by_waiver`, never approved
|
|
84
|
+
* (`checkBlockReviews`). When the block closes, one signed `gate.review_waived_all {file,
|
|
85
|
+
* content_hash, findings, by}` row per such file is written just before `block.close` (after any
|
|
86
|
+
* other close row); a refused close writes none. The close prints `block <id>: <file>
|
|
87
|
+
* reviewed_by_waiver (<ids>; by <who>)` before the closed line, and `--json` adds
|
|
88
|
+
* `reviewed_by_waiver: [{file, content_hash, status, findings, by}]` (only when there is one).
|
|
89
|
+
* B71: a finding a signed `review.answered` row clears (another owned file resolved it, judged by
|
|
90
|
+
* a reviewer) counts like a waived one, listed under `answered` next to `findings` in the close
|
|
91
|
+
* row and the `--json` entry; the line then reads `(<ids>; by <who>; answered <ids>)`, or
|
|
92
|
+
* `(answered <ids>)` when nothing was waived (`by` is then null).
|
|
93
|
+
*
|
|
94
|
+
* B63b: `context.require_coder_tests: true` in the run's config snapshot (immutable like
|
|
95
|
+
* `review.external`; the live config is never read for it) adds one check after the report check:
|
|
96
|
+
* `--report` must be given and readable, its last sentinel for the block must be COMPLETE, and a
|
|
97
|
+
* `TESTS <command> exit=<n>` line must come before it with `n` 0 — else `coder report FAILED:
|
|
98
|
+
* context.require_coder_tests needs --report` / `--report cannot be read` / `no ===BLOCK <id>
|
|
99
|
+
* COMPLETE=== sentinel` / `no TESTS line` / `TESTS exit=<n>`. Waived reviews (the human flag or
|
|
100
|
+
* `review.external`) do not lift it. On a pass a signed `coder.tests {block, command, exit}` row
|
|
101
|
+
* (the command redacted) is one of `closeBlock`'s close rows, after any waiver row. Without the key
|
|
102
|
+
* (or false) nothing is added.
|
|
103
|
+
*
|
|
62
104
|
* B47: `block open` without `--level` takes the level `autopilot level` chose for the block (the
|
|
63
105
|
* latest `autopilot.level` row of the run for it whose MAC verifies, `autopilotLevel`); with
|
|
64
106
|
* neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
|
|
@@ -72,6 +114,48 @@
|
|
|
72
114
|
* `--level` still wins, with no decision row. Outside brief mode `--lines` stays the forecast it
|
|
73
115
|
* always was, and no `--level` without an autopilot level is the usage error above.
|
|
74
116
|
*
|
|
117
|
+
* B60: `--tests <paths…>` names the block's test files (the same path rules as `--owned`); they are
|
|
118
|
+
* recorded as the block's `test_files` and on the dispatch row, and a gate argv item that is
|
|
119
|
+
* exactly `{test_files}` runs them (`gates run --run --block`). Without it the gate takes the owned
|
|
120
|
+
* entries that look like tests.
|
|
121
|
+
*
|
|
122
|
+
* B62a: under `proof.isolation: worktree` (the run's config snapshot, `proof/worktree.mjs`) `block
|
|
123
|
+
* open` makes the block's own worktree and branch before the block is recorded — or reuses the one
|
|
124
|
+
* an earlier attempt of the same block made — and prints a `worktree <path> · branch <branch>` line
|
|
125
|
+
* between the open line and the brief pointer. A refused `git worktree add` refuses the open (exit
|
|
126
|
+
* 1) with nothing recorded. `block close --remove-worktree` runs `git worktree remove <path>` (no
|
|
127
|
+
* `--force`; the branch is kept) only after the block closed, and writes a signed
|
|
128
|
+
* `worktree.removed` or `worktree.remove_failed` row; a failed removal is exit 1 with the block
|
|
129
|
+
* still closed. The flag on a block without a worktree, or on another subcommand, is a usage error
|
|
130
|
+
* (exit 2). It is coder-forbidden (`CODER_ONLY_FORBIDDEN`).
|
|
131
|
+
*
|
|
132
|
+
* B62b: `block close` of a block with a worktree measures that tree (`blockTree`), not the main
|
|
133
|
+
* one: the changed files (committed on the block's branch and uncommitted, against
|
|
134
|
+
* `worktree.base_sha`), the file set and its content hashes, and the unowned changes all come from
|
|
135
|
+
* the tree, by the same rules as for the main tree. A change in the main tree or in another
|
|
136
|
+
* block's tree never counts for the block. A fresh tree holds nothing but the block's own work,
|
|
137
|
+
* so every change in it is the block's: the `tree_at_open` stamps (taken in the main tree) do not
|
|
138
|
+
* exempt anything there, and a file another block owns is not left to that block (its close
|
|
139
|
+
* measures only its own tree) — a change in the tree to any file this block does not own is
|
|
140
|
+
* `unowned_change`. The whole close uses one base, the tree's `base_sha`. A tree that is gone,
|
|
141
|
+
* not a directory, without the workspace's subdirectory, or recorded malformed refuses the close
|
|
142
|
+
* before anything is asked or written. The ledger, the signed result files and the high paths stay
|
|
143
|
+
* in the main tree. Nothing is checked out, pushed or merged.
|
|
144
|
+
*
|
|
145
|
+
* B72 (issues #12, #15): `block reopen <id> --run <r> --file <path> --reason <text>` is the
|
|
146
|
+
* human's restart of a file whose review STOPPED or whose state is LOST (`review/reopen.mjs`):
|
|
147
|
+
* coder-forbidden like `block waive`. The done marker of the current content's ticket is removed
|
|
148
|
+
* (the ticket of the block's worktree when it has one), then one signed `review.reopened {block,
|
|
149
|
+
* file, from, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row is written, so the
|
|
150
|
+
* next `review-file` runs a full round 1. Refused (exit 1, no row): `unknown_block` and
|
|
151
|
+
* `reopen-block-not-open` (checked before anything is read), a `worktree-*` code (the block's tree
|
|
152
|
+
* cannot be read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read),
|
|
153
|
+
* `reopen-not-owned`, `reopen-tampered`, `reopen-not-stopped`, `reopen-same-content`,
|
|
154
|
+
* `reopen-already-recorded`, `reopen-write-failed`; a missing `--file` or `--reason` is a usage
|
|
155
|
+
* error (exit 2). It prints `block <id> reopened <file> (from
|
|
156
|
+
* <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
|
|
157
|
+
* "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
|
|
158
|
+
*
|
|
75
159
|
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
76
160
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
77
161
|
* through `closeBlock`'s `extraChecks`.
|
|
@@ -86,26 +170,90 @@ import { laneFacts } from '../decide/lane-facts.mjs';
|
|
|
86
170
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
87
171
|
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
88
172
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
89
|
-
import { configInForce, externalReviewer } from '../state/config-snapshot.mjs';
|
|
173
|
+
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
90
174
|
import { StateError } from '../state/paths.mjs';
|
|
91
175
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
92
176
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
93
177
|
import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
94
178
|
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
95
|
-
import { writeSafe } from '../util/redact.mjs';
|
|
179
|
+
import { redact, writeSafe } from '../util/redact.mjs';
|
|
180
|
+
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
96
181
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
97
182
|
import { validateConfig } from '../config/validate.mjs';
|
|
98
183
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
99
184
|
import { exec } from '../util/exec.mjs';
|
|
100
|
-
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
101
|
-
import {
|
|
185
|
+
import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
186
|
+
import { clearDone } from '../worker/queue.mjs';
|
|
187
|
+
import { loadState } from '../worker/review-state.mjs';
|
|
188
|
+
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
189
|
+
import { runRootPath } from '../util/tmp.mjs';
|
|
190
|
+
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
191
|
+
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
192
|
+
import { blockOpenFindings } from '../review/open-findings.mjs';
|
|
193
|
+
import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* B72: `block reopen` — the file's content is read where the block works (its worktree, else the
|
|
197
|
+
* main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
|
|
198
|
+
* the checks, the done marker's removal and then the signed row are `reopenFile`'s.
|
|
199
|
+
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
|
|
200
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
|
|
201
|
+
*/
|
|
202
|
+
async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
|
|
203
|
+
// the stable block codes come first, before any tree, config, key or ledger read
|
|
204
|
+
const entry = openBlockEntry(record, runId, id);
|
|
205
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
206
|
+
const mainTop = await real(await repoTop(record.workspace));
|
|
207
|
+
const wt = await blockTree(record.workspace, entry).catch((thrown) => {
|
|
208
|
+
if (typeof thrown?.code === 'string' && thrown.code.startsWith('worktree-')) throw new StateError(thrown.code, `block ${id}: ${thrown.message}, so the file's content cannot be read; nothing was recorded`);
|
|
209
|
+
throw thrown;
|
|
210
|
+
});
|
|
211
|
+
const contentRoot = wt === null ? mainTop : wt.top;
|
|
212
|
+
const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
|
|
213
|
+
// the state is read where the worker keeps it: an unreadable snapshot or tmp.root is refused,
|
|
214
|
+
// never guessed (a wrong root would read as "no state"); only an absent tmp.root (undefined or
|
|
215
|
+
// null) means the default root, any other non-string is refused (B79)
|
|
216
|
+
/** @type {string} */
|
|
217
|
+
let runRootDir;
|
|
218
|
+
try {
|
|
219
|
+
const root = configInForce(record)?.tmp?.root;
|
|
220
|
+
if (root !== undefined && root !== null && typeof root !== 'string') throw new TypeError('tmp.root is not a string');
|
|
221
|
+
runRootDir = runRootPath(runId, typeof root === 'string' ? { root } : {});
|
|
222
|
+
} catch {
|
|
223
|
+
throw new StateError('reopen-config', `reopen-config: the run's config snapshot or its tmp.root cannot be read, so the review state of ${file} cannot be found; nothing was recorded`);
|
|
224
|
+
}
|
|
225
|
+
const key = await loadKey(runId);
|
|
226
|
+
const rows = await readAllRows(record.project);
|
|
227
|
+
const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
|
|
228
|
+
const { row, cleared } = await reopenFile({
|
|
229
|
+
runId,
|
|
230
|
+
id,
|
|
231
|
+
file,
|
|
232
|
+
reason,
|
|
233
|
+
record,
|
|
234
|
+
rows,
|
|
235
|
+
key,
|
|
236
|
+
writeRow,
|
|
237
|
+
writeSigned,
|
|
238
|
+
io: {
|
|
239
|
+
owns: ownsAt(wsPrefix),
|
|
240
|
+
loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
|
|
241
|
+
contentHash: (f) => contentHash(contentRoot, f),
|
|
242
|
+
// C11 (synchronous): the cached result of the current content's ticket would repeat the stop
|
|
243
|
+
clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
return { row, cleared };
|
|
247
|
+
}
|
|
102
248
|
|
|
103
249
|
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'
|
|
250
|
+
'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' +
|
|
251
|
+
' 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' +
|
|
252
|
+
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
253
|
+
' code-forge block reopen <id> --run <r> --file <path> --reason <text>\n' +
|
|
254
|
+
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
107
255
|
|
|
108
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
|
|
256
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
|
|
109
257
|
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
|
|
110
258
|
|
|
111
259
|
/**
|
|
@@ -114,9 +262,11 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
114
262
|
* stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date,
|
|
115
263
|
* isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
|
|
116
264
|
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
265
|
+
* removeBlockTree?: typeof removeBlockTree,
|
|
117
266
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
118
267
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
119
268
|
* `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
|
|
269
|
+
* `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
|
|
120
270
|
* @returns {Promise<number>}
|
|
121
271
|
*/
|
|
122
272
|
export async function runBlock(args, deps = {}) {
|
|
@@ -129,19 +279,68 @@ export async function runBlock(args, deps = {}) {
|
|
|
129
279
|
return 2;
|
|
130
280
|
}
|
|
131
281
|
try {
|
|
132
|
-
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned'], booleans: ['no-require-reviews'] });
|
|
282
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
|
|
283
|
+
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
284
|
+
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
285
|
+
const json = flags.json === true;
|
|
286
|
+
const say = json ? (/** @type {string} */ _s) => {} : out;
|
|
287
|
+
const emit = (/** @type {Record<string, unknown>} */ obj) => out(`${JSON.stringify(obj)}\n`);
|
|
288
|
+
// B60: --tests names exact paths (a file or a directory — PHPUnit and Pest take directories).
|
|
289
|
+
// `*` and `?` are refused: a glob would reach the gate unexpanded. `[` and `{` are allowed,
|
|
290
|
+
// since real paths use them (`app/[id]/page.test.tsx`) and argv never reaches a shell. A
|
|
291
|
+
// leading "-" would be read as an option.
|
|
292
|
+
for (const t of sub === 'open' && Array.isArray(flags.tests) ? flags.tests : []) {
|
|
293
|
+
if (/[*?]/.test(t)) throw new StateError('usage', `--tests takes exact paths (a file or a directory); "*" and "?" are refused: ${JSON.stringify(t)}`);
|
|
294
|
+
if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
|
|
295
|
+
}
|
|
133
296
|
const [id, extra] = positionals;
|
|
134
297
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
135
298
|
if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
|
|
136
299
|
if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
|
|
137
300
|
throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
|
|
138
301
|
}
|
|
302
|
+
if (sub !== 'close' && flags['remove-worktree'] === true) throw new StateError('usage', '--remove-worktree is a block close flag');
|
|
139
303
|
if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
|
|
140
|
-
if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
304
|
+
if ((sub === 'stop' || sub === 'waive' || sub === 'reopen') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
|
|
141
305
|
if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
|
|
306
|
+
if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
|
|
307
|
+
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
142
308
|
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
309
|
const record = await readRun(runId);
|
|
144
310
|
const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
311
|
+
// B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
|
|
312
|
+
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
313
|
+
// done file is the only addition: no row, and every output stays exactly as before.
|
|
314
|
+
const hook = blockEndHook(record);
|
|
315
|
+
const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason) => {
|
|
316
|
+
// ruling (e): `ended` never throws — any failure here is the done-file warning and a null
|
|
317
|
+
// `doneFile`, so the caller's try/finally still reaches the hook
|
|
318
|
+
/** @type {string | null} */
|
|
319
|
+
let doneFile = null;
|
|
320
|
+
try {
|
|
321
|
+
doneFile = await writeDoneFile({
|
|
322
|
+
record,
|
|
323
|
+
id,
|
|
324
|
+
status,
|
|
325
|
+
event,
|
|
326
|
+
reason: typeof reason === 'string' ? redact(reason) : null,
|
|
327
|
+
openFindings: (rows) => blockOpenFindings(record, rows, id),
|
|
328
|
+
err,
|
|
329
|
+
});
|
|
330
|
+
} catch (thrown) {
|
|
331
|
+
const code = /** @type {any} */ (thrown)?.code;
|
|
332
|
+
err(`warning: done file not written: ${typeof code === 'string' && /^[A-Z0-9_]{1,32}$/.test(code) ? code : 'UNKNOWN'}\n`);
|
|
333
|
+
doneFile = null;
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
json: hook === null ? {} : { done_file: doneFile },
|
|
337
|
+
runHook: async () => {
|
|
338
|
+
if (hook === null) return;
|
|
339
|
+
const top = await repoTop(record.workspace).catch(() => record.workspace);
|
|
340
|
+
await runBlockEndHook({ hook, runId, id, status, doneFile: doneFile ?? '', repoTop: top, writeRow, err });
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
};
|
|
145
344
|
// B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
|
|
146
345
|
// a failed check is a warning, never a reason to stop the block command
|
|
147
346
|
try {
|
|
@@ -179,7 +378,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
179
378
|
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new StateError('usage', '--brief must be a file inside the workspace');
|
|
180
379
|
brief = { path: relative, content: await readFile(absolute) };
|
|
181
380
|
}
|
|
182
|
-
|
|
381
|
+
// B62a: the run's isolation, from its config snapshot (fixed for the run)
|
|
382
|
+
const isolated = worktreeSettings(record);
|
|
383
|
+
const { block, pointer, worktreeReused } = await openBlock({
|
|
183
384
|
runId,
|
|
184
385
|
id,
|
|
185
386
|
level,
|
|
@@ -191,37 +392,76 @@ export async function runBlock(args, deps = {}) {
|
|
|
191
392
|
brief,
|
|
192
393
|
...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
|
|
193
394
|
...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
|
|
395
|
+
...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
|
|
194
396
|
cfg: await openConfig(record.workspace),
|
|
195
397
|
writeRow,
|
|
398
|
+
...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
|
|
196
399
|
});
|
|
197
400
|
const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
|
|
198
401
|
const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
|
|
199
|
-
|
|
200
|
-
|
|
402
|
+
const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
|
|
403
|
+
const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
|
|
404
|
+
say(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}${tests}\n`);
|
|
405
|
+
if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
|
|
406
|
+
say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
|
|
407
|
+
if (json) {
|
|
408
|
+
emit({
|
|
409
|
+
block: id,
|
|
410
|
+
event: 'open',
|
|
411
|
+
level: block.level,
|
|
412
|
+
asked: level ?? null,
|
|
413
|
+
source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
|
|
414
|
+
kind: block.kind,
|
|
415
|
+
attempt: block.attempt,
|
|
416
|
+
base_sha: block.base_sha,
|
|
417
|
+
tests: testCount,
|
|
418
|
+
worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
|
|
419
|
+
brief: pointer ?? null,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
201
422
|
return 0;
|
|
202
423
|
}
|
|
203
424
|
if (sub === 'attempt') {
|
|
204
|
-
|
|
425
|
+
const n = await attemptBlock({ runId, id, writeRow });
|
|
426
|
+
say(`block ${id} attempt ${n}\n`);
|
|
427
|
+
if (json) emit({ block: id, event: 'attempt', attempt: n });
|
|
205
428
|
return 0;
|
|
206
429
|
}
|
|
207
430
|
if (sub === 'rebase') {
|
|
208
431
|
const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow });
|
|
209
|
-
|
|
432
|
+
say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
|
|
433
|
+
if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
|
|
210
434
|
return 0;
|
|
211
435
|
}
|
|
212
436
|
if (sub === 'claim') {
|
|
213
437
|
await claimPath({ runId, id, file: extra, writeRow });
|
|
214
|
-
|
|
438
|
+
say(`block ${id} claimed ${extra}\n`);
|
|
439
|
+
if (json) emit({ block: id, event: 'claimed', path: extra });
|
|
215
440
|
return 0;
|
|
216
441
|
}
|
|
217
442
|
if (sub === 'stop') {
|
|
218
443
|
await stopBlock({ runId, id, reason: /** @type {string} */ (flags.reason), writeRow });
|
|
219
|
-
|
|
220
|
-
|
|
444
|
+
const end = await ended('stopped', 'block.stop', /** @type {string} */ (flags.reason));
|
|
445
|
+
try {
|
|
446
|
+
say(`block ${id} stopped\n`);
|
|
447
|
+
if (json) emit({ block: id, event: 'stopped', ...end.json });
|
|
448
|
+
return 0;
|
|
449
|
+
} finally {
|
|
450
|
+
await end.runHook();
|
|
451
|
+
}
|
|
221
452
|
}
|
|
222
453
|
if (sub === 'waive') {
|
|
223
454
|
await waiveFinding({ runId, id, finding: extra, reason: /** @type {string} */ (flags.reason), file: /** @type {string} */ (flags.file), writeRow });
|
|
224
|
-
|
|
455
|
+
say(`block ${id} waived ${extra} in ${flags.file} (by: human)\n`);
|
|
456
|
+
if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'human' });
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
if (sub === 'reopen') {
|
|
460
|
+
const file = /** @type {string} */ (flags.file);
|
|
461
|
+
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
|
|
462
|
+
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
463
|
+
say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
|
|
464
|
+
if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
|
|
225
465
|
return 0;
|
|
226
466
|
}
|
|
227
467
|
if (sub === 'close') {
|
|
@@ -230,6 +470,21 @@ export async function runBlock(args, deps = {}) {
|
|
|
230
470
|
// B52: a block that is not open is refused here, with or without --no-require-reviews, by
|
|
231
471
|
// the same check and message as `closeBlock` — and the owner is never asked about it
|
|
232
472
|
if (entry.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
473
|
+
// B62a: removing a tree the block does not have is a usage error, before anything runs
|
|
474
|
+
const removeTree = flags['remove-worktree'] === true;
|
|
475
|
+
if (removeTree && !entry.worktree) throw new StateError('usage', `block ${id} has no worktree (proof.isolation is not worktree); --remove-worktree does not apply`);
|
|
476
|
+
// B62b: the block's tree is where its work is measured — a tree that is gone refuses the close
|
|
477
|
+
// before anything is asked or written
|
|
478
|
+
const wt = await blockTree(record.workspace, entry).catch((thrown) => {
|
|
479
|
+
if (thrown?.code === 'worktree-missing') {
|
|
480
|
+
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`);
|
|
481
|
+
}
|
|
482
|
+
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`);
|
|
483
|
+
throw thrown;
|
|
484
|
+
});
|
|
485
|
+
// ONE base for the whole close: a worktree block is measured against the commit its tree was
|
|
486
|
+
// made from (B62a keeps it equal to the block's base_sha: a re-open keeps it, a rebase is refused)
|
|
487
|
+
const base = wt !== null ? wt.base_sha : entry.base_sha;
|
|
233
488
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
234
489
|
// B52: the human's way out is confirmed by the human, at a terminal, before anything is written
|
|
235
490
|
if (reviewsWaived) {
|
|
@@ -248,26 +503,46 @@ export async function runBlock(args, deps = {}) {
|
|
|
248
503
|
// relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
|
|
249
504
|
// (`repoRootOf`), and `tree_at_open` is stamped there too. An owned entry matches a path
|
|
250
505
|
// 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
|
-
|
|
506
|
+
// A failure here is the usual `git-failed` refusal. B62b: for a block with a worktree the
|
|
507
|
+
// listing and the content are the tree's (`top`); `mainTop` keeps the run's state.
|
|
508
|
+
/** @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
509
|
let treeOnce;
|
|
254
510
|
const tree = () =>
|
|
255
511
|
(treeOnce ??= (async () => {
|
|
256
|
-
const
|
|
512
|
+
const mainTop = await repoTop(record.workspace);
|
|
513
|
+
const inTree = wt !== null;
|
|
514
|
+
const top = inTree ? wt.top : mainTop;
|
|
515
|
+
const where = inTree ? `the worktree ${wt.path}` : 'the workspace';
|
|
257
516
|
// both sides as realpaths: git prints the top resolved (/private/var on macOS) while the
|
|
258
517
|
// run record may keep a symlinked spelling (/var)
|
|
259
518
|
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
260
|
-
const wsPrefix = path.relative(await real(
|
|
519
|
+
const wsPrefix = path.relative(await real(mainTop), await real(record.workspace)).split(path.sep).join('/');
|
|
261
520
|
const owns = ownsAt(wsPrefix);
|
|
262
521
|
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
263
|
-
const changed = await changedFiles({ repoRoot: top, base
|
|
522
|
+
const changed = await changedFiles({ repoRoot: top, base });
|
|
264
523
|
// the file set is the diff since base ∪ every owned file with review rows of this block —
|
|
265
524
|
// work the diff cannot see (committed before `block open`, done in another worktree) is
|
|
266
|
-
// still checked, at its content in the workspace
|
|
525
|
+
// still checked, at its content in the workspace (in the block's worktree when it has one)
|
|
267
526
|
const reviewed = reviewedFiles(rows, id).filter(isMine);
|
|
268
|
-
const fileSet = await blockFileSet({ repoRoot: top, base
|
|
269
|
-
return { top, wsPrefix, owns, changed, fileSet, reviewed };
|
|
527
|
+
const fileSet = await blockFileSet({ repoRoot: top, base, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
|
|
528
|
+
return { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed };
|
|
270
529
|
})());
|
|
530
|
+
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
531
|
+
// `block.close` and only when every other check passed — never by a refused close
|
|
532
|
+
/** @type {Record<string, any>[]} */
|
|
533
|
+
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
534
|
+
/** @type {import('../review/gate-check.mjs').WaivedAll[]} */
|
|
535
|
+
let waivedAll = [];
|
|
536
|
+
// B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
|
|
537
|
+
// `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
|
|
538
|
+
// when the block closes — a refused close writes none)
|
|
539
|
+
const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
|
|
540
|
+
// idempotent: the waived rows are rebuilt from the final list, never appended per call
|
|
541
|
+
// (closeBlock reads closeRows only after every extra check passed)
|
|
542
|
+
waivedAll = files;
|
|
543
|
+
const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
|
|
544
|
+
closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
|
|
545
|
+
};
|
|
271
546
|
const reviews = reviewGateCheck(async () => {
|
|
272
547
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
273
548
|
if (transcript === null) {
|
|
@@ -277,13 +552,14 @@ export async function runBlock(args, deps = {}) {
|
|
|
277
552
|
// the human's way out of the per-file review check leaves a signed audit row before the close
|
|
278
553
|
if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
279
554
|
// 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();
|
|
555
|
+
const highPaths = await highPathsAt(record.workspace, base);
|
|
556
|
+
const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
282
557
|
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
283
558
|
const key = await loadKey(runId);
|
|
284
559
|
// B52: an empty diff with review rows is never silent — name the likely cause
|
|
285
560
|
if (reviewed.length > 0 && !changed.some(isMine)) {
|
|
286
|
-
|
|
561
|
+
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';
|
|
562
|
+
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
563
|
}
|
|
288
564
|
// B52: only code-forge's own state is exempt — `<top>/.code-forge/`, where the worker keeps
|
|
289
565
|
// its queue and signed results — never a `--transcript` or `--report` path in the source
|
|
@@ -293,7 +569,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
293
569
|
// `.code-forge/runs/<run>/<id>.log`)
|
|
294
570
|
const wsRel = (/** @type {string} */ p) => path.posix.join(wsPrefix, p);
|
|
295
571
|
const ownState = new Set([wsRel(`.code-forge/runs/${runId}.json`), wsRel(`.code-forge/runs/${runId}/${id}.log`)]);
|
|
296
|
-
|
|
572
|
+
// B62b: a block's own tree started clean at its base and no other block works in it — no
|
|
573
|
+
// stamp from the main tree and no other block's ownership exempts a change there
|
|
574
|
+
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
575
|
if (scope === null) err(`WARN block ${id}: opened before code-forge recorded the tree at block open; unowned changes were not checked\n`);
|
|
298
576
|
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
577
|
return {
|
|
@@ -305,7 +583,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
305
583
|
// B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
|
|
306
584
|
unowned: scope?.unowned ?? [],
|
|
307
585
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
308
|
-
resultFiles: await resultFilesFor({ repoRoot:
|
|
586
|
+
resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
|
|
309
587
|
files: reviewsWaived || external !== null ? [] : fileSet,
|
|
310
588
|
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
|
|
311
589
|
// --no-require-reviews); B58: `review.external` does not lift it
|
|
@@ -314,7 +592,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
314
592
|
proofFiles: fileSet,
|
|
315
593
|
highPaths,
|
|
316
594
|
};
|
|
317
|
-
});
|
|
595
|
+
}, { onWaivedAll });
|
|
318
596
|
// B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
|
|
319
597
|
const report = async () => {
|
|
320
598
|
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
@@ -328,12 +606,76 @@ export async function runBlock(args, deps = {}) {
|
|
|
328
606
|
const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
|
|
329
607
|
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
330
608
|
};
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
609
|
+
/** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
|
|
610
|
+
const extraChecks = [reviews, report];
|
|
611
|
+
// B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
|
|
612
|
+
// needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
|
|
613
|
+
// reads the report, so a refusal here leaves no row (not even the human waiver row the review
|
|
614
|
+
// check writes). Absent or false: nothing is added, the close is exactly as before.
|
|
615
|
+
if (requireCoderTests(record)) {
|
|
616
|
+
extraChecks.unshift(async () => {
|
|
617
|
+
const tests = await coderTests(flags.report, id);
|
|
618
|
+
if (!tests.ok) return tests;
|
|
619
|
+
// signed under the run lock by `closeBlock`, after the waiver row, only when the block closes
|
|
620
|
+
closeRows.push({ event: 'coder.tests', block: id, command: redact(tests.command), exit: tests.exit });
|
|
621
|
+
return { ok: true };
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
// B64b: the hook command is the orchestrator's, never a coder's — a coder transcript that runs
|
|
625
|
+
// it refuses the close. Only with the hook configured (the list is otherwise unchanged); it
|
|
626
|
+
// only reads the transcript, so it runs first and a refusal here leaves no row.
|
|
627
|
+
if (hook !== null) {
|
|
628
|
+
extraChecks.unshift(async () => {
|
|
629
|
+
const text = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
630
|
+
const [hit] = text === null ? [] : scanTranscript(text, hookForbidden(hook.argv));
|
|
631
|
+
return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
|
|
635
|
+
// B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
|
|
636
|
+
// are written and the run lock released; the done file is written before the tree is removed
|
|
637
|
+
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;
|
|
638
|
+
// B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
|
|
639
|
+
// does — also when a later step (the output, `--remove-worktree`) throws; it never changes
|
|
640
|
+
// the exit code or stdout (`runHook` never throws)
|
|
641
|
+
try {
|
|
642
|
+
// B70: each file that passed as reviewed_by_waiver is named before the closed line
|
|
643
|
+
// B71: an answered finding is named as answered; a file with only answered findings has no `by`
|
|
644
|
+
if (result.ok) {
|
|
645
|
+
for (const f of waivedAll) {
|
|
646
|
+
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
647
|
+
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
648
|
+
say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
(result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
652
|
+
// B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
|
|
653
|
+
if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
|
|
654
|
+
const closedJson = {
|
|
655
|
+
block: id,
|
|
656
|
+
event: result.status,
|
|
657
|
+
status: result.status,
|
|
658
|
+
reason: result.reason ?? null,
|
|
659
|
+
close_event: result.event ?? null,
|
|
660
|
+
...(end?.json ?? {}),
|
|
661
|
+
// B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
|
|
662
|
+
...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
|
|
663
|
+
};
|
|
664
|
+
if (!result.ok || !removeTree) {
|
|
665
|
+
if (json && result.ok) emit(closedJson);
|
|
666
|
+
return result.ok ? 0 : 1;
|
|
667
|
+
}
|
|
668
|
+
const removed = await (deps.removeBlockTree ?? removeBlockTree)(runId, id, entry.worktree, record.workspace, writeRow, say, err);
|
|
669
|
+
// a failed removal is exit 1 with its stderr text and nothing on stdout, like every failure
|
|
670
|
+
if (json && removed === 0) {
|
|
671
|
+
// built from the block's own tree entry; `removed` is what removeBlockTree reported (0 = removed)
|
|
672
|
+
const treeJson = entry.worktree ? { removed: removed === 0, path: entry.worktree.path, branch: entry.worktree.branch } : null;
|
|
673
|
+
emit({ ...closedJson, worktree: treeJson });
|
|
674
|
+
}
|
|
675
|
+
return removed;
|
|
676
|
+
} finally {
|
|
677
|
+
await end?.runHook();
|
|
678
|
+
}
|
|
337
679
|
}
|
|
338
680
|
} catch (thrown) {
|
|
339
681
|
err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
|
|
@@ -343,6 +685,50 @@ export async function runBlock(args, deps = {}) {
|
|
|
343
685
|
return 2;
|
|
344
686
|
}
|
|
345
687
|
|
|
688
|
+
/**
|
|
689
|
+
* B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
|
|
690
|
+
* `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
|
|
691
|
+
* `worktree.remove_failed {block, path, reason}` row. A failed removal (a tree with modified or
|
|
692
|
+
* untracked files) leaves the tree where it is and the block closed, and is exit 1.
|
|
693
|
+
* @param {string} runId @param {string} id @param {{path: string, branch: string, base_sha: string}} worktree
|
|
694
|
+
* @param {string} workspace @param {(row: Record<string, any>) => Promise<unknown>} writeRow
|
|
695
|
+
* @param {(s: string) => unknown} out @param {(s: string) => unknown} err
|
|
696
|
+
* @returns {Promise<number>}
|
|
697
|
+
*/
|
|
698
|
+
async function removeBlockTree(runId, id, worktree, workspace, writeRow, out, err) {
|
|
699
|
+
const failed = await removeWorktree({ cwd: workspace, worktree });
|
|
700
|
+
if (failed === null) {
|
|
701
|
+
await writeSigned(runId, writeRow, { event: 'worktree.removed', block: id, path: worktree.path, branch: worktree.branch });
|
|
702
|
+
out(`block ${id} worktree removed: ${worktree.path} (branch ${worktree.branch} kept)\n`);
|
|
703
|
+
return 0;
|
|
704
|
+
}
|
|
705
|
+
await writeSigned(runId, writeRow, { event: 'worktree.remove_failed', block: id, path: worktree.path, reason: failed });
|
|
706
|
+
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`);
|
|
707
|
+
return 1;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* B63b: the coder's green test run, read from its final report — the block's LAST sentinel must
|
|
712
|
+
* be COMPLETE, with a `TESTS <command> exit=0` line before it (`parseSentinel`).
|
|
713
|
+
* @param {unknown} reportFlag - the `--report` value (absent, or `true` for a valueless flag).
|
|
714
|
+
* @param {string} id
|
|
715
|
+
* @returns {Promise<{ok: true, command: string, exit: number} | {ok: false, reason: string}>}
|
|
716
|
+
*/
|
|
717
|
+
async function coderTests(reportFlag, id) {
|
|
718
|
+
if (typeof reportFlag !== 'string') return { ok: false, reason: 'coder report FAILED: context.require_coder_tests needs --report' };
|
|
719
|
+
let text;
|
|
720
|
+
try {
|
|
721
|
+
text = await readFile(reportFlag, 'utf8');
|
|
722
|
+
} catch {
|
|
723
|
+
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
724
|
+
}
|
|
725
|
+
const sentinel = parseSentinel(text, id);
|
|
726
|
+
if (sentinel?.status !== 'complete') return { ok: false, reason: `coder report FAILED: no ===BLOCK ${id} COMPLETE=== sentinel` };
|
|
727
|
+
if (!sentinel.tests) return { ok: false, reason: 'coder report FAILED: no TESTS line' };
|
|
728
|
+
if (sentinel.tests.exit !== 0) return { ok: false, reason: `coder report FAILED: TESTS exit=${sentinel.tests.exit}` };
|
|
729
|
+
return { ok: true, command: sentinel.tests.command, exit: 0 };
|
|
730
|
+
}
|
|
731
|
+
|
|
346
732
|
/** B52: the refusal when `--no-require-reviews` has no terminal to ask the human at. */
|
|
347
733
|
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
734
|
|