@codedology/code-forge 0.15.4 → 0.16.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 +26 -0
- package/README.md +1 -1
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +4 -2
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +14 -5
- package/skill/references/review.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/review-file.mjs +5 -0
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +37 -5
- package/src/decide/escalation.mjs +5 -5
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +20 -4
- package/src/review/packet.mjs +58 -20
- package/src/session/facts.mjs +234 -64
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +17 -5
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +2 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +6 -4
- package/src/worker/queue.mjs +2 -1
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +92 -89
- package/src/worker/worker-warning.mjs +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.16.0] — 2026-11-01
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `forge spawn --print-argv` prints the exact command it would run and runs nothing; the Solo adapter guide step 2 now uses it (B229)
|
|
14
|
+
- `npm run bench:block` measures one block flow under Node and under the Go daemon (process count, peak memory, wall time); each pass runs in its own child process (B184)
|
|
15
|
+
- Forge facts checks the code names in a brief, marks ignored or outside-snapshot paths UNVERIFIABLE (never NOT-FOUND), takes claims from the brief only, maps absolute paths inside any `--sources` root to that source, and handles globs, `/dev/null` and route patterns (B227)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Reviewers get the bodies of the functions the changed code calls, inside the packet budget, so they stop raising false findings about code they could not see (B212)
|
|
20
|
+
- A second stalled file now gets its own L3 patch rung instead of stopping the whole block with `l3_patch_exhausted` (B213)
|
|
21
|
+
- The worker prunes done queue tickets and keeps an index; worker polls and done checks read only new ledger rows instead of the whole ledger each time (B214)
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Worker start times use one format everywhere: `ps` pads days 1-9 with two spaces, so worker pins and route checks failed on those days. Old padded pins still match (B225)
|
|
26
|
+
- `gates run --changed` also runs the tests of files that import a changed file, so a change cannot break callers in other folders unseen (B221)
|
|
27
|
+
- Daemon tests wait for readiness against a deadline instead of fixed sleeps, so they do not fail on a busy machine (B226)
|
|
28
|
+
- `gates run --changed` gives each test runner only its own test files (no more `.go` files passed to `node --test`); when none fit, the gate runs the full suite and reports scope `full` (B203)
|
|
29
|
+
- A gate that hits its time limit now says `TIMEOUT after <n> s (not a test failure)` with the flag to raise it, and test files left unfinished by the timeout are no longer listed as failed tests (B223)
|
|
30
|
+
- `wait-block` no longer reports done while a file's last review was unavailable; it re-queues the file or names it, so `block close` does not refuse it later as unreviewed (B217)
|
|
31
|
+
- `block close` no longer refuses a file that the block's own commits created and then deleted; a missing file with no such history still needs review (B230)
|
|
32
|
+
- `status` warns when a live worker of the run is not the pinned one and names `run start --reattach`, before `block close` can stop blocks it served (B224)
|
|
33
|
+
- CI is green again on Linux: `block rebase` works when git has no user name or email and keeps each commit's committer, and overlapping `watch` ticks for one block no longer lose state (B231)
|
|
34
|
+
|
|
9
35
|
## [0.15.4] — 2026-10-31
|
|
10
36
|
|
|
11
37
|
### Added
|
package/README.md
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codedology/code-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"!scripts/changelog.mjs",
|
|
35
35
|
"!scripts/known-fix.mjs",
|
|
36
36
|
"!scripts/bench-review.mjs",
|
|
37
|
+
"!scripts/bench-block.mjs",
|
|
37
38
|
"!scripts/build-native.mjs",
|
|
38
39
|
"!scripts/compat-golden.mjs",
|
|
39
40
|
"README.md",
|
|
@@ -46,7 +47,8 @@
|
|
|
46
47
|
"release": "node scripts/release.mjs",
|
|
47
48
|
"changelog": "node scripts/changelog.mjs",
|
|
48
49
|
"known-fix": "node scripts/known-fix.mjs",
|
|
49
|
-
"bench:review": "node scripts/bench-review.mjs"
|
|
50
|
+
"bench:review": "node scripts/bench-review.mjs",
|
|
51
|
+
"bench:block": "node scripts/bench-block.mjs"
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
52
54
|
"@clack/prompts": "1.8.1",
|
package/schema/facts.schema.json
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"properties": {
|
|
13
13
|
"fact_id": { "type": "string", "pattern": "^F[0-9]+$" },
|
|
14
14
|
"claim": { "type": "string", "minLength": 1 },
|
|
15
|
-
"kind": { "type": "string", "enum": ["flag", "command", "path", "version", "endpoint", "package", "env"] },
|
|
15
|
+
"kind": { "type": "string", "enum": ["flag", "command", "path", "version", "endpoint", "package", "env", "symbol"] },
|
|
16
16
|
"command": { "type": "string" },
|
|
17
17
|
"output_excerpt": { "type": "string", "maxLength": 200 },
|
|
18
18
|
"tag": { "type": "string", "enum": ["VERIFIED", "NOT-FOUND", "UNVERIFIABLE"] },
|
package/skill/SKILL.md
CHANGED
|
@@ -32,7 +32,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
|
|
|
32
32
|
| coder | the lane S1 picks (L0–L2); climbs one rung per escalation | the project tree | the engine adapter |
|
|
33
33
|
| reviewer, recheck | L2 | closed-book, empty cwd, packet on stdin | the worker |
|
|
34
34
|
| judge, S2, author | L3 | closed-book, one turn (never Codex: `references/security.md` §4) | the worker, `forge s2`, `forge author` |
|
|
35
|
-
| L3 rung | L3 | a patch ≤ 80 lines, once per
|
|
35
|
+
| L3 rung | L3 | a patch ≤ 80 lines, once per file | `forge spawn --level L3 --role coder` |
|
|
36
36
|
|
|
37
37
|
`forge resolve L<n>` prints what a level resolves to (provider, model, effort, fallback); `forge models` lists the catalog. With `review.multimodel` on, the second reviewer is another provider's L2 and the judge comes from a third provider.
|
|
38
38
|
|
|
@@ -16,7 +16,7 @@ Solo adds spawn, watch, timers, scratchpads, locks and `kv` on top of the CLI; i
|
|
|
16
16
|
## §3 Spawn protocol per block
|
|
17
17
|
|
|
18
18
|
1. Pre-flight: `list_processes` and close **only** agents this run spawned (own pids only); `list_agent_tools` to resolve runtime ids live, never from memory. Never more than `caps.coders` coder processes alive; a further spawn waits for a completion wake, never for a timer.
|
|
19
|
-
2. `forge block open <id> --run <r> …` (`code.md` §3), then `spawn_agent(agent_tool_id=<coder runtime>, name="coder-<id>", extra_args=<the argv the level resolves to>)` — the model, effort and permission mode are pinned **at spawn** from `forge resolve L<n>` and the provider's coder flags (`forge spawn --level L<n> --role coder --brief <file
|
|
19
|
+
2. `forge block open <id> --run <r> …` (`code.md` §3), then `spawn_agent(agent_tool_id=<coder runtime>, name="coder-<id>", extra_args=<the argv the level resolves to>)` — the model, effort and permission mode are pinned **at spawn** from `forge resolve L<n>` and the provider's coder flags (`forge spawn --level L<n> --role coder --brief <file> --print-argv` prints the exact argv and runs nothing when you need to see it); never a post-boot model switch. Coders run in the provider's non-interactive auto mode: a session parked at a permission prompt is the first failure mode of every run.
|
|
20
20
|
3. First `send_input` after the `❯` prompt: the agent's instructions, then the pointer (§2).
|
|
21
21
|
4. **Launch-verification gate** — the one thing you babysit: poll `get_process_output` / `search_output` until the coder shows the ACK and tool activity. Dead-idle after the pointer (no prompt on screen) ⇒ one Enter (`send_input bytes=[13]`) and re-check. A **permission or model-picker prompt** is never answered with Enter — it would accept an unreviewed default: `close_process`, respawn with the correct `extra_args`, and record a `spawn-flag defect` line in the block record (the flags came from `forge resolve`; a prompt means they were not pinned).
|
|
22
22
|
5. **Wake channels, event-driven, never fixed polls:** record your own pid once (`whoami` → `kv_set("forge:orchestrator_pid")`); coders that can call Solo carry the push contract (post-sentinel `kv_set("block:<id>", …)` + `timer_set(1s, body, delivery_process_id=<your pid>)`); for every group arm `timer_fire_when_idle_any([coders], max_wait≈20–25 min, delivery_process_id=<your pid>)` right after the launch gate (idle-any ignores already-idle processes, so arm while they are provably running); keep one long dead-man fallback per 30–40 min with an L0 triage first. On wake: sentinel ⇒ gate; no sentinel and active again ⇒ re-arm; no sentinel and idle ⇒ parked, intervene now.
|
|
@@ -36,7 +36,7 @@ No key ⇒ a banner at run start; 401 ⇒ stop; 422 ⇒ stop with the request id
|
|
|
36
36
|
3a. **Heavy rounds:** below L2, `escalation.after_rounds_with_warnings` (default 1) rounds at the current level whose open `fix_now` set holds ≥ `escalation.warning_threshold` (default 2) warnings or any `critical` ⇒ +1 level at once (`review_warnings`). A cheap coder that fails a round with several warnings climbs; it never spends three rounds at the same level. At L2 it does not fire — the L3 rung keeps its own triggers (rules 2, 3, 6). `after_rounds_with_warnings: 0` turns it off.
|
|
37
37
|
4. **Security floor, at dispatch:** `security_sensitive` ⇒ first-attempt level `min(max(lane, L1) + 1, L2)` (`security`). Then the **docs floor**: a `docs` or `contract` block (below) is never dispatched under `levels.coder_floor_docs` (default L1) — `forge block open` raises the level and the `dispatch` row carries `requested_level` and `trigger: docs_floor`. L0 never codes docs or contracts.
|
|
38
38
|
5. **S2 ruling:** S1 `next = escalate` is never acted on directly — it goes to S2 as a check (`s2_ruling`); S1 `next = stop` ⇒ the human.
|
|
39
|
-
6. **Review cap:** `review.max_rounds_per_file` reached on a file with an open `fix_now` ⇒ the L3 rung for that file, once per
|
|
39
|
+
6. **Review cap:** `review.max_rounds_per_file` reached on a file with an open `fix_now` ⇒ the L3 rung for that file, once per file, always as a patch whatever `escalation.l3_mode` says (`review_cap`); checked last, only when no rule above returned an action this attempt.
|
|
40
40
|
|
|
41
41
|
**Block kind.** `forge block open --kind code|docs|contract` declares it; without the flag it is detected from the owned paths: every path ends in `.md .mdx .markdown .rst .adoc` ⇒ `docs` (`.txt` is not docs: `requirements.txt`); every path is docs or a contract (`*.schema.json|yaml|yml`, `.proto`, `.graphql`, `.gql`, `.avsc`, `*.openapi.json|yaml|yml`, or a `.json/.yaml/.yml` under a `schema/`, `schemas/`, `contract/` or `contracts/` directory) and one is a contract ⇒ `contract`; anything else ⇒ `code`. A glob is judged by its literal ending, so a block owning `docs/**` declares its kind. The kind is recorded in the run record and drives the docs floor (rule 4) and the review topology (`review.md` §2).
|
|
42
42
|
|
package/skill/references/plan.md
CHANGED
|
@@ -4,14 +4,23 @@
|
|
|
4
4
|
|
|
5
5
|
Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.facts.md` before anything else.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Claims come from the brief only. `--sources <path>` chooses where checks run. Source text adds no claims.
|
|
8
8
|
|
|
9
9
|
What it does:
|
|
10
10
|
|
|
11
|
-
1.
|
|
12
|
-
2. One L0
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
1. The CLI extracts claim tokens from the brief: flags, commands, paths, versions, endpoints, packages and environment names. Whole code spans also name `symbol` claims: PascalCase types with two or more humps, snake_case identifiers and code file names such as `SigmaClient.php`. A prose word such as Model adds no symbol. Command prefixes still come from the brief and the project's `package.json` bin names. For example, `run reload` becomes `code-forge run reload` when the brief names that project CLI. A known CLI or a CLI named separately in the brief keeps its own name.
|
|
12
|
+
2. One L0 delegate checks a read-only snapshot. The project uses its git HEAD tree plus sources inside it. Each outside source directory appears under `_sources/<label>/`. Its label is its folder name; repeated names get `-2`, `-3`. Git sources use HEAD, limited to the named subfolder. Non-git sources use a copy. An outside source file appears at `_sources/<label>/<file name>`. Symlinks and secret files are dropped. Files and directories are sealed. The snapshot is removed when the session ends.
|
|
13
|
+
|
|
14
|
+
Absolute paths inside a root map to its snapshot path. Relative paths get one row per root when several roots are present. Flags and symbols also get a row per root. These rows have a `source` label. The sheet adds a `source` column after `kind` when any row has that label. A single-root relative claim keeps the old row format. Routes such as `/api/v1/*`, HTML fragments such as `/h1`, and `/dev/null` are not claims. An absolute path outside every source root and the tool roots is UNVERIFIABLE when its first directory exists on this machine.
|
|
15
|
+
|
|
16
|
+
Before the packet is sent, one `git check-ignore` batch checks each root's paths. Ignored paths are UNVERIFIABLE: `git-ignored in <label>: the snapshot leaves ignored paths out`. A path present on disk but absent from the snapshot is UNVERIFIABLE: `not at HEAD in <label>: outside the snapshot`. Neither goes to the delegate. Sources inside the project may still supply untracked files to the snapshot.
|
|
17
|
+
|
|
18
|
+
The delegate runs one read-only command per row and quotes its literal output. A flag needs `grep -rn -- <flag> <source folder>`. An identifier symbol needs `grep -rn -- 'TenantSubscription' app` or `grep -rn -- 'class TenantSubscription' app`. The quoted pattern is the token alone or one of `class interface trait enum struct type function func def` followed by the token. VERIFIED needs a `file:line:text` source-file hit holding the whole token. Markdown, text, docs, plans, tests, fixtures and the brief do not count. Symbols search one whole top-level source folder. A flag may use a nested source folder for VERIFIED; NOT-FOUND needs a whole top-level folder. Top-level means directly below a root: `src` or `_sources/<label>/app`. `_sources` itself never counts.
|
|
19
|
+
|
|
20
|
+
A file-name symbol needs `find <root> -name SigmaClient.php`, where the root is `.` or `_sources/<label>`. VERIFIED needs an output path ending in `/SigmaClient.php`. A glob path needs `find lang/es -name '*.php'`. Its pattern is quoted. Only that claim may also use `ls -d lang/es/*.php`, quoted or unquoted. VERIFIED needs a matching output path. NOT-FOUND needs `No such file` output. Unquoted globs on other claims are refused.
|
|
21
|
+
|
|
22
|
+
The session gets the shell tool with write deny rules and provider restrictions. Node validates the reported command afterward. Allowed reads include `ls cat head tail wc grep stat file uname sw_vers jq which`, `command -v`, the restricted `find` predicates, `git log|show|status|rev-parse|ls-files|cat-file`, `npm view|ls`, `test -e|-f|-d|-n`, and a single-URL HEAD `curl` request. No pipes or command lists. No program runs with `--help`, `-h` or `--version`. Versions use `npm view`, `which` or `ls`. Environment claims use only `printenv NAME >/dev/null`; their excerpts are blanked. Backticks, `$`, other redirections, `~`, `..`, secret paths and absolute paths outside `/usr`, `/opt/homebrew`, `/bin` are refused. Root paths in the brief use their mapped snapshot paths for checks. `npm view` and `curl -I` may reach the network.
|
|
23
|
+
3. Node validates the closed delegate schema. A bad proof or an empty VERIFIED excerpt downgrades that row to UNVERIFIABLE with a reason. It does not count. Source labels are added by Node. Every claim, command, excerpt and reason passes through the redactor. The rendered sheet records the brief's sha and mtime.
|
|
15
24
|
|
|
16
25
|
**Do not read the sources yourself; read the sheet.** Your context is spent on design, never on `--help` output. A fact is never a model's opinion: every claim carries the command that produced it.
|
|
17
26
|
|
|
@@ -107,7 +107,7 @@ Only real rechecks count toward the round cap, per-level ladder and stall rules
|
|
|
107
107
|
2. **Round n ≥ 2 reviews only the fix hunks**: the diff between the content hash reviewed in round n−1 and the current content, ± `review.context.hunk_context_lines`, plus the open findings by id (`review.recheck_scope: fix_hunks`, the default; `file` re-sends the whole packet for a consumer that wants it). The `recheck` lens answers `resolved[]` per finding — S1 `resolved` at or above `thresholds.findings.resolved` closes it, else the fresh L2 recheck decides — and may raise a new finding **only inside a fix hunk**; a finding outside becomes a `late_finding`, ruled at block close (`review.late_findings: sweep`; `block` makes it block the round).
|
|
108
108
|
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.
|
|
109
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.
|
|
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
|
|
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 file** 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.
|
|
111
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):
|
|
112
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).
|
|
113
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.
|
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.16.0"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -8,15 +8,21 @@
|
|
|
8
8
|
| brief sha | `<sha256>` |
|
|
9
9
|
| brief mtime | `<iso-8601>` |
|
|
10
10
|
| built at | `<iso-8601>` |
|
|
11
|
-
| delegate | L0, read-only tools,
|
|
11
|
+
| delegate | L0, read-only tools, sealed source snapshot |
|
|
12
12
|
| claims | `<n>` — VERIFIED `<v>` · NOT-FOUND `<nf>` · UNVERIFIABLE `<u>` |
|
|
13
13
|
|
|
14
14
|
## Claims
|
|
15
15
|
|
|
16
16
|
| fact id | claim | kind | command | output excerpt | tag |
|
|
17
17
|
|---|---|---|---|---|---|
|
|
18
|
-
| F1 | `<claim token as written in the brief>` | flag \| command \| path \| version \| endpoint \| package \| env | `<the read-only command that was run>` | `<≤ 200 characters of its output>` | VERIFIED |
|
|
18
|
+
| F1 | `<claim token as written in the brief>` | flag \| command \| path \| symbol \| version \| endpoint \| package \| env | `<the read-only command that was run>` | `<≤ 200 characters of its output>` | VERIFIED |
|
|
19
19
|
| F2 | … | … | … | `<why it could not be checked>` | UNVERIFIABLE |
|
|
20
20
|
| F3 | … | … | … | `<the empty or negative output>` | NOT-FOUND |
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Claims come from the brief only. `--sources` chooses roots for checks. Outside roots appear under `_sources/<label>/`. Rows with a root label have a `source` field. When present, the table adds a `source` column after `kind`. Single-root relative rows omit it.
|
|
23
|
+
|
|
24
|
+
A `symbol` is a whole code span naming a PascalCase type, snake_case identifier or code file name. Identifiers use `grep -rn -- 'TenantSubscription' app` or a quoted keyword and token such as `'class TenantSubscription'`. VERIFIED needs a source-file `file:line:` hit holding the whole token. NOT-FOUND needs a whole top-level source folder. File names use `find . -name SigmaClient.php` (or `_sources/<label>` as the root). VERIFIED needs a path ending in `/SigmaClient.php`.
|
|
25
|
+
|
|
26
|
+
Ignored paths are UNVERIFIABLE with `git-ignored in <label>: the snapshot leaves ignored paths out`. Untracked paths absent from the snapshot are UNVERIFIABLE with `not at HEAD in <label>: outside the snapshot`. Neither is sent to the delegate. Routes and `/dev/null` add no claims. Glob paths use a quoted `find -name` pattern or `ls -d` of the exact glob.
|
|
27
|
+
|
|
28
|
+
Every command is read-only. A bad proof downgrades its row to UNVERIFIABLE. A VERIFIED tag needs a non-empty excerpt. A tag is never inferred. An acceptance clause in the plan cites a fact by id (`F<n>`). A clause whose claim is NOT-FOUND or UNVERIFIABLE must appear in the plan's "Acceptance clauses the facts sheet cannot back" list with a tolerance naming a block, or `forge plan check` refuses it.
|
package/src/cli/review-file.mjs
CHANGED
|
@@ -694,6 +694,11 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, print,
|
|
|
694
694
|
say({ block, files, done: false, reason: 'split_required', next: `The owner raises review.budgets for ${split.file}, runs run reload, then review-file ${split.file} --fresh.` });
|
|
695
695
|
return 0;
|
|
696
696
|
}
|
|
697
|
+
const unavailable = files.find((f) => f.outcome === 'unavailable');
|
|
698
|
+
if (live && unavailable) {
|
|
699
|
+
say({ block, files, done: false, reason: 'review_unavailable', next: `review-file ${unavailable.file} --fresh re-runs it; if --fresh refuses, block reopen ${block} --run ${run} --file ${unavailable.file} --reason <text>` });
|
|
700
|
+
return 0;
|
|
701
|
+
}
|
|
697
702
|
if (live && files.every((f) => f.status === 'done' || f.status === 'findings' || f.status === 'excluded')) {
|
|
698
703
|
say({ block, files, done: true });
|
|
699
704
|
return 0;
|
package/src/cli/spawn.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge spawn --level L<n> --role <role> --brief <file> [--schema <file>] [--cwd <dir>]
|
|
3
|
-
* [--run <id>] [--block <id>] [--timeout <s>] [--background]` (plan §5.2; block B9a).
|
|
3
|
+
* [--run <id>] [--block <id>] [--timeout <s>] [--background] [--print-argv]` (plan §5.2; block B9a).
|
|
4
4
|
*
|
|
5
5
|
* Runs one isolated session through `src/session/spawn.mjs` and prints its result as one JSON line
|
|
6
6
|
* on stdout; the `level=… provider=… model=… effort=… fallback_step=…` line goes to stderr.
|
|
7
|
+
* With `--print-argv`, prints the first step's argv and metadata instead of launching a session.
|
|
7
8
|
* Exit codes: 0 ok / started, 1 failed (timeout, bad output, refused), 2 usage, 3 unavailable
|
|
8
9
|
* (every step of the ladder reported unavailability).
|
|
9
10
|
*
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
import { readFile } from 'node:fs/promises';
|
|
18
19
|
import path from 'node:path';
|
|
19
20
|
import { loadProjectConfig, projectRootFor, slugFor } from '../config/load.mjs';
|
|
20
|
-
import { ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
|
|
21
|
+
import { previewArgv, ROLES, runRootFor, SessionError, spawnSession } from '../session/spawn.mjs';
|
|
21
22
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
22
23
|
import { StateError } from '../state/paths.mjs';
|
|
23
24
|
import { readRun } from '../state/run.mjs';
|
|
@@ -43,7 +44,7 @@ async function readJSONFlag(file, flag) {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
const USAGE = `usage: code-forge spawn --level L0|L1|L2|L3 --role ${ROLES.join('|')} --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <seconds>] [--background]\n`;
|
|
47
|
+
const USAGE = `usage: code-forge spawn --level L0|L1|L2|L3 --role ${ROLES.join('|')} --brief <file> [--schema <file>] [--cwd <dir>] [--run <id>] [--block <id>] [--timeout <seconds>] [--background] [--print-argv]\n`;
|
|
47
48
|
|
|
48
49
|
/**
|
|
49
50
|
* @param {{status: string}} result
|
|
@@ -93,8 +94,8 @@ export async function runSpawn(args, deps = {}) {
|
|
|
93
94
|
const stdout = deps.stdout ?? process.stdout;
|
|
94
95
|
const stderr = deps.stderr ?? process.stderr;
|
|
95
96
|
try {
|
|
96
|
-
const { flags, positionals } = parseFlags(args, { values: ['level', 'role', 'brief', 'schema', 'cwd', 'run', 'block', 'timeout'], booleans: ['background'] });
|
|
97
|
-
if (positionals.length > 0 || typeof flags.level !== 'string' || typeof flags.role !== 'string' || typeof flags.brief !== 'string') {
|
|
97
|
+
const { flags, positionals } = parseFlags(args, { values: ['level', 'role', 'brief', 'schema', 'cwd', 'run', 'block', 'timeout'], booleans: ['background', 'print-argv'] });
|
|
98
|
+
if (positionals.length > 0 || typeof flags.level !== 'string' || typeof flags.role !== 'string' || typeof flags.brief !== 'string' || (flags['print-argv'] && flags.background)) {
|
|
98
99
|
writeSafe(stderr, USAGE);
|
|
99
100
|
return 2;
|
|
100
101
|
}
|
|
@@ -111,23 +112,26 @@ export async function runSpawn(args, deps = {}) {
|
|
|
111
112
|
const runRootDir = typeof flags.run === 'string' ? runRootFor(flags.run, loaded.config?.tmp?.root) : undefined;
|
|
112
113
|
// B93: only a coder reads coder.sandbox, so only a coder session is held to the run-start value
|
|
113
114
|
const cfg = typeof flags.run === 'string' && flags.role === 'coder' ? await checkRunSandbox(loaded.config, flags.run) : loaded.config;
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
{ ...deps, stderr }
|
|
130
|
-
|
|
115
|
+
const opts = {
|
|
116
|
+
cfg,
|
|
117
|
+
level: /** @type {any} */ (flags.level),
|
|
118
|
+
role: /** @type {any} */ (flags.role),
|
|
119
|
+
promptPath: path.resolve(flags.brief),
|
|
120
|
+
cwd: typeof flags.cwd === 'string' ? path.resolve(flags.cwd) : undefined,
|
|
121
|
+
schema,
|
|
122
|
+
timeoutMs: timeout === undefined ? undefined : timeout * 1000,
|
|
123
|
+
background: flags.background === true,
|
|
124
|
+
runRoot: runRootDir,
|
|
125
|
+
run: typeof flags.run === 'string' ? flags.run : undefined,
|
|
126
|
+
block: typeof flags.block === 'string' ? flags.block : undefined,
|
|
127
|
+
slug: slugFor(cfg, root),
|
|
128
|
+
};
|
|
129
|
+
if (flags['print-argv']) {
|
|
130
|
+
const preview = await previewArgv(opts, { ...deps, stderr });
|
|
131
|
+
writeSafe(stdout, `${JSON.stringify(preview)}\n`);
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
const result = await spawnSession(opts, { ...deps, stderr });
|
|
131
135
|
const { text: _text, ...shown } = /** @type {Record<string, any>} */ (result);
|
|
132
136
|
writeSafe(stdout, `${JSON.stringify(shown)}\n`);
|
|
133
137
|
return exitCodeFor(result);
|
package/src/cli/status.mjs
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Text: at most {@link STATUS_MAX_LINES} lines per run, runs separated by one blank line:
|
|
17
17
|
* run <id> · <status> · <plan|brief> · spent $<x>[ of $<budget>]
|
|
18
|
-
* <id> <state> <level> · <model or -> · gate <pass|fail|none>[ (<gate>)] · findings <n>
|
|
18
|
+
* <id> <state> <level> · <model or -> · gate <pass|fail|timeout|none>[ (<gate>)] · findings <n>
|
|
19
19
|
* … +<k> more blocks (only when the open blocks do not fit)
|
|
20
20
|
* closed <a> · stopped <b> (only when any exist; printed last, but block lines are
|
|
21
21
|
* dropped first to keep it inside the cap)
|
|
22
|
-
* The gate field is `gate pass`, `gate fail (<gate>)`, `gate
|
|
22
|
+
* The gate field is `gate pass`, `gate fail (<gate>)`, `gate timeout (<gate>)`, `gate fail`
|
|
23
|
+
* (no gate named) or `gate none`.
|
|
23
24
|
* `<x>` is `run status`'s spent_usd with 2 decimals, followed by `+?` when some session has no
|
|
24
25
|
* known price (`run status`'s unknown_usd_sessions > 0); the whole amount is `$?` when the ledger
|
|
25
26
|
* cannot be read. A run record with no status shows `-`.
|
|
@@ -58,6 +59,7 @@ import { configInForce } from '../state/config-snapshot.mjs';
|
|
|
58
59
|
import { parseFlags } from '../state/cli-args.mjs';
|
|
59
60
|
import { runsDir, StateError } from '../state/paths.mjs';
|
|
60
61
|
import { readRun } from '../state/run.mjs';
|
|
62
|
+
import { unpinnedWorkerWarnings } from '../worker/worker-warning.mjs';
|
|
61
63
|
import { daemonRequest } from '../util/native.mjs';
|
|
62
64
|
import { writeSafe } from '../util/redact.mjs';
|
|
63
65
|
|
|
@@ -67,7 +69,8 @@ const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
|
|
|
67
69
|
const STATUS_MAX_LINES = 10;
|
|
68
70
|
|
|
69
71
|
/**
|
|
70
|
-
* @typedef {import('../state/brief-status.mjs').
|
|
72
|
+
* @typedef {Omit<import('../state/brief-status.mjs').BlockStatus, 'gate'> & {gate: {result: 'pass'|'fail'|'timeout'|'none', name: string | null}}} BlockStatus
|
|
73
|
+
* @typedef {Omit<import('../state/brief-status.mjs').RunStatus, 'blocks'> & {blocks: BlockStatus[], awaiting_human_check?: string[]}} RunStatus
|
|
71
74
|
*/
|
|
72
75
|
|
|
73
76
|
/** @param {string} p @returns {Promise<string>} */
|
|
@@ -79,6 +82,31 @@ async function realOr(p) {
|
|
|
79
82
|
}
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/** Recover timeout reasons from the last gate run, including older rows without timed_out.
|
|
86
|
+
* @param {RunStatus} summary @param {Array<Record<string, any>> | null} rows
|
|
87
|
+
* @returns {RunStatus}
|
|
88
|
+
*/
|
|
89
|
+
function timeoutStatus(summary, rows) {
|
|
90
|
+
const pending = new Set(), timedOut = new Set();
|
|
91
|
+
for (const row of rows ?? []) {
|
|
92
|
+
if (row.run !== summary.run_id || typeof row.block !== 'string') continue;
|
|
93
|
+
if (row.event === 'gate.red') {
|
|
94
|
+
if (row.reason === 'timeout' || row.timed_out === true) {
|
|
95
|
+
pending.add(row.block);
|
|
96
|
+
timedOut.add(row.block);
|
|
97
|
+
} else {
|
|
98
|
+
pending.delete(row.block);
|
|
99
|
+
timedOut.delete(row.block);
|
|
100
|
+
}
|
|
101
|
+
} else if (row.event === 'gate.done') {
|
|
102
|
+
if (row.all_ok === true || !pending.has(row.block)) timedOut.delete(row.block);
|
|
103
|
+
pending.delete(row.block);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { ...summary, blocks: summary.blocks.map((block) => block.gate.result === 'fail' && timedOut.has(block.id)
|
|
107
|
+
? { ...block, gate: { ...block.gate, result: 'timeout' } } : block) };
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/**
|
|
83
111
|
* The status data of one run.
|
|
84
112
|
* @param {Record<string, any>} record
|
|
@@ -114,8 +142,8 @@ export async function statusOf(record, cachedRows) {
|
|
|
114
142
|
else byBlock.set(r.block, [r]);
|
|
115
143
|
}
|
|
116
144
|
const awaiting = Object.keys(record.blocks ?? {}).filter((id) => record.blocks[id].status === 'awaiting_human_check').sort();
|
|
117
|
-
return { ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
|
|
118
|
-
...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
|
|
145
|
+
return timeoutStatus({ ...briefStatus({ record, rows, budget, findingsOf: (block) => openFindingsOf({ block, runId, mine: byBlock.get(block) ?? [], rows: all, key }) }),
|
|
146
|
+
...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) }, rows);
|
|
119
147
|
}
|
|
120
148
|
|
|
121
149
|
/** Restore the Node renderer's insertion order after Go's sorted JSON map encoding.
|
|
@@ -231,7 +259,11 @@ export default async function status(args) {
|
|
|
231
259
|
const answer = await daemonRequest(String(record.run_id), { method: 'status' }, { config });
|
|
232
260
|
let summary;
|
|
233
261
|
try { summary = answer ? daemonStatus(answer.runs[0]) : null; } catch { summary = null; }
|
|
262
|
+
if (summary?.blocks.some((block) => block.gate.result === 'fail')) {
|
|
263
|
+
summary = timeoutStatus(summary, await readAllRows(record.project).catch(() => null));
|
|
264
|
+
}
|
|
234
265
|
shown.push(summary ?? await statusOf(record));
|
|
266
|
+
for (const warning of await unpinnedWorkerWarnings(record)) err(`${warning}\n`);
|
|
235
267
|
}
|
|
236
268
|
if (json) {
|
|
237
269
|
out(`${JSON.stringify({ runs: shown })}\n`);
|
|
@@ -220,13 +220,13 @@ export function runningCeiling(stopAt = 'L3') {
|
|
|
220
220
|
|
|
221
221
|
/**
|
|
222
222
|
* Rules 1, 2b and 2 share this: below the running ceiling ⇒ +1 level (never to L3); at the
|
|
223
|
-
* running ceiling ⇒ rule 5, the
|
|
223
|
+
* running ceiling ⇒ rule 5, the file's one L3 rung (R1), then `stop`.
|
|
224
224
|
* @param {'retries'|'review_rounds'|'review_stall'|'review_warnings'} trigger
|
|
225
225
|
* @param {string} currentLevel
|
|
226
226
|
* @param {string} stopAt
|
|
227
227
|
* @param {'patch'|'code'} l3Mode
|
|
228
|
-
* @param {boolean} l3RungAlreadyUsed - the L3 rung was already used once on THIS
|
|
229
|
-
* EITHER mode and from either rule 5 or rule 6 (one rung per
|
|
228
|
+
* @param {boolean} l3RungAlreadyUsed - the L3 rung was already used once on THIS file, in
|
|
229
|
+
* EITHER mode and from either rule 5 or rule 6 (one rung per file, not per trigger). A second
|
|
230
230
|
* hit always stops.
|
|
231
231
|
* @returns {EscalationResult}
|
|
232
232
|
*/
|
|
@@ -270,8 +270,8 @@ function finalizeEscalate(trigger, currentLevel, stopAt, l3Mode, l3RungAlreadyUs
|
|
|
270
270
|
* @property {string} [stopAt] - `escalation.stop_at`, default `'L3'`.
|
|
271
271
|
* @property {'patch'|'code'} [l3Mode] - `escalation.l3_mode`, default `'patch'` (R1).
|
|
272
272
|
* @property {boolean} [l3RungAlreadyUsed] - the L3 rung (ceiling, rule 5, OR the file's review
|
|
273
|
-
* cap, rule 6) was already used once before on THIS
|
|
274
|
-
* always `stop`s — the plan's L3 rung is one per
|
|
273
|
+
* cap, rule 6) was already used once before on THIS file, from either source. A second hit
|
|
274
|
+
* always `stop`s — the plan's L3 rung is one per file, not one per trigger.
|
|
275
275
|
* @property {number} [roundsAtFile] - rule 6 (v1.3, §4.11): total review rounds run on this file
|
|
276
276
|
* so far, counted across every level — unlike `reviewRoundsAtLevel`, this is never reset by a
|
|
277
277
|
* level escalation.
|