@codedology/code-forge 0.15.2 → 0.15.4
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 +81 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +162 -1
- package/docs/reference/config.md +38 -15
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +4 -1
- package/schema/code-forge.schema.json +83 -3
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +52 -0
- package/skill/references/ledger.md +13 -1
- package/skill/references/plan.md +5 -1
- package/skill/references/review.md +46 -8
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +427 -38
- package/src/cli/context.mjs +17 -7
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +5 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +278 -41
- package/src/cli/run.mjs +3 -2
- package/src/cli/status.mjs +43 -9
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/local.mjs +22 -4
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +113 -12
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +52 -10
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +88 -9
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +42 -16
- package/src/review/route.mjs +58 -4
- package/src/review/session-retry.mjs +59 -0
- package/src/session/facts.mjs +19 -7
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +139 -49
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/native.mjs +131 -0
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +211 -30
- package/src/worker/queue.mjs +107 -24
- package/src/worker/watch.mjs +18 -7
package/src/cli/block.mjs
CHANGED
|
@@ -4,11 +4,12 @@
|
|
|
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|light]
|
|
7
|
-
* [--tests <paths…>]
|
|
7
|
+
* [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]
|
|
8
8
|
* block attempt <id> --run <r>
|
|
9
9
|
* block rebase <id> --run <r> [--head <ref>]
|
|
10
10
|
* block claim <id> <path> --run <r>
|
|
11
|
-
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
11
|
+
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree] [--auto-claim]
|
|
12
|
+
* block land <id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate] [--json]
|
|
12
13
|
* block stop <id> --run <r> --reason <text>
|
|
13
14
|
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes] (orchestrator --yes: evidence and a fresh judge)
|
|
14
15
|
* block reopen <id> --run <r> --file <path> --reason <text> [--yes] (B124c: evidence and a fresh judge for the agent path)
|
|
@@ -20,8 +21,9 @@
|
|
|
20
21
|
* close (`closed`): status, reason, close_event (the close result's own event word, as the text
|
|
21
22
|
* prints it in parentheses, else null), and `worktree {removed, path, branch}` with
|
|
22
23
|
* `--remove-worktree`). On
|
|
23
|
-
* failure the exit code and stderr are unchanged and stdout stays empty, except
|
|
24
|
-
*
|
|
24
|
+
* failure the exit code and stderr are unchanged and stdout stays empty, except an open refused
|
|
25
|
+
* for `after-not-done`, `bad-after`, `outcome-not-ready` or `bad-after-outcome`, which prints `{"block", "event": "refused", "code", "message"}`,
|
|
26
|
+
* or a close the gate refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
|
|
25
27
|
* error, a thrown refusal, a cancelled `--no-require-reviews`, `block waive` or `block reopen` (or
|
|
26
28
|
* one with no terminal, B80) and a failed `--remove-worktree`
|
|
27
29
|
* print nothing on stdout. Without `--json` every output is byte-identical to before.
|
|
@@ -194,14 +196,20 @@
|
|
|
194
196
|
* through `closeBlock`'s `extraChecks`.
|
|
195
197
|
*/
|
|
196
198
|
|
|
197
|
-
import {
|
|
198
|
-
import {
|
|
199
|
+
import { createHash } from 'node:crypto';
|
|
200
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
201
|
+
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
|
|
199
202
|
import path from 'node:path';
|
|
200
203
|
import { parse as parseYAML } from 'yaml';
|
|
204
|
+
import { runGatesVerb } from './gates.mjs';
|
|
205
|
+
import { parseTapPassed } from '../gates/acceptance.mjs';
|
|
206
|
+
import { parseMax, requeueStoppedFiles, runReviewFile } from './review-file.mjs';
|
|
201
207
|
import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
202
208
|
import { fallbackLane } from '../decide/fallback-rules.mjs';
|
|
203
209
|
import { laneFacts } from '../decide/lane-facts.mjs';
|
|
204
210
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
211
|
+
import { checkOutcome } from '../outcome/outcome.mjs';
|
|
212
|
+
import { loadPipeline } from '../pipeline/pipeline.mjs';
|
|
205
213
|
import { amendAcceptanceBlock, attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, prepareAcceptanceAmendment, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
206
214
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
207
215
|
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
@@ -209,17 +217,17 @@ import { readFileDiff } from '../review/packet.mjs';
|
|
|
209
217
|
import { StateError } from '../state/paths.mjs';
|
|
210
218
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
211
219
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
212
|
-
import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
220
|
+
import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, lockfileProblem, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
213
221
|
import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
|
|
214
222
|
import { checkExpiry, grantFor, grantState } from '../autopilot/grant.mjs';
|
|
215
223
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
216
224
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
217
225
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
218
|
-
import { validateConfig } from '../config/validate.mjs';
|
|
226
|
+
import { reviewExcludeRule, validateConfig } from '../config/validate.mjs';
|
|
219
227
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
220
228
|
import { exec } from '../util/exec.mjs';
|
|
221
229
|
import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
222
|
-
import { clearDone, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
|
|
230
|
+
import { clearDone, enqueue, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
|
|
223
231
|
import { loadState } from '../worker/review-state.mjs';
|
|
224
232
|
import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
225
233
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
@@ -236,7 +244,7 @@ import { acceptedTranscriptHits, prepareAcceptHit, writeAcceptedHit } from '../r
|
|
|
236
244
|
* main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
|
|
237
245
|
* the checks, the done marker's removal and then the signed row are `reopenFile`'s.
|
|
238
246
|
* @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>, agent?: boolean, judge?: import('../review/agent-waiver.mjs').WaiverJudge}} opts
|
|
239
|
-
* @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
|
|
247
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean, ticket?: string}>}
|
|
240
248
|
*/
|
|
241
249
|
async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
|
|
242
250
|
if (agent) {
|
|
@@ -268,7 +276,7 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
|
|
|
268
276
|
const key = await loadKey(runId);
|
|
269
277
|
const rows = await readAllRows(record.project);
|
|
270
278
|
const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
|
|
271
|
-
const { row, cleared } = await reopenFile({
|
|
279
|
+
const { row, cleared, ticket } = await reopenFile({
|
|
272
280
|
runId,
|
|
273
281
|
id,
|
|
274
282
|
file,
|
|
@@ -286,14 +294,242 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
|
|
|
286
294
|
readFile: (f) => readRegularFileNoFollow(contentRoot, f),
|
|
287
295
|
// C11 (synchronous): the cached result of the current content's ticket would repeat the stop
|
|
288
296
|
clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
|
|
297
|
+
enqueue: (f, reopened) => enqueue({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree, after: reopened.ts }).ticket,
|
|
289
298
|
},
|
|
290
299
|
});
|
|
291
|
-
return { row, cleared };
|
|
300
|
+
return { row, cleared, ticket };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Quote a displayed command for a POSIX shell; execution always uses argv.
|
|
304
|
+
* @param {string[]} argv @returns {string}
|
|
305
|
+
*/
|
|
306
|
+
function landCommand(argv) {
|
|
307
|
+
return argv.map((arg) => /^[\w./:@=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(' ');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Claim only changes the normal scope check cannot account for, then join real review tickets.
|
|
311
|
+
* @param {{record: Record<string, any>, runId: string, id: string, writeRow: import('../state/run.mjs').WriteRow, print: (line: string) => void, join?: boolean}} opts
|
|
312
|
+
*/
|
|
313
|
+
async function autoClaimChanges({ record, runId, id, writeRow, print, join = true }) {
|
|
314
|
+
const entry = record.blocks[id];
|
|
315
|
+
const wt = await blockTree(record.workspace, entry);
|
|
316
|
+
const mainTop = await repoTop(record.workspace);
|
|
317
|
+
const top = wt?.top ?? mainTop;
|
|
318
|
+
const prefix = path.relative(mainTop, await realpath(record.workspace)).split(path.sep).join('/');
|
|
319
|
+
const owns = ownsAt(prefix);
|
|
320
|
+
const ownState = new Set([path.posix.join(prefix, `.code-forge/runs/${runId}.json`), path.posix.join(prefix, `.code-forge/runs/${runId}/${id}.log`)]);
|
|
321
|
+
const changed = (await changedFiles({ repoRoot: top, base: wt?.base_sha ?? entry.base_sha })).filter((f) => !ownState.has(f));
|
|
322
|
+
const scope = await unownedChanges({ top, changed, id, blocks: record.blocks, rows: (await readAllRows(record.project)).filter((r) => r.run === runId), key: await loadKey(runId),
|
|
323
|
+
isMine: (file) => owns(entry.owned_files, file, entry.owned_kinds), owns, ownTree: wt !== null });
|
|
324
|
+
const files = [...new Set([...(scope?.unowned ?? []), ...(scope?.siblings.map((s) => s.file) ?? [])])].sort();
|
|
325
|
+
if (files.length === 0) return;
|
|
326
|
+
const invoke = async (/** @type {string[]} */ args) => {
|
|
327
|
+
let output = '', error = '';
|
|
328
|
+
const code = await runReviewFile(args, { cwd: top, stdout: { write: (s) => { output += s; } }, stderr: { write: (s) => { error += s; } } });
|
|
329
|
+
let data;
|
|
330
|
+
try { data = JSON.parse(output); } catch { /* Excluded files print a text line. */ }
|
|
331
|
+
if (code !== 0) throw new StateError('auto-claim-review', error || output || 'auto-claim review failed');
|
|
332
|
+
return { data, output };
|
|
333
|
+
};
|
|
334
|
+
for (const file of files) {
|
|
335
|
+
await claimPath({ runId, id, file, writeRow, auto: true });
|
|
336
|
+
Object.assign(entry, (await readRun(runId)).blocks[id]);
|
|
337
|
+
print(`block ${id} auto-claimed ${file}\n`);
|
|
338
|
+
await invoke([file, '--block', id, '--run', runId]);
|
|
339
|
+
}
|
|
340
|
+
if (!join) return; // Land joins all claimed and originally owned paths under one deadline.
|
|
341
|
+
const joined = await invoke(['--wait-block', id, '--run', runId]);
|
|
342
|
+
if (joined.data?.done !== true) throw new StateError('auto-claim-review', `auto-claim reviews did not finish: ${joined.output.trim()}`);
|
|
343
|
+
for (const file of joined.data.files) {
|
|
344
|
+
if (file.status === 'excluded' || file.outcome === 'approved') continue;
|
|
345
|
+
const detail = file.ticket ? (await invoke(['--wait', file.ticket])).output : file.outcome ?? file.status;
|
|
346
|
+
throw new StateError('auto-claim-review', `auto-claim review refused ${file.file}: ${detail}`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Join, gates and close share their existing CLI handlers, without spawning the CLI.
|
|
351
|
+
* @param {{id: string, runId: string, record: Record<string, any>, report: string, transcript: string, maxMs: number, merge: boolean, noMergeGate: boolean, json: boolean}} options
|
|
352
|
+
* @param {NonNullable<Parameters<typeof runBlock>[1]>} deps
|
|
353
|
+
* @returns {Promise<number>}
|
|
354
|
+
*/
|
|
355
|
+
async function landBlock({ id, runId, record, report, transcript, maxMs, merge, noMergeGate, json }, deps) {
|
|
356
|
+
const stdout = deps.stdout ?? process.stdout;
|
|
357
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
358
|
+
const emit = (/** @type {Record<string, any>} */ value) => writeSafe(stdout, `${JSON.stringify(value)}\n`);
|
|
359
|
+
let step = 'join';
|
|
360
|
+
let next = landCommand(['code-forge', 'review-file', '--wait-block', id, '--run', runId]);
|
|
361
|
+
const stop = (/** @type {string} */ reason) => {
|
|
362
|
+
if (step === 'join' && reason === 'wait_timeout') {
|
|
363
|
+
next = landCommand(['code-forge', 'block', 'land', id, '--run', runId, '--report', report, '--transcript', transcript,
|
|
364
|
+
'--max', `${Math.max(1800, Math.ceil(maxMs / 500))}s`, ...(merge ? ['--merge'] : []), ...(noMergeGate ? ['--no-merge-gate'] : []), ...(json ? ['--json'] : [])]);
|
|
365
|
+
}
|
|
366
|
+
writeSafe(stderr, `block ${id} land stopped at ${step}: ${reason.trim()}\nnext: ${next}\n`);
|
|
367
|
+
if (json) emit({ block: id, event: 'refused', step, reason: reason.trim(), next });
|
|
368
|
+
return 1;
|
|
369
|
+
};
|
|
370
|
+
/** @param {(streams: {stdout: {write: (s: string) => void}, stderr: {write: (s: string) => void}}) => Promise<number>} run */
|
|
371
|
+
const capture = async (run) => {
|
|
372
|
+
let output = '';
|
|
373
|
+
let error = '';
|
|
374
|
+
const code = await run({ stdout: { write: (s) => { output += s; } }, stderr: { write: (s) => { error += s; } } });
|
|
375
|
+
/** @type {Record<string, any> | null} */
|
|
376
|
+
let data = null;
|
|
377
|
+
try { data = JSON.parse(output); } catch { /* A thrown refusal may only write stderr. */ }
|
|
378
|
+
return { code, output, error, data };
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
const entry = record.blocks?.[id];
|
|
382
|
+
if (!entry) return stop(`unknown block in run ${runId}`);
|
|
383
|
+
if (entry.status !== 'open') return stop(`block is ${entry.status}, not open`);
|
|
384
|
+
const tree = await blockTree(record.workspace, entry);
|
|
385
|
+
if (!tree) return stop('block has no recorded worktree and branch');
|
|
386
|
+
const deadline = Date.now() + maxMs;
|
|
387
|
+
await autoClaimChanges({ record, runId, id, writeRow: (row) => appendRow(row, { slug: record.project }), print: (line) => writeSafe(json ? stderr : stdout, line), join: false });
|
|
388
|
+
let joined;
|
|
389
|
+
// Findings are usually final; cap retries so land does not wait the full --max on them.
|
|
390
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
391
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
392
|
+
if (remaining === 0) return stop('wait_timeout');
|
|
393
|
+
const duration = remaining > 9_999_999 ? `${Math.floor(remaining / 1000)}s` : `${remaining}ms`;
|
|
394
|
+
joined = await capture((streams) => runReviewFile(['--wait-block', id, '--run', runId, '--max', duration], { ...streams, cwd: tree.workspace }));
|
|
395
|
+
for (const line of joined.error.split('\n')) if (line.startsWith('auto-requeue ')) writeSafe(stderr, `${line}\n`);
|
|
396
|
+
if (joined.code !== 0 || !joined.data?.done || !joined.data.files.some((file) => file.status === 'findings')) break;
|
|
397
|
+
}
|
|
398
|
+
if (joined.code !== 0 || !joined.data?.done) {
|
|
399
|
+
if (joined.data?.status === 'wait_timeout' || joined.data?.reason === 'wait_timeout' || Date.now() >= deadline) return stop('wait_timeout');
|
|
400
|
+
return stop(joined.error || joined.data?.message || joined.data?.reason || joined.data?.status || 'reviews did not finish');
|
|
401
|
+
}
|
|
402
|
+
const problems = joined.data.files.filter((/** @type {Record<string, any>} */ file) => file.status !== 'excluded' && file.outcome !== 'approved');
|
|
403
|
+
if (problems.length > 0) {
|
|
404
|
+
const details = [];
|
|
405
|
+
for (const file of problems) {
|
|
406
|
+
details.push(`${file.file}: ${file.outcome ?? file.status}`);
|
|
407
|
+
if (file.ticket) {
|
|
408
|
+
const result = await capture((streams) => runReviewFile(['--wait', file.ticket], { ...streams, cwd: tree.workspace }));
|
|
409
|
+
details.push(result.output.trim() || result.error.trim());
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
next = landCommand(['code-forge', 'review-file', '--wait', problems[0].ticket]);
|
|
413
|
+
return stop(details.join('\n'));
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
step = 'gates';
|
|
417
|
+
next = landCommand(['code-forge', 'gates', 'run', '--run', runId, '--block', id, '--changed']);
|
|
418
|
+
const gates = await capture((streams) => runGatesVerb(['run', '--run', runId, '--block', id, '--changed', '--cwd', tree.workspace], streams));
|
|
419
|
+
if (gates.code !== 0 || gates.data?.allOk !== true) return stop(gates.error + (gates.output || 'gates did not pass'));
|
|
420
|
+
if (gates.error) writeSafe(stderr, gates.error);
|
|
421
|
+
|
|
422
|
+
step = 'close';
|
|
423
|
+
const closeArgs = ['close', id, '--run', runId, '--report', report, '--transcript', transcript];
|
|
424
|
+
next = landCommand(['code-forge', 'block', ...closeArgs]);
|
|
425
|
+
const closed = await capture((streams) => runBlock([...closeArgs, '--json'], { ...deps, ...streams }));
|
|
426
|
+
if (closed.code !== 0 || closed.data?.status !== 'closed') {
|
|
427
|
+
if (typeof closed.data?.next_command === 'string') next = closed.data.next_command;
|
|
428
|
+
return stop(closed.error || closed.data?.reason || closed.data?.status || 'close refused');
|
|
429
|
+
}
|
|
430
|
+
if (closed.error) writeSafe(stderr, closed.error);
|
|
431
|
+
|
|
432
|
+
step = 'merge';
|
|
433
|
+
const main = await repoTop(record.workspace);
|
|
434
|
+
if (typeof entry.base_branch !== 'string' || !entry.base_branch) return stop('block has no recorded base branch');
|
|
435
|
+
const mergeArgs = ['git', '-C', main, 'merge', '--no-ff', '--no-edit', '--', tree.branch];
|
|
436
|
+
const command = landCommand(mergeArgs);
|
|
437
|
+
next = command;
|
|
438
|
+
if (!merge) {
|
|
439
|
+
writeSafe(json ? stderr : stdout, 'hint: the merge gate runs only with --merge\n');
|
|
440
|
+
if (json) emit({ block: id, event: 'closed', merge: command });
|
|
441
|
+
else writeSafe(stdout, `block ${id} closed; merge: ${command}\n`);
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
const gitOptions = { cwd: main, env: gitChildEnv(), timeoutMs: 120000 };
|
|
445
|
+
const branch = await exec(['git', 'symbolic-ref', '-q', 'HEAD'], gitOptions);
|
|
446
|
+
if (branch.result !== 'ok' || branch.stdout.trim() !== `refs/heads/${entry.base_branch}`) {
|
|
447
|
+
next = `${landCommand(['git', '-C', main, 'checkout', entry.base_branch])} && ${command}`;
|
|
448
|
+
return stop(`main checkout must be on recorded base branch ${entry.base_branch}`);
|
|
449
|
+
}
|
|
450
|
+
const status = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], gitOptions);
|
|
451
|
+
if (status.result !== 'ok') return stop(status.stderr || 'cannot check main checkout');
|
|
452
|
+
if (status.stdout) {
|
|
453
|
+
next = landCommand(['git', '-C', main, 'status', '--short']);
|
|
454
|
+
return stop('main checkout is dirty; commit or stash its changes before merging');
|
|
455
|
+
}
|
|
456
|
+
// Close measures uncommitted content too; never call that content landed if git cannot merge it.
|
|
457
|
+
const blockStatus = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], { ...gitOptions, cwd: tree.top });
|
|
458
|
+
if (blockStatus.result !== 'ok' || blockStatus.stdout) {
|
|
459
|
+
next = landCommand(['git', '-C', tree.top, 'status', '--short']);
|
|
460
|
+
return stop('block worktree is dirty or unreadable; commit and review its changes before merging');
|
|
461
|
+
}
|
|
462
|
+
if (noMergeGate || configInForce(record)?.gates?.merge_gate === false) {
|
|
463
|
+
writeSafe(stderr, 'warning: merge gate skipped; merged full suite was not checked\n');
|
|
464
|
+
} else {
|
|
465
|
+
step = 'merge-gate';
|
|
466
|
+
next = landCommand(['code-forge', 'gates', 'run', '--cwd', tree.workspace]);
|
|
467
|
+
const mainHead = await exec(['git', 'rev-parse', '--verify', 'HEAD'], gitOptions);
|
|
468
|
+
const blockHead = await exec(['git', 'rev-parse', '--verify', `refs/heads/${tree.branch}`], gitOptions);
|
|
469
|
+
if (mainHead.result !== 'ok' || blockHead.result !== 'ok') return stop('cannot resolve merge parents');
|
|
470
|
+
const started = Date.now();
|
|
471
|
+
// A sibling of this run's block worktree, never the process temp directory.
|
|
472
|
+
const temporary = await mkdtemp(path.join(path.dirname(tree.path), '.merge-gate-'));
|
|
473
|
+
let added = false;
|
|
474
|
+
let failure = '';
|
|
475
|
+
try {
|
|
476
|
+
// mkdtemp gives an empty directory; git worktree add accepts an existing empty path.
|
|
477
|
+
const created = await exec(['git', 'worktree', 'add', '--detach', temporary, mainHead.stdout.trim()], gitOptions);
|
|
478
|
+
added = created.result === 'ok';
|
|
479
|
+
if (!added) throw new Error(created.stderr || 'cannot create merge-gate worktree');
|
|
480
|
+
const merged = await exec(['git', 'merge', '--no-ff', '--no-edit', '--', blockHead.stdout.trim()], { ...gitOptions, cwd: temporary });
|
|
481
|
+
if (merged.result !== 'ok') {
|
|
482
|
+
const conflicts = await exec(['git', 'diff', '--name-only', '--diff-filter=U'], { ...gitOptions, cwd: temporary });
|
|
483
|
+
failure = conflicts.stdout.trim() ? `merge conflict:\n${conflicts.stdout.trim()}` : merged.stderr || merged.stdout || 'trial merge failed';
|
|
484
|
+
} else {
|
|
485
|
+
const workspace = path.join(temporary, path.relative(main, record.workspace));
|
|
486
|
+
// No block scope: the shared gate runner drops scope tokens and runs the full suite.
|
|
487
|
+
const suite = await capture((streams) => runGatesVerb(['run', '--cwd', workspace], streams));
|
|
488
|
+
if (suite.code !== 0 || suite.data?.allOk !== true) {
|
|
489
|
+
const results = suite.data?.results ?? [];
|
|
490
|
+
const output = results.filter((r) => !r.ok).map((r) => `${r.stdout}\n${r.stderr}`).join('\n');
|
|
491
|
+
const failed = [...parseTapPassed(output).failed].slice(0, 10);
|
|
492
|
+
failure = failed.length ? `full suite failed:\n${failed.join('\n')}`
|
|
493
|
+
: `full suite failed:\n${(suite.error || output || suite.output).trim().split('\n').slice(-20).join('\n')}`;
|
|
494
|
+
} else if (!suite.data.results.some((r) => r.gate === 'test' && !r.skipped)) {
|
|
495
|
+
failure = 'full suite unavailable: configure gates.test before merging';
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
} finally {
|
|
499
|
+
const removed = await exec(['git', 'worktree', 'remove', '--force', temporary], gitOptions);
|
|
500
|
+
if (added && removed.result !== 'ok') throw new Error(`cannot remove merge-gate worktree ${temporary}: ${removed.stderr}`);
|
|
501
|
+
await rm(temporary, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
if (failure) return stop(failure);
|
|
504
|
+
// Refuse a moving target: only merge the same parents whose combined suite passed.
|
|
505
|
+
const currentMain = await exec(['git', 'rev-parse', '--verify', 'HEAD'], gitOptions);
|
|
506
|
+
const currentBlock = await exec(['git', 'rev-parse', '--verify', `refs/heads/${tree.branch}`], gitOptions);
|
|
507
|
+
const currentBranch = await exec(['git', 'symbolic-ref', '-q', 'HEAD'], gitOptions);
|
|
508
|
+
const currentStatus = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], gitOptions);
|
|
509
|
+
if (currentMain.result !== 'ok' || currentBlock.result !== 'ok' || currentBranch.result !== 'ok' || currentStatus.result !== 'ok'
|
|
510
|
+
|| currentMain.stdout !== mainHead.stdout || currentBlock.stdout !== blockHead.stdout || currentBranch.stdout !== branch.stdout || currentStatus.stdout) {
|
|
511
|
+
return stop('merge parents or main checkout changed during the full suite; check the checkout and rerun the merge gate');
|
|
512
|
+
}
|
|
513
|
+
writeSafe(json ? stderr : stdout, `merge gate: full suite passed (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
|
|
514
|
+
}
|
|
515
|
+
step = 'merge';
|
|
516
|
+
next = command;
|
|
517
|
+
const merged = await exec(mergeArgs, gitOptions);
|
|
518
|
+
if (merged.result !== 'ok') {
|
|
519
|
+
next = landCommand(['git', '-C', main, 'status']);
|
|
520
|
+
return stop(merged.stderr || merged.stdout || 'git merge failed; resolve the merge before continuing');
|
|
521
|
+
}
|
|
522
|
+
if (json) emit({ block: id, event: 'landed', merge: command });
|
|
523
|
+
else writeSafe(stdout, `block ${id} landed\n`);
|
|
524
|
+
return 0;
|
|
525
|
+
} catch (thrown) {
|
|
526
|
+
return stop(thrown?.message ?? String(thrown));
|
|
527
|
+
}
|
|
292
528
|
}
|
|
293
529
|
|
|
294
530
|
const USAGE =
|
|
295
|
-
'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|light] [--tests <paths…>]\n' +
|
|
296
|
-
' 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' +
|
|
531
|
+
'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|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]\n' +
|
|
532
|
+
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--auto-claim] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
297
533
|
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
298
534
|
' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
299
535
|
' code-forge block accept-hit <id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>\n' +
|
|
@@ -301,9 +537,42 @@ const USAGE =
|
|
|
301
537
|
' code-forge block ack-waivers <id> --run <r>\n' +
|
|
302
538
|
' code-forge block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]\n' +
|
|
303
539
|
' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
|
|
540
|
+
' code-forge block land <id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate]\n' +
|
|
304
541
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
305
542
|
|
|
306
|
-
|
|
543
|
+
/** @type {Record<string, string>} */
|
|
544
|
+
const VERB_USAGE = {
|
|
545
|
+
open: '<id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]',
|
|
546
|
+
attempt: '<id> --run <r>',
|
|
547
|
+
rebase: '<id> --run <r> [--head <ref>] [--accept-changed <file>… --reason <text>]',
|
|
548
|
+
claim: '<id> <path> --run <r>',
|
|
549
|
+
close: '<id> --run <r> [--worker-pid <pid>] [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--auto-claim] [--decide]',
|
|
550
|
+
land: '<id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate]',
|
|
551
|
+
stop: '<id> --run <r> --reason <text>',
|
|
552
|
+
waive: '<id> <finding-id> --run <r> --file <path> --reason <text> [--yes]',
|
|
553
|
+
reopen: '<id> --run <r> --file <path> --reason <text> [--yes]',
|
|
554
|
+
'accept-hit': '<id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>',
|
|
555
|
+
unwaive: '<id> <finding-id> --run <r> --file <path> --reason <text>',
|
|
556
|
+
'ack-waivers': '<id> --run <r>',
|
|
557
|
+
'human-pass': '<id> --run <r> --note <text> [--proof <dir-or-file>]',
|
|
558
|
+
'amend-acceptance': '<id> --run <r> --acceptance <file> --reason <text>',
|
|
559
|
+
};
|
|
560
|
+
const SUBCOMMANDS = Object.keys(VERB_USAGE);
|
|
561
|
+
|
|
562
|
+
/** @param {string} sub @returns {string} */
|
|
563
|
+
function verbUsage(sub) {
|
|
564
|
+
return `usage: code-forge block ${sub} ${VERB_USAGE[sub]} [--json] [--help|-h]\n` +
|
|
565
|
+
' --help, -h Print this usage and exit without changing state.\n' +
|
|
566
|
+
' --json Print one JSON line on success instead of text.\n' +
|
|
567
|
+
(sub === 'open' || sub === 'amend-acceptance'
|
|
568
|
+
? ' --acceptance <file> JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n' +
|
|
569
|
+
' Human checks use {clause, kind: human_check} without tests.\n' : '') +
|
|
570
|
+
(sub === 'open' ? ' --brief <file> A file inside the repository, not inline text.\n' : '') +
|
|
571
|
+
(sub === 'land' ? ' Join block reviews, run changed gates in its worktree, then close.\n' +
|
|
572
|
+
' --max <dur> Total review wait (default 30m); accepts 90s, 30m or 1500ms.\n' +
|
|
573
|
+
' --merge Check the merged full suite, then merge with --no-ff in the clean main checkout.\n' +
|
|
574
|
+
' --no-merge-gate Skip the merged full suite with a warning.\n' : '');
|
|
575
|
+
}
|
|
307
576
|
|
|
308
577
|
/** Refuse likely shell quoting slips before any row is written.
|
|
309
578
|
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
@@ -329,7 +598,33 @@ function checkWhitespacePaths(workspace, entries, label) {
|
|
|
329
598
|
}
|
|
330
599
|
}
|
|
331
600
|
}
|
|
332
|
-
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
|
|
601
|
+
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof', 'max'];
|
|
602
|
+
|
|
603
|
+
/** Check specialist outcomes against the run's project, before state open can write anything.
|
|
604
|
+
* @param {string} root @param {string} id @param {string[]} ids
|
|
605
|
+
* @returns {{step: string, sha256: string}[]}
|
|
606
|
+
*/
|
|
607
|
+
function acceptedOutcomes(root, id, ids) {
|
|
608
|
+
const loaded = loadPipeline(root);
|
|
609
|
+
const bad = (/** @type {string} */ problem) => new StateError('bad-after-outcome', `block ${id}: --after-outcome: ${problem}`);
|
|
610
|
+
if (!loaded.exists) throw bad(`no code-forge.pipeline.yml in ${root}`);
|
|
611
|
+
if (!loaded.ok) throw bad(loaded.problems[0]);
|
|
612
|
+
const steps = [...new Set(ids)].map((stepId) => {
|
|
613
|
+
const step = loaded.pipeline.steps.find((candidate) => candidate.id === stepId);
|
|
614
|
+
if (!step) throw bad(`unknown step ${stepId}`);
|
|
615
|
+
return step;
|
|
616
|
+
});
|
|
617
|
+
return steps.map((step) => {
|
|
618
|
+
const file = path.resolve(root, step.outcome);
|
|
619
|
+
const result = existsSync(file) ? checkOutcome(file, { root }) : { ok: false, problems: ['missing outcome'] };
|
|
620
|
+
if (!result.ok) throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: ${result.problems[0]}`);
|
|
621
|
+
try {
|
|
622
|
+
return { step: step.id, sha256: createHash('sha256').update(readFileSync(file)).digest('hex') };
|
|
623
|
+
} catch (err) {
|
|
624
|
+
throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: cannot read outcome (${err.code ?? 'error'})`);
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
333
628
|
|
|
334
629
|
/**
|
|
335
630
|
* @param {string[]} args
|
|
@@ -359,8 +654,13 @@ export async function runBlock(args, deps = {}) {
|
|
|
359
654
|
err(USAGE);
|
|
360
655
|
return 2;
|
|
361
656
|
}
|
|
657
|
+
if (rest.includes('--help') || rest.includes('-h')) {
|
|
658
|
+
out(verbUsage(sub));
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
let jsonOpenId;
|
|
362
662
|
try {
|
|
363
|
-
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
663
|
+
const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests', ...(sub === 'rebase' ? ['accept-changed'] : []), ...(sub === 'open' ? ['after', 'after-outcome'] : [])], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'land' ? ['merge', 'no-merge-gate'] : []), ...(sub === 'close' ? ['decide', 'auto-claim'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
|
|
364
664
|
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
365
665
|
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
366
666
|
const json = flags.json === true;
|
|
@@ -375,8 +675,22 @@ export async function runBlock(args, deps = {}) {
|
|
|
375
675
|
if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
|
|
376
676
|
}
|
|
377
677
|
const [id, extra] = positionals;
|
|
678
|
+
if (sub === 'open' && json) jsonOpenId = id;
|
|
378
679
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
379
680
|
if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
|
|
681
|
+
if (sub === 'land' && (positionals.length !== 1 || typeof flags.report !== 'string' || typeof flags.transcript !== 'string' || Object.keys(flags).some((key) => !['run', 'report', 'transcript', 'max', 'merge', 'no-merge-gate', 'json'].includes(key)))) {
|
|
682
|
+
throw new StateError('usage', 'block land takes one block id, --run, --report, --transcript and optional --max/--merge/--no-merge-gate/--json');
|
|
683
|
+
}
|
|
684
|
+
let landMaxMs = 30 * 60_000;
|
|
685
|
+
if (sub === 'land' && flags.max !== undefined) {
|
|
686
|
+
// Reuse review-file's parser, with minutes as a convenience for long lands.
|
|
687
|
+
try {
|
|
688
|
+
landMaxMs = typeof flags.max === 'string' && /^\d{1,7}m$/.test(flags.max)
|
|
689
|
+
? parseMax(`${Number(flags.max.slice(0, -1)) * 60}s`) : parseMax(flags.max);
|
|
690
|
+
} catch {
|
|
691
|
+
throw new StateError('usage', '--max must look like 90s, 30m or 1500ms');
|
|
692
|
+
}
|
|
693
|
+
}
|
|
380
694
|
if (sub === 'amend-acceptance' && (positionals.length !== 1 || typeof flags.acceptance !== 'string' || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason))) {
|
|
381
695
|
throw new StateError('usage', 'amend-acceptance needs one block id, --acceptance and a nonblank --reason without control characters');
|
|
382
696
|
}
|
|
@@ -405,6 +719,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
405
719
|
return 0;
|
|
406
720
|
}
|
|
407
721
|
const record = await readRun(runId);
|
|
722
|
+
if (sub === 'land') return await landBlock({ id, runId, record, report: /** @type {string} */ (flags.report), transcript: /** @type {string} */ (flags.transcript), maxMs: landMaxMs, merge: flags.merge === true, noMergeGate: flags['no-merge-gate'] === true, json }, deps);
|
|
408
723
|
if (sub === 'amend-acceptance') {
|
|
409
724
|
/** @type {ReturnType<typeof prepareAcceptanceAmendment> | null} */
|
|
410
725
|
let prepared = null;
|
|
@@ -481,10 +796,11 @@ export async function runBlock(args, deps = {}) {
|
|
|
481
796
|
const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
482
797
|
const reopened = async (/** @type {boolean} */ agent) => {
|
|
483
798
|
const file = /** @type {string} */ (flags.file);
|
|
484
|
-
const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
|
|
799
|
+
const { row, cleared, ticket } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
|
|
485
800
|
const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
|
|
486
801
|
say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
|
|
487
|
-
if (
|
|
802
|
+
if (ticket) say(`ticket ${ticket}\nnext: code-forge review-file --wait ${ticket}\n`);
|
|
803
|
+
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: row.by, ticket });
|
|
488
804
|
return 0;
|
|
489
805
|
};
|
|
490
806
|
// Agent refusals precede even the lazy expiry checks: no rows on refusal.
|
|
@@ -542,13 +858,18 @@ export async function runBlock(args, deps = {}) {
|
|
|
542
858
|
// B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
|
|
543
859
|
// a failed check is a warning, never a reason to stop the block command
|
|
544
860
|
const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
861
|
+
const expire = async () => {
|
|
862
|
+
try {
|
|
863
|
+
if (!deciding && !requireHumanClose) {
|
|
864
|
+
err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
|
|
865
|
+
}
|
|
866
|
+
} catch (thrown) {
|
|
867
|
+
err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
|
|
548
868
|
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
|
|
869
|
+
};
|
|
870
|
+
// B148: a refused dependency must not leave even an expiry row behind.
|
|
871
|
+
const deferExpiry = sub === 'open' && (Array.isArray(flags.after) || Array.isArray(flags['after-outcome']));
|
|
872
|
+
if (!deferExpiry) await expire();
|
|
552
873
|
|
|
553
874
|
if (sub === 'open') {
|
|
554
875
|
// an explicit --level wins; B59: else in a brief-mode run the rules lane; B47: else the level
|
|
@@ -581,7 +902,21 @@ export async function runBlock(args, deps = {}) {
|
|
|
581
902
|
}
|
|
582
903
|
// B62a: the run's isolation, from its config snapshot (fixed for the run)
|
|
583
904
|
const isolated = worktreeSettings(record);
|
|
584
|
-
|
|
905
|
+
// Preserve --after's self-before-unfinished order before examining specialist outcomes.
|
|
906
|
+
// State open still checks these dependencies under the run lock.
|
|
907
|
+
let afterOutcome;
|
|
908
|
+
if (Array.isArray(flags['after-outcome'])) {
|
|
909
|
+
if (Array.isArray(flags.after)) {
|
|
910
|
+
if (flags.after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
|
|
911
|
+
const blocking = [...new Set(flags.after)].filter((dependency) => record.blocks[dependency]?.status !== 'closed');
|
|
912
|
+
if (blocking.length > 0) {
|
|
913
|
+
const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
|
|
914
|
+
throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
afterOutcome = acceptedOutcomes(record.workspace, id, flags['after-outcome']);
|
|
918
|
+
}
|
|
919
|
+
const { block, pointer, worktreeReused, keptClaims } = await openBlock({
|
|
585
920
|
runId,
|
|
586
921
|
id,
|
|
587
922
|
level,
|
|
@@ -594,15 +929,19 @@ export async function runBlock(args, deps = {}) {
|
|
|
594
929
|
...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
|
|
595
930
|
...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
|
|
596
931
|
...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
|
|
932
|
+
...(Array.isArray(flags.after) ? { after: flags.after } : {}),
|
|
933
|
+
...(afterOutcome ? { after_outcome: afterOutcome } : {}),
|
|
597
934
|
cfg: await openConfig(record.workspace),
|
|
598
935
|
writeRow,
|
|
599
936
|
...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
|
|
600
937
|
});
|
|
938
|
+
if (deferExpiry) await expire();
|
|
601
939
|
const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
|
|
602
940
|
const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
|
|
603
941
|
const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
|
|
604
942
|
const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
|
|
605
943
|
say(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}${tests}\n`);
|
|
944
|
+
if (keptClaims > 0) say(`kept claims: ${keptClaims}\n`);
|
|
606
945
|
if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
|
|
607
946
|
say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
|
|
608
947
|
if (json) {
|
|
@@ -614,6 +953,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
614
953
|
source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
|
|
615
954
|
kind: block.kind,
|
|
616
955
|
attempt: block.attempt,
|
|
956
|
+
...(keptClaims > 0 ? { kept_claims: keptClaims } : {}),
|
|
617
957
|
base_sha: block.base_sha,
|
|
618
958
|
tests: testCount,
|
|
619
959
|
worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
|
|
@@ -623,13 +963,24 @@ export async function runBlock(args, deps = {}) {
|
|
|
623
963
|
return 0;
|
|
624
964
|
}
|
|
625
965
|
if (sub === 'attempt') {
|
|
626
|
-
const n = await attemptBlock({ runId, id, writeRow });
|
|
966
|
+
const { attempt: n, keptClaims } = await attemptBlock({ runId, id, writeRow });
|
|
627
967
|
say(`block ${id} attempt ${n}\n`);
|
|
628
|
-
if (
|
|
968
|
+
if (keptClaims > 0) say(`kept claims: ${keptClaims}\n`);
|
|
969
|
+
if (json) emit({ block: id, event: 'attempt', attempt: n, ...(keptClaims > 0 ? { kept_claims: keptClaims } : {}) });
|
|
629
970
|
return 0;
|
|
630
971
|
}
|
|
631
972
|
if (sub === 'rebase') {
|
|
632
|
-
const
|
|
973
|
+
const acceptChanged = Array.isArray(flags['accept-changed']) ? [...new Set(flags['accept-changed'])] : [];
|
|
974
|
+
if (acceptChanged.length) {
|
|
975
|
+
if (positionals.length !== 1 || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason)) throw new StateError('usage', '--accept-changed needs one block id and a nonblank --reason without control characters');
|
|
976
|
+
for (const file of acceptChanged) checkFileForm(file, { normalized: true });
|
|
977
|
+
const asked = await confirmHumanVerb('rebase', runId, id, { file: acceptChanged.join(', ') }, record, deps, async () => {
|
|
978
|
+
if ((await checkExpiry(runId, (deps.now ?? (() => new Date()))())).state === 'active') throw new StateError('autopilot-active', 'block rebase --accept-changed is refused while an autopilot grant is active');
|
|
979
|
+
});
|
|
980
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY('rebase')}\n`); return 2; }
|
|
981
|
+
if (asked === 'cancelled') { err(`block ${id} rebase: cancelled — nothing was recorded\n`); return 1; }
|
|
982
|
+
}
|
|
983
|
+
const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow, acceptChanged, reason: typeof flags.reason === 'string' ? flags.reason : undefined });
|
|
633
984
|
say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
|
|
634
985
|
if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
|
|
635
986
|
return 0;
|
|
@@ -679,7 +1030,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
679
1030
|
throw thrown;
|
|
680
1031
|
});
|
|
681
1032
|
// ONE base for the whole close: a worktree block is measured against the commit its tree was
|
|
682
|
-
// made from (kept equal to base_sha, including after a
|
|
1033
|
+
// made from (kept equal to base_sha, including after a worktree rebase)
|
|
683
1034
|
const base = wt !== null ? wt.base_sha : entry.base_sha;
|
|
684
1035
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
685
1036
|
let routineGrant = null;
|
|
@@ -705,8 +1056,28 @@ export async function runBlock(args, deps = {}) {
|
|
|
705
1056
|
}
|
|
706
1057
|
// B58: the owner's other way out — `review.external.required` in the run's config snapshot
|
|
707
1058
|
// (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
|
|
1059
|
+
if (flags['auto-claim'] === true) await autoClaimChanges({ record, runId, id, writeRow, print: json ? err : say });
|
|
708
1060
|
const external = reviewsWaived ? null : externalReviewer(record);
|
|
709
|
-
const
|
|
1061
|
+
const reviewConfig = configInForce(record);
|
|
1062
|
+
let rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
1063
|
+
// Restart changed stopped findings before entering closeBlock's run lock: the worker
|
|
1064
|
+
// needs that lock to publish its fresh review. Refresh rows after the join finishes.
|
|
1065
|
+
if (!reviewsWaived && external === null && !deciding && routineGrant === null) {
|
|
1066
|
+
const mainTop = await repoTop(record.workspace);
|
|
1067
|
+
const restarted = await requeueStoppedFiles({ repoRoot: mainTop, run: runId, block: id,
|
|
1068
|
+
tree: wt?.top ?? mainTop, files: reviewedFiles(rows, id), print: err });
|
|
1069
|
+
if (restarted.size > 0) {
|
|
1070
|
+
let output = '';
|
|
1071
|
+
const code = await runReviewFile(['--wait-block', id, '--run', runId], {
|
|
1072
|
+
cwd: wt?.workspace ?? record.workspace, stdout: { write: (text) => { output += text; } }, stderr: { write: err },
|
|
1073
|
+
});
|
|
1074
|
+
if (code !== 0 || JSON.parse(output).done !== true) {
|
|
1075
|
+
err(`block ${id} open: automatic requeue did not finish\n`);
|
|
1076
|
+
return code || 1;
|
|
1077
|
+
}
|
|
1078
|
+
rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
710
1081
|
// B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
|
|
711
1082
|
// base is the repository top level: the worker signs every review row with a repo-root-
|
|
712
1083
|
// relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
|
|
@@ -770,6 +1141,16 @@ export async function runBlock(args, deps = {}) {
|
|
|
770
1141
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
771
1142
|
const highPaths = await highPathsAt(record.workspace, base);
|
|
772
1143
|
const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
1144
|
+
const excluded = fileSet.flatMap((f) => {
|
|
1145
|
+
const rule = reviewExcludeRule(reviewConfig, f.file);
|
|
1146
|
+
return rule === null ? [] : [{ ...f, rule }];
|
|
1147
|
+
});
|
|
1148
|
+
for (const f of excluded) {
|
|
1149
|
+
const problem = lockfileProblem(top, f.file, reviewConfig);
|
|
1150
|
+
if (problem !== null) throw new StateError('lockfile', `lockfile ${f.file}: ${problem}`);
|
|
1151
|
+
}
|
|
1152
|
+
const otherRows = closeRows.filter((r) => r.event !== 'gate.review_excluded');
|
|
1153
|
+
closeRows.splice(0, closeRows.length, ...otherRows, ...excluded.map((f) => ({ event: 'gate.review_excluded', block: id, ...f })));
|
|
773
1154
|
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
774
1155
|
const key = await loadKey(runId);
|
|
775
1156
|
// B52: an empty diff with review rows is never silent — name the likely cause
|
|
@@ -801,7 +1182,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
801
1182
|
unowned: scope?.unowned ?? [],
|
|
802
1183
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
803
1184
|
resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
|
|
804
|
-
files: reviewsWaived || external !== null ? [] : fileSet,
|
|
1185
|
+
files: reviewsWaived || external !== null ? [] : fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
|
|
805
1186
|
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
|
|
806
1187
|
// --no-require-reviews); B58: `review.external` does not lift it
|
|
807
1188
|
noChanges: !reviewsWaived && fileSet.length === 0,
|
|
@@ -852,7 +1233,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
852
1233
|
}
|
|
853
1234
|
};
|
|
854
1235
|
return await (deps.reviewedLines ?? reviewedLines)({
|
|
855
|
-
files: fileSet,
|
|
1236
|
+
files: fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
|
|
856
1237
|
waivedAll,
|
|
857
1238
|
ticketOf,
|
|
858
1239
|
onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
|
|
@@ -879,7 +1260,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
879
1260
|
const { fileSet } = await tree();
|
|
880
1261
|
const lines = await toolLines();
|
|
881
1262
|
const given = new Set(lines.map((l) => l.file));
|
|
882
|
-
const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
|
|
1263
|
+
const missing = missingReviewTickets(text, fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null).map((f) => f.file)).filter((f) => !given.has(f));
|
|
883
1264
|
if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
884
1265
|
for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
|
|
885
1266
|
return { ok: true };
|
|
@@ -900,7 +1281,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
900
1281
|
});
|
|
901
1282
|
if (configInForce(record)?.gates?.test != null) {
|
|
902
1283
|
const testGate = async () => {
|
|
903
|
-
|
|
1284
|
+
// A rebase changes the tested tree even when owned commit timestamps are preserved.
|
|
1285
|
+
const lastRebase = rows.reduce((last, row, index) => row.block === id && row.event === 'block.rebase' ? index : last, -1);
|
|
1286
|
+
const green = rows.filter((row, index) => index > lastRebase && row.block === id && row.event === 'gate.done' && row.all_ok === true);
|
|
904
1287
|
// Only commits after the block base that touch its owned paths belong to this block.
|
|
905
1288
|
// Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
|
|
906
1289
|
const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
|
|
@@ -1024,6 +1407,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
1024
1407
|
await warnMovedBase(record.workspace, entry, id, runId, err);
|
|
1025
1408
|
for (const w of lineWarnings) err(w);
|
|
1026
1409
|
for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
|
|
1410
|
+
for (const f of closeRows.filter((r) => r.event === 'gate.review_excluded')) say(`not reviewed (review.exclude ${f.rule}): ${f.file}\n`);
|
|
1027
1411
|
for (const f of waivedAll) {
|
|
1028
1412
|
const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
|
|
1029
1413
|
if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
|
|
@@ -1072,6 +1456,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
1072
1456
|
}
|
|
1073
1457
|
}
|
|
1074
1458
|
} catch (thrown) {
|
|
1459
|
+
if (jsonOpenId && thrown instanceof StateError && ['after-not-done', 'bad-after', 'outcome-not-ready', 'bad-after-outcome'].includes(thrown.code)) {
|
|
1460
|
+
out(`${JSON.stringify({ block: jsonOpenId, event: 'refused', code: thrown.code, message: thrown.message })}\n`);
|
|
1461
|
+
}
|
|
1075
1462
|
err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
|
|
1076
1463
|
return thrown instanceof StateError && thrown.code === 'usage' ? 2 : 1;
|
|
1077
1464
|
}
|
|
@@ -1168,7 +1555,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
|
|
|
1168
1555
|
|
|
1169
1556
|
/**
|
|
1170
1557
|
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
1171
|
-
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close'} verb @returns {string}
|
|
1558
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close' | 'rebase'} verb @returns {string}
|
|
1172
1559
|
*/
|
|
1173
1560
|
export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
1174
1561
|
? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
|
|
@@ -1229,14 +1616,16 @@ async function confirmNoReviews(runId, id, deps) {
|
|
|
1229
1616
|
* `by: autopilot`); human-pass instead refuses an active grant. The
|
|
1230
1617
|
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
1231
1618
|
* asked about a waive or reopen the verb would refuse that way.
|
|
1232
|
-
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance'} verb @param {string} runId @param {string} id
|
|
1619
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'rebase'} verb @param {string} runId @param {string} id
|
|
1233
1620
|
* @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[], oldCount?: number, newCount?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1234
1621
|
* @param {() => Promise<unknown> | unknown} [before]
|
|
1235
1622
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
1236
1623
|
*/
|
|
1237
1624
|
function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
1238
1625
|
const message = () =>
|
|
1239
|
-
verb === '
|
|
1626
|
+
verb === 'rebase'
|
|
1627
|
+
? `Accept rebased changes in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
1628
|
+
: verb === 'amend-acceptance'
|
|
1240
1629
|
? `Amend acceptance of block ${id} (run ${runId}) from ${what.oldCount} clauses to ${what.newCount} clauses? Only the owner may say yes.`
|
|
1241
1630
|
: verb === 'human-pass'
|
|
1242
1631
|
? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
|