@codedology/code-forge 0.15.3 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +9 -1
- package/docs/reference/config.md +11 -1
- 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 +7 -2
- package/schema/code-forge.schema.json +26 -1
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +37 -0
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +18 -5
- package/skill/references/review.md +47 -9
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/block.mjs +284 -11
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/help.mjs +1 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/review-file.mjs +126 -14
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +80 -14
- package/src/decide/escalation.mjs +5 -5
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/agent-waiver.mjs +95 -8
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +33 -13
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +111 -22
- package/src/review/reopen.mjs +38 -13
- package/src/review/route.mjs +58 -4
- package/src/session/facts.mjs +252 -70
- package/src/session/plan-check.mjs +4 -1
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +56 -21
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +133 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +150 -20
- package/src/worker/queue.mjs +3 -2
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +97 -83
- package/src/worker/worker-warning.mjs +35 -0
package/src/cli/block.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
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)
|
|
@@ -197,9 +198,12 @@
|
|
|
197
198
|
|
|
198
199
|
import { createHash } from 'node:crypto';
|
|
199
200
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
200
|
-
import { readFile, realpath } from 'node:fs/promises';
|
|
201
|
+
import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
|
|
201
202
|
import path from 'node:path';
|
|
202
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';
|
|
203
207
|
import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
204
208
|
import { fallbackLane } from '../decide/fallback-rules.mjs';
|
|
205
209
|
import { laneFacts } from '../decide/lane-facts.mjs';
|
|
@@ -296,9 +300,236 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
|
|
|
296
300
|
return { row, cleared, ticket };
|
|
297
301
|
}
|
|
298
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
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
299
530
|
const USAGE =
|
|
300
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' +
|
|
301
|
-
' 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' +
|
|
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' +
|
|
302
533
|
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
303
534
|
' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
|
|
304
535
|
' code-forge block accept-hit <id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>\n' +
|
|
@@ -306,6 +537,7 @@ const USAGE =
|
|
|
306
537
|
' code-forge block ack-waivers <id> --run <r>\n' +
|
|
307
538
|
' code-forge block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]\n' +
|
|
308
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' +
|
|
309
541
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
310
542
|
|
|
311
543
|
/** @type {Record<string, string>} */
|
|
@@ -314,7 +546,8 @@ const VERB_USAGE = {
|
|
|
314
546
|
attempt: '<id> --run <r>',
|
|
315
547
|
rebase: '<id> --run <r> [--head <ref>] [--accept-changed <file>… --reason <text>]',
|
|
316
548
|
claim: '<id> <path> --run <r>',
|
|
317
|
-
close: '<id> --run <r> [--worker-pid <pid>] [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--decide]',
|
|
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]',
|
|
318
551
|
stop: '<id> --run <r> --reason <text>',
|
|
319
552
|
waive: '<id> <finding-id> --run <r> --file <path> --reason <text> [--yes]',
|
|
320
553
|
reopen: '<id> --run <r> --file <path> --reason <text> [--yes]',
|
|
@@ -334,7 +567,11 @@ function verbUsage(sub) {
|
|
|
334
567
|
(sub === 'open' || sub === 'amend-acceptance'
|
|
335
568
|
? ' --acceptance <file> JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n' +
|
|
336
569
|
' Human checks use {clause, kind: human_check} without tests.\n' : '') +
|
|
337
|
-
(sub === 'open' ? ' --brief <file> A file inside the repository, not inline text.\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' : '');
|
|
338
575
|
}
|
|
339
576
|
|
|
340
577
|
/** Refuse likely shell quoting slips before any row is written.
|
|
@@ -361,7 +598,7 @@ function checkWhitespacePaths(workspace, entries, label) {
|
|
|
361
598
|
}
|
|
362
599
|
}
|
|
363
600
|
}
|
|
364
|
-
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'];
|
|
365
602
|
|
|
366
603
|
/** Check specialist outcomes against the run's project, before state open can write anything.
|
|
367
604
|
* @param {string} root @param {string} id @param {string[]} ids
|
|
@@ -423,7 +660,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
423
660
|
}
|
|
424
661
|
let jsonOpenId;
|
|
425
662
|
try {
|
|
426
|
-
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 === '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'] : [])] });
|
|
427
664
|
// B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
|
|
428
665
|
// per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
|
|
429
666
|
const json = flags.json === true;
|
|
@@ -441,6 +678,19 @@ export async function runBlock(args, deps = {}) {
|
|
|
441
678
|
if (sub === 'open' && json) jsonOpenId = id;
|
|
442
679
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
443
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
|
+
}
|
|
444
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))) {
|
|
445
695
|
throw new StateError('usage', 'amend-acceptance needs one block id, --acceptance and a nonblank --reason without control characters');
|
|
446
696
|
}
|
|
@@ -469,6 +719,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
469
719
|
return 0;
|
|
470
720
|
}
|
|
471
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);
|
|
472
723
|
if (sub === 'amend-acceptance') {
|
|
473
724
|
/** @type {ReturnType<typeof prepareAcceptanceAmendment> | null} */
|
|
474
725
|
let prepared = null;
|
|
@@ -665,7 +916,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
665
916
|
}
|
|
666
917
|
afterOutcome = acceptedOutcomes(record.workspace, id, flags['after-outcome']);
|
|
667
918
|
}
|
|
668
|
-
const { block, pointer, worktreeReused } = await openBlock({
|
|
919
|
+
const { block, pointer, worktreeReused, keptClaims } = await openBlock({
|
|
669
920
|
runId,
|
|
670
921
|
id,
|
|
671
922
|
level,
|
|
@@ -690,6 +941,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
690
941
|
const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
|
|
691
942
|
const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
|
|
692
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`);
|
|
693
945
|
if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
|
|
694
946
|
say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
|
|
695
947
|
if (json) {
|
|
@@ -701,6 +953,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
701
953
|
source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
|
|
702
954
|
kind: block.kind,
|
|
703
955
|
attempt: block.attempt,
|
|
956
|
+
...(keptClaims > 0 ? { kept_claims: keptClaims } : {}),
|
|
704
957
|
base_sha: block.base_sha,
|
|
705
958
|
tests: testCount,
|
|
706
959
|
worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
|
|
@@ -710,9 +963,10 @@ export async function runBlock(args, deps = {}) {
|
|
|
710
963
|
return 0;
|
|
711
964
|
}
|
|
712
965
|
if (sub === 'attempt') {
|
|
713
|
-
const n = await attemptBlock({ runId, id, writeRow });
|
|
966
|
+
const { attempt: n, keptClaims } = await attemptBlock({ runId, id, writeRow });
|
|
714
967
|
say(`block ${id} attempt ${n}\n`);
|
|
715
|
-
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 } : {}) });
|
|
716
970
|
return 0;
|
|
717
971
|
}
|
|
718
972
|
if (sub === 'rebase') {
|
|
@@ -802,9 +1056,28 @@ export async function runBlock(args, deps = {}) {
|
|
|
802
1056
|
}
|
|
803
1057
|
// B58: the owner's other way out — `review.external.required` in the run's config snapshot
|
|
804
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 });
|
|
805
1060
|
const external = reviewsWaived ? null : externalReviewer(record);
|
|
806
1061
|
const reviewConfig = configInForce(record);
|
|
807
|
-
|
|
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
|
+
}
|
|
808
1081
|
// B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
|
|
809
1082
|
// base is the repository top level: the worker signs every review row with a repo-root-
|
|
810
1083
|
// relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
|
package/src/cli/doctor.mjs
CHANGED
|
@@ -10,12 +10,16 @@
|
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { rmSync } from 'node:fs';
|
|
12
12
|
import path from 'node:path';
|
|
13
|
-
import { loadProjectConfig } from '../config/load.mjs';
|
|
13
|
+
import { loadProjectConfig, projectRootFor } from '../config/load.mjs';
|
|
14
14
|
import { runDoctor } from '../doctor/index.mjs';
|
|
15
15
|
import { counts, exitCode, renderJSON, renderText } from '../doctor/rows.mjs';
|
|
16
16
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
17
|
+
import { configInForce } from '../state/config-snapshot.mjs';
|
|
18
|
+
import { readRun } from '../state/run.mjs';
|
|
19
|
+
import { daemonEnabled, daemonRequest, nativePath, runNative } from '../util/native.mjs';
|
|
17
20
|
import { writeSafe } from '../util/redact.mjs';
|
|
18
21
|
import { runRoot, setRunRoot } from '../util/tmp.mjs';
|
|
22
|
+
import { liveWorker } from '../worker/queue.mjs';
|
|
19
23
|
|
|
20
24
|
const USAGE = 'usage: code-forge doctor [--quick] [--json] [--cwd <dir>]\n';
|
|
21
25
|
|
|
@@ -56,6 +60,33 @@ export async function runDoctorCli(args, deps = {}, adopt = false) {
|
|
|
56
60
|
}
|
|
57
61
|
try {
|
|
58
62
|
const rows = await runDoctor({ cwd, quick: flags.quick === true, ...(root ? { runRootDir: root } : {}) }, deps);
|
|
63
|
+
if (!flags.quick) {
|
|
64
|
+
const binary = nativePath();
|
|
65
|
+
let nativeDetail = 'not found, using Node';
|
|
66
|
+
let nativeOK = false;
|
|
67
|
+
if (binary) {
|
|
68
|
+
const result = await runNative(['version']);
|
|
69
|
+
try {
|
|
70
|
+
const version = result?.code === 0 ? JSON.parse(result.stdout) : null;
|
|
71
|
+
if (typeof version?.version === 'string') {
|
|
72
|
+
nativeOK = true;
|
|
73
|
+
nativeDetail = `${binary}, version ${version.version}`;
|
|
74
|
+
} else nativeDetail = `${binary}: unavailable, using Node`;
|
|
75
|
+
} catch { nativeDetail = `${binary}: unavailable, using Node`; }
|
|
76
|
+
}
|
|
77
|
+
rows.push({ id: 'native', status: nativeOK ? 'OK' : 'INFO', label: 'native binary', detail: nativeDetail });
|
|
78
|
+
let daemon = 'off';
|
|
79
|
+
try {
|
|
80
|
+
const loaded = await loadProjectConfig(cwd);
|
|
81
|
+
const worker = liveWorker(projectRootFor(cwd));
|
|
82
|
+
const config = (worker ? configInForce(await readRun(worker.run)) : null)
|
|
83
|
+
?? (loaded.ok ? loaded.config : { native: { daemon: false } });
|
|
84
|
+
if (daemonEnabled(config)) {
|
|
85
|
+
daemon = worker && await daemonRequest(worker.run, { method: 'ping' }, { config }) ? 'live' : 'fallback';
|
|
86
|
+
}
|
|
87
|
+
} catch { /* an unreadable config or worker leaves native acceleration off */ }
|
|
88
|
+
rows.push({ id: 'native_daemon', status: 'INFO', label: 'native daemon', detail: daemon });
|
|
89
|
+
}
|
|
59
90
|
writeSafe(stdout, flags.json ? renderJSON(rows) : renderText(rows));
|
|
60
91
|
const c = counts(rows);
|
|
61
92
|
writeSafe(stderr, `doctor: ${c.OK} ok, ${c.WARN} warn, ${c.FAIL} fail\n`);
|
package/src/cli/help.mjs
CHANGED
|
@@ -47,6 +47,7 @@ export default async function help(_args, context = {}) {
|
|
|
47
47
|
process.stdout.write('\nBlock usage: code-forge block <verb> --help (or -h).\n');
|
|
48
48
|
process.stdout.write(' block open --acceptance <file>: JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n');
|
|
49
49
|
process.stdout.write(' block open --brief <file>: a file inside the repository, not inline text.\n');
|
|
50
|
+
process.stdout.write(' block land <id> --run <r> --report <file> --transcript <file> [--merge] [--json]: join reviews, run changed gates, close, then optionally merge with --no-ff.\n');
|
|
50
51
|
process.stdout.write(' Configuration fallback entries must be {provider, model} objects, not model-name strings (optional effort).\n');
|
|
51
52
|
return 0;
|
|
52
53
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// One companion per native daemon: reuse status's config and review-gate rules
|
|
2
|
+
// over the daemon's cached record/rows, without rereading the ledger or running ps.
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
4
|
+
import { statusOf } from './status.mjs';
|
|
5
|
+
|
|
6
|
+
for await (const line of createInterface({ input: process.stdin, crlfDelay: Infinity })) {
|
|
7
|
+
try {
|
|
8
|
+
const { record, rows } = JSON.parse(line);
|
|
9
|
+
process.stdout.write(`${JSON.stringify({ runs: [await statusOf(record, rows)] })}\n`);
|
|
10
|
+
} catch {
|
|
11
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: 'status-unreadable' })}\n`);
|
|
12
|
+
}
|
|
13
|
+
}
|