@codedology/code-forge 0.15.2 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +162 -1
  5. package/docs/reference/config.md +38 -15
  6. package/native/README.md +10 -0
  7. package/native/bin/darwin-arm64/code-forge-native +0 -0
  8. package/native/bin/darwin-x64/code-forge-native +0 -0
  9. package/native/bin/linux-arm64/code-forge-native +0 -0
  10. package/native/bin/linux-x64/code-forge-native +0 -0
  11. package/package.json +4 -1
  12. package/schema/code-forge.schema.json +83 -3
  13. package/schema/outcome.schema.json +45 -0
  14. package/schema/pipeline.schema.json +30 -0
  15. package/scripts/first-use.mjs +8 -0
  16. package/scripts/gen-config-doc.mjs +1 -1
  17. package/skill/references/autopilot.md +6 -1
  18. package/skill/references/code.md +52 -0
  19. package/skill/references/ledger.md +13 -1
  20. package/skill/references/plan.md +5 -1
  21. package/skill/references/review.md +46 -8
  22. package/skill/scripts/forge +1 -1
  23. package/src/cli/block.mjs +427 -38
  24. package/src/cli/context.mjs +17 -7
  25. package/src/cli/doctor.mjs +32 -1
  26. package/src/cli/gates.mjs +76 -10
  27. package/src/cli/help.mjs +5 -0
  28. package/src/cli/native-status.mjs +13 -0
  29. package/src/cli/outcome.mjs +87 -0
  30. package/src/cli/pipeline.mjs +61 -0
  31. package/src/cli/proof.mjs +10 -7
  32. package/src/cli/report.mjs +53 -6
  33. package/src/cli/review-file.mjs +278 -41
  34. package/src/cli/run.mjs +3 -2
  35. package/src/cli/status.mjs +43 -9
  36. package/src/cli/sweep.mjs +28 -0
  37. package/src/cli/validate.mjs +6 -0
  38. package/src/config/validate.mjs +83 -1
  39. package/src/context/pack.mjs +53 -3
  40. package/src/doctor/index.mjs +11 -0
  41. package/src/doctor/local.mjs +22 -4
  42. package/src/doctor/worker-probe.mjs +1 -1
  43. package/src/engines/builders/codex.mjs +27 -7
  44. package/src/engines/builders/grok.mjs +11 -5
  45. package/src/engines/codex-home.mjs +18 -15
  46. package/src/engines/sentinel.mjs +11 -0
  47. package/src/gates/detect.mjs +6 -3
  48. package/src/gates/run.mjs +295 -27
  49. package/src/ledger/export.mjs +1 -1
  50. package/src/ledger/report.mjs +143 -5
  51. package/src/outcome/outcome.mjs +108 -0
  52. package/src/pipeline/pipeline.mjs +112 -0
  53. package/src/proof/red-green.mjs +41 -13
  54. package/src/proof/runners.mjs +28 -4
  55. package/src/proof/worktree.mjs +25 -15
  56. package/src/review/agent-waiver.mjs +113 -12
  57. package/src/review/engine.mjs +25 -5
  58. package/src/review/gate-check.mjs +52 -10
  59. package/src/review/lenses/full.md +1 -0
  60. package/src/review/lenses/quick.md +1 -0
  61. package/src/review/model-stats.mjs +125 -0
  62. package/src/review/packet.mjs +88 -9
  63. package/src/review/related.mjs +112 -0
  64. package/src/review/reopen.mjs +42 -16
  65. package/src/review/route.mjs +58 -4
  66. package/src/review/session-retry.mjs +59 -0
  67. package/src/session/facts.mjs +19 -7
  68. package/src/session/plan-check.mjs +4 -1
  69. package/src/session/spawn.mjs +54 -3
  70. package/src/state/block.mjs +139 -49
  71. package/src/state/config-snapshot.mjs +13 -3
  72. package/src/state/run.mjs +3 -2
  73. package/src/util/exec.mjs +48 -31
  74. package/src/util/native.mjs +131 -0
  75. package/src/util/reaper.mjs +238 -8
  76. package/src/worker/engine.mjs +7 -3
  77. package/src/worker/loop.mjs +211 -30
  78. package/src/worker/queue.mjs +107 -24
  79. package/src/worker/watch.mjs +18 -7
@@ -6,9 +6,22 @@ A light block reviews all owned changed files in one packet, with each path head
6
6
 
7
7
  Each file's `outcome` is `approved`, `findings`, `stopped`, `split_required` or `unavailable` from its completion marker, and `null` while `pending` or `not_queued`; a live join queues an owned changed file even if it has no earlier ticket. A `split_required` outcome returns `done: false`, `reason: split_required` and `next` naming the file: the owner raises `review.budgets`, runs `run reload`, then runs `review-file <path> --fresh`.
8
8
 
9
+ **Changed stopped findings restart automatically.** When the latest review stopped with
10
+ findings and the current content hash in the block tree differs from the stopped hash,
11
+ `review-file --wait-block` queues a fresh full review at round 1 through the reopen ticket
12
+ path. It writes one signed `review.reopened` row (`by: auto`) with reason
13
+ `auto-requeue: content changed after stopped review`, prints one `auto-requeue <file>: …
14
+ (ticket <id>)` line on stderr, and waits for the new ticket normally. Rejoining reuses the
15
+ attempt. A signed review after that automatic restart supersedes earlier cap findings;
16
+ its own findings still count. Unchanged content, findings fully covered by valid waivers, and files owned by
17
+ another open block do not requeue. Old waivers do not approve changed bytes. `block land`
18
+ uses this join before gates and close. Direct `block close` also queues changed stopped
19
+ findings and waits for their fresh tickets before its review gate. Explicit reopen remains available for stopped or
20
+ lost reviews outside this automatic case.
21
+
9
22
  `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 same content answers the cached result; `review-file <path> --block <id> --fresh` (B75) drops that cached result and queues the ticket again only when it never counted as a review (`stale`, `unavailable`, round-1 `split_required`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`), or when it is a `split_required` stop whose measured lens budget changed since it was measured (see below). With an unchanged budget, a counted `split_required` stop is still refused `fresh-needs-reopen`. Use it after such a transient answer or budget change to get a real review; a coder may run it, and with no cached result it is a plain `review-file`. A cached `review-state-tampered` is refused `fresh-tampered` (only the owner's `block close --no-require-reviews` gets past it); any other cached result is refused `fresh-needs-reopen` (the human's exits are `block reopen` and `block waive`). A refused marker is read in place and never moved; only a re-runnable one is claimed (an atomic rename, then a byte compare) and dropped. 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. Without commit watch, 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.
10
23
 
11
- With `review.file_loop: watch` (B104), commit each owned file after its tests are green and continue coding. Watch replaces per-file enqueueing. Before the completion sentinel, including fix rounds, run `forge review-file --wait-block <id> --run <r> [--max 90s]`, inspect finished tickets with `--wait <ticket>`, and fix findings. The worker watches the block worktree HEAD (otherwise repository HEAD) and reads owned content from git objects. A file's first commit is queued at once with the same ticket id as a manual request. Later commits of a file that already has a ticket of this block are held until `--wait-block`. If the last review found problems and the file changed blind, the join does not recheck it. It answers `status: findings` with that ticket and records delivery once. Read the findings with `review-file --wait <ticket>`, fix them, and the next join rechecks the latest content once. A file whose last review approved still queues its latest content at the join. A stopped file is not requeued. Each held file writes one `watch.held {block, file, content_hash}` row per new head. Older pending tickets are still marked `refused`, `reason: superseded`. A running ticket finishes for its old hash, which cannot approve newer bytes; `--wait-block` joins the current ticket per file. The join reruns a stale, unavailable or round-1 `split_required` review of the current content once. The file stays `pending` until that rerun finishes. No manual `--fresh` is needed. The retry is recorded per ticket across joins. If the rerun is stale again, later joins report `done`; `--wait <ticket>` shows that stale status. `--fresh` refuses a superseded marker; enqueueing that content again removes only its superseded marker so it can run, while a real result stays cached. `queue/watch/<block>.json` stores the last seen commit; missing or unreadable state restarts at `base_sha` with idempotent tickets. A rewound tick queues nothing and drops the last seen commit so the next tick restarts at `base_sha`. A `watch.skipped {block, reason}` row is written once per reason until a healthy head clears the skip reasons: `unreadable_head` (head, tree or objects cannot be read), `not_descendant` (head does not descend from the block base), `rewound` (head no longer descends from the last seen commit), or `unowned_path` (unowned paths are ignored; owned paths still queue). A skip never fails the block. After a `watch.skipped` row, use `forge review-file <path> --block <id> --run <r>` when the file needs a manual review; use `forge review-file <fix file> --block <id> --run <r> --answers <ids> --of <source>` for a fix in another owned file that answers findings. Set watch before starting the worker. Without the `review.file_loop: watch` setting, the default `wait` loop performs no watch work or git calls.
24
+ With `review.file_loop: watch` (B104), commit each owned file after its tests are green and continue coding. Watch replaces per-file enqueueing. Before the completion sentinel, including fix rounds, run `forge review-file --wait-block <id> --run <r> [--max 90s]`, inspect finished tickets with `--wait <ticket>`, and fix findings. The worker watches the block worktree HEAD (otherwise repository HEAD) and reads owned content from git objects. A file's first commit is queued at once with the same ticket id as a manual request. Later commits of a file that already has a ticket of this block are held until `--wait-block`. If the last review found problems and the file changed blind, the join does not recheck it. It answers `status: findings` with that ticket and records delivery once. Read the findings with `review-file --wait <ticket>`, fix them, and the next join rechecks the latest content once. A file whose last review approved still queues its latest content at the join. A stopped file with changed content and unwaived findings restarts automatically as described above. Each held file writes one `watch.held {block, file, content_hash}` row per new head. Older pending tickets are still marked `refused`, `reason: superseded`. A running ticket finishes for its old hash, which cannot approve newer bytes; `--wait-block` joins the current ticket per file. The join reruns a stale, unavailable or round-1 `split_required` review of the current content once. The file stays `pending` until that rerun finishes. No manual `--fresh` is needed. The retry is recorded per ticket across joins. If the rerun is stale again, later joins report `done`; `--wait <ticket>` shows that stale status. `--fresh` refuses a superseded marker; enqueueing that content again removes only its superseded marker so it can run, while a real result stays cached. `queue/watch/<block>.json` stores the last seen commit; missing or unreadable state restarts at `base_sha` with idempotent tickets. A rewound tick queues nothing and drops the last seen commit so the next tick restarts at `base_sha`. A `watch.skipped {block, reason}` row is written once per reason until a healthy head clears the skip reasons: `unreadable_head` (head, tree or objects cannot be read), `not_descendant` (head does not descend from the block base), `rewound` (head no longer descends from the last seen commit), or `unowned_path` (unowned paths are ignored; owned paths still queue). A skip never fails the block. After a `watch.skipped` row, use `forge review-file <path> --block <id> --run <r>` when the file needs a manual review; use `forge review-file <fix file> --block <id> --run <r> --answers <ids> --of <source>` for a fix in another owned file that answers findings. Set watch before starting the worker. Without the `review.file_loop: watch` setting, the default `wait` loop performs no watch work or git calls.
12
25
 
13
26
  A cached round-1 `split_required` ran no session and counted no round. Plain `review-file` drops its marker and re-queues the same ticket with `retry: true`; `--fresh` also re-queues it (B110). After the human raises the budget and runs `run reload`, the worker re-plans with the live config; the ticket id stays the same. If it still does not fit, it returns `split_required` again with no session or cost. For a recheck `split_required` stop, raising its lens budget and running `run reload` is enough for plain `review-file` or `--fresh` to review the same content again, printing the old and new budgets; with an unchanged budget it stays cached and `--fresh` still refuses `fresh-needs-reopen`.
14
27
 
@@ -52,6 +65,26 @@ Without those lists, attachments remain tracked-only. Existing path and read-onl
52
65
 
53
66
  Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, `## paired` (optional), `## moved code` (optional), diff. With a `context` key in the config (B63a, `code.md` §3) the project rules digest is the rules files' text and then, under a `### lessons` subheading, the lessons file, read from the main tree, never from a block worktree, each time a packet is built. `context` is read from the live config; `run reload` accepts a change to it. Rules and lessons share the one 60-line cap and are trimmed together, from the end. Without the key the slot is `(none)`, as before. A rules or lessons file missing, outside the repository or unreadable when the ticket is reviewed (or a bad `context` value) makes the ticket `review.unavailable` with `reason: context-unreadable`: no session runs and its packet directory is removed. The worker does not retry it: the ticket is done with that signed result. `review-file` on the same content is a fresh attempt (`retry: true`) each time it is run, with no cap (no session ran, so the one re-run of a failed packet is not used up); once the file is back, that attempt is reviewed normally. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
54
67
 
68
+ ### How code-forge picks the reviewer
69
+
70
+ The L2 model and its fallbacks compete by availability × (1 − waived finding share) ÷ median
71
+ session seconds, measured across this project's runs over `review.adaptive.window_days` (14).
72
+ Timeouts, schema errors, exits and silence kills count as unavailable. Models below
73
+ `min_sessions` (10) keep configured order; no history uses the configured reviewer. An `explore`
74
+ share (0.1), hashed from the ticket id, gives a less-measured model a reproducible turn.
75
+ `doctor` shows the model's sessions, median seconds, ok percentage and waived
76
+ percentage. The worker writes its chosen model and reason in a signed `review.route` row before
77
+ the first session. `review.adaptive.enabled: false` gives legacy routing exactly. There is no
78
+ default file-size route; packets are capped at 32k tokens.
79
+
80
+ A matching explicit L2 route joins the candidates with configured priority at cold start;
81
+ `pin: true` forces it. L3 rules retain their explicit judge selection. Retries reuse the signed
82
+ choice. `run reload` accepts route and adaptive changes.
83
+
84
+ The following routing details describe legacy mode with adaptive selection disabled. With
85
+ adaptive selection on, L2 routes contribute candidates and each primary choice gets a signed
86
+ row with its reason.
87
+
55
88
  Big files (B90): to send a big file to a long-context reviewer, set `review.routes` once — do not edit `.code-forge.yml` per file and restore it after. A rule is `{min_lines, level?, model, provider?, effort?, fallback?, session_timeout_s?, second?}`; `min_lines` and `model` are required. A rule's level decides which sessions it changes: an L2 rule (`level: L2`, the default) changes only the reviewers' model, and in consensus mode only slot A — slot B stays `review.second_levels.L2`; an L3 rule (`level: L3`) changes only the judge's model. What is counted: the file's lines at the reviewed state (the bytes in the review packet) — its newline bytes, plus one for a last line with no newline — once per ticket. A deleted file is never routed. Rules compete only within their level: among the rules of one level, the one with the largest `min_lines` at or under the count wins, and one ticket can take one L2 rule and one L3 rule; two rules with the same `level` and `min_lines` are refused. Left out, an optional key gets: `provider` — the top-level `provider` (the rule replaces the level entry, so the level's own `provider` is not inherited), and validation checks the rule with that same provider; `effort` — none (the CLI's default); `fallback` — none; `session_timeout_s` — the rule does not count in the timeout; `second` — `review.second_levels.<level>`. The timeout is one value for every session of the ticket: the largest `session_timeout_s` among the matched rules that set one (a matched rule without one is left out of that max, it does not count as `review.session_timeout_s`), else `review.session_timeout_s`. The rule's `second` is the second-level try's model (§4) for its level, in place of `review.second_levels.<level>` — except an L2 rule in consensus mode, whose `second` is not used. One signed `review.route {level, min_lines, lines, provider, model, session_timeout_s, second_model}` row per routed level is written before the ticket's first session: `provider` is the one the routed level runs on (the inherited one when the rule names none), and `second_model` is the rule's `second` model when that second is used, else null — always null for an L2 rule in consensus mode, because slot B is `review.second_levels.L2`, not the rule's. A retry of the same ticket never writes its rows twice. A row that cannot be written ⇒ `review.unavailable` with `reason: route-row`, and a routes list in the snapshot that cannot be used (or a routed level that cannot be resolved) ⇒ `reason: route-invalid` — no session runs. No rule matches (or no `review.routes`) ⇒ the ticket runs exactly as before. `run reload` accepts a change to it; validation refuses two rules with the same `level` and `min_lines`, and, with `review.multimodel`, an L2 rule whose provider (its own, else the inherited top-level one) equals the second reviewer's (slot B's, or the rule's own `second.provider`) (`review-routes-invalid`).
56
89
 
57
90
  Deleted files (B74): a deleted owned file is reviewed at the content hash `deleted`; its round-1 packets (each reviewer's and the judge's; a file that stays deleted has no later round, since its content cannot change) have a `## deleted file` section before the diff saying the file is deleted and listing the tree paths that still name its basename — one `git grep -l -z --untracked -F -e <basename> -- .` with the reviewed working tree (the block's worktree or the main tree, the one the diff is read from) as cwd, at most 20 paths sorted, never the deleted path itself or a secret-like path; a path with a control character is never printed, and it and the paths past 20 are counted as `(<n> more not listed)`. No match ⇒ `no references found`; a failed grep, a basename under 3 characters or a deleted path with a control character (printed JSON-escaped) ⇒ `references not checked`. A file is "deleted" only when its diff is not empty and the path is absent from the reviewed tree (a binary file that still exists never gets the note); a note reader that fails gives `references not checked` and the review goes on. No exemption: the deletion still needs a signed approval (or the waiver/answers exits) to close, and an approval at `deleted` stops counting once the file comes back.
@@ -76,7 +109,7 @@ Only real rechecks count toward the round cap, per-level ladder and stall rules
76
109
  3a. **Heavy rounds climb at once** (`review_warnings`, `decisions.md` §5 rule 3a): below L2, a round — round 1 included — that leaves ≥ `escalation.warning_threshold` (2) warnings or any `critical` open, `escalation.after_rounds_with_warnings` (1) times at the current level, climbs one level for the next fix. One round with a stronger model is faster and cheaper than three with a cheap one. The per-file cap (step 5) stays the outer limit.
77
110
  4. **Ladder**: the running ceiling is `min(L2, escalation.stop_at)` — L3 is never a *running* level. A trigger — rule 1 (`retries`), rule 2 (`escalation.review_rounds_per_level` exhausted with an open `fix_now`), rule 2b (`review_stall`) or step 3a (`review_warnings`) — below the ceiling climbs one level, coded by a fresh session with the open findings and the fix history in its brief (an L1 block's rounds 3–4 run at L2). At the ceiling — an L2 block, or an L1 block that has climbed to L2 — it takes the **L3 rung**: a patch ≤ 80 lines, owned files only, reviewed as a `patch_check` round outside the cap. Under `escalation.l3_mode: code` (opt-in) the rung is instead one L3 coding turn. Either way the rung fires **once per block** and the same post-patch order follows it. After it, in order: `patch_check` clean ⇒ complete; the patch closed no finding that was open before it (ids compared, not counts) ⇒ `stopped: l3_patch_exhausted` (`review_stall`); round ≥ `review.max_rounds_per_file` ⇒ `stopped: review_cap`; otherwise the fixes continue at L2 with `rounds_at_level` reset and the patch in the brief. A patch that closed at least one old finding is progress, not a stall, even when new findings in its fix hunks keep the open set from shrinking (B73). Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
78
111
  5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). The per-file cap still counts per file: the orchestrator sends one fix message per block (`code.md` §4), and one block round is one round for each file it touched. Round 4 still open ⇒ the L3 rung if the block has not used it (same post-patch order as step 4), else `stopped: review_cap`. A stopped file (`review_cap`, `l3_patch_exhausted`, `split_required`, …) keeps the block from closing; the ledger lists its open findings. The stop names its ways out: a `stopped` answer from `review-file` carries `exits: ["block reopen", "block waive", "review-file --answers", "re-decompose"]`, and the close's `review_cap` detail and `unreviewed` detail for a stopped file end with `exits: block reopen, block waive, review-file --answers, re-decompose` (B73). That printed list is the code's list, not an order; follow Exit 1–4 below. **The ways out of a stop, in this order** (take the first that fits):
79
- - **Exit 1 — fix, then `block reopen` (B72).** The file is fixed by hand, then the human restarts its review: `forge block reopen <id> --file <path> --run <r> --reason <text>`. It also restarts a `lost` state (step 8) on its current content. Without `--yes` it needs human confirmation; see **Human confirmation** below. `block reopen` and `block waive` both have evidence-backed agent paths through `--yes` with a fresh judge. A stopped file must differ from the content its stop reviewed. Every stop records that content as `stop_content_hash` in the signed state; a state written before B72, or before B114 by a fix-loop stop, has none, and then only the `review.cap` row of that stop (after the file's latest reopen, same round and reason) names it — otherwise the content is unknown. With unknown stop content, reopen accepts only when no verified `review.round` of this run, block and file names the current hash (B114). It records `from_hash: null`; the worker enforces the same check. If that hash was reviewed, change the file and reopen, or the owner can use `block close --no-require-reviews`. Waiving findings at an old hash cannot clear unreviewed current bytes. The verb first removes the done marker of the current content's ticket, so the next `review-file` runs instead of returning the cached stop. Then it writes one signed `review.reopened {block, file, from: stopped|lost, stop_reason, at_seq, from_hash, to_hash, reason, by: human | agent}` row (`at_seq` is the file's latest `review.state` anchor seq; `from_hash` the content the stop reviewed, null for `lost` or unknown stop content; `to_hash` the current content). There is at most one row per `at_seq`: a retry on the same content finds its row and writes no second one, and other content at the same `at_seq` is refused. The worker starts a new state for a row whose MAC verifies, of this run, `by: human` or `by: agent`, whose `at_seq` is the latest anchor seq: round 1, a full review (no fix hunks), the level from the run record, up to `review.max_rounds_per_file` rounds again. It saves at `at_seq + 1`, so a row works once. For a stop the worker checks again that the content changed; on the stop's own content the stop repeats. The block's one L3 rung stays spent when it was used, so a reopened file that reaches the cap stops at `review_cap`. After a verified human or agreed agent reopen and a fresh signed review of the current bytes, the old cap ids are cleared. The fresh review’s own findings still count. An approval written after the `review.cap` row also clears the cap. Refusals (exit 1, no row): `reopen-same-content` (a stopped file still at the content its stop reviewed, or unknown stop content whose current hash has a verified round: for unknown content, change the file and reopen, or the owner can use `block close --no-require-reviews`), `reopen-tampered` (an integrity failure stays a security event), `reopen-not-stopped` (no state, or an open or complete one), `reopen-block-not-open` (the block is stopped or closed), `reopen-not-owned`, `unknown_block` (the block lookup code `block waive` uses; it and `reopen-block-not-open` are checked before anything else is read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read), `reopen-already-recorded` (a row for this `at_seq` already names other content: run `review-file`, or the content changed since that reopen), `reopen-write-failed` (the row was not written). A missing `--file` or `--reason`, or a `--file` that is not a plain repo-root-relative path (no `..`, `./`, `//` or `/./`, and no control character), is `usage` (exit 2).
112
+ - **Exit 1 — fix, then `block reopen` (B72).** The file is fixed by hand, then the human restarts its review: `forge block reopen <id> --file <path> --run <r> --reason <text>`. It also restarts a `lost` state (step 8) on its current content. Without `--yes` it needs human confirmation; see **Human confirmation** below. `block reopen` and `block waive` both have evidence-backed agent paths through `--yes` with a fresh judge. A stopped file must differ from the content its stop reviewed. Every stop records that content as `stop_content_hash` in the signed state; a state written before B72, or before B114 by a fix-loop stop, has none, and then only the `review.cap` row of that stop (after the file's latest reopen, same round and reason) names it — otherwise the content is unknown. With unknown stop content, reopen accepts only when no verified `review.round` of this run, block and file names the current hash (B114). It records `from_hash: null`; the worker enforces the same check. If that hash was reviewed, change the file and reopen, or the owner can use `block close --no-require-reviews`. Waiving findings at an old hash cannot clear unreviewed current bytes. The verb first removes the done marker of the current content's ticket, so the next `review-file` runs instead of returning the cached stop. Then it writes one signed `review.reopened {block, file, from: stopped|lost|open, stop_reason, at_seq, from_hash, to_hash, reason, by: human | agent}` row (`at_seq` is the file's latest `review.state` anchor seq; `from_hash` the content the stop reviewed, null for `lost` or unknown stop content, the current content for `open`; `to_hash` the current content). There is at most one row per `at_seq`: a retry on the same content finds its row and writes no second one, and other content at the same `at_seq` is refused. The worker starts a new state for a row whose MAC verifies, of this run, `by: human` or `by: agent`, whose `at_seq` is the latest anchor seq: round 1, a full review (no fix hunks), the level from the run record, up to `review.max_rounds_per_file` rounds again. It saves at `at_seq + 1`, so a row works once. For a stop the worker checks again that the content changed; on the stop's own content the stop repeats. The block's one L3 rung stays spent when it was used, so a reopened file that reaches the cap stops at `review_cap`. After a verified human or agreed agent reopen and a fresh signed review of the current bytes, the old cap ids are cleared. The fresh review’s own findings still count. An approval written after the `review.cap` row also clears the cap. Refusals (exit 1, no row): `reopen-same-content` (a stopped file still at the content its stop reviewed, or unknown stop content whose current hash has a verified round: for unknown content, change the file and reopen, or the owner can use `block close --no-require-reviews`), `reopen-tampered` (an integrity failure stays a security event), `reopen-not-stopped` (no state, a complete one, or an open one unless the agent path is used with findings), `reopen-block-not-open` (the block is stopped or closed), `reopen-not-owned`, `unknown_block` (the block lookup code `block waive` uses; it and `reopen-block-not-open` are checked before anything else is read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read), `reopen-already-recorded` (a row for this `at_seq` already names other content: run `review-file`, or the content changed since that reopen), `reopen-write-failed` (the row was not written). A missing `--file` or `--reason`, or a `--file` that is not a plain repo-root-relative path (no `..`, `./`, `//` or `/./`, and no control character), is `usage` (exit 2).
80
113
  - **Exit 2 — `review-file --answers` (B71).** A finding fixed in ANOTHER file of the block (a missing test added in the test file, say) is answered with `forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> --run <r>` (1 to 10 ids, none twice; `--of` names the source because finding ids are numbered per file). A coder may run it.
81
114
  - **Refusals.** The checks run when the ticket is queued and again in the worker, whose check is the one that counts. The block must own both files (`answers-not-owned`), they must be two files (`answers-same-file`), and every id must be open on the source's CURRENT hash (`answers-not-open`). Open means named by the latest signed refusal row whose `content_hash` is the current hash, or by ANY signed `review.cap` row whose `content_hash` is the current hash, not waived or answered yet, and one the close could count as answered: never the proof finding (a proof finding is cleared by its proof, not by a reviewer) and never an id read at an unknown severity (a cap id no signed refusal at that hash records, or a value outside critical/warning/nit). A cap id from an older hash (or a cap row with no `content_hash`) is never open for an answer. The close still requires a human waiver unless the cap row is superseded. A verified human or agreed agent `review.reopened` row after the cap and a signed `review.round` of the current hash after the reopen supersede it. An agent row counts only with `verdict: {verdict: agree, reason}`. A missing `--of`, no ids, more than 10 ids, an id given twice or a malformed id is `usage` (exit 2). The other refusals exit 1 and queue nothing. In the worker, an answering file that is gone or cannot be read is `answers-file-missing`.
82
115
  - **The review.** The ticket is its own kind (`kind: answers`); its id also covers the source, the source hash and the sorted ids, so the same request is the same ticket. The worker sends ONE fresh L2 reviewer session (lens `answers`) a packet with the findings, the source lines around each, and the answering file's diff against the block base (its full content when the base has no such file). Each file is read ONCE: those bytes are hashed and the packet is built from them, so the row's `by_hash` is the hash of what the reviewer saw. The packet file is removed after the session; a packet that cannot be built is `unavailable` (`packet-unreadable`), one that cannot be written `unavailable` (`packet-write-failed`). The reviewer answers `resolved` or `not_resolved` per id. A missing, repeated or broken verdict is `not_resolved`. A failed session (no result, not `ok`, a non-zero exit, or a spawn that throws: `session-error`) is `unavailable` (retry it; it is not a verdict) and writes no row. An answering file changed since the ticket is `stale` (`answering-file-changed`); a source changed since the ticket is `stale` (`source-changed`). All verdicts are built first, then each resolved id gets one signed `review.answered {block, file, content_hash, finding, by_file, by_hash, ticket}` row. If a write fails the ticket is `unavailable` (`ledger-write-failed`); the same request again retries the same ticket, the ids it already answered stay open for it, and it writes only the missing rows, so it ends with one row per id. `review-file --wait` prints `{answers: [{id, verdict, reason}]}`.
@@ -85,14 +118,19 @@ Only real rechecks count toward the round cap, per-level ladder and stall rules
85
118
  - **Undo and acknowledge agent waivers.** The human runs `forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>` to reopen a finding. It requires a verified agent waiver for an owned file. Unknown blocks, unowned files and missing verified agent waivers refuse with `unwaive-no-agent-waiver` (exit 1) before any prompt. A yes writes one signed `review.unwaived {block, file, finding, reason, by: human}` row. It cancels all earlier agent waivers for that finding and file in that block. Human waivers remain in force. A later agreed agent waiver counts again. Unsigned or tampered unwaives never cancel a waiver at the gate.
86
119
  Set `review.agent_waivers.require_ack: true` before run start to require human acknowledgement before close. The key is read only from the run snapshot and cannot change through run reload. The human runs `forge block ack-waivers <id> --run <r>`. It prints each counted waiver's file, finding, reason and verdict before asking. No counted waivers refuses with `ack-waivers-none` (exit 1), without a prompt. A yes writes one signed `agent_waivers.acked {block, digest, count, by: human}` row. The digest hashes the sorted file, finding and signed row identity set. A new or removed waiver changes it. Close refuses with `agent-waivers-not-acked` until a verified human ack matches the current set. With the key absent or false, close is unchanged.
87
120
  Both verbs are human-only and coder-forbidden. They ask at a terminal and have no `--yes`. No terminal exits 2. A no or cancel exits 1. These paths record nothing.
88
- - **Agent waivers.** Use `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> --yes`. The orchestrator may run `block waive --yes` only for a recorded warning or nit finding, with evidence, and a fresh judge must agree. Critical, unknown and unrecorded findings refuse with `waive-yes-critical` (exit 1). The reason must cite an existing repo-relative path with `:<line>`, an existing test file, or a numbered acceptance clause of this block. The path must exist in the block's tree. A clause number must be within its acceptance clause count. Missing evidence refuses with `waive-yes-no-evidence` (exit 1), before the judge runs. One fresh L3 judge gets the recorded finding, reason and current file bytes. It never resumes the file's reviewer session. An `agree` writes one signed `review.waived` row with `by: agent`, the recorded severity, reason, `verdict: agree` and `judge_reason`. A disagreement or broken answer refuses with `waive-yes-disagreed` (exit 1). The judge's reason is printed. No refusal writes a waiver. The finding stays open.
89
- - **Agent reopens.** Use `block reopen <id> --run <r> --file <path> --reason <text> --yes`. It runs without a terminal or prompt. Every ordinary reopen check runs first. Evidence uses the same rule as agent waivers: an existing repo path with `:line`, an existing test file, or a numbered acceptance clause. No evidence refuses `reopen-yes-no-evidence` (exit 1). Only one verified agent reopen per block and file is allowed in a run; another refuses `reopen-yes-limit` (exit 1). Neither refusal calls the judge. One fresh L3 judge gets the stop reason, last findings, reason and current file bytes. A disagreement or invalid answer refuses `reopen-yes-disagreed` (exit 1) and prints the judge reason. The file stays stopped and nothing is written. Agreement uses the same done-marker removal and signed reopen row as the human path, with `by: agent` and `verdict: {verdict, reason}`. There is no severity restriction. The human path remains available after the agent limit. Close prints `agent reopen <file>: <reason> (verdict: agree)` for each agent reopen. Its JSON adds `agent_reopens: [{file, reason, verdict}]` only when nonempty.
90
- - **Human confirmation.** Without `--yes`, `block waive` keeps its human confirmation and writes `by: human`. Critical and unknown-severity findings need the human terminal path. With no terminal it exits 2: `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)`. `block reopen --yes` needs evidence and agreement from a fresh judge, at most once per block and file in a run. With no terminal it exits 2: `block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)`. Without `--yes`, the orchestrator prints the exact command with the human's reason; the human runs it in their own terminal. The orchestrator never answers a human confirmation prompt. A coder never runs either verb. Both verbs validate the file and block before asking. A waive also checks ownership and that the finding is recorded. A no or cancel records nothing. Under an active autopilot grant these human paths still prompt. The delegate's existing `autopilot waive` path still writes `by: autopilot`. The prompt checks only that stdin and stdout are terminals; a pseudo-terminal passes it, so it does not prove a human (`security.md` §3).
121
+ - **Agent waivers.** Use `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> --yes`. The orchestrator may run `block waive --yes` for a recorded warning or nit finding, or for a recorded critical under an active autopilot grant with a cited acceptance test that passed its latest signed gate for the current content; evidence and agreement from a fresh L3 judge are required.
122
+ A critical needs an active autopilot grant; otherwise it refuses `waive-yes-critical`. Unknown and unrecorded findings always refuse that code.
123
+ For a critical, cite a test file named in the block's acceptance `tests:` that passed in its latest signed gate record for the current content; otherwise `waive-yes-critical-no-test`.
124
+ Also cite the finding's own path with `:line` or `:start-end` (`waive-yes-no-evidence` if missing), and obtain L3 agreement; disagreement refuses `waive-yes-disagreed`, a broken answer `waive-yes-broken`. Refusals write nothing.
125
+ Agreement writes the existing signed `review.waived` row with `by: agent`, severity, reason, `verdict: agree`, and `judge_reason`; a critical also records `autopilot: true` and the cited `test` path. Human `block unwaive` still undoes it.
126
+ Warning/nit evidence remains an existing repo path with a line or range, an existing test file, or a numbered block acceptance clause. The fresh judge receives the recorded finding, reason, current file bytes and numbered windows for up to ten valid line citations (ten context lines either side, eighty lines maximum per window). Missing files, escaping paths and symlinks are omitted; the reviewer session is never resumed.
127
+ - **Agent reopens.** Use `block reopen <id> --run <r> --file <path> --reason <text> --yes`. It runs without a terminal or prompt. For an agent reopen of an open review with findings, the ordinary `reopen-not-stopped` and `reopen-same-content` checks are skipped. The file and reason form checks, block lookup and open status, ownership, state integrity and valid anchor sequence, and `reopen-already-recorded` check still run before agent evidence, limit and judge checks. Stopped and lost reviews retain their ordinary reopen checks; a stopped review still requires changed content. Evidence uses the same rule as agent waivers, including numbered citation windows. An open review with findings is also eligible, including critical findings and unchanged content, but requires a path with a line or line range; a bare test file or acceptance clause alone does not suffice. No evidence refuses `reopen-yes-no-evidence` (exit 1). Only one verified agent reopen per block and file is allowed in a run; another refuses `reopen-yes-limit` (exit 1). Neither refusal calls the judge. One fresh L3 judge gets the stop reason (null for an open review), last findings, reason, current file bytes and citation windows. A disagreement or invalid answer refuses `reopen-yes-disagreed` (exit 1) and prints the judge reason. The review stays unchanged and nothing is written. Agreement removes the done marker, writes a signed reopen row with `by: agent`, `verdict: {verdict, reason}` and `from: open` for an open review, then queues a fresh full review. It does not approve or waive the findings. There is no severity restriction. The human path remains available after the agent limit and still requires a stopped or lost review. Close prints `agent reopen <file>: <reason> (verdict: agree)` for each agent reopen. Its JSON adds `agent_reopens: [{file, reason, verdict}]` only when nonempty.
128
+ - **Human confirmation.** Without `--yes`, `block waive` keeps its human confirmation and writes `by: human`. Critical findings outside the agent exception above, and all unknown-severity findings, need the human terminal path. With no terminal it exits 2: `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)`. `block reopen --yes` needs evidence and agreement from a fresh judge, at most once per block and file in a run. With no terminal it exits 2: `block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)`. Without `--yes`, the orchestrator prints the exact command with the human's reason; the human runs it in their own terminal. The orchestrator never answers a human confirmation prompt. A coder never runs either verb. Both verbs validate the file and block before asking. A waive also checks ownership and that the finding is recorded. A no or cancel records nothing. Under an active autopilot grant these human paths still prompt. The delegate's existing `autopilot waive` path still writes `by: autopilot`. The prompt checks only that stdin and stdout are terminals; a pseudo-terminal passes it, so it does not prove a human (`security.md` §3).
91
129
  - **False transcript hits.** The human runs `forge block accept-hit <id> --run <r> --rule <rule-id> --line <n> --transcript <path> --reason <text>` in their terminal. It uses the same human confirmation and has no `--yes`. It checks the run, block, readable transcript and scanned rule/line pair before asking. A missing hit refuses `accept-hit-no-such-hit` (exit 1). Bad forms are exit 2. No terminal is exit 2. A no or cancel writes nothing. A yes writes one signed `rule_break.accepted {block, rule, line, line_sha256, reason, by: human}` row. Close matches its run, block, rule and exact line content hash, even if the line moves in a joined log. Other hits and ledger `rule_break` rows still refuse. Close prints each acceptance it used with the current line and reason. Its JSON adds `accepted_hits: [{rule, line, reason}]`. A coder never runs this verb.
92
- - **Agent waivers at close.** A signed `by: agent` waiver with `verdict: agree` clears a warning or nit finding for `reviewed_by_waiver`. Unsigned or tampered rows never count. Its `gate.review_waived_all` row uses `by: agent`. `block close` prints one line per signed agent waiver with file, finding, reason and verdict. `--json` adds `agent_waivers: [{file, finding, severity, reason, verdict}]` when there are agent waivers.
130
+ - **Agent waivers at close.** A signed `by: agent` waiver with `verdict: agree` clears a warning or nit finding, or a critical recorded with `autopilot: true` and a cited `test`, for `reviewed_by_waiver`. Unsigned or tampered rows never count. Its `gate.review_waived_all` row uses `by: agent`. `block close` prints one line per signed agent waiver with file, finding, reason and verdict. `--json` adds `agent_waivers: [{file, finding, severity, reason, verdict}]` when there are agent waivers.
93
131
  - **Exit 4 — re-decompose.** The human decides, and the architect plans the work again as new blocks (`decisions.md` §6, `redecompose`). Each new block reviews its files from round 1.
94
- - **A fully waived or answered file closes with a `gate.review_waived_all` row.** A file with no approval for its current hash counts as reviewed — `reviewed_by_waiver`, never approved — when a signed `review.round` exists for that hash (a repeated stop or an `unavailable` result never counts), the latest refusal row for the hash is signed, carries `content_hash` equal to that hash (a refusal row with no `content_hash` still refuses) and names at least one finding, and every finding it names and every open id of the file's unsuperseded `review.cap` rows is waived or answered. A cap row adds no ids when a verified human or agreed agent `review.reopened` row follows it and a signed `review.round` of the current hash follows the reopen. A critical waiver counts only `by: human`; an autopilot waiver counts only when `autopilotWaiverProblem` finds nothing, and only for a finding a signed refusal row at that hash records as warning or nit (or records with no severity at all). A cap id no refusal row records a severity for, or a cap row whose `open` is not a list of ids, is the human's alone. A signed, still-current answer clears its finding there, and in the late-finding and cap checks only at the severity the rows RECORD for that id (a late finding's own severity; a cap id's from the signed refusals at the current hash) — a missing or unknown one is never cleared by an answer. An answer counts only while BOTH the source and the answering file have the row's hashes (a change to either makes it stale), and it may clear a critical. Otherwise the file stays `unreviewed`. The close writes one signed `gate.review_waived_all {block, file, content_hash, findings, by}` row per such file just before `block.close`: `findings` are the sorted waived ids, `by: human` when every clearing waiver is the human's, else `agent` when an agent waiver cleared a finding, else `autopilot`; `answered` lists the answered ids (only when there is one); with every id answered, `findings` is `[]` and `by` is null. When an id is both waived and answered, a human waiver is reported as the waiver; otherwise the answer is listed under `answered` instead of an autopilot waiver. The close line prints only the parts that apply: `block <id>: <file> reviewed_by_waiver (<ids>; by <human|autopilot|agent>)`, `reviewed_by_waiver (W1; by human; answered C1)`, or `reviewed_by_waiver (answered C1, C2)` (ids sorted and joined by `, `). A refused close writes no such row, and a close where no file needs it writes none.
95
- - **Not exits.** `block attempt` never resets review state: it only raises the attempt counter (a coder may run it), and a stopped file stays stopped, with no session. New content without a reopen row still gets the cached stop, with no session. `review-file --fresh` (§1) re-runs only a result that never counted as a review (`stale`, `unavailable`, round-1 `split_required`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); on a recheck stop it refuses `fresh-needs-reopen`. A `tampered` state (step 8) is not a stop and gets no exits: only the owner's `block close --no-require-reviews` gets past it.
132
+ - **A fully waived or answered file closes with a `gate.review_waived_all` row.** A file with no approval for its current hash counts as reviewed — `reviewed_by_waiver`, never approved — when a signed `review.round` exists for that hash (a repeated stop or an `unavailable` result never counts), the latest refusal row for the hash is signed, carries `content_hash` equal to that hash (a refusal row with no `content_hash` still refuses) and names at least one finding, and every finding it names and every open id of the file's unsuperseded `review.cap` rows is waived or answered. A cap row adds no ids when a verified human or agreed agent `review.reopened` row follows it and a signed `review.round` of the current hash follows the reopen. A critical waiver counts `by: human` or as an agreed agent waiver with `autopilot: true` and a cited `test`; an autopilot waiver counts only when `autopilotWaiverProblem` finds nothing, and only for a finding a signed refusal row at that hash records as warning or nit (or records with no severity at all). A cap id no refusal row records a severity for, or a cap row whose `open` is not a list of ids, is the human's alone. A signed, still-current answer clears its finding there, and in the late-finding and cap checks only at the severity the rows RECORD for that id (a late finding's own severity; a cap id's from the signed refusals at the current hash) — a missing or unknown one is never cleared by an answer. An answer counts only while BOTH the source and the answering file have the row's hashes (a change to either makes it stale), and it may clear a critical. Otherwise the file stays `unreviewed`. The close writes one signed `gate.review_waived_all {block, file, content_hash, findings, by}` row per such file just before `block.close`: `findings` are the sorted waived ids, `by: human` when every clearing waiver is the human's, else `agent` when an agent waiver cleared a finding, else `autopilot`; `answered` lists the answered ids (only when there is one); with every id answered, `findings` is `[]` and `by` is null. When an id is both waived and answered, a human waiver is reported as the waiver; otherwise the answer is listed under `answered` instead of an autopilot waiver. The close line prints only the parts that apply: `block <id>: <file> reviewed_by_waiver (<ids>; by <human|autopilot|agent>)`, `reviewed_by_waiver (W1; by human; answered C1)`, or `reviewed_by_waiver (answered C1, C2)` (ids sorted and joined by `, `). A refused close writes no such row, and a close where no file needs it writes none.
133
+ - **Not exits.** `block attempt` never resets review state: it only raises the attempt counter (a coder may run it). A stopped file needs a signed reopen row to restart; the block join writes that row automatically for changed, unwaived findings (§1). `review-file --fresh` (§1) re-runs only a result that never counted as a review (`stale`, `unavailable`, round-1 `split_required`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); on a recheck stop it refuses `fresh-needs-reopen`. A `tampered` state (step 8) is not a stop and gets no exits: only the owner's `block close --no-require-reviews` gets past it.
96
134
  6. **Block-close sweep**: `forge block close` batches the `late_findings` per file to one L3 judge ruling (`fix_now | nit`); a `fix_now` opens a round on that file at the block's current level and counts toward its cap. The gate counts a ruling only when it is `nit` on a warning or nit finding; a critical late finding stays open until the human waives it.
97
135
  7. Ledger: one `review.round` row per round with `open_before`, `closed`, `new_in_hunks`, `late`, `open_after`; `forge report` prints rounds per file and rounds that stalled.
98
136
  8. **A state file that goes missing is rebuilt from the ledger.** The worker keeps each file's round, level, open and late findings and stop in a signed state file. Every save first writes a signed `review.state` anchor row, and the anchor carries the state, so a missing file is rebuilt from it (Restore, below). Only an anchor with no state leaves the file `lost` (Lost, below).
@@ -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.2"
13
+ PINNED_VERSION="0.15.4"
14
14
  set -eu
15
15
 
16
16
  self="$0"