@codedology/code-forge 0.15.0 → 0.15.2
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 +17 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +130 -1
- package/package.json +1 -1
- package/skill/SKILL.md +2 -0
- package/skill/references/code.md +3 -1
- package/skill/references/security.md +2 -0
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +3 -1
- package/src/cli/block.mjs +42 -12
- package/src/config/schema-compile.mjs +6 -4
- package/src/review/gate-check.mjs +11 -4
- package/src/session/facts.mjs +18 -4
- package/src/state/block.mjs +34 -2
- package/src/util/forbidden.mjs +216 -6
- package/src/worker/engine.mjs +15 -3
- package/src/worker/queue.mjs +33 -6
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.15.2] — 2026-10-30
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- OpenAI strict schemas make only optional keys nullable, so `forge facts` no longer refuses an answer with null on a required field; a facts row with a null command or excerpt is downgraded to UNVERIFIABLE on its own (issue #27, B165)
|
|
14
|
+
|
|
15
|
+
## [0.15.1] — 2026-10-19
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- The test leak check ignores a lock held by a live process outside the suite (same pid start time), so gates can run while a review worker runs (B142)
|
|
20
|
+
- The coder brief keeps only the final test log after a sandbox EPERM rerun, and fix rounds list the typecheck command next to the block tests (B140)
|
|
21
|
+
- The first-use and brief-mode walkthrough tests no longer fail when a live code-forge run of the developer writes into ~/.code-forge during the suite (B145)
|
|
22
|
+
- Process tests have wider time bounds, so the suite no longer fails from slowness on a busy machine (B144)
|
|
23
|
+
- The Codex transcript check ignores rule text inside a node/python/ruby/perl script body that spawns nothing, also inside `zsh -lc` wrappers (B141)
|
|
24
|
+
- New human verb `block amend-acceptance` corrects a wrong acceptance clause of an open block; reviews signed before the change no longer count at close (B143b)
|
|
25
|
+
|
|
9
26
|
## [0.15.0] — 2026-10-19
|
|
10
27
|
|
|
11
28
|
### Changed
|
package/README.md
CHANGED
|
@@ -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": 156,
|
|
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": {
|
|
@@ -2754,6 +2754,135 @@
|
|
|
2754
2754
|
"B106"
|
|
2755
2755
|
],
|
|
2756
2756
|
"note": "Issue #16 item 7 (rest of Group E). Fix round 1: results without a worker field keep the old pin check (82 old tests). Fix round 2: a recycled live pid must match its own pin (critical); an added worker is stopped when its probe fails."
|
|
2757
|
+
},
|
|
2758
|
+
"B142": {
|
|
2759
|
+
"title": "Test leak check ignores a verified live foreign lock (owner pid alive, same start time, not the suite or its descendant)",
|
|
2760
|
+
"wave": 14,
|
|
2761
|
+
"level": "L2",
|
|
2762
|
+
"status": "landed",
|
|
2763
|
+
"owned": [
|
|
2764
|
+
"test/helpers/isolate.mjs",
|
|
2765
|
+
"test/helpers-isolate.test.mjs"
|
|
2766
|
+
],
|
|
2767
|
+
"depends_on": [],
|
|
2768
|
+
"note": "F-19. Gates passed on the second run (first run: one reaper timing flake at load ~490)."
|
|
2769
|
+
},
|
|
2770
|
+
"B140": {
|
|
2771
|
+
"title": "Coder brief keeps only the final test log after a sandbox EPERM rerun; fix rounds list the typecheck command",
|
|
2772
|
+
"wave": 14,
|
|
2773
|
+
"level": "L2",
|
|
2774
|
+
"status": "landed",
|
|
2775
|
+
"owned": [
|
|
2776
|
+
"skill/references/code.md",
|
|
2777
|
+
"skill/templates/coder-brief.md",
|
|
2778
|
+
"test/skill/structure.test.mjs"
|
|
2779
|
+
],
|
|
2780
|
+
"depends_on": [],
|
|
2781
|
+
"note": "F-20 + F-23. Gates passed on the third run with --timeout-ms 1800000 after B142 landed. Closed on the old base, then rebased (F-27)."
|
|
2782
|
+
},
|
|
2783
|
+
"B145": {
|
|
2784
|
+
"title": "In-test no-leak checks (first-use, brief-mode walkthrough) use the suite leak filter: baseline runs and verified live foreign locks are not leaks",
|
|
2785
|
+
"wave": 14,
|
|
2786
|
+
"level": "L2",
|
|
2787
|
+
"status": "landed",
|
|
2788
|
+
"owned": [
|
|
2789
|
+
"test/helpers/isolate.mjs",
|
|
2790
|
+
"test/helpers-isolate.test.mjs",
|
|
2791
|
+
"test/first-use.test.mjs",
|
|
2792
|
+
"test/integration/brief-mode-walkthrough.test.mjs"
|
|
2793
|
+
],
|
|
2794
|
+
"depends_on": [
|
|
2795
|
+
"B142"
|
|
2796
|
+
],
|
|
2797
|
+
"note": "F-28, found in the B141 gates during this run. Gates passed on the first run."
|
|
2798
|
+
},
|
|
2799
|
+
"B144": {
|
|
2800
|
+
"title": "Process-spawning tests get per-test bounds of at least 30 s and wider observation deadlines, keeping the timing proofs that matter (SIGKILL at settle)",
|
|
2801
|
+
"wave": 14,
|
|
2802
|
+
"level": "L2",
|
|
2803
|
+
"status": "landed",
|
|
2804
|
+
"owned": [
|
|
2805
|
+
"test/util/exec.test.mjs",
|
|
2806
|
+
"test/util/reaper.test.mjs",
|
|
2807
|
+
"test/util/tmp.test.mjs",
|
|
2808
|
+
"test/state/block-end.test.mjs",
|
|
2809
|
+
"test/worker/helpers.mjs",
|
|
2810
|
+
"test/worker/worker.test.mjs",
|
|
2811
|
+
"test/worker/pool.test.mjs",
|
|
2812
|
+
"test/worker/heartbeat.test.mjs",
|
|
2813
|
+
"test/worker/orphan.test.mjs",
|
|
2814
|
+
"test/worker/multi.test.mjs",
|
|
2815
|
+
"test/cli/review.test.mjs"
|
|
2816
|
+
],
|
|
2817
|
+
"depends_on": [],
|
|
2818
|
+
"note": "F-24. One fix round (2 review warnings). Gates passed on the first run. F-29 (queue enqueue < 1 s) left out: it is a product speed rule."
|
|
2819
|
+
},
|
|
2820
|
+
"B141": {
|
|
2821
|
+
"title": "The Codex transcript check ignores rule text inside a node/python/ruby/perl script body that spawns nothing, also inside zsh -lc wrappers; parent-shell substitutions in double-quoted wrapper segments still count",
|
|
2822
|
+
"wave": 14,
|
|
2823
|
+
"level": "L2",
|
|
2824
|
+
"status": "landed",
|
|
2825
|
+
"owned": [
|
|
2826
|
+
"src/util/forbidden.mjs",
|
|
2827
|
+
"test/util/transcript-codex.test.mjs",
|
|
2828
|
+
"skill/references/security.md"
|
|
2829
|
+
],
|
|
2830
|
+
"depends_on": [],
|
|
2831
|
+
"note": "F-21. Three fix rounds (zsh -lc wrappers, shell quoting, double-quoted substitutions). The real B137b transcript gives no hit. Gates passed on the third run (runs 1-2: load and live-run flakes, fixed by B144/B145)."
|
|
2832
|
+
},
|
|
2833
|
+
"B143": {
|
|
2834
|
+
"title": "A human verb to correct a block's acceptance clauses (block amend-acceptance)",
|
|
2835
|
+
"wave": 14,
|
|
2836
|
+
"level": "L2",
|
|
2837
|
+
"status": "landed",
|
|
2838
|
+
"owned": [
|
|
2839
|
+
"src/cli/block.mjs",
|
|
2840
|
+
"src/state/block.mjs",
|
|
2841
|
+
"src/review/gate-check.mjs",
|
|
2842
|
+
"src/util/forbidden.mjs",
|
|
2843
|
+
"src/cli/review-file.mjs",
|
|
2844
|
+
"src/worker/engine.mjs",
|
|
2845
|
+
"src/worker/queue.mjs",
|
|
2846
|
+
"docs/how-it-works.md",
|
|
2847
|
+
"skill/references/code.md",
|
|
2848
|
+
"skill/SKILL.md",
|
|
2849
|
+
"test/cli/block-amend-acceptance.test.mjs",
|
|
2850
|
+
"test/engines/codex-home.test.mjs",
|
|
2851
|
+
"test/engines/builders/forbidden-rendering.test.mjs",
|
|
2852
|
+
"test/engines/builders/argv-snapshots.test.mjs"
|
|
2853
|
+
],
|
|
2854
|
+
"depends_on": [
|
|
2855
|
+
"B141"
|
|
2856
|
+
],
|
|
2857
|
+
"note": "F-17. Stopped for scope only: two pinned counts in test/util/forbidden.test.mjs were not in the owned list. Its changes landed through B143b."
|
|
2858
|
+
},
|
|
2859
|
+
"B143b": {
|
|
2860
|
+
"title": "A human verb to correct a block's acceptance clauses: block amend-acceptance (terminal confirm, signed row, stale approvals no longer count, new review tickets see the new clauses; coder-forbidden)",
|
|
2861
|
+
"wave": 14,
|
|
2862
|
+
"level": "L2",
|
|
2863
|
+
"status": "landed",
|
|
2864
|
+
"owned": [
|
|
2865
|
+
"src/cli/block.mjs",
|
|
2866
|
+
"src/state/block.mjs",
|
|
2867
|
+
"src/review/gate-check.mjs",
|
|
2868
|
+
"src/util/forbidden.mjs",
|
|
2869
|
+
"src/cli/review-file.mjs",
|
|
2870
|
+
"src/worker/engine.mjs",
|
|
2871
|
+
"src/worker/queue.mjs",
|
|
2872
|
+
"docs/how-it-works.md",
|
|
2873
|
+
"skill/references/code.md",
|
|
2874
|
+
"skill/SKILL.md",
|
|
2875
|
+
"test/cli/block-amend-acceptance.test.mjs",
|
|
2876
|
+
"test/engines/codex-home.test.mjs",
|
|
2877
|
+
"test/engines/builders/forbidden-rendering.test.mjs",
|
|
2878
|
+
"test/engines/builders/argv-snapshots.test.mjs",
|
|
2879
|
+
"test/util/forbidden.test.mjs"
|
|
2880
|
+
],
|
|
2881
|
+
"depends_on": [
|
|
2882
|
+
"B143",
|
|
2883
|
+
"B141"
|
|
2884
|
+
],
|
|
2885
|
+
"note": "F-17. Took the B143 commit, updated the pinned coder-only counts. Reviews clean on the first join; gates passed on the first run."
|
|
2757
2886
|
}
|
|
2758
2887
|
}
|
|
2759
2888
|
}
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -22,6 +22,8 @@ State lives in the plan file's header — `facts → planned → hardened → co
|
|
|
22
22
|
|
|
23
23
|
## §2 Roles by level (never by model name)
|
|
24
24
|
|
|
25
|
+
`block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>` is human-only: it corrects an open block's clauses after terminal confirmation, requires a nonblank reason, has no `--yes`, and refuses an active autopilot grant. Earlier reviews become stale and a fresh review uses the new clauses. Coders never run it (`references/code.md` §5).
|
|
26
|
+
|
|
25
27
|
`block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]` is human-only: the owner checks the listed device checks and confirms in their own terminal, with no `--yes` and no active autopilot grant. The note is required; proof is optional, must name an existing path when given, and is recorded as `proof: null` when omitted. A successful code close with `kind: human_check` acceptance clauses leaves `awaiting_human_check`, which status and report list; it has no fresh done file until the human passes or stops it. Never run this verb for the human, and do not end the run or treat the block as ready to land while it is awaiting a check (`references/code.md` §5).
|
|
26
28
|
|
|
27
29
|
| role | level | session | who runs it |
|
package/skill/references/code.md
CHANGED
|
@@ -68,10 +68,12 @@ Waived or external reviews (`--no-require-reviews`, `review.external`) do not li
|
|
|
68
68
|
|
|
69
69
|
Each `review-file` ticket is served by the worker `forge run start` launched: tools first (the project's gates on that file — red ⇒ `tools_red`, no model review), then S1 `risk`, then the planned depth (`review.md` §2). You watch the run, not the coder's output: `forge status` (B64a) is the progress check — read-only (no ledger row, never the run lock), every run of this repository that is not ended, newest first, with a blank line between runs, at most 10 lines per run: `run <id> · <status> · <plan|brief> · spent $<x>[ of $<budget>]` (money with 2 decimals; `$<x>+?` when some session has no known price, `$?` when the ledger cannot be read), then per open or in-attempt block `<id> <open|attempt-<n>> <level> · <model or -> · gate <pass|fail|none>[ (<gate>)] · findings <n>` (the gate field is `gate pass`, `gate fail (<gate>)` with the failing gate's name, `gate fail` when no gate was named, or `gate none` when no gate ran; findings = the count of open review findings the close gate would refuse today), `… +<k> more blocks` as the last block line when they do not fit, and `closed <a> · stopped <b>` only when either is non-zero — printed LAST, but it keeps its place in the cap ahead of block lines (block lines are dropped first). No such run: `no active run`, exit 0. `--run <id>` shows one run (ended too); an unknown run exits 1, a usage error exits 2. `--json` prints one line, `{"runs":[…]}`, with every open block (no cap, so `more` is always 0), `unknown_usd_sessions`, and per block `gate: {result, name}` (`name` null for `pass`, for `none` and for an unnamed fail). Use it instead of reading logs or polling the coder's transcript: it never prints transcript or diff text. `forge ledger tail --slug <slug>` shows the rows themselves (`review.plan`, `review.round`, `review.done`). Idle is a wake, never proof; the sentinel is the only completion signal (`adapters/*.md`).
|
|
70
70
|
|
|
71
|
-
**One fix round per block (B94).** After the coder's first COMPLETE, wait until every `review-file` ticket of the block is answered and the block's tests ran; then send ONE fix message with every failing test (name and first error line) and every open finding (by file and id). Never one finding or one file at a time. A finding that arrives after that message waits for the next round. In that round the coder fixes, runs
|
|
71
|
+
**One fix round per block (B94).** After the coder's first COMPLETE, wait until every `review-file` ticket of the block is answered and the block's tests ran; then send ONE fix message with every failing test (name and first error line) and every open finding (by file and id). The message lists the block test command next to the project build/typecheck command from the context pack's `build` field (for example, `npm run typecheck`). Never one finding or one file at a time. A finding that arrives after that message waits for the next round. In that round the coder fixes, runs both commands, follows the selected file loop on every changed file, and prints its `TESTS` line. In fix rounds, follow the selected loop: `wait` uses `forge review-file` and `--wait`, `overlap` enqueues each green file, and `watch` commits each green file. Both `overlap` and `watch` run `forge review-file --wait-block <id> --run <r>` before the sentinel. The per-file cap still counts per file: one block round is one round for each file it touched (`review.md` §5).
|
|
72
72
|
|
|
73
73
|
## §5 The block gate is a re-measurement — `forge block close <id> --run <r>`
|
|
74
74
|
|
|
75
|
+
To correct an open block's snapshotted clauses, the human uses `code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>` with a nonblank reason and the same acceptance format as `block open`. Both terminals and confirmation are required, with no `--yes` and no active autopilot grant. Invalid or unchanged lists write nothing; a successful amendment updates the human checks and signs one `block.acceptance_amended` row with both clause-list hashes. Earlier reviews no longer count at close; review requests, block waits and watch ticks queue a fresh review with the new clauses. Coders must never run this human verb.
|
|
76
|
+
|
|
75
77
|
The human can use `block close <id> --run <r> --decide --reason <text>` to see all waivable findings, unacknowledged agent waivers and acceptable transcript hits in one numbered list, then confirm once. Yes writes the same signed human rows as the individual verbs, with the shared reason for findings and hits, then runs normal close. No writes nothing. Unreviewed or stopped content needing a new review, failed gates, unowned changes and missing proof print `cannot decide here:` and write nothing. Both terminal streams are required; no `--yes`; an active autopilot grant refuses decision close. Coders must leave this step to the human.
|
|
76
78
|
|
|
77
79
|
`close.require_human: true` in the run snapshot also makes plain close require terminal confirmation. It cannot change through `run reload`. An active `close:routine` grant permits unattended close only with signed approvals for every changed file, a signed green gate row, no waiver rows of any kind and no transcript or accepted hits; otherwise `close-not-routine: <reason>` refuses it. A routine close also signs `autopilot.close {block, grant}`. With the key absent or false, ordinary close retains its existing behavior.
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
Rendered into every coder spawn as the CLI's own deny rules (path rules widen a directory to `<dir>/**`; a read-write path denies read, edit and write); carried verbatim in the brief; refused by the package's own process spawner; and at block close the transcript (captured stdout, the harness log, or the Solo output) is grepped for the forbidden verbs **and paths**. A hit fails the gate as `rule_break`. Only the human can accept a false hit with `block accept-hit`, after terminal confirmation. That verb is also coder-forbidden. It writes one signed `rule_break.accepted` row for the run, block, rule and SHA-256 of the exact transcript line text. Close verifies the signature and matches by line content, so moving the line does not invalidate it. Unsigned, tampered or other-run or other-block rows never count. A ledger `rule_break` row still refuses. Where a CLI cannot load a rules file, `doctor` says so and the transcript grep remains the detector.
|
|
12
12
|
|
|
13
|
+
In Codex `exec --json` transcripts, node, Python, Ruby and Perl heredoc or quoted inline script bodies without a process-spawn word are not scanned for command or contains rules. Backticks and `$(` count when the shell expands the body (an unquoted heredoc delimiter or double-quoted inline script), or for Ruby/Perl; `%x` counts for Ruby/Perl, and `qx` for Perl. Quoted or backslash-escaped heredoc delimiters and single-quoted inline scripts prevent shell expansion. A single sh/bash/zsh/dash/ksh wrapper with `-c`, `-lc`, `-ic` or `-l -c` and one quoted script is decoded before this check; its prefix remains scanned. Path rules still apply inside those bodies. Nested or malformed wrappers, shell and unknown-command heredocs, unterminated heredocs and the shell text outside interpreter bodies are scanned whole; the plain-text transcript grep is unchanged.
|
|
14
|
+
|
|
13
15
|
## §3 Signed ledger rows — the threat model, stated honestly
|
|
14
16
|
|
|
15
17
|
`block unwaive` and `block ack-waivers` are human-only and coder-forbidden with both `forge` and `code-forge` prefixes. Both require a yes at a terminal and have no `--yes`. No terminal exits 2 with no row. A no, cancel or pre-check refusal writes nothing. Only verified human undo and acknowledgement rows count at close.
|
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.15.
|
|
13
|
+
PINNED_VERSION="0.15.2"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -33,6 +33,8 @@ Proof is behaviour, not text: never read a source file in a test to assert on it
|
|
|
33
33
|
|
|
34
34
|
## Final report — the close prints the ticket ids
|
|
35
35
|
|
|
36
|
+
If the sandbox refuses process probes with EPERM errors, rerun the block tests outside the sandbox. Keep and report only the final run. Name the final log file in the report. A saved log of a refused sandbox run is not proof either way.
|
|
37
|
+
|
|
36
38
|
Run the block's test command (`test_command`) and print its result on a line of its own, exactly:
|
|
37
39
|
|
|
38
40
|
`TESTS <command> exit=<n>`
|
|
@@ -54,7 +56,7 @@ Or `===BLOCK <id> FAILED: <reason>===`.
|
|
|
54
56
|
|
|
55
57
|
## Fix-round brief (round ≥ 2, or a respawn after a climb)
|
|
56
58
|
|
|
57
|
-
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then follow the selected file loop on **every file the round changed** (`wait`: run `forge review-file <path> --block <id> --run <r>` and `--wait`; `overlap`: enqueue each green file and continue; `watch`: commit each green file; both `overlap` and `watch`: run `forge review-file --wait-block <id> --run <r>` before the sentinel) — a fix the reviewer never saw is not a fix. A fix made in another owned file is sent with `--answers <ids> --of <source>` (The file loop). The round's final report follows the same rule as the first: it needs no ticket lines.
|
|
59
|
+
The open findings by id, the fix history, the files they sit in. The message lists the block test command next to the project build/typecheck command from the context pack's `build` field (for example, `npm run typecheck`). Fix only those hunks, then follow the selected file loop on **every file the round changed** (`wait`: run `forge review-file <path> --block <id> --run <r>` and `--wait`; `overlap`: enqueue each green file and continue; `watch`: commit each green file; both `overlap` and `watch`: run `forge review-file --wait-block <id> --run <r>` before the sentinel) — a fix the reviewer never saw is not a fix. A fix made in another owned file is sent with `--answers <ids> --of <source>` (The file loop). The round's final report follows the same rule as the first: it needs no ticket lines. Run both commands after the fixes and before the round's sentinel. Print a fresh `TESTS <command> exit=<n>` line just before the round's sentinel: a TESTS line from an earlier report does not count. In a fix round, you may use `code-forge gates run --run <r> --block <id> --changed` as the block's test command; the TESTS line names the command actually run (substitute the run and block ids). Any green `code-forge gates run --run <r> --block <id>` records the owned file hashes on `gate.done`, including runs without `--changed`; when `--slug` is omitted, these rows go to the run's project ledger. More than 200 owned files means no record, so the next `--changed` falls back with `no_baseline`; unreadable inputs also leave no record. `{test_files}` expands to changed tests and stem-paired tests of changed sources; `{changed_files}` expands to changed lint/format paths. Deleted files still count for pairing and the threshold, but both tokens receive only files that exist now. Both tokens must be whole argv items and cannot be the command or the only item; config validation refuses a gate whose command or only item is `{changed_files}`. Without `--changed`, `{changed_files}` is dropped. Gates without tokens run as before. Rows carry `scope: changed`, including fallback rows with a `reason`: `no_baseline` uses today's scope, `unpaired` uses today's test scope when a changed source has no test pair, `threshold` runs the full suite above `gates.full_suite_threshold_files` changed files, and `no_changes` uses today's scope when nothing is left to run. The orchestrator runs the full `code-forge gates run` before `block close` (D2).
|
|
58
60
|
|
|
59
61
|
## Context pack
|
|
60
62
|
|
package/src/cli/block.mjs
CHANGED
|
@@ -202,7 +202,7 @@ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
|
202
202
|
import { fallbackLane } from '../decide/fallback-rules.mjs';
|
|
203
203
|
import { laneFacts } from '../decide/lane-facts.mjs';
|
|
204
204
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
205
|
-
import { attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
205
|
+
import { amendAcceptanceBlock, attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, prepareAcceptanceAmendment, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
206
206
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
207
207
|
import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
|
|
208
208
|
import { readFileDiff } from '../review/packet.mjs';
|
|
@@ -219,7 +219,7 @@ import { validateConfig } from '../config/validate.mjs';
|
|
|
219
219
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
220
220
|
import { exec } from '../util/exec.mjs';
|
|
221
221
|
import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
222
|
-
import { clearDone, resolveTicket } from '../worker/queue.mjs';
|
|
222
|
+
import { clearDone, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
|
|
223
223
|
import { loadState } from '../worker/review-state.mjs';
|
|
224
224
|
import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
225
225
|
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
@@ -300,9 +300,10 @@ const USAGE =
|
|
|
300
300
|
' code-forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
|
|
301
301
|
' code-forge block ack-waivers <id> --run <r>\n' +
|
|
302
302
|
' code-forge block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]\n' +
|
|
303
|
+
' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
|
|
303
304
|
' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
|
|
304
305
|
|
|
305
|
-
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass'];
|
|
306
|
+
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass', 'amend-acceptance'];
|
|
306
307
|
|
|
307
308
|
/** Refuse likely shell quoting slips before any row is written.
|
|
308
309
|
* @param {string} workspace @param {string[]} entries @param {string} label
|
|
@@ -376,6 +377,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
376
377
|
const [id, extra] = positionals;
|
|
377
378
|
const runId = typeof flags.run === 'string' ? flags.run : undefined;
|
|
378
379
|
if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
|
|
380
|
+
if (sub === 'amend-acceptance' && (positionals.length !== 1 || typeof flags.acceptance !== 'string' || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason))) {
|
|
381
|
+
throw new StateError('usage', 'amend-acceptance needs one block id, --acceptance and a nonblank --reason without control characters');
|
|
382
|
+
}
|
|
379
383
|
if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
|
|
380
384
|
throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
|
|
381
385
|
}
|
|
@@ -401,6 +405,30 @@ export async function runBlock(args, deps = {}) {
|
|
|
401
405
|
return 0;
|
|
402
406
|
}
|
|
403
407
|
const record = await readRun(runId);
|
|
408
|
+
if (sub === 'amend-acceptance') {
|
|
409
|
+
/** @type {ReturnType<typeof prepareAcceptanceAmendment> | null} */
|
|
410
|
+
let prepared = null;
|
|
411
|
+
const what = { file: '', oldCount: 0, newCount: 0 };
|
|
412
|
+
const noGrant = (/** @type {Record<string, any>} */ current) => {
|
|
413
|
+
if (current.autopilot && grantState(current.autopilot, (deps.now ?? (() => new Date()))()) === 'active') throw new StateError('autopilot-active', 'amend-acceptance is refused while an autopilot grant is active');
|
|
414
|
+
};
|
|
415
|
+
const asked = await confirmHumanVerb(sub, runId, id, what, record, deps, async () => {
|
|
416
|
+
noGrant(record);
|
|
417
|
+
const clauses = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
|
|
418
|
+
prepared = prepareAcceptanceAmendment(record, id, clauses);
|
|
419
|
+
what.oldCount = prepared.oldCount;
|
|
420
|
+
what.newCount = prepared.acceptance.length;
|
|
421
|
+
});
|
|
422
|
+
if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
|
|
423
|
+
if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
|
|
424
|
+
if (prepared === null) throw new Error('acceptance was not prepared');
|
|
425
|
+
const accepted = /** @type {ReturnType<typeof prepareAcceptanceAmendment>} */ (prepared);
|
|
426
|
+
const row = await amendAcceptanceBlock({ runId, id, acceptance: accepted.acceptance, old_sha256: accepted.old_sha256, reason: /** @type {string} */ (flags.reason), writeRow: (signed) => appendRow(signed, { slug: record.project }), before: noGrant });
|
|
427
|
+
resetAcceptanceWatch(await repoTop(record.workspace), id);
|
|
428
|
+
say(`block ${id} acceptance amended (${row.clauses} clauses; by: human)\n`);
|
|
429
|
+
if (json) emit(row);
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
404
432
|
const deciding = sub === 'close' && flags.decide === true;
|
|
405
433
|
if (deciding) {
|
|
406
434
|
if (positionals.length !== 1 || typeof flags.reason !== 'string' || !flags.reason.trim() || /[\x00-\x1f\x7f]/.test(flags.reason)) throw new StateError('usage', '--decide needs one block id and a nonblank --reason without control characters');
|
|
@@ -1140,7 +1168,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
|
|
|
1140
1168
|
|
|
1141
1169
|
/**
|
|
1142
1170
|
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
1143
|
-
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'close'} verb @returns {string}
|
|
1171
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close'} verb @returns {string}
|
|
1144
1172
|
*/
|
|
1145
1173
|
export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
1146
1174
|
? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
|
|
@@ -1160,7 +1188,7 @@ export const humanVerbNoTTY = (verb) => verb === 'waive'
|
|
|
1160
1188
|
* terminal check and the question (a refusal it throws means the human is never asked).
|
|
1161
1189
|
* `deps.stdinIsTTY` / `deps.stdoutIsTTY` inject each side, `deps.isTTY` both at once, `deps.ui`
|
|
1162
1190
|
* the prompt (default `@clack/prompts`). A "no" or a cancel (Ctrl-C) is `cancelled`.
|
|
1163
|
-
* @param {ConfirmDeps} deps @param {string} message
|
|
1191
|
+
* @param {ConfirmDeps} deps @param {string | (() => string)} message
|
|
1164
1192
|
* @param {() => Promise<unknown> | unknown} [before]
|
|
1165
1193
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
1166
1194
|
*/
|
|
@@ -1170,7 +1198,7 @@ async function confirmAtTerminal(deps, message, before) {
|
|
|
1170
1198
|
if (!(stdinTTY && stdoutTTY)) return 'no-tty';
|
|
1171
1199
|
if (before) await before();
|
|
1172
1200
|
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
1173
|
-
const go = await ui.confirm({ message, initialValue: false });
|
|
1201
|
+
const go = await ui.confirm({ message: typeof message === 'function' ? message() : message, initialValue: false });
|
|
1174
1202
|
return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
|
|
1175
1203
|
}
|
|
1176
1204
|
|
|
@@ -1201,14 +1229,16 @@ async function confirmNoReviews(runId, id, deps) {
|
|
|
1201
1229
|
* `by: autopilot`); human-pass instead refuses an active grant. The
|
|
1202
1230
|
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
1203
1231
|
* asked about a waive or reopen the verb would refuse that way.
|
|
1204
|
-
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass'} verb @param {string} runId @param {string} id
|
|
1205
|
-
* @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[]}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1206
|
-
* @param {() =>
|
|
1232
|
+
* @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance'} verb @param {string} runId @param {string} id
|
|
1233
|
+
* @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[], oldCount?: number, newCount?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
1234
|
+
* @param {() => Promise<unknown> | unknown} [before]
|
|
1207
1235
|
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
1208
1236
|
*/
|
|
1209
1237
|
function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
1210
|
-
const message =
|
|
1211
|
-
verb === '
|
|
1238
|
+
const message = () =>
|
|
1239
|
+
verb === 'amend-acceptance'
|
|
1240
|
+
? `Amend acceptance of block ${id} (run ${runId}) from ${what.oldCount} clauses to ${what.newCount} clauses? Only the owner may say yes.`
|
|
1241
|
+
: verb === 'human-pass'
|
|
1212
1242
|
? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
|
|
1213
1243
|
: verb === 'waive'
|
|
1214
1244
|
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
@@ -1226,7 +1256,7 @@ function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
|
|
|
1226
1256
|
}
|
|
1227
1257
|
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
1228
1258
|
else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
1229
|
-
before?.();
|
|
1259
|
+
await before?.();
|
|
1230
1260
|
});
|
|
1231
1261
|
}
|
|
1232
1262
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* OpenAI's structured-output "strict" mode has three rules a hand-written schema violates by
|
|
6
6
|
* default: (1) every property under an object must be listed in `required` (optional fields are
|
|
7
|
-
* instead modeled as **nullable** required fields), (2) every object must set
|
|
7
|
+
* instead modeled as **nullable** required fields; source-required keys stay as they are), (2) every object must set
|
|
8
8
|
* `additionalProperties: false`, (3) nothing may be missing from `required`. Claude and Grok take
|
|
9
9
|
* the source schema unchanged (they support ordinary JSON Schema `required`/optional).
|
|
10
10
|
*
|
|
@@ -149,8 +149,9 @@ function childSchemas(node) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* Recursively rewrites every object node: a `properties` map makes every property `required
|
|
153
|
-
* nullable
|
|
152
|
+
* Recursively rewrites every object node: a `properties` map makes every property `required`;
|
|
153
|
+
* optional keys become nullable; required keys stay as they are. It closes the node (unless it
|
|
154
|
+
* already has an open-ended `additionalProperties`); a
|
|
154
155
|
* bare `{type: 'object'}` is closed to an empty strict object. `visited` guards against revisiting
|
|
155
156
|
* the same shared node twice — not against a genuine schema cycle, which this project's schema
|
|
156
157
|
* does not have.
|
|
@@ -165,12 +166,13 @@ function compileForOpenAI(node, visited) {
|
|
|
165
166
|
|
|
166
167
|
if (node.properties && typeof node.properties === 'object') {
|
|
167
168
|
const keys = Object.keys(node.properties);
|
|
169
|
+
const wasRequired = new Set(Array.isArray(node.required) ? node.required : []);
|
|
168
170
|
node.required = keys;
|
|
169
171
|
if (node.additionalProperties === undefined) {
|
|
170
172
|
node.additionalProperties = false;
|
|
171
173
|
}
|
|
172
174
|
for (const key of keys) {
|
|
173
|
-
makeNullable(node.properties[key]);
|
|
175
|
+
if (!wasRequired.has(key)) makeNullable(node.properties[key]);
|
|
174
176
|
}
|
|
175
177
|
} else if (isObjectTyped(node) && node.additionalProperties === undefined && node.patternProperties === undefined) {
|
|
176
178
|
node.properties = {};
|
|
@@ -139,7 +139,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
139
139
|
/** @type {Map<string, string>} */
|
|
140
140
|
const current = new Map();
|
|
141
141
|
for (const f of [...proofFiles, ...files]) current.set(f.file, f.content_hash);
|
|
142
|
-
const { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
142
|
+
const { mine, acceptanceAmended, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, recordedRefusalSeverity, supersededCap } = reviewRules({ block, runId, rows, key, resultFiles, current });
|
|
143
143
|
/** @type {Refusal[]} */
|
|
144
144
|
const refusals = [];
|
|
145
145
|
const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
|
|
@@ -169,7 +169,7 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
|
|
|
169
169
|
else {
|
|
170
170
|
// B73: a stopped file (a cap row of the block) names its ways out
|
|
171
171
|
const exits = mine.some((r) => r.event === 'review.cap' && r.file === file) ? `; ${stopExitsLine}` : '';
|
|
172
|
-
refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${exits}` });
|
|
172
|
+
refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content${acceptanceAmended ? ' (acceptance amended)' : ''}${exits}` });
|
|
173
173
|
}
|
|
174
174
|
} else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
|
|
175
175
|
if (signedApproval) {
|
|
@@ -279,7 +279,14 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
279
279
|
// B71: `verify` replaces the MAC check only for `review-file`'s unverified pre-check ({@link
|
|
280
280
|
// namedOnHash}), which has no key and can only refuse; it then reads no autopilot waiver
|
|
281
281
|
const ok = verify ?? ((/** @type {Record<string, any>} */ r) => verified(r, key));
|
|
282
|
-
|
|
282
|
+
let amendedAt = -1;
|
|
283
|
+
rows.forEach((r, i) => {
|
|
284
|
+
if (r?.event === 'block.acceptance_amended' && r.block === block && r.run === runId && ok(r)) amendedAt = i;
|
|
285
|
+
});
|
|
286
|
+
const acceptanceAmended = amendedAt >= 0;
|
|
287
|
+
// Acceptance is a review input. Every older review verdict belongs to the previous input.
|
|
288
|
+
const mine = rows.filter((r, i) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId)
|
|
289
|
+
&& !(i < amendedAt && typeof r.event === 'string' && r.event.startsWith('review.')));
|
|
283
290
|
/** A verified human or agreed agent reopen followed by a real review clears older cap ids.
|
|
284
291
|
* @param {Record<string, any>} cap @returns {boolean}
|
|
285
292
|
*/
|
|
@@ -517,7 +524,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
|
|
|
517
524
|
return new Map([...named].filter(([id, severity]) => id !== PROOF_FINDING && answerableSeverity(severity) && waiverOf(file, id, severity, ticket).by === null));
|
|
518
525
|
};
|
|
519
526
|
|
|
520
|
-
return { mine, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
|
|
527
|
+
return { mine, acceptanceAmended, latestOf, signedOf, matches, waiverOf, waiverFor, waivedAllOf, openAt, answerRows, recordedRefusalSeverity, supersededCap };
|
|
521
528
|
}
|
|
522
529
|
|
|
523
530
|
/**
|
package/src/session/facts.mjs
CHANGED
|
@@ -777,7 +777,8 @@ function ruleBroken(row, claim, ctx) {
|
|
|
777
777
|
}
|
|
778
778
|
|
|
779
779
|
/**
|
|
780
|
-
* Validate the delegate's answer (step 3).
|
|
780
|
+
* Validate the delegate's answer (step 3). Null commands/excerpts are normalized on a copy and
|
|
781
|
+
* their matched claims downgraded; every other schema-invalid answer is refused. A row answers
|
|
781
782
|
* claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
|
|
782
783
|
* the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
|
|
783
784
|
* equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
|
|
@@ -794,11 +795,23 @@ function ruleBroken(row, claim, ctx) {
|
|
|
794
795
|
* @throws {FactsError} `refused` when the answer does not match the schema.
|
|
795
796
|
*/
|
|
796
797
|
export function validateFacts(answer, claims, opts = {}) {
|
|
797
|
-
|
|
798
|
+
const normalized = structuredClone(answer);
|
|
799
|
+
/** @type {Map<object, string[]>} */
|
|
800
|
+
const nullFields = new Map();
|
|
801
|
+
if (normalized && typeof normalized === 'object' && 'facts' in normalized && Array.isArray(normalized.facts)) {
|
|
802
|
+
for (const row of normalized.facts) {
|
|
803
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
|
|
804
|
+
const fields = ['command', 'output_excerpt'].filter((field) => row[field] === null);
|
|
805
|
+
if (fields.length === 0) continue;
|
|
806
|
+
for (const field of fields) row[field] = '';
|
|
807
|
+
nullFields.set(row, fields);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (!validateFile(normalized)) {
|
|
798
811
|
const where = (validateFile.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).slice(0, 5);
|
|
799
812
|
throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
|
|
800
813
|
}
|
|
801
|
-
const rows = /** @type {{facts: Fact[]}} */ (
|
|
814
|
+
const rows = /** @type {{facts: Fact[]}} */ (normalized).facts;
|
|
802
815
|
/** @type {Fact[]} */
|
|
803
816
|
const facts = [];
|
|
804
817
|
/** @type {Downgrade[]} */
|
|
@@ -828,7 +841,8 @@ export function validateFacts(answer, claims, opts = {}) {
|
|
|
828
841
|
return;
|
|
829
842
|
}
|
|
830
843
|
const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
|
|
831
|
-
const
|
|
844
|
+
const fields = nullFields.get(row);
|
|
845
|
+
const broken = fields ? `delegate returned null ${fields.join(', ')}` : ruleBroken(row, claim, opts);
|
|
832
846
|
if (broken) {
|
|
833
847
|
const reason = redact(broken);
|
|
834
848
|
downgraded.push({ fact_id: id, reason });
|
package/src/state/block.mjs
CHANGED
|
@@ -24,7 +24,7 @@ import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
|
24
24
|
import { StateError } from './paths.mjs';
|
|
25
25
|
import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
|
|
26
26
|
import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
|
|
27
|
-
import { verifyRow, loadKey } from './signer.mjs';
|
|
27
|
+
import { canonicalJSON, verifyRow, loadKey } from './signer.mjs';
|
|
28
28
|
import * as reviewQueue from '../worker/queue.mjs';
|
|
29
29
|
import { repoRootOf } from '../worker/ticket.mjs';
|
|
30
30
|
|
|
@@ -33,7 +33,7 @@ import { repoRootOf } from '../worker/ticket.mjs';
|
|
|
33
33
|
export const BRIEF_POINTER_MAX_BYTES = 200;
|
|
34
34
|
|
|
35
35
|
/** Ledger events the block gate requires to carry a valid MAC (§4.6 row 10). */
|
|
36
|
-
export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'review.approved', 'proof']);
|
|
36
|
+
export const GATE_EVENTS = Object.freeze(['dispatch', 'block.attempt', 'block.rebase', 'block.claim', 'block.acceptance_amended', 'review.approved', 'proof']);
|
|
37
37
|
|
|
38
38
|
const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
39
39
|
const LEVEL = /^L[0-3]$/;
|
|
@@ -233,6 +233,38 @@ function assertAcceptance(clauses) {
|
|
|
233
233
|
return clauses;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
/** Validate before the human question, and again under the run lock.
|
|
237
|
+
* @param {Record<string, any>} record @param {string} id @param {unknown} clauses
|
|
238
|
+
*/
|
|
239
|
+
export function prepareAcceptanceAmendment(record, id, clauses) {
|
|
240
|
+
const block = record.blocks?.[id];
|
|
241
|
+
if (!block) throw new StateError('unknown_block', `block ${id} is not in run ${record.run_id}`);
|
|
242
|
+
if (block.status !== 'open') throw new StateError('not-open', `not-open: block ${id} is not open`);
|
|
243
|
+
const acceptance = assertAcceptance(clauses);
|
|
244
|
+
const old_sha256 = sha256(canonicalJSON(block.acceptance));
|
|
245
|
+
const new_sha256 = sha256(canonicalJSON(acceptance));
|
|
246
|
+
if (old_sha256 === new_sha256) throw new StateError('acceptance-unchanged', 'acceptance-unchanged: the clause list is unchanged; nothing was recorded');
|
|
247
|
+
return { acceptance, old_sha256, new_sha256, oldCount: block.acceptance.length };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** @param {{runId: string, id: string, acceptance: unknown, reason: string, old_sha256: string, writeRow: WriteRow, before: (record: Record<string, any>) => void}} opts */
|
|
251
|
+
export async function amendAcceptanceBlock({ runId, id, acceptance, reason, old_sha256, writeRow, before }) {
|
|
252
|
+
return mutateRun(runId, null, writeRow, (record) => {
|
|
253
|
+
before(record);
|
|
254
|
+
const prepared = prepareAcceptanceAmendment(record, id, acceptance);
|
|
255
|
+
if (prepared.old_sha256 !== old_sha256) throw new StateError('acceptance-moved', `acceptance changed while confirming block ${id}; confirm again`);
|
|
256
|
+
const block = record.blocks[id];
|
|
257
|
+
block.acceptance = prepared.acceptance;
|
|
258
|
+
const checks = prepared.acceptance.filter((c) => c.kind === 'human_check').map((c) => c.clause);
|
|
259
|
+
if (checks.length) block.human_checks = checks;
|
|
260
|
+
else delete block.human_checks;
|
|
261
|
+
// Unique even when a later amendment restores an earlier list of clauses.
|
|
262
|
+
block.acceptance_revision = randomUUID();
|
|
263
|
+
const row = { event: 'block.acceptance_amended', block: id, old_sha256, new_sha256: prepared.new_sha256, clauses: prepared.acceptance.length, reason, by: 'human' };
|
|
264
|
+
return { result: row, rows: [row] };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
236
268
|
/**
|
|
237
269
|
* Under the run lock: load the run, check it is active and (when `id` is given) that the block
|
|
238
270
|
* is open, run `mutate`, write its signed `rows` through the ledger writer, THEN save the record —
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -360,6 +360,12 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
360
360
|
groups: [{ anyOf: ['--add-worker'] }],
|
|
361
361
|
description: 'Add a review worker to a run — the orchestrator only (B107)',
|
|
362
362
|
},
|
|
363
|
+
{
|
|
364
|
+
id: 'code-forge-block-amend-acceptance-from-coder',
|
|
365
|
+
kind: 'anyPrefix',
|
|
366
|
+
prefixes: [['code-forge', 'block', 'amend-acceptance'], ['forge', 'block', 'amend-acceptance']],
|
|
367
|
+
description: 'Correct a block acceptance list — human-only, confirmed in the human terminal (B143)',
|
|
368
|
+
},
|
|
363
369
|
].map(freezeEntry),
|
|
364
370
|
);
|
|
365
371
|
|
|
@@ -819,6 +825,194 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
819
825
|
|
|
820
826
|
// ── Transcripts ──────────────────────────────────────────────────────────────
|
|
821
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Mask inert, non-shell interpreter bodies for command/contains rules only. Walk shell
|
|
830
|
+
* words and queued heredocs once; unknown commands, malformed quotes and unfinished
|
|
831
|
+
* heredocs stay fail closed. Decode at most one shell -c wrapper; deeper wrappers
|
|
832
|
+
* stay whole. This deliberately does not evaluate shell expansions.
|
|
833
|
+
* @param {string} command
|
|
834
|
+
* @param {boolean} [unwrapShell]
|
|
835
|
+
* @returns {string}
|
|
836
|
+
*/
|
|
837
|
+
function transcriptCommandText(command, unwrapShell = true) {
|
|
838
|
+
if (unwrapShell) {
|
|
839
|
+
const wrapper = /^\s*(?:[^\s'"\\;|&()<>`$]+\/)?(?:sh|bash|zsh|dash|ksh)(?:[ \t]+-[li]+)*[ \t]+-[li]*c[ \t]+(['"])/.exec(command);
|
|
840
|
+
if (wrapper) {
|
|
841
|
+
let quote = wrapper[1];
|
|
842
|
+
const parts = [];
|
|
843
|
+
let i = wrapper[0].length;
|
|
844
|
+
while (i < command.length) {
|
|
845
|
+
const char = command[i++];
|
|
846
|
+
if (char === quote) {
|
|
847
|
+
// A shell word can splice opposite-quoted text into its enclosing
|
|
848
|
+
// quotes, as in "script"'literal'"rest". Require the enclosing
|
|
849
|
+
// quote again at the end; uncertain suffixes still scan whole.
|
|
850
|
+
if (command[i] === (quote === '"' ? "'" : '"')) {
|
|
851
|
+
quote = command[i++];
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (quote !== wrapper[1] || command.slice(i).trim() !== '') return command;
|
|
855
|
+
const prefix = command.slice(0, wrapper[0].length - 1);
|
|
856
|
+
return prefix + transcriptCommandText(parts.join(''), false);
|
|
857
|
+
}
|
|
858
|
+
// Within double quotes, other backslashes are literal; single quotes
|
|
859
|
+
// preserve every character. A backslash-newline pair disappears.
|
|
860
|
+
if (quote === '"' && char === '\\' && /["\\$`\n]/.test(command[i] ?? '')) {
|
|
861
|
+
const escaped = command[i++];
|
|
862
|
+
if (escaped !== '\n') parts.push(escaped);
|
|
863
|
+
} else {
|
|
864
|
+
// Keep the source segment's quote context until after this check:
|
|
865
|
+
// the parent shell expands these even inside an inner quoted heredoc.
|
|
866
|
+
// Single-quoted splices and the escaped characters above are literal.
|
|
867
|
+
// Unquoted splices already fail closed at the closing-quote check.
|
|
868
|
+
if (quote === '"' && (char === '`' || (char === '$' && command[i] === '('))) return command;
|
|
869
|
+
parts.push(char);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return command;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const spawnWord = /\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork|system|popen|subprocess|child_process|Popen)\b/;
|
|
876
|
+
const interpreter = /^(node|python|python3|ruby|perl)(?:\d+(?:\.\d+)*)?$/;
|
|
877
|
+
/** @param {string} body */
|
|
878
|
+
const hasShellSubstitution = (body) => {
|
|
879
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
880
|
+
// Skip escaped characters in pairs so even backslash runs still expand.
|
|
881
|
+
if (body[i] === '\\') { i += 1; continue; }
|
|
882
|
+
if (body[i] === '`' || (body[i] === '$' && body[i + 1] === '(')) return true;
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
};
|
|
886
|
+
/** @param {string} body @param {string} language @param {boolean} shellExpands */
|
|
887
|
+
const hasSpawn = (body, language, shellExpands) => spawnWord.test(body)
|
|
888
|
+
|| (shellExpands && hasShellSubstitution(body))
|
|
889
|
+
|| ((language === 'ruby' || language === 'perl') && /`|\$\(|%x/.test(body))
|
|
890
|
+
|| (language === 'perl' && /\bqx\b/.test(body));
|
|
891
|
+
const evalFlags = new Set(['-e', '-c', '--eval', '-p', '--print']);
|
|
892
|
+
/** @type {{start: number, end: number}[]} */
|
|
893
|
+
const inert = [];
|
|
894
|
+
/** @type {{delimiter: string, tabs: boolean, language: string, shellExpands: boolean}[]} */
|
|
895
|
+
let heredocs = [];
|
|
896
|
+
let state = 'command';
|
|
897
|
+
let language = '';
|
|
898
|
+
let evalNext = false;
|
|
899
|
+
let envValue = false;
|
|
900
|
+
let redirect = '';
|
|
901
|
+
let i = 0;
|
|
902
|
+
while (i < command.length) {
|
|
903
|
+
const char = command[i];
|
|
904
|
+
if (char === '\n') {
|
|
905
|
+
i += 1;
|
|
906
|
+
for (const doc of heredocs) {
|
|
907
|
+
const start = i;
|
|
908
|
+
let closed = false;
|
|
909
|
+
while (i < command.length) {
|
|
910
|
+
const newline = command.indexOf('\n', i);
|
|
911
|
+
const end = newline < 0 ? command.length : newline;
|
|
912
|
+
const line = command.slice(i, end);
|
|
913
|
+
if ((doc.tabs ? line.replace(/^\t+/, '') : line) === doc.delimiter) {
|
|
914
|
+
if (doc.language && !hasSpawn(command.slice(start, i), doc.language, doc.shellExpands)) inert.push({ start, end: i });
|
|
915
|
+
i = newline < 0 ? end : end + 1;
|
|
916
|
+
closed = true;
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
i = newline < 0 ? end : end + 1;
|
|
920
|
+
}
|
|
921
|
+
if (!closed) return command;
|
|
922
|
+
}
|
|
923
|
+
heredocs = [];
|
|
924
|
+
state = 'command';
|
|
925
|
+
evalNext = false;
|
|
926
|
+
redirect = '';
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
if (/\s/.test(char)) { i += 1; continue; }
|
|
930
|
+
if (char === '#') {
|
|
931
|
+
const newline = command.indexOf('\n', i);
|
|
932
|
+
i = newline < 0 ? command.length : newline;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (';|&()'.includes(char)) {
|
|
936
|
+
state = 'command';
|
|
937
|
+
evalNext = false;
|
|
938
|
+
i += 1;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (char === '<' || char === '>') {
|
|
942
|
+
redirect = char;
|
|
943
|
+
i += 1;
|
|
944
|
+
if (command[i] === char) { redirect += char; i += 1; }
|
|
945
|
+
if (redirect === '<<' && command[i] === '-') { redirect += '-'; i += 1; }
|
|
946
|
+
// Here-strings and other uncertain shell syntax are never exempted.
|
|
947
|
+
if (command[i] === '<') return command;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const start = i;
|
|
951
|
+
const parts = [];
|
|
952
|
+
let quotes = 0;
|
|
953
|
+
let quote = '';
|
|
954
|
+
let escaped = false;
|
|
955
|
+
let unquoted = false;
|
|
956
|
+
while (i < command.length && !/[\s;|&()<>]/.test(command[i])) {
|
|
957
|
+
const next = command[i++];
|
|
958
|
+
if (next === "'" || next === '"') {
|
|
959
|
+
quotes += 1;
|
|
960
|
+
quote = next;
|
|
961
|
+
let closed = false;
|
|
962
|
+
while (i < command.length) {
|
|
963
|
+
const inside = command[i++];
|
|
964
|
+
if (inside === next) { closed = true; break; }
|
|
965
|
+
if (inside === '\\' && next === '"' && i < command.length) parts.push(command[i++]);
|
|
966
|
+
else parts.push(inside);
|
|
967
|
+
}
|
|
968
|
+
if (!closed) return command;
|
|
969
|
+
} else {
|
|
970
|
+
unquoted = true;
|
|
971
|
+
if (next === '\\') escaped = true;
|
|
972
|
+
parts.push(next === '\\' && i < command.length ? command[i++] : next);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const word = parts.join('');
|
|
976
|
+
if (redirect) {
|
|
977
|
+
if (redirect.startsWith('<<')) heredocs.push({
|
|
978
|
+
delimiter: word, tabs: redirect === '<<-', language: state === 'interpreter' ? language : '',
|
|
979
|
+
shellExpands: quotes === 0 && !escaped,
|
|
980
|
+
});
|
|
981
|
+
redirect = '';
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (state === 'env') {
|
|
985
|
+
if (envValue) { envValue = false; continue; }
|
|
986
|
+
if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(word) || ['-i', '--ignore-environment'].includes(word)) continue;
|
|
987
|
+
if (['-u', '--unset', '-C', '--chdir'].includes(word)) { envValue = true; continue; }
|
|
988
|
+
if (word === '--') { state = 'command'; continue; }
|
|
989
|
+
if (word.startsWith('-')) { state = 'other'; continue; }
|
|
990
|
+
state = 'command';
|
|
991
|
+
}
|
|
992
|
+
if (state === 'command') {
|
|
993
|
+
if (/^[A-Za-z_][A-Za-z_0-9]*=/.test(word)) continue;
|
|
994
|
+
const name = word.slice(word.lastIndexOf('/') + 1);
|
|
995
|
+
language = interpreter.exec(name)?.[1] ?? '';
|
|
996
|
+
state = name === 'env' ? 'env' : language ? 'interpreter' : 'other';
|
|
997
|
+
} else if (state === 'interpreter') {
|
|
998
|
+
if (evalNext) {
|
|
999
|
+
if (quotes === 1 && !unquoted && !hasSpawn(command.slice(start, i), language, quote === '"')) inert.push({ start, end: i });
|
|
1000
|
+
evalNext = false;
|
|
1001
|
+
} else if (evalFlags.has(word)) evalNext = true;
|
|
1002
|
+
else if (!word.startsWith('-') || word === '--') state = 'other';
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (heredocs.length > 0) return command;
|
|
1006
|
+
const chunks = [];
|
|
1007
|
+
let end = 0;
|
|
1008
|
+
for (const span of inert) {
|
|
1009
|
+
chunks.push(command.slice(end, span.start), ' ');
|
|
1010
|
+
end = span.end;
|
|
1011
|
+
}
|
|
1012
|
+
chunks.push(command.slice(end));
|
|
1013
|
+
return chunks.join('');
|
|
1014
|
+
}
|
|
1015
|
+
|
|
822
1016
|
/**
|
|
823
1017
|
* Scan captured coder output (subprocess stdout, Solo `search_output` text, a harness log) for
|
|
824
1018
|
* any entry of `list`, line by line: each line is split into shell-ish tokens (whitespace,
|
|
@@ -829,7 +1023,18 @@ export function renderForCodex(list = FORBIDDEN) {
|
|
|
829
1023
|
* Codex `exec --json` logs (every non-empty line an object with a string `type`, first event
|
|
830
1024
|
* `thread.started`) scan command_execution commands and file_change paths as writes. Other
|
|
831
1025
|
* items scan whole, except agent_message and reasoning; a command_execution's aggregated_output
|
|
832
|
-
* is never scanned; non-item events are skipped.
|
|
1026
|
+
* is never scanned; non-item events are skipped. For string commands, node/python/ruby/perl
|
|
1027
|
+
* heredocs and quoted -e/-c/--eval/-p/--print bodies without a process-spawn word are omitted
|
|
1028
|
+
* from command and contains rules only; path rules still scan the whole command. Backticks and
|
|
1029
|
+
* $( count when unescaped and the shell expands the body (unquoted heredoc delimiter or double-quoted inline
|
|
1030
|
+
* script), or for Ruby/Perl; %x counts for Ruby/Perl, and qx for Perl. Quoted or backslash-escaped
|
|
1031
|
+
* heredoc delimiters and single-quoted inline scripts prevent shell expansion. One sh/bash/zsh/
|
|
1032
|
+
* dash/ksh -c wrapper (including -lc/-ic and -l -c) with a single quoted script (including
|
|
1033
|
+
* opposite-quoted splices) is decoded first; its prefix remains scanned. Unescaped parent-shell
|
|
1034
|
+
* substitutions in double-quoted wrapper segments keep the entire command scanned; single-quoted
|
|
1035
|
+
* splices are literal at that layer. Unquoted splices, nested wrappers,
|
|
1036
|
+
* malformed wrappers, shell/unknown heredocs and
|
|
1037
|
+
* unterminated bodies scan whole. Any other log uses the plain-text grep above, unchanged.
|
|
833
1038
|
*
|
|
834
1039
|
* Linear in the line length: `contains` and `path` entries are evaluated ONCE per line over its
|
|
835
1040
|
* tokens (a `write` path entry hits when a write command starts before a token naming the path);
|
|
@@ -864,13 +1069,17 @@ export function scanTranscript(text, list = FORBIDDEN) {
|
|
|
864
1069
|
const hits = [];
|
|
865
1070
|
lines
|
|
866
1071
|
.forEach((lineText, index) => {
|
|
1072
|
+
let commandText;
|
|
867
1073
|
if (events !== null) {
|
|
868
1074
|
const event = events[index];
|
|
869
1075
|
if (!event?.type.startsWith('item.') || !event.item) return;
|
|
870
1076
|
const item = event.item;
|
|
871
1077
|
if (item.type === 'agent_message' || item.type === 'reasoning') return;
|
|
872
1078
|
// a command that is not a string is scanned with the rest of its item (fail closed), its output left out
|
|
873
|
-
if (item.type === 'command_execution')
|
|
1079
|
+
if (item.type === 'command_execution') {
|
|
1080
|
+
lineText = typeof item.command === 'string' ? item.command : JSON.stringify({ ...item, aggregated_output: undefined });
|
|
1081
|
+
if (typeof item.command === 'string') commandText = transcriptCommandText(lineText);
|
|
1082
|
+
}
|
|
874
1083
|
else if (item.type === 'file_change') {
|
|
875
1084
|
lineText = (Array.isArray(item.changes) ? item.changes : [])
|
|
876
1085
|
.filter((change) => typeof change?.path === 'string')
|
|
@@ -878,20 +1087,21 @@ export function scanTranscript(text, list = FORBIDDEN) {
|
|
|
878
1087
|
} else lineText = JSON.stringify(item);
|
|
879
1088
|
}
|
|
880
1089
|
const tokens = lineText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
|
|
1090
|
+
const commandTokens = commandText === undefined ? tokens : commandText.split(/(>>?)|[\s'"`;|&(),{}[\]]+/).filter((t) => typeof t === 'string' && t.length > 0);
|
|
881
1091
|
const ids = new Set();
|
|
882
1092
|
let firstWrite = -1;
|
|
883
1093
|
for (let i = 0; i < tokens.length && firstWrite < 0; i += 1) {
|
|
884
1094
|
if (writeStarts.has(commandName(tokens[i])) && isWriteCommand(tokens.slice(i, i + TRANSCRIPT_WINDOW))) firstWrite = i;
|
|
885
1095
|
}
|
|
886
1096
|
for (const entry of list) {
|
|
887
|
-
if (entry.kind === 'contains' && matchesEntry([],
|
|
1097
|
+
if (entry.kind === 'contains' && matchesEntry([], commandTokens, entry)) ids.add(entry.id);
|
|
888
1098
|
if (entry.kind !== 'path') continue;
|
|
889
1099
|
const named = tokens.findIndex((t, i) => (entry.access === 'readwrite' || (firstWrite >= 0 && i > firstWrite)) && namesPath(t, entry));
|
|
890
1100
|
if (named >= 0) ids.add(entry.id);
|
|
891
1101
|
}
|
|
892
|
-
for (let i = 0; i <
|
|
893
|
-
if (!firstWords.has(commandName(
|
|
894
|
-
const window =
|
|
1102
|
+
for (let i = 0; i < commandTokens.length; i += 1) {
|
|
1103
|
+
if (!firstWords.has(commandName(commandTokens[i])) && !commandTokens[i].includes('code-forge')) continue;
|
|
1104
|
+
const window = commandTokens.slice(i, i + TRANSCRIPT_WINDOW);
|
|
895
1105
|
for (const entry of commandEntries) {
|
|
896
1106
|
if (!ids.has(entry.id) && isForbidden(window, [entry])) ids.add(entry.id);
|
|
897
1107
|
}
|
package/src/worker/engine.mjs
CHANGED
|
@@ -171,7 +171,7 @@ import { assertTicketId, contentHash, readRegularFileNoFollow, WorkerError } fro
|
|
|
171
171
|
* `tree` (B62b) is the recorded `worktree` `{path, branch, base_sha}`, null when the block has
|
|
172
172
|
* none; `base` is then the tree's `base_sha`. A recorded worktree that `worktreeRecord` refuses
|
|
173
173
|
* (path, branch, hex base_sha) throws `worktree-malformed`.
|
|
174
|
-
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
174
|
+
* @returns {Promise<{base: string | null, level: string, owned: string[], ownedKinds: Record<string, string>, testFiles: string[], acceptance?: unknown, acceptance_revision?: string, kind: string | null, tree: WorktreeEntry | null, workspace: string | null}>}
|
|
175
175
|
*/
|
|
176
176
|
export async function blockEntryFor(runId, block) {
|
|
177
177
|
let record;
|
|
@@ -199,7 +199,7 @@ export async function blockEntryFor(runId, block) {
|
|
|
199
199
|
throw new Error('worktree-malformed');
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
|
-
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
202
|
+
return { base: tree ? tree.base_sha : sha, level: typeof entry.level === 'string' && /^L[0-3]$/.test(entry.level) ? entry.level : 'L2', owned, ownedKinds, testFiles, acceptance: entry.acceptance, acceptance_revision: entry.acceptance_revision, kind: entry.kind === 'light' ? 'light' : blockKind({ owned, declared: entry.kind }), tree, workspace: typeof record.workspace === 'string' ? record.workspace : null };
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
/** @typedef {{path: string, branch: string, base_sha: string}} WorktreeEntry */
|
|
@@ -354,6 +354,9 @@ export async function reviewTicket(ticket, ctx) {
|
|
|
354
354
|
return { status: 'unavailable', reason: err instanceof Error ? err.message : 'run-record-unreadable', approved: false, engine: 'adaptive', sessions: [] };
|
|
355
355
|
}
|
|
356
356
|
if (entry.kind === null) entry = { ...entry, kind: blockKind({ owned: [ticket.file] }) };
|
|
357
|
+
if (!ticket.kind && ticket.acceptance_revision !== entry.acceptance_revision) {
|
|
358
|
+
return { status: 'stale', reason: 'acceptance-amended', approved: false, engine: 'adaptive', sessions: [] };
|
|
359
|
+
}
|
|
357
360
|
// B62b: every content read of this ticket happens in the block's tree (the main tree without
|
|
358
361
|
// one), taken from the SAME entry as the base and the facts. The worker passes the tree it
|
|
359
362
|
// hashed the ticket in as `ctx.contentRoot`; a record that changed since names another tree.
|
|
@@ -621,7 +624,16 @@ async function fixLoopRound(ticket, ctx, { base, level, owned, ownedKinds, block
|
|
|
621
624
|
seq: loaded.seq,
|
|
622
625
|
contentHash: ticket.content_hash,
|
|
623
626
|
};
|
|
624
|
-
|
|
627
|
+
let amendedAt = -1;
|
|
628
|
+
let anchoredAt = -1;
|
|
629
|
+
rows.forEach((row, i) => {
|
|
630
|
+
if (row.block !== ticket.block || !verifyRow(row, key).ok) return;
|
|
631
|
+
if (row.event === 'block.acceptance_amended') amendedAt = i;
|
|
632
|
+
if (row.event === 'review.state' && row.file === ticket.file) anchoredAt = i;
|
|
633
|
+
});
|
|
634
|
+
// A changed review input starts a full round, including previously stopped files. Keep
|
|
635
|
+
// the anchor sequence and the block's spent L3 rung; tampered state still refuses above.
|
|
636
|
+
const reopened = amendedAt > anchoredAt || reopenRowFor(rows, reopenAt) !== null;
|
|
625
637
|
if (loaded.status === 'lost' && !reopened) return { outcome: /** @type {ReviewOutcome} */ ({ status: 'refused', reason: 'review-state-lost', message: LOST_MESSAGE, approved: false, engine: 'adaptive', sessions: [] }) };
|
|
626
638
|
let seq = loaded.seq;
|
|
627
639
|
if (loaded.status === 'restored' && loaded.state !== null && !reopened) {
|
package/src/worker/queue.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import { createHash, randomBytes } from 'node:crypto';
|
|
32
32
|
import { chmodSync, existsSync, linkSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
+
import { runRecordPath } from '../state/paths.mjs';
|
|
34
35
|
import { loadKey, signRow, verifyRow } from '../state/signer.mjs';
|
|
35
36
|
import { isAlive, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
|
|
36
37
|
import { assertRowPath, assertTicketId, contentHash, ticketId, WorkerError } from './ticket.mjs';
|
|
@@ -75,7 +76,7 @@ function readJSON(file) {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/**
|
|
78
|
-
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
|
|
79
|
+
* @typedef {{ticket: string, run: string, block: string, file: string, content_hash: string, enqueued_at: string, tree?: string, acceptance_revision?: string, kind?: 'answers', by_file?: string, by_hash?: string, answers?: string[]}} Ticket
|
|
79
80
|
* `tree` (B62b): the block's worktree the content was hashed in. It is part of the ticket id,
|
|
80
81
|
* and the worker only checks it against the tree it takes from the run record (a ticket whose
|
|
81
82
|
* tree is not that one is `unavailable`, `worktree-mismatch`); it never reads content there.
|
|
@@ -93,7 +94,30 @@ export function resolveTicket({ repoRoot, run, block, file, tree = null }) {
|
|
|
93
94
|
const rel = assertRowPath(file);
|
|
94
95
|
const root = tree === null ? null : realTree(tree);
|
|
95
96
|
const content_hash = contentHash(root ?? repoRoot, rel);
|
|
96
|
-
|
|
97
|
+
const revision = acceptanceRevision(repoRoot, run, block);
|
|
98
|
+
return { rel, root, content_hash, ticket: reviewTicketId({ run, block, file: rel, content_hash, tree: root, acceptance_revision: revision }) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The mirror lets sandboxed callers resolve the same ticket without the private record.
|
|
102
|
+
* @param {string} repoRoot @param {string} run @param {string} block @returns {string | undefined}
|
|
103
|
+
*/
|
|
104
|
+
function acceptanceRevision(repoRoot, run, block) {
|
|
105
|
+
const record = readJSON(runRecordPath(run)) ?? readJSON(path.join(repoRoot, '.code-forge', 'runs', `${run}.json`));
|
|
106
|
+
const revision = record?.blocks?.[block]?.acceptance_revision;
|
|
107
|
+
return typeof revision === 'string' ? revision : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** @param {{run: string, block: string, file: string, content_hash: string, tree?: string | null, acceptance_revision?: string}} parts */
|
|
111
|
+
function reviewTicketId(parts) {
|
|
112
|
+
const id = ticketId(parts);
|
|
113
|
+
return parts.acceptance_revision === undefined ? id : createHash('sha256').update(`${id}\0${parts.acceptance_revision}`).digest('hex').slice(0, 24);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Reset only the commit-watch cursor; review evidence and results remain intact.
|
|
117
|
+
* @param {string} repoRoot @param {string} block
|
|
118
|
+
*/
|
|
119
|
+
export function resetAcceptanceWatch(repoRoot, block) {
|
|
120
|
+
rmSync(path.join(queueDir(repoRoot), 'watch', `${block}.json`), { force: true });
|
|
97
121
|
}
|
|
98
122
|
|
|
99
123
|
/**
|
|
@@ -118,9 +142,10 @@ export function enqueue({ repoRoot, run, block, file, tree = null, now = new Dat
|
|
|
118
142
|
export function enqueueHash({ repoRoot, run, block, file, content_hash, tree = null, now = new Date() }) {
|
|
119
143
|
const rel = assertRowPath(file);
|
|
120
144
|
const root = tree === null ? null : realTree(tree);
|
|
121
|
-
const
|
|
145
|
+
const acceptance_revision = acceptanceRevision(repoRoot, run, block);
|
|
146
|
+
const ticket = reviewTicketId({ run, block, file: rel, content_hash, tree: root, acceptance_revision });
|
|
122
147
|
const target = path.join(queueDir(repoRoot), `${ticket}.json`);
|
|
123
|
-
const named = root === null ? {} : { tree: root };
|
|
148
|
+
const named = { ...(root === null ? {} : { tree: root }), ...(acceptance_revision === undefined ? {} : { acceptance_revision }) };
|
|
124
149
|
if (!existsSync(target)) {
|
|
125
150
|
/** @type {Ticket} */
|
|
126
151
|
const body = { ticket, run, block, file: rel, content_hash, enqueued_at: now.toISOString(), ...named };
|
|
@@ -444,7 +469,8 @@ export function blockTickets({ repoRoot, run, block, tree = null }) {
|
|
|
444
469
|
throw new WorkerError('queue-unreadable', 'the review queue cannot be read');
|
|
445
470
|
}
|
|
446
471
|
const tickets = names.filter((n) => /^[0-9a-f]{24}\.json$/.test(n)).map((n) => readTicket(repoRoot, n.slice(0, 24)));
|
|
447
|
-
|
|
472
|
+
const revision = acceptanceRevision(repoRoot, run, block);
|
|
473
|
+
return tickets.filter((t) => !t.kind && t.run === run && t.block === block && (t.tree ?? null) === tree && t.acceptance_revision === revision)
|
|
448
474
|
.sort((a, b) => a.enqueued_at.localeCompare(b.enqueued_at) || a.ticket.localeCompare(b.ticket));
|
|
449
475
|
}
|
|
450
476
|
|
|
@@ -505,7 +531,8 @@ export function readTicket(repoRoot, id) {
|
|
|
505
531
|
if (typeof t.by_hash !== 'string' || ids.join(',') !== t.answers.join(',') || answersTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
506
532
|
return /** @type {Ticket} */ (t);
|
|
507
533
|
}
|
|
508
|
-
if (
|
|
534
|
+
if (t.acceptance_revision !== undefined && typeof t.acceptance_revision !== 'string') throw new WorkerError('bad-ticket', `ticket ${id} has an invalid acceptance revision`);
|
|
535
|
+
if (reviewTicketId(t) !== id) throw new WorkerError('bad-ticket', `ticket ${id} does not match its id`);
|
|
509
536
|
return /** @type {Ticket} */ (t);
|
|
510
537
|
}
|
|
511
538
|
|