@codedology/code-forge 0.7.6 → 0.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,16 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.7] — 2026-10-11
10
+
11
+ ### Added
12
+
13
+ - Brief-only mode (B59, issue #13 item 2): run start --mode brief writes a signed run.mode {mode: brief, plan: none}
14
+ row; the mode is fixed for the run. In a brief run, block open without --level takes the lane from the rules
15
+ (fallbackLane) over the owned entries, --lines and proof.tiers.high.paths of the run config snapshot, and writes a
16
+ signed decision row {question: lane, source: rules, facts} before dispatch; an explicit --level wins. report prints
17
+ the mode of each run in its text output (not in --json or --export). --mode plan or no flag keeps the old behaviour.
18
+
9
19
  ## [0.7.6] — 2026-10-11
10
20
 
11
21
  ### Added
package/README.md CHANGED
@@ -12,6 +12,13 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
12
12
 
13
13
  Next release:
14
14
 
15
+ - **Work from a brief, with no plan.** `run start --mode brief` marks the run as brief-only.
16
+ Then `block open` without `--level` picks the lane from the rules: the owned files,
17
+ `--lines` and `proof.tiers.high.paths`. A signed `decision` row records it with
18
+ `source: rules`. `--level` still wins, and `report` shows the mode of each run.
19
+
20
+ In 0.7.6:
21
+
15
22
  - **An outside reviewer can replace the local review.** Set `review.external: {required: true,
16
23
  name: <reviewer>}` in `.code-forge.yml`. `block close` then skips the per-file review check
17
24
  and writes a signed `gate.reviews_waived` row with `by: config`. It needs no TTY and works
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 61,
7
+ "block_count": 62,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -836,6 +836,20 @@
836
836
  "B57"
837
837
  ],
838
838
  "note": "root-added 2026-10-11; GitHub issue #13 adoption plan (plans/adoption-13/plan.md); also amends src/state/{config-snapshot,run,block}.mjs, src/cli/block.mjs, schema/code-forge.schema.json, docs/reference/config.md, docs/{how-it-works,concepts}.md, skill/references/{review,decisions,code}.md, test/config/schema-coverage.test.mjs"
839
+ },
840
+ "B59": {
841
+ "title": "Brief-only mode (issue #13 item 2): run start --mode brief writes a signed run.mode {mode: brief, plan: none} row and stores the mode (fixed for the run); in a brief run block open without --level takes the lane from fallbackLane over facts built from the owned entries, --lines and proof.tiers.high.paths of the run config snapshot (src/decide/lane-facts.mjs) and writes a signed decision row {question: lane, source: rules, facts} before dispatch; an explicit --level wins; outside brief mode nothing changes; report prints the run mode per run in its text output (not a 14th section); no task verb",
842
+ "wave": 13,
843
+ "level": "L1",
844
+ "status": "landed",
845
+ "owned": [
846
+ "src/decide/lane-facts.mjs",
847
+ "test/state/brief-mode.test.mjs"
848
+ ],
849
+ "depends_on": [
850
+ "B58"
851
+ ],
852
+ "note": "root-added 2026-10-11; GitHub issue #13 adoption plan (plans/adoption-13/plan.md); also amends src/state/{run,block,config-snapshot}.mjs, src/cli/{run,block,report}.mjs, src/ledger/report.mjs, src/review/engine.mjs (shared migration/policy path checks), docs/{how-it-works,concepts}.md, skill/SKILL.md, skill/references/plan.md"
839
853
  }
840
854
  }
841
855
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.6",
3
+ "version": "0.7.7",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
package/skill/SKILL.md CHANGED
@@ -11,7 +11,7 @@ You are the **orchestrator**: the session that invoked this skill. You dispatch,
11
11
 
12
12
  1. `forge validate` — exit 0, or stop and show the refusal (it names the key, never the value).
13
13
  2. `forge doctor --quick` — config, links, key resolution. On the first run of a day run `forge doctor` in full and read its `engine` row and its `signer: same-user boundary only` row (`references/security.md`).
14
- 3. `forge run start` — prints `run <id> started · engine <e> · worker pid <n>`. The engine is `solo`, `harness` or `subprocess` (`engine: auto` picks; `subprocess` only by explicit config). No engine ⇒ the four-line stop text in `references/degraded.md` §1 is printed and you stop. Keep `<id>`: every later verb takes `--run <id>`.
14
+ 3. `forge run start` — prints `run <id> started · engine <e> · worker pid <n>`. The engine is `solo`, `harness` or `subprocess` (`engine: auto` picks; `subprocess` only by explicit config). No engine ⇒ the four-line stop text in `references/degraded.md` §1 is printed and you stop. Keep `<id>`: every later verb takes `--run <id>`. A small change with briefs and no plan starts with `forge run start --mode brief` instead (`references/plan.md` §6); the mode is fixed for the run.
15
15
  4. When the engine is not `solo`, print the banner of `references/degraded.md` §2 once.
16
16
 
17
17
  ## §1 Invocation and state
@@ -10,7 +10,7 @@ The coder then forecasts **cases** (tests) and **lines**. Compute the floor your
10
10
 
11
11
  ## §3 Dispatch — what the brief contains
12
12
 
13
- `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] [--kind code|docs|contract] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused; an owned entry with no glob character — `*`, `?`, `{` — owns that path and every path below it, so `src/feature` owns `src/feature/a.swift` but not `src/featureX/a.swift`, and `src/feature` overlaps `src/feature/x.swift` or `src` in another block — unless the entry is a regular file in the workspace at `block open` (or `block claim`): then it owns only itself, recorded as `owned_kinds`, and every later check reads that answer; a glob owns what it matches, and a brace entry `src/{a,b}` is a glob, so `src/a` owns nothing below it; no trailing `/`), the block kind (declared, else detected — `decisions.md` §5), the level (a docs/contract block below `levels.coder_floor_docs`, default L1, is raised to it and the CLI prints the floor), the clauses with their test ids and the forecast, writes the ledger `dispatch` row and prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` (≤ 200 B). Call it before every spawn, in every engine. A docs block that owns a glob (`docs/**`) must declare `--kind docs`: a glob is judged by its literal ending, so undeclared it counts as `code`. The brief file carries, in this order:
13
+ `forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] [--kind code|docs|contract] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused; an owned entry with no glob character — `*`, `?`, `{` — owns that path and every path below it, so `src/feature` owns `src/feature/a.swift` but not `src/featureX/a.swift`, and `src/feature` overlaps `src/feature/x.swift` or `src` in another block — unless the entry is a regular file in the workspace at `block open` (or `block claim`): then it owns only itself, recorded as `owned_kinds`, and every later check reads that answer; a glob owns what it matches, and a brace entry `src/{a,b}` is a glob, so `src/a` owns nothing below it; no trailing `/`), the block kind (declared, else detected — `decisions.md` §5), the level (a docs/contract block below `levels.coder_floor_docs`, default L1, is raised to it and the CLI prints the floor), the clauses with their test ids and the forecast, writes the ledger `dispatch` row and prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` (≤ 200 B). Call it before every spawn, in every engine. `--level` may be left out only in a brief-mode run (`plan.md` §6: the rules pick the lane from the owned entries, `--lines` and `proof.tiers.high.paths`, and a signed `decision` row with `source: rules` records it) or when autopilot recorded a level; otherwise its absence is a usage error. A docs block that owns a glob (`docs/**`) must declare `--kind docs`: a glob is judged by its literal ending, so undeclared it counts as `code`. The brief file carries, in this order:
14
14
 
15
15
  1. `id`, `title`, the level, `owned_files` ("edit ONLY these"), the acceptance clauses with test ids, `test_command`.
16
16
  2. The reply contract: `ACK <sha8> lines=<n>` before any tool call; the facts diff; the forecast.
@@ -4,7 +4,7 @@
4
4
 
5
5
  Every row has the run, the block, the event and a timestamp; the ones the block gate relies on carry `mac` (`security.md` §3). Useful fields: `level`, `provider`, `model`, `effort`, `fallback_step` (every spawn), `role ∈ {coder, reviewer, judge, s2, author, facts}`, `trigger` (escalations), `source ∈ {jev, rules, s2, s2-fallback, shadow}` (decisions), `depth_unconstrained` / `depth_chosen` / `degrade_step` and `context_mode ∈ {whole, hunks, minimal, recheck}` (reviews), `round` and `kind ∈ {full, recheck, patch_check, sweep}` (rounds), `isolation ∈ {export, lock}` (proof), `l3_patch`, `l3_fallback`, `overspend`. **Tokens are facts, dollars are estimates**: `tokens_source: reported` when the CLI's JSON carries usage, else `estimated`. File paths in rows are relative to the repository root.
6
6
 
7
- Events you will read most: `run.start`, `run.stop {reason}`, `run.end`, `dispatch`, `gate.red`, `review.plan`, `review.round`, `review.done`, `review.approved`, `review.unavailable {reason}`, `review.late_finding`, `review.waived {by: human}`, `review.budget`, `forecast.cases_exceeded`, `proof`, `outcome`, `block.rebase`, `block.orphan`, `ledger.tamper`, `worker.replaced`, `facts.built`, `tmp.swept`.
7
+ Events you will read most: `run.start`, `run.mode {mode: brief, plan: none}` (a brief-mode run, `plan.md` §6), `review.external {required, reviewer}` (the owner's outside reviewer, `review.md`), `run.stop {reason}`, `run.end`, `dispatch`, `gate.red`, `review.plan`, `review.round`, `review.done`, `review.approved`, `review.unavailable {reason}`, `review.late_finding`, `review.waived {by: human}`, `gate.reviews_waived {by: human | config}` (`by: config` from `review.external`), `decision {question: lane, source: rules}` (a brief-mode `block open` without `--level`), `review.budget`, `forecast.cases_exceeded`, `proof`, `outcome`, `block.rebase`, `block.orphan`, `ledger.tamper`, `worker.replaced`, `facts.built`, `tmp.swept`.
8
8
 
9
9
  ## §2 `forge report --slug <slug> [--json] [--export <dir>]`
10
10
 
@@ -52,3 +52,15 @@ Never treat a chat summary of the plan as the approval step, and never start `fo
52
52
  ## §5 Cost
53
53
 
54
54
  One L0 session per plan for the facts (typically a claim list of 40–80 items) and one L3 session per author round; `forge report --slug <slug>` shows both as `facts $` and coder/review splits.
55
+
56
+ ## §6 Brief mode — briefs, no plan
57
+
58
+ For a small change whose blocks are clear from their briefs (a fix, a doc pass, one or two blocks with no ordering to argue about), skip §0–§4: `forge run start --mode brief`. The run record stores `mode: brief` and the ledger gets one signed `run.mode {mode: brief, plan: none}` row right after `run.start`; the mode is fixed for the run (`run reload` never changes it) and `forge report` lists each run as `brief` or `plan`. Plan mode (`--mode plan`, or no flag) is the default and is unchanged. There is no separate verb for a task: each block is still `forge block open` with its brief, owned files and acceptance clauses, and is closed by the same gate.
59
+
60
+ In a brief-mode run `forge block open <id> --run <r> --owned <paths…> --acceptance <file> --brief <file> --lines <n>` may leave out `--level`: the lane then comes from the deterministic rules (the `--rules` fallback of `jev ask lane`), over these facts only —
61
+
62
+ - an owned entry under `proof.tiers.high.paths` of the run's config snapshot (or a directory entry above one), a migration path, or a policy/middleware path ⇒ `L2`;
63
+ - else more than 4 owned entries or more than 200 estimated changed lines (`--lines`) ⇒ `L1`;
64
+ - else `L0` (a docs or contract block is still raised to `levels.coder_floor_docs`).
65
+
66
+ `block open` writes one signed `decision {question: lane, answer: <lane>, source: rules}` row naming the block, then the `dispatch` row, and prints `(rules lane)` after the level. An explicit `--level` still wins and writes no decision row. Give `--lines` whenever you can: without it the size rule sees only the number of owned entries. Outside brief mode a missing `--level` with no autopilot level stays a usage error, and `--lines` stays the forecast it always was.
@@ -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.7.6"
13
+ PINNED_VERSION="0.7.7"
14
14
  set -eu
15
15
 
16
16
  self="$0"
package/src/cli/block.mjs CHANGED
@@ -64,6 +64,14 @@
64
64
  * neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
65
65
  * explicit `--level` always wins; a repeated or valueless `--level` is a usage error as before.
66
66
  *
67
+ * B59: in a brief-mode run (`run start --mode brief`), `block open` without `--level` takes the
68
+ * lane from the §3.5 rules (`fallbackLane`) over facts built from the owned entries, `--lines`
69
+ * (estimated changed lines) and `proof.tiers.high.paths` of the run's config snapshot
70
+ * (`laneFacts`), and writes one signed `decision {question: lane, source: rules}` row naming the
71
+ * block, then the `dispatch` row (`openBlock`; neither when the open is refused). An explicit
72
+ * `--level` still wins, with no decision row. Outside brief mode `--lines` stays the forecast it
73
+ * always was, and no `--level` without an autopilot level is the usage error above.
74
+ *
67
75
  * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
68
76
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
69
77
  * through `closeBlock`'s `extraChecks`.
@@ -73,10 +81,12 @@ import { readFile, realpath } from 'node:fs/promises';
73
81
  import path from 'node:path';
74
82
  import { parse as parseYAML } from 'yaml';
75
83
  import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
84
+ import { fallbackLane } from '../decide/fallback-rules.mjs';
85
+ import { laneFacts } from '../decide/lane-facts.mjs';
76
86
  import { appendRow, readAllRows } from '../ledger/write.mjs';
77
87
  import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
78
88
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
79
- import { externalReviewer } from '../state/config-snapshot.mjs';
89
+ import { configInForce, externalReviewer } from '../state/config-snapshot.mjs';
80
90
  import { StateError } from '../state/paths.mjs';
81
91
  import { readRun, writeSigned } from '../state/run.mjs';
82
92
  import { loadKey, verifyRow } from '../state/signer.mjs';
@@ -141,8 +151,25 @@ export async function runBlock(args, deps = {}) {
141
151
  }
142
152
 
143
153
  if (sub === 'open') {
144
- // B47: no --level ⇒ the level autopilot chose for the block (a signed autopilot.level row)
145
- const level = typeof flags.level === 'string' ? flags.level : await autopilotLevel(runId, id, record);
154
+ // an explicit --level wins; B59: else in a brief-mode run the rules lane; B47: else the level
155
+ // autopilot chose for the block (a signed autopilot.level row)
156
+ /** @type {{lane: string, facts: ReturnType<typeof laneFacts>} | null} */
157
+ let rules = null;
158
+ /** @type {string | null} */
159
+ let level;
160
+ // B59: only the rules lane parses --lines this early; every other open parses it where it
161
+ // always was (after the level check and --attempt), so its errors keep their pre-B59 order
162
+ /** @type {number | null | undefined} */
163
+ let lines;
164
+ if (typeof flags.level === 'string') {
165
+ level = flags.level;
166
+ } else if (record.mode === 'brief') {
167
+ lines = intFlag(flags.lines, 'lines') ?? null;
168
+ rules = rulesLane(record, /** @type {string[]} */ (flags.owned), lines);
169
+ level = rules.lane;
170
+ } else {
171
+ level = await autopilotLevel(runId, id, record);
172
+ }
146
173
  if (level === null) throw new StateError('usage', `no --level given and no autopilot level recorded for ${id}`);
147
174
  const acceptance = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
148
175
  let brief;
@@ -160,14 +187,15 @@ export async function runBlock(args, deps = {}) {
160
187
  acceptance,
161
188
  attempt: intFlag(flags.attempt, 'attempt'),
162
189
  base: typeof flags.base === 'string' ? flags.base : undefined,
163
- lines: intFlag(flags.lines, 'lines') ?? null,
190
+ lines: lines === undefined ? (intFlag(flags.lines, 'lines') ?? null) : lines,
164
191
  brief,
192
+ ...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
165
193
  ...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
166
194
  cfg: await openConfig(record.workspace),
167
195
  writeRow,
168
196
  });
169
197
  const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
170
- const chosen = typeof flags.level === 'string' ? '' : ' (autopilot level)';
198
+ const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
171
199
  out(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
172
200
  out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
173
201
  return 0;
@@ -344,6 +372,19 @@ async function confirmNoReviews(runId, id, deps) {
344
372
  return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
345
373
  }
346
374
 
375
+ /**
376
+ * B59: the rules lane for a brief-mode block — `fallbackLane` over the facts of its owned entries,
377
+ * the `--lines` estimate and the snapshot's `proof.tiers.high.paths` (none for a record from
378
+ * before snapshots).
379
+ * @param {Record<string, any>} record @param {string[]} owned @param {number | null} lines
380
+ * @returns {{lane: string, facts: ReturnType<typeof laneFacts>}}
381
+ */
382
+ function rulesLane(record, owned, lines) {
383
+ const high = configInForce(record)?.proof?.tiers?.high?.paths;
384
+ const facts = laneFacts({ owned, lines, highPaths: high });
385
+ return { lane: fallbackLane(facts), facts };
386
+ }
387
+
347
388
  /**
348
389
  * B47: the level `autopilot level` chose for the block — the latest `autopilot.level` row of this
349
390
  * run for it whose MAC verifies — or null when there is none.
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
3
- * only, plus an optional `--export <dir>` that writes the same 13 sections as JSON files.
3
+ * only, plus an optional `--export <dir>` that writes the same 13 sections as JSON files. The
4
+ * terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
5
+ * both are text only, never in `--json` or `--export`, so the 13 pinned sections stay as they are.
4
6
  */
5
7
 
6
8
  import { writeSafe } from '../util/redact.mjs';
7
- import { buildReport, buildSpend } from '../ledger/report.mjs';
9
+ import { buildReport, buildRunModes, buildSpend } from '../ledger/report.mjs';
8
10
  import { exportReportSections } from '../ledger/export.mjs';
9
11
  import { readAllRows } from '../ledger/write.mjs';
10
12
 
@@ -61,6 +63,7 @@ export async function runReport(args, deps = {}) {
61
63
  out(`== ${name} ==\n${JSON.stringify(data, null, 2)}\n`);
62
64
  }
63
65
  out(spendLines(buildSpend(rows)));
66
+ out(modeLines(buildRunModes(rows)));
64
67
  return 0;
65
68
  } catch (err_) {
66
69
  err(`report: ${err_?.message ?? String(err_)}\n`);
@@ -81,6 +84,17 @@ export function spendLines({ runs, total }) {
81
84
  return `${lines.join('\n')}\n`;
82
85
  }
83
86
 
87
+ /**
88
+ * B59: the mode of each run (`brief` or `plan`), after the spend — text only, never in `--json`
89
+ * or `--export`. Empty (no header) when the ledger has no run.
90
+ * @param {ReturnType<typeof buildRunModes>} modes
91
+ * @returns {string}
92
+ */
93
+ export function modeLines(modes) {
94
+ if (modes.length === 0) return '';
95
+ return `${['run mode:', ...modes.map((m) => ` run ${m.run}: ${m.mode}`)].join('\n')}\n`;
96
+ }
97
+
84
98
  /** @param {string[]} args @returns {Promise<number>} */
85
99
  export default async function report(args) {
86
100
  return runReport(args);
package/src/cli/run.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * `code-forge run start|status|end` (plan §4.9). The orchestrator runs these; a coder never does
3
3
  * (`run start` is on the forbidden list, ★ `code-forge-worker-from-coder`).
4
4
  *
5
- * run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]
5
+ * run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief]
6
6
  * run start --reattach --run <id> [--worker-pid <pid>]
7
7
  * run status --run <id> (B33: includes spent_usd / budget_usd / unknown_usd_sessions)
8
8
  * run reload --run <id>
@@ -14,6 +14,12 @@
14
14
  * queue announces (`repinWorker`). Called as `runRun(args, deps)` without `deps.startWorker` (the
15
15
  * state tests), no worker is launched and none is pinned unless `--worker-pid` is given.
16
16
  *
17
+ * B59 `--mode brief`: a run with briefs and no plan. The mode is stored in the run record and on a
18
+ * signed `run.mode {mode: brief, plan: none}` row, and it is fixed for the run (`run reload` never
19
+ * touches it); `block open` without `--level` then takes the lane from the rules. `--mode plan`
20
+ * (the default) changes nothing; any other value, or `--mode` on any other verb or with
21
+ * `--reattach`, is a usage error (exit 2).
22
+ *
17
23
  * B35 `run reload`: re-read and validate the workspace's `.code-forge.yml` and replace the run's
18
24
  * config snapshot (`reloadRun`) — no `block stop` / `run end` / `run start` cycle. An invalid
19
25
  * config or a change to a key that is fixed for the run is refused with nothing changed. It prints
@@ -29,7 +35,7 @@ import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
29
35
  import { appendRow, readAllRows } from '../ledger/write.mjs';
30
36
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
31
37
  import { StateError } from '../state/paths.mjs';
32
- import { endRun, processStartTime, readRun, reattachWorker, startRun, stopPinnedWorker } from '../state/run.mjs';
38
+ import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
33
39
  import { writeSafe } from '../util/redact.mjs';
34
40
  import { sweepRoots, tmpBase } from '../util/tmp.mjs';
35
41
  import { launchWorker, repinWorker } from '../worker/loop.mjs';
@@ -101,9 +107,12 @@ export async function runRun(args, deps = {}) {
101
107
  return 2;
102
108
  }
103
109
  try {
104
- const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid'], booleans: ['reattach'] });
110
+ const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach'] });
105
111
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
106
112
  const workerPid = intFlag(flags['worker-pid'], 'worker-pid');
113
+ const mode = typeof flags.mode === 'string' ? flags.mode : undefined;
114
+ if (mode !== undefined && (sub !== 'start' || flags.reattach)) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
115
+ if (mode !== undefined && !RUN_MODES.includes(mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
107
116
 
108
117
  if (sub === 'start' && flags.reattach) {
109
118
  if (!runId) throw new StateError('usage', 'run start --reattach needs --run');
@@ -129,7 +138,7 @@ export async function runRun(args, deps = {}) {
129
138
  const engine = typeof flags.engine === 'string' ? flags.engine : undefined;
130
139
  const tmpRoot = typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
131
140
  await sweepRoots({ root: tmpBase(tmpRoot) }); // dead runs' temp roots and orphans (§9.6, V12)
132
- let record = await startRun({ workspace, project, config: loaded.config, engine, runId, workerPid, writeRow: ledgerWriter(project), probe });
141
+ let record = await startRun({ workspace, project, config: loaded.config, engine, mode, runId, workerPid, writeRow: ledgerWriter(project), probe });
133
142
  if (!record.worker && startWorker) {
134
143
  const launched = await startWorker({ runId: record.run_id, workspace });
135
144
  const startedAt = await (probe ?? processStartTime)(launched.pid); // what the stop below re-checks against
@@ -141,7 +150,8 @@ export async function runRun(args, deps = {}) {
141
150
  throw thrown;
142
151
  }
143
152
  }
144
- out(`run ${record.run_id} started · engine ${record.engine} · worker ${record.worker ? `pid ${record.worker.pid}` : 'not pinned'}\n`);
153
+ const brief = record.mode === 'brief' ? ' · mode brief (no plan)' : '';
154
+ out(`run ${record.run_id} started · engine ${record.engine}${brief} · worker ${record.worker ? `pid ${record.worker.pid}` : 'not pinned'}\n`);
145
155
  if (!record.worker) err('run start: no worker pinned — start the worker, then `run start --reattach --run <id> --worker-pid <pid>`\n');
146
156
  return 0;
147
157
  }
@@ -211,7 +221,7 @@ async function reloadVerb(runId, { out, err }) {
211
221
  }
212
222
 
213
223
  const USAGE =
214
- 'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
224
+ 'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
215
225
 
216
226
  /** @param {string[]} args @returns {Promise<number>} */
217
227
  export default async function run(args) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * B59: the facts `fallbackLane` (./fallback-rules.mjs) needs, built from what a brief-mode
3
+ * `block open` knows before any code exists: the block's owned entries, the estimated changed
4
+ * lines (`--lines`) and `proof.tiers.high.paths` from the run's config snapshot. Pure — no disk,
5
+ * no git; the caller reads the snapshot.
6
+ *
7
+ * filesChanged the number of distinct owned entries (a directory entry counts once)
8
+ * linesAdded `--lines`, 0 when not given
9
+ * touchesMigration an owned entry names a migration (`isMigrationPath`)
10
+ * touchesPolicyOrMiddleware an owned entry names a policy or middleware (`isPolicyPath`)
11
+ * pathFloorHit a high path owns an owned entry, or an owned entry owns a high path
12
+ * (a directory entry above a high path counts: its files are in scope)
13
+ *
14
+ * The migration and policy checks are case-insensitive SUBSTRING matches anywhere in the path
15
+ * (`src/migrationHelper.ts` is a migration path), the same patterns the §3.5 review rules use
16
+ * (`rulesRisk` in src/review/engine.mjs calls these two functions).
17
+ */
18
+
19
+ import { ownsFile } from '../state/registry.mjs';
20
+
21
+ /** A path the §3.5 rules treat as a migration. @param {string} file @returns {boolean} */
22
+ export const isMigrationPath = (file) => /migration/i.test(file);
23
+
24
+ /** A path the §3.5 rules treat as a policy or middleware. @param {string} file @returns {boolean} */
25
+ export const isPolicyPath = (file) => /polic(y|ies)|middleware/i.test(file);
26
+
27
+ /**
28
+ * @param {{owned: ReadonlyArray<string>, lines?: number | null, highPaths?: unknown}} input
29
+ * `highPaths` that is not an array (absent, null) means no high paths.
30
+ * @returns {Required<Omit<import('./fallback-rules.mjs').FallbackFacts, 'keywordsFound'>>}
31
+ */
32
+ export function laneFacts({ owned, lines = null, highPaths = [] }) {
33
+ const high = (Array.isArray(highPaths) ? highPaths : []).filter((p) => typeof p === 'string' && p.length > 0);
34
+ return {
35
+ filesChanged: new Set(owned).size,
36
+ linesAdded: lines ?? 0,
37
+ touchesMigration: owned.some(isMigrationPath),
38
+ touchesPolicyOrMiddleware: owned.some(isPolicyPath),
39
+ pathFloorHit: owned.some((entry) => ownsFile(high, entry) || high.some((h) => ownsFile([entry], h))),
40
+ };
41
+ }
@@ -12,7 +12,7 @@
12
12
  * coder/reviewer/judge/s2/author/facts spend (plus `ledger add coder` manual rows and
13
13
  * `session.background` rows, whose price is unknown); its dollars come from `./spend.mjs`
14
14
  * (`usd`, else `cost_usd`, else ESTIMATED from its tokens × `./prices.mjs`); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
15
- * {block,question,source}; `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
15
+ * {block,question,source}; `run.start` {run}; `run.mode` {run,mode,plan} (B59, `buildRunModes`); `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
16
16
  * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason}.
17
17
  */
18
18
 
@@ -60,6 +60,20 @@ export function buildSpend(rows) {
60
60
  return { runs: spendByRun(acted), total: sumSpend(acted) };
61
61
  }
62
62
 
63
+ /**
64
+ * B59: the mode of every run with a `run.start` row, in ledger order — `brief` when the run has a
65
+ * `run.mode {mode: brief}` row, else `plan` (a run with no `run.mode` row is a plan run). Like the
66
+ * spend, it is terminal text only (`src/cli/report.mjs`): it is not in `--json` or `--export`, so
67
+ * the pinned `SECTION_NAMES` sections stay exactly as they are.
68
+ * @param {Array<Record<string, any>>} rows
69
+ * @returns {Array<{run: string, mode: 'brief' | 'plan'}>}
70
+ */
71
+ export function buildRunModes(rows) {
72
+ const brief = new Set(rows.filter((r) => r.event === 'run.mode' && r.mode === 'brief' && typeof r.run === 'string').map((r) => r.run));
73
+ const runs = [...new Set(rows.filter((r) => r.event === 'run.start' && typeof r.run === 'string').map((r) => r.run))];
74
+ return runs.map((run) => ({ run, mode: brief.has(run) ? 'brief' : 'plan' }));
75
+ }
76
+
63
77
  /**
64
78
  * Cost per block — completed AND still open (B33: a running total while the block is open):
65
79
  * review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (any `block.close` row) and
@@ -51,6 +51,7 @@ import path from 'node:path';
51
51
  import { resolveLevel } from '../config/known-ids.mjs';
52
52
  import { blockKind, isDocsKind } from '../decide/escalation.mjs';
53
53
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
54
+ import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
54
55
  import { tierFor } from '../proof/tiers.mjs';
55
56
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
56
57
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
@@ -152,8 +153,8 @@ export function rulesRisk({ file, plusCount, cfg }) {
152
153
  pathFloorHit,
153
154
  filesChanged: 1,
154
155
  linesAdded: plusCount,
155
- touchesMigration: /migration/i.test(file),
156
- touchesPolicyOrMiddleware: /polic(y|ies)|middleware/i.test(file),
156
+ touchesMigration: isMigrationPath(file),
157
+ touchesPolicyOrMiddleware: isPolicyPath(file),
157
158
  });
158
159
  }
159
160
 
@@ -11,7 +11,7 @@
11
11
  * B12) — this module never imports them (C17).
12
12
  */
13
13
 
14
- import { createHash } from 'node:crypto';
14
+ import { createHash, randomUUID } from 'node:crypto';
15
15
  import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
@@ -299,10 +299,16 @@ function refuseConfigOwner(owned, kinds, guarded) {
299
299
  * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
300
300
  * B58: with `review.external.required` set, an owned entry that owns the project's config is
301
301
  * refused (`config-owned`, {@link refuseConfigOwner}).
302
+ * B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
303
+ * naming the block, with `decisionId` (default a fresh UUID) as its `decision_id`, just before the
304
+ * `dispatch` row — under the same lock, so a refused open writes neither. Its `answer` is the
305
+ * requested lane and must equal `level` (else `bad-decision`, nothing written); the docs floor may
306
+ * still raise the dispatched level, and the dispatch row then names the lane as `requested_level`.
302
307
  * @param {{
303
308
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
304
309
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
305
310
  * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date,
311
+ * decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
306
312
  * }} opts
307
313
  * @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
308
314
  */
@@ -313,6 +319,7 @@ export async function openBlock(opts) {
313
319
  if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
314
320
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
315
321
  if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
322
+ if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
316
323
  const owned = assertOwned(opts.owned);
317
324
  const acceptance = assertAcceptance(opts.acceptance);
318
325
  const kind = blockKind({ owned, declared: opts.kind });
@@ -345,7 +352,10 @@ export async function openBlock(opts) {
345
352
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
346
353
  return {
347
354
  result: entry,
348
- rows: [{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
355
+ rows: [
356
+ ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
357
+ { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines },
358
+ ],
349
359
  };
350
360
  });
351
361
  return { block, pointer };
@@ -154,22 +154,31 @@ export function snapshotFor(record, ticket) {
154
154
  }
155
155
 
156
156
  /**
157
- * B58: the outside reviewer named by the snapshot in force when `review.external.required` is
158
- * true; null otherwise (key absent, `required` false, or a record from before snapshots). The key
159
- * is immutable ({@link IMMUTABLE_KEYS}), so this is the value `run start` stored.
157
+ * The config snapshot in force for the run (no ticket pin), or null for a record from before
158
+ * snapshots.
160
159
  * @param {Record<string, any>} record
161
- * @returns {string | null}
160
+ * @returns {Record<string, any> | null}
162
161
  * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
163
162
  */
164
- export function externalReviewer(record) {
165
- let snap;
163
+ export function configInForce(record) {
166
164
  try {
167
- snap = snapshotFor(record, '');
165
+ return snapshotFor(record, '')?.config ?? null;
168
166
  } catch (thrown) {
169
167
  if (!(thrown instanceof Error) || thrown.message !== 'config-snapshot') throw thrown;
170
168
  throw new StateError('config-snapshot', `run ${record.run_id}: the config snapshot in force does not match its hash`);
171
169
  }
172
- const external = snap?.config.review?.external;
170
+ }
171
+
172
+ /**
173
+ * B58: the outside reviewer named by the snapshot in force when `review.external.required` is
174
+ * true; null otherwise (key absent, `required` false, or a record from before snapshots). The key
175
+ * is immutable ({@link IMMUTABLE_KEYS}), so this is the value `run start` stored.
176
+ * @param {Record<string, any>} record
177
+ * @returns {string | null}
178
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
179
+ */
180
+ export function externalReviewer(record) {
181
+ const external = configInForce(record)?.review?.external;
173
182
  if (!isMapping(external) || external.required !== true) return null;
174
183
  // required without a usable name is no switch at all (the schema refuses it; this is the floor)
175
184
  const name = typeof external.name === 'string' ? external.name.trim() : '';
package/src/state/run.mjs CHANGED
@@ -132,11 +132,17 @@ async function pinWorker(workerPid, probe) {
132
132
  return { pid: workerPid, started_at: startedAt };
133
133
  }
134
134
 
135
+ /** B59: the run modes `run start --mode` takes; `plan` is the default and is never recorded. */
136
+ export const RUN_MODES = Object.freeze(['plan', 'brief']);
137
+
135
138
  /**
136
139
  * `run start`: record + key + worker pin + ledger `run.start`, all under the run lock. An
137
140
  * existing run id is refused before anything is written (its key and record stay untouched).
141
+ * B59: `mode: 'brief'` (a run with briefs and no plan) is stored in the record as `mode` and
142
+ * written as a signed `run.mode {mode: 'brief', plan: 'none'}` row right after `run.start`; `plan`
143
+ * or no mode leaves the record and the rows exactly as before. Nothing changes the mode later.
138
144
  * @param {{
139
- * workspace: string, project: string, config?: Record<string, any>, engine?: string,
145
+ * workspace: string, project: string, config?: Record<string, any>, engine?: string, mode?: string,
140
146
  * runId?: string, workerPid?: number, orchestratorPid?: number, soloPid?: string | null,
141
147
  * writeRow: WriteRow, probe?: StartTimeProbe, now?: Date,
142
148
  * }} opts
@@ -149,6 +155,8 @@ export async function startRun(opts) {
149
155
  throw new StateError('bad-project', 'project slug must match /^[a-z0-9][a-z0-9-]*$/ (config key project.slug)');
150
156
  }
151
157
  const workspace = assertWorkspaceOutsideRuns(opts.workspace);
158
+ if (opts.mode !== undefined && !RUN_MODES.includes(opts.mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
159
+ const brief = opts.mode === 'brief';
152
160
  // B58: the schema lets `review.external` stand without a name while `required` is off; on, the
153
161
  // reviewer must be named — refused before anything is written
154
162
  const external = config.review?.external;
@@ -172,6 +180,7 @@ export async function startRun(opts) {
172
180
  status: 'active',
173
181
  blocks: {},
174
182
  orphans: [],
183
+ ...(brief ? { mode: 'brief' } : {}),
175
184
  // B35: the config snapshot the worker reviews with (`run reload` replaces it). Only when the
176
185
  // caller passed the loaded config: a record without it keeps the worker on its boot config.
177
186
  ...(opts.config !== undefined ? { config: initialConfigState(config) } : {}),
@@ -185,6 +194,7 @@ export async function startRun(opts) {
185
194
  await generateKey(runId);
186
195
  await saveRun(record);
187
196
  await writeSigned(runId, writeRow, { event: 'run.start', engine: record.engine, worker_pinned: worker !== null });
197
+ if (brief) await writeSigned(runId, writeRow, { event: 'run.mode', mode: 'brief', plan: 'none' });
188
198
  // B58: the owner's outside reviewer is on the ledger from the first row of the run on
189
199
  const reviewer = externalReviewer(record);
190
200
  if (reviewer !== null) await writeSigned(runId, writeRow, { event: 'review.external', required: true, reviewer });