@giamme/forge 0.8.0 → 0.9.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +106 -1766
  3. package/SKILL.md +3 -2
  4. package/VERSION +1 -1
  5. package/docs/README.md +26 -0
  6. package/docs/add-ons.md +33 -0
  7. package/docs/artifacts.md +69 -0
  8. package/docs/commands.md +315 -0
  9. package/docs/dataset.md +20 -0
  10. package/docs/decomposed-runs.md +268 -0
  11. package/docs/development.md +95 -0
  12. package/docs/forge-ui.md +92 -0
  13. package/docs/fractal.md +181 -0
  14. package/docs/install.md +160 -0
  15. package/docs/memory.md +156 -0
  16. package/docs/models.md +118 -0
  17. package/docs/reviews.md +231 -0
  18. package/docs/safety.md +55 -0
  19. package/docs/troubleshooting.md +54 -0
  20. package/forge +5 -0
  21. package/package.json +3 -2
  22. package/references/decompose.md +1 -1
  23. package/references/fractal.md +1 -1
  24. package/references/harnesses.md +18 -6
  25. package/references/solo.md +1 -1
  26. package/scripts/forge-dispatch.sh +7 -1
  27. package/scripts/forge-runs.py +121 -0
  28. package/scripts/forge-runtime.py +59 -6
  29. package/scripts/forge-version.py +54 -12
  30. package/scripts/forge_dataset/harvest.py +1 -1
  31. package/scripts/forge_dataset/importer.py +1 -1
  32. package/scripts/forge_fractal/__init__.py +2 -1
  33. package/ui/README.md +80 -20
  34. package/ui/THIRD_PARTY_NOTICES.md +108 -0
  35. package/ui/docs/api.md +99 -17
  36. package/ui/forge_ui/export.py +16 -6
  37. package/ui/forge_ui/fractal.py +1 -2
  38. package/ui/forge_ui/reader.py +95 -7
  39. package/ui/forge_ui/server.py +10 -1
  40. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  41. package/ui/forge_ui/static/app.js +279 -124
  42. package/ui/forge_ui/static/cloud3d.js +3 -2
  43. package/ui/forge_ui/static/cloudui.js +8 -5
  44. package/ui/forge_ui/static/convo.js +295 -0
  45. package/ui/forge_ui/static/datatable.js +232 -0
  46. package/ui/forge_ui/static/diffview.js +29 -10
  47. package/ui/forge_ui/static/findings3d.js +134 -0
  48. package/ui/forge_ui/static/graph.js +3 -3
  49. package/ui/forge_ui/static/icons.js +94 -0
  50. package/ui/forge_ui/static/index.html +25 -13
  51. package/ui/forge_ui/static/iso.js +70 -19
  52. package/ui/forge_ui/static/leaderboard.js +44 -48
  53. package/ui/forge_ui/static/panel.js +68 -32
  54. package/ui/forge_ui/static/runmodel.js +17 -1
  55. package/ui/forge_ui/static/scene3d.js +518 -43
  56. package/ui/forge_ui/static/search.js +2 -1
  57. package/ui/forge_ui/static/shell.js +137 -0
  58. package/ui/forge_ui/static/stagectl.js +150 -0
  59. package/ui/forge_ui/static/style.css +924 -469
  60. package/ui/forge_ui/static/theme.js +25 -0
  61. package/ui/forge_ui/transcripts.py +608 -0
package/docs/safety.md ADDED
@@ -0,0 +1,55 @@
1
+ # Safety
2
+
3
+ [← README](../README.md) · [All docs](README.md)
4
+
5
+ What forge never does to your branch and working tree, what it refuses to run on, and how it keeps reviewed work and merged work the same.
6
+
7
+ ## Safety guarantees
8
+
9
+ - Solo runs leave source changes uncommitted. Forge creates private review commits, but
10
+ does not commit on the solo source branch, push, or reset the user tree.
11
+ - In a decomposed run, commits and merges happen **only** on the `forge/<run-id>/*` task
12
+ branches and the `forge/<run-id>-integration` branch — branches forge created for itself.
13
+ Never your branch, never a push. Your branch and working tree are untouched for the whole
14
+ run.
15
+ - Bypass flags are never added on forge's own initiative. `--yolo-dwarf` / `--yolo-qa` are the
16
+ only route to an unsandboxed run, so that choice is always explicit and visible.
17
+ - **One dwarf per working tree, always.** Two agents editing one tree interleave their edits
18
+ into a diff neither of them wrote and qa cannot untangle. Parallelism comes from separate
19
+ worktrees, which is why worktrees are mandatory there rather than an optimization.
20
+ - A failed task's branch and worktree are **never** deleted — they're the only record of what
21
+ went wrong and the only thing to retry from. A successful task's worktree is removed only
22
+ after `results.tsv` is written and read back.
23
+ - QA runs in a disposable repository. Mutations to the source or review snapshot invalidate
24
+ acceptance. Disabled editing tools alone do not prevent writes through Bash.
25
+
26
+ ## Preconditions
27
+
28
+ - Not a git repo → decomposed runs refuse. Worktrees are impossible and there's no honest
29
+ fallback.
30
+ - Uncommitted changes → **warned prominently.** Worktrees branch from a commit, so your local
31
+ edits are invisible to every dwarf. This is the likeliest route to a confusingly wrong
32
+ result.
33
+
34
+ ## Execution integrity
35
+
36
+ Forge now captures solo changes against a recorded starting snapshot, including staged edits,
37
+ without altering the user's index. Existing work is recorded separately in `existing.diff`.
38
+ QA receives the full implementation requirements and reviews a disposable repository; source
39
+ or snapshot changes invalidate the result. Only the recorded reviewed task commit can merge.
40
+
41
+ Parallel execution rechecks dependencies before each task and retry. Failed prerequisites leave
42
+ consumers BLOCKED. Directory and child file declarations are serialized.
43
+
44
+ `forge-dispatch.sh doctor --spec sol --role dwarf` validates a selected model recipe using local
45
+ CLI help. Solo and parallel runners preflight the whole selected pipeline before implementation.
46
+ These checks do not establish authentication, billing or live model availability.
47
+
48
+ Use `forge-parallel.sh run <plan> --verify 'your check command'` or put the command in
49
+ `.forge/verify` to verify the combined result. Output and exit status are saved with the plan.
50
+ Integration checks a candidate containing the current user branch before updating it. A missing
51
+ command is reported as UNVERIFIED; a failing configured check blocks integration. The project's
52
+ own test suites that the command does not run are named as warnings (advisory), and a failing
53
+ command is rerun once so a flaky lane is reported rather than failing the run — see
54
+ [verification coverage and flaky reruns](reviews.md#verification-coverage-and-flaky-reruns).
55
+ `integrate` also names any task that is not merged and therefore not part of what it delivers.
@@ -0,0 +1,54 @@
1
+ # Troubleshooting
2
+
3
+ [← README](../README.md) · [All docs](README.md)
4
+
5
+ Symptoms, causes and fixes, from a skill a harness cannot see to a task that will not merge.
6
+
7
+ | Symptom | Cause / fix |
8
+ |---|---|
9
+ | skill missing in **codex** only | frontmatter isn't strict YAML. Codex refuses to load and logs `invalid YAML`; the other four are lenient and load it fine. Note codex's line numbers exclude the opening `---`, so its "line 3" is the file's line 4 |
10
+ | skill missing in **antigravity** | `agy` ignores `~/.gemini/skills` entirely — skills load only inside a plugin. Re-run `forge-install.sh` |
11
+ | antigravity runs an **old version** | `agy plugin install` copies. Re-run `forge-install.sh` after every edit |
12
+ | duplicate entry in **opencode** | opencode auto-scans `~/.claude/`; a `~/.config/opencode/command/forge.md` on top is redundant |
13
+ | agy: `Find command timed out` | missing `--add-dir <repo>` — forge passes it; a hand-built command must too |
14
+ | agy dwarf ends with an error and an empty diff | it tried to run a shell command without yolo. A single denial aborts the run — use `--yolo-dwarf` |
15
+ | dwarf produced no changes | usually an ambiguous prompt it couldn't resolve headlessly. Nobody can answer a question mid-run |
16
+ | task status `ERROR` | worktree creation or a dispatch failed — read `tasks/<id>/dwarf.out` / `qa.out` |
17
+ | task status `UNKNOWN` | QA never emitted a verdict line; read `tasks/<id>/qa.last`. `retry` (or `run --retry-failed N`) asks QA again on the unchanged work — no dwarf runs, no attempt is spent |
18
+ | task status `INFRA`, `run` exits `8` | a quota/usage limit, auth problem, rate limit, network error or empty answer stopped a dispatch — not a task failure, and no attempt was spent. Read `tasks/<id>/infra.txt` (`class=`, `retry_after=`), fix the cause, run the same command again. `run --infra-retries M` waits it out instead. Dependents stay `PENDING`, never `BLOCKED` |
19
+ | a task merged although its reviewer said FAIL | `--qa-threshold` accepted it: every CONFIRMED finding was labelled and milder than the threshold. See `tasks/<id>/known_issues.md` and `qa.gate`; nothing is accepted without an explicit threshold |
20
+ | `accept` exits `3` "changed since it was reviewed" | the branch or worktree moved after QA, so accepting would sign off on unreviewed code. `retry` for a fresh review |
21
+ | `combine` / `split` refuses | the message names why: unmerged task left behind, duplicate ids, locked or dirty plan, a Fractal-backed plan, or a selection that strands a dependent. Nothing was changed |
22
+ | `integrate --final-review` stops with exit `5` | the whole-run reviewer found a blocking defect (or never reached a verdict); findings are in `<plan>/…/final-review/qa.last`. Your branch is untouched. Exit `8` means the reviewer hit a quota/auth stop: run it again |
23
+ | `FLAKY` in the summary, verification `PASS` | the verify command failed once and passed on rerun. Read `verification.log` (the first run) — an intermittent failure can be a real bug. `--verify-retries 0` restores the strict single run |
24
+ | `verify coverage: suite '…' is not run by the verify command` | the project defines a suite your `--verify` skips. Advisory only; add it to the command (or `.forge/verify`) if it should gate the merge |
25
+ | implementer guard notes in the summary | the dwarf committed, switched branch, left processes running, or promised later work. Recorded in `tasks/<id>/guard.txt`; the diff and the reviewer still decide. Leftover processes were stopped |
26
+ | task status `CONFLICT` | QA passed but the merge conflicted — `files` was understated somewhere. Check the run's scope-drift list: it usually names the file |
27
+ | task status `TIMEOUT` | that role exceeded the dispatch timeout and was killed. Raise `--timeout`, or `retry` — the partial edit is still in the worktree |
28
+ | a dispatch never returns | it can't any more: every backend is killed at its timeout (`--timeout`, else `FORGE_TIMEOUT`, else the registry's `timeout` column, else 45 min — 60 min for the default `xhigh` QA/planner effort) |
29
+ | a task won't re-run: "already exists" | a worktree directory was deleted by hand. `run` and `retry` prune stale registrations first, so re-run rather than deleting the branch |
30
+ | a failed task is expensive to redo | it isn't — `forge-parallel.sh retry <dir> <id> [--dwarf <spec>]` re-runs that one task with its reviewer's findings, in the worktree it already has |
31
+ | an interrupted run seems lost | run `run` again; merged tasks are skipped, not re-dispatched |
32
+ | every wave has exactly one task | `files` sets overlap across most tasks; the decomposition isn't actually parallel |
33
+ | dwarves reimplement already-merged work | `goal.txt` or `files` missing, so the capsule carries no useful status |
34
+ | several tasks report "produced no changes" | the decomposition made tasks that weren't independently actionable — use a coarser `--decompose-level`, not more retries |
35
+ | `retry` says "produced no new changes" | the dwarf returned a byte-identical diff, so no reviewer was paid to read the same code twice. Escalate with `--dwarf`, or fix the prompt |
36
+ | a new file's code never reached QA | fixed: untracked files are diffed against `/dev/null`. A hand-built capture using only `git status --porcelain` shows the reviewer a filename and no content |
37
+ | claude dwarf ends "I need your permission to edit" | a permission mode that prompts for Edit. forge uses `acceptEdits` plus an explicit Bash allowance, because `auto` allows shell but prompts for Edit, and `acceptEdits` alone allows Edit but prompts for real shell commands |
38
+ | `.forge/` turns up in a review | a diff capture is missing `':(exclude).forge'` |
39
+ | memory stays empty | nothing durable was learned — the common, correct case. The ledger still has a row per dispatch |
40
+ | a wrong fact keeps reappearing | `memory.md` is rebuilt from the ledger; delete the ledger rows, not the memory line |
41
+ | dwarves build incompatible interfaces | the seam was never agreed — plan the approach, or use `--planner` |
42
+ | Fractal was installed but a new unattended run uses ordinary execution | installation never activates it; pass `--fractal` explicitly |
43
+ | `Fractal selected but unavailable` | run `forge fractal doctor`, then the suggested managed install; resume retains the selected backend |
44
+ | existing run rejects a new backend, pool or limit | selection and bounds are recorded; create a new run directory for changed settings |
45
+ | Fractal reports completion but Forge has not accepted the work | inspect QA, verdict and verification; execution completion does not establish acceptance |
46
+ | Fractal import stops with `source_drift` | the source changed after its snapshot; preserve both versions and inspect the candidate instead of forcing import |
47
+ | Fractal work was paused or its coordinator exited | inspect `status` and `logs`, then use explicit `resume RUN_ID`; retry starts a new attempt |
48
+ | browser dashboard closed while work is running | execution is independent; reopen with `forge fractal open RUN_ID` |
49
+ | costs are unknown or a dollar cap is rejected | the bridge only reports available usage; v1 cannot enforce a spending cap |
50
+
51
+ For per-harness invocation details, effort ladders and known CLI failure modes, see
52
+ [`references/harnesses.md`](../references/harnesses.md). For decomposition mechanics, see
53
+ [`references/decompose.md`](../references/decompose.md). For memory internals, see
54
+ [`references/memory.md`](../references/memory.md).
package/forge CHANGED
@@ -22,6 +22,7 @@ usage() {
22
22
  ' jev optional Jev advice: setup, status, doctor, enable, disable, backtest' \
23
23
  ' dataset every run'"'"'s inputs and outcomes: status, dump, import, export, outcomes, ...' \
24
24
  ' ui forge-ui, the run viewer: open [DIR], status, stop' \
25
+ ' runs this repo'"'"'s recent runs in the terminal: state, verdicts and run dirs' \
25
26
  ' update fast-forward this checkout and refresh every harness installation' \
26
27
  ' version the installed version, and whether a newer release is out' \
27
28
  '' \
@@ -50,6 +51,10 @@ case "${1:-}" in
50
51
  shift
51
52
  exec python3 "$ROOT/scripts/forge-ui.py" "$@"
52
53
  ;;
54
+ runs)
55
+ shift
56
+ exec python3 "$ROOT/scripts/forge-runs.py" "$@"
57
+ ;;
53
58
  update)
54
59
  shift
55
60
  exec /bin/bash "$ROOT/scripts/forge-update.sh" "$@"
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@giamme/forge",
3
- "version": "0.8.0",
4
- "description": "One model builds it. A different model reviews what it actually built.",
3
+ "version": "0.9.0",
4
+ "description": "Multi-model coding with a reviewer that reads the code: plan, build in parallel worktrees, review every real diff, merge only what passes.",
5
5
  "keywords": ["claude-code", "codex", "opencode", "agent-skill", "code-review", "multi-model"],
6
6
  "license": "MIT",
7
7
  "author": "Giamme",
@@ -14,6 +14,7 @@
14
14
  "VERSION",
15
15
  "CHANGELOG.md",
16
16
  "README.md",
17
+ "docs/",
17
18
  "LICENSE",
18
19
  "registry.tsv",
19
20
  "references/",
@@ -521,7 +521,7 @@ its last message, and to say plainly what it could not run.
521
521
  ## Accepting, splitting, combining and the whole-run review
522
522
 
523
523
  Four subcommands act on plans after tasks have run. Full usage and refusal conditions are in the
524
- [README](../README.md#accepting-a-task-despite-its-reviewer); the mechanics:
524
+ [README](../docs/reviews.md#accepting-a-task-despite-its-reviewer); the mechanics:
525
525
 
526
526
  - **`accept <plan> <task> --reason … --approved`** overrides a `FAIL`/`UNKNOWN` reviewer only
527
527
  when the reviewed commit is still the branch tip and the worktree still matches
@@ -5,7 +5,7 @@ model routing, independent QA of the cumulative diff, verification and integrati
5
5
  Fractal execution completion and Forge acceptance are separate outcomes. Costs are
6
6
  observational, and this integration makes no speed or cost-saving claim.
7
7
 
8
- For a first run, follow the [README walkthrough](../README.md#fractal-execution).
8
+ For a first run, follow the [Fractal guide](../docs/fractal.md).
9
9
  This reference covers the exact selection, routing, lifecycle and inspection contracts.
10
10
 
11
11
  ## Contents
@@ -46,8 +46,8 @@ disables), else the optional `timeout` column of the model's registry row, else
46
46
 
47
47
  **Infrastructure failures exit `8`.** After the backend exits, `forge-runtime.py classify` reads
48
48
  the dispatch log (never `<role>.last`, which is only created afterwards): claude/openclaude/grok
49
- `result` events with `is_error`, codex `turn.failed`/`error` events that nothing followed,
50
- and for the raw-log harnesses (opencode, antigravity) the last 2 KB when the exit was nonzero —
49
+ `result` events with `is_error`, codex `turn.failed`/`error` and opencode `error` events that no
50
+ answer followed, and for the raw-log harness (antigravity) the last 2 KB when the exit was nonzero —
51
51
  looking for strong phrases (usage/session limit, quota, insufficient balance/credit, rate limit,
52
52
  429/529, unauthorized/401/403, invalid API key, not logged in, ECONNRESET/ENOTFOUND/…). An
53
53
  `empty` answer (exit 0, nothing said) is the fifth class. codex's non-fatal reconnect notices
@@ -90,8 +90,9 @@ reason forge validates efforts itself before dispatch.
90
90
 
91
91
  **Prompt delivery uses stdin.** Forge copies the input once (including FIFOs), checks it
92
92
  with a streaming whitespace character class, and runs `codex exec ... - < role.prompt`.
93
- Native review has no custom prompt and uses `/dev/null`. Codex JSONL events and Claude
94
- `--output-format json` provide native usage; final responses remain in `<role>.last`.
93
+ Native review has no custom prompt and uses `/dev/null`. Codex JSONL events, Claude and openclaude
94
+ `--output-format stream-json` and opencode `--format json` provide native usage; final responses
95
+ remain in `<role>.last`.
95
96
  See the official [Codex non-interactive guide](https://learn.chatgpt.com/docs/non-interactive-mode)
96
97
  and [Claude headless guide](https://code.claude.com/docs/en/headless).
97
98
 
@@ -109,12 +110,18 @@ and `--approve-for-me` additionally conflicts with an explicit `-s/--sandbox`.
109
110
  ## claude / openclaude
110
111
 
111
112
  ```
112
- <harness> -p --model <model> --add-dir <run-dir> --effort <effort> \
113
+ <harness> -p --model <model> --add-dir <run-dir> --output-format stream-json --verbose --effort <effort> \
113
114
  (--permission-mode acceptEdits --allowedTools Bash | --dangerously-skip-permissions)
114
115
  [--disallowed-tools "Edit,Write,NotebookEdit"]
115
116
  ```
116
117
  with the prompt delivered on **stdin**.
117
118
 
119
+ **The log is a stream.** `--output-format stream-json` (which `-p` accepts only with `--verbose`)
120
+ writes one JSON message per line: the session, every assistant message and tool call, every tool
121
+ result, and a closing `result` line with the final answer and the token counts, which is what
122
+ forge reads into `<role>.last` and the metrics. The rest is the run's conversation, which forge-ui
123
+ shows. Hook and rate-limit events in the stream are ignored.
124
+
118
125
  **The prompt must not go on argv when a variadic flag precedes it.** `--disallowed-tools`,
119
126
  `--allowed-tools` and friends are variadic, so a following positional is consumed as
120
127
  another value. In practice the prompt is parsed word by word into tool names — you get a
@@ -162,7 +169,7 @@ Claude-Code-shaped harness. That is the point of `sol:xhigh:openclaude`.
162
169
  ## opencode
163
170
 
164
171
  ```
165
- opencode run -m <provider>/<model> --dir <repo> --variant <effort> --auto [--agent plan] "<prompt>"
172
+ opencode run -m <provider>/<model> --dir <repo> --variant <effort> --auto [--agent plan] --format json "<prompt>"
166
173
  ```
167
174
 
168
175
  Models are always `provider/model`; `opencode models` lists what is configured.
@@ -195,6 +202,11 @@ agy --model <model> --print-timeout <t> [--effort <e>] --add-dir <repo> --add-di
195
202
  The harness is named `antigravity` in forge; the binary is `agy`. This is how Gemini is
196
203
  reached.
197
204
 
205
+ **Its log stays plain text.** agy has `--output-format stream-json`, but only its closing event
206
+ has been seen (`{"event": "result", "result": {"status", "response", "error", "usage", ...}}`, on a
207
+ lapsed login), not the stream of a working run, and the final answer would come from it. Until
208
+ then forge keeps agy's printed output, which is both its log and its answer.
209
+
198
210
  **`--print` must use the attached `=` form.** It is a Go flag with an optional value, so
199
211
  written detached it consumes whatever token follows as its prompt. `agy --print
200
212
  --print-timeout 120s --model X "real prompt"` fails with *"--print took --print-timeout as
@@ -48,7 +48,7 @@ nothing was judged — fix the cause and run again; a dwarf that returns nothing
48
48
  nothing is reported as 8, not 5). The default dispatch timeout is 2700 seconds, scaled up for
49
49
  `xhigh`/`max` effort (so the default QA/planner effort gets 3600), overridable per model in the
50
50
  registry; 0 disables that dispatch timeout, but an enabled Fractal task still has its own attempt
51
- deadline. See [dispatch timeouts](../README.md#scriptsforge-dispatchsh).
51
+ deadline. See [dispatch timeouts](../docs/commands.md#scriptsforge-dispatchsh).
52
52
 
53
53
  The dwarf prompt carries implementer rules (no commits, no background processes, finish every
54
54
  measurement before the last message). forge records what it observes: if the dwarf committed
@@ -405,7 +405,10 @@ case "$HARNESS" in
405
405
  # puts it outside the harness's default reach — without this the agent
406
406
  # refuses to open the very artifacts forge just wrote for it.
407
407
  CMD=("$HARNESS" -p --model "$MODEL" --add-dir "$RUN_DIR")
408
- [ "$HARNESS" = claude ] && CMD+=(--output-format json)
408
+ # One JSON message per line: what the agent said and every tool call, so the run keeps
409
+ # its conversation (forge-ui reads it), ending in the `result` line forge-runtime.py
410
+ # takes the final message and the tokens from. stream-json needs --verbose with -p.
411
+ CMD+=(--output-format stream-json --verbose)
409
412
  [ -n "$EFFORT" ] && CMD+=(--effort "$EFFORT")
410
413
  if [ "$YOLO" = 1 ]; then
411
414
  CMD+=(--dangerously-skip-permissions)
@@ -443,6 +446,9 @@ case "$HARNESS" in
443
446
  # permission prompt that nothing can answer headlessly, so it is always set.
444
447
  CMD+=(--auto)
445
448
  read_only_role && [ "$YOLO" != 1 ] && CMD+=(--agent plan)
449
+ # One JSON event per line (text, tool_use, step_finish, error): the conversation, the
450
+ # final message and the tokens, read by forge-runtime.py and forge-ui.
451
+ CMD+=(--format json)
446
452
  CMD+=("$PROMPT")
447
453
  ;;
448
454
  antigravity)
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ """forge runs [--repo DIR | --all] [--limit N] [--json]: this repo's recent runs, in the terminal.
3
+
4
+ Every solo run, `plan` and `run` registers its run dir (forge_registry.py); this lists them newest
5
+ first, one line each (run id, kind, age, task states, verification and final review, goal) with the
6
+ run dir under it, so you can see what happened without a browser and get the dir to resume, retry
7
+ or open with `forge ui open`. The repo is the Git top level of the current directory unless
8
+ `--repo` names one; `--all` lists the runs of every repo. It reads the same run summaries as
9
+ forge-ui and never writes anything.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+ import subprocess
18
+ import sys
19
+ import time
20
+
21
+ SKILL = Path(__file__).resolve().parent.parent
22
+ sys.path.insert(0, str(SKILL / 'scripts'))
23
+ sys.path.insert(0, str(SKILL / 'ui'))
24
+
25
+ import forge_registry # noqa: E402
26
+ from forge_ui import reader # noqa: E402
27
+
28
+ FIELDS = ('key', 'run_id', 'kind', 'goal', 'repo', 'path', 'created', 'updated', 'task_count', 'counts',
29
+ 'verification_status', 'final_review')
30
+
31
+
32
+ def repo_of(start: Path) -> Path:
33
+ """The Git top level of `start`, else `start` itself: the key runs are registered under."""
34
+ try:
35
+ out = subprocess.run(['git', '-C', str(start), 'rev-parse', '--show-toplevel'], capture_output=True,
36
+ text=True, stdin=subprocess.DEVNULL, timeout=10)
37
+ if out.returncode == 0 and out.stdout.strip():
38
+ return Path(out.stdout.strip()).resolve()
39
+ except (OSError, subprocess.SubprocessError):
40
+ pass
41
+ return start.resolve()
42
+
43
+
44
+ def registered(path: Path) -> list[str]:
45
+ """The run dirs one registry file lists (absolute paths, comments skipped)."""
46
+ try:
47
+ lines = path.read_text(encoding='utf-8', errors='replace').splitlines()
48
+ except OSError:
49
+ return []
50
+ return [line.strip() for line in lines if line.strip() and not line.startswith('#') and os.path.isabs(line.strip())]
51
+
52
+
53
+ def runs(repo: Path | None) -> list[dict]:
54
+ if repo is None:
55
+ found = reader.discover(registry=True, registry_dir=forge_registry.registry_dir())
56
+ else:
57
+ found = reader.discover(plans=registered(forge_registry.runs_registry(repo)), registry=False)
58
+ result = []
59
+ for path, source in found:
60
+ try:
61
+ summary = reader.summarize(path, source)
62
+ except Exception: # one unreadable run dir must not hide the others
63
+ continue
64
+ result.append({k: summary.get(k) for k in FIELDS})
65
+ return sorted(result, key=lambda r: r['updated'] if r['updated'] is not None else float('-inf'), reverse=True)
66
+
67
+
68
+ def age(epoch, now) -> str:
69
+ if epoch is None:
70
+ return '?'
71
+ s = max(0, now - epoch)
72
+ for unit, size in (('d', 86400), ('h', 3600), ('m', 60)):
73
+ if s >= size:
74
+ return f'{int(s // size)}{unit} ago'
75
+ return 'just now'
76
+
77
+
78
+ def line(run: dict, now: float, with_repo: bool = False) -> str:
79
+ counts = ' · '.join(f'{status.lower()} {n}' for status, n in (run['counts'] or {}).items()) or 'no tasks'
80
+ checks = ''.join(f' {label} {run[key]}' for label, key in (('verify', 'verification_status'), ('review', 'final_review'))
81
+ if run.get(key))
82
+ goal = str(run['goal'] or 'Untitled run').splitlines()[0]
83
+ if len(goal) > 72:
84
+ goal = goal[:71] + '…'
85
+ repo = f" {Path(run['repo']).name or run['repo']}" if with_repo and run.get('repo') else ''
86
+ return f"{run['run_id'] or run['key']} {run['kind']}{repo} {age(run['updated'], now)} {counts}{checks} {goal}\n {run['path']}"
87
+
88
+
89
+ def main(argv=None) -> int:
90
+ parser = argparse.ArgumentParser(prog='forge runs', description="This repo's recent forge runs, newest first. Read-only.")
91
+ which = parser.add_mutually_exclusive_group()
92
+ which.add_argument('--repo', type=Path, metavar='DIR', help='the repo whose runs to list (default: the Git top level of the current directory)')
93
+ which.add_argument('--all', action='store_true', help='list the runs of every repo')
94
+ parser.add_argument('--limit', type=int, default=10, metavar='N', help='at most N runs (default 10; 0 lists them all)')
95
+ parser.add_argument('--json', action='store_true', help='print the runs as a JSON array, for scripts and agents')
96
+ args = parser.parse_args(argv)
97
+ if args.limit < 0:
98
+ parser.error('--limit must be 0 or more')
99
+ if args.repo is not None and not args.repo.is_dir():
100
+ parser.error(f'--repo: no such directory: {args.repo}')
101
+ repo = None if args.all else repo_of(args.repo if args.repo is not None else Path.cwd())
102
+ found = runs(repo)
103
+ shown = found[:args.limit] if args.limit else found
104
+ if args.json:
105
+ json.dump(shown, sys.stdout, indent=2)
106
+ sys.stdout.write('\n')
107
+ return 0
108
+ where = 'every repo' if repo is None else str(repo)
109
+ if not found:
110
+ print(f'forge runs: no runs registered for {where}')
111
+ print('Runs register when they start (/forge "<goal>" --dwarf <model>); FORGE_RUN_REGISTRY=off turns that off.')
112
+ return 0
113
+ print(f'forge runs for {where}: {len(shown)} of {len(found)}, newest first')
114
+ now = time.time()
115
+ for run in shown:
116
+ print(line(run, now, with_repo=repo is None))
117
+ return 0
118
+
119
+
120
+ if __name__ == '__main__':
121
+ sys.exit(main())
@@ -101,9 +101,45 @@ def token_count(value: object) -> int | None:
101
101
  return value if type(value) is int and value >= 0 else None
102
102
 
103
103
 
104
+ # Harnesses whose log is a stream of JSON events rather than the answer itself.
105
+ STRUCTURED = ('codex', 'claude', 'openclaude', 'grok', 'opencode')
106
+
107
+
108
+ def _opencode_final(events: list) -> str:
109
+ """opencode's answer: the text of its last step, after its last tool call."""
110
+ final, step = '', []
111
+ for event in events:
112
+ kind = event.get('type')
113
+ if kind in ('step_start', 'tool_use'):
114
+ step = []
115
+ elif kind == 'text':
116
+ part = event.get('part')
117
+ text = part.get('text') if isinstance(part, dict) else None
118
+ if isinstance(text, str) and text.strip():
119
+ step.append(text)
120
+ final = '\n'.join(step)
121
+ return final
122
+
123
+
104
124
  def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
105
125
  usage = dict(input_tokens=None, cached_input_tokens=None, output_tokens=None)
106
126
  final = None
127
+ if harness == 'opencode':
128
+ events = _events(log)[0]
129
+ for event in events:
130
+ part = event.get('part')
131
+ tokens = part.get('tokens') if event.get('type') == 'step_finish' and isinstance(part, dict) else None
132
+ if not isinstance(tokens, dict):
133
+ continue
134
+ cache = tokens.get('cache') if isinstance(tokens.get('cache'), dict) else {}
135
+ # input counts what was read from and written to the cache, as claude's does
136
+ for key, parts in (('input_tokens', (tokens.get('input'), cache.get('read'), cache.get('write'))),
137
+ ('cached_input_tokens', (cache.get('read'),)),
138
+ ('output_tokens', (tokens.get('output'), tokens.get('reasoning')))):
139
+ counts = [token_count(v) for v in parts if v is not None]
140
+ if counts and all(v is not None for v in counts):
141
+ usage[key] = (usage[key] or 0) + sum(counts)
142
+ return _opencode_final(events) or None, usage
107
143
  for line in log.splitlines():
108
144
  try:
109
145
  event = json.loads(line)
@@ -124,8 +160,9 @@ def extract(log: str, harness: str) -> tuple[str | None, dict[str, int | None]]:
124
160
  if isinstance(item, dict) and item.get('type') == 'agent_message':
125
161
  if isinstance(item.get('text'), str):
126
162
  final = item['text']
127
- # grok's streaming-messages-json ends in the same `result` event, token buckets included.
128
- if harness in ('claude', 'grok') and event.get('type') == 'result':
163
+ # claude's and openclaude's stream-json and grok's streaming-messages-json end in the same
164
+ # `result` event, token buckets included.
165
+ if harness in ('claude', 'openclaude', 'grok') and event.get('type') == 'result':
129
166
  if isinstance(event.get('result'), str):
130
167
  final = event['result']
131
168
  # Claude reports uncached, cache-read and cache-creation separately.
@@ -149,7 +186,7 @@ def finish(attempt: str, root: str, role: str, harness: str, rc: str) -> None:
149
186
  def _finish(attempt: Path, root: Path, role: str, harness: str, rc: str) -> None:
150
187
  log = root / (role + '.log')
151
188
  last = root / (role + '.last')
152
- structured = harness in ('codex', 'claude', 'grok')
189
+ structured = harness in STRUCTURED
153
190
  text = log.read_text(errors='replace') if structured and log.exists() else ''
154
191
  final, usage = extract(text, harness)
155
192
  if not last.exists() or not last.stat().st_size:
@@ -439,7 +476,7 @@ def classify(log: str, harness: str, rc: object, last: str = '', role: str = '',
439
476
  text = Path(log).read_text(errors='replace')
440
477
  except OSError:
441
478
  text = ''
442
- events, stray = _events(text) if harness in ('codex', 'claude', 'openclaude', 'grok') else ([], [])
479
+ events, stray = _events(text) if harness in STRUCTURED else ([], [])
443
480
 
444
481
  if harness == 'codex':
445
482
  final = _codex_final(events, last)
@@ -454,6 +491,22 @@ def classify(log: str, harness: str, rc: object, last: str = '', role: str = '',
454
491
  return 'empty', None, 'codex finished with exit 0 but produced no final message'
455
492
  return classify_text('\n'.join(pending + [_tail(stray)]), now=now)
456
493
 
494
+ if harness == 'opencode' and events:
495
+ # Like codex: an error event counts when the run produced no answer after it.
496
+ errors = []
497
+ for event in events:
498
+ error = event.get('error') if event.get('type') == 'error' else None
499
+ if isinstance(error, dict):
500
+ data = error.get('data') if isinstance(error.get('data'), dict) else {}
501
+ errors.append(str(data.get('message') or error.get('name') or json.dumps(error)))
502
+ final = _opencode_final(events)
503
+ if rc == 0:
504
+ if final.strip():
505
+ return None
506
+ hit = classify_text('\n'.join(errors), now=now) if errors else None
507
+ return hit or ('empty', None, 'opencode finished with exit 0 but produced no final message')
508
+ return classify_text('\n'.join(errors + [_tail(stray)]), now=now)
509
+
457
510
  results = [e for e in events if e.get('type') == 'result'] if harness in ('claude', 'openclaude', 'grok') else []
458
511
  if results:
459
512
  event = results[-1]
@@ -478,8 +531,8 @@ def classify(log: str, harness: str, rc: object, last: str = '', role: str = '',
478
531
  return 'empty', None, '%s finished with exit 0 but its final result is empty' % harness
479
532
  return None
480
533
 
481
- # Raw-log harness (opencode, antigravity, openclaude's plain text, or a claude that
482
- # died before emitting its result): the log is the message, so only the tail of a
534
+ # Raw-log harness (antigravity, an opencode or openclaude from before their streams, or a
535
+ # claude that died before emitting its result): the log is the message, so only the tail of a
483
536
  # failing run is evidence. A successful run's prose is never scanned.
484
537
  if rc != 0:
485
538
  return classify_text(_tail(text), now=now)
@@ -10,6 +10,10 @@ VERSION, prints a single line saying so. It is silent offline, when the checkout
10
10
  upstream, in CI (`CI` set) and when `FORGE_UPDATE_CHECK=off`, and it always exits 0: it never
11
11
  changes a run's outcome. The last check is cached in
12
12
  `${XDG_STATE_HOME:-~/.local/state}/forge/update-check.json`.
13
+
14
+ An npm installation (the package sits under a `node_modules` folder, the same rule as
15
+ forge-update.sh) has no git upstream: there the newest release is the package's `latest` on the
16
+ npm registry (`npm_config_registry` when set, as npm reads it), and the update command is npm's.
13
17
  """
14
18
  from __future__ import annotations
15
19
 
@@ -21,6 +25,8 @@ import subprocess
21
25
  import sys
22
26
  import tempfile
23
27
  import time
28
+ import urllib.parse
29
+ import urllib.request
24
30
  from pathlib import Path
25
31
 
26
32
  SKILL = Path(__file__).resolve().parent.parent
@@ -28,6 +34,8 @@ DAY_S = 24 * 3600
28
34
  TIMEOUT_S = 5
29
35
  _TAG = re.compile(r'^refs/tags/v(\d+(?:\.\d+)*)$')
30
36
  _OFF = ('off', '0', 'false', 'no')
37
+ NPM_PACKAGE = '@giamme/forge'
38
+ NPM_REGISTRY = 'https://registry.npmjs.org/'
31
39
 
32
40
 
33
41
  def state_file() -> Path:
@@ -84,6 +92,34 @@ def checkout(source: Path) -> dict:
84
92
  return info
85
93
 
86
94
 
95
+ def npm_install(source: Path) -> dict:
96
+ """An npm installation's facts, like checkout()'s: empty when `source` is not under node_modules."""
97
+ if 'node_modules' not in source.resolve().parts:
98
+ return {}
99
+ try:
100
+ name = json.loads((source / 'package.json').read_text(encoding='utf-8')).get('name') or NPM_PACKAGE
101
+ except (OSError, ValueError, UnicodeDecodeError, AttributeError):
102
+ name = NPM_PACKAGE
103
+ registry = os.environ.get('npm_config_registry') or os.environ.get('NPM_CONFIG_REGISTRY') or NPM_REGISTRY
104
+ url = registry.rstrip('/') + '/' + urllib.parse.quote(name, safe='@') + '/latest'
105
+ return dict(npm=name, remote='npm', url=url)
106
+
107
+
108
+ def update_command(info: dict) -> str:
109
+ return f'npm install -g {info["npm"]}@latest && forge install' if info.get('npm') else 'forge update'
110
+
111
+
112
+ def npm_latest(url: str) -> str | None:
113
+ """The `latest` version on the registry, or None when it cannot be read in time."""
114
+ try:
115
+ with urllib.request.urlopen(urllib.request.Request(url, headers={'Accept': 'application/json'}),
116
+ timeout=TIMEOUT_S) as reply:
117
+ version = json.loads(reply.read().decode('utf-8')).get('version')
118
+ except Exception: # offline, HTTP error, a proxy page instead of JSON: no answer, never a failure
119
+ return None
120
+ return version if isinstance(version, str) and re.fullmatch(r'\d+(?:\.\d+)*', version) else None
121
+
122
+
87
123
  def latest_release(source: Path, remote: str) -> str | None:
88
124
  """The highest vX[.Y[.Z]] tag on the remote, or None when it cannot be reached in time."""
89
125
  out = _git(source, 'ls-remote', '--tags', '--refs', '--', remote, 'v*')
@@ -116,7 +152,7 @@ def write_cache(data: dict) -> None:
116
152
  def check(source: Path, info: dict) -> dict:
117
153
  """Ask the upstream now and cache the answer. Offline keeps the last known release."""
118
154
  cache = read_cache()
119
- found = latest_release(source, info['remote'])
155
+ found = npm_latest(info['url']) if info.get('npm') else latest_release(source, info['remote'])
120
156
  same = cache.get('url') == info['url']
121
157
  entry = dict(checked=time.time(), url=info['url'],
122
158
  latest=found if found else (cache.get('latest') if same else None), reached=found is not None)
@@ -131,15 +167,15 @@ def disabled() -> bool:
131
167
  return bool(ci) and ci not in _OFF
132
168
 
133
169
 
134
- def available_line(latest: str, current: str) -> str:
135
- return f'forge {latest} is available (you have {current}): forge update'
170
+ def available_line(latest: str, current: str, info: dict | None = None) -> str:
171
+ return f'forge {latest} is available (you have {current}): {update_command(info or {})}'
136
172
 
137
173
 
138
174
  def notice(source: Path) -> None:
139
175
  if disabled():
140
176
  return
141
177
  current = local_version(source)
142
- info = checkout(source)
178
+ info = checkout(source) or npm_install(source)
143
179
  if not current or not info.get('remote'):
144
180
  return
145
181
  cache = read_cache()
@@ -148,22 +184,26 @@ def notice(source: Path) -> None:
148
184
  return # checked (and told) within the last day
149
185
  entry = check(source, info)
150
186
  if newer(entry['latest'], current):
151
- print(available_line(entry['latest'], current))
187
+ print(available_line(entry['latest'], current, info))
152
188
 
153
189
 
154
190
  def show(source: Path, ask: bool) -> int:
155
191
  current = local_version(source)
156
192
  print(f'forge {current or "unknown"}')
157
- info = checkout(source)
193
+ info = checkout(source) or npm_install(source)
158
194
  if not info:
159
195
  print(f'checkout: {source} (not a git checkout; update it with forge update --source <checkout>)')
160
196
  return 0
161
- where = info['branch'] or 'detached HEAD'
162
- print(f'checkout: {source} ({where} @ {info["commit"] or "?"})')
163
- if info.get('remote'):
164
- print(f'upstream: {info["upstream"] or info["remote"]} ({info["url"] or "no url"})')
197
+ if info.get('npm'):
198
+ print(f'installed: {source} (npm package {info["npm"]})')
199
+ print(f'upstream: npm registry ({info["url"]})')
165
200
  else:
166
- print('upstream: none')
201
+ where = info['branch'] or 'detached HEAD'
202
+ print(f'checkout: {source} ({where} @ {info["commit"] or "?"})')
203
+ if info.get('remote'):
204
+ print(f'upstream: {info["upstream"] or info["remote"]} ({info["url"] or "no url"})')
205
+ else:
206
+ print('upstream: none')
167
207
  entry = check(source, info) if ask and info.get('remote') else read_cache()
168
208
  if ask and info.get('remote') and not entry.get('reached'):
169
209
  print(f'latest: could not reach {info["remote"]}')
@@ -172,9 +212,11 @@ def show(source: Path, ask: bool) -> int:
172
212
  if isinstance(entry.get('checked'), (int, float)) else 'unknown'
173
213
  print(f'latest: {entry["latest"]} (checked {when})')
174
214
  if newer(entry['latest'], current):
175
- print(available_line(entry['latest'], current))
215
+ print(available_line(entry['latest'], current, info))
176
216
  else:
177
217
  print('latest: not checked yet; run forge version --check')
218
+ if info.get('npm'):
219
+ print(f'update: {update_command(info)}')
178
220
  return 0
179
221
 
180
222