@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.
- package/CHANGELOG.md +24 -0
- package/README.md +106 -1766
- package/SKILL.md +3 -2
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +5 -0
- package/package.json +3 -2
- package/references/decompose.md +1 -1
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/harvest.py +1 -1
- package/scripts/forge_dataset/importer.py +1 -1
- package/scripts/forge_fractal/__init__.py +2 -1
- package/ui/README.md +80 -20
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +99 -17
- package/ui/forge_ui/export.py +16 -6
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +95 -7
- package/ui/forge_ui/server.py +10 -1
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +279 -124
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +25 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +68 -32
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +924 -469
- package/ui/forge_ui/static/theme.js +25 -0
- 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.
|
|
4
|
-
"description": "
|
|
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/",
|
package/references/decompose.md
CHANGED
|
@@ -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](../
|
|
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
|
package/references/fractal.md
CHANGED
|
@@ -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 [
|
|
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
|
package/references/harnesses.md
CHANGED
|
@@ -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
|
|
50
|
-
and for the raw-log
|
|
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
|
|
94
|
-
`--output-format json` provide native usage; final responses
|
|
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
|
package/references/solo.md
CHANGED
|
@@ -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](../
|
|
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
|
-
|
|
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())
|
package/scripts/forge-runtime.py
CHANGED
|
@@ -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
|
|
128
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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)
|
package/scripts/forge-version.py
CHANGED
|
@@ -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}):
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|