@codedology/code-forge 0.14.0 → 0.15.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +136 -438
  3. package/docs/README.md +1 -1
  4. package/docs/reference/blocks.json +420 -1
  5. package/docs/reference/config.md +7 -0
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +22 -0
  8. package/scripts/first-use.mjs +11 -5
  9. package/skill/SKILL.md +2 -0
  10. package/skill/references/autopilot.md +2 -0
  11. package/skill/references/code.md +25 -1
  12. package/skill/references/review.md +10 -2
  13. package/skill/scripts/forge +1 -1
  14. package/src/autopilot/scopes.mjs +2 -1
  15. package/src/cli/block.mjs +235 -44
  16. package/src/cli/gates.mjs +36 -6
  17. package/src/cli/ledger.mjs +21 -2
  18. package/src/cli/report.mjs +14 -0
  19. package/src/cli/review-file.mjs +56 -16
  20. package/src/cli/run.mjs +44 -7
  21. package/src/cli/status.mjs +7 -3
  22. package/src/cli/upgrade.mjs +95 -15
  23. package/src/config/validate.mjs +13 -1
  24. package/src/gates/acceptance.mjs +7 -3
  25. package/src/gates/expand.mjs +25 -2
  26. package/src/gates/run.mjs +38 -8
  27. package/src/install/link.mjs +12 -6
  28. package/src/ledger/report.mjs +29 -7
  29. package/src/review/accept-hit.mjs +22 -4
  30. package/src/review/engine.mjs +30 -10
  31. package/src/review/finding.schema.json +2 -1
  32. package/src/review/fixloop.mjs +79 -14
  33. package/src/review/gate-check.mjs +22 -3
  34. package/src/review/lenses/recheck.md +3 -0
  35. package/src/review/packet.mjs +26 -0
  36. package/src/state/block.mjs +162 -20
  37. package/src/state/run.mjs +35 -8
  38. package/src/util/forbidden.mjs +14 -0
  39. package/src/util/reaper.mjs +3 -3
  40. package/src/worker/engine.mjs +60 -4
  41. package/src/worker/loop.mjs +19 -10
  42. package/src/worker/queue.mjs +5 -2
@@ -91,7 +91,7 @@ import { verifyRow } from '../state/signer.mjs';
91
91
  import { decisionProblem, grantCoverProblem } from './gate-check.mjs';
92
92
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
93
93
  import { parseDiff } from './context.mjs';
94
- import { assemblePacket, assertPacketPath, diffOnlyTokens, readFileDiff } from './packet.mjs';
94
+ import { assemblePacket, assertPacketPath, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
95
95
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
96
96
  import { pairedContext } from './engine.mjs';
97
97
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf } from './sections.mjs';
@@ -101,6 +101,7 @@ import { spawnWithTimeoutRetry } from './session-retry.mjs';
101
101
  import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
102
102
 
103
103
  /** @typedef {import('./triage.mjs').Finding} Finding */
104
+ /** @typedef {Finding & {repeated?: boolean}} LoopFinding */
104
105
  /** @typedef {import('./context.mjs').Hunk} Hunk */
105
106
 
106
107
  /**
@@ -121,12 +122,15 @@ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallba
121
122
  * @property {number} round - rounds counted so far (patch_check rounds excluded).
122
123
  * @property {number} rounds_at_level
123
124
  * @property {number} [warn_rounds_at_level] - heavy rounds at the current level (rule 2c, B34); absent in a state saved before B34 ⇒ 0.
124
- * @property {Finding[]} open - the open `fix_now` set.
125
+ * @property {LoopFinding[]} open - the open `fix_now` set.
125
126
  * @property {Finding[]} late
127
+ * @property {string[]} [finding_history] - claim/evidence keys from earlier rounds of this file.
128
+ * @property {string[]} [finding_ids] - ids seen in earlier rounds, including closed findings.
126
129
  * @property {string | null} reviewed_content - the content the last counted round reviewed.
127
130
  * @property {boolean} l3_rung_used - the block's L3 rung (rule 5 or 6) was used — once per block.
128
131
  * @property {'open' | 'complete' | 'stopped'} status
129
132
  * @property {string} [stop_content_hash] - bytes reviewed by a stop (absent in legacy states).
133
+ * @property {string} [stop_lens] - the lens whose packet stopped before a round.
130
134
  * @property {NextStep | null} next
131
135
  * @property {{context_mode: string, context_lines: number} | null} [last_packet] - the last recheck round's context: one packet's, or over a Markdown recheck's section packets the summed lines and the one mode they share, else `mixed` (B54).
132
136
  * @property {'full' | 'recheck' | 'patch_check' | null} [pending_kind] - the round to re-run after a `retry`.
@@ -262,11 +266,24 @@ export function extraRoundsFor(rows, { runId, block, file, key }) {
262
266
  /**
263
267
  * B47: reopen a file stopped at `review_cap` when its raised cap leaves a round: the state goes
264
268
  * back to `open` with `next: fix` at its level (`trigger: autopilot_extra_round`); the next round
265
- * is a recheck. Any other state is left as it is.
269
+ * is a recheck. B132 also resumes a pre-round split stop when its packet budget changed,
270
+ * preserving the counted rounds and findings. Any other state is left as it is.
266
271
  * @param {FileState} state @param {number} extraRounds @param {Record<string, any> | undefined} cfg
267
272
  * @returns {boolean} whether the state was reopened.
268
273
  */
269
274
  export function reopenForExtraRound(state, extraRounds, cfg) {
275
+ // Only a recheck resumes as a fix; full and patch_check stops keep their round kind.
276
+ // Its packet is measured against full_in, so other lens budgets cannot reopen it.
277
+ if (state.status === 'stopped' && state.next?.reason === 'split_required' && state.stop_lens === 'recheck' &&
278
+ typeof state.next.budget === 'number' && Number.isFinite(state.next.budget) && state.next.budget > 0 &&
279
+ state.next.budget !== budgetFor(cfg, 'full_in')) {
280
+ state.status = 'open';
281
+ state.next = { action: 'fix', level: state.level, trigger: null };
282
+ state.pending_kind = null;
283
+ delete state.stop_content_hash;
284
+ delete state.stop_lens;
285
+ return true;
286
+ }
270
287
  if (state.status !== 'stopped' || state.next?.reason !== 'review_cap') return false;
271
288
  if (state.round >= roundCap(cfg, extraRounds)) return false;
272
289
  state.status = 'open';
@@ -349,8 +366,18 @@ const OPEN_FINDINGS_HEAD = '## open findings\n';
349
366
  /** @param {Finding} f @returns {string} the finding's line in a recheck packet's open-findings list. */
350
367
  export const openFindingLine = (f) => `- ${f.id} (${f.severity}, lines ${f.line_start}-${f.line_end}): ${f.claim ?? ''}`;
351
368
 
369
+ /** @param {Finding} f @returns {string} compare BOTH fields, ignoring only surrounding whitespace. */
370
+ const findingKey = (f) => JSON.stringify([(f.claim ?? '').trim(), (f.evidence ?? '').trim()]);
371
+
372
+ /** @param {string} text @returns {string} */
373
+ const normaliseWhitespace = (text) => text.trim().replace(/\s+/g, ' ');
374
+
352
375
  /** @param {ReadonlyArray<Finding>} open @returns {string} the open-findings list a recheck packet ends with. */
353
- const openFindingsText = (open) => `${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
376
+ const openFindingsText = (open) => {
377
+ // Keep the final id/claim list stable for section readers; evidence is keyed by id just before it.
378
+ const evidence = open.length > 0 ? `## finding evidence\n${open.map((f) => `${f.id}: ${(f.evidence ?? '').trim().replace(/\s+/g, ' ').slice(0, 300)}`).join('\n')}\n` : '';
379
+ return `${evidence}${OPEN_FINDINGS_HEAD}${open.length > 0 ? open.map(openFindingLine).join('\n') : '(none)'}\n`;
380
+ };
354
381
 
355
382
  /**
356
383
  * The size a recheck packet is held to (B54): its diff-only `recheck` packet (the measure behind
@@ -631,10 +658,11 @@ async function stopBeforeRound(state, deps, kind, file, contentHash, packet) {
631
658
  typeof packet.tokensIn === 'number' && typeof packet.budget === 'number'
632
659
  ? { tokens_in: packet.tokensIn, budget: packet.budget, ...(typeof packet.section === 'string' ? { section: packet.section } : {}) }
633
660
  : {};
634
- const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size };
661
+ const row = { event: 'review.cap', file, round: state.round, reason, open: state.open.map((f) => f.id), ...size, lens: kind };
635
662
  if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
636
663
  state.status = 'stopped';
637
664
  state.stop_content_hash = contentHash;
665
+ state.stop_lens = kind;
638
666
  state.next = { action: 'stop', reason, ...size };
639
667
  state.pending_kind = null;
640
668
  return state;
@@ -661,6 +689,14 @@ export async function runRound(state, deps, opts = {}) {
661
689
  let newInHunks = 0;
662
690
  /** @type {Array<Record<string, any>>} */
663
691
  const lateRows = [];
692
+ /** @type {Array<Record<string, any>>} */
693
+ const staleRows = [];
694
+ const history = new Set([...(state.finding_history ?? []), ...state.open.map(findingKey), ...state.late.map(findingKey)]);
695
+ const knownIds = new Set([...(state.finding_ids ?? []), ...state.open.map((f) => f.id), ...state.late.map((f) => f.id)]);
696
+ const roundFindings = new Set();
697
+ const roundIds = new Set();
698
+ /** @type {Set<string>} */
699
+ const repeated = new Set();
664
700
  /** @type {number | null} */
665
701
  let tokensIn = null;
666
702
  /** @type {number | null} */
@@ -671,6 +707,10 @@ export async function runRound(state, deps, opts = {}) {
671
707
  if (kind === 'full') {
672
708
  const result = await deps.review();
673
709
  if (result?.status !== 'reviewed') return retry(state, kind, result?.reason ?? result?.status ?? 'unavailable');
710
+ for (const finding of result.findings ?? []) {
711
+ roundFindings.add(findingKey(finding));
712
+ roundIds.add(finding.id);
713
+ }
674
714
  const triaged = await triageFindings({ file: current.rel, findings: result.findings ?? [], fromJudge: cameFromJudge(result), cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
675
715
  draft.open = triaged.fix_now;
676
716
  newInHunks = triaged.fix_now.length;
@@ -727,6 +767,9 @@ export async function runRound(state, deps, opts = {}) {
727
767
  }
728
768
  /** @type {Set<Finding>} the pending findings a session that LISTED them resolved */
729
769
  const resolved = new Set();
770
+ /** @type {Set<Finding>} */
771
+ const stale = new Set();
772
+ const currentText = normaliseWhitespace(current.content);
730
773
  /** @type {Finding[]} */
731
774
  const inside = [];
732
775
  /** @type {Finding[]} */
@@ -742,11 +785,30 @@ export async function runRound(state, deps, opts = {}) {
742
785
  return retry(state, kind, session.verdict.reason ?? 'unavailable');
743
786
  }
744
787
  const review = /** @type {Record<string, any>} */ (session.verdict.review);
745
- const resolvedIds = new Set((review.resolved ?? []).filter((/** @type {any} */ r) => r?.resolved === true).map((/** @type {any} */ r) => r.id));
746
- // only the findings listed in THIS packet can be resolved by its session
747
- for (const f of part.open) if (resolvedIds.has(f.id)) resolved.add(f);
788
+ // Only this packet's findings can close here. Legacy answers without a quote stay
789
+ // open; a nonempty quote that no longer exists in the file proves the answer stale.
790
+ for (const f of part.open) {
791
+ const answer = (review.resolved ?? []).find((/** @type {any} */ r) => r.id === f.id);
792
+ if (answer?.resolved === true) {
793
+ resolved.add(f);
794
+ continue;
795
+ }
796
+ const quote = normaliseWhitespace(answer?.current_lines ?? '');
797
+ const reason = quote.length > 0 && !currentText.includes(quote) ? 'current_lines_not_found' : null;
798
+ if (reason) {
799
+ stale.add(f);
800
+ staleRows.push({ event: 'review.stale_finding', file: current.rel, round: state.round + 1, finding: f.id, reason });
801
+ }
802
+ }
748
803
  const found = /** @type {Finding[]} */ (review.findings ?? []);
749
804
  for (const f of part.index === null ? found : sectionFindings(found, part.index)) {
805
+ const key = findingKey(f);
806
+ roundFindings.add(key);
807
+ roundIds.add(f.id);
808
+ if (kind === 'recheck' && !knownIds.has(f.id) && history.has(key)) {
809
+ Object.assign(f, { repeated: true });
810
+ repeated.add(f.id);
811
+ }
750
812
  if (insideHunks(f, diff.hunks)) {
751
813
  inside.push(f);
752
814
  continue;
@@ -756,8 +818,7 @@ export async function runRound(state, deps, opts = {}) {
756
818
  if (s.late === 'block' && f.severity !== 'nit') blocking.push(f);
757
819
  }
758
820
  }
759
- // every pending finding stays open unless the session that listed it resolved it
760
- const stillOpen = pending.filter((f) => !resolved.has(f));
821
+ const stillOpen = pending.filter((f) => !resolved.has(f) && !stale.has(f));
761
822
  closed += pending.length - stillOpen.length;
762
823
  const triaged = await triageFindings({ file: current.rel, findings: inside, fromJudge: false, diffText: diff.diffText, cfg: deps.cfg, jev: deps.jev, rule: deps.rule, writeRow: deps.writeRow });
763
824
  newInHunks = triaged.fix_now.length;
@@ -767,11 +828,12 @@ export async function runRound(state, deps, opts = {}) {
767
828
  // A patch_check with no fix hunks closed nothing: B73 still stops it.
768
829
  }
769
830
 
831
+ const unweighted = kind !== 'full' && (staleRows.length > 0 || repeated.size > 0 || draft.open.some((f) => f.repeated)) && draft.open.every((f) => f.repeated);
770
832
  if (kind !== 'patch_check') {
771
833
  draft.round += 1;
772
- draft.rounds_at_level += 1;
834
+ if (!unweighted) draft.rounds_at_level += 1;
773
835
  // THIS round's open set, after triage: only the warnings still open count
774
- if (heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
836
+ if (!unweighted && heavyRound(draft.open, s.warnThreshold)) draft.warn_rounds_at_level = (draft.warn_rounds_at_level ?? 0) + 1;
775
837
  } else {
776
838
  // the block continues at L2 after the rung with a fresh round counter (§3.6) — also here, not
777
839
  // only in `converge`, because the worker runs the patch_check from its own ticket
@@ -779,6 +841,8 @@ export async function runRound(state, deps, opts = {}) {
779
841
  draft.warn_rounds_at_level = 0;
780
842
  }
781
843
  draft.reviewed_content = current.content;
844
+ if (history.size > 0 || roundFindings.size > 0) draft.finding_history = [...new Set([...history, ...roundFindings])];
845
+ if (knownIds.size > 0 || roundIds.size > 0) draft.finding_ids = [...new Set([...knownIds, ...roundIds])];
782
846
  draft.last_packet = packetInfo;
783
847
  const roundRow = {
784
848
  event: 'review.round',
@@ -794,6 +858,7 @@ export async function runRound(state, deps, opts = {}) {
794
858
  tokens_in: tokensIn,
795
859
  tokens_out: tokensOut,
796
860
  ...(packetInfo ? { context_mode: packetInfo.context_mode } : {}),
861
+ ...(repeated.size > 0 ? { repeated: [...repeated] } : {}),
797
862
  };
798
863
 
799
864
  if (draft.open.length === 0) {
@@ -805,13 +870,13 @@ export async function runRound(state, deps, opts = {}) {
805
870
  // closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
806
871
  const after = new Set(draft.open.map((f) => f.id));
807
872
  const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
808
- draft.next = decideNext(draft, openBefore, s, kind, closedIds);
873
+ draft.next = unweighted && kind !== 'patch_check' ? { action: 'fix', level: draft.level, trigger: null } : decideNext(draft, openBefore, s, kind, closedIds);
809
874
  if (draft.next.action === 'stop') {
810
875
  draft.status = 'stopped';
811
876
  draft.stop_content_hash = current.hash;
812
877
  }
813
878
  }
814
- const rows = [...lateRows, roundRow];
879
+ const rows = [...staleRows, ...lateRows, roundRow];
815
880
  if (draft.status === 'stopped') rows.push({ event: 'review.cap', file: current.rel, round: draft.round, reason: draft.next.reason, open: draft.open.map((f) => f.id) });
816
881
  for (const row of rows) {
817
882
  if (!(await mustNote(deps, row))) return retry(state, kind, 'ledger_write_failed');
@@ -237,6 +237,24 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
237
237
  return { ok: refusals.length === 0, refusals, waivedAll: refusals.length === 0 ? waivedAll : [], ...(acceptedHits.length > 0 ? { acceptedHits } : {}) };
238
238
  }
239
239
 
240
+ /** B138: delegation never turns a waived or answered file into an approval.
241
+ * @param {Parameters<typeof checkBlockReviews>[0]} opts
242
+ * @returns {string | null}
243
+ */
244
+ export function routineCloseProblem(opts) {
245
+ const mine = opts.rows.filter((r) => r.block === opts.block && r.run === opts.runId);
246
+ if (mine.some((r) => typeof r.event === 'string' && r.event.includes('waiv'))) return 'waiver rows for block';
247
+ if (mine.some((r) => r.event === 'rule_break' || r.event === 'rule_break.accepted')) return 'rule_break or accepted hit for block';
248
+ const hits = acceptedTranscriptHits({ ...opts, transcript: opts.transcript ?? null, rows: [] }).remaining;
249
+ if (hits.length > 0) return `rule_break transcript line ${hits[0].line}: ${hits[0].id}`;
250
+ if (!mine.some((r) => r.event === 'gate.done' && r.all_ok === true && verified(r, opts.key))) return 'no signed green gate row';
251
+ const result = checkBlockReviews(opts);
252
+ if (!result.ok) return result.refusals.map((r) => `${r.code}${r.file ? ` ${r.file}` : ''}`).join('; ');
253
+ if (result.waivedAll.length > 0) return 'reviewed_by_waiver or answered-only file';
254
+ if (opts.files.length === 0) return 'no approved changed files';
255
+ return null;
256
+ }
257
+
240
258
  /** B71: the signed row the worker writes for a finding a reviewer judged resolved by another owned file. */
241
259
  export const ANSWERED_EVENT = 'review.answered';
242
260
 
@@ -639,15 +657,16 @@ export function openFindings(findings) {
639
657
  * B52: the signed result files {@link denialOf} needs — one per `review.result` row of the block
640
658
  * that has no `approved` field (an old ledger) and is a verdict — keyed by ticket. A file that is
641
659
  * missing, unparsable, names another run or ticket, or fails its MAC is null.
642
- * @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>}} opts
660
+ * @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>, includeCurrent?: boolean}} opts
643
661
  * `repoRoot`: the repository root the worker writes `.code-forge/reviews/` under.
662
+ * `includeCurrent`: B138 decision lists also need claims from modern signed results.
644
663
  * @returns {Promise<Record<string, ResultFile>>}
645
664
  */
646
- export async function resultFilesFor({ repoRoot, runId, block, rows }) {
665
+ export async function resultFilesFor({ repoRoot, runId, block, rows, includeCurrent = false }) {
647
666
  /** @type {Record<string, ResultFile>} */
648
667
  const out = {};
649
668
  for (const r of rows) {
650
- if (r?.event !== 'review.result' || r.block !== block || Object.hasOwn(r, 'approved') || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
669
+ if (r?.event !== 'review.result' || r.block !== block || (!includeCurrent && Object.hasOwn(r, 'approved')) || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
651
670
  try {
652
671
  const checked = await verifyResult(repoRoot, runId, r.ticket);
653
672
  out[r.ticket] = checked.ok && checked.result ? { status: checked.result.status, approved: checked.result.approved, findings: checked.result.findings } : null;
@@ -10,6 +10,9 @@ that changed since that round, each with a window of the current file around it.
10
10
  - For every open finding, add one entry to `resolved`: `{id, resolved: true|false, why}`
11
11
  (`why` ≤ 200 chars). `resolved: true` only when the shown lines fix the problem without
12
12
  introducing a new one.
13
+ - For `resolved: false`, you MUST add `current_lines` (≤ 300 chars): quote the exact current
14
+ line(s) from the packet that still show the problem, without line-number prefixes. Read the
15
+ changed lines; the old finding's claim and evidence alone cannot keep it open.
13
16
  - A new problem goes in `findings` ONLY when its `line_start..line_end` lies inside a shown fix
14
17
  hunk. Anything outside the hunks is not yours to report in this round.
15
18
  - `passed` is true only when every open finding is resolved and you raised no new `critical`
@@ -223,6 +223,32 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
223
223
  return { status: 'ok', text, hunkHeaders, contextMode: ctx.mode, contextLines: ctx.lineCount, digestLines: digest.length, tokensIn, budget: limit, overBudget: tokensIn > limit };
224
224
  }
225
225
 
226
+ /** One full-budget packet for a light block; every diff and path heading is retained.
227
+ * @param {{diffs: FileDiff[], cfg?: Record<string, any>, rulesDigest?: string, factsExcerpt?: string}} opts
228
+ */
229
+ export function assembleLightPacket({ diffs, cfg, rulesDigest = '', factsExcerpt = '' }) {
230
+ const lens = `${loadLens('full')}\nReview ALL files below in one round. Findings must name their exact file path. Echo every listed hunk in file order, including duplicates.`;
231
+ const diffText = diffs.map((diff) => `### ${diff.file}\n${diffSectionOf(diff)}`).join('\n\n');
232
+ const budget = budgetFor(cfg, 'full_in');
233
+ let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
234
+ const context = (mode) => diffs.map((diff) => `### ${diff.file}\n${buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode }).text}`).join('\n\n');
235
+ let contextMode = 'auto';
236
+ let ctx = context(contextMode);
237
+ let text = render(lens, digest, factsExcerpt, ctx, diffText);
238
+ if (estimateTokens(text) > budget) {
239
+ contextMode = 'minimal';
240
+ ctx = context(contextMode);
241
+ text = render(lens, digest, factsExcerpt, ctx, diffText);
242
+ }
243
+ while (estimateTokens(text) > budget && digest.length > 0) {
244
+ digest = digest.slice(0, -1);
245
+ text = render(lens, digest, factsExcerpt, ctx, diffText);
246
+ }
247
+ const tokensIn = estimateTokens(text);
248
+ return { status: tokensIn > budget ? 'split_required' : 'ok', text, tokensIn, budget,
249
+ contextMode, hunkHeaders: diffs.flatMap((diff) => diff.hunks.map((h) => h.header)) };
250
+ }
251
+
226
252
  /** Cap the entire section at full_in / 16 tokens, without cutting a UTF-8 character.
227
253
  * @param {string} paired @param {number} fullIn @returns {string}
228
254
  */
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { createHash, randomUUID } from 'node:crypto';
15
- import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
15
+ import { createReadStream, existsSync, lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync } from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
@@ -25,6 +25,8 @@ 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
27
  import { verifyRow, loadKey } from './signer.mjs';
28
+ import * as reviewQueue from '../worker/queue.mjs';
29
+ import { repoRootOf } from '../worker/ticket.mjs';
28
30
 
29
31
  /** @typedef {import('./run.mjs').WriteRow} WriteRow */
30
32
 
@@ -216,16 +218,18 @@ export function briefPointer(relPath, content) {
216
218
 
217
219
  /**
218
220
  * @param {unknown} clauses
219
- * @returns {{clause: string, tests: string[]}[]}
221
+ * @returns {import('../gates/acceptance.mjs').AcceptanceClause[]}
220
222
  */
221
223
  function assertAcceptance(clauses) {
222
224
  const ok =
223
225
  Array.isArray(clauses) &&
224
226
  clauses.length > 0 &&
225
227
  clauses.every(
226
- (c) => typeof c?.clause === 'string' && c.clause.length > 0 && Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0),
228
+ (c) => typeof c?.clause === 'string' && c.clause.length > 0 && (c.kind === 'human_check'
229
+ ? !Object.hasOwn(c, 'tests')
230
+ : Array.isArray(c.tests) && c.tests.length > 0 && c.tests.every((t) => typeof t === 'string' && t.length > 0)),
227
231
  );
228
- if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} is required');
232
+ if (!ok) throw new StateError('bad-acceptance', 'acceptance: a non-empty list of {clause, tests: [test ids…]} or {clause, kind: human_check} without tests is required');
229
233
  return clauses;
230
234
  }
231
235
 
@@ -315,6 +319,7 @@ function refuseConfigOwner(owned, kinds, guarded) {
315
319
  * and the dispatch row carry the floor level, and the row adds `requested_level` and
316
320
  * `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
317
321
  * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
322
+ * B134: `base_branch` is the workspace's current branch, or null when detached.
318
323
  * B58: with `review.external.required` set, an owned entry that owns the project's config is
319
324
  * refused (`config-owned`, {@link refuseConfigOwner}).
320
325
  * B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
@@ -352,12 +357,13 @@ export async function openBlock(opts) {
352
357
  if (typeof level !== 'string' || !LEVEL.test(level)) throw new StateError('bad-level', 'level must be L0..L3');
353
358
  if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
354
359
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
355
- if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
360
+ 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(', ')}`);
356
361
  if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
357
362
  const owned = assertOwned(opts.owned);
358
363
  const acceptance = assertAcceptance(opts.acceptance);
364
+ const humanChecks = acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
359
365
  const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
360
- const kind = blockKind({ owned, declared: opts.kind });
366
+ const kind = opts.kind === 'light' ? 'light' : blockKind({ owned, declared: opts.kind });
361
367
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
362
368
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
363
369
 
@@ -384,6 +390,7 @@ export async function openBlock(opts) {
384
390
  try {
385
391
  const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
386
392
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
393
+ if (record.blocks[id]?.status === 'awaiting_human_check') throw new StateError('already-open', `block ${id} is awaiting a human check`);
387
394
  // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
388
395
  // file owns only itself, here and in every later check
389
396
  const kinds = ownedKinds(record.workspace, owned);
@@ -391,6 +398,8 @@ export async function openBlock(opts) {
391
398
  const prior = previousTree(record);
392
399
  const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
393
400
  if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
401
+ const branch = await git(['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], record.workspace, [0, 1]);
402
+ const baseBranch = branch.code === 0 ? branch.stdout.trim() : null;
394
403
  // B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
395
404
  const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
396
405
  const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
@@ -402,7 +411,8 @@ export async function openBlock(opts) {
402
411
  // passed; a tree made here is rolled back below if the rows or the save then fail
403
412
  made = planned ? await planned.make() : null;
404
413
  const tree = made;
405
- const entry = { block: id, base_sha: baseSha, 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 } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
414
+ 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 } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
415
+ if (humanChecks.length > 0) Object.assign(entry, { human_checks: humanChecks });
406
416
  record.blocks[id] = entry;
407
417
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
408
418
  const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
@@ -411,7 +421,7 @@ export async function openBlock(opts) {
411
421
  rows: [
412
422
  ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
413
423
  ...created,
414
- { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
424
+ { 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 } : {}) },
415
425
  ],
416
426
  };
417
427
  });
@@ -444,11 +454,14 @@ export async function attemptBlock({ runId, id, writeRow }) {
444
454
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
445
455
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
446
456
  * is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
447
- * below it). B62a: refused for a block with its own worktree (`worktree-rebase`).
457
+ * below it). B62a: refused for an open block with its own worktree (`worktree-rebase`).
458
+ * B134: a closed worktree block instead rebases its branch onto its recorded base branch,
459
+ * retaining every changed owned file's blob; a conflict or changed blob restores the old head.
448
460
  * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
449
461
  * @returns {Promise<{old_base: string, new_base: string}>}
450
462
  */
451
463
  export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
464
+ if (['closed', 'awaiting_human_check'].includes((await readRun(runId)).blocks[id]?.status)) return rebaseClosedBlock({ runId, id, writeRow });
452
465
  return mutateRun(runId, id, writeRow, async (record, block) => {
453
466
  // B62a: a worktree block's base is the commit its tree started from; moving it would leave the
454
467
  // tree on its old base, so the rebase is refused
@@ -466,6 +479,75 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
466
479
  });
467
480
  }
468
481
 
482
+ /**
483
+ * Hash committed paths, with null for a deletion. NUL-delimited tree entries preserve unusual
484
+ * file names; filter exact names because git's path selection may also return descendants.
485
+ * @param {string} cwd @param {string} ref @param {string[]} files
486
+ * @returns {Promise<Map<string, string | null>>}
487
+ */
488
+ async function committedBlobs(cwd, ref, files) {
489
+ const hashes = new Map(files.map((file) => [file, /** @type {string | null} */ (null)]));
490
+ if (files.length === 0) return hashes;
491
+ const tree = await git(['git', 'ls-tree', '-r', '-z', '--full-tree', ref, '--', ...files], cwd);
492
+ for (const entry of tree.stdout.split('\0').filter(Boolean)) {
493
+ const tab = entry.indexOf('\t');
494
+ const file = entry.slice(tab + 1);
495
+ if (hashes.has(file)) hashes.set(file, entry.slice(0, tab).split(' ')[2]);
496
+ }
497
+ return hashes;
498
+ }
499
+
500
+ /**
501
+ * Closed blocks have a separate mutation path: check their status again under the lock,
502
+ * restore the branch on failure (including a failed signed write), and retain closed/awaiting status.
503
+ * @param {{runId: string, id: string, writeRow: WriteRow}} opts
504
+ * @returns {Promise<{old_base: string, new_base: string}>}
505
+ */
506
+ async function rebaseClosedBlock({ runId, id, writeRow }) {
507
+ return withRunLock(runId, async () => {
508
+ const record = await readRun(runId);
509
+ if (record.status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
510
+ const block = record.blocks[id];
511
+ if (!['closed', 'awaiting_human_check'].includes(block?.status) || !block.worktree) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
512
+ if (!block.base_branch) throw new StateError('rebase-no-base', `block ${id} has no recorded base branch`);
513
+ const tree = block.worktree.path;
514
+ const branch = await git(['git', 'symbolic-ref', '--quiet', 'HEAD'], tree, [0, 1]);
515
+ 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}`);
516
+ // A reset must never discard edits made after close, or abort an earlier rebase.
517
+ const status = await git(['git', 'status', '--porcelain'], tree);
518
+ if (status.stdout.length > 0) throw new StateError('rebase-conflict', 'rebase-conflict');
519
+ const oldHead = await resolveCommit('HEAD', tree);
520
+ const newBase = await resolveCommit(`refs/heads/${block.base_branch}`, record.workspace);
521
+ const top = await repoTop(record.workspace);
522
+ const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
523
+ const diff = await git(['git', 'diff', '--no-renames', '--name-only', '-z', block.base_sha, oldHead, '--'], tree);
524
+ const files = diff.stdout.split('\0').filter((file) => file.length > 0 && (
525
+ ownsFile(block.owned_files, file, block.owned_kinds) ||
526
+ (prefix !== '' && file.startsWith(`${prefix}/`) && ownsFile(block.owned_files, file.slice(prefix.length + 1), block.owned_kinds))
527
+ )).sort();
528
+ const before = await committedBlobs(tree, oldHead, files);
529
+ const moved = { old_base: block.base_sha, new_base: newBase };
530
+ try {
531
+ const rebased = await exec(['git', '-C', tree, 'rebase', newBase], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
532
+ if (rebased.result !== 'ok') throw new StateError('rebase-conflict', 'rebase-conflict');
533
+ const after = await committedBlobs(tree, 'HEAD', files);
534
+ const changed = files.filter((file) => before.get(file) !== after.get(file));
535
+ if (changed.length > 0) throw new StateError('rebase-changed-reviewed', `rebase-changed-reviewed ${changed.join(', ')}`);
536
+ block.base_sha = newBase;
537
+ block.worktree.base_sha = newBase;
538
+ await writeSigned(runId, writeRow, { event: 'block.rebase', block: id, ...moved, closed: true });
539
+ await saveRun(record);
540
+ return moved;
541
+ } catch (err) {
542
+ // --abort restores a conflicted rebase; after a completed rebase there is nothing to
543
+ // abort, and reset restores the old branch head. The tree was clean before either.
544
+ await exec(['git', '-C', tree, 'rebase', '--abort'], { cwd: record.workspace, env: gitEnv(), timeoutMs: 30000 });
545
+ await git(['git', 'reset', '--hard', oldHead], tree);
546
+ throw err;
547
+ }
548
+ });
549
+ }
550
+
469
551
  /**
470
552
  * `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
471
553
  * glob, and when another open block owns it — or anything it would own: the claimed path becomes an
@@ -513,13 +595,35 @@ export async function checkScope({ runId, id, files, writeRow }) {
513
595
 
514
596
  /** @param {{runId: string, id: string, reason: string, writeRow: WriteRow, event?: string}} opts */
515
597
  export async function stopBlock({ runId, id, reason, writeRow, event = 'block.stop' }) {
516
- await mutateRun(runId, id, writeRow, (_record, block) => {
598
+ await mutateRun(runId, null, writeRow, (record) => {
599
+ const block = record.blocks[id];
600
+ if (!['open', 'awaiting_human_check'].includes(block?.status)) throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
517
601
  block.status = 'stopped';
518
602
  block.stop_reason = reason;
519
603
  return { result: undefined, rows: [{ event, block: id, reason }] };
520
604
  });
521
605
  }
522
606
 
607
+ /**
608
+ * Called only after the CLI's terminal confirmation; recheck under the lock before signing.
609
+ * @param {{runId: string, id: string, note: string, proof?: string | null, writeRow: WriteRow, now?: Date}} opts
610
+ */
611
+ export async function humanPassBlock({ runId, id, note, proof = null, writeRow, now = new Date() }) {
612
+ return mutateRun(runId, null, writeRow, (record) => {
613
+ const block = record.blocks[id];
614
+ if (block?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
615
+ if (typeof note !== 'string' || note.trim().length === 0) throw new StateError('usage', 'block human-pass needs --note <text>');
616
+ if (proof !== null && (typeof proof !== 'string' || !existsSync(proof))) throw new StateError('usage', '--proof must name an existing directory or file');
617
+ const grant = record.autopilot;
618
+ if (grant && !['stopped', 'expired'].includes(grant.status) && !(now.getTime() >= Date.parse(grant.until))) {
619
+ throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
620
+ }
621
+ const row = { event: 'block.human_pass', block: id, note, proof, checks: [...block.human_checks], by: 'human' };
622
+ block.status = 'closed';
623
+ return { result: row, rows: [row] };
624
+ });
625
+ }
626
+
523
627
  /**
524
628
  * B8's rows of the block gate, in order: worker pin ⇒ `worker.replaced`/`worker.down` (block
525
629
  * stopped); the block's signed `dispatch` row must exist and verify, and every other
@@ -533,16 +637,44 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
533
637
  * livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
534
638
  * extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
535
639
  * closeRows?: Record<string, any>[],
640
+ * dryRun?: boolean,
536
641
  * }} opts
537
- * @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
642
+ * @returns {Promise<{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}>}
538
643
  */
539
- export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
644
+ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [], dryRun = false }) {
540
645
  const record = await readRun(runId);
541
646
  if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
542
647
 
543
- const pin = await checkWorkerPin(record, { livePid, probe });
648
+ let pin = await checkWorkerPin(record, { livePid, probe });
649
+ const pins = record.workers ?? (record.worker ? [record.worker] : []);
650
+ 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'));
651
+ // Ledgers written before B107 omit worker identity and use the run's pin check above.
652
+ if (counted.some((row) => row.worker !== undefined && !pins.some((p) => p.pid === row.worker?.pid && p.started_at === row.worker?.started_at))) {
653
+ pin = { ok: false, event: 'worker.replaced', reason: 'a counted review result does not name a pinned worker' };
654
+ }
655
+ if (pin.ok) {
656
+ const repoRoot = await repoRootOf(record.workspace);
657
+ let names;
658
+ try { names = readdirSync(reviewQueue.queueDir(repoRoot)); }
659
+ catch (err) { if (err.code !== 'ENOENT') throw err; names = []; }
660
+ for (const name of names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n))) {
661
+ const ticket = name.slice(0, 24);
662
+ const queued = reviewQueue.readTicket(repoRoot, ticket);
663
+ if (queued.run !== runId || queued.block !== id) continue;
664
+ const claimKey = `ticket\0${runId}\0${ticket}`;
665
+ const file = path.join(os.homedir(), '.code-forge', 'locks', `${createHash('sha256').update(claimKey).digest('hex')}.lock`);
666
+ let claim;
667
+ try { claim = JSON.parse(readFileSync(file, 'utf8')); }
668
+ catch (err) { if (err.code === 'ENOENT') continue; throw err; }
669
+ const owner = pins.find((p) => p.pid === claim.pid && p.started_at === claim.start);
670
+ if (owner && !(await checkWorkerPin({ worker: owner }, { probe })).ok) {
671
+ pin = { ok: false, event: 'worker.down', reason: `dead pinned worker pid ${owner.pid} holds ticket ${ticket}` };
672
+ break;
673
+ }
674
+ }
675
+ }
544
676
  if (!pin.ok) {
545
- await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
677
+ if (!dryRun) await stopBlock({ runId, id, reason: pin.reason, writeRow, event: pin.event });
546
678
  return { ok: false, status: 'stopped', event: pin.event, reason: pin.reason };
547
679
  }
548
680
 
@@ -555,25 +687,35 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
555
687
  const reasons = bad.map((x) => `${x.row.event}: ${x.result.reason}`);
556
688
  if (!dispatchOk) reasons.push('no verified dispatch row');
557
689
  const reason = reasons.join('; ');
558
- await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
690
+ if (!dryRun) await stopBlock({ runId, id, reason, writeRow, event: 'ledger.tamper' });
559
691
  return { ok: false, status: 'stopped', event: 'ledger.tamper', reason };
560
692
  }
561
693
 
694
+ const failures = [];
562
695
  for (const check of extraChecks) {
563
696
  const result = await check();
564
- if (!result.ok) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
697
+ if (!result.ok) {
698
+ if (!dryRun) return { ok: false, status: 'open', reason: result.reason ?? 'check failed' };
699
+ failures.push(result.reason ?? 'check failed');
700
+ }
701
+ }
702
+ if (dryRun) {
703
+ if (record.orphans.length > 0) failures.push(`orphans: ${record.orphans.join(', ')}`);
704
+ return { ok: failures.length === 0, status: 'open', ...(failures.length ? { reason: failures.join('; ') } : {}) };
565
705
  }
566
706
 
567
- /** @typedef {{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
707
+ /** @typedef {{ok: boolean, status: 'closed' | 'awaiting_human_check' | 'stopped' | 'open', reason?: string, event?: string}} CloseResult */
568
708
  return mutateRun(runId, id, writeRow, (current, block) => {
569
709
  if (current.orphans.length > 0) {
570
710
  /** @type {CloseResult} */
571
711
  const refused = { ok: false, status: 'open', reason: `orphans: ${current.orphans.join(', ')}` };
572
712
  return { result: refused, rows: [] };
573
713
  }
574
- block.status = 'closed';
714
+ const checks = block.human_checks ?? [];
715
+ block.status = checks.length > 0 ? 'awaiting_human_check' : 'closed';
575
716
  /** @type {CloseResult} */
576
- const closed = { ok: true, status: 'closed' };
577
- return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }] };
717
+ const closed = { ok: true, status: block.status };
718
+ return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' },
719
+ ...(checks.length > 0 ? [{ event: 'block.awaiting_human_check', block: id, checks }] : [])] };
578
720
  });
579
721
  }