@codedology/code-forge 0.7.4 → 0.7.6
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 +30 -0
- package/README.md +16 -0
- package/docs/reference/blocks.json +27 -1
- package/docs/reference/config.md +3 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +10 -1
- package/skill/references/code.md +2 -2
- package/skill/references/decisions.md +1 -1
- package/skill/references/review.md +2 -0
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +23 -6
- package/src/cli/plan.mjs +1 -1
- package/src/cli/proof.mjs +3 -2
- package/src/proof/export.mjs +50 -9
- package/src/proof/tiers.mjs +2 -1
- package/src/review/gate-check.mjs +20 -19
- package/src/session/plan-check.mjs +18 -3
- package/src/state/block.mjs +74 -14
- package/src/state/config-snapshot.mjs +27 -0
- package/src/state/owned-kinds.mjs +44 -0
- package/src/state/registry.mjs +115 -16
- package/src/state/run.mjs +15 -3
- package/src/worker/engine.mjs +21 -18
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,36 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.6] — 2026-10-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- review.external {required, name} (B58, issue #11): an owner-approved outside reviewer replaces the local per-file
|
|
14
|
+
review. Read only from the run-start config snapshot (run reload refuses to change it; run start refuses required:
|
|
15
|
+
true without a name) and signed as a review.external row; block close then skips the per-file review check (also
|
|
16
|
+
not_approved) and writes a signed gate.reviews_waived {by: config, reviewer} row just before block.close, only when
|
|
17
|
+
the block closes, with no TTY and under autopilot. Unowned, proof, no_changes, transcript, orphans and closeBlock
|
|
18
|
+
checks still refuse. block open and block claim refuse an owned entry that covers .code-forge.yml. Without the key
|
|
19
|
+
nothing changes.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- block open, block claim and block close now read the run's config snapshot; a snapshot that does not match its hash
|
|
24
|
+
refuses them with config-snapshot, with or without review.external (B58).
|
|
25
|
+
|
|
26
|
+
## [0.7.5] — 2026-10-08
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- An owned entry with no glob character now owns every path below it everywhere, not only at `block close`: `block open`
|
|
31
|
+
and the plan check refuse a directory that overlaps a file or directory of another block (parent or child, either way
|
|
32
|
+
round; `src/feature` vs `src/featureX` stay disjoint), and `block claim`, `block rebase`, the scope gate, proof and
|
|
33
|
+
the worker's file set and budget count the files inside an owned directory (B57, issue #2). An entry that is a regular
|
|
34
|
+
file at `block open` or `block claim` owns only itself (recorded as `owned_kinds`); one not there yet stays
|
|
35
|
+
directory-like; a brace entry stays a glob. A refused `block claim` names the entry it overlaps
|
|
36
|
+
(`<path> overlaps <entry> owned by open block <id>`), and `proof export` never copies or removes an owned directory
|
|
37
|
+
itself, only the files in it.
|
|
38
|
+
|
|
9
39
|
## [0.7.4] — 2026-10-08
|
|
10
40
|
|
|
11
41
|
### Added
|
package/README.md
CHANGED
|
@@ -12,6 +12,22 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
|
|
|
12
12
|
|
|
13
13
|
Next release:
|
|
14
14
|
|
|
15
|
+
- **An outside reviewer can replace the local review.** Set `review.external: {required: true,
|
|
16
|
+
name: <reviewer>}` in `.code-forge.yml`. `block close` then skips the per-file review check
|
|
17
|
+
and writes a signed `gate.reviews_waived` row with `by: config`. It needs no TTY and works
|
|
18
|
+
under autopilot. All other close checks still run. The key is fixed at `run start`, and a
|
|
19
|
+
coder can never own `.code-forge.yml`.
|
|
20
|
+
|
|
21
|
+
In 0.7.5:
|
|
22
|
+
|
|
23
|
+
- **An owned directory owns the files inside it, everywhere.** A block that owns `src/feature`
|
|
24
|
+
now owns `src/feature/a.swift` for `block open`, `block claim`, `block rebase`, the scope gate,
|
|
25
|
+
proof and the review worker, not only for `block close`. `block open` refuses a second block
|
|
26
|
+
that owns a file or folder inside it (or a folder around it), so two coders can no longer edit
|
|
27
|
+
the same files at once. A path that is already a file when the block opens owns only itself.
|
|
28
|
+
|
|
29
|
+
In 0.7.4:
|
|
30
|
+
|
|
15
31
|
- **Code moved between files is no longer read as deleted.** When a block moves code from one
|
|
16
32
|
file to another, each file's review packet now says where the lines went (or came from), so the
|
|
17
33
|
reviewer of the old file stops reporting the removal as a lost feature, also on rechecks. Every
|
|
@@ -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":
|
|
7
|
+
"block_count": 61,
|
|
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": {
|
|
@@ -810,6 +810,32 @@
|
|
|
810
810
|
"B55"
|
|
811
811
|
],
|
|
812
812
|
"note": "root-added 2026-10-08; GitHub issue #2 note of 2026-10-06 (stale critical from a cross-file move); also amends src/review/{packet,engine,fixloop,sections}.mjs, src/worker/engine.mjs, docs/how-it-works.md, skill/references/review.md"
|
|
813
|
+
},
|
|
814
|
+
"B57": {
|
|
815
|
+
"title": "One ownership rule (issue #2): an owned entry with no glob character owns its path and every path below it (src/feature owns src/feature/a.swift, never src/featureX/a.swift) unless block open or block claim saw a regular file there (owned_kinds: then it owns only itself); globs, brace entries included, own what they match; findOverlap reports file/dir and dir/dir parent-child overlaps either way and a directory-like entry against a glob matching it or any path below it; the rule lives in src/state/registry.mjs and every caller uses it (block open, claim, rebase, scope gate, close gate, proof, worker file set and budget, plan check)",
|
|
816
|
+
"wave": 12,
|
|
817
|
+
"level": "L2",
|
|
818
|
+
"status": "landed",
|
|
819
|
+
"owned": [
|
|
820
|
+
"src/state/owned-kinds.mjs"
|
|
821
|
+
],
|
|
822
|
+
"depends_on": [
|
|
823
|
+
"B56"
|
|
824
|
+
],
|
|
825
|
+
"note": "root-added 2026-10-08; found while fixing GitHub issue #2 (B52); also amends src/state/{registry,block}.mjs, src/review/gate-check.mjs, src/cli/{block,proof,plan}.mjs, src/proof/{export,tiers}.mjs, src/worker/engine.mjs, src/session/plan-check.mjs, tests, docs/how-it-works.md, skill/references/code.md"
|
|
826
|
+
},
|
|
827
|
+
"B58": {
|
|
828
|
+
"title": "Outside reviewer (issue #11, #13 item 1): config key review.external {required, name}, read only from the run-start config snapshot (immutable, run reload refuses it), signed review.external row at run start; block close skips the per-file review check and writes a signed gate.reviews_waived {by: config, reviewer} row just before block.close, only when the block closes; unowned, proof, no_changes, transcript and closeBlock checks still refuse; block open and block claim refuse an owned entry that covers .code-forge.yml; run start refuses required: true without a name",
|
|
829
|
+
"wave": 13,
|
|
830
|
+
"level": "L2",
|
|
831
|
+
"status": "landed",
|
|
832
|
+
"owned": [
|
|
833
|
+
"test/review/external.test.mjs"
|
|
834
|
+
],
|
|
835
|
+
"depends_on": [
|
|
836
|
+
"B57"
|
|
837
|
+
],
|
|
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"
|
|
813
839
|
}
|
|
814
840
|
}
|
|
815
841
|
}
|
package/docs/reference/config.md
CHANGED
|
@@ -117,6 +117,9 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
|
|
|
117
117
|
| `review.provider_concurrency.openai` | integer | | `2` | min 1 |
|
|
118
118
|
| `review.provider_concurrency.xai` | integer | | `2` | min 1 |
|
|
119
119
|
| `review.parallel_tickets` | integer | | `3` | min 1; max 16; B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap. |
|
|
120
|
+
| `review.external` | object | | — | |
|
|
121
|
+
| `review.external.required` | boolean | | `false` | B58: true ⇒ block close skips the local per-file review checks (and the --report ticket check), with no terminal and under autopilot, and writes a signed gate.reviews_waived {by: config} row. Unowned changes, proof, no_changes and the transcript grep still refuse. No block may own .code-forge.yml. |
|
|
122
|
+
| `review.external.name` | string | | — | minLength 1; B58: the outside reviewer, written in the review.external and gate.reviews_waived rows. Required when required is true: run start refuses a missing or blank name. |
|
|
120
123
|
| `engine` | enum: `auto`, `solo`, `harness`, `subprocess` | | `"auto"` | |
|
|
121
124
|
| `harnesses` | array<string> | | — | |
|
|
122
125
|
| `harness` | object | | — | |
|
package/package.json
CHANGED
|
@@ -153,7 +153,16 @@
|
|
|
153
153
|
"xai": { "type": "integer", "minimum": 1, "default": 2 }
|
|
154
154
|
}
|
|
155
155
|
},
|
|
156
|
-
"parallel_tickets": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3, "description": "B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap." }
|
|
156
|
+
"parallel_tickets": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3, "description": "B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap." },
|
|
157
|
+
"external": {
|
|
158
|
+
"type": "object",
|
|
159
|
+
"additionalProperties": false,
|
|
160
|
+
"description": "B58: an outside reviewer reviews each block. Set it before run start; run reload refuses to add, remove or change it.",
|
|
161
|
+
"properties": {
|
|
162
|
+
"required": { "type": "boolean", "default": false, "description": "B58: true ⇒ block close skips the local per-file review checks (and the --report ticket check), with no terminal and under autopilot, and writes a signed gate.reviews_waived {by: config} row. Unowned changes, proof, no_changes and the transcript grep still refuse. No block may own .code-forge.yml." },
|
|
163
|
+
"name": { "type": "string", "minLength": 1, "pattern": "\\S", "description": "B58: the outside reviewer, written in the review.external and gate.reviews_waived rows. Required when required is true: run start refuses a missing or blank name." }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
157
166
|
}
|
|
158
167
|
},
|
|
159
168
|
"engine": { "type": "string", "enum": ["auto", "solo", "harness", "subprocess"], "default": "auto" },
|
package/skill/references/code.md
CHANGED
|
@@ -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), 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. 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.
|
|
@@ -41,7 +41,7 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
|
|
|
41
41
|
| rows genuine | the MAC of every gate-relevant row verifies; the live worker matches the pinned pid |
|
|
42
42
|
| nothing left open | no unruled `late_findings` (a ruling clears only a warning or nit ruled `nit`; a critical late finding needs the human's waiver); no file at `review_cap` without a signed `review.waived` row |
|
|
43
43
|
|
|
44
|
-
`--no-require-reviews` is the human's way out, written as its own audit row. The human runs it themselves at a terminal: it asks for a yes, has no `--yes`, exits 2 unless stdin and stdout are both terminals, lifts `no_changes` and is refused while an autopilot grant is active — so never run it for them; tell them the command when they say so in chat. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
|
|
44
|
+
`--no-require-reviews` is the human's way out, written as its own audit row. The human runs it themselves at a terminal: it asks for a yes, has no `--yes`, exits 2 unless stdin and stdout are both terminals, lifts `no_changes` and is refused while an autopilot grant is active — so never run it for them; tell them the command when they say so in chat. The owner's other way out is `review.external` in the config before `run start` (`review.md` §1): the gate then skips the review rows and the report's ticket ids and writes `gate.reviews_waived {by: config}`; every other check, `no_changes` included, still applies. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
|
|
45
45
|
|
|
46
46
|
## §6 Budget — the stop is the feature (R6)
|
|
47
47
|
|
|
@@ -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 or `unowned_change`, a `not_approved` critical finding, `--no-require-reviews`, `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).
|
|
53
|
+
`next = stop`, `review.over_budget`, an orphan file or `unowned_change`, a `not_approved` critical finding, `--no-require-reviews`, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. The owner has two ways out of the per-file review gate: `--no-require-reviews` at a terminal, or `review.external.required: true` with the outside reviewer's `name`, set in `.code-forge.yml` before `run start` (`review.md` §1). You never choose either one. 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).
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
`forge review-file <path> --block <id> --run <r>` enqueues a ticket and returns `{ticket, status: queued}` in under a second; `forge review-file --wait <ticket> [--max 90s]` polls it. The worker computes the diff itself against the base sha `forge block open` recorded: tracked changes plus untracked files, filtered by `owned_files` for the review and unfiltered for the scope check. A coder that never calls `review-file` gains nothing: the block gate refuses any changed or new file without a signed `review.approved` row for its final content hash. The reviewer is always a new OS process (R1, R10) — identical under Solo, a harness subagent, a detached CLI process, or a human typing.
|
|
6
6
|
|
|
7
|
+
**The owner's two ways out of the per-file review.** (1) `forge block close <id> --no-require-reviews`, typed by the owner at a terminal (`code.md` §5). (2) `review.external: {required: true, name: <reviewer>}` in `.code-forge.yml`, set before `run start`, when an outside reviewer reviews each block (B58). With `required: true`, `run start` refuses a missing or blank `name`, and writes a signed `review.external` row with the reviewer's name. `run reload` refuses to add, remove or change the key. With the key, `block close` does not check the review rows (a `not_approved` file included) or the report's ticket ids, needs no terminal and works under an autopilot grant. When the block closes, it writes a signed `gate.reviews_waived {by: config, reviewer}` row just before `block.close`; a refused close writes no such row. Unowned changes, `unproven`, `no_changes`, the transcript grep and the registry's orphans still refuse. No block may own `.code-forge.yml` while the key is on: `block open` and `block claim` refuse it. `block open`, `block claim` and `block close` read the run's config snapshot to find the key, so a snapshot that does not match its hash refuses them with `config-snapshot`, with or without the key. Never set or remove the key yourself: it is the owner's decision.
|
|
8
|
+
|
|
7
9
|
## §2 Depth by risk (adaptive mode)
|
|
8
10
|
|
|
9
11
|
The topology is decided in one place (`planSessions`). Consensus (§6) runs only when `review.multimodel` is on **and** the file's risk is above `review.single_reviewer_max_risk` (default 1) **and** the block is not a `docs`/`contract` block (`decisions.md` §5) unless `review.multimodel_for_docs` is true (default false). Everything else is adaptive, below. Why: a consensus review of a four-file docs block cost $16.70 — review cost dominates, and a low-risk file gains nothing from a second provider.
|
package/skill/scripts/forge
CHANGED
|
@@ -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.
|
|
13
|
+
PINNED_VERSION="0.7.6"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
package/src/cli/block.mjs
CHANGED
|
@@ -50,6 +50,15 @@
|
|
|
50
50
|
* FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
|
|
51
51
|
* way out). It runs after the review rows and reuses their file set (listed once).
|
|
52
52
|
*
|
|
53
|
+
* B58 (issue #11): `review.external.required: true` in the run's config snapshot (the owner set it
|
|
54
|
+
* before `run start`; `run reload` refuses to change it) is the owner's non-interactive way out
|
|
55
|
+
* when an outside reviewer reviews the block: the per-file review checks and the `--report` ticket
|
|
56
|
+
* check are skipped, with no TTY and under an active autopilot grant, and a signed
|
|
57
|
+
* `gate.reviews_waived {by: config, reviewer: <name>}` row is written just before `block.close`,
|
|
58
|
+
* only when the block closes. Everything
|
|
59
|
+
* else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
|
|
60
|
+
* `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
|
|
61
|
+
*
|
|
53
62
|
* B47: `block open` without `--level` takes the level `autopilot level` chose for the block (the
|
|
54
63
|
* latest `autopilot.level` row of the run for it whose MAC verifies, `autopilotLevel`); with
|
|
55
64
|
* neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
|
|
@@ -67,6 +76,7 @@ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
|
67
76
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
68
77
|
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
69
78
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
79
|
+
import { externalReviewer } from '../state/config-snapshot.mjs';
|
|
70
80
|
import { StateError } from '../state/paths.mjs';
|
|
71
81
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
72
82
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
@@ -201,6 +211,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
201
211
|
return 1;
|
|
202
212
|
}
|
|
203
213
|
}
|
|
214
|
+
// B58: the owner's other way out — `review.external.required` in the run's config snapshot
|
|
215
|
+
// (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
|
|
216
|
+
const external = reviewsWaived ? null : externalReviewer(record);
|
|
204
217
|
const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
205
218
|
// B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
|
|
206
219
|
// base is the repository top level: the worker signs every review row with a repo-root-
|
|
@@ -218,7 +231,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
218
231
|
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
219
232
|
const wsPrefix = path.relative(await real(top), await real(record.workspace)).split(path.sep).join('/');
|
|
220
233
|
const owns = ownsAt(wsPrefix);
|
|
221
|
-
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
|
|
234
|
+
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
222
235
|
const changed = await changedFiles({ repoRoot: top, base: entry.base_sha });
|
|
223
236
|
// the file set is the diff since base ∪ every owned file with review rows of this block —
|
|
224
237
|
// work the diff cannot see (committed before `block open`, done in another worktree) is
|
|
@@ -238,7 +251,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
238
251
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
239
252
|
const highPaths = await highPathsAt(record.workspace, entry.base_sha);
|
|
240
253
|
const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
241
|
-
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
|
|
254
|
+
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
|
|
242
255
|
const key = await loadKey(runId);
|
|
243
256
|
// B52: an empty diff with review rows is never silent — name the likely cause
|
|
244
257
|
if (reviewed.length > 0 && !changed.some(isMine)) {
|
|
@@ -265,8 +278,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
265
278
|
unowned: scope?.unowned ?? [],
|
|
266
279
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
267
280
|
resultFiles: await resultFilesFor({ repoRoot: top, runId, block: id, rows }),
|
|
268
|
-
files: reviewsWaived ? [] : fileSet,
|
|
269
|
-
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
|
|
281
|
+
files: reviewsWaived || external !== null ? [] : fileSet,
|
|
282
|
+
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
|
|
283
|
+
// --no-require-reviews); B58: `review.external` does not lift it
|
|
270
284
|
noChanges: !reviewsWaived && fileSet.length === 0,
|
|
271
285
|
// B20: high-tier files need a proven red→green row; `--no-require-reviews` does not lift it
|
|
272
286
|
proofFiles: fileSet,
|
|
@@ -275,7 +289,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
275
289
|
});
|
|
276
290
|
// B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
|
|
277
291
|
const report = async () => {
|
|
278
|
-
if (typeof flags.report !== 'string' || reviewsWaived) return { ok: true };
|
|
292
|
+
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
279
293
|
let text;
|
|
280
294
|
try {
|
|
281
295
|
text = await readFile(flags.report, 'utf8');
|
|
@@ -286,7 +300,10 @@ export async function runBlock(args, deps = {}) {
|
|
|
286
300
|
const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
|
|
287
301
|
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
288
302
|
};
|
|
289
|
-
|
|
303
|
+
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
304
|
+
// `block.close` and only when every other check passed — never by a refused close
|
|
305
|
+
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
306
|
+
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report], closeRows });
|
|
290
307
|
(result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
291
308
|
return result.ok ? 0 : 1;
|
|
292
309
|
}
|
package/src/cli/plan.mjs
CHANGED
|
@@ -71,7 +71,7 @@ export async function runPlanVerb(args, deps = {}) {
|
|
|
71
71
|
// package.json fails only the plans that depend on it
|
|
72
72
|
let result;
|
|
73
73
|
try {
|
|
74
|
-
result = checkPlan(text, { planPath, factsPath, factsText, cfg, decisions, projectBins: () => readProjectBins(root) });
|
|
74
|
+
result = checkPlan(text, { planPath, factsPath, factsText, cfg, decisions, root, projectBins: () => readProjectBins(root) });
|
|
75
75
|
} catch (err) {
|
|
76
76
|
if (!(err instanceof FactsError)) throw err;
|
|
77
77
|
writeSafe(stderr, `plan: ${err.message}\n`);
|
package/src/cli/proof.mjs
CHANGED
|
@@ -36,7 +36,7 @@ import { acquireProofLock, lockHolder, releaseProofLock } from '../proof/lock.mj
|
|
|
36
36
|
import { MECHANISMS, readJournal, restoreJournal, runRedGreen } from '../proof/red-green.mjs';
|
|
37
37
|
import { tierFor } from '../proof/tiers.mjs';
|
|
38
38
|
import { blockFileSet, repoRelativePath } from '../review/gate-check.mjs';
|
|
39
|
-
import {
|
|
39
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
40
40
|
|
|
41
41
|
const USAGE =
|
|
42
42
|
'usage: code-forge proof tier --file <path> --risk <0-3> [--security] [--cwd <dir>]\n' +
|
|
@@ -126,6 +126,7 @@ async function exportAndRecord({ runId, record, blockId, block, config, writeRow
|
|
|
126
126
|
blockId,
|
|
127
127
|
baseSha: block.base_sha,
|
|
128
128
|
owned: block.owned_files,
|
|
129
|
+
ownedKinds: block.owned_kinds,
|
|
129
130
|
linkDirs: config?.proof?.export?.link_dirs ?? DEFAULT_LINK_DIRS,
|
|
130
131
|
copyUntracked: config?.proof?.export?.copy_untracked ?? DEFAULT_COPY_UNTRACKED,
|
|
131
132
|
});
|
|
@@ -243,7 +244,7 @@ export async function runProof(args, deps = {}) {
|
|
|
243
244
|
repoRoot: record.workspace,
|
|
244
245
|
base: block.base_sha,
|
|
245
246
|
owned: block.owned_files,
|
|
246
|
-
matchOwned: (f) =>
|
|
247
|
+
matchOwned: (f) => ownsFile(block.owned_files, f, block.owned_kinds),
|
|
247
248
|
});
|
|
248
249
|
const sources = mechanism === 'assertion-deletion' ? [] : changed.map((c) => c.file).filter((f) => f !== file && !TEST_PATH.test(f));
|
|
249
250
|
if (mechanism === 'revert' && sources.length === 0) {
|
package/src/proof/export.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { copyFile, lstat, mkdir, mkdtemp, readlink, realpath, rm, stat, symlink
|
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import { runGates } from '../gates/run.mjs';
|
|
30
30
|
import { StateError } from '../state/paths.mjs';
|
|
31
|
-
import { ownsFile } from '../state/registry.mjs';
|
|
31
|
+
import { isExactEntry, ownsFile } from '../state/registry.mjs';
|
|
32
32
|
import { writeSigned } from '../state/run.mjs';
|
|
33
33
|
import { exec } from '../util/exec.mjs';
|
|
34
34
|
import { revParse } from '../util/git.mjs';
|
|
@@ -148,8 +148,16 @@ async function exportIgnoredBlobs(entries, base, cwd) {
|
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
150
|
* Build (or rebuild) the export of one block.
|
|
151
|
+
*
|
|
152
|
+
* Owned paths follow the registry's one rule (B57): `ownedKinds` is the block's `owned_kinds` from
|
|
153
|
+
* the run record — every caller passes it; left out it is `{}`, so every exact entry is
|
|
154
|
+
* directory-like (fail closed: it owns the paths below it). A path's kind may differ between base
|
|
155
|
+
* and the tree: a base FILE where the tree has a directory leaves the export before the files below
|
|
156
|
+
* it are copied, and a base DIRECTORY where the tree has a file leaves with every base blob in it;
|
|
157
|
+
* each base blob that leaves is listed in `removed`. A directory is never removed whole because
|
|
158
|
+
* it is owned or missing: only the blobs below it, one by one.
|
|
151
159
|
* @param {{
|
|
152
|
-
* cwd: string, blockId: string, baseSha: string, owned: ReadonlyArray<string>,
|
|
160
|
+
* cwd: string, blockId: string, baseSha: string, owned: ReadonlyArray<string>, ownedKinds?: Readonly<Record<string, string>>,
|
|
153
161
|
* linkDirs?: ReadonlyArray<string>, copyUntracked?: ReadonlyArray<string>, exportRoot?: string,
|
|
154
162
|
* }} opts
|
|
155
163
|
* @returns {Promise<{dir: string, base: string, restored: string[], untracked: string[], owned: string[], removed: string[], linked: string[]}>}
|
|
@@ -206,21 +214,54 @@ export async function buildExport(opts) {
|
|
|
206
214
|
|
|
207
215
|
// (d) owned files at current content; owned base files deleted in the tree leave the export.
|
|
208
216
|
const listed = (await git(['ls-files', '-z', '--cached', '--others', '--exclude-standard'], cwd)).split('\0').filter(Boolean);
|
|
209
|
-
const exact = owned.filter((o) =>
|
|
210
|
-
const
|
|
217
|
+
const exact = owned.filter((o) => isExactEntry(o));
|
|
218
|
+
const ownedKinds = opts.ownedKinds ?? {};
|
|
219
|
+
const candidates = [...new Set([...listed, ...exact, ...baseBlobs])].filter((f) => ownsFile(owned, f, ownedKinds)).sort();
|
|
220
|
+
const candidateSet = new Set(candidates);
|
|
211
221
|
const ownedCopied = [];
|
|
212
|
-
|
|
222
|
+
/** @type {Set<string>} */
|
|
223
|
+
const removedSet = new Set();
|
|
224
|
+
/** A base blob that is not in the tree at its path any more leaves the export: listed. */
|
|
225
|
+
const markRemoved = (/** @type {string} */ rel) => {
|
|
226
|
+
if (baseBlobs.has(rel)) removedSet.add(rel);
|
|
227
|
+
};
|
|
228
|
+
/** B57: an export entry above `rel` that is not a directory (a base file the tree turned into a directory) leaves. */
|
|
229
|
+
const clearParents = async (/** @type {string} */ rel) => {
|
|
230
|
+
const segments = rel.split('/');
|
|
231
|
+
for (let n = 1; n < segments.length; n += 1) {
|
|
232
|
+
const parent = segments.slice(0, n).join('/');
|
|
233
|
+
const at = await lstatOrNull(path.join(dir, parent));
|
|
234
|
+
if (at === null) return;
|
|
235
|
+
if (!at.isDirectory()) {
|
|
236
|
+
await rm(path.join(dir, parent), { force: true });
|
|
237
|
+
markRemoved(parent);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
};
|
|
213
242
|
for (const rel of candidates) {
|
|
214
243
|
assertRelPath(rel, 'owned files');
|
|
215
244
|
const src = path.join(cwd, rel);
|
|
216
245
|
const dest = path.join(dir, rel);
|
|
217
246
|
const st = await lstatOrNull(src);
|
|
218
|
-
await
|
|
219
|
-
|
|
220
|
-
|
|
247
|
+
const at = await lstatOrNull(dest);
|
|
248
|
+
// B57: nothing to copy — a path missing from the tree, or a directory (an exact owned entry may
|
|
249
|
+
// name one; its files are candidates of their own). A stale non-directory export entry there
|
|
250
|
+
// leaves; an export directory is never removed whole — its owned blobs leave one by one.
|
|
251
|
+
if (st === null || st.isDirectory()) {
|
|
252
|
+
if (at !== null && !at.isDirectory()) await rm(dest, { force: true });
|
|
253
|
+
markRemoved(rel);
|
|
221
254
|
continue;
|
|
222
255
|
}
|
|
256
|
+
if (at?.isDirectory()) {
|
|
257
|
+
// a base directory the tree turned into a file: every base blob in it is gone from the tree
|
|
258
|
+
for (const blob of baseBlobs) if (blob.startsWith(`${rel}/`) && !candidateSet.has(blob)) markRemoved(blob);
|
|
259
|
+
await rm(dest, { recursive: true, force: true });
|
|
260
|
+
} else if (at !== null) {
|
|
261
|
+
await rm(dest, { force: true });
|
|
262
|
+
}
|
|
223
263
|
if (!st.isFile() && !st.isSymbolicLink()) continue;
|
|
264
|
+
await clearParents(rel);
|
|
224
265
|
await mkdir(path.dirname(dest), { recursive: true });
|
|
225
266
|
if (st.isSymbolicLink()) await symlink(await readlink(src), dest);
|
|
226
267
|
else await copyFile(src, dest);
|
|
@@ -240,7 +281,7 @@ export async function buildExport(opts) {
|
|
|
240
281
|
linked.push(rel);
|
|
241
282
|
}
|
|
242
283
|
|
|
243
|
-
return { dir, base, restored: [...restored].sort(), untracked: untracked.sort(), owned: ownedCopied, removed, linked: linked.sort() };
|
|
284
|
+
return { dir, base, restored: [...restored].sort(), untracked: untracked.sort(), owned: ownedCopied, removed: [...removedSet].sort(), linked: linked.sort() };
|
|
244
285
|
} finally {
|
|
245
286
|
await rm(scratch, { recursive: true, force: true });
|
|
246
287
|
}
|
package/src/proof/tiers.mjs
CHANGED
|
@@ -23,7 +23,8 @@ export const HIGH_RISK = 2;
|
|
|
23
23
|
/**
|
|
24
24
|
* @param {{file: string, risk: number, securitySensitive?: boolean, highPaths?: ReadonlyArray<string>}} input
|
|
25
25
|
* `risk` is the S1 `risk` answer (0–3); `highPaths` is `proof.tiers.high.paths` (exact paths or
|
|
26
|
-
* globs in the registry's syntax: `*`, `?`, `**`, `{a,b}
|
|
26
|
+
* globs in the registry's syntax: `*`, `?`, `**`, `{a,b}`; an exact path also covers the paths
|
|
27
|
+
* below it, B57).
|
|
27
28
|
* @returns {{tier: Tier, reason: TierReason}} the FIRST reason that applies, in the order
|
|
28
29
|
* security, risk, path — every one of them yields `high`, so the order only picks the label.
|
|
29
30
|
* @throws {TypeError} on a non-string file, a risk outside 0–3, or a non-array `highPaths`.
|
|
@@ -68,7 +68,7 @@ import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
|
|
|
68
68
|
import { exec } from '../util/exec.mjs';
|
|
69
69
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
70
70
|
import { StateError } from '../state/paths.mjs';
|
|
71
|
-
import {
|
|
71
|
+
import { ownsFile } from '../state/registry.mjs';
|
|
72
72
|
import { sameStamp, treeStamp } from '../state/block.mjs';
|
|
73
73
|
import { verifyRow } from '../state/signer.mjs';
|
|
74
74
|
import { tierFor } from '../proof/tiers.mjs';
|
|
@@ -565,15 +565,15 @@ export function repoRelativePath(raw) {
|
|
|
565
565
|
|
|
566
566
|
/**
|
|
567
567
|
* The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
|
|
568
|
-
* `DELETED_HASH`) ∪ untracked files, filtered to `owned` (exact paths
|
|
568
|
+
* `DELETED_HASH`) ∪ untracked files, filtered to `owned` (`matchOwned`, default the registry's `ownsFile` with `ownedKinds`: an exact entry owns its path and the paths below it unless recorded as a regular file, B57), each with its content hash.
|
|
569
569
|
* Every path goes through `repoRelativePath`.
|
|
570
|
-
* @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean, changed?: string[]}} opts
|
|
570
|
+
* @param {{repoRoot: string, base: string, owned: string[], ownedKinds?: Record<string, string>, matchOwned?: (file: string) => boolean, changed?: string[]}} opts
|
|
571
571
|
* `changed`: the tree's changed files when the caller already listed them ({@link changedFiles}).
|
|
572
572
|
* @returns {Promise<Array<{file: string, content_hash: string}>>}
|
|
573
573
|
*/
|
|
574
|
-
export async function blockFileSet({ repoRoot, base, owned, matchOwned, changed }) {
|
|
574
|
+
export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwned, changed }) {
|
|
575
575
|
const all = changed ?? (await changedFiles({ repoRoot, base }));
|
|
576
|
-
const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned
|
|
576
|
+
const isOwned = matchOwned ?? ((/** @type {string} */ f) => ownsFile(owned, f, ownedKinds));
|
|
577
577
|
return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
|
|
578
578
|
}
|
|
579
579
|
|
|
@@ -597,15 +597,16 @@ export async function changedFiles({ repoRoot, base }) {
|
|
|
597
597
|
}
|
|
598
598
|
|
|
599
599
|
/**
|
|
600
|
-
* B52: whether `owned` (a block's `owned_files`) covers `file` at the gate: the
|
|
601
|
-
*
|
|
602
|
-
* no glob character
|
|
603
|
-
* `src/feature/Fan.swift`)
|
|
604
|
-
*
|
|
600
|
+
* B52: whether `owned` (a block's `owned_files`) covers `file` at the gate. B57: the registry's one
|
|
601
|
+
* ownership rule (`ownsFile`) — the rule `block open`, the scope gate and the worker use: an entry
|
|
602
|
+
* with no glob character covers its path and every path below it as a directory (`src/feature`
|
|
603
|
+
* owns `src/feature/Fan.swift`, never `src/featureX/B.swift`) unless `kinds` (the block's
|
|
604
|
+
* `owned_kinds`) recorded it as a regular file, a glob what it matches.
|
|
605
|
+
* @param {ReadonlyArray<string>} owned @param {string} file @param {Readonly<Record<string, string>>} [kinds]
|
|
606
|
+
* @returns {boolean}
|
|
605
607
|
*/
|
|
606
|
-
export function ownsPath(owned, file) {
|
|
607
|
-
|
|
608
|
-
return owned.some((o) => typeof o === 'string' && o.length > 0 && !/[*?[\]{}]/.test(o) && file.startsWith(`${o.replace(/\/+$/, '')}/`));
|
|
608
|
+
export function ownsPath(owned, file, kinds) {
|
|
609
|
+
return ownsFile(owned, file, kinds);
|
|
609
610
|
}
|
|
610
611
|
|
|
611
612
|
/**
|
|
@@ -614,11 +615,11 @@ export function ownsPath(owned, file) {
|
|
|
614
615
|
* given, or — for a workspace in a subdirectory (`wsPrefix`, the workspace relative to the top;
|
|
615
616
|
* `''` when they are the same) — relative to the workspace. A file outside the workspace is
|
|
616
617
|
* matched as given only.
|
|
617
|
-
* @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string) => boolean}
|
|
618
|
+
* @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean}
|
|
618
619
|
*/
|
|
619
620
|
export function ownsAt(wsPrefix) {
|
|
620
621
|
const prefix = wsPrefix.replace(/\/+$/, '');
|
|
621
|
-
return (owned, file) => ownsPath(owned, file) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1)));
|
|
622
|
+
return (owned, file, kinds) => ownsPath(owned, file, kinds) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1), kinds));
|
|
622
623
|
}
|
|
623
624
|
|
|
624
625
|
/** B52: the review rows that name a file the block's worker reviewed. */
|
|
@@ -673,8 +674,8 @@ const currentHash = (repoRoot, file) => (isOnDisk(repoRoot, file) ? contentHash(
|
|
|
673
674
|
* warns that the check did not run (never a silent pass).
|
|
674
675
|
* @param {{
|
|
675
676
|
* top: string, changed: ReadonlyArray<string>, id: string, blocks: Record<string, any>,
|
|
676
|
-
* rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string) => boolean, stateDir?: string,
|
|
677
|
-
* }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` match a path.
|
|
677
|
+
* rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string, kinds?: Readonly<Record<string, string>>) => boolean, stateDir?: string,
|
|
678
|
+
* }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` (with its `owned_kinds`) match a path.
|
|
678
679
|
* @returns {Promise<{unowned: string[], siblings: Array<{file: string, block: string}>} | null>}
|
|
679
680
|
*/
|
|
680
681
|
export async function unownedChanges({ top, changed, id, blocks, rows, key, isMine, owns = ownsPath, stateDir = '.code-forge' }) {
|
|
@@ -692,7 +693,7 @@ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMi
|
|
|
692
693
|
return others.some(
|
|
693
694
|
([other, b]) =>
|
|
694
695
|
b.status === 'closed' &&
|
|
695
|
-
owns(b.owned_files, file) &&
|
|
696
|
+
owns(b.owned_files, file, b.owned_kinds) &&
|
|
696
697
|
rows.some((r) => r?.event === 'review.approved' && r.block === other && r.file === file && r.content_hash === hash && verified(r, key)),
|
|
697
698
|
);
|
|
698
699
|
};
|
|
@@ -712,7 +713,7 @@ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMi
|
|
|
712
713
|
const siblings = [];
|
|
713
714
|
for (const file of changed) {
|
|
714
715
|
if (state(file) || isMine(file) || (await unchangedSinceOpen(file))) continue;
|
|
715
|
-
const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file));
|
|
716
|
+
const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file, b.owned_kinds));
|
|
716
717
|
if (sibling) siblings.push({ file, block: sibling[0] });
|
|
717
718
|
else if (!approvedClosed(file)) unowned.push(file);
|
|
718
719
|
}
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { existsSync, readFileSync } from 'node:fs';
|
|
30
30
|
import path from 'node:path';
|
|
31
31
|
import { assertOwned, findOverlap, ownsFile } from '../state/registry.mjs';
|
|
32
|
+
import { ownedKinds } from '../state/owned-kinds.mjs';
|
|
32
33
|
import { extractClaims, parseSheet, sha256 } from './facts.mjs';
|
|
33
34
|
import { findSection, nearMissWarning, REQUIRED_SECTIONS } from './plan-sections.mjs';
|
|
34
35
|
|
|
@@ -300,6 +301,9 @@ export function recordedLanes(rows, planName) {
|
|
|
300
301
|
* `run reload` is then backed by a VERIFIED `<bin> run reload`.
|
|
301
302
|
* @property {Array<Record<string, any>>} [decisions] - the project's ledger rows (the recorded lanes);
|
|
302
303
|
* none given ⇒ no block has a recorded lane.
|
|
304
|
+
* @property {string} [root] - B57: the project root the owned paths are relative to; an exact owned
|
|
305
|
+
* path that is a regular file there owns only itself in the overlap and seam checks (as at
|
|
306
|
+
* `block open`). None given ⇒ every exact owned path is directory-like (fail closed).
|
|
303
307
|
*/
|
|
304
308
|
|
|
305
309
|
/**
|
|
@@ -383,12 +387,23 @@ export function checkPlan(text, opts = {}) {
|
|
|
383
387
|
else if (b.lines > limits.blockLines) errors.push(`block ${b.id}: lines forecast ${b.lines} exceeds budget.block_lines ${limits.blockLines} — split it`);
|
|
384
388
|
}
|
|
385
389
|
|
|
386
|
-
// disjoint owned files per wave
|
|
390
|
+
// disjoint owned files per wave (B57: an exact path owns the paths below it unless it is a file)
|
|
391
|
+
// (paths are looked at only once they pass `assertOwned`: never `..` or absolute)
|
|
392
|
+
/** @param {PlanBlock} b @returns {Record<string, string>} */
|
|
393
|
+
const kindsFor = (b) => {
|
|
394
|
+
if (opts.root === undefined) return {};
|
|
395
|
+
try {
|
|
396
|
+
return ownedKinds(opts.root, assertOwned(b.owned));
|
|
397
|
+
} catch {
|
|
398
|
+
return {};
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const kindsOf = new Map(blocks.map((b) => [b.id, kindsFor(b)]));
|
|
387
402
|
for (let i = 0; i < blocks.length; i += 1) {
|
|
388
403
|
for (let j = i + 1; j < blocks.length; j += 1) {
|
|
389
404
|
const [a, b] = [blocks[i], blocks[j]];
|
|
390
405
|
if (a.wave !== b.wave) continue;
|
|
391
|
-
const hit = findOverlap(a.owned, b.owned);
|
|
406
|
+
const hit = findOverlap(a.owned, b.owned, { kindsA: kindsOf.get(a.id), kindsB: kindsOf.get(b.id) });
|
|
392
407
|
if (hit) errors.push(`blocks ${a.id} and ${b.id} (wave ${a.wave}) overlap: ${hit[0]} / ${hit[1]}`);
|
|
393
408
|
}
|
|
394
409
|
}
|
|
@@ -398,7 +413,7 @@ export function checkPlan(text, opts = {}) {
|
|
|
398
413
|
for (const b of blocks) {
|
|
399
414
|
const deps = closure(blocks, b.id);
|
|
400
415
|
for (const imported of b.imports) {
|
|
401
|
-
const owner = blocks.find((o) => o.id !== b.id && ownsFile(o.owned, imported));
|
|
416
|
+
const owner = blocks.find((o) => o.id !== b.id && ownsFile(o.owned, imported, kindsOf.get(o.id)));
|
|
402
417
|
if (owner && !deps.has(owner.id)) errors.push(`block ${b.id} imports ${imported} owned by ${owner.id}, which is not in its depends_on`);
|
|
403
418
|
}
|
|
404
419
|
}
|
package/src/state/block.mjs
CHANGED
|
@@ -17,9 +17,12 @@ import os from 'node:os';
|
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
19
19
|
import { exec } from '../util/exec.mjs';
|
|
20
|
+
import { DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
|
|
21
|
+
import { externalReviewer } from './config-snapshot.mjs';
|
|
22
|
+
import { ownedKinds, pathKind } from './owned-kinds.mjs';
|
|
20
23
|
import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
21
24
|
import { StateError } from './paths.mjs';
|
|
22
|
-
import { assertOwned, findOverlap, scopeGate } from './registry.mjs';
|
|
25
|
+
import { assertOwned, findOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
23
26
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
24
27
|
import { verifyRow, loadKey } from './signer.mjs';
|
|
25
28
|
|
|
@@ -253,6 +256,39 @@ function activeBlocks(record) {
|
|
|
253
256
|
return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
|
|
254
257
|
}
|
|
255
258
|
|
|
259
|
+
/**
|
|
260
|
+
* B58: with `review.external.required` set in the run's snapshot, the project's `.code-forge.yml`
|
|
261
|
+
* is the owner's switch past the local per-file review, so no block may own it. These are the
|
|
262
|
+
* spellings an owned entry may match it by: relative to the workspace and, for a workspace in a
|
|
263
|
+
* subdirectory, relative to the repository top level (the close gate matches both, `ownsAt`).
|
|
264
|
+
* Empty when the key is not set.
|
|
265
|
+
* @param {Record<string, any>} record @returns {Promise<string[]>}
|
|
266
|
+
* @throws {StateError} `config-snapshot`, `git-failed`
|
|
267
|
+
*/
|
|
268
|
+
async function guardedConfigPaths(record) {
|
|
269
|
+
if (externalReviewer(record) === null) return [];
|
|
270
|
+
const top = await repoTop(record.workspace);
|
|
271
|
+
// the repo-top spelling `<prefix>/.code-forge.yml` may also match a different file of the same
|
|
272
|
+
// name under the workspace (`<ws>/<prefix>/.code-forge.yml`): that over-match only refuses more,
|
|
273
|
+
// never less, and is the one `ownsAt` gives the close gate
|
|
274
|
+
const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
|
|
275
|
+
return prefix === '' ? [DEFAULT_CONFIG_FILENAME] : [DEFAULT_CONFIG_FILENAME, `${prefix}/${DEFAULT_CONFIG_FILENAME}`];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* B58: refuse an owned entry that owns one of `guarded` (B57's rule, `ownsFile`: the exact path,
|
|
280
|
+
* a directory above it, or a glob that matches it).
|
|
281
|
+
* @param {ReadonlyArray<string>} owned @param {import('./registry.mjs').OwnedKinds} kinds @param {ReadonlyArray<string>} guarded
|
|
282
|
+
* @throws {StateError} `config-owned`
|
|
283
|
+
*/
|
|
284
|
+
function refuseConfigOwner(owned, kinds, guarded) {
|
|
285
|
+
for (const entry of owned) {
|
|
286
|
+
if (guarded.some((file) => ownsFile([entry], file, kinds))) {
|
|
287
|
+
throw new StateError('config-owned', `owned entry ${JSON.stringify(entry)} covers the project's ${DEFAULT_CONFIG_FILENAME}; no block may own it while review.external.required is true`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
256
292
|
/**
|
|
257
293
|
* `block open` — refuses a second open of the same id and any owned set that overlaps another
|
|
258
294
|
* ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast and the
|
|
@@ -261,6 +297,8 @@ function activeBlocks(record) {
|
|
|
261
297
|
* and the dispatch row carry the floor level, and the row adds `requested_level` and
|
|
262
298
|
* `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
|
|
263
299
|
* tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
|
|
300
|
+
* B58: with `review.external.required` set, an owned entry that owns the project's config is
|
|
301
|
+
* refused (`config-owned`, {@link refuseConfigOwner}).
|
|
264
302
|
* @param {{
|
|
265
303
|
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
266
304
|
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
@@ -283,19 +321,26 @@ export async function openBlock(opts) {
|
|
|
283
321
|
|
|
284
322
|
// B52: the tree is stamped BEFORE the run lock (hashing can be slow; other block commands must
|
|
285
323
|
// not wait on it); under the lock the base is resolved again and must be the one stamped against
|
|
286
|
-
const
|
|
324
|
+
const before = await readRun(runId);
|
|
325
|
+
const { workspace } = before;
|
|
287
326
|
const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
|
|
288
327
|
const treeOpen = await treeAtOpen(workspace, stampedBase);
|
|
328
|
+
// B58: `review.external` cannot change mid-run, so the record read here decides
|
|
329
|
+
const guarded = await guardedConfigPaths(before);
|
|
289
330
|
|
|
290
331
|
const block = await mutateRun(runId, null, writeRow, async (record) => {
|
|
291
332
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
333
|
+
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
334
|
+
// file owns only itself, here and in every later check
|
|
335
|
+
const kinds = ownedKinds(record.workspace, owned);
|
|
336
|
+
refuseConfigOwner(owned, kinds, guarded);
|
|
292
337
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
293
|
-
const pair = findOverlap(owned, other.owned_files);
|
|
338
|
+
const pair = findOverlap(owned, other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
|
|
294
339
|
if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
295
340
|
}
|
|
296
341
|
const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
|
|
297
342
|
if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
|
|
298
|
-
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
|
|
343
|
+
const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
|
|
299
344
|
record.blocks[id] = entry;
|
|
300
345
|
const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
|
|
301
346
|
return {
|
|
@@ -318,7 +363,8 @@ export async function attemptBlock({ runId, id, writeRow }) {
|
|
|
318
363
|
* `block rebase` — move the base to `head` only when the old base is its ancestor and no commit
|
|
319
364
|
* in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
|
|
320
365
|
* (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
|
|
321
|
-
* is a conflict too. Never silent.
|
|
366
|
+
* is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
|
|
367
|
+
* below it).
|
|
322
368
|
* @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
|
|
323
369
|
* @returns {Promise<{old_base: string, new_base: string}>}
|
|
324
370
|
*/
|
|
@@ -329,7 +375,7 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
329
375
|
const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
|
|
330
376
|
if (ancestor.code !== 0) throw new StateError('base-not-ancestor', `block ${id}: base ${block.base_sha.slice(0, 12)} is not an ancestor of ${head}`);
|
|
331
377
|
const diff = await git(['git', 'diff', '--no-renames', '--name-only', block.base_sha, newBase], cwd);
|
|
332
|
-
const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 &&
|
|
378
|
+
const conflicts = diff.stdout.split('\n').filter((file) => file.length > 0 && ownsFile(block.owned_files, file, block.owned_kinds));
|
|
333
379
|
if (conflicts.length > 0) throw new StateError('owned-conflict', `block ${id}: commits since its base changed owned files: ${conflicts.join(', ')}`);
|
|
334
380
|
const moved = { old_base: block.base_sha, new_base: newBase };
|
|
335
381
|
block.base_sha = newBase;
|
|
@@ -339,18 +385,29 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
339
385
|
|
|
340
386
|
/**
|
|
341
387
|
* `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
|
|
342
|
-
* glob, and when another open block owns it
|
|
388
|
+
* glob, and when another open block owns it — or anything it would own: the claimed path becomes an
|
|
389
|
+
* owned entry, so it is checked with `findOverlap` (B57: an entry owns the paths below it too,
|
|
390
|
+
* unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
|
|
391
|
+
* B58: with `review.external.required` set, a path that owns the project's config is refused
|
|
392
|
+
* (`config-owned`), as at `block open`.
|
|
343
393
|
* @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
|
|
344
394
|
*/
|
|
345
395
|
export async function claimPath({ runId, id, file, writeRow }) {
|
|
346
396
|
assertOwned([file]);
|
|
347
|
-
if (
|
|
397
|
+
if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
|
|
398
|
+
const guarded = await guardedConfigPaths(await readRun(runId));
|
|
348
399
|
await mutateRun(runId, id, writeRow, (record, block) => {
|
|
400
|
+
const kinds = { [file]: pathKind(record.workspace, file) };
|
|
401
|
+
refuseConfigOwner([file], kinds, guarded);
|
|
349
402
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
350
|
-
|
|
403
|
+
const pair = otherId === id ? null : findOverlap([file], other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
|
|
404
|
+
if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
405
|
+
}
|
|
406
|
+
if (!block.owned_files.includes(file)) {
|
|
407
|
+
block.owned_files.push(file);
|
|
408
|
+
block.owned_kinds = { ...block.owned_kinds, ...kinds };
|
|
351
409
|
}
|
|
352
|
-
|
|
353
|
-
record.orphans = record.orphans.filter((/** @type {string} */ o) => o !== file);
|
|
410
|
+
record.orphans = record.orphans.filter((/** @type {string} */ o) => !ownsFile([file], o, kinds));
|
|
354
411
|
return { result: undefined, rows: [{ event: 'block.claim', block: id, path: file }] };
|
|
355
412
|
});
|
|
356
413
|
}
|
|
@@ -384,15 +441,18 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
|
|
|
384
441
|
* stopped); the block's signed `dispatch` row must exist and verify, and every other
|
|
385
442
|
* gate-relevant row of the block must verify ⇒ else `ledger.tamper` (block stopped); then
|
|
386
443
|
* `extraChecks`; finally, UNDER the run lock, orphans in the registry ⇒ refused (block stays
|
|
387
|
-
* open), else closed.
|
|
444
|
+
* open), else closed. B58: `closeRows` are signed and written under that same lock just before
|
|
445
|
+
* `block.close`, and only when the block closes — a row that records why it may close is never
|
|
446
|
+
* left behind by a refused close.
|
|
388
447
|
* @param {{
|
|
389
448
|
* runId: string, id: string, rows: Record<string, any>[], writeRow: WriteRow,
|
|
390
449
|
* livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
|
|
391
450
|
* extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
|
|
451
|
+
* closeRows?: Record<string, any>[],
|
|
392
452
|
* }} opts
|
|
393
453
|
* @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
394
454
|
*/
|
|
395
|
-
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [] }) {
|
|
455
|
+
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
|
|
396
456
|
const record = await readRun(runId);
|
|
397
457
|
if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
398
458
|
|
|
@@ -430,6 +490,6 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
|
|
|
430
490
|
block.status = 'closed';
|
|
431
491
|
/** @type {CloseResult} */
|
|
432
492
|
const closed = { ok: true, status: 'closed' };
|
|
433
|
-
return { result: closed, rows: [{ event: 'block.close', block: id, status: 'complete' }] };
|
|
493
|
+
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }] };
|
|
434
494
|
});
|
|
435
495
|
}
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { createHash } from 'node:crypto';
|
|
21
|
+
import { StateError } from './paths.mjs';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Keys that cannot change mid-run, each with why. A change to one is refused by `run reload`
|
|
@@ -30,6 +31,9 @@ export const IMMUTABLE_KEYS = Object.freeze({
|
|
|
30
31
|
'tmp.root': "the worker's run temp root is created at run start",
|
|
31
32
|
keys: 'the worker resolves keys once, at start',
|
|
32
33
|
'system1.key': 'the worker resolves the Jev key once, at start',
|
|
34
|
+
// B58: the owner's switch past the local per-file review is set before the run starts or not at
|
|
35
|
+
// all, so nobody turns it on (or off) mid-run
|
|
36
|
+
'review.external': 'the outside reviewer is fixed by the owner before the run starts',
|
|
33
37
|
});
|
|
34
38
|
|
|
35
39
|
/** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
|
|
@@ -148,3 +152,26 @@ export function snapshotFor(record, ticket) {
|
|
|
148
152
|
if (!isMapping(config) || configHash(config) !== hash) throw new Error('config-snapshot');
|
|
149
153
|
return { hash, config };
|
|
150
154
|
}
|
|
155
|
+
|
|
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.
|
|
160
|
+
* @param {Record<string, any>} record
|
|
161
|
+
* @returns {string | null}
|
|
162
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
163
|
+
*/
|
|
164
|
+
export function externalReviewer(record) {
|
|
165
|
+
let snap;
|
|
166
|
+
try {
|
|
167
|
+
snap = snapshotFor(record, '');
|
|
168
|
+
} catch (thrown) {
|
|
169
|
+
if (!(thrown instanceof Error) || thrown.message !== 'config-snapshot') throw thrown;
|
|
170
|
+
throw new StateError('config-snapshot', `run ${record.run_id}: the config snapshot in force does not match its hash`);
|
|
171
|
+
}
|
|
172
|
+
const external = snap?.config.review?.external;
|
|
173
|
+
if (!isMapping(external) || external.required !== true) return null;
|
|
174
|
+
// required without a usable name is no switch at all (the schema refuses it; this is the floor)
|
|
175
|
+
const name = typeof external.name === 'string' ? external.name.trim() : '';
|
|
176
|
+
return name === '' ? null : name; // trimmed: no row ever carries padding
|
|
177
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B57: what each EXACT owned entry is on disk when a block is opened or a path claimed — the
|
|
3
|
+
* answer the registry's ownership rule (`state/registry.mjs`) reads through `owned_kinds`. Only a
|
|
4
|
+
* regular file (`lstat`, so never a symlink, whatever it points at) is `file`: it owns only itself.
|
|
5
|
+
* Everything else stays directory-like (fail closed): `dir`, `absent` (not there yet, or a parent
|
|
6
|
+
* is not a directory), `other` (a symlink, a socket, or a path `lstat` could not read).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lstatSync } from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { isExactEntry } from './registry.mjs';
|
|
12
|
+
|
|
13
|
+
/** @typedef {'file' | 'dir' | 'absent' | 'other'} OwnedKind */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} root - the directory the entry is relative to (the run's workspace).
|
|
17
|
+
* @param {string} entry - a repo-relative POSIX path (already through `assertOwned`).
|
|
18
|
+
* @returns {OwnedKind}
|
|
19
|
+
*/
|
|
20
|
+
export function pathKind(root, entry) {
|
|
21
|
+
let st;
|
|
22
|
+
try {
|
|
23
|
+
st = lstatSync(path.join(root, ...entry.split('/')));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
const code = /** @type {any} */ (err)?.code;
|
|
26
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? 'absent' : 'other';
|
|
27
|
+
}
|
|
28
|
+
if (st.isFile()) return 'file';
|
|
29
|
+
if (st.isDirectory()) return 'dir';
|
|
30
|
+
return 'other';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The kind of every exact entry of `owned` (`isExactEntry` — a glob has no kind), keyed by the
|
|
35
|
+
* entry as written.
|
|
36
|
+
* @param {string} root @param {ReadonlyArray<string>} owned
|
|
37
|
+
* @returns {Record<string, OwnedKind>}
|
|
38
|
+
*/
|
|
39
|
+
export function ownedKinds(root, owned) {
|
|
40
|
+
/** @type {Record<string, OwnedKind>} */
|
|
41
|
+
const kinds = Object.create(null); // an entry named `__proto__` is a key like any other
|
|
42
|
+
for (const entry of owned) if (isExactEntry(entry)) kinds[entry] = pathKind(root, entry);
|
|
43
|
+
return kinds;
|
|
44
|
+
}
|
package/src/state/registry.mjs
CHANGED
|
@@ -18,11 +18,29 @@
|
|
|
18
18
|
* `test/fixtures/repos/node/**` are disjoint, `src/**` and `src/cli/*.mjs` overlap, and so do
|
|
19
19
|
* `src/*.mjs` and `src/cli/*.mjs` (they cannot share a path, but overlap is not ruled out by
|
|
20
20
|
* the prefix rule — the owner resolves it by naming exact paths).
|
|
21
|
+
*
|
|
22
|
+
* B57 — ONE ownership rule, here, for every caller (block open/claim/rebase, the scope gate, the
|
|
23
|
+
* worker's file set and budget, proof, plan check, the close gate's `ownsPath`): an entry with no
|
|
24
|
+
* glob character (`*`, `?`, `{`) owns that exact path AND every path below it, as a directory
|
|
25
|
+
* would (`src/feature` owns `src/feature/a.swift`, never `src/featureX/a.swift`); a glob entry
|
|
26
|
+
* matches as above and nothing more — a brace entry (`src/{a,b}`) is a glob, so each alternative
|
|
27
|
+
* owns exactly itself, nothing below it. This module is pure and cannot tell a file from a
|
|
28
|
+
* directory, so the caller passes what it saw: `kinds` (a block's `owned_kinds`, recorded at
|
|
29
|
+
* `block open` and `block claim` by `state/owned-kinds.mjs`) maps an exact entry to `file` when it
|
|
30
|
+
* was a regular file — then it owns only itself. Any other kind, or no kind (not there yet, an
|
|
31
|
+
* older block, a plan), stays directory-like: fail closed. So `findOverlap` reports a parent/child
|
|
32
|
+
* pair of directory-like entries (either way round), and a directory-like entry against a glob
|
|
33
|
+
* that matches the entry itself or ANY path below it (decided exactly, `globBelow`: `src/feature`
|
|
34
|
+
* vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
|
|
35
|
+
* a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
|
|
36
|
+
* `src/**\/*.mjs` does not).
|
|
21
37
|
*/
|
|
22
38
|
|
|
23
39
|
import { StateError } from './paths.mjs';
|
|
24
40
|
|
|
25
41
|
const GLOB_CHARS = /[*?]/;
|
|
42
|
+
/** What makes an owned ENTRY a glob (B57: the one definition): `*`, `?`, or a `{a,b}` group. */
|
|
43
|
+
const GLOB_ENTRY_CHARS = /[*?{]/;
|
|
26
44
|
/** Glob syntax this matcher does NOT implement — refused inside a glob entry only. */
|
|
27
45
|
const UNSUPPORTED_GLOB_CHARS = /[[\]()!+@]/;
|
|
28
46
|
|
|
@@ -99,7 +117,7 @@ export function assertOwned(owned) {
|
|
|
99
117
|
};
|
|
100
118
|
if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
|
|
101
119
|
if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
|
|
102
|
-
const isGlob =
|
|
120
|
+
const isGlob = !isExactEntry(entry);
|
|
103
121
|
if (isGlob && UNSUPPORTED_GLOB_CHARS.test(entry)) {
|
|
104
122
|
refuse('a glob may not contain [ ] ( ) ! + @ — character classes and extglobs are not supported; name the files exactly or use only *, ?, ** and {a,b}');
|
|
105
123
|
}
|
|
@@ -123,43 +141,124 @@ function staticSegments(p) {
|
|
|
123
141
|
const segmentPrefix = (a, b) => a.length <= b.length && a.every((s, i) => s === b[i]);
|
|
124
142
|
|
|
125
143
|
/**
|
|
126
|
-
*
|
|
144
|
+
* What a caller saw on disk for each exact owned entry (B57, the module doc); only `file` changes
|
|
145
|
+
* the rule. Keys are the entries' original spellings.
|
|
146
|
+
* @typedef {Readonly<Record<string, string>> | null | undefined} OwnedKinds
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* B57: is owned `entry` an EXACT path — no `*`, `?` or `{` (`[ ] ( ) ! + @ }` are literal there)?
|
|
151
|
+
* The one definition `assertOwned`, `block claim`, the ownership rule, proof export and
|
|
152
|
+
* `state/owned-kinds.mjs` share.
|
|
153
|
+
* @param {string} entry @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
export function isExactEntry(entry) {
|
|
156
|
+
return !GLOB_ENTRY_CHARS.test(entry);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* B57: an exact entry that is not a recorded regular file — it owns its path and everything below
|
|
161
|
+
* it (see the module doc). `kinds` is keyed by the entry as written.
|
|
162
|
+
* @param {string} entry @param {OwnedKinds} kinds @returns {boolean}
|
|
163
|
+
*/
|
|
164
|
+
const isTreeEntry = (entry, kinds) => isExactEntry(entry) && !(kinds != null && Object.hasOwn(kinds, entry) && kinds[entry] === 'file');
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* B57: an exact entry's path for matching — trailing slashes dropped (`assertOwned` refuses them in
|
|
168
|
+
* the run record; the close gate's callers may still pass one), the same in both functions.
|
|
169
|
+
* @param {string} entry @returns {string}
|
|
170
|
+
*/
|
|
171
|
+
const exactPath = (entry) => entry.replace(/\/+$/, '');
|
|
172
|
+
|
|
173
|
+
/** @param {string} root @param {string} file @returns {boolean} `file` is `root` or a path below it. */
|
|
174
|
+
const coversPath = (root, file) => file === root || file.startsWith(`${root}/`);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* B57: can brace-free glob `pattern` match some path strictly below directory `dir` (one or more
|
|
178
|
+
* segments after it)? Decided exactly: `dir`'s segments must match a prefix of the pattern, and
|
|
179
|
+
* any pattern left over (or a `**` reached on the way) can always be met by some name below.
|
|
180
|
+
* @param {string} dir @param {string} pattern
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
function globBelow(dir, pattern) {
|
|
184
|
+
const f = dir.split('/');
|
|
185
|
+
const p = pattern.split('/');
|
|
186
|
+
for (let i = 0; ; i += 1) {
|
|
187
|
+
if (i < p.length && p[i] === '**') return true;
|
|
188
|
+
if (i === f.length) return i < p.length;
|
|
189
|
+
if (i === p.length || !segmentRegExp(p[i]).test(f[i])) return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* One brace alternative of an owned entry: `tree` when the entry is directory-like (it owns the
|
|
195
|
+
* paths below it too). An exact entry is one alternative, its {@link exactPath}; an empty one
|
|
196
|
+
* (`/`) has none.
|
|
197
|
+
* @typedef {{p: string, tree: boolean}} Alternative
|
|
198
|
+
* @param {string} entry @param {OwnedKinds} kinds @returns {Alternative[]}
|
|
199
|
+
*/
|
|
200
|
+
function alternatives(entry, kinds) {
|
|
201
|
+
if (!isExactEntry(entry)) return expandBraces(entry).map((p) => ({ p, tree: false }));
|
|
202
|
+
const p = exactPath(entry);
|
|
203
|
+
return p.length === 0 ? [] : [{ p, tree: isTreeEntry(entry, kinds) }];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {Alternative} a @param {Alternative} b
|
|
127
208
|
* @returns {boolean}
|
|
128
209
|
*/
|
|
129
210
|
function patternsOverlap(a, b) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
211
|
+
if (b.tree && !a.tree) return patternsOverlap(b, a);
|
|
212
|
+
if (a.tree) {
|
|
213
|
+
if (b.tree) return coversPath(a.p, b.p) || coversPath(b.p, a.p);
|
|
214
|
+
if (!GLOB_CHARS.test(b.p)) return coversPath(a.p, b.p);
|
|
215
|
+
return globMatch(a.p, b.p) || globBelow(a.p, b.p);
|
|
216
|
+
}
|
|
217
|
+
const aGlob = GLOB_CHARS.test(a.p);
|
|
218
|
+
const bGlob = GLOB_CHARS.test(b.p);
|
|
219
|
+
if (!aGlob && !bGlob) return a.p === b.p;
|
|
220
|
+
if (!aGlob) return globMatch(a.p, b.p);
|
|
221
|
+
if (!bGlob) return globMatch(b.p, a.p);
|
|
222
|
+
const sa = staticSegments(a.p);
|
|
223
|
+
const sb = staticSegments(b.p);
|
|
137
224
|
return segmentPrefix(sa, sb) || segmentPrefix(sb, sa);
|
|
138
225
|
}
|
|
139
226
|
|
|
140
227
|
/**
|
|
228
|
+
* Could two owned lists ever own one path? An exact entry is directory-like unless its kind says
|
|
229
|
+
* `file` (B57, the module doc), so `src/feature` and `src/feature/x.swift` overlap either way round.
|
|
141
230
|
* @param {ReadonlyArray<string>} ownedA @param {ReadonlyArray<string>} ownedB
|
|
231
|
+
* @param {{kindsA?: OwnedKinds, kindsB?: OwnedKinds}} [kinds] - each list's `owned_kinds`.
|
|
142
232
|
* @returns {[string, string] | null} the first overlapping pair (original spellings), or null.
|
|
143
233
|
*/
|
|
144
|
-
export function findOverlap(ownedA, ownedB) {
|
|
234
|
+
export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
|
|
145
235
|
for (const a of ownedA) {
|
|
146
236
|
for (const b of ownedB) {
|
|
147
|
-
if (
|
|
237
|
+
if (alternatives(a, kindsA).some((x) => alternatives(b, kindsB).some((y) => patternsOverlap(x, y)))) return [a, b];
|
|
148
238
|
}
|
|
149
239
|
}
|
|
150
240
|
return null;
|
|
151
241
|
}
|
|
152
242
|
|
|
153
243
|
/**
|
|
154
|
-
*
|
|
244
|
+
* Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
|
|
245
|
+
* below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
|
|
246
|
+
* non-string or empty entry owns nothing; an exact entry is matched as its {@link exactPath}, as
|
|
247
|
+
* in `findOverlap`.
|
|
248
|
+
* @param {ReadonlyArray<unknown>} owned @param {string} file @param {OwnedKinds} [kinds] - the block's `owned_kinds`.
|
|
155
249
|
* @returns {boolean}
|
|
156
250
|
*/
|
|
157
|
-
export function ownsFile(owned, file) {
|
|
158
|
-
return owned.some((entry) =>
|
|
251
|
+
export function ownsFile(owned, file, kinds) {
|
|
252
|
+
return owned.some((entry) => {
|
|
253
|
+
if (typeof entry !== 'string' || entry.length === 0) return false;
|
|
254
|
+
if (!isExactEntry(entry)) return expandBraces(entry).some((p) => p === file || (GLOB_CHARS.test(p) && globMatch(file, p)));
|
|
255
|
+
const root = exactPath(entry);
|
|
256
|
+
return root.length > 0 && (isTreeEntry(entry, kinds) ? coversPath(root, file) : root === file);
|
|
257
|
+
});
|
|
159
258
|
}
|
|
160
259
|
|
|
161
260
|
/**
|
|
162
|
-
* @typedef {{owned_files: string[], status?: string}} RegistryBlock
|
|
261
|
+
* @typedef {{owned_files: string[], owned_kinds?: Record<string, string>, status?: string}} RegistryBlock
|
|
163
262
|
* @param {Record<string, RegistryBlock>} blocks - the ACTIVE blocks only (status `open`)
|
|
164
263
|
* @param {ReadonlyArray<string>} files - the shared tree's changed + untracked files
|
|
165
264
|
* @returns {{scopes: Record<string, string[]>, orphans: string[]}}
|
|
@@ -169,7 +268,7 @@ export function computeScopes(blocks, files) {
|
|
|
169
268
|
const scopes = Object.fromEntries(Object.keys(blocks).map((id) => [id, []]));
|
|
170
269
|
const orphans = [];
|
|
171
270
|
for (const file of [...new Set(files)].sort()) {
|
|
172
|
-
const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file));
|
|
271
|
+
const owners = Object.keys(blocks).filter((id) => ownsFile(blocks[id].owned_files, file, blocks[id].owned_kinds));
|
|
173
272
|
if (owners.length === 0) orphans.push(file);
|
|
174
273
|
for (const id of owners) scopes[id].push(file);
|
|
175
274
|
}
|
package/src/state/run.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { exec } from '../util/exec.mjs';
|
|
18
18
|
import { StateError, assertRunId, assertWorkspaceOutsideRuns, mirrorPath, newRunId, runRecordPath, runsDir } from './paths.mjs';
|
|
19
|
-
import { changedKeyPaths, configHash, IMMUTABLE_KEYS, immutableChanges, initialConfigState } from './config-snapshot.mjs';
|
|
19
|
+
import { changedKeyPaths, configHash, externalReviewer, IMMUTABLE_KEYS, immutableChanges, initialConfigState } from './config-snapshot.mjs';
|
|
20
20
|
import { generateKey, loadKey, signRow } from './signer.mjs';
|
|
21
21
|
|
|
22
22
|
/** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
|
|
@@ -149,6 +149,12 @@ export async function startRun(opts) {
|
|
|
149
149
|
throw new StateError('bad-project', 'project slug must match /^[a-z0-9][a-z0-9-]*$/ (config key project.slug)');
|
|
150
150
|
}
|
|
151
151
|
const workspace = assertWorkspaceOutsideRuns(opts.workspace);
|
|
152
|
+
// B58: the schema lets `review.external` stand without a name while `required` is off; on, the
|
|
153
|
+
// reviewer must be named — refused before anything is written
|
|
154
|
+
const external = config.review?.external;
|
|
155
|
+
if (external?.required === true && (typeof external.name !== 'string' || external.name.trim() === '')) {
|
|
156
|
+
throw new StateError('external-no-name', 'review.external.required is true but review.external.name is missing or blank');
|
|
157
|
+
}
|
|
152
158
|
const worker = await pinWorker(opts.workerPid, probe);
|
|
153
159
|
const record = {
|
|
154
160
|
version: 1,
|
|
@@ -179,6 +185,9 @@ export async function startRun(opts) {
|
|
|
179
185
|
await generateKey(runId);
|
|
180
186
|
await saveRun(record);
|
|
181
187
|
await writeSigned(runId, writeRow, { event: 'run.start', engine: record.engine, worker_pinned: worker !== null });
|
|
188
|
+
// B58: the owner's outside reviewer is on the ledger from the first row of the run on
|
|
189
|
+
const reviewer = externalReviewer(record);
|
|
190
|
+
if (reviewer !== null) await writeSigned(runId, writeRow, { event: 'review.external', required: true, reviewer });
|
|
182
191
|
return record;
|
|
183
192
|
});
|
|
184
193
|
}
|
|
@@ -198,8 +207,8 @@ export async function startRun(opts) {
|
|
|
198
207
|
* Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
|
|
199
208
|
* (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
|
|
200
209
|
* against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
|
|
201
|
-
* caller the same way `run start` did)
|
|
202
|
-
* `old_hash: null`.
|
|
210
|
+
* caller the same way `run start` did), and B58's `review.external.required` must not turn true;
|
|
211
|
+
* it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
|
|
203
212
|
*
|
|
204
213
|
* No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
|
|
205
214
|
* source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
|
|
@@ -238,6 +247,9 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
238
247
|
frozen = [];
|
|
239
248
|
if (effectiveSlug !== undefined && effectiveSlug !== current.project) frozen.push('project.slug');
|
|
240
249
|
if (typeof current.engine === 'string' && (config.engine ?? 'auto') !== current.engine) frozen.push('engine');
|
|
250
|
+
// B58: a run started without the key has none to keep — turning it on now is a change; an
|
|
251
|
+
// entry that leaves it off (`required` false or absent, as the schema default) is not
|
|
252
|
+
if (config.review?.external?.required === true) frozen.push('review.external');
|
|
241
253
|
}
|
|
242
254
|
if (frozen.length > 0) {
|
|
243
255
|
const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
|
package/src/worker/engine.mjs
CHANGED
|
@@ -107,16 +107,17 @@ import { assertTicketId } from './ticket.mjs';
|
|
|
107
107
|
* block entry without a base `block-base-missing` — a guessed base could let a change escape review.
|
|
108
108
|
* @param {string} runId @param {string} block
|
|
109
109
|
* The block's kind (B34) is the recorded `kind` when `block open` stored one, else `blockKind`
|
|
110
|
-
* of its owned files.
|
|
110
|
+
* of its owned files. `ownedKinds` is the recorded `owned_kinds` (B57: which exact entries were
|
|
111
|
+
* regular files at `block open`/`claim`), `{}` when there is none (every exact entry directory-like).
|
|
111
112
|
* With no run record the kind is null: `reviewTicket` takes the ticket file's kind.
|
|
112
|
-
* @returns {Promise<{base: string | null, level: string, owned: string[], acceptance?: unknown, kind: string | null}>}
|
|
113
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, acceptance?: unknown, kind: string | null}>}
|
|
113
114
|
*/
|
|
114
115
|
export async function blockEntryFor(runId, block) {
|
|
115
116
|
let record;
|
|
116
117
|
try {
|
|
117
118
|
record = await readRun(runId);
|
|
118
119
|
} catch (err) {
|
|
119
|
-
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], acceptance: undefined, kind: null };
|
|
120
|
+
if (/** @type {any} */ (err)?.code === 'no-run') return { base: null, level: 'L2', owned: [], ownedKinds: {}, acceptance: undefined, kind: null };
|
|
120
121
|
throw new Error('run-record-unreadable');
|
|
121
122
|
}
|
|
122
123
|
if (!record || typeof record !== 'object' || !record.blocks || typeof record.blocks !== 'object') throw new Error('run-record-unreadable');
|
|
@@ -125,7 +126,9 @@ export async function blockEntryFor(runId, block) {
|
|
|
125
126
|
const sha = entry?.base_sha;
|
|
126
127
|
if (typeof sha !== 'string' || sha.length === 0) throw new Error('block-base-missing');
|
|
127
128
|
const owned = Array.isArray(entry.owned_files) ? entry.owned_files.filter((f) => typeof f === 'string') : [];
|
|
128
|
-
|
|
129
|
+
const kinds = entry.owned_kinds;
|
|
130
|
+
const ownedKinds = kinds && typeof kinds === 'object' && !Array.isArray(kinds) ? kinds : {};
|
|
131
|
+
return { base: sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, acceptance: entry.acceptance, kind: blockKind({ owned, declared: entry.kind }) };
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/** The acceptance line written when the block's clauses cannot be read. */
|
|
@@ -210,7 +213,7 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
210
213
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
211
214
|
}
|
|
212
215
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
213
|
-
const peerDiffs = blockPeerDiffs(ctx.repoRoot, { base: entry.base, owned: entry.owned, file: ticket.file });
|
|
216
|
+
const peerDiffs = blockPeerDiffs(ctx.repoRoot, { base: entry.base, owned: entry.owned, ownedKinds: entry.ownedKinds, file: ticket.file });
|
|
214
217
|
try {
|
|
215
218
|
if (!ctx.key) {
|
|
216
219
|
return await reviewFile(
|
|
@@ -218,7 +221,7 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
218
221
|
{ spawn: ctx.spawn, ...(ctx.writeRow ? { writeRow: ctx.writeRow } : {}) },
|
|
219
222
|
);
|
|
220
223
|
}
|
|
221
|
-
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs });
|
|
224
|
+
return await fixLoopRound(ticket, ctx, { base: entry.base, level: entry.level, owned: entry.owned, ownedKinds: entry.ownedKinds, blockKindOf: entry.kind, key: ctx.key, workDir, factsExcerpt: acceptanceExcerpt(entry.acceptance, ctx.cfg), peerDiffs });
|
|
222
225
|
} finally {
|
|
223
226
|
rmSync(workDir, { recursive: true, force: true });
|
|
224
227
|
}
|
|
@@ -229,13 +232,13 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
229
232
|
* untracked), filtered to the block's owned files (`ownsFile`, as the budget row does), minus the
|
|
230
233
|
* ticket's file. The review names code moved between them (`## moved code`). No base, no owned
|
|
231
234
|
* list, or a git failure ⇒ [] (no hint; the review itself goes on unchanged).
|
|
232
|
-
* @param {string} repoRoot @param {{base: string | null, owned: string[], file: string}} opts
|
|
235
|
+
* @param {string} repoRoot @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>, file: string}} opts
|
|
233
236
|
* @returns {Promise<string[]>}
|
|
234
237
|
*/
|
|
235
|
-
export async function blockPeers(repoRoot, { base, owned, file }) {
|
|
238
|
+
export async function blockPeers(repoRoot, { base, owned, ownedKinds, file }) {
|
|
236
239
|
if (base === null || !Array.isArray(owned) || owned.length === 0) return [];
|
|
237
240
|
try {
|
|
238
|
-
return (await computeFileSet({ cwd: repoRoot, base })).all.filter((f) => f !== file && ownsFile(owned, f));
|
|
241
|
+
return (await computeFileSet({ cwd: repoRoot, base })).all.filter((f) => f !== file && ownsFile(owned, f, ownedKinds));
|
|
239
242
|
} catch {
|
|
240
243
|
return [];
|
|
241
244
|
}
|
|
@@ -246,16 +249,16 @@ export async function blockPeers(repoRoot, { base, owned, file }) {
|
|
|
246
249
|
* listed and the diffs read (`peerDiffReader`) at most once, however many packets ask — lazily, so
|
|
247
250
|
* a round with no packet (a stopped file) runs no git call. Any throw or rejection on the way ⇒
|
|
248
251
|
* no peer diffs (no hint), memoised like a success.
|
|
249
|
-
* @param {string} repoRoot @param {{base: string | null, owned: string[], file: string}} opts
|
|
252
|
+
* @param {string} repoRoot @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>, file: string}} opts
|
|
250
253
|
* @returns {() => Promise<import('../review/moved.mjs').PeerDiff[]>}
|
|
251
254
|
*/
|
|
252
|
-
export function blockPeerDiffs(repoRoot, { base, owned, file }) {
|
|
255
|
+
export function blockPeerDiffs(repoRoot, { base, owned, ownedKinds, file }) {
|
|
253
256
|
/** @type {Promise<import('../review/moved.mjs').PeerDiff[]> | null} */
|
|
254
257
|
let once = null;
|
|
255
258
|
return () => {
|
|
256
259
|
once ??= (async () => {
|
|
257
260
|
try {
|
|
258
|
-
return await peerDiffReader({ repoRoot, base, file, peers: await blockPeers(repoRoot, { base, owned, file }) })();
|
|
261
|
+
return await peerDiffReader({ repoRoot, base, file, peers: await blockPeers(repoRoot, { base, owned, ownedKinds, file }) })();
|
|
259
262
|
} catch {
|
|
260
263
|
return [];
|
|
261
264
|
}
|
|
@@ -326,10 +329,10 @@ export function mayTakeRung(state, kind, cfg, rungUsed) {
|
|
|
326
329
|
* first full round, or any round once the rung is gone) runs outside the block lock, in parallel
|
|
327
330
|
* with the block's other files.
|
|
328
331
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
329
|
-
* @param {{base: string | null, level: string, owned: string[], blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
332
|
+
* @param {{base: string | null, level: string, owned: string[], ownedKinds?: Record<string, string>, blockKindOf: string, key: Buffer, workDir: string, factsExcerpt: string, peerDiffs?: () => Promise<import('../review/moved.mjs').PeerDiff[]>}} opts
|
|
330
333
|
* @returns {Promise<ReviewOutcome>}
|
|
331
334
|
*/
|
|
332
|
-
async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
|
|
335
|
+
async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, blockKindOf, key, workDir, factsExcerpt, peerDiffs }) {
|
|
333
336
|
if (!ctx.readRows || !ctx.writeRow) return { status: 'unavailable', reason: 'no-ledger', approved: false, engine: 'adaptive', sessions: [] };
|
|
334
337
|
const readRows = ctx.readRows;
|
|
335
338
|
/** @type {{outcome: ReviewOutcome} | {round: () => Promise<ReviewOutcome>}} */
|
|
@@ -343,7 +346,7 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, blockKindOf, key,
|
|
|
343
346
|
// Check-then-write under the block lock: no other ticket of this block reads the rows between
|
|
344
347
|
// this check and the budget row's write, so the row is written once per block.
|
|
345
348
|
if (!rows.some((r) => r?.event === 'review.budget' && r.block === ticket.block)) {
|
|
346
|
-
await recordBlockBudget(ticket, ctx, { base, owned });
|
|
349
|
+
await recordBlockBudget(ticket, ctx, { base, owned, ownedKinds });
|
|
347
350
|
}
|
|
348
351
|
const where = { runRootDir: ctx.runRootDir, runId: ctx.runId, block: ticket.block, file: ticket.file, key, rows };
|
|
349
352
|
const loaded = loadState(where);
|
|
@@ -489,15 +492,15 @@ async function playRound(ticket, ctx, { state, kind, where, seq, base, blockKind
|
|
|
489
492
|
* not run yet), written as ONE `review.budget` row (+ `review.over_budget` when over). Best effort:
|
|
490
493
|
* a git or ledger failure is swallowed, the review goes on.
|
|
491
494
|
* @param {import('./queue.mjs').Ticket} ticket @param {ReviewContext} ctx
|
|
492
|
-
* @param {{base: string | null, owned: string[]}} opts
|
|
495
|
+
* @param {{base: string | null, owned: string[], ownedKinds?: Record<string, string>}} opts
|
|
493
496
|
*/
|
|
494
|
-
async function recordBlockBudget(ticket, ctx, { base, owned }) {
|
|
497
|
+
async function recordBlockBudget(ticket, ctx, { base, owned, ownedKinds }) {
|
|
495
498
|
try {
|
|
496
499
|
/** @type {string[]} */
|
|
497
500
|
let changed = [];
|
|
498
501
|
if (base !== null && owned.length > 0) {
|
|
499
502
|
try {
|
|
500
|
-
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f));
|
|
503
|
+
changed = (await computeFileSet({ cwd: ctx.repoRoot, base })).all.filter((f) => ownsFile(owned, f, ownedKinds));
|
|
501
504
|
} catch {
|
|
502
505
|
// no row on a git failure: a ticket-only forecast would be permanent (the row is written
|
|
503
506
|
// once per block); the block's next keyed ticket retries
|