@codedology/code-forge 0.15.1 → 0.15.3

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 (63) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +154 -1
  5. package/docs/reference/config.md +27 -14
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +57 -2
  8. package/schema/outcome.schema.json +45 -0
  9. package/schema/pipeline.schema.json +30 -0
  10. package/scripts/first-use.mjs +8 -0
  11. package/scripts/gen-config-doc.mjs +1 -1
  12. package/skill/references/code.md +15 -0
  13. package/skill/references/ledger.md +13 -1
  14. package/skill/scripts/forge +1 -1
  15. package/src/cli/block.mjs +146 -30
  16. package/src/cli/context.mjs +17 -7
  17. package/src/cli/gates.mjs +76 -10
  18. package/src/cli/help.mjs +4 -0
  19. package/src/cli/outcome.mjs +87 -0
  20. package/src/cli/pipeline.mjs +61 -0
  21. package/src/cli/proof.mjs +10 -7
  22. package/src/cli/report.mjs +53 -6
  23. package/src/cli/review-file.mjs +168 -38
  24. package/src/cli/run.mjs +3 -2
  25. package/src/cli/sweep.mjs +28 -0
  26. package/src/cli/validate.mjs +6 -0
  27. package/src/config/schema-compile.mjs +6 -4
  28. package/src/config/validate.mjs +83 -1
  29. package/src/context/pack.mjs +53 -3
  30. package/src/doctor/local.mjs +22 -4
  31. package/src/engines/builders/codex.mjs +27 -7
  32. package/src/engines/builders/grok.mjs +11 -5
  33. package/src/engines/codex-home.mjs +18 -15
  34. package/src/engines/sentinel.mjs +11 -0
  35. package/src/gates/detect.mjs +6 -3
  36. package/src/gates/run.mjs +295 -27
  37. package/src/ledger/export.mjs +1 -1
  38. package/src/ledger/report.mjs +143 -5
  39. package/src/outcome/outcome.mjs +108 -0
  40. package/src/pipeline/pipeline.mjs +112 -0
  41. package/src/proof/red-green.mjs +41 -13
  42. package/src/proof/runners.mjs +28 -4
  43. package/src/proof/worktree.mjs +25 -15
  44. package/src/review/agent-waiver.mjs +18 -4
  45. package/src/review/engine.mjs +25 -5
  46. package/src/review/gate-check.mjs +39 -1
  47. package/src/review/lenses/full.md +1 -0
  48. package/src/review/lenses/quick.md +1 -0
  49. package/src/review/packet.mjs +34 -6
  50. package/src/review/related.mjs +112 -0
  51. package/src/review/reopen.mjs +4 -3
  52. package/src/review/session-retry.mjs +59 -0
  53. package/src/session/facts.mjs +18 -4
  54. package/src/session/spawn.mjs +54 -3
  55. package/src/state/block.mjs +101 -34
  56. package/src/state/config-snapshot.mjs +13 -3
  57. package/src/state/run.mjs +3 -2
  58. package/src/util/exec.mjs +48 -31
  59. package/src/util/reaper.mjs +238 -8
  60. package/src/worker/engine.mjs +7 -3
  61. package/src/worker/loop.mjs +67 -14
  62. package/src/worker/queue.mjs +106 -23
  63. package/src/worker/watch.mjs +2 -2
@@ -0,0 +1,30 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://code-forge.dev/schema/pipeline.schema.json",
4
+ "title": "Specialist skill pipeline",
5
+ "type": "object",
6
+ "properties": {
7
+ "root": { "type": "string" },
8
+ "steps": {
9
+ "type": "array",
10
+ "minItems": 1,
11
+ "items": {
12
+ "type": "object",
13
+ "properties": {
14
+ "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
15
+ "skill": { "type": "string" },
16
+ "needs": { "type": "array", "items": { "type": "string" }, "default": [] },
17
+ "outcome": {
18
+ "type": "string",
19
+ "pattern": "^(?![\\\\/])(?![A-Za-z]:)(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+$"
20
+ },
21
+ "approval": { "enum": ["human", "none"] }
22
+ },
23
+ "required": ["id", "skill", "outcome", "approval"],
24
+ "additionalProperties": false
25
+ }
26
+ }
27
+ },
28
+ "required": ["root", "steps"],
29
+ "additionalProperties": false
30
+ }
@@ -50,7 +50,9 @@ import { fileURLToPath } from 'node:url';
50
50
  import { parse as parseYAML, parseDocument } from 'yaml';
51
51
  import { readAllRows } from '../src/ledger/write.mjs';
52
52
  import { estimateCostUsd } from '../src/ledger/prices.mjs';
53
+ import { readRun } from '../src/state/run.mjs';
53
54
  import { exec } from '../src/util/exec.mjs';
55
+ import { stopMembers } from '../src/util/reaper.mjs';
54
56
  import { runRoot, setRunRoot, tmpBase } from '../src/util/tmp.mjs';
55
57
 
56
58
  const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -435,6 +437,12 @@ export async function main(argv, io = {}) {
435
437
  const ended = await exec([process.execPath, BIN, 'run', 'end', '--run', run], { cwd: copy, env: scrubbedEnv(), timeoutMs: 60000 });
436
438
  say(`run end · exit ${ended.code} · ${ended.stdout.trimEnd() || ended.stderr.trimEnd()}`);
437
439
  summary.run_ended = ended.code === 0;
440
+ if (summary.run_ended) {
441
+ // `run end` sends SIGTERM; delivery is not exit. Wait for the pinned worker's
442
+ // cleanup before this script reports completion, rechecking identity before signals.
443
+ const record = await readRun(run);
444
+ await stopMembers(new Map((record.workers ?? (record.worker ? [record.worker] : [])).map((worker) => [worker.pid, worker.started_at])));
445
+ }
438
446
  }
439
447
  process.off('SIGINT', onSignal);
440
448
  process.off('SIGTERM', onSignal);
@@ -64,7 +64,7 @@ function notesLabel(nodeIn, defs) {
64
64
  if (node.exclusiveMinimum !== undefined) parts.push(`> ${node.exclusiveMinimum}`);
65
65
  if (node.minLength !== undefined) parts.push(`minLength ${node.minLength}`);
66
66
  if (node.minItems !== undefined) parts.push(`minItems ${node.minItems}`);
67
- if (typeof node.description === 'string' && node.type !== 'object') parts.push(node.description);
67
+ if (typeof node.description === 'string') parts.push(node.description);
68
68
  return parts.join('; ');
69
69
  }
70
70
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  Small changes may use `block open <id> --kind light`. Close allows at most `light.max_files` owned changed files (default 5) and `light.max_lines` added plus removed lines (default 300), including new files. Auto ticket lines are unchanged. `gates run --block` runs the block's tests through `{test_files}`, ignoring the full-suite threshold; supply block tests and a test command containing that item. A close refusal `light-limits <files> files, <lines> lines` means the block outgrew this kind: the orchestrator stops the open attempt, then opens the same id with `block open <id> --kind code --attempt <n+1>` and the original base, ownership and acceptance.
4
4
 
5
+ For website checks, configure `gates.custom` with up to 20 unique named `{name, argv, env?, evidence?}` gates, such as build, lighthouse and links. They run independently after the four fixed gates, including on light blocks and with `--changed`; custom argv refuses `{test_files}` and `{changed_files}`. The `gates-not-argv-array` error rule validates entries. Evidence paths/globs stay under cwd; after pass or fail, up to 200 sorted regular files are recorded as path, bytes and SHA-256 without copying content. Missing patterns are listed without failing the gate. `gate.done` carries custom summaries and, with `--run/--block`, the evidence map; red custom gates carry their name and `custom: true`.
6
+
5
7
  ## §1 The coder's first deliverable is a facts diff
6
8
 
7
9
  Before any forecast or code, the coder reads the code it will call and reports **every contradiction between the plan and the code** in one message — a signature, a flag, a key, a version. One stop for facts, never one per discovery. Contract corrections (a key, a rule, a signature) are the architect's alone and are recorded, not asked; a ruling reaches the human only when it changes user-visible behaviour, money semantics or an approved decision. A coder that stops three times for three true facts did nothing wrong; the protocol did.
@@ -14,6 +16,19 @@ The coder then forecasts **cases** (tests) and **lines**. Compute the floor your
14
16
 
15
17
  Acceptance (`block open --acceptance`) is a YAML/JSON list. Code clauses need `tests: [<test ids>]`. A device check instead uses `- clause: "Check the layout on a phone"` with `kind: human_check` on the next indented line and no `tests` key (even an empty list is refused). These texts are recorded as the block's `human_checks` and skipped by test-coverage checks; every other clause still needs tests.
16
18
 
19
+ For design first, put `{clause: "Owner approves the design", kind: human_check}` in `design.acc.yml`, then follow this order:
20
+
21
+ ```sh
22
+ code-forge block open DESIGN --run <r> --level L1 --kind docs --owned docs/design.md --acceptance design.acc.yml --brief <file> --lines <n>
23
+ # Finish the design and pass its code gates, then:
24
+ code-forge block close DESIGN --run <r>
25
+ # The owner approves the design in their terminal:
26
+ code-forge block human-pass DESIGN --run <r> --note "Design approved"
27
+ code-forge block open CODE --run <r> --level L2 --owned src/feature.mjs --acceptance code.acc.yml --after DESIGN --brief <file> --lines <n>
28
+ ```
29
+
30
+ `--after <id> [<id> ...]` collects ids like `--owned` and removes duplicates. Every dependency must be `closed` in this run, checked under the same run lock as the other open checks. Open refuses with `after-not-done`, naming every blocker and its status (`open`, `awaiting_human_check`, `stopped`, or `not in run`); no rows or worktree are created. With `--json`, the refusal also prints `{block, event: "refused", code: "after-not-done", message}` (self-dependency uses `bad-after`). Only a supplied flag adds `after: [ids]` to the entry and `dispatch` row. Supply it again on each later attempt that must wait for the design; an attempt without it does not recheck dependencies.
31
+
17
32
  Run a Codex coder with `codex exec --json` in its worktree, save stdout to a log, and pass that
18
33
  log to `block close --transcript <file>`. A Codex JSON log (every non-empty line is an object
19
34
  with a string `type`, first event `thread.started`) is checked by its commands: command items'
@@ -10,7 +10,19 @@ Events you will read most: `run.start`, `run.mode {mode: brief, plan: none}` (a
10
10
 
11
11
  `review.unwaived {block, file, finding, reason, by: human}` is a signed human undo of earlier agent waivers. `agent_waivers.acked {block, digest, count, by: human}` is a signed human acknowledgement of the current counted set. The gate verifies both rows. The digest is SHA-256 of the sorted file, finding and row signature set. New or removed waivers need a new acknowledgement when the run snapshot requires one.
12
12
 
13
- Terminal output in this version. Sections: cost per block — completed and still open, with `completed` and the count of unknown-price sessions (per level and lane, split review / coder / S1+S2 / facts / proof time) · lane distribution · escalations per block with `trigger` · S1 calls per block · S2 rate and overrule rate per question · the review budget table with `missed_after_degrade` per degrade step · review depth and context-mode distribution, findings by category and severity · fix rounds per file (one block round is one round for each file it touched, `review.md` §5), rounds that stalled, files stopped at `review_cap` · `review.unavailable` by reason · proof time per block · forecast vs actual lines and cases with the measured lines-per-case · blocks stopped at L3 · `overspend` and `l3_fallback` counts · runs stopped for `no_engine` · `outside_review` (14th): per PR sorted by number, `{pr, rounds, findings, link, blocks, code_forge_usd, unknown_usd_sessions}` — `code_forge_usd` sums the `cost_per_block` totals of the PR's blocks in its run, rounded as `cost_per_block` rounds; unknown-price sessions count 0 and are counted; a PR with no blocks shows 0 and 0 unknown; `[]` with no row; in the terminal the lines print under `== outside_review ==`: `outside review: none`, or one line per PR (`PR #12 · 3 outside rounds · 7 findings · code-forge $3.75 (+1 unknown) · B1, B2`). `agent_waivers` is the 15th and last section. It lists every agent waiver as `{block, file, finding, severity, reason, verdict, unwaived}`. A later human unwaive marks earlier matching rows as `unwaived: true`. A later agent waiver is a separate entry. An empty run gives `[]`. `--export <dir>` writes one JSON file per section — the durable dataset a future UI reads. After the sections the terminal output prints the spend per run (open blocks included) and the total. `report` checks no row's signature in any section (an unsigned row counts like a signed one); shadow rows (`source: shadow`) are excluded from every section.
13
+ Terminal output in this version. Sections: cost per block — completed and still open, with `completed` and the count of unknown-price sessions (per level and lane, split review / coder / S1+S2 / facts / proof time) · lane distribution · escalations per block with `trigger` · S1 calls per block · S2 rate and overrule rate per question · the review budget table with `missed_after_degrade` per degrade step · review depth and context-mode distribution, findings by category and severity · fix rounds per file (one block round is one round for each file it touched, `review.md` §5), rounds that stalled, files stopped at `review_cap` · `review.unavailable` by reason · proof time per block · forecast vs actual lines and cases with the measured lines-per-case · blocks stopped at L3 · `overspend` and `l3_fallback` counts · runs stopped for `no_engine` · `outside_review` (14th): per PR sorted by number, `{pr, rounds, findings, link, blocks, code_forge_usd, unknown_usd_sessions}` — `code_forge_usd` sums the `cost_per_block` totals of the PR's blocks in its run, rounded as `cost_per_block` rounds; unknown-price sessions count 0 and are counted; a PR with no blocks shows 0 and 0 unknown; `[]` with no row; in the terminal the lines print under `== outside_review ==`: `outside review: none`, or one line per PR (`PR #12 · 3 outside rounds · 7 findings · code-forge $3.75 (+1 unknown) · B1, B2`). `agent_waivers` is the 15th section. It lists every agent waiver as `{block, file, finding, severity, reason, verdict, unwaived}`. A later human unwaive marks earlier matching rows as `unwaived: true`. A later agent waiver is a separate entry. An empty run gives `[]`. `run_time` is appended as the 16th section (shape below). `--export <dir>` writes one JSON file per section — the durable dataset a future UI reads. After the sections the terminal output prints the spend per run (open blocks included) and the total. `report` checks no row's signature in any section (an unsigned row counts like a signed one); shadow rows (`source: shadow`) are excluded from every section.
14
+
15
+ Each `cost_per_block` entry also carries `wallMs` and `humanWaitMs` in milliseconds. `wallMs` spans the first valid `dispatch.ts` through the final completion: `block.close` without a pending human check, or `block.human_pass` after `block.awaiting_human_check`. An entry whose last lifecycle row is `block.stop` has `stopped: true` and `wallMs` through the stop timestamp, with `humanWaitMs: null`; `stopped` is omitted otherwise. Stopped blocks are listed even without spend or a close. Attempts share the first dispatch; redispatching clears `stopped` and makes the block open again until its final completion, whose wall time still starts at the first dispatch. `humanWaitMs` spans the final check's `block.awaiting_human_check.ts` through `block.human_pass.ts`. Both fields are null while the block is open or awaiting a human check; `humanWaitMs` is also null with no human check. Missing or invalid endpoints give null, as do reversed endpoints. Existing grouping by run and block still applies.
16
+
17
+ In text output, a `cost_per_block` entry with no run uses `outside runs` in the run column, and spend with no run prints as ` outside runs: $<usd> · <n> session(s)` (with the same unknown-price suffix) after the real runs and before the spend total; `--json` and `--export` keep `run: null` unchanged.
18
+
19
+ `run_time` has the shape `{runs: [...], outside_runs?: {usd, rows}, total: {...}}` in `--json` and `run_time.json` from `--export`:
20
+
21
+ - `runs` lists only runs with a string run id, in first-seen ledger order. Each entry is `{run, started, ended, wallMs, usd, blocks_closed, blocks_stopped}`, with `open: true` added only when there is no valid `run.end` timestamp. `started` is the first valid `run.start.ts`, falling back to the run's first valid row timestamp, or null. `ended` is the final valid `run.end.ts`, or null. These timestamp strings preserve the row's ISO representation. `wallMs` is elapsed milliseconds to `ended`, or to the last valid row timestamp for an open run; it is null when endpoints are unavailable or reversed. `usd` reuses the existing per-run spend total, including open blocks and counting unknown-price sessions as 0. `blocks_closed` counts distinct completed blocks (pending human checks do not count); `blocks_stopped` counts distinct blocks with a `block.stop` row, even across attempts.
22
+ - `outside_runs` counts rows without a string run id as `{usd, rows}`: their spend from the same spend helpers and their row count. They contribute no run or wall time. The key is omitted when there are no such rows, including an empty ledger.
23
+ - `total` is `{runs, wallMs, usd, blocks_closed}`: the number of listed real runs, the sum of their known durations (null contributes 0), the existing whole-ledger spend total **including `outside_runs.usd`**, and the sum of their completed-block counts. With no rows it is `{runs: 0, wallMs: 0, usd: 0, blocks_closed: 0}` and the list is `[]`. One slug is one repo/site, so this is the site's cost and cumulative run time.
24
+
25
+ Only full ISO timestamps with a timezone are used for timing; missing or invalid `ts` values are ignored without dropping the row's spend or lifecycle information. Shadow rows never affect times, counts or spend. The terminal shows block dollars beside readable times (for example `12m 30s`) and a status of `completed`, `stopped` or `open`, retains the cost breakdown and proof time, and prints a short run table with its total line; `-` means unavailable time. When `outside_runs` exists, `outside runs: $<usd> (<rows> rows, no wall time)` prints immediately before the total, with no `(none)` run in the table.
14
26
 
15
27
  ## §3 Live views
16
28
 
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.15.1"
13
+ PINNED_VERSION="0.15.3"
14
14
  set -eu
15
15
 
16
16
  self="$0"
package/src/cli/block.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
6
6
  * [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract|light]
7
- * [--tests <paths…>]
7
+ * [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]
8
8
  * block attempt <id> --run <r>
9
9
  * block rebase <id> --run <r> [--head <ref>]
10
10
  * block claim <id> <path> --run <r>
@@ -20,8 +20,9 @@
20
20
  * close (`closed`): status, reason, close_event (the close result's own event word, as the text
21
21
  * prints it in parentheses, else null), and `worktree {removed, path, branch}` with
22
22
  * `--remove-worktree`). On
23
- * failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
24
- * refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
23
+ * failure the exit code and stderr are unchanged and stdout stays empty, except an open refused
24
+ * for `after-not-done`, `bad-after`, `outcome-not-ready` or `bad-after-outcome`, which prints `{"block", "event": "refused", "code", "message"}`,
25
+ * or a close the gate refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
25
26
  * error, a thrown refusal, a cancelled `--no-require-reviews`, `block waive` or `block reopen` (or
26
27
  * one with no terminal, B80) and a failed `--remove-worktree`
27
28
  * print nothing on stdout. Without `--json` every output is byte-identical to before.
@@ -194,7 +195,8 @@
194
195
  * through `closeBlock`'s `extraChecks`.
195
196
  */
196
197
 
197
- import { existsSync, statSync } from 'node:fs';
198
+ import { createHash } from 'node:crypto';
199
+ import { existsSync, readFileSync, statSync } from 'node:fs';
198
200
  import { readFile, realpath } from 'node:fs/promises';
199
201
  import path from 'node:path';
200
202
  import { parse as parseYAML } from 'yaml';
@@ -202,6 +204,8 @@ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
202
204
  import { fallbackLane } from '../decide/fallback-rules.mjs';
203
205
  import { laneFacts } from '../decide/lane-facts.mjs';
204
206
  import { appendRow, readAllRows } from '../ledger/write.mjs';
207
+ import { checkOutcome } from '../outcome/outcome.mjs';
208
+ import { loadPipeline } from '../pipeline/pipeline.mjs';
205
209
  import { amendAcceptanceBlock, attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, prepareAcceptanceAmendment, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
206
210
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
207
211
  import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
@@ -209,17 +213,17 @@ import { readFileDiff } from '../review/packet.mjs';
209
213
  import { StateError } from '../state/paths.mjs';
210
214
  import { readRun, writeSigned } from '../state/run.mjs';
211
215
  import { loadKey, verifyRow } from '../state/signer.mjs';
212
- import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
216
+ import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, lockfileProblem, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
213
217
  import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
214
218
  import { checkExpiry, grantFor, grantState } from '../autopilot/grant.mjs';
215
219
  import { redact, writeSafe } from '../util/redact.mjs';
216
220
  import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
217
221
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
218
- import { validateConfig } from '../config/validate.mjs';
222
+ import { reviewExcludeRule, validateConfig } from '../config/validate.mjs';
219
223
  import { migrateConfig } from '../config/migrate.mjs';
220
224
  import { exec } from '../util/exec.mjs';
221
225
  import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
222
- import { clearDone, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
226
+ import { clearDone, enqueue, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
223
227
  import { loadState } from '../worker/review-state.mjs';
224
228
  import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
225
229
  import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
@@ -236,7 +240,7 @@ import { acceptedTranscriptHits, prepareAcceptHit, writeAcceptedHit } from '../r
236
240
  * main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
237
241
  * the checks, the done marker's removal and then the signed row are `reopenFile`'s.
238
242
  * @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>, agent?: boolean, judge?: import('../review/agent-waiver.mjs').WaiverJudge}} opts
239
- * @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
243
+ * @returns {Promise<{row: Record<string, any>, cleared: boolean, ticket?: string}>}
240
244
  */
241
245
  async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
242
246
  if (agent) {
@@ -268,7 +272,7 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
268
272
  const key = await loadKey(runId);
269
273
  const rows = await readAllRows(record.project);
270
274
  const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
271
- const { row, cleared } = await reopenFile({
275
+ const { row, cleared, ticket } = await reopenFile({
272
276
  runId,
273
277
  id,
274
278
  file,
@@ -286,13 +290,14 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
286
290
  readFile: (f) => readRegularFileNoFollow(contentRoot, f),
287
291
  // C11 (synchronous): the cached result of the current content's ticket would repeat the stop
288
292
  clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
293
+ enqueue: (f, reopened) => enqueue({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree, after: reopened.ts }).ticket,
289
294
  },
290
295
  });
291
- return { row, cleared };
296
+ return { row, cleared, ticket };
292
297
  }
293
298
 
294
299
  const USAGE =
295
- 'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>]\n' +
300
+ 'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]\n' +
296
301
  ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
297
302
  ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
298
303
  ' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
@@ -303,7 +308,34 @@ const USAGE =
303
308
  ' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
304
309
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
305
310
 
306
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass', 'amend-acceptance'];
311
+ /** @type {Record<string, string>} */
312
+ const VERB_USAGE = {
313
+ open: '<id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]',
314
+ attempt: '<id> --run <r>',
315
+ rebase: '<id> --run <r> [--head <ref>] [--accept-changed <file>… --reason <text>]',
316
+ claim: '<id> <path> --run <r>',
317
+ close: '<id> --run <r> [--worker-pid <pid>] [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--decide]',
318
+ stop: '<id> --run <r> --reason <text>',
319
+ waive: '<id> <finding-id> --run <r> --file <path> --reason <text> [--yes]',
320
+ reopen: '<id> --run <r> --file <path> --reason <text> [--yes]',
321
+ 'accept-hit': '<id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>',
322
+ unwaive: '<id> <finding-id> --run <r> --file <path> --reason <text>',
323
+ 'ack-waivers': '<id> --run <r>',
324
+ 'human-pass': '<id> --run <r> --note <text> [--proof <dir-or-file>]',
325
+ 'amend-acceptance': '<id> --run <r> --acceptance <file> --reason <text>',
326
+ };
327
+ const SUBCOMMANDS = Object.keys(VERB_USAGE);
328
+
329
+ /** @param {string} sub @returns {string} */
330
+ function verbUsage(sub) {
331
+ return `usage: code-forge block ${sub} ${VERB_USAGE[sub]} [--json] [--help|-h]\n` +
332
+ ' --help, -h Print this usage and exit without changing state.\n' +
333
+ ' --json Print one JSON line on success instead of text.\n' +
334
+ (sub === 'open' || sub === 'amend-acceptance'
335
+ ? ' --acceptance <file> JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n' +
336
+ ' Human checks use {clause, kind: human_check} without tests.\n' : '') +
337
+ (sub === 'open' ? ' --brief <file> A file inside the repository, not inline text.\n' : '');
338
+ }
307
339
 
308
340
  /** Refuse likely shell quoting slips before any row is written.
309
341
  * @param {string} workspace @param {string[]} entries @param {string} label
@@ -331,6 +363,32 @@ function checkWhitespacePaths(workspace, entries, label) {
331
363
  }
332
364
  const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
333
365
 
366
+ /** Check specialist outcomes against the run's project, before state open can write anything.
367
+ * @param {string} root @param {string} id @param {string[]} ids
368
+ * @returns {{step: string, sha256: string}[]}
369
+ */
370
+ function acceptedOutcomes(root, id, ids) {
371
+ const loaded = loadPipeline(root);
372
+ const bad = (/** @type {string} */ problem) => new StateError('bad-after-outcome', `block ${id}: --after-outcome: ${problem}`);
373
+ if (!loaded.exists) throw bad(`no code-forge.pipeline.yml in ${root}`);
374
+ if (!loaded.ok) throw bad(loaded.problems[0]);
375
+ const steps = [...new Set(ids)].map((stepId) => {
376
+ const step = loaded.pipeline.steps.find((candidate) => candidate.id === stepId);
377
+ if (!step) throw bad(`unknown step ${stepId}`);
378
+ return step;
379
+ });
380
+ return steps.map((step) => {
381
+ const file = path.resolve(root, step.outcome);
382
+ const result = existsSync(file) ? checkOutcome(file, { root }) : { ok: false, problems: ['missing outcome'] };
383
+ if (!result.ok) throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: ${result.problems[0]}`);
384
+ try {
385
+ return { step: step.id, sha256: createHash('sha256').update(readFileSync(file)).digest('hex') };
386
+ } catch (err) {
387
+ throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: cannot read outcome (${err.code ?? 'error'})`);
388
+ }
389
+ });
390
+ }
391
+
334
392
  /**
335
393
  * @param {string[]} args
336
394
  * @param {{
@@ -359,8 +417,13 @@ export async function runBlock(args, deps = {}) {
359
417
  err(USAGE);
360
418
  return 2;
361
419
  }
420
+ if (rest.includes('--help') || rest.includes('-h')) {
421
+ out(verbUsage(sub));
422
+ return 0;
423
+ }
424
+ let jsonOpenId;
362
425
  try {
363
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
426
+ const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests', ...(sub === 'rebase' ? ['accept-changed'] : []), ...(sub === 'open' ? ['after', 'after-outcome'] : [])], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
364
427
  // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
365
428
  // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
366
429
  const json = flags.json === true;
@@ -375,6 +438,7 @@ export async function runBlock(args, deps = {}) {
375
438
  if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
376
439
  }
377
440
  const [id, extra] = positionals;
441
+ if (sub === 'open' && json) jsonOpenId = id;
378
442
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
379
443
  if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
380
444
  if (sub === 'amend-acceptance' && (positionals.length !== 1 || typeof flags.acceptance !== 'string' || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason))) {
@@ -481,10 +545,11 @@ export async function runBlock(args, deps = {}) {
481
545
  const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
482
546
  const reopened = async (/** @type {boolean} */ agent) => {
483
547
  const file = /** @type {string} */ (flags.file);
484
- const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
548
+ const { row, cleared, ticket } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
485
549
  const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
486
550
  say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
487
- if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: row.by });
551
+ if (ticket) say(`ticket ${ticket}\nnext: code-forge review-file --wait ${ticket}\n`);
552
+ if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: row.by, ticket });
488
553
  return 0;
489
554
  };
490
555
  // Agent refusals precede even the lazy expiry checks: no rows on refusal.
@@ -542,13 +607,18 @@ export async function runBlock(args, deps = {}) {
542
607
  // B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
543
608
  // a failed check is a warning, never a reason to stop the block command
544
609
  const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
545
- try {
546
- if (!deciding && !requireHumanClose) {
547
- err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
610
+ const expire = async () => {
611
+ try {
612
+ if (!deciding && !requireHumanClose) {
613
+ err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
614
+ }
615
+ } catch (thrown) {
616
+ err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
548
617
  }
549
- } catch (thrown) {
550
- err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
551
- }
618
+ };
619
+ // B148: a refused dependency must not leave even an expiry row behind.
620
+ const deferExpiry = sub === 'open' && (Array.isArray(flags.after) || Array.isArray(flags['after-outcome']));
621
+ if (!deferExpiry) await expire();
552
622
 
553
623
  if (sub === 'open') {
554
624
  // an explicit --level wins; B59: else in a brief-mode run the rules lane; B47: else the level
@@ -581,6 +651,20 @@ export async function runBlock(args, deps = {}) {
581
651
  }
582
652
  // B62a: the run's isolation, from its config snapshot (fixed for the run)
583
653
  const isolated = worktreeSettings(record);
654
+ // Preserve --after's self-before-unfinished order before examining specialist outcomes.
655
+ // State open still checks these dependencies under the run lock.
656
+ let afterOutcome;
657
+ if (Array.isArray(flags['after-outcome'])) {
658
+ if (Array.isArray(flags.after)) {
659
+ if (flags.after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
660
+ const blocking = [...new Set(flags.after)].filter((dependency) => record.blocks[dependency]?.status !== 'closed');
661
+ if (blocking.length > 0) {
662
+ const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
663
+ throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
664
+ }
665
+ }
666
+ afterOutcome = acceptedOutcomes(record.workspace, id, flags['after-outcome']);
667
+ }
584
668
  const { block, pointer, worktreeReused } = await openBlock({
585
669
  runId,
586
670
  id,
@@ -594,10 +678,13 @@ export async function runBlock(args, deps = {}) {
594
678
  ...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
595
679
  ...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
596
680
  ...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
681
+ ...(Array.isArray(flags.after) ? { after: flags.after } : {}),
682
+ ...(afterOutcome ? { after_outcome: afterOutcome } : {}),
597
683
  cfg: await openConfig(record.workspace),
598
684
  writeRow,
599
685
  ...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
600
686
  });
687
+ if (deferExpiry) await expire();
601
688
  const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
602
689
  const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
603
690
  const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
@@ -629,7 +716,17 @@ export async function runBlock(args, deps = {}) {
629
716
  return 0;
630
717
  }
631
718
  if (sub === 'rebase') {
632
- const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow });
719
+ const acceptChanged = Array.isArray(flags['accept-changed']) ? [...new Set(flags['accept-changed'])] : [];
720
+ if (acceptChanged.length) {
721
+ if (positionals.length !== 1 || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason)) throw new StateError('usage', '--accept-changed needs one block id and a nonblank --reason without control characters');
722
+ for (const file of acceptChanged) checkFileForm(file, { normalized: true });
723
+ const asked = await confirmHumanVerb('rebase', runId, id, { file: acceptChanged.join(', ') }, record, deps, async () => {
724
+ if ((await checkExpiry(runId, (deps.now ?? (() => new Date()))())).state === 'active') throw new StateError('autopilot-active', 'block rebase --accept-changed is refused while an autopilot grant is active');
725
+ });
726
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY('rebase')}\n`); return 2; }
727
+ if (asked === 'cancelled') { err(`block ${id} rebase: cancelled — nothing was recorded\n`); return 1; }
728
+ }
729
+ const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow, acceptChanged, reason: typeof flags.reason === 'string' ? flags.reason : undefined });
633
730
  say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
634
731
  if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
635
732
  return 0;
@@ -679,7 +776,7 @@ export async function runBlock(args, deps = {}) {
679
776
  throw thrown;
680
777
  });
681
778
  // ONE base for the whole close: a worktree block is measured against the commit its tree was
682
- // made from (kept equal to base_sha, including after a closed worktree rebase)
779
+ // made from (kept equal to base_sha, including after a worktree rebase)
683
780
  const base = wt !== null ? wt.base_sha : entry.base_sha;
684
781
  const reviewsWaived = flags['no-require-reviews'] === true;
685
782
  let routineGrant = null;
@@ -706,6 +803,7 @@ export async function runBlock(args, deps = {}) {
706
803
  // B58: the owner's other way out — `review.external.required` in the run's config snapshot
707
804
  // (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
708
805
  const external = reviewsWaived ? null : externalReviewer(record);
806
+ const reviewConfig = configInForce(record);
709
807
  const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
710
808
  // B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
711
809
  // base is the repository top level: the worker signs every review row with a repo-root-
@@ -770,6 +868,16 @@ export async function runBlock(args, deps = {}) {
770
868
  // B20: the high paths come first — a base the workspace does not know refuses the close here
771
869
  const highPaths = await highPathsAt(record.workspace, base);
772
870
  const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
871
+ const excluded = fileSet.flatMap((f) => {
872
+ const rule = reviewExcludeRule(reviewConfig, f.file);
873
+ return rule === null ? [] : [{ ...f, rule }];
874
+ });
875
+ for (const f of excluded) {
876
+ const problem = lockfileProblem(top, f.file, reviewConfig);
877
+ if (problem !== null) throw new StateError('lockfile', `lockfile ${f.file}: ${problem}`);
878
+ }
879
+ const otherRows = closeRows.filter((r) => r.event !== 'gate.review_excluded');
880
+ closeRows.splice(0, closeRows.length, ...otherRows, ...excluded.map((f) => ({ event: 'gate.review_excluded', block: id, ...f })));
773
881
  const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
774
882
  const key = await loadKey(runId);
775
883
  // B52: an empty diff with review rows is never silent — name the likely cause
@@ -801,7 +909,7 @@ export async function runBlock(args, deps = {}) {
801
909
  unowned: scope?.unowned ?? [],
802
910
  // B52: an old ledger's review.result rows are judged by their signed result files
803
911
  resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
804
- files: reviewsWaived || external !== null ? [] : fileSet,
912
+ files: reviewsWaived || external !== null ? [] : fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
805
913
  // B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
806
914
  // --no-require-reviews); B58: `review.external` does not lift it
807
915
  noChanges: !reviewsWaived && fileSet.length === 0,
@@ -852,7 +960,7 @@ export async function runBlock(args, deps = {}) {
852
960
  }
853
961
  };
854
962
  return await (deps.reviewedLines ?? reviewedLines)({
855
- files: fileSet,
963
+ files: fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
856
964
  waivedAll,
857
965
  ticketOf,
858
966
  onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
@@ -879,7 +987,7 @@ export async function runBlock(args, deps = {}) {
879
987
  const { fileSet } = await tree();
880
988
  const lines = await toolLines();
881
989
  const given = new Set(lines.map((l) => l.file));
882
- const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
990
+ const missing = missingReviewTickets(text, fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null).map((f) => f.file)).filter((f) => !given.has(f));
883
991
  if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
884
992
  for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
885
993
  return { ok: true };
@@ -900,7 +1008,9 @@ export async function runBlock(args, deps = {}) {
900
1008
  });
901
1009
  if (configInForce(record)?.gates?.test != null) {
902
1010
  const testGate = async () => {
903
- const green = rows.filter((row) => row.block === id && row.event === 'gate.done' && row.all_ok === true);
1011
+ // A rebase changes the tested tree even when owned commit timestamps are preserved.
1012
+ const lastRebase = rows.reduce((last, row, index) => row.block === id && row.event === 'block.rebase' ? index : last, -1);
1013
+ const green = rows.filter((row, index) => index > lastRebase && row.block === id && row.event === 'gate.done' && row.all_ok === true);
904
1014
  // Only commits after the block base that touch its owned paths belong to this block.
905
1015
  // Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
906
1016
  const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
@@ -1024,6 +1134,7 @@ export async function runBlock(args, deps = {}) {
1024
1134
  await warnMovedBase(record.workspace, entry, id, runId, err);
1025
1135
  for (const w of lineWarnings) err(w);
1026
1136
  for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
1137
+ for (const f of closeRows.filter((r) => r.event === 'gate.review_excluded')) say(`not reviewed (review.exclude ${f.rule}): ${f.file}\n`);
1027
1138
  for (const f of waivedAll) {
1028
1139
  const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
1029
1140
  if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
@@ -1072,6 +1183,9 @@ export async function runBlock(args, deps = {}) {
1072
1183
  }
1073
1184
  }
1074
1185
  } catch (thrown) {
1186
+ if (jsonOpenId && thrown instanceof StateError && ['after-not-done', 'bad-after', 'outcome-not-ready', 'bad-after-outcome'].includes(thrown.code)) {
1187
+ out(`${JSON.stringify({ block: jsonOpenId, event: 'refused', code: thrown.code, message: thrown.message })}\n`);
1188
+ }
1075
1189
  err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
1076
1190
  return thrown instanceof StateError && thrown.code === 'usage' ? 2 : 1;
1077
1191
  }
@@ -1168,7 +1282,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
1168
1282
 
1169
1283
  /**
1170
1284
  * B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
1171
- * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close'} verb @returns {string}
1285
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close' | 'rebase'} verb @returns {string}
1172
1286
  */
1173
1287
  export const humanVerbNoTTY = (verb) => verb === 'waive'
1174
1288
  ? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
@@ -1229,14 +1343,16 @@ async function confirmNoReviews(runId, id, deps) {
1229
1343
  * `by: autopilot`); human-pass instead refuses an active grant. The
1230
1344
  * pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
1231
1345
  * asked about a waive or reopen the verb would refuse that way.
1232
- * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance'} verb @param {string} runId @param {string} id
1346
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'rebase'} verb @param {string} runId @param {string} id
1233
1347
  * @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[], oldCount?: number, newCount?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
1234
1348
  * @param {() => Promise<unknown> | unknown} [before]
1235
1349
  * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
1236
1350
  */
1237
1351
  function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
1238
1352
  const message = () =>
1239
- verb === 'amend-acceptance'
1353
+ verb === 'rebase'
1354
+ ? `Accept rebased changes in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1355
+ : verb === 'amend-acceptance'
1240
1356
  ? `Amend acceptance of block ${id} (run ${runId}) from ${what.oldCount} clauses to ${what.newCount} clauses? Only the owner may say yes.`
1241
1357
  : verb === 'human-pass'
1242
1358
  ? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
@@ -7,7 +7,7 @@
7
7
  * (`tmp.root` is fixed for the run), as for the worker. The orchestrator sends the pack's text with
8
8
  * every coder task.
9
9
  *
10
- * Prints two lines: the path, then `tokens ~<n>` (the review packets' bytes / 4 estimate); with
10
+ * Prints the pack text; --path-only prints the path then `tokens ~<n>` for legacy callers; with
11
11
  * `--json` one line `{"path":…,"tokens":…,"lines":…,"cut":…,"sha256":…}`. Each pack written
12
12
  * appends a signed `context.pack {block, path, sha256, tokens, lines, cut}` ledger row — unless
13
13
  * the block's last verified `context.pack` row has the same sha256 and path (same pack: no row,
@@ -16,17 +16,19 @@
16
16
  * `<block>.md`. A row that cannot be appended removes only the temp file, so an earlier pack stays
17
17
  * as it was and nothing was written. A rename that fails after the row was appended says so.
18
18
  *
19
- * With no `context` key, the pack holds only the block header and built-in proof rule (300 lines).
19
+ * The header names this install's exact node/bin command and version. With no `context` key,
20
+ * the pack holds the block header, code-forge section and built-in proof rule (300 lines).
20
21
  * Exit codes: 0 written, 1 refused (a rules or lessons file missing or outside
21
22
  * the repository, a bad `context` value, a control character, an unknown run or block, a file or
22
23
  * ledger write that fails), 2 usage. Never runs any command it lists.
23
24
  */
24
25
 
25
26
  import { createHash } from 'node:crypto';
26
- import { mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
27
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
27
28
  import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
28
30
  import { loadProjectConfig } from '../config/load.mjs';
29
- import { assemblePack, ContextError, contextSettings, packTokens, readSources } from '../context/pack.mjs';
31
+ import { assemblePack, ContextError, contextSettings, packTokens, readBlockBrief, readSources } from '../context/pack.mjs';
30
32
  import { appendRow, readAllRows } from '../ledger/write.mjs';
31
33
  import { runRootFor } from '../session/spawn.mjs';
32
34
  import { parseFlags } from '../state/cli-args.mjs';
@@ -37,7 +39,7 @@ import { loadKey, verifyRow } from '../state/signer.mjs';
37
39
  import { assertBlockId, mainRootOf, WorkerError } from '../worker/ticket.mjs';
38
40
  import { writeSafe } from '../util/redact.mjs';
39
41
 
40
- const USAGE = 'usage: code-forge context pack --run <id> --block <id> [--json]\n';
42
+ const USAGE = 'usage: code-forge context pack --run <id> --block <id> [--json] [--path-only]\n';
41
43
 
42
44
  /** @param {unknown} err @returns {string} the fs error's code (or message) for a one-line refusal. */
43
45
  const fsCode = (err) => /** @type {NodeJS.ErrnoException} */ (err)?.code ?? (err instanceof Error ? err.message : 'error');
@@ -76,7 +78,7 @@ export async function runContext(args, deps = {}) {
76
78
  return 2;
77
79
  }
78
80
  try {
79
- const { flags, positionals } = parseFlags(rest, { values: ['run', 'block'], booleans: ['json'] });
81
+ const { flags, positionals } = parseFlags(rest, { values: ['run', 'block'], booleans: ['json', 'path-only'] });
80
82
  if (positionals.length > 0 || typeof flags.run !== 'string' || typeof flags.block !== 'string') throw new StateError('usage', 'context pack needs --run and --block');
81
83
  const runId = flags.run;
82
84
  let block;
@@ -106,7 +108,14 @@ export async function runContext(args, deps = {}) {
106
108
  level: entry.level ?? null,
107
109
  owned: Array.isArray(entry.owned_files) ? entry.owned_files : [],
108
110
  tests: Array.isArray(entry.test_files) ? entry.test_files : null,
111
+ brief: readBlockBrief(repoRoot, entry),
112
+ acceptance: entry.acceptance,
109
113
  worktree: entry.worktree && typeof entry.worktree.path === 'string' ? { path: entry.worktree.path, branch: entry.worktree.branch } : null,
114
+ codeForge: {
115
+ node: process.execPath,
116
+ bin: fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url)),
117
+ version: JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version,
118
+ },
110
119
  },
111
120
  });
112
121
  const sha256 = createHash('sha256').update(text).digest('hex');
@@ -155,7 +164,8 @@ export async function runContext(args, deps = {}) {
155
164
  return 1;
156
165
  }
157
166
  if (flags.json === true) writeSafe(stdout, `${JSON.stringify({ path: file, tokens, lines, cut, sha256 })}\n`);
158
- else writeSafe(stdout, `${file}\ntokens ~${tokens}\n`);
167
+ else if (flags['path-only'] === true) writeSafe(stdout, `${file}\ntokens ~${tokens}\n`);
168
+ else writeSafe(stdout, text);
159
169
  return 0;
160
170
  } catch (thrown) {
161
171
  if (thrown instanceof StateError && thrown.code === 'usage') {