@codedology/code-forge 0.9.0 → 0.10.0
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 +58 -0
- package/README.md +29 -8
- package/docs/reference/blocks.json +385 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +8 -3
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +11 -4
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +2 -0
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +164 -24
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +27 -6
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/engine.mjs +82 -30
- package/src/review/fixloop.mjs +45 -13
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +4 -6
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +2 -2
- package/src/worker/loop.mjs +79 -14
- package/src/worker/ticket.mjs +9 -1
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
`forge review-file <path> --block <id> --run <r>` enqueues a ticket and returns `{ticket, status: queued}` in under a second; `forge review-file --wait <ticket> [--max 90s]` polls it. The 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`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`). Use it after such a transient answer 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. A coder that never calls `review-file` gains nothing: the block gate refuses any changed or new file without a signed `review.approved` row for its final content hash. The reviewer is always a new OS process (R1, R10) — identical under Solo, a harness subagent, a detached CLI process, or a human typing.
|
|
6
6
|
|
|
7
|
+
A ticket whose session breaks is not a review. A broken result is a `schema` miss or a timed-out attempt (B91): a timeout counts as a broken result, like a schema miss. Misses and timeouts of the same packet, level and role are counted together, so one miss plus one timeout is two. A packet's second broken result is tried once on the ticket's second level, in the same ticket, only when a second level exists (`review.second_levels.<level>`, or for a routed ticket the rule's `second`) and never in consensus mode (§4).
|
|
8
|
+
|
|
7
9
|
Under `proof.isolation: worktree` (`proof.md` §3) the coder calls `review-file` inside the block's tree. The ticket still goes to the main tree's queue, where the worker runs. The content hash, the diff against the block's base and the peers' diffs are read in the block's tree, which the worker takes from the run record, and the ticket and the answer carry `tree: <path>`. The tree is part of the ticket id, and a ticket whose tree is not the one in the run record, or a ticket with no tree for a block that has one, is answered `review.unavailable` with `reason: worktree-mismatch`. A block the run record gives no worktree is hashed in the main tree, wherever `review-file` is called, and its ticket names no tree. The packet's facts slot names the tree and its branch. A change made only in the main tree is never seen. A recorded tree that cannot be used, or an unreadable run record, refuses `review-file` itself (exit 1, nothing queued); after the ticket was queued the worker answers `review.unavailable` with the same reason. The reasons: `worktree-missing` (gone), `worktree-not-dir` (a file), `worktree-malformed` (a bad record), `worktree-branch-mismatch` (off its branch), `worktree-foreign` (a checkout of another repository), `worktree-workspace-missing` (no workspace subdirectory in the tree), `git-failed` (git could not run, with its reason), `run-record-unreadable` (the record cannot be parsed or names no workspace), plus `worktree-mismatch` (worker only) and `bad-path` (`review-file` only). See `proof.md` §3 for each cause.
|
|
8
10
|
|
|
9
11
|
**The owner's two ways out of the per-file review.** (1) `forge block close <id> --no-require-reviews`, typed by the owner at a terminal (`code.md` §5). (2) `review.external: {required: true, name: <reviewer>}` in `.code-forge.yml`, set before `run start`, when an outside reviewer reviews each block (B58). With `required: true`, `run start` refuses a missing or blank `name`, and writes a signed `review.external` row with the reviewer's name. `run reload` refuses to add, remove or change the key. With the key, `block close` does not check the review rows (a `not_approved` file included) or the report's ticket ids, needs no terminal and works under an autopilot grant. When the block closes, it writes a signed `gate.reviews_waived {by: config, reviewer}` row just before `block.close`; a refused close writes no such row. Unowned changes, `unproven`, `no_changes`, the transcript grep and the registry's orphans still refuse. No block may own `.code-forge.yml` while the key is on: `block open` and `block claim` refuse it. `block open`, `block claim` and `block close` read the run's config snapshot to find the key, so a snapshot that does not match its hash refuses them with `config-snapshot`, with or without the key. Never set or remove the key yourself: it is the owner's decision.
|
|
@@ -21,11 +23,15 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
|
|
|
21
23
|
|
|
22
24
|
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, 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.
|
|
23
25
|
|
|
26
|
+
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`).
|
|
27
|
+
|
|
24
28
|
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.
|
|
25
29
|
|
|
30
|
+
Test quality (B92): a test file that reads a source file and asserts on its text gets a `## test-quality` section before the diff, in each reviewer's round-1 packet and the judge's. Rechecks get the lens rule but no `## test-quality` section. The section names the file and the flagged line numbers — at most 10, the rest counted as `(<n> more not listed)` — never the file's text. Detection is a text match with no model call: a test file (a test path, a `Tests/` dir, or a name ending in `Tests.<ext>` or `Test.<ext>`; never a `.md` or `.txt` file or a file under a `fixtures`, `fixture` or `__fixtures__` dir) with a line holding a read call and a line holding a contains or match call (`includes(`, `contains(`, `toContain(`, `toMatch(`, `assert.match(`, `assertIn(`, `strings.Contains(`). The read calls that count are `readFileSync`, `readFile`, `open(`, `read_text`, `String(contentsOf`, `contentsOfFile`, `File.read`, `os.ReadFile`, `fs::read_to_string`, `readText(`, `readString(` and `ReadAllText(`, matched as text. The call counts when its span — its own line, plus up to 3 more lines while the read call's own `(` is still open (a formatter split) — holds a quoted source path (`.swift`, `.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`, `.py`, `.go`, `.rs`, `.kt`, `.java`, `.rb`, `.php`, `.cs`) or a name bound on an earlier line to one; the call's own line is the one listed. A name is bound only when its value IS the source-path literal: the whole right-hand side, or the literal passed straight to `join`, `path.join`, `resolve` or `path.resolve` (`spawnSync('node', ['bin/cli.mjs'])` binds nothing); the binding may carry modifiers and a type (`private val X`, `const val X`, `private const val X`, `private static let X`, `private static final String X`, `private const string X`, `const string X`, `export const X`). A fixture read never counts: a read whose span holds a string literal with a `fixtures`, `fixture` or `__fixtures__` path segment (`'test/fixtures/a.mjs'`, `join(dir, 'fixtures', 'a.mjs')`), or names a name bound on a line holding such a literal (`const FIXTURES = join(__dirname, 'fixtures')`). The section is a hint, not a finding: it writes no row and changes no gate. The lens rule tells the reviewer to check those lines and raise such a test as a `warning` ("test reads source text; prove behaviour instead") unless the read file is a fixture. It is extra text counted in the packet budget, like the moved-code section; a file with no flag gets no section. Over budget it takes the path any extra takes: the context shrinks, then the digest is trimmed; when the diff plus the extras alone are over budget, the file is `split_required` (with `tokens_in` and `budget`, no session; a Markdown file is split by section instead).
|
|
31
|
+
|
|
26
32
|
## §4 The stub guard — what counts as a review (R11)
|
|
27
33
|
|
|
28
|
-
A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1; `context-unreadable`, before any session, is retried differently: see §3) and **is not approval**; `review-file` on the same content re-runs the same packet once, then the file stays unreviewed and the block cannot close. Every `schema` miss leaves a signed `review.schema_invalid` row with a structural diagnosis of the answer — its size and kind (`json`, `not-json`, `empty`), its top-level keys and their types, and the schema errors (path, keyword, message), or the parse error and its byte offset — never the answer's text. At a packet's **second**
|
|
34
|
+
A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1; `context-unreadable`, before any session, is retried differently: see §3) and **is not approval**; `review-file` on the same content re-runs the same packet once, then the file stays unreviewed and the block cannot close. Every `schema` miss leaves a signed `review.schema_invalid` row with a structural diagnosis of the answer — its size and kind (`json`, `not-json`, `empty`), its top-level keys and their types, and the schema errors (path, keyword, message), or the parse error and its byte offset — never the answer's text. A broken result is a `schema` miss or a timed-out attempt (B91). At a packet's **second** broken result (same run, file, content hash, packet text, level and role — counted from the signed `review.schema_invalid` rows and the signed `review.session_timeout` notes of the level's own models, plus the result in hand; misses and timeouts count together, so one miss plus one timeout is a second broken result), the worker tries the packet ONCE on the ticket's second level when there is one — `review.second_levels.<level>`, or for a routed ticket the rule's `second` (B90; an L2 rule's `second` never applies in consensus mode) — and writes a signed `review.schema_fallback {lens, role, level, packet_hash, from_provider, from_model, to, to_provider, to_model, attempts, trigger, refused?}` row first (no row written ⇒ no try; any earlier row for the packet at that level ⇒ no try). `trigger` is `schema` or `timeout`, the kind of the second broken result; a row without it (written before B91) is a `schema` one. Its first model that is not the failed one — and, for a closed-book role, not Codex unless `review.allow_open_book_codex` — runs (`refused: same-model` or `refused: closed-book` when there is none). The session's tokens are both sessions'. Its answer goes through the same stub guard; a valid one is a normal answer, a failed one leaves the file unavailable. Never in consensus mode; `budget.usd` refuses it like any session. Without a second level nothing is tried and no `review.schema_fallback` row is written: a `schema` miss adds only its diagnosis row, and a timeout runs as before B91, its note now carrying `packet_hash` and signed. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level. A second timeout is the packet's second broken result: with a second level it is tried there in the same ticket (`trigger: timeout`), without one it is `unavailable: timeout`. With a second level, a timeout after a `schema` miss on the same packet goes there at once, with no same-model retry; without one, that timeout is retried once on the same model, then the file is `unavailable: timeout`. The second level's own misses and timeouts (`second_level: true`) never count. Notes without `packet_hash` (written before B91), unsigned notes and `second_level: true` notes are read but never count. Each timed-out attempt leaves a signed `review.session_timeout` ledger note with the packet's `packet_hash`, the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
|
|
29
35
|
|
|
30
36
|
## §5 The convergence rule — bounded fix rounds (R15)
|
|
31
37
|
|
|
@@ -36,13 +42,14 @@ Per file, per block:
|
|
|
36
42
|
3. **Strictly shrinking**: the open `fix_now` set after round n is smaller than after round n−1, counting closures minus new in-hunk findings. Equal or larger ⇒ `review_stall` (rule 2b), a trigger at once.
|
|
37
43
|
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.
|
|
38
44
|
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.
|
|
39
|
-
5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). 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):
|
|
40
|
-
- **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. It is human-only like `block waive`: on the coder forbidden list, run by the
|
|
45
|
+
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):
|
|
46
|
+
- **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. It is human-only like `block waive`: on the coder forbidden list, and run by the human in their own terminal (see **The human runs `block waive` and `block reopen`** below). 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 from before B72 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 and the reopen is refused. 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}` row (`at_seq` is the file's latest `review.state` anchor seq; `from_hash` the content the stop reviewed, null for `lost`; `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`, 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`. The gate needs nothing new: an approval written after the `review.cap` row clears the cap. Refusals (exit 1, no row): `reopen-same-content` (a stopped file still at the content its stop reviewed, or a stop whose content is unknown: change the file, or waive its findings instead), `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).
|
|
41
47
|
- **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.
|
|
42
48
|
- **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 it, and the human waive exit clears it. 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`.
|
|
43
49
|
- **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}]}`.
|
|
44
50
|
- **Never an approval.** The answering file still needs its own `review.approved`, and the source's fix-loop state is unchanged.
|
|
45
|
-
- **Exit 3 — `block waive`.** The human waives each open finding: `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`). The coder never sees a `waive` verb; it is on the forbidden list. **Autopilot:** under a grant, a warning or nit finding may be waived by the delegate (`forge autopilot waive … --decision <id>`, written `by: autopilot`) and a file at the cap may get one extra round (`forge autopilot round`); a critical or proof waiver is never delegated (`autopilot.md`).
|
|
51
|
+
- **Exit 3 — `block waive`.** The human waives each open finding: `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`). The coder never sees a `waive` verb; it is on the forbidden list. The human runs it in their own terminal (next paragraph). **Autopilot:** under a grant, a warning or nit finding may be waived by the delegate (`forge autopilot waive … --decision <id>`, written `by: autopilot`) and a file at the cap may get one extra round (`forge autopilot round`); a critical or proof waiver is never delegated (`autopilot.md`).
|
|
52
|
+
- **The human runs `block waive` and `block reopen` (B80).** Both verbs ask for a yes at a terminal. The orchestrator prints the exact command, with the human's reason, and never answers the prompt; the human types the command in their own terminal. The rule: a coder or the orchestrator never runs either verb. With stdin or stdout not a terminal (a pipe, or a process started with no terminal) the waive exits 2 with `block waive needs the human at a terminal to confirm; nothing was recorded (there is no --yes)` and the reopen with `block reopen needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`; neither writes a row or removes a done marker. At a terminal the verb first refuses what it would refuse anyway, before it asks: a malformed `--file` or finding id, or one with a control character (exit 2), an unknown block (`unknown_block`), for a reopen a block that is not open (`reopen-block-not-open`) or a file it does not own (`reopen-not-owned`), and for a waive a file the block does not own (`waive-not-owned`) or a finding id that no review row of the block names for that file (`waive-unknown-finding`; the `proof` finding is exempt) — exit 1, nothing asked, nothing written. A "no" or a cancel prints `block <id> <verb>: cancelled — nothing was recorded` (exit 1). A "yes" writes the same signed row as before. There is no `--yes`: it is an unknown flag (exit 2). Under an active autopilot grant both verbs still prompt the same way (only `block close --no-require-reviews` is refused); the delegate's waivers go through `forge autopilot waive` (`by: autopilot`), which needs no terminal. 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).
|
|
46
53
|
- **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.
|
|
47
54
|
- **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 `review.cap` rows is waived or answered. 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 `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>)`, `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.
|
|
48
55
|
- **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`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); on a 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.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## §1 Keys never reach a coder
|
|
4
4
|
|
|
5
|
-
`keys.<name>` in `.code-forge.yml` holds a reference only (`user`, `op://…`, `env:NAME`, `keychain:<name>`), never a value. Resolution runs the chain — environment → OS keychain → 1Password `op read` (cached 8 h) → ask at setup — and runs **only inside the worker or the orchestrator's own CLI calls, never in a coder process**. `forge keys list | set <name> [--op <ref>] | test <name> [--ref <ref>] | remove <name>`; `list` never prints a value. Every output path is redacted: a message names the key path, never the value. Coder sessions receive neither the S1 key nor the signer key in their environment (subprocess env allow-list; Solo agent env likewise); reviewer sessions keep only the provider login variables they need to authenticate.
|
|
5
|
+
`keys.<name>` in `.code-forge.yml` holds a reference only (`user`, `op://…`, `env:NAME`, `keychain:<name>`), never a value. Resolution runs the chain — environment → OS keychain → 1Password `op read` (cached `keys.cache_hours`, default 8 h, max 24 h; `keys set --op <ref> --cache-hours <n>` sets the stored expiry for that one key, but the next CLI/worker/doctor read still caps it at `keys.cache_hours`, 8 h when unset) → ask at setup — and runs **only inside the worker or the orchestrator's own CLI calls, never in a coder process**. `forge keys list | set <name> [--op <ref> [--cache-hours <1-24>]] | test <name> [--ref <ref>] | remove <name>`; `list` never prints a value; under 1 h left it adds a stderr `WARN <name> expires in <m> min; re-link: code-forge keys set <name> --op <ref>` line, and `doctor` a `WARN` keys row. When an expired cached key cannot be refreshed, `jev ask` prints `jev ask: the cached jev key expired at <iso> and 1Password did not answer; re-link: code-forge keys set jev --op <ref>`: **relay that line to the human and stop**; never run `keys set --op` yourself in an agent session, because 1Password asks the human to approve it. Every output path is redacted: a message names the key path, never the value. Coder sessions receive neither the S1 key nor the signer key in their environment (subprocess env allow-list; Solo agent env likewise); reviewer sessions keep only the provider login variables they need to authenticate.
|
|
6
6
|
|
|
7
7
|
## §2 The forbidden list — one list, rendered per CLI at spawn, grepped from every transcript
|
|
8
8
|
|
|
@@ -14,11 +14,11 @@ Rendered into every coder spawn as the CLI's own deny rules (path rules widen a
|
|
|
14
14
|
|
|
15
15
|
**Mechanism.** A per-run HMAC key (32 random bytes) lives in `~/.code-forge/runs/<run>.key`, mode 0600, outside the workspace; `forge run start` generates it, the worker and `forge block close` read it, and it is never placed in a coder's environment or workspace. Gate-relevant rows carry `mac`; a verification failure is a `ledger.tamper` row and the block is `stopped`. Key rotation = a new run.
|
|
16
16
|
|
|
17
|
-
**What it defends against.** A coder that never called `review-file` and appends or edits rows or result files with its ordinary tools — no valid MAC, refused. A coder that edits a review result after the worker wrote it — the MAC no longer matches. A stale approval reused after the file changed — rows are keyed by content hash. A coder that starts its own worker to sign rows — the pinned pid and start time in the run record no longer match, `worker.replaced`, block stopped. A coder that waives its own finding — `block waive` is forbidden and the row requires `by: human` signed by the worker's key.
|
|
17
|
+
**What it defends against.** A coder that never called `review-file` and appends or edits rows or result files with its ordinary tools — no valid MAC, refused. A coder that edits a review result after the worker wrote it — the MAC no longer matches. A stale approval reused after the file changed — rows are keyed by content hash. A coder that starts its own worker to sign rows — the pinned pid and start time in the run record no longer match, `worker.replaced`, block stopped. A coder that waives its own finding — `block waive` is forbidden and the row requires `by: human` signed by the worker's key. An agent that runs `block waive` or `block reopen` where stdin or stdout is not a terminal (a pipe, or a process started with no terminal) — both ask for a yes at a terminal, so they exit 2 with `block <verb> needs the human at a terminal to confirm; nothing was recorded (there is no --yes)` and record nothing (B80). The orchestrator prints the exact command and never answers the prompt; the human types the command in their own terminal. There is no `--yes`. Under an active autopilot grant `block waive` and `block reopen` still prompt the same way; only `block close --no-require-reviews` is refused under a grant. Autopilot's own waive path, `forge autopilot waive` (written `by: autopilot`), needs no terminal.
|
|
18
18
|
|
|
19
|
-
**What it does not defend against, and the product does not claim.** A coder running as the same OS user with a shell can read the key file and mint a MAC, or edit the run record. Within one OS user there is no boundary that a determined bypass-permissions process cannot cross; the deny rules make it a deliberate, named rule break rather than an accident, and the transcript grep makes the attempt visible (`rule_break: key_path`). `doctor` prints `signer: same-user boundary only` on every full run so nobody reads "signed" as "unforgeable".
|
|
19
|
+
**What it does not defend against, and the product does not claim.** A coder running as the same OS user with a shell can read the key file and mint a MAC, or edit the run record. **The honest limit of the `block waive` / `block reopen` prompt:** the prompt checks only that stdin and stdout are terminals. It does not prove a human: a process that runs the verb in a pseudo-terminal (`script`, `expect`, a pty library, or an agent shell that is itself a pty, as Solo processes may be) passes the check and can answer yes, and a same-user process can read the run key and sign a row. The signer is a same-user boundary only. Within one OS user there is no boundary that a determined bypass-permissions process cannot cross; the deny rules make it a deliberate, named rule break rather than an accident, and the transcript grep makes the attempt visible (`rule_break: key_path`). `doctor` prints `signer: same-user boundary only` on every full run so nobody reads "signed" as "unforgeable".
|
|
20
20
|
|
|
21
|
-
**The real boundary is the consumer's choice:** run coders as a second OS user, or in a container that cannot reach `~/.code-forge/runs/` (a Codex `workspace-write` sandbox already blocks writes there; reads remain open). An in-memory key over a socket was considered and rejected: a same-user process can still replace the worker or edit the pin.
|
|
21
|
+
**The real boundary is the consumer's choice:** run coders as a second OS user, or in a container that cannot reach `~/.code-forge/runs/` (a Codex `workspace-write` sandbox already blocks writes there; reads remain open). The Codex coder's `coder.sandbox` profile (`adapters/subprocess.md` §3) sets how far that holds: `workspace` (the default; no key gives today's argv) opens only the project tree for writes; `toolchain` also opens each checked `add_dirs` entry. An entry is refused (`sandbox-dir-refused`) when it is `/` or the home directory, or is, holds or sits inside `~/.code-forge`, `~/.codex`, `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config`, `~/Library/Keychains`, the run temp root or the session `CODEX_HOME`, so `~/Library` is refused too; `~/` expands against HOME, each entry becomes an absolute realpath, and a symlink is judged by its target; the message names the key path, never the value. `full` (`-s danger-full-access`) turns the Codex sandbox off: it opens every path the OS user can write, `~/.code-forge/runs/` included, with network as the CLI allows, so only the same-user rules above hold, like the Claude coder; the argv keeps the `$TMPDIR` and `/tmp` exclusion flags, which Codex ignores there. Under `workspace` or `full`, `add_dirs` is unused and `validate` warns `sandbox-dirs-unused`. The key has no effect on the Claude and Grok coders. `doctor` prints no `coder sandbox` row without the key, one INFO row with it, a WARN row for `full`, and a no-effect INFO row only when a Claude or Grok coder is configured. The profile is authority: fixed at `run start`, refused by `run reload`, and a differing live value stops `forge spawn --role coder --run <r>` with `sandbox-changed`; without `--run` the live config is read. An in-memory key over a socket was considered and rejected: a same-user process can still replace the worker or edit the pin.
|
|
22
22
|
|
|
23
23
|
## §4 Isolation of closed-book sessions
|
|
24
24
|
|
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.
|
|
13
|
+
PINNED_VERSION="0.10.0"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
After you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet. A finding whose fix belongs in another owned file (a missing test, say) is fixed there and sent with `forge review-file <that file> --block <id> --run <r> --answers <ids> --of <source>`. A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval).
|
|
19
19
|
|
|
20
|
+
Proof is behaviour, not text: never read a source file in a test to assert on its text. Call the code and check what it does; a test may read a fixture. A test file that reads a source file and asserts on its text gets a `## test-quality` section in its review packet. The section is a hint, not a finding: the reviewer checks those lines and, when the test really reads source text to assert on it, raises a `warning` (unless the file read is a fixture).
|
|
21
|
+
|
|
20
22
|
## Final report — FAILED unless it lists the ticket ids
|
|
21
23
|
|
|
22
24
|
Run the block's test command (`test_command`) and print its result on a line of its own, exactly:
|
|
@@ -159,7 +159,9 @@ function describe(row) {
|
|
|
159
159
|
case 'review.session_timeout':
|
|
160
160
|
return { event: 'Review session timed out', detail: `${row.lens ?? row.role ?? 'session'} · attempt ${u(row.attempt)} · ${row.retried === true ? 'retried' : 'gave up'}`, link: null };
|
|
161
161
|
case 'review.schema_fallback':
|
|
162
|
-
|
|
162
|
+
// B91: `trigger: timeout` ⇒ the second broken result was a timed-out attempt; no trigger
|
|
163
|
+
// (rows before B91) or `schema` ⇒ two schema misses
|
|
164
|
+
return { event: row.trigger === 'timeout' ? 'Review broken twice (timeout)' : 'Review answer off-schema twice', detail: `${row.lens ?? row.role ?? 'session'} · ${u(row.from_model, 'unknown model')} → ${u(row.to_model, 'unknown model')} · ${typeof row.refused === 'string' ? `refused (${u(row.refused)})` : 'retried once'}`, link: null };
|
|
163
165
|
case 'worker.down':
|
|
164
166
|
return { event: 'Worker down', detail: 'the pinned worker is not running', link: null };
|
|
165
167
|
case 'worker.replaced':
|
|
@@ -209,12 +211,16 @@ const SCHEMA_FALLBACK_FIX = Object.freeze({
|
|
|
209
211
|
});
|
|
210
212
|
|
|
211
213
|
/**
|
|
212
|
-
* The incident of a `review.schema_fallback` row (B55): retried once, or refused and why.
|
|
214
|
+
* The incident of a `review.schema_fallback` row (B55): retried once, or refused and why. B91:
|
|
215
|
+
* a row with `trigger: timeout` says the second broken result was a timeout.
|
|
213
216
|
* @param {Row} r @param {string | null} time
|
|
214
217
|
* @returns {{time: string | null, what: string, effect: string, fix: string}}
|
|
215
218
|
*/
|
|
216
219
|
function schemaFallbackIncident(r, time) {
|
|
217
|
-
const what =
|
|
220
|
+
const what =
|
|
221
|
+
r.trigger === 'timeout'
|
|
222
|
+
? `A ${r.lens ?? r.role ?? 'review'} session broke twice on the same packet, the second time by timing out (${u(r.from_model, 'unknown model')})`
|
|
223
|
+
: `A ${r.lens ?? r.role ?? 'review'} answer failed the schema twice on the same packet (${u(r.from_model, 'unknown model')})`;
|
|
218
224
|
if (typeof r.refused !== 'string') return { time, what, effect: `It was retried once on the second level (${u(r.to_model, 'unknown model')}).`, fix: "Check the file's next review result." };
|
|
219
225
|
const fix = Object.hasOwn(SCHEMA_FALLBACK_FIX, r.refused) ? SCHEMA_FALLBACK_FIX[/** @type {keyof typeof SCHEMA_FALLBACK_FIX} */ (r.refused)] : 'Review the file by hand.';
|
|
220
226
|
return { time, what, effect: `The second-level try was refused (${u(r.refused)}); the review stays unavailable.`, fix };
|
package/src/cli/block.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* block claim <id> <path> --run <r>
|
|
11
11
|
* block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
|
|
12
12
|
* block stop <id> --run <r> --reason <text>
|
|
13
|
-
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
|
|
14
|
-
* block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72)
|
|
13
|
+
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9; asks at a terminal, B80)
|
|
14
|
+
* block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72; asks at a terminal, B80)
|
|
15
15
|
*
|
|
16
16
|
* B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
|
|
17
17
|
* text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* `--remove-worktree`). On
|
|
23
23
|
* failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
|
|
24
24
|
* refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
|
|
25
|
-
* error, a thrown refusal, a cancelled `--no-require-reviews`
|
|
25
|
+
* error, a thrown refusal, a cancelled `--no-require-reviews`, `block waive` or `block reopen` (or
|
|
26
|
+
* one with no terminal, B80) and a failed `--remove-worktree`
|
|
26
27
|
* print nothing on stdout. Without `--json` every output is byte-identical to before.
|
|
27
28
|
*
|
|
28
29
|
* B64b block end: a close that closes or stops the block and `block stop` write
|
|
@@ -156,6 +157,22 @@
|
|
|
156
157
|
* <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
|
|
157
158
|
* "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
|
|
158
159
|
*
|
|
160
|
+
* B80 (D1 = a): `block waive` and `block reopen` need the human at a terminal, through the same
|
|
161
|
+
* confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
|
|
162
|
+
* record read — before the autopilot expiry checks or anything else is written — stdin or stdout
|
|
163
|
+
* not a TTY prints `block <verb> needs the human at a terminal to confirm; nothing was recorded
|
|
164
|
+
* (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
|
|
165
|
+
* question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
|
|
166
|
+
* 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
|
|
167
|
+
* block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
|
|
168
|
+
* does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
|
|
169
|
+
* (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
|
|
170
|
+
* <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
|
|
171
|
+
* same signed row). There is no `--yes` (an unknown flag, exit 2). An active autopilot grant
|
|
172
|
+
* changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
|
|
173
|
+
* autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
|
|
174
|
+
* passes it, so it does not prove a human. The coder deny-list entries stay.
|
|
175
|
+
*
|
|
159
176
|
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
160
177
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
161
178
|
* through `closeBlock`'s `extraChecks`.
|
|
@@ -174,7 +191,7 @@ import { configInForce, externalReviewer, requireCoderTests } from '../state/con
|
|
|
174
191
|
import { StateError } from '../state/paths.mjs';
|
|
175
192
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
176
193
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
177
|
-
import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
194
|
+
import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
178
195
|
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
179
196
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
180
197
|
import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
|
|
@@ -186,6 +203,7 @@ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
|
186
203
|
import { clearDone } from '../worker/queue.mjs';
|
|
187
204
|
import { loadState } from '../worker/review-state.mjs';
|
|
188
205
|
import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
|
|
206
|
+
import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
|
|
189
207
|
import { runRootPath } from '../util/tmp.mjs';
|
|
190
208
|
import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
|
|
191
209
|
import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
|
|
@@ -265,7 +283,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
265
283
|
* removeBlockTree?: typeof removeBlockTree,
|
|
266
284
|
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
267
285
|
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
268
|
-
* `@clack/prompts`):
|
|
286
|
+
* `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
|
|
287
|
+
* `block reopen`), which needs both to be TTYs.
|
|
269
288
|
* `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
|
|
270
289
|
* @returns {Promise<number>}
|
|
271
290
|
*/
|
|
@@ -307,7 +326,20 @@ export async function runBlock(args, deps = {}) {
|
|
|
307
326
|
if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
|
|
308
327
|
if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
|
|
309
328
|
const record = await readRun(runId);
|
|
310
|
-
|
|
329
|
+
// B80: waive and reopen are the human's — asked at a terminal before anything is written (not
|
|
330
|
+
// even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
|
|
331
|
+
if (sub === 'waive' || sub === 'reopen') {
|
|
332
|
+
const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
|
|
333
|
+
if (asked === 'no-tty') {
|
|
334
|
+
err(`${humanVerbNoTTY(sub)}\n`);
|
|
335
|
+
return 2;
|
|
336
|
+
}
|
|
337
|
+
if (asked === 'cancelled') {
|
|
338
|
+
err(`block ${id} ${sub}: cancelled — nothing was recorded\n`);
|
|
339
|
+
return 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
|
|
311
343
|
// B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
|
|
312
344
|
// then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
|
|
313
345
|
// done file is the only addition: no row, and every output stays exactly as before.
|
|
@@ -733,31 +765,139 @@ async function coderTests(reportFlag, id) {
|
|
|
733
765
|
export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
|
|
734
766
|
|
|
735
767
|
/**
|
|
736
|
-
*
|
|
737
|
-
*
|
|
738
|
-
* script, the delegate's tooling), so the human confirms at a terminal and there is NO `--yes`.
|
|
739
|
-
* No terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
740
|
-
* question) — ⇒ `usage` (exit 2); an active autopilot grant on the run ⇒ `autopilot-active`
|
|
741
|
-
* (exit 1; the owner stops the grant first) — nothing is written either way. `deps.stdinIsTTY` /
|
|
742
|
-
* `deps.stdoutIsTTY` inject each side; `deps.isTTY` injects both at once.
|
|
743
|
-
* @param {string} runId @param {string} id
|
|
744
|
-
* @param {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} deps
|
|
745
|
-
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
746
|
-
* @throws {StateError} `usage`, `autopilot-active`
|
|
768
|
+
* B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
|
|
769
|
+
* @param {'waive' | 'reopen'} verb @returns {string}
|
|
747
770
|
*/
|
|
748
|
-
|
|
771
|
+
export const humanVerbNoTTY = (verb) => `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
|
|
775
|
+
*/
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* The ONE human confirm of the `block` verb (B52 `--no-require-reviews`, B80 `block waive` and
|
|
779
|
+
* `block reopen`): a flag or a verb can be run by any process (the orchestrator, a script, a
|
|
780
|
+
* coder), so the human answers a yes/no question at a terminal and there is NO `--yes`. No
|
|
781
|
+
* terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
782
|
+
* question) — is `no-tty`, decided before anything is read or asked. `before` runs between the
|
|
783
|
+
* terminal check and the question (a refusal it throws means the human is never asked).
|
|
784
|
+
* `deps.stdinIsTTY` / `deps.stdoutIsTTY` inject each side, `deps.isTTY` both at once, `deps.ui`
|
|
785
|
+
* the prompt (default `@clack/prompts`). A "no" or a cancel (Ctrl-C) is `cancelled`.
|
|
786
|
+
* @param {ConfirmDeps} deps @param {string} message
|
|
787
|
+
* @param {() => Promise<unknown> | unknown} [before]
|
|
788
|
+
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
789
|
+
*/
|
|
790
|
+
async function confirmAtTerminal(deps, message, before) {
|
|
749
791
|
const stdinTTY = deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true;
|
|
750
792
|
const stdoutTTY = deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true;
|
|
751
|
-
if (!(stdinTTY && stdoutTTY))
|
|
752
|
-
|
|
753
|
-
if (grant.state === 'active') {
|
|
754
|
-
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
755
|
-
}
|
|
793
|
+
if (!(stdinTTY && stdoutTTY)) return 'no-tty';
|
|
794
|
+
if (before) await before();
|
|
756
795
|
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
757
|
-
const go = await ui.confirm({ message
|
|
796
|
+
const go = await ui.confirm({ message, initialValue: false });
|
|
758
797
|
return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
|
|
759
798
|
}
|
|
760
799
|
|
|
800
|
+
/**
|
|
801
|
+
* B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
|
|
802
|
+
* same rule as `autopilot approve` (`confirmAtTerminal`). No terminal ⇒ `usage` (exit 2); an
|
|
803
|
+
* active autopilot grant on the run ⇒ `autopilot-active` (exit 1; the owner stops the grant
|
|
804
|
+
* first) — nothing is written either way.
|
|
805
|
+
* @param {string} runId @param {string} id @param {ConfirmDeps} deps
|
|
806
|
+
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
807
|
+
* @throws {StateError} `usage`, `autopilot-active`
|
|
808
|
+
*/
|
|
809
|
+
async function confirmNoReviews(runId, id, deps) {
|
|
810
|
+
const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, async () => {
|
|
811
|
+
const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
|
|
812
|
+
if (grant.state === 'active') {
|
|
813
|
+
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
if (asked === 'no-tty') throw new StateError('usage', NO_REVIEWS_NO_TTY);
|
|
817
|
+
return asked;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
|
|
822
|
+
* terminal through `confirmAtTerminal`, exactly like `--no-require-reviews`, but an active
|
|
823
|
+
* autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
|
|
824
|
+
* pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
|
|
825
|
+
* asked about a waive or reopen the verb would refuse that way.
|
|
826
|
+
* @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
|
|
827
|
+
* @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
|
|
828
|
+
* @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
|
|
829
|
+
*/
|
|
830
|
+
function confirmHumanVerb(verb, runId, id, what, record, deps) {
|
|
831
|
+
const message =
|
|
832
|
+
verb === 'waive'
|
|
833
|
+
? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
|
|
834
|
+
: `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
|
|
835
|
+
return confirmAtTerminal(deps, message, async () => {
|
|
836
|
+
if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
|
|
837
|
+
else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Does the block own `file`, by the rule both verbs use (`ownsAt` with the workspace's prefix
|
|
843
|
+
* inside its repo)?
|
|
844
|
+
* @param {Record<string, any>} record @param {Record<string, any>} entry @param {string} file
|
|
845
|
+
* @returns {Promise<boolean>}
|
|
846
|
+
*/
|
|
847
|
+
async function blockOwnsFile(record, entry, file) {
|
|
848
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
849
|
+
const mainTop = await real(await repoTop(record.workspace));
|
|
850
|
+
const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
|
|
851
|
+
const owned = Array.isArray(entry.owned_files) ? entry.owned_files : [];
|
|
852
|
+
return ownsAt(wsPrefix)(owned, file, entry.owned_kinds);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* B80: the checks a reopen must pass BEFORE the human is asked, in `reopenFile`'s order: the
|
|
857
|
+
* `--file` form (`checkFileForm`, the reopen rule: `usage`), the block (`openBlockEntry`:
|
|
858
|
+
* `unknown_block`, `reopen-block-not-open`) and ownership (`reopen-not-owned`, the same message).
|
|
859
|
+
* The state checks that read the key and the ledger stay the verb's.
|
|
860
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id @param {string} file
|
|
861
|
+
* @returns {Promise<void>}
|
|
862
|
+
* @throws {StateError}
|
|
863
|
+
*/
|
|
864
|
+
async function reopenPrecheck(record, runId, id, file) {
|
|
865
|
+
checkFileForm(file, { normalized: true });
|
|
866
|
+
const entry = openBlockEntry(record, runId, id);
|
|
867
|
+
if (!(await blockOwnsFile(record, entry, file))) throw new StateError('reopen-not-owned', `reopen-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* B80: the checks a waive must pass BEFORE the human is asked, so the human is never asked about a
|
|
872
|
+
* waive the verb would refuse, in `waiveFinding`'s order: the finding id and `--file` have the
|
|
873
|
+
* forms `waiveFinding` takes (`checkFindingId`, `checkFileForm`: `usage`, the same helpers); the
|
|
874
|
+
* block is in the run (`unknown_block`); the block owns the file
|
|
875
|
+
* (`waive-not-owned`, the reopen's ownership rule through `ownsAt`); and, except for the proof
|
|
876
|
+
* finding (`proof`, which no review row names), a review row of this run and block for the file
|
|
877
|
+
* names the id (`waive-unknown-finding`): `review.triage` / `review.late_finding` `finding`, a
|
|
878
|
+
* `review.result` `open` id (as the gate reads it, `openFindings`) or a `review.cap` `open` id.
|
|
879
|
+
* @param {Record<string, any>} record @param {string} runId @param {string} id
|
|
880
|
+
* @param {string} finding @param {string} file
|
|
881
|
+
* @returns {Promise<void>}
|
|
882
|
+
* @throws {StateError}
|
|
883
|
+
*/
|
|
884
|
+
async function waivePrecheck(record, runId, id, finding, file) {
|
|
885
|
+
checkFindingId(finding);
|
|
886
|
+
checkFileForm(file, { normalized: false });
|
|
887
|
+
const entry = record.blocks?.[id];
|
|
888
|
+
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
889
|
+
if (!(await blockOwnsFile(record, entry, file))) throw new StateError('waive-not-owned', `waive-not-owned: block ${id} does not own ${file}; nothing was recorded`);
|
|
890
|
+
if (finding === PROOF_FINDING) return;
|
|
891
|
+
const named = (await readAllRows(record.project)).some((r) => {
|
|
892
|
+
if (r?.run !== runId || r.block !== id || r.file !== file) return false;
|
|
893
|
+
if (r.event === 'review.triage' || r.event === 'review.late_finding') return r.finding === finding;
|
|
894
|
+
if (r.event === 'review.result') return Array.isArray(r.open) && openFindings(r.open).some((o) => o.id === finding);
|
|
895
|
+
if (r.event === 'review.cap') return Array.isArray(r.open) && r.open.includes(finding);
|
|
896
|
+
return false;
|
|
897
|
+
});
|
|
898
|
+
if (!named) throw new StateError('waive-unknown-finding', `waive-unknown-finding: no review row of block ${id} names finding ${finding} in ${file}; nothing was recorded`);
|
|
899
|
+
}
|
|
900
|
+
|
|
761
901
|
/**
|
|
762
902
|
* B59: the rules lane for a brief-mode block — `fallbackLane` over the facts of its owned entries,
|
|
763
903
|
* the `--lines` estimate and the snapshot's `proof.tiers.high.paths` (none for a record from
|
package/src/cli/jev.mjs
CHANGED
|
@@ -23,11 +23,17 @@
|
|
|
23
23
|
* subfolder thus writes to the project's ledger. An unknown flag is a usage error.
|
|
24
24
|
*
|
|
25
25
|
* Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
|
|
26
|
-
* Wave 1 module to import a block in an earlier batch statically)
|
|
26
|
+
* Wave 1 module to import a block in an earlier batch statically) with the project's
|
|
27
|
+
* `keys.cache_hours` (B84; an invalid value warns once and uses 8 h), asks Jev, runs the answer
|
|
27
28
|
* through `thresholds.decide()`, and appends one `{event: 'decision', ...}` row through B6's
|
|
28
29
|
* writer (also an earlier batch) — the shape `ledger/calibration.mjs` already expects:
|
|
29
30
|
* `decision_id`, `question`, `confidence` (plan §6.3's calibration buckets read exactly these).
|
|
30
31
|
*
|
|
32
|
+
* B85: when no key resolved because the cached one expired and 1Password did not answer, and the
|
|
33
|
+
* reference is `op://…`, the generic "no Jev key resolved" line is replaced by one re-link line
|
|
34
|
+
* naming the expired time and the exact `code-forge keys set jev --op <ref>` command (the
|
|
35
|
+
* 1Password reasons still print first; same text with or without a TTY).
|
|
36
|
+
*
|
|
31
37
|
* Never prints the key: it only ever reaches the `Authorization` header inside `askJev`, and
|
|
32
38
|
* `resolveKey` has already registered it with `redact()` before this module sees it.
|
|
33
39
|
*/
|
|
@@ -40,7 +46,7 @@ import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../de
|
|
|
40
46
|
import { askJev as realAskJev } from '../decide/jev-client.mjs';
|
|
41
47
|
import { decide } from '../decide/thresholds.mjs';
|
|
42
48
|
import { resolveDispatchQuestionsByRules } from '../decide/fallback-rules.mjs';
|
|
43
|
-
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
49
|
+
import { cacheHoursFrom, createDefaultKeyStore, isTime, resolveKey, shellWord } from '../keys/store.mjs';
|
|
44
50
|
import { appendRow as realAppendRow } from '../ledger/write.mjs';
|
|
45
51
|
import { writeSafe } from '../util/redact.mjs';
|
|
46
52
|
|
|
@@ -113,6 +119,7 @@ function badRuleFacts(state) {
|
|
|
113
119
|
* @param {object} deps
|
|
114
120
|
* @param {import('../keys/store.mjs').KeyStore} [deps.store]
|
|
115
121
|
* @param {typeof loadConfigFile} [deps.loadConfig]
|
|
122
|
+
* @param {typeof import('../keys/onepassword.mjs').opRead} [deps.opRead] - the 1Password reader (tests inject a fake).
|
|
116
123
|
* @param {typeof realAskJev} [deps.askJev]
|
|
117
124
|
* @param {typeof realAppendRow} [deps.appendRow]
|
|
118
125
|
* @param {{write: (s: string) => unknown}} [deps.stdout]
|
|
@@ -126,6 +133,7 @@ export async function runJev(args, deps = {}) {
|
|
|
126
133
|
const {
|
|
127
134
|
store,
|
|
128
135
|
loadConfig = loadConfigFile,
|
|
136
|
+
opRead,
|
|
129
137
|
askJev: ask = realAskJev,
|
|
130
138
|
appendRow: write = realAppendRow,
|
|
131
139
|
stdout = process.stdout,
|
|
@@ -218,9 +226,14 @@ export async function runJev(args, deps = {}) {
|
|
|
218
226
|
|
|
219
227
|
const keyStore = store ?? (await createDefaultKeyStore(env));
|
|
220
228
|
const keyRef = keyRefFlag.value ?? cfg?.keys?.jev;
|
|
229
|
+
// B84: the project's cache time; an invalid one warns once and falls back to the default
|
|
230
|
+
const cache = cacheHoursFrom(cfg);
|
|
231
|
+
if (cache.problem !== null) {
|
|
232
|
+
err(`WARN jev ask: ${cache.problem}; caching for the default ${cache.hours} h\n`);
|
|
233
|
+
}
|
|
221
234
|
let resolved;
|
|
222
235
|
try {
|
|
223
|
-
resolved = await resolveKey('jev', { store: keyStore, ref: keyRef, env, now: now() });
|
|
236
|
+
resolved = await resolveKey('jev', { store: keyStore, ref: keyRef, env, now: now(), cacheHours: cache.hours, ...(opRead ? { opRead } : {}) });
|
|
224
237
|
} catch (thrown) {
|
|
225
238
|
err(`jev ask: ${thrown?.message ?? String(thrown)}\n`);
|
|
226
239
|
return 2;
|
|
@@ -229,6 +242,12 @@ export async function runJev(args, deps = {}) {
|
|
|
229
242
|
for (const reason of resolved.errors) {
|
|
230
243
|
err(`jev ask: ${reason}\n`);
|
|
231
244
|
}
|
|
245
|
+
// `expired` is the dropped entry's exp in ms; anything that is not a valid time gives the
|
|
246
|
+
// generic line
|
|
247
|
+
if (isTime(resolved.expired) && typeof keyRef === 'string' && keyRef.startsWith('op://')) {
|
|
248
|
+
err(`jev ask: the cached jev key expired at ${new Date(resolved.expired).toISOString()} and 1Password did not answer; re-link: code-forge keys set jev --op ${shellWord(keyRef)}\n`);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
232
251
|
err('jev ask: no Jev key resolved (set one with "code-forge keys set jev")\n');
|
|
233
252
|
return 1;
|
|
234
253
|
}
|