@codedology/code-forge 0.6.0 → 0.6.1

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,28 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.1] — 2026-10-07
10
+
11
+ ### Added
12
+
13
+ - Skill rule R17 "Never relax a rule alone": the orchestrator and any delegate never change, relax or remove a limit,
14
+ threshold, budget, forbidden entry, gate or review requirement without the owner; when a limit blocks work they stop,
15
+ show the data, propose options and wait. The skill also asks to supervise cost continuously (budget.usd, spend after
16
+ every block close).
17
+
18
+ ### Fixed
19
+
20
+ - A verb run from a subfolder uses the project root (the git root, or the nearest regular `.code-forge.yml` below it;
21
+ outside git never above HOME) for its config and ledger slug: `jev`, `plan`, `author`, `facts`, `spawn`, `s2` and
22
+ `run start`. A `.git` at HOME (a dotfiles repo) does not make HOME a project.
23
+ - `plan check` reads the list form of the "cannot back" section that the template shows, still reads the old table
24
+ form, and splits clauses on `;` and on ` · (n)`.
25
+ - `facts`: a flag claim is proved only by `grep -rn -- <flag> <source folder>` (hits in source files under that
26
+ folder; docs, plans, tests, fixtures and the brief never count). The delegate never runs `--help`, `-h`, `--version`,
27
+ a pipe, a list or `rg`; grep options come from a fixed allow-list. No Claude allow rules are added.
28
+ - A command claim written without its CLI (`run reload`) gets the project's own bin only when the brief names that bin
29
+ and the first word is not a well-known CLI.
30
+
9
31
  ## [0.6.0] — 2026-10-06
10
32
 
11
33
  ### Added
package/README.md CHANGED
@@ -8,17 +8,39 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
8
8
  > `examples/` have all landed (`docs/reference/blocks.json` lists every build block). What changed
9
9
  > in each version is in [CHANGELOG.md](CHANGELOG.md).
10
10
 
11
- ## What's new (next release)
11
+ ## What's new in 0.6
12
+
13
+ Next release (0.6.1):
14
+
15
+ - **Never relax a rule alone.** A new skill rule (R17): the orchestrator and the delegate never
16
+ change, relax or remove a limit, threshold, budget, gate or review requirement. When a limit
17
+ blocks work they stop, show the data, propose options and wait for you.
18
+ - **Fixes from using code-forge on itself.** A verb run from a subfolder now uses the project's
19
+ ledger and config. `plan check` reads the list form the template shows. `facts` checks a flag
20
+ with `grep -rn -- <flag> <source folder>` instead of `--help`, so the check runs in the
21
+ delegate's sandbox.
22
+
23
+ In 0.6.0:
12
24
 
13
25
  - **Autopilot.** Step away for up to 24 hours. `code-forge autopilot start` grants a delegate (a
14
- fresh L2 or L3 session) a few of your decisions: waive a warning or a nit, one extra fix round,
15
- the coder level. A fixed deny list keeps the rest yours: critical waivers, plan and design
16
- approval, limits, budget raises, merges, destructive actions. Each category can pause at a
17
- budget stop, and every answer is a signed ledger row.
26
+ fresh closed-book L2 or L3 session, never Codex) a few of your decisions: waive a warning or a
27
+ nit, one extra fix round past the review cap, the coder level. The orchestrator asks the
28
+ delegate first (`autopilot ask`) and acts only on an answer it acted on. A fixed deny list in
29
+ the code keeps the rest yours: critical and proof waivers, skipping reviews, plan and design
30
+ approval, any limit or rule change, budget raises, merges, destructive actions. Every answer is
31
+ a signed ledger row; each waiver opens a GitHub issue. See [docs/autopilot.md](docs/autopilot.md).
32
+ - **Budget per category.** `--budget review=<usd>,coding=<usd>` pauses a category at `--stop-at`
33
+ (default 0.9) of its cap until you return. `autopilot approve` (only you, at a terminal) changes
34
+ one setting until a time and puts it back by itself. While a grant is active, `run reload`
35
+ refuses any limit key.
18
36
  - **A live log for your return.** In Claude Code the orchestrator keeps one Claude Docs page per
19
- run with two tabs: Binnacle (the summary) and Full log (every event, newest first).
20
- `autopilot status` and `autopilot stop` print its link; without the connector they print the two
21
- Markdown files. See [docs/autopilot.md](docs/autopilot.md).
37
+ run with two tabs: Binnacle (status at a glance, decisions, blocks, open questions, actions only
38
+ you can take, incidents, timeline) and Full log (every event, newest first). `autopilot status`
39
+ and `autopilot stop` print its link; without the connector they print two Markdown files in the
40
+ run dir (`autopilot binnacle`, `autopilot log`).
41
+ - **From 0.5.x, not listed before:** two files of one block can no longer both take the block's one
42
+ L3 patch rung (0.5.0), and a maintainer can install a tagged release before it is on npm
43
+ ([getting-started](docs/getting-started.md#keeping-it-up-to-date), 0.5.1).
22
44
 
23
45
  ## What's new in 0.5
24
46
 
@@ -143,7 +165,7 @@ usage line.
143
165
  | `validate` | `code-forge validate [--file <path>]` |
144
166
  | `resolve` | `code-forge resolve <L0\|L1\|L2\|L3>` |
145
167
  | `run` | `code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]` · `run start --reattach --run <id> [--worker-pid <pid>]` · `run status --run <id>` (with spent and budget USD) · `run reload --run <id>` (re-read `.code-forge.yml` mid-run) · `run end --run <id>` |
146
- | `block` | `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]` · `block attempt\|rebase <id> --run <r>` · `block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews]` · `block claim <id> <path> --run <r>` · `block stop <id> --run <r> --reason <text>` · `block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (human-only) |
168
+ | `block` | `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]` · `block attempt\|rebase <id> --run <r>` · `block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews]` · `block claim <id> <path> --run <r>` · `block stop <id> --run <r> --reason <text>` · `block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (human-only) |
147
169
  | `worker` | `code-forge worker --run <id> [--cwd <dir>] [--poll-ms <n>] [--once]` — started detached by `run start`, never by a coder |
148
170
  | `review` | `code-forge review [--base <ref>] [--files <path…>] [--acceptance <file> \| --intent "<text>"] [--run <id>] [--max <seconds>] [--json] [--keep-run]` — review only: no coder, no proof, never closes a block |
149
171
  | `review-file` | `code-forge review-file <path> --block <id> [--run <id>]` (enqueue) · `review-file --wait <ticket> [--max <seconds>s]` (poll, default 90s; still running at `--max` prints `status: pending, reason: wait_timeout`) |
@@ -164,7 +186,7 @@ usage line.
164
186
  | `upgrade` | `code-forge upgrade [--source <path>]` |
165
187
  | `logs` | `code-forge logs [--last N] [--json]` · `logs summary [--days N] [--json]` · `logs clear [--yes]` · `logs path` — the local error log (`~/.code-forge/logs/errors.jsonl`): errors, warnings, and `[fixed in X.Y.Z]` marks |
166
188
  | `logs report` | `code-forge logs report [--last N] [--kind K] [--verb V] [--note "text"] [--include-warnings] [--with-doctor] [--no-ai] [--allow-old] [--force] [--dry-run] [--yes]` — share selected errors as a public GitHub issue; you see the full text first and say yes once. An error a newer version fixes is not filed unless `--force` |
167
- | `autopilot` | `code-forge autopilot start --run <id> --until <ISO-8601 with offset> --delegate <L2\|L3> --allow <scope,…> [--deny <scope,…>] [--budget <category>=<usd>,…] [--stop-at <0..1>] [--yes]` · `autopilot status\|stop --run <id>` (print the live log's link, or the two Markdown files) · `autopilot ask --run <id> --scope <scope> --question <text> [--options a,b,…] [--block <id>] [--file <path>] [--finding <id>] [--json]` · `autopilot waive\|round\|level … --decision <id>` · `autopilot approve --run <id> --key <dot.path> --value <json> --until <time>` (owner only, at a terminal) · `autopilot binnacle --run <id> [--json\|--markdown] [--link <https url>]` · `autopilot log --run <id> [--json\|--markdown]` — the owner's time-boxed grant to a delegate; see [docs/autopilot.md](docs/autopilot.md) |
189
+ | `autopilot` | `code-forge autopilot start --run <id> --until <ISO-8601 with offset> --delegate <L2\|L3> --allow <scope,…> [--deny <scope,…>] [--budget <category>=<usd>,…] [--stop-at <0..1>] [--yes]` · `autopilot status --run <id> [--json]` · `autopilot stop --run <id>` (both print the live log's link, or the two Markdown files) · `autopilot ask --run <id> --scope <scope> --question <text> [--options a,b,…] [--context-file <path>] [--block <id>] [--file <path>] [--finding <id>] [--json]` · `autopilot waive\|round\|level … --decision <id>` · `autopilot approve --run <id> --key <dot.path> --value <json> --until <time>` (owner only, at a terminal) · `autopilot binnacle --run <id> [--json\|--markdown] [--link <https url>]` · `autopilot log --run <id> [--json\|--markdown]` — the owner's time-boxed grant to a delegate; see [docs/autopilot.md](docs/autopilot.md) |
168
190
  | `help` / `version` | `code-forge --help` (or no verb) · `code-forge --version` |
169
191
 
170
192
  `run`, `block`, `worker`, `spawn`, `s2`, `author`, `facts` and `review-file` are what the Agent
@@ -203,7 +225,8 @@ CLI verbs like any other and every one of them is exercised by this package's ow
203
225
 
204
226
  `schema/code-forge.schema.json` is the source of truth for `.code-forge.yml`.
205
227
  [docs/reference/config.md](docs/reference/config.md) lists every key with its type and default.
206
- Keys added in 0.4 and 0.5 (the last two rows are new in 0.5):
228
+ Keys added in 0.4, 0.5 and 0.6 (`review.parallel_tickets` and `review.provider_concurrency` are
229
+ new in 0.5, `autopilot.min_confidence` in 0.6):
207
230
 
208
231
  | Key | Default | What it does |
209
232
  |---|---|---|
@@ -217,6 +240,7 @@ Keys added in 0.4 and 0.5 (the last two rows are new in 0.5):
217
240
  | `review.session_timeout_s` | `300` | a review session that runs longer is killed and retried once |
218
241
  | `review.parallel_tickets` | `3` | review tickets the worker runs at once (1 to 16); `1` reviews one file at a time |
219
242
  | `review.provider_concurrency` | anthropic `4`, openai `2`, xai `2` | review sessions at once per provider |
243
+ | `autopilot.min_confidence` | `0.7` | the lowest delegate confidence that is acted on; below it the question goes to you |
220
244
 
221
245
  `code-forge block open … --kind code|docs|contract` sets a block's kind when its file endings do
222
246
  not say it. The reference page is generated:
package/docs/README.md CHANGED
@@ -9,9 +9,9 @@ disagree, the verb is right: each verb prints its own usage line when its argume
9
9
  | # | Page | Read it when you want to… |
10
10
  |---|---|---|
11
11
  | 1 | [overview.md](overview.md) | know what code-forge is, the problem it solves, who it is for, and what it is not |
12
- | 2 | [concepts.md](concepts.md) | look up a word: block, level, lane, tier, S1/S2, waiver, ledger, budget, block kind, closed book, known fix… |
13
- | 3 | [how-it-works.md](how-it-works.md) | follow the full workflow from a brief to a closed block, and see who does what and where state lives |
14
- | 4 | [getting-started.md](getting-started.md) | install it, answer `init`, run `doctor`, try the first commands, and read or report errors (`code-forge logs`) |
12
+ | 2 | [concepts.md](concepts.md) | look up a word: block, level, lane, tier, S1/S2, waiver, ledger, budget, block kind, closed book, plan approval, owner rule, autopilot grant, known fix… |
13
+ | 3 | [how-it-works.md](how-it-works.md) | follow the full workflow from a brief to a closed block (plan approval, parallel review, the escalation ladder, the rules the orchestrator keeps), and see who does what and where state lives |
14
+ | 4 | [getting-started.md](getting-started.md) | install it, answer `init`, install the recommended tools (`code-forge tools`), run `doctor`, set a budget, upgrade, and read or report errors (`code-forge logs`) |
15
15
  | 5 | [tutorial-first-test.md](tutorial-first-test.md) | run one real end-to-end test: build the code-forge marketing site in a new repo |
16
16
  | 5a | [review-only.md](review-only.md) | only review code you already wrote: a branch, uncommitted work or some files, with CI exit codes |
17
17
  | 5b | [autopilot.md](autopilot.md) | step away while a run goes on: grant a delegate a few decisions for a set time, and review the live log (Binnacle and Full log) when you return |
@@ -19,7 +19,7 @@ disagree, the verb is right: each verb prints its own usage line when its argume
19
19
  | 7 | [marketing.md](marketing.md) | read the product story: the problem, the promise, the honest limits |
20
20
 
21
21
  New to the tool? Read 1 → 3 → 4, then do 5. Keep 2 open as a glossary. Only want reviews? Read 4,
22
- then 5a.
22
+ then 5a. Going away while a run goes on? Read 5b.
23
23
 
24
24
  ## Reference pages (already in the repo)
25
25
 
@@ -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": 52,
7
+ "block_count": 54,
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": {
@@ -701,6 +701,37 @@
701
701
  "B49a"
702
702
  ],
703
703
  "note": "root-added 2026-10-06; plan plans/autopilot (issue #5); also amends src/cli/autopilot.mjs, skill/SKILL.md, skill/references/{decisions,review,harden,plan,code}.md, skill/references/adapters/claude-code.md, README.md, docs/README.md, docs/concepts.md, docs/getting-started.md"
704
+ },
705
+ "B50": {
706
+ "title": "Dogfood tool fixes: the project root (git root, or the nearest regular .code-forge.yml below it; outside git never above HOME) sets the config and ledger slug for every verb run from a subfolder; plan check reads the list form and the old table form of the cannot-back section and splits clauses on ';' and ' · (n)'; the facts delegate proves a flag only with grep -rn -- <flag> <source folder> (exact option allow-list, no --help, no pipes, no rg, hits only in source files under the path); a command claim without its CLI gets the project bin only when the brief names it",
707
+ "wave": 11,
708
+ "level": "L2",
709
+ "status": "landed",
710
+ "owned": [
711
+ "src/config/load.mjs",
712
+ "src/session/plan-check.mjs",
713
+ "src/session/facts.mjs",
714
+ "test/session/plan-check.test.mjs",
715
+ "test/session/facts.test.mjs"
716
+ ],
717
+ "depends_on": [
718
+ "B49b"
719
+ ],
720
+ "note": "root-added 2026-10-06; dogfood findings from plans/parallel-review §6.1 and plans/autopilot; no Claude allow rules (rejected: an allow-list of commands is unsafe); also amends src/cli/{author,facts,jev,plan,run,s2,spawn}.mjs, src/autopilot/limits.mjs, skill/references/plan.md, skill/templates/plan.md, docs/how-it-works.md, docs/tutorial-first-test.md"
721
+ },
722
+ "B51": {
723
+ "title": "Skill rule R17 'Never relax a rule alone' and cost supervision; owner rules and docs gaps filled",
724
+ "wave": 11,
725
+ "level": "L1",
726
+ "status": "landed",
727
+ "owned": [
728
+ "skill/references/rules-core.md",
729
+ "test/skill/structure.test.mjs"
730
+ ],
731
+ "depends_on": [
732
+ "B49b"
733
+ ],
734
+ "note": "root-added 2026-10-06; also amends skill/SKILL.md, skill/references/{autopilot,decisions,ledger}.md, README.md, docs/*"
704
735
  }
705
736
  }
706
737
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
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
@@ -62,6 +62,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
62
62
  - **R14** No design before facts
63
63
  - **R15** Re-checks shrink: fix hunks only, four rounds, then a patch, then a human
64
64
  - **R16** Proof is exact assertions on the main behaviour
65
+ - **R17** Never relax a rule alone
65
66
 
66
67
  ## §5 Where to read more
67
68
 
@@ -45,4 +45,4 @@ These never go to the delegate, whatever the grant says (the CLI's fixed deny li
45
45
  - a critical waiver, a proof waiver, or closing a block without its reviews;
46
46
  - merges and destructive actions.
47
47
 
48
- When one of these comes up during the window: stop that line of work, add it to Actions only you can take, and wait.
48
+ When one of these comes up during the window: stop that line of work, add it to Actions only you can take, and wait. The delegate never relaxes a rule or a limit to keep work moving (`rules-core.md` R17).
@@ -50,6 +50,6 @@ A fresh L3 session receives the failure evidence — the last attempt's diff, th
50
50
 
51
51
  ## §7 What reaches the human
52
52
 
53
- `next = stop`, `review.over_budget`, an orphan file, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded.
53
+ `next = stop`, `review.over_budget`, an orphan file, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
54
54
 
55
55
  **Autopilot:** while a grant is active, a warning or nit waiver, one extra round at `review_cap` and a coder level go first to the delegate (`forge autopilot ask`, then `forge autopilot waive|round|level --decision <id>` only when it acted, `autopilot.md` §2). Never delegated: `next = stop`, `ask_human`, a security question, a critical finding, a budget raise, `redecompose` — they wait for the owner (`autopilot.md` §5).
@@ -18,6 +18,8 @@ Terminal output in this version. Sections: cost per block — completed and stil
18
18
 
19
19
  Every `session` row carries `usd` (tokens × the static price table) or `usd: null, usd_unknown: true` when the provider/level has no price — never a guess; a `session.background` coder's price is always unknown. Spend code-forge never saw (a cloud or hand-run coder) goes in with `forge ledger add coder --run <r> --block <b> --usd <n> [--note "..."]`, a signed `session` row with `manual: true`. With `budget.usd` set, every session spawn first sums the run's spend: at 80 % one `budget.warning` row and one stderr line per run; at 100 % the session is refused (`budget.usd 20.00 reached (spent 20.13); raise budget.usd or end the run`), a `budget.refused` row is written, nothing is spawned, and a review reports `review.unavailable {reason: budget}`. A budget whose spend cannot be read refuses too (fail closed).
20
20
 
21
+ Watch the cost the whole run, not only at the end: ask the owner for a `budget.usd` before the first block when the project has none, read `spent_usd` / `budget_usd` in `forge run status` after every block close, and record cloud or hand-run coder spend with `forge ledger add coder` as soon as it is known. A budget stop is the owner's call (`rules-core.md` R6, R17).
22
+
21
23
  Sessions that run at the same time (B41): each session first takes its provider slot, then — under one in-process lock per run — reads the spend, checks it and reserves its estimated cost (the packet's bytes / 4 in, plus a fixed tokens_out per role and level, priced with the same table; a session waiting for a slot reserves nothing). A session is refused when spent + reserved by running sessions + its own estimate passes the budget: `budget.usd 20.00 would be exceeded: spent 3.10, reserved by running sessions 15.80, this session about 1.92; it starts when running sessions finish or when budget.usd is raised`, or, when its estimate alone is above the budget, `this session's estimated cost (about 64.00) is above budget.usd 20.00; raise budget.usd`; the `budget.refused` row then carries `reserved_usd` and `estimate_usd`. The reservation is released when the session's row is written, or when the spawn fails or throws. The 80 % warning is checked under the same lock, so it is written once per run.
22
24
 
23
25
  Known limits, accepted: unknown-price sessions and `session.background` coders add **0** to the spent total — record their real cost with `forge ledger add coder --usd <n>`; a background coder's reservation is released at its start row, since its cost is never seen; a session refused because of running sessions' reservations is refused, not queued — run it again once they finish; `forge run status` shows `unknown_usd_sessions` and prints `N sessions have no known price; the budget does not count them` when N > 0. The lock and the reservations live in one process only: two separate code-forge processes starting a session of one run at the same moment can still each pass the check or each write a `budget.warning` row.
@@ -4,8 +4,8 @@
4
4
 
5
5
  Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.facts.md` before anything else. What it does:
6
6
 
7
- 1. Deterministic: the CLI extracts every *claim token* from the brief and the sources it names — CLI flags, `<cli> <verb>` commands, paths, versions, endpoints, package names, environment variable names — and writes a claim list.
8
- 2. One L0 session whose cwd is a **read-only snapshot of the repository at HEAD plus the `--sources` files**, built under the run's temp root and removed at session end — so `./path` claims are checkable, while **uncommitted files are not visible unless named as sources** (the snapshot is HEAD, not the working tree) — and whose packet is the claim list and the rule "run the cheapest read-only check, quote the command and the output line, tag VERIFIED / NOT-FOUND / UNVERIFIABLE, never infer". Two lines of defence, exactly as built. **First, at spawn:** the session gets the shell tool only, with the forbidden list plus every write verb rendered as deny rules and the provider's restricted mode (file tools confined to the working directory). There is no pre-execution gate on individual commands beyond those deny rules: the delegate runs what it runs. **Second, after the fact:** the CLI checks every `command` the delegate reports against an allow-list, fail closed, and refuses the whole sheet on the first violation. Allowed forms: `ls cat head tail wc grep rg stat file uname sw_vers jq which` with in-cwd arguments (`grep` never recursive), `find` with only these predicates — `-name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin -not ! -a -o -and -or` — every other predicate refused, `git log|show|status|rev-parse|ls-files|cat-file|--version`, `npm view|ls|--version`, `node --version`, `test -e|-f|-d|-n <one operand>`, `curl` as a HEAD request of exactly one http(s) URL with `-s -S -I -L -f --max-time <N>` only, and `<cli> --help|-h|--version` for a bare CLI name that is not a build runner; an env claim is only `printenv NAME >/dev/null` with its excerpt blanked. Refused anywhere: backticks, `$`, redirection, unquoted globs, `~`, `..`, absolute paths outside `/usr`, `/opt/homebrew`, `/bin`, and secret-looking paths. **`npm view` and `curl -I` reach the network** — they are what makes a package version or an endpoint checkable; nothing else the delegate may run does.
7
+ 1. Deterministic: the CLI extracts every *claim token* from the brief and the sources it names — CLI flags, `<cli> <verb>` commands, paths, versions, endpoints, package names, environment variable names — and writes a claim list. A `<cli> <verb>` written without its CLI (`run reload`) whose first word is not one of the project's CLIs, not a well-known CLI (`git gh npm npx node pnpm yarn bun deno ls cat grep which find test make docker curl claude codex python python3 pip go cargo op`), and that the brief never uses as a CLI of its own (`` `gh` `` or `` `gh --repo x` `` in a code span) — decided from the brief and `package.json` alone, never PATH — becomes `<project CLI> <verb>` (`code-forge run reload`) when the brief itself (not a source) names one of the project's CLIs (its `package.json` `bin` names) as a whole word — `.code-forge.yml` or `code-forge.config` do not count; `plan check` then counts a clause citing `run reload` as backed by that VERIFIED fact, and only when the extra word is one of the project's own CLIs.
8
+ 2. One L0 session whose cwd is a **read-only snapshot of the repository at HEAD plus the `--sources` files**, built under the run's temp root and removed at session end — so `./path` claims are checkable, while **uncommitted files are not visible unless named as sources** (the snapshot is HEAD, not the working tree) — and whose packet is the claim list and the rule "run the cheapest read-only check, quote the command and the output line, tag VERIFIED / NOT-FOUND / UNVERIFIABLE, never infer". Two lines of defence, exactly as built. **First, at spawn:** the session gets the shell tool only, with the forbidden list plus every write verb rendered as deny rules and the provider's restricted mode (file tools confined to the working directory). Nothing is pre-approved: the delegate only runs what the provider already treats as read-only. A flag claim is therefore checked in the project's **source text**, never by running a program: `grep -rn -- <flag> <path>` over a top-level source folder of the snapshot such as `src` (never `.` or the root); it is VERIFIED only when the claim is that flag and the excerpt has a `file:line:text` hit from a source file — not `.md`/`.markdown`/`.txt`, not under `docs/ plans/ test/ tests/ fixtures/ node_modules/`, not the brief, and under the grep's path — holding the flag as a whole token (`--force-push` does not prove `--force`); a NOT-FOUND counts only over one whole top-level source folder that exists as a directory in the snapshot (not hidden, not `dist build coverage out` or the folders above). The grep's path must be an existing source directory of the snapshot (a nested one such as `src/cli` is fine for VERIFIED; a single file never is), and no other command — a plain grep included — ever proves or disproves a flag. Every check is ONE command: no pipe and no list (`| ; && || &`). There is no pre-execution gate on individual commands beyond the deny rules: the delegate runs what it runs. **Second, after the fact:** the CLI checks every `command` the delegate reports against an allow-list, fail closed, and refuses the whole sheet on the first violation. Allowed forms (never `rg`, which searches recursively by default): `ls cat head tail wc grep stat file uname sw_vers jq which` with in-cwd arguments (`grep` options only `-n -c -i -w -F` before `--`, recursive only as the whole command `grep -rn -- <--long-flag> <path>` with one relative subfolder or file, never `.` or a secret path), `find` with only these predicates — `-name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin -not ! -a -o -and -or` — every other predicate refused, `git log|show|status|rev-parse|ls-files|cat-file`, `npm view|ls`, `test -e|-f|-d|-n <one operand>`, and `curl` as a HEAD request of exactly one http(s) URL with `-s -S -I -L -f --max-time <N>` only. No program ever runs with `--help`, `-h` or `--version` (BSD `rm foo --help` deletes `foo`): a version claim is checked with `npm view`, `which` or `ls`; an env claim is only `printenv NAME >/dev/null` with its excerpt blanked. Refused anywhere: backticks, `$`, redirection, unquoted globs, `~`, `..`, absolute paths outside `/usr`, `/opt/homebrew`, `/bin`, and secret-looking paths. **`npm view` and `curl -I` reach the network** — they are what makes a package version or an endpoint checkable; nothing else the delegate may run does.
9
9
  **Path claims:** `./path` claims resolve against the HEAD snapshot, so a committed file is VERIFIED or NOT-FOUND by a plain `ls`/`test -e`; a file that exists only uncommitted in the working tree reads NOT-FOUND unless you passed it with `--sources`. `~/…` claims stay UNVERIFIABLE (`~` is refused), and absolute paths are allowed only under `/usr`, `/opt/homebrew` and `/bin`; a home-directory path that matters to a block is checked by you with one read-only command and recorded in the unbackable list with that command as the tolerance.
10
10
  3. Deterministic again: the answer is validated against the facts schema, rendered with `templates/facts-sheet.md`, and refused when a VERIFIED tag has no output excerpt. Every claim, command and excerpt passes through the redactor. The header records the brief's sha and mtime.
11
11
 
@@ -23,7 +23,7 @@ Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.
23
23
  | `owned_files` | exact paths or disjoint globs; a glob never contains `[ ] ( ) ! + @` (an exact path may); pairwise disjoint across blocks that can run together |
24
24
  | `depends_on` | acyclic; a block imports only modules of blocks it depends on — a batch-mate's module is a `next = stop` |
25
25
  | `level` | the S1 `lane` answer recorded for the block — `forge jev ask lane --block <id> --plan <plan> --state <file>`, or `--rules` (the deterministic fallback) when Jev is unavailable; never a block category (`L0` trivial · `L1` a plain feature following a pattern · `L2` money, dates, tenancy, migrations, concurrency · `split` too large or two concerns); **L3 is not a lane** |
26
- | `acceptance` | clauses that each name ≥ 1 test id; a clause citing a flag, path, version or API cites a VERIFIED fact id or sits in the unbackable list with a tolerance naming this block |
26
+ | `acceptance` | clauses that each name ≥ 1 test id, numbered `(1) … · (2) …` or separated by `;` (`plan check` splits the cell on both and checks each clause on its own); a clause citing a flag, path, version or API cites a VERIFIED fact id or sits in the unbackable list with a tolerance naming this block |
27
27
  | `cases`, `lines` | forecasts: `cases = 2 × counted clauses`, `lines = 26 × cases` with a floor of 1 200 for a greenfield block and 500 for an amendment; above `budget.block_cases` or `budget.block_lines` the block is split in the plan, never "split-ready" |
28
28
  | `test_command` | a quoted glob or an explicit file list, never a bare directory |
29
29
  | `commit_message` | house style |
@@ -36,7 +36,7 @@ Every clause of the parent's acceptance maps to exactly one block; an unmapped c
36
36
 
37
37
  ## §4 The deterministic exit — `forge plan check <plan> [--facts <sheet>]`
38
38
 
39
- Prints every failing row by name and exits 1 on any of: an acceptance clause with a claim token that resolves to no VERIFIED fact and no tolerance row (`unbackable clause without tolerance: <block> <clause>`); a missing caller map or a block absent from it; a block without `cases` and `lines`; a forecast above `budget.block_cases` / `budget.block_lines` without a named split; overlapping `owned_files` in one wave; a cyclic `depends_on` or an import of a batch-mate; a glob with `[ ] ( ) ! + @`; more than `caps.coders` concurrent blocks in a slot; `questions[]` still open; the facts sheet's sha absent from the draft; a block whose level has no recorded lane, or differs from it (`block <id>: level L2 differs from the recorded lane L1 (jev)`). A required section under a near-miss heading at its template position (§0.x, §3) passes with a `WARN` naming the exact heading. stdout ends with the lane recorded per block (`lanes: B1 L1 (jev) · B2 L0 (rules)`). Green ⇒ set `status: hardened` after the harden job (`harden.md`), never before.
39
+ Prints every failing row by name and exits 1 on any of: an acceptance clause with a claim token that resolves to no VERIFIED fact and no tolerance line (`unbackable clause without tolerance: <block> <clause>`) — a tolerance line is a list item of §0.x naming the block, the token and the word `tolerance`, as `templates/plan.md` shows (`- B3 \`--flag\` (F4, UNVERIFIABLE) — tolerance: …`); a row of the older table form with a filled tolerance column still counts (`—`, `-`, `…`, `n/a` and the like are empty); a missing caller map or a block absent from it; a block without `cases` and `lines`; a forecast above `budget.block_cases` / `budget.block_lines` without a named split; overlapping `owned_files` in one wave; a cyclic `depends_on` or an import of a batch-mate; a glob with `[ ] ( ) ! + @`; more than `caps.coders` concurrent blocks in a slot; `questions[]` still open; the facts sheet's sha absent from the draft; a block whose level has no recorded lane, or differs from it (`block <id>: level L2 differs from the recorded lane L1 (jev)`). A required section under a near-miss heading at its template position (§0.x, §3) passes with a `WARN` naming the exact heading. stdout ends with the lane recorded per block (`lanes: B1 L1 (jev) · B2 L0 (rules)`). `plan check` and `jev ask` may run from any folder of the project: both read the project root's config and ledger (the nearest `.code-forge.yml` file up to the git top level, else the git top level; outside git, never above the home directory, and only the folder itself when it is not under home), never a subfolder's name. `author`, `facts`, `spawn`, `s2` and `run start` read the same root. Green ⇒ set `status: hardened` after the harden job (`harden.md`), never before.
40
40
 
41
41
  ### §4.1 Owner approval in plan mode
42
42
 
@@ -1,4 +1,4 @@
1
- # Core rules — sixteen, each with its why
1
+ # Core rules — seventeen, each with its why
2
2
 
3
3
  The headings below are the list in `SKILL.md` §4. A rule names levels and roles, never a model. Config keys are the ones `forge validate` accepts.
4
4
 
@@ -50,6 +50,9 @@ Round n ≥ 2 reviews the fix hunks and the open findings; the open set shrinks
50
50
  ### R16 — Proof is exact assertions on the main behaviour
51
51
  One exact test per clause plus a negative or boundary where the clause has a failure mode; red→green for every new test; there is no mutation tier (`proof.md` §1). Why: exhaustive suites hid the defects the isolated review found.
52
52
 
53
+ ### R17 — Never relax a rule alone
54
+ The orchestrator and any delegate never change, relax or remove a rule, limit, permission or check without the owner: config limits, thresholds, budgets, the forbidden list, gates, review requirements, or a local patch that loosens a check. When a limit blocks work: stop, show the data, propose options, wait. Only the owner raises a limit (the budget once per run, R6); during autopilot only the owner's own `forge autopilot approve` (`autopilot.md` §5). Why: a limit relaxed to finish the work protects nothing, and the owner learns of it too late.
55
+
53
56
  ## What survives from the previous pipeline (14 rows)
54
57
 
55
58
  | survivor | lands in | kind |
@@ -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.6.0"
13
+ PINNED_VERSION="0.6.1"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -14,10 +14,9 @@
14
14
 
15
15
  ### §0.x Acceptance clauses the facts sheet cannot back
16
16
 
17
- `none` — or one row per clause:
17
+ `none` — or one list item per unbacked claim, naming the block, the claim token, its fact id and tag, and the word `tolerance` (`forge plan check` reads these lines):
18
18
 
19
- | block | clause | claim | tag | tolerance (how the block's acceptance survives it) |
20
- |---|---|---|---|---|
19
+ - B3 `--flag` (F4, UNVERIFIABLE) — tolerance: <how the block's acceptance survives it>
21
20
 
22
21
  ## §1 Architecture decision
23
22
 
@@ -29,6 +28,8 @@ Chosen approach and why · rejected alternatives (one line each) · the project
29
28
  |---|---|---|---|---|---|---|---|---|---|
30
29
  | B1 | … | L1 | — | `src/x.mjs`, `test/x.test.mjs` | 12 | 1 200 | (1) … (`test/x.test.mjs`, F3) · (2) … | `node --test 'test/x/**/*.test.mjs'` | … |
31
30
 
31
+ Acceptance clauses are numbered `(1) … · (2) …` (or separated by `;`); `forge plan check` splits the cell on both and checks each clause on its own.
32
+
32
33
  `level` is the lane recorded for the block by `forge jev ask lane --block <id>` (or `--rules`), never a category; `forge plan check` refuses any other.
33
34
 
34
35
  Split rule: `cases = 2 × clauses`, `lines = 26 × cases` (floor 1 200 greenfield, 500 amendment); above `budget.block_cases` or `budget.block_lines` the block is split here. A glob in `owned_files` never contains `[ ] ( ) ! + @`.
@@ -360,6 +360,7 @@ export function limitGuard(allow, now) {
360
360
  export async function reloadWorkspace({ runId, allow = null, writeRow, now = new Date() }) {
361
361
  const { project, workspace, status } = await readRun(runId);
362
362
  if (status !== 'active') throw new StateError('run-ended', `run ${runId} has ended`);
363
+ // the run record's workspace IS the project root (`run start` records `projectRootFor`, B50)
363
364
  const loaded = await loadProjectConfig(workspace);
364
365
  if (!loaded.ok || !loaded.config) {
365
366
  if (loaded.error === 'not-found') return { ok: false, error: 'not-found' };
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { mkdirSync, writeFileSync } from 'node:fs';
13
13
  import path from 'node:path';
14
- import { loadProjectConfig, slugFor } from '../config/load.mjs';
14
+ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
15
15
  import { AuthorError, runAuthor } from '../session/author.mjs';
16
16
  import { runRootFor, SessionError } from '../session/spawn.mjs';
17
17
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
@@ -39,7 +39,10 @@ export async function runAuthorVerb(args, deps = {}) {
39
39
  /** @param {string | string[] | true | undefined} v */
40
40
  const abs = (v) => (typeof v === 'string' ? path.resolve(cwd, v) : undefined);
41
41
  const briefPath = /** @type {string} */ (abs(flags.brief));
42
- const loaded = await loadProjectConfig(cwd);
42
+ // B50: the project root's config and ledger slug, also from a subfolder; flag paths stay
43
+ // cwd-relative, and the default output goes to <root>/<plans_dir>
44
+ const root = projectRootFor(cwd);
45
+ const loaded = await loadProjectConfig(root);
43
46
  if (!loaded.ok) {
44
47
  writeSafe(stderr, `author: ${loaded.message}\n`);
45
48
  return 2;
@@ -57,7 +60,7 @@ export async function runAuthorVerb(args, deps = {}) {
57
60
  answersPath: abs(flags.answers),
58
61
  run,
59
62
  runRoot: run ? runRootFor(run, cfg?.tmp?.root) : undefined,
60
- slug: slugFor(cfg, cwd),
63
+ slug: slugFor(cfg, root),
61
64
  timeoutMs: timeout === undefined ? undefined : timeout * 1000,
62
65
  },
63
66
  { ...deps, stderr },
@@ -66,7 +69,7 @@ export async function runAuthorVerb(args, deps = {}) {
66
69
  writeSafe(stderr, `author: ${result.status}${result.reason ? ` (${result.reason})` : ''}\n`);
67
70
  return exitCodeFor(result);
68
71
  }
69
- const out = abs(flags.out) ?? path.join(path.resolve(cwd, cfg.project?.plans_dir ?? 'plans'), `${path.basename(briefPath, path.extname(briefPath))}.${flags.job}.md`);
72
+ const out = abs(flags.out) ?? path.join(path.resolve(root, cfg.project?.plans_dir ?? 'plans'), `${path.basename(briefPath, path.extname(briefPath))}.${flags.job}.md`);
70
73
  mkdirSync(path.dirname(out), { recursive: true });
71
74
  writeFileSync(out, /** @type {string} */ (result.draft));
72
75
  writeSafe(stdout, `${JSON.stringify({ draft: out, questions: result.questions, cost: result.cost })}\n`);
package/src/cli/facts.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { accessSync, constants, statSync } from 'node:fs';
13
13
  import path from 'node:path';
14
- import { loadProjectConfig, slugFor } from '../config/load.mjs';
14
+ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
15
15
  import { buildFacts, FactsError } from '../session/facts.mjs';
16
16
  import { runRootFor, SessionError } from '../session/spawn.mjs';
17
17
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
@@ -63,14 +63,16 @@ export async function runFactsVerb(args, deps = {}) {
63
63
  writeSafe(stderr, USAGE);
64
64
  return 2;
65
65
  }
66
- const loaded = await loadProjectConfig(cwd);
66
+ // B50: the project root's config, slug, plans dir and snapshot, also from a subfolder (flag paths stay cwd-relative)
67
+ const root = projectRootFor(cwd);
68
+ const loaded = await loadProjectConfig(root);
67
69
  if (!loaded.ok) {
68
70
  writeSafe(stderr, `facts: ${loaded.message}\n`);
69
71
  return 2;
70
72
  }
71
73
  const cfg = /** @type {Record<string, any>} */ (loaded.config);
72
74
  const briefPath = path.resolve(cwd, flags.brief);
73
- const plansDir = path.resolve(cwd, cfg.project?.plans_dir ?? 'plans');
75
+ const plansDir = path.resolve(root, cfg.project?.plans_dir ?? 'plans');
74
76
  const outPath = typeof flags.out === 'string' ? path.resolve(cwd, flags.out) : path.join(plansDir, `${path.basename(briefPath, path.extname(briefPath))}.facts.md`);
75
77
  const sources = Array.isArray(flags.sources) ? flags.sources.map((s) => path.resolve(cwd, s)) : [];
76
78
  // --brief must be a regular file; a --sources path may be a file or a directory
@@ -88,11 +90,11 @@ export async function runFactsVerb(args, deps = {}) {
88
90
  const run = typeof flags.run === 'string' ? flags.run : undefined;
89
91
  // pre-run argument checks: a bad --run id or an unusable project state is usage (exit 2)
90
92
  const runRoot = run ? runRootFor(run, cfg?.tmp?.root) : undefined;
91
- const slug = slugFor(cfg, cwd);
93
+ const slug = slugFor(cfg, root);
92
94
  let result;
93
95
  try {
94
96
  result = await buildFacts(
95
- { cfg, briefPath, sources, outPath, run, runRoot, slug, projectDir: cwd, timeoutMs: timeout === undefined ? undefined : timeout * 1000 },
97
+ { cfg, briefPath, sources, outPath, run, runRoot, slug, projectDir: root, timeoutMs: timeout === undefined ? undefined : timeout * 1000 },
96
98
  { ...deps, stderr },
97
99
  );
98
100
  } catch (err) {
package/src/cli/jev.mjs CHANGED
@@ -16,8 +16,11 @@
16
16
  * or mistyped one is a usage error naming it, with nothing recorded. `system1.disable` still
17
17
  * applies. No key is resolved, no request is sent, and the row says `source: 'rules'`.
18
18
  * The ledger slug is `--slug`, else `project.slug`, else the directory-name slug `run` and
19
- * `author` use (`slugFor`; B36 — it used to fall back to `code-forge`). An unknown flag is a usage
20
- * error.
19
+ * `author` use (`slugFor`; B36 — it used to fall back to `code-forge`). B50: the config and the
20
+ * directory name are the PROJECT ROOT's (`projectRootFor`): inside git, the nearest regular-file
21
+ * `.code-forge.yml` from the cwd up to the git top level, else the git top level; outside git, the
22
+ * nearest one up to the home directory when the cwd is under it, else the cwd alone. A run from a
23
+ * subfolder thus writes to the project's ledger. An unknown flag is a usage error.
21
24
  *
22
25
  * Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
23
26
  * Wave 1 module to import a block in an earlier batch statically), asks Jev, runs the answer
@@ -31,7 +34,7 @@
31
34
 
32
35
  import { randomUUID } from 'node:crypto';
33
36
  import { readFile } from 'node:fs/promises';
34
- import { loadConfigFile, DEFAULT_CONFIG_FILENAME, slugFor } from '../config/load.mjs';
37
+ import { loadConfigFile, DEFAULT_CONFIG_FILENAME, projectRootFor, slugFor } from '../config/load.mjs';
35
38
  import path from 'node:path';
36
39
  import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../decide/questions.mjs';
37
40
  import { askJev as realAskJev } from '../decide/jev-client.mjs';
@@ -179,7 +182,9 @@ export async function runJev(args, deps = {}) {
179
182
  return 1;
180
183
  }
181
184
 
182
- const configPath = path.join(cwd, DEFAULT_CONFIG_FILENAME);
185
+ // B50: the project's config and ledger, also from a subfolder of the project
186
+ const root = projectRootFor(cwd);
187
+ const configPath = path.join(root, DEFAULT_CONFIG_FILENAME);
183
188
  const loaded = await loadConfig(configPath);
184
189
  let cfg = {};
185
190
  if (loaded.ok) {
@@ -190,7 +195,7 @@ export async function runJev(args, deps = {}) {
190
195
  }
191
196
 
192
197
  // the same ledger as the rest of the tool: --slug, else project.slug, else the directory name
193
- const slug = slugFlag.value ?? slugFor(cfg, cwd);
198
+ const slug = slugFlag.value ?? slugFor(cfg, root);
194
199
 
195
200
  if (isQuestionDisabled(id, cfg)) {
196
201
  err(`jev ask: question "${id}" is disabled by system1.disable\n`);
package/src/cli/plan.mjs CHANGED
@@ -5,16 +5,17 @@
5
5
  * WARN lines (a required section found by its position under a near-miss heading) go to stderr and
6
6
  * never fail the check. Either way stdout ends with the lane recorded per block (B36):
7
7
  * `lanes: B1 L1 (jev) · B2 none` — read from the project's ledger (`--slug`, else `project.slug`,
8
- * else the directory-name slug `run`, `author` and `jev ask` use).
9
- * Limits come from `.code-forge.yml` when there is one (`caps.coders`, `budget.block_cases`,
8
+ * else the directory-name slug `run`, `author` and `jev ask` use — the project root's, B50).
9
+ * Limits come from the project root's `.code-forge.yml` when there is one (`caps.coders`, `budget.block_cases`,
10
10
  * `budget.block_lines`), else the plan defaults.
11
11
  */
12
12
 
13
13
  import { readFileSync } from 'node:fs';
14
14
  import path from 'node:path';
15
- import { loadProjectConfig, slugFor } from '../config/load.mjs';
15
+ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
16
16
  import { ledgerPath } from '../ledger/paths.mjs';
17
17
  import { readAllRows } from '../ledger/write.mjs';
18
+ import { FactsError, readProjectBins } from '../session/facts.mjs';
18
19
  import { checkPlan } from '../session/plan-check.mjs';
19
20
  import { parseFlags } from '../state/cli-args.mjs';
20
21
  import { StateError } from '../state/paths.mjs';
@@ -49,14 +50,16 @@ export async function runPlanVerb(args, deps = {}) {
49
50
  };
50
51
  const text = read(planPath, 'the plan file');
51
52
  const factsText = factsPath ? read(factsPath, '--facts') : undefined;
52
- const loaded = await loadProjectConfig(cwd);
53
+ // B50: the project's config and ledger, also from a subfolder of the project
54
+ const root = projectRootFor(cwd);
55
+ const loaded = await loadProjectConfig(root);
53
56
  if (!loaded.ok && loaded.error !== 'not-found') {
54
57
  writeSafe(stderr, `plan: ${loaded.message}\n`);
55
58
  return 2;
56
59
  }
57
60
  const cfg = loaded.ok ? loaded.config : undefined;
58
61
  // the same ledger as the rest of the tool (and `jev ask`): --slug, else project.slug, else the directory name
59
- const slug = typeof flags.slug === 'string' ? flags.slug : slugFor(cfg, cwd);
62
+ const slug = typeof flags.slug === 'string' ? flags.slug : slugFor(cfg, root);
60
63
  try {
61
64
  ledgerPath(slug);
62
65
  } catch {
@@ -64,7 +67,16 @@ export async function runPlanVerb(args, deps = {}) {
64
67
  }
65
68
  // a missing ledger is no rows (every block `none`); any other read error propagates as itself
66
69
  const decisions = await readAllRows(slug);
67
- const result = checkPlan(text, { planPath, factsPath, factsText, cfg, decisions });
70
+ // package.json is read lazily: only a command claim that needs the bin prefix reads it, so a bad
71
+ // package.json fails only the plans that depend on it
72
+ let result;
73
+ try {
74
+ result = checkPlan(text, { planPath, factsPath, factsText, cfg, decisions, projectBins: () => readProjectBins(root) });
75
+ } catch (err) {
76
+ if (!(err instanceof FactsError)) throw err;
77
+ writeSafe(stderr, `plan: ${err.message}\n`);
78
+ return 2;
79
+ }
68
80
  for (const line of result.warnings) writeSafe(stderr, `${line}\n`);
69
81
  for (const line of result.errors) writeSafe(stderr, `${line}\n`);
70
82
  if (result.ok) writeSafe(stdout, `plan check: ok (${result.blocks} blocks)\n`);
package/src/cli/run.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import path from 'node:path';
26
26
  import { reloadFailureText, reloadWorkspace } from '../autopilot/limits.mjs';
27
- import { DEFAULT_CONFIG_FILENAME, loadProjectConfig, slugFor } from '../config/load.mjs';
27
+ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
28
28
  import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
29
29
  import { appendRow, readAllRows } from '../ledger/write.mjs';
30
30
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
@@ -118,7 +118,8 @@ export async function runRun(args, deps = {}) {
118
118
  return 0;
119
119
  }
120
120
  if (sub === 'start') {
121
- const workspace = path.resolve(typeof flags.cwd === 'string' ? flags.cwd : process.cwd());
121
+ // B50: the run's workspace is the project root (its config and ledger slug), also from a subfolder
122
+ const workspace = projectRootFor(path.resolve(typeof flags.cwd === 'string' ? flags.cwd : process.cwd()));
122
123
  const loaded = await loadProjectConfig(workspace);
123
124
  if (!loaded.ok) {
124
125
  err(`run start: ${loaded.message}\n`);
package/src/cli/s2.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import path from 'node:path';
11
- import { loadProjectConfig, slugFor } from '../config/load.mjs';
11
+ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
12
12
  import { runS2 } from '../session/s2.mjs';
13
13
  import { runRootFor, SessionError } from '../session/spawn.mjs';
14
14
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
@@ -51,7 +51,9 @@ export async function runS2Verb(args, deps = {}) {
51
51
  writeSafe(stderr, USAGE);
52
52
  return 2;
53
53
  }
54
- const loaded = await loadProjectConfig(process.cwd());
54
+ // B50: the project root's config and ledger slug, also from a subfolder
55
+ const root = projectRootFor(process.cwd());
56
+ const loaded = await loadProjectConfig(root);
55
57
  if (!loaded.ok) {
56
58
  writeSafe(stderr, `s2: ${loaded.message}\n`);
57
59
  return 2;
@@ -66,7 +68,7 @@ export async function runS2Verb(args, deps = {}) {
66
68
  run: typeof flags.run === 'string' ? flags.run : undefined,
67
69
  block: typeof flags.block === 'string' ? flags.block : undefined,
68
70
  runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
69
- slug: slugFor(cfg, process.cwd()),
71
+ slug: slugFor(cfg, root),
70
72
  timeoutMs: timeout === undefined ? undefined : timeout * 1000,
71
73
  },
72
74
  { ...deps, stderr },
package/src/cli/spawn.mjs CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import path from 'node:path';
13
- import { loadProjectConfig, slugFor } from '../config/load.mjs';
13
+ import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
14
14
  import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
15
15
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
16
16
  import { StateError } from '../state/paths.mjs';
@@ -60,7 +60,9 @@ export async function runSpawn(args, deps = {}) {
60
60
  writeSafe(stderr, USAGE);
61
61
  return 2;
62
62
  }
63
- const loaded = await loadProjectConfig(process.cwd());
63
+ // B50: the project root's config and ledger slug, also from a subfolder
64
+ const root = projectRootFor(process.cwd());
65
+ const loaded = await loadProjectConfig(root);
64
66
  if (!loaded.ok) {
65
67
  writeSafe(stderr, `spawn: ${loaded.message}\n`);
66
68
  return 2;
@@ -81,7 +83,7 @@ export async function runSpawn(args, deps = {}) {
81
83
  runRoot: typeof flags.run === 'string' ? runRootFor(flags.run, cfg?.tmp?.root) : undefined,
82
84
  run: typeof flags.run === 'string' ? flags.run : undefined,
83
85
  block: typeof flags.block === 'string' ? flags.block : undefined,
84
- slug: slugFor(cfg, process.cwd()),
86
+ slug: slugFor(cfg, root),
85
87
  },
86
88
  { ...deps, stderr },
87
89
  );