@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/session/facts.mjs
CHANGED
|
@@ -104,9 +104,9 @@ export class FactsError extends Error {
|
|
|
104
104
|
const PATTERNS = /** @type {ReadonlyArray<[ClaimKind, RegExp]>} */ ([
|
|
105
105
|
['endpoint', /https?:\/\/[^\s`'"<>)\]|]+/g],
|
|
106
106
|
['package', /(?<![\w./@-])@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*/g],
|
|
107
|
-
['path', /(?<![\w/.~-])(?:~|\.{1,2})
|
|
107
|
+
['path', /(?<![\w/.~-])(?:~|\.{1,2}|[\w.-]+)?\/[^\s`'"<>)\]|,;]*/g],
|
|
108
108
|
['flag', /(?<![\w-])--[a-z][a-z0-9-]*/g],
|
|
109
|
-
['version', /(?<![\w.§])\d+\.\d+(?:\.\d+)*/g],
|
|
109
|
+
['version', /(?<![\w.§])(?<!§ )\d+\.\d+(?:\.\d+)*/g],
|
|
110
110
|
['env', /(?<![\w])\$?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+(?![\w])/g],
|
|
111
111
|
]);
|
|
112
112
|
|
|
@@ -122,6 +122,7 @@ const trimTail = (token) => token.replace(/[.,:;]+$/, '');
|
|
|
122
122
|
* `bin` keys, {@link readProjectBins}); none ⇒ command claims are kept as written.
|
|
123
123
|
* @property {string} [briefText] - the brief alone (no sources): only IT can name the project CLI
|
|
124
124
|
* that a command claim gets prefixed with (default: the whole text).
|
|
125
|
+
* @property {string} [projectDir] - project root whose top-level folders admit extensionless paths.
|
|
125
126
|
*/
|
|
126
127
|
|
|
127
128
|
/**
|
|
@@ -205,6 +206,8 @@ export function readProjectBins(projectDir) {
|
|
|
205
206
|
*/
|
|
206
207
|
export function extractClaims(text, opts = {}) {
|
|
207
208
|
let masked = String(text);
|
|
209
|
+
const topLevelFolders = new Set(opts.projectDir === undefined ? [] : readdirSync(opts.projectDir, { withFileTypes: true })
|
|
210
|
+
.filter((entry) => entry.isDirectory()).map((entry) => entry.name));
|
|
208
211
|
/** @type {Array<{index: number, token: string, kind: ClaimKind}>} */
|
|
209
212
|
const found = [];
|
|
210
213
|
/** @param {number} start @param {number} length */
|
|
@@ -215,7 +218,9 @@ export function extractClaims(text, opts = {}) {
|
|
|
215
218
|
const scan = (kind, re) => {
|
|
216
219
|
for (const m of [...masked.matchAll(re)]) {
|
|
217
220
|
const token = trimTail(kind === 'env' ? m[0].replace(/^\$/, '') : m[0]);
|
|
218
|
-
if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})
|
|
221
|
+
if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})?\/$/.test(token))) continue;
|
|
222
|
+
if (kind === 'path' && !/^(~|\.{1,2})?\//.test(token)
|
|
223
|
+
&& !/\.[a-zA-Z0-9][\w-]*$/.test(token) && !topLevelFolders.has(token.split('/')[0])) continue;
|
|
219
224
|
found.push({ index: /** @type {number} */ (m.index), token, kind });
|
|
220
225
|
mask(/** @type {number} */ (m.index), m[0].length);
|
|
221
226
|
}
|
|
@@ -778,7 +783,8 @@ function ruleBroken(row, claim, ctx) {
|
|
|
778
783
|
|
|
779
784
|
/**
|
|
780
785
|
* Validate the delegate's answer (step 3). Null commands/excerpts are normalized on a copy and
|
|
781
|
-
* their matched claims downgraded;
|
|
786
|
+
* their matched claims downgraded; long excerpts are capped without changing the tag. Every other
|
|
787
|
+
* schema-invalid answer is refused. A row answers
|
|
782
788
|
* claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
|
|
783
789
|
* the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
|
|
784
790
|
* equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
|
|
@@ -802,9 +808,11 @@ export function validateFacts(answer, claims, opts = {}) {
|
|
|
802
808
|
for (const row of normalized.facts) {
|
|
803
809
|
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
|
|
804
810
|
const fields = ['command', 'output_excerpt'].filter((field) => row[field] === null);
|
|
805
|
-
if (fields.length === 0) continue;
|
|
806
811
|
for (const field of fields) row[field] = '';
|
|
807
|
-
nullFields.set(row, fields);
|
|
812
|
+
if (fields.length > 0) nullFields.set(row, fields);
|
|
813
|
+
if (typeof row.output_excerpt === 'string' && row.output_excerpt.length > EXCERPT_MAX) {
|
|
814
|
+
row.output_excerpt = `${row.output_excerpt.slice(0, EXCERPT_MAX - 1)}…`;
|
|
815
|
+
}
|
|
808
816
|
}
|
|
809
817
|
}
|
|
810
818
|
if (!validateFile(normalized)) {
|
|
@@ -1194,8 +1202,12 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
1194
1202
|
if (opts.projectDir !== undefined) {
|
|
1195
1203
|
try { projectBins = readProjectBins(opts.projectDir); } catch { projectBins = []; }
|
|
1196
1204
|
}
|
|
1197
|
-
const extractOpts = opts.projectDir === undefined ? {} : { projectBins, briefText };
|
|
1205
|
+
const extractOpts = opts.projectDir === undefined ? {} : { projectBins, briefText, projectDir: opts.projectDir };
|
|
1198
1206
|
const claims = extractClaims([briefText, ...readSourcesText(opts.sources ?? [])].join('\n'), extractOpts);
|
|
1207
|
+
const lines = briefText.split(/\r?\n/).length - Number(briefText.endsWith('\n'));
|
|
1208
|
+
if (lines > 40 && claims.length < 5) {
|
|
1209
|
+
(deps.stderr ?? process.stderr).write(`forge facts: the brief has ${lines} lines but only ${claims.length} claims; the sheet checks almost nothing\n`);
|
|
1210
|
+
}
|
|
1199
1211
|
const root = opts.runRoot ?? currentRunRoot();
|
|
1200
1212
|
// B9b.1: a run root inside a git work tree (`tmp.root` configured in the project) cannot hold
|
|
1201
1213
|
// the snapshot — git and the harness would walk up into the live repo — so the facts area moves
|
|
@@ -358,7 +358,10 @@ export function checkPlan(text, opts = {}) {
|
|
|
358
358
|
const projectBins = () => (binsCache ??= typeof given === 'function' ? given() : given ?? []);
|
|
359
359
|
for (const b of blocks) {
|
|
360
360
|
for (const clause of splitClauses(b.acceptance)) {
|
|
361
|
-
|
|
361
|
+
// A trailing parenthesized test citation names planned coverage, not a fact about
|
|
362
|
+
// an existing file. Paths in the clause itself still need facts or tolerances.
|
|
363
|
+
const factualClause = clause.replace(/\((?:\s*`(?:\.\/)?(?:test|tests|spec)\/[^`\n]+\.[\w]+`[\s,]*)+\)\s*$/, '');
|
|
364
|
+
const unbacked = extractClaims(factualClause).filter((c) => !isVerified(c, verified, projectBins));
|
|
362
365
|
if (unbacked.every((c) => tolerances.some((t) => t.includes(c.token) && idRe(b.id).test(t)))) continue;
|
|
363
366
|
errors.push(`unbackable clause without tolerance: ${b.id} ${clause}`);
|
|
364
367
|
}
|
package/src/session/spawn.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
|
51
51
|
import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
52
52
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
53
53
|
import { compileSchema } from '../config/schema-compile.mjs';
|
|
54
|
+
import { blockTaskLines, ContextError, readBlockBrief } from '../context/pack.mjs';
|
|
54
55
|
import { buildArgv } from '../engines/builders/index.mjs';
|
|
55
56
|
import { removeCodexHome } from '../engines/codex-home.mjs';
|
|
56
57
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
@@ -61,6 +62,7 @@ import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd
|
|
|
61
62
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
62
63
|
import { categoryGate, categoryPlan, RECORD_UNKNOWN_MESSAGE } from '../autopilot/limits.mjs';
|
|
63
64
|
import { StateError } from '../state/paths.mjs';
|
|
65
|
+
import { readRun } from '../state/run.mjs';
|
|
64
66
|
import { exec } from '../util/exec.mjs';
|
|
65
67
|
import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
|
|
66
68
|
import { semaphore } from '../util/locks.mjs';
|
|
@@ -68,6 +70,7 @@ import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.m
|
|
|
68
70
|
import { asText, stderrTail } from '../util/diagnostic-tail.mjs';
|
|
69
71
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
70
72
|
import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
|
|
73
|
+
import { assertBlockId, mainRootOf } from '../worker/ticket.mjs';
|
|
71
74
|
|
|
72
75
|
export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']);
|
|
73
76
|
export const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
|
|
@@ -538,6 +541,8 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
538
541
|
if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
|
|
539
542
|
if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
|
|
540
543
|
if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
|
|
544
|
+
/** @type {string | null} */
|
|
545
|
+
let coderPrompt = null;
|
|
541
546
|
const stderr = deps.stderr ?? process.stderr;
|
|
542
547
|
const runRootDir = opts.runRoot ?? currentRunRoot();
|
|
543
548
|
if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
|
|
@@ -570,6 +575,9 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
570
575
|
// in the `finally` below on every other exit path (a throw, a background start)
|
|
571
576
|
const releaseReservation = gate.release;
|
|
572
577
|
try {
|
|
578
|
+
// Budget refusals take precedence over task loading, including legacy runs without
|
|
579
|
+
// this block. A failed context load must still release the accepted reservation.
|
|
580
|
+
if (role === 'coder' && opts.block && coderPrompt === null) coderPrompt = await coderPromptFor(opts);
|
|
573
581
|
const outcome = await runAttempt(step, i + 1 === steps.length, releaseReservation);
|
|
574
582
|
if (outcome.result) return outcome.result;
|
|
575
583
|
waitMs = outcome.waitMs;
|
|
@@ -602,7 +610,7 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
602
610
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
603
611
|
let keep = false;
|
|
604
612
|
try {
|
|
605
|
-
const built = buildStep(opts, step, sessionDir, openBook);
|
|
613
|
+
const built = buildStep(opts, step, sessionDir, openBook, coderPrompt);
|
|
606
614
|
const cli = built.cli;
|
|
607
615
|
built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
|
|
608
616
|
if (role === 'coder') {
|
|
@@ -659,6 +667,42 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
659
667
|
}
|
|
660
668
|
}
|
|
661
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Add the recorded task and completion contract without overwriting the caller's brief.
|
|
672
|
+
* @param {SessionOpts} opts @returns {Promise<string>}
|
|
673
|
+
*/
|
|
674
|
+
async function coderPromptFor(opts) {
|
|
675
|
+
let blockId;
|
|
676
|
+
let original;
|
|
677
|
+
try {
|
|
678
|
+
blockId = assertBlockId(opts.block);
|
|
679
|
+
original = readFileSync(opts.promptPath, 'utf8');
|
|
680
|
+
} catch (err) {
|
|
681
|
+
throw new SessionError('usage', err instanceof Error ? err.message : 'the coder brief cannot be read');
|
|
682
|
+
}
|
|
683
|
+
let task = [];
|
|
684
|
+
if (opts.run) {
|
|
685
|
+
let record;
|
|
686
|
+
try {
|
|
687
|
+
record = await readRun(opts.run);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
if (!(err instanceof StateError && err.code === 'no-run')) throw err;
|
|
690
|
+
}
|
|
691
|
+
if (record) {
|
|
692
|
+
const block = record.blocks?.[blockId];
|
|
693
|
+
if (!block) throw new SessionError('usage', `run ${opts.run} has no block ${blockId}`);
|
|
694
|
+
try {
|
|
695
|
+
const repoRoot = await mainRootOf(record.workspace);
|
|
696
|
+
task = blockTaskLines({ brief: readBlockBrief(repoRoot, block), acceptance: block.acceptance }, original);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
if (err instanceof ContextError) throw new SessionError('usage', err.message);
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return `${original.trimEnd()}\n\n${task.length ? `${task.join('\n')}\n\n` : ''}Report the changed files and pass/fail counts. Print TESTS <command> exit=<n> on its own line before the sentinel. On success, print this exact sentinel as your last line:\n===BLOCK ${blockId} COMPLETE===\n`;
|
|
704
|
+
}
|
|
705
|
+
|
|
662
706
|
/**
|
|
663
707
|
* B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
|
|
664
708
|
* 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
|
|
@@ -812,8 +856,9 @@ async function checkGate(opts, deps, writeRow, stderr, budget, estimate) {
|
|
|
812
856
|
* @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
|
|
813
857
|
* @param {string} sessionDir
|
|
814
858
|
* @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
|
|
859
|
+
* @param {string | null} [coderPrompt]
|
|
815
860
|
*/
|
|
816
|
-
function buildStep(opts, step, sessionDir, openBook) {
|
|
861
|
+
function buildStep(opts, step, sessionDir, openBook, coderPrompt = null) {
|
|
817
862
|
const closedBook = opts.role !== 'coder';
|
|
818
863
|
// B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
|
|
819
864
|
const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
|
|
@@ -823,11 +868,17 @@ function buildStep(opts, step, sessionDir, openBook) {
|
|
|
823
868
|
if (cli === undefined) throw new SessionError('usage', `provider "${step.provider}" has no CLI mapping`);
|
|
824
869
|
/** @type {Record<string, any>} */
|
|
825
870
|
const params = { provider: step.provider, role: opts.role, model: step.model, promptPath: opts.promptPath, cwd };
|
|
871
|
+
if (coderPrompt !== null) {
|
|
872
|
+
params.promptPath = path.join(sessionDir, 'coder-brief.md');
|
|
873
|
+
writeFileSync(params.promptPath, coderPrompt, { mode: 0o600 });
|
|
874
|
+
}
|
|
826
875
|
if (step.effort) params.effort = step.effort;
|
|
827
876
|
if (step.flagFallback) params.fallback = [step.flagFallback];
|
|
828
877
|
if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
|
|
829
878
|
if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
|
|
830
879
|
if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
|
|
880
|
+
// The session owns this home: a named run must not use the spawning process's lazy root.
|
|
881
|
+
if (cli === 'codex' && !closedBook) params.runRoot = opts.runRoot ?? currentRunRoot();
|
|
831
882
|
// B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
|
|
832
883
|
if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
|
|
833
884
|
// B93: the Codex coder's sandbox profile; no `coder.sandbox` key builds exactly today's argv
|
|
@@ -903,7 +954,7 @@ async function runForeground(built, opts, deps) {
|
|
|
903
954
|
});
|
|
904
955
|
} finally {
|
|
905
956
|
// B4.1: the Codex coder's per-session CODEX_HOME (rules + auth copy) dies with the session.
|
|
906
|
-
if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME);
|
|
957
|
+
if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME, opts.runRoot);
|
|
907
958
|
}
|
|
908
959
|
const duration_ms = Date.now() - started;
|
|
909
960
|
/** @type {string | null} */
|
package/src/state/block.mjs
CHANGED
|
@@ -24,9 +24,10 @@ import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
|
24
24
|
import { StateError } from './paths.mjs';
|
|
25
25
|
import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
26
26
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
27
|
-
import { canonicalJSON, verifyRow, loadKey } from './signer.mjs';
|
|
27
|
+
import { canonicalJSON, verifyRow, loadKey, signRow } from './signer.mjs';
|
|
28
|
+
import { readAllRows } from '../ledger/write.mjs';
|
|
28
29
|
import * as reviewQueue from '../worker/queue.mjs';
|
|
29
|
-
import { repoRootOf } from '../worker/ticket.mjs';
|
|
30
|
+
import { assertInsideRoot, contentHash, repoRootOf } from '../worker/ticket.mjs';
|
|
30
31
|
|
|
31
32
|
/** @typedef {import('./run.mjs').WriteRow} WriteRow */
|
|
32
33
|
|
|
@@ -292,6 +293,16 @@ function activeBlocks(record) {
|
|
|
292
293
|
return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
|
|
293
294
|
}
|
|
294
295
|
|
|
296
|
+
/** Earlier claims are authoritative only when their run, block and MAC match.
|
|
297
|
+
* @param {Record<string, any>} record @param {string} id @returns {Promise<string[]>}
|
|
298
|
+
*/
|
|
299
|
+
async function signedClaims(record, id) {
|
|
300
|
+
const key = await loadKey(record.run_id);
|
|
301
|
+
const rows = await readAllRows(record.project);
|
|
302
|
+
return [...new Set(rows.filter((row) => row.event === 'block.claim' && row.run === record.run_id && row.block === id && verifyRow(row, key).ok)
|
|
303
|
+
.map((row) => assertOwned([row.path])[0]))];
|
|
304
|
+
}
|
|
305
|
+
|
|
295
306
|
/**
|
|
296
307
|
* B58: with `review.external.required` set in the run's snapshot, the project's `.code-forge.yml`
|
|
297
308
|
* is the owner's switch past the local per-file review, so no block may own it. These are the
|
|
@@ -362,6 +373,11 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
362
373
|
* B60: `tests` (`block open --tests <paths…>`, the same path rules as owned entries, else
|
|
363
374
|
* `bad-tests`) is recorded as the entry's `test_files` and on the dispatch row; a gate argv item
|
|
364
375
|
* `{test_files}` expands to them (`gates/expand.mjs`). Left out, neither carries the key.
|
|
376
|
+
* B148: `after` lists dependencies in this run. Under the same run lock, every dependency must
|
|
377
|
+
* be closed (`after-not-done`); self-dependency is `bad-after`. Unique ids are recorded on the
|
|
378
|
+
* entry and dispatch only when supplied, and a later attempt checks only its own supplied list.
|
|
379
|
+
* B153: `after_outcome` carries the specialist step ids and outcome byte hashes accepted by
|
|
380
|
+
* the CLI against the run's project root; it is recorded on the entry and dispatch when supplied.
|
|
365
381
|
* B62a: `worktree` (given by `block open` under `proof.isolation: worktree`, `proof/worktree.mjs`;
|
|
366
382
|
* this module never imports it) plans the block's own worktree, or the reuse of the one an earlier
|
|
367
383
|
* entry of the same id recorded (`previous`); its `make()` makes or reuses it. Both are called
|
|
@@ -376,11 +392,12 @@ function refuseConfigOwner(owned, kinds, guarded) {
|
|
|
376
392
|
* @param {{
|
|
377
393
|
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
378
394
|
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
379
|
-
* kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[],
|
|
395
|
+
* kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[], after?: string[],
|
|
396
|
+
* after_outcome?: {step: string, sha256: string}[],
|
|
380
397
|
* decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
|
|
381
|
-
* worktree?: (ctx: {baseSha: string, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
|
|
398
|
+
* worktree?: (ctx: {baseSha: string, baseExplicit: boolean, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
|
|
382
399
|
* }} opts
|
|
383
|
-
* @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
|
|
400
|
+
* @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean, keptClaims: number}>} `worktreeReused`:
|
|
384
401
|
* the block took an earlier attempt's worktree as it was.
|
|
385
402
|
*/
|
|
386
403
|
export async function openBlock(opts) {
|
|
@@ -391,11 +408,12 @@ export async function openBlock(opts) {
|
|
|
391
408
|
if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
|
|
392
409
|
if (opts.kind !== undefined && opts.kind !== 'light' && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${[...BLOCK_KINDS, 'light'].join(', ')}`);
|
|
393
410
|
if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
|
|
394
|
-
const
|
|
411
|
+
const declaredOwned = assertOwned(opts.owned);
|
|
395
412
|
const acceptance = assertAcceptance(opts.acceptance);
|
|
396
413
|
const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
397
414
|
const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
|
|
398
|
-
const
|
|
415
|
+
const after = opts.after === undefined ? null : [...new Set(opts.after)];
|
|
416
|
+
const kind = opts.kind === 'light' ? 'light' : blockKind({ owned: declaredOwned, declared: opts.kind });
|
|
399
417
|
const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
|
|
400
418
|
const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
|
|
401
419
|
|
|
@@ -420,11 +438,21 @@ export async function openBlock(opts) {
|
|
|
420
438
|
/** @type {OpenTree | null} */
|
|
421
439
|
let made = null;
|
|
422
440
|
try {
|
|
423
|
-
const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
441
|
+
const { block, reused, keptClaims } = await mutateRun(runId, null, writeRow, async (record) => {
|
|
424
442
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
425
443
|
if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
|
|
444
|
+
if (after !== null) {
|
|
445
|
+
if (after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
|
|
446
|
+
const blocking = after.filter((dependency) => record.blocks[dependency]?.status !== 'closed');
|
|
447
|
+
if (blocking.length > 0) {
|
|
448
|
+
const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
|
|
449
|
+
throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
426
452
|
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
427
453
|
// file owns only itself, here and in every later check
|
|
454
|
+
const claims = attempt > 1 ? await signedClaims(record, id) : [];
|
|
455
|
+
const owned = [...new Set([...declaredOwned, ...claims])];
|
|
428
456
|
const kinds = ownedKinds(record.workspace, owned);
|
|
429
457
|
refuseConfigOwner(owned, kinds, guarded);
|
|
430
458
|
const prior = previousTree(record);
|
|
@@ -433,7 +461,7 @@ export async function openBlock(opts) {
|
|
|
433
461
|
const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
|
|
434
462
|
const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
|
|
435
463
|
// B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
|
|
436
|
-
const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
|
|
464
|
+
const planned = opts.worktree ? await opts.worktree({ baseSha, baseExplicit: opts.base !== undefined, previous: prior }) : null;
|
|
437
465
|
const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
|
|
438
466
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
439
467
|
const pair = blocksOverlap(self, other);
|
|
@@ -443,21 +471,23 @@ export async function openBlock(opts) {
|
|
|
443
471
|
// passed; a tree made here is rolled back below if the rows or the save then fail
|
|
444
472
|
made = planned ? await planned.make() : null;
|
|
445
473
|
const tree = made;
|
|
446
|
-
const
|
|
474
|
+
const outcomeDependencies = opts.after_outcome === undefined ? {} : { after_outcome: opts.after_outcome };
|
|
475
|
+
const entry = { block: id, base_sha: baseSha, base_branch: baseBranch, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies, ...(tree ? { worktree: tree.worktree } : {}) };
|
|
447
476
|
if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
|
|
477
|
+
if (opts.brief) Object.assign(entry, { brief: { path: opts.brief.path } });
|
|
448
478
|
record.blocks[id] = entry;
|
|
449
479
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
450
480
|
const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
|
|
451
481
|
return {
|
|
452
|
-
result: { block: entry, reused: tree !== null && !tree.created },
|
|
482
|
+
result: { block: entry, reused: tree !== null && !tree.created, keptClaims: claims.length },
|
|
453
483
|
rows: [
|
|
454
484
|
...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
|
|
455
485
|
...created,
|
|
456
|
-
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, base_branch: baseBranch, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
|
|
486
|
+
{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, base_branch: baseBranch, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}), ...(after !== null ? { after } : {}), ...outcomeDependencies },
|
|
457
487
|
],
|
|
458
488
|
};
|
|
459
489
|
});
|
|
460
|
-
return { block, pointer, worktreeReused: reused };
|
|
490
|
+
return { block, pointer, worktreeReused: reused, keptClaims };
|
|
461
491
|
} catch (err) {
|
|
462
492
|
// B62a: nothing was recorded, so a tree this open made must not outlive it
|
|
463
493
|
if (made?.created && made.rollback) {
|
|
@@ -475,9 +505,12 @@ export async function openBlock(opts) {
|
|
|
475
505
|
|
|
476
506
|
/** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
|
|
477
507
|
export async function attemptBlock({ runId, id, writeRow }) {
|
|
478
|
-
return mutateRun(runId, id, writeRow, (
|
|
508
|
+
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
509
|
+
const claims = await signedClaims(record, id);
|
|
510
|
+
block.owned_files = [...new Set([...block.owned_files, ...claims])];
|
|
511
|
+
block.owned_kinds = { ...ownedKinds(record.workspace, claims), ...block.owned_kinds };
|
|
479
512
|
block.attempt += 1;
|
|
480
|
-
return { result: block.attempt, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
|
|
513
|
+
return { result: { attempt: block.attempt, keptClaims: claims.length }, rows: [{ event: 'block.attempt', block: id, attempt: block.attempt }] };
|
|
481
514
|
});
|
|
482
515
|
}
|
|
483
516
|
|
|
@@ -486,18 +519,19 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
486
519
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
487
520
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
488
521
|
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
489
|
-
* below it).
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
* @
|
|
522
|
+
* below it). Worktree blocks rebase their branch onto the recorded base branch. Open blocks
|
|
523
|
+
* refuse upstream commits touching owned files; closed blocks retain every changed owned blob.
|
|
524
|
+
* A refusal restores the head at entry. A rebase check keeps the candidate content available
|
|
525
|
+
* to the worker, with a signed checkpoint; the recorded base moves only after clearance.
|
|
526
|
+
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow, acceptChanged?: string[], reason?: string, rebaseCheck?: boolean}} opts
|
|
527
|
+
* @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
|
|
494
528
|
*/
|
|
495
|
-
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
496
|
-
if (
|
|
529
|
+
export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow, acceptChanged = [], reason, rebaseCheck = false }) {
|
|
530
|
+
if (acceptChanged.length && (typeof reason !== 'string' || !reason.trim())) throw new StateError('usage', '--accept-changed needs a nonblank --reason');
|
|
531
|
+
const block = (await readRun(runId)).blocks[id];
|
|
532
|
+
if (block?.worktree || ['closed', 'awaiting_human_check'].includes(block?.status)) return rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck });
|
|
533
|
+
if (rebaseCheck || acceptChanged.length) throw new StateError('usage', 'rebase clearance requires a worktree block');
|
|
497
534
|
return mutateRun(runId, id, writeRow, async (record, block) => {
|
|
498
|
-
// B62a: a worktree block's base is the commit its tree started from; moving it would leave the
|
|
499
|
-
// tree on its old base, so the rebase is refused
|
|
500
|
-
if (block.worktree) throw new StateError('worktree-rebase', `block ${id} has its own worktree ${block.worktree.path} on branch ${block.worktree.branch}; its base is the base of that tree, so block rebase does not apply — work on the branch in the tree instead`);
|
|
501
535
|
const cwd = record.workspace;
|
|
502
536
|
const newBase = await resolveCommit(head, cwd);
|
|
503
537
|
const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
|
|
@@ -507,7 +541,7 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
507
541
|
if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
|
|
508
542
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
509
543
|
block.base_sha = newBase;
|
|
510
|
-
return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved }] };
|
|
544
|
+
return { result: moved, rows: [{ event: 'block.rebase', block: id, ...moved, changed_reviewed: [], cleared: [] }] };
|
|
511
545
|
});
|
|
512
546
|
}
|
|
513
547
|
|
|
@@ -530,44 +564,91 @@ async function committedBlobs(cwd, ref, files) {
|
|
|
530
564
|
}
|
|
531
565
|
|
|
532
566
|
/**
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* @param {{runId: string, id: string, writeRow: WriteRow}} opts
|
|
536
|
-
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
567
|
+
* Recheck worktree block status under the lock, restore the branch on failure (including a
|
|
568
|
+
* failed signed write), and retain its open/closed/awaiting status.
|
|
569
|
+
* @param {{runId: string, id: string, writeRow: WriteRow, acceptChanged: string[], reason?: string, rebaseCheck: boolean}} opts
|
|
570
|
+
* @returns {Promise<{old_base: string, new_base: string, files?: ReturnType<typeof reviewQueue.enqueue>[], pending?: boolean}>}
|
|
537
571
|
*/
|
|
538
|
-
async function
|
|
572
|
+
async function rebaseWorktreeBlock({ runId, id, writeRow, acceptChanged, reason, rebaseCheck }) {
|
|
539
573
|
return withRunLock(runId, async () => {
|
|
540
574
|
const record = await readRun(runId);
|
|
541
575
|
if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
|
|
542
576
|
const block = record.blocks[id];
|
|
543
|
-
if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
577
|
+
if (!['open', 'closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
578
|
+
const closed = block.status !== 'open';
|
|
544
579
|
if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
|
|
545
580
|
const tree = block.worktree.path;
|
|
546
581
|
const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
|
|
547
582
|
if (branch.stdout.trim() !== `refs/heads/${block.worktree.branch}`) throw new StateError('worktree-rebase', `block ${id}: its worktree is not on branch ${block.worktree.branch}`);
|
|
548
583
|
// A reset must never discard edits made after close, or abort an earlier rebase.
|
|
549
584
|
const status = await git(['git', 'status', '--porcelain'], tree);
|
|
550
|
-
if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
585
|
+
if (status.stdout.length > 0) throw new StateError(closed ? 'rebase-conflict' : 'rebase-dirty', closed ? 'rebase-conflict' : `rebase-dirty: block ${id}: commit or clean first`);
|
|
551
586
|
const oldHead = await resolveCommit('HEAD', tree);
|
|
587
|
+
const key = await loadKey(runId);
|
|
588
|
+
const pending = block.rebase_check;
|
|
589
|
+
if (pending && (!verifyRow(pending, key).ok || pending.run !== runId || pending.block !== id || pending.old_base !== block.base_sha)) {
|
|
590
|
+
throw new StateError('rebase-check-invalid', 'the pending rebase checkpoint is invalid');
|
|
591
|
+
}
|
|
592
|
+
if (pending && (await git(isAncestorArgv(pending.head, oldHead), tree, [0, 1])).code !== 0) {
|
|
593
|
+
throw new StateError('rebase-check-invalid', 'the worktree no longer contains the pending rebase');
|
|
594
|
+
}
|
|
595
|
+
const reviewedHead = pending?.old_head ?? oldHead;
|
|
552
596
|
const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
|
|
553
597
|
const top = await repoTop(record.workspace);
|
|
554
598
|
const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
|
|
555
|
-
const
|
|
556
|
-
const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
|
|
599
|
+
const owns = (/** @type {string} */ file) => (
|
|
557
600
|
ownsFile(block.owned_files, file, block.owned_kinds) ||
|
|
558
601
|
(prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
|
|
559
|
-
)
|
|
560
|
-
|
|
602
|
+
);
|
|
603
|
+
let files = [];
|
|
604
|
+
if (closed) {
|
|
605
|
+
const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, reviewedHead, '--'], tree);
|
|
606
|
+
files = diff.stdout.split('\0').filter((file) => file.length > 0 && owns(file)).sort();
|
|
607
|
+
} else {
|
|
608
|
+
// Inspect every upstream commit, including changes later reverted and both rename paths.
|
|
609
|
+
const upstream = await git(['git', 'log', '--format=', '--no-renames', '--name-only', '-z', '-m', `${block.base_sha}..${newBase}`, '--'], tree);
|
|
610
|
+
const conflicts = [...new Set(upstream.stdout.split('\0').filter((file) => file.length > 0 && owns(file)))].sort();
|
|
611
|
+
if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
|
|
612
|
+
}
|
|
613
|
+
const before = closed ? await committedBlobs(tree, reviewedHead, files) : null;
|
|
561
614
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
562
615
|
try {
|
|
563
616
|
const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
|
|
564
617
|
if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
|
|
565
|
-
const after = await committedBlobs(tree, 'HEAD', files);
|
|
566
|
-
const changed = files.filter((file) => before.get(file) !== after.get(file));
|
|
567
|
-
|
|
618
|
+
const after = before === null ? null : await committedBlobs(tree, 'HEAD', files);
|
|
619
|
+
const changed = after === null ? [] : files.filter((file) => before.get(file) !== after.get(file));
|
|
620
|
+
const repoRoot = await repoRootOf(record.workspace);
|
|
621
|
+
if (rebaseCheck) {
|
|
622
|
+
// The worker reviews the authoritative worktree, so retain this candidate while leaving
|
|
623
|
+
// both bases untouched. Sign the original head so a later retry cannot forget changes.
|
|
624
|
+
block.rebase_check = signRow({ event: 'block.rebase_check', run: runId, block: id, ...moved, old_head: reviewedHead, head: await resolveCommit('HEAD', tree), started_at: pending?.started_at ?? new Date().toISOString() }, key);
|
|
625
|
+
const queued = changed.map((file) => reviewQueue.enqueue({ repoRoot, run: runId, block: id, file, tree, after: block.rebase_check.started_at }));
|
|
626
|
+
await saveRun(record);
|
|
627
|
+
return { ...moved, files: queued, pending: true };
|
|
628
|
+
}
|
|
629
|
+
const invalidAccepted = acceptChanged.filter((file) => !changed.includes(file));
|
|
630
|
+
if (invalidAccepted.length) throw new StateError('usage', `--accept-changed names files not changed by this rebase: ${invalidAccepted.join(', ')}`);
|
|
631
|
+
const rows = pending ? (await readAllRows(record.project)).filter((row) => row.run === runId && row.block === id && verifyRow(row, key).ok && Date.parse(row.ts) >= Date.parse(pending.started_at)) : [];
|
|
632
|
+
/** @type {Array<{file: string, via: string, ticket?: string, reason?: string}>} */
|
|
633
|
+
const cleared = [];
|
|
634
|
+
for (const file of changed) {
|
|
635
|
+
if (acceptChanged.includes(file)) {
|
|
636
|
+
cleared.push({ file, via: 'human', reason });
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
const hash = contentHash(tree, file);
|
|
640
|
+
const ticket = reviewQueue.resolveTicket({ repoRoot, run: runId, block: id, file, tree, after: pending?.started_at }).ticket;
|
|
641
|
+
const relevant = rows.filter((row) => row.file === file && row.content_hash === hash);
|
|
642
|
+
const approval = relevant.reduce((last, row, index) => row.event === 'review.approved' && (row.ticket === undefined || row.ticket === ticket) ? index : last, -1);
|
|
643
|
+
const denied = relevant.slice(approval + 1).some((row) => row.event === 'review.cap' || (row.event === 'review.result' && row.approved === false) || (row.event === 'review.round' && row.open_after > 0));
|
|
644
|
+
if (approval >= 0 && !denied) cleared.push({ file, via: 'review', ticket });
|
|
645
|
+
}
|
|
646
|
+
const uncleared = changed.filter((file) => !cleared.some((entry) => entry.file === file));
|
|
647
|
+
if (uncleared.length) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${uncleared.join(', ')}\nRe-review: code-forge review-file --rebase-check --block ${id} --run ${runId}\nHuman accept (repeat --accept-changed for each file): code-forge block rebase ${id} --run ${runId} --accept-changed <file> --reason <text>`);
|
|
568
648
|
block.base_sha = newBase;
|
|
569
649
|
block.worktree.base_sha = newBase;
|
|
570
|
-
|
|
650
|
+
delete block.rebase_check;
|
|
651
|
+
await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, changed_reviewed: changed, cleared, ...(closed ? { closed: true } : { worktree: true }) });
|
|
571
652
|
await saveRun(record);
|
|
572
653
|
return moved;
|
|
573
654
|
} catch (err) {
|
|
@@ -588,25 +669,33 @@ async function rebaseClosedBlock({ runId, id, writeRow }) {
|
|
|
588
669
|
* B58: with `review.external.required` set, a path that owns the project's config is refused
|
|
589
670
|
* (`config-owned`), as at `block open`. B62a: two blocks that each have their own worktree may
|
|
590
671
|
* own the same path (`blocksOverlap`).
|
|
591
|
-
* @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
|
|
672
|
+
* @param {{runId: string, id: string, file: string, writeRow: WriteRow, auto?: boolean}} opts
|
|
592
673
|
*/
|
|
593
|
-
export async function claimPath({ runId, id, file, writeRow }) {
|
|
674
|
+
export async function claimPath({ runId, id, file, writeRow, auto = false }) {
|
|
594
675
|
assertOwned([file]);
|
|
595
676
|
if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
|
|
596
677
|
const guarded = await guardedConfigPaths(await readRun(runId));
|
|
597
|
-
await mutateRun(runId, id, writeRow, (record, block) => {
|
|
598
|
-
const
|
|
678
|
+
await mutateRun(runId, id, writeRow, async (record, block) => {
|
|
679
|
+
const root = auto ? await repoTop(block.worktree?.path ?? record.workspace) : record.workspace;
|
|
680
|
+
if (auto) {
|
|
681
|
+
if (file.split('/').some((part) => ['.git', '.code-forge'].includes(part.toLowerCase()))) throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim repository state`);
|
|
682
|
+
try { assertInsideRoot(root, file); }
|
|
683
|
+
catch { throw new StateError('unowned_change', `unowned_change ${file}: cannot auto-claim a path outside the repository or a symlink`); }
|
|
684
|
+
}
|
|
685
|
+
const kinds = { [file]: pathKind(root, file) };
|
|
599
686
|
refuseConfigOwner([file], kinds, guarded);
|
|
687
|
+
const prefix = auto ? path.relative(await repoTop(record.workspace), realpathSync(record.workspace)).split(path.sep).join('/') : '';
|
|
600
688
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
601
|
-
const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: block.worktree ?? null }, other);
|
|
602
|
-
|
|
689
|
+
const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: auto ? null : block.worktree ?? null }, other);
|
|
690
|
+
const relativeOwner = auto && otherId !== id && prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(other.owned_files, file.slice(prefix.length + 1), other.owned_kinds);
|
|
691
|
+
if (pair || relativeOwner) throw new StateError(auto ? 'unowned_change' : 'overlap', `${auto ? 'unowned_change ' : ''}${file} overlaps ${pair?.[1] ?? file} owned by open block ${otherId}`);
|
|
603
692
|
}
|
|
604
693
|
if (!block.owned_files.includes(file)) {
|
|
605
694
|
block.owned_files.push(file);
|
|
606
695
|
block.owned_kinds = { ...block.owned_kinds, ...kinds };
|
|
607
696
|
}
|
|
608
697
|
record.orphans = record.orphans.filter((/** @type {string} */ o) => !ownsFile([file], o, kinds));
|
|
609
|
-
return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
|
|
698
|
+
return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file, ...(auto ? { auto: true, reason: 'auto-claim: changed outside owned files' } : {}) }] };
|
|
610
699
|
});
|
|
611
700
|
}
|
|
612
701
|
|
|
@@ -679,9 +768,10 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
|
|
|
679
768
|
|
|
680
769
|
let pin = await checkWorkerPin(record, { livePid, probe });
|
|
681
770
|
const pins = record.workers ?? (record.worker ? [record.worker] : []);
|
|
771
|
+
const reviewPins = [...pins, ...(record.worker_history ?? [])];
|
|
682
772
|
const counted = rows.filter((row) => row.block === id && (row.run === undefined || row.run === runId) && row.event === 'review.result' && !['stale', 'unavailable', 'answers'].includes(row.status) && !(row.status === 'refused' && row.reason === 'other-run'));
|
|
683
773
|
// Ledgers written before B107 omit worker identity and use the run's pin check above.
|
|
684
|
-
if (counted.some((row) => row.worker !== undefined && !
|
|
774
|
+
if (counted.some((row) => row.worker !== undefined && !reviewPins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
|
|
685
775
|
pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
|
|
686
776
|
}
|
|
687
777
|
if (pin.ok) {
|
|
@@ -45,7 +45,7 @@ export const IMMUTABLE_KEYS = Object.freeze({
|
|
|
45
45
|
// B64b: the block-end hook runs a command on the owner's machine at every block end, so only
|
|
46
46
|
// the owner sets it, before the run starts
|
|
47
47
|
'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
|
|
48
|
-
//
|
|
48
|
+
// B171: existing runner entries stay fixed; immutableChanges permits append-only additions.
|
|
49
49
|
'proof.runners': 'the commands that prove a block are fixed by the owner before the run starts',
|
|
50
50
|
// B93: the coder sandbox profile widens what the coder may write, so only the owner sets it,
|
|
51
51
|
// before the run starts (a coder can never own `.code-forge.yml`)
|
|
@@ -138,7 +138,17 @@ function valueAt(cfg, dotted) {
|
|
|
138
138
|
* @returns {string[]}
|
|
139
139
|
*/
|
|
140
140
|
export function immutableChanges(before, after) {
|
|
141
|
-
return Object.keys(IMMUTABLE_KEYS).filter((k) =>
|
|
141
|
+
return Object.keys(IMMUTABLE_KEYS).filter((k) => {
|
|
142
|
+
const oldValue = valueAt(before, k);
|
|
143
|
+
const newValue = valueAt(after, k);
|
|
144
|
+
// Additions go at the end: prepending could replace the first match of an existing runner.
|
|
145
|
+
if (k === 'proof.runners' && Array.isArray(newValue)) {
|
|
146
|
+
const oldRunners = oldValue ?? [];
|
|
147
|
+
if (Array.isArray(oldRunners) && newValue.length >= oldRunners.length
|
|
148
|
+
&& oldRunners.every((entry, i) => canonicalJSON(entry) === canonicalJSON(newValue[i]))) return false;
|
|
149
|
+
}
|
|
150
|
+
return canonicalJSON(oldValue ?? null) !== canonicalJSON(newValue ?? null);
|
|
151
|
+
});
|
|
142
152
|
}
|
|
143
153
|
|
|
144
154
|
/**
|
|
@@ -243,7 +253,7 @@ export function blockEndHook(record) {
|
|
|
243
253
|
}
|
|
244
254
|
|
|
245
255
|
/**
|
|
246
|
-
* B87: `proof.runners` of the
|
|
256
|
+
* B87: `proof.runners` of the snapshot in force (never the live config), as a deep copy
|
|
247
257
|
* (entries and argv arrays), or null when the key is absent or the record is from before
|
|
248
258
|
* snapshots. `proof/runners.mjs` checks each entry again before it runs one (fail closed).
|
|
249
259
|
* @param {Record<string, any>} record
|