forgeo-cli 0.9.0__tar.gz → 0.10.0__tar.gz

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 (87) hide show
  1. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/CHANGELOG.md +20 -1
  2. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/PKG-INFO +15 -3
  3. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/README.md +14 -2
  4. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/backlog.md +5 -0
  5. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/cli-reference.md +2 -0
  6. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/getting-started.md +32 -16
  7. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/web-console-api.md +30 -9
  8. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/install.sh +1 -1
  9. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/pyproject.toml +1 -1
  10. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/__init__.py +1 -1
  11. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/agent.py +25 -1
  12. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/backlog_github.py +11 -12
  13. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/central.py +101 -4
  14. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/daemon_control.py +1 -1
  15. forgeo_cli-0.10.0/src/forgeo/setup.py +462 -0
  16. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/web/central/central.css +92 -0
  17. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/web/central/central.js +82 -2
  18. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/web/central/instance.html +16 -1
  19. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/conftest.py +20 -7
  20. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_cli.py +7 -32
  21. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_setup.py +54 -3
  22. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_web.py +1 -1
  23. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_web_lock.py +1 -1
  24. forgeo_cli-0.9.0/src/forgeo/setup.py +0 -230
  25. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/.github/workflows/ci.yml +0 -0
  26. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/.gitignore +0 -0
  27. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/CONTRIBUTING.md +0 -0
  28. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/LICENSE +0 -0
  29. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/config/nginx-forgeo.conf +0 -0
  30. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/agent-contract.md +0 -0
  31. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/configuration.md +0 -0
  32. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/img/console.png +0 -0
  33. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/img/demo.gif +0 -0
  34. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/img/logo.png +0 -0
  35. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/img/og.png +0 -0
  36. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/img/title.svg +0 -0
  37. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/docs/index.md +0 -0
  38. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/forgeo.spec +0 -0
  39. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/mkdocs.yml +0 -0
  40. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/scripts/__init__.py +0 -0
  41. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/scripts/render_homebrew_formula.py +0 -0
  42. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/__main__.py +0 -0
  43. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/backlog.py +0 -0
  44. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/backlog_gitlab.py +0 -0
  45. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/backlog_http.py +0 -0
  46. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/backlog_issue_base.py +0 -0
  47. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/backlog_jira.py +0 -0
  48. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/cli.py +0 -0
  49. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/config.py +0 -0
  50. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/daemon.py +0 -0
  51. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/forgeo.py +0 -0
  52. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/git.py +0 -0
  53. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/instances.py +0 -0
  54. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/io.py +0 -0
  55. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/models.py +0 -0
  56. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/notify.py +0 -0
  57. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/oauth.py +0 -0
  58. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/paths.py +0 -0
  59. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/runs.py +0 -0
  60. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/update.py +0 -0
  61. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/validate.py +0 -0
  62. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/web/central/index.html +0 -0
  63. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/web/central/login.html +0 -0
  64. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/web/style.css +0 -0
  65. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/src/forgeo/web_common.py +0 -0
  66. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_agent.py +0 -0
  67. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_backlog.py +0 -0
  68. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_backlog_github.py +0 -0
  69. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_backlog_gitlab.py +0 -0
  70. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_backlog_http.py +0 -0
  71. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_backlog_jira.py +0 -0
  72. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_daemon.py +0 -0
  73. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_factory.py +0 -0
  74. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_git.py +0 -0
  75. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_install.py +0 -0
  76. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_instances.py +0 -0
  77. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_io.py +0 -0
  78. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_models.py +0 -0
  79. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_oauth.py +0 -0
  80. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_paths.py +0 -0
  81. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_remote_backlog_cycle.py +0 -0
  82. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_render_homebrew.py +0 -0
  83. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_runs.py +0 -0
  84. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_update.py +0 -0
  85. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/tests/test_web_common.py +0 -0
  86. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/www/404.html +0 -0
  87. {forgeo_cli-0.9.0 → forgeo_cli-0.10.0}/www/index.html +0 -0
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.0] - 2026-08-24
11
+
12
+ ### Added
13
+
14
+ - `forgeo init` now asks for **backlog provider** (`file`/`github`/`gitlab`/`jira`/`http`). For `github` it auto-detects `owner/repo` from `git remote origin`, prompts for `token_env` (`GITHUB_TOKEN`) and can persist a pasted classic PAT (`ghp_...`, scope `repo`) to `~/.config/forgeo/github_token_env.sh` (600, wired to `~/.bashrc`). Same for `gitlab` (`GITLAB_TOKEN`, base URL) and `jira`/`http`. The wizard writes `backlog: https://api.github.com`, `backlog_provider: github`, `github: {repo, auth: {token_env}}` and `state_dir: .forgeo` automatically.
15
+ - Web console now treats `jira`/`github`/`gitlab` as a **read-mostly mirror** instead of a replacement: the home page cards show `Open in Jira/GitHub/GitLab ↗` (via `external_board_url`/`external_board_label`), the instance page shows a top banner linking to the native board, and each task card/modal links to the native issue (`external_url`). Document backlogs (`file`/`http`) keep the existing primary-editor behaviour. Creating/editing still works through the dashboard, but triage is expected in the native tracker where Forgeo-specific state (BLOCKED/FAILED reasons, `agent_response`, retry budget) is now surfaced on the mirror.
16
+ - Central API now exposes `backlog_provider`, `backlog`, `backlog_is_issue_provider`, `external_board_url`/`external_board_label` on `GET /api/instances` and `GET /api/instances/<name>/status`, and `external_url` per task on `GET /api/instances/<name>/tasks` (+ single-task) for issue providers. Covers `https://api.github.com` → `https://github.com` and `https://…/api/v3` → web base mapping for GitHub Enterprise, and `https://jira…/issues/?jql=` / `https://gitlab…/{repo}/-/issues` board links.
17
+
18
+ ### Fixed
19
+
20
+ - GitHub provider now encodes `owner/repo` as two path segments (`quote` per segment, keeping `/`) instead of `quote(repo, safe='')` which produced `owner%2Frepo` and 404 on every `GET /repos/{owner%2Frepo}/issues`.
21
+
22
+ ### Changed
23
+
24
+ - `README`, `docs/backlog.md`, `docs/getting-started.md` and `docs/web-console-api.md` document the mirror vs editor split and the new `external_*` API fields.
25
+ - `src/forgeo/central.py` deduplicates provider metadata via `_backlog_meta()` and centralises GitHub web-base handling; `instance-card` is now a `div[role=link]` to allow nested external links without invalid HTML.
26
+ - `README`, `docs/getting-started.md` and `docs/cli-reference.md` document the new backlog-provider step in `forgeo init` and the `GITHUB_TOKEN` PAT setup (`https://github.com/settings/tokens/new`, scope `repo`).
27
+
10
28
  ## [0.9.0] - 2026-08-23
11
29
 
12
30
  ### Added
@@ -389,7 +407,8 @@ Initial release of the scheduled, agent-driven software forgeo.
389
407
  overlapping-run skipping.
390
408
  - Dogfooding docs removed; local configs kept out of the repository.
391
409
 
392
- [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.9.0...HEAD
410
+ [Unreleased]: https://github.com/lucaGazzola/forgeo/compare/v0.10.0...HEAD
411
+ [0.10.0]: https://github.com/lucaGazzola/forgeo/compare/v0.9.0...v0.10.0
393
412
  [0.9.0]: https://github.com/lucaGazzola/forgeo/compare/v0.8.0...v0.9.0
394
413
  [0.8.0]: https://github.com/lucaGazzola/forgeo/compare/v0.7.3...v0.8.0
395
414
  [0.7.3]: https://github.com/lucaGazzola/forgeo/compare/v0.7.2...v0.7.3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: forgeo-cli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input.
5
5
  Project-URL: Homepage, https://forgeo.org
6
6
  Project-URL: Documentation, https://forgeo.org
@@ -110,6 +110,10 @@ forgeo init
110
110
 
111
111
  Guided wizard, run from your project root. Writes `forgeo.yaml` (the
112
112
  config) and a `.forgeo/` folder for the backlog, logs and blocker files.
113
+ The wizard asks for the backlog provider (`file` for a local JSON file, or
114
+ `github`/`gitlab`/`jira`/`http` for an external tracker — for `github` it
115
+ auto-detects `owner/repo` from `git remote` and can persist a pasted
116
+ `GITHUB_TOKEN` to `~/.config/forgeo/github_token_env.sh`).
113
117
 
114
118
  The base flow is then three steps: fill the backlog, check the
115
119
  configuration, start the daemon.
@@ -121,8 +125,10 @@ configuration, start the daemon.
121
125
  # Backlog format), created on first use:
122
126
  # .forgeo/backlog.json
123
127
 
124
- # Or: configure `backlog_provider: jira` / `github` / `gitlab` and set `backlog` to the provider base URL
125
- # (see the backlog documentation for Jira/GitHub/GitLab).
128
+ # Or: pick github/gitlab/jira in forgeo init (or configure forgeo.yaml
129
+ # manually): set backlog_provider: github and backlog: https://api.github.com
130
+ # with github.repo + token_env, then export GITHUB_TOKEN and run
131
+ # forgeo validate. See backlog docs for Jira/GitHub/GitLab.
126
132
 
127
133
  # Or: add tasks from the web console once your forgeo is registered
128
134
  # (first `forgeo start` registers it automatically):
@@ -226,6 +232,12 @@ individually with workflow state and engine metadata stored on the issue. A *fil
226
232
  restored automatically if it is ever found corrupt — a bad write never loses
227
233
  your tasks.
228
234
 
235
+ The central dashboard (`forgeo web`) mirrors every instance: for `file`/`http` it is the primary editor; for
236
+ `jira`/`github`/`gitlab` it is a read-mostly mirror of the native tracker — each task card and its detail modal link
237
+ to the native issue (`Open in Jira/GitHub/GitLab ↗`), a top banner links to the native board, and the board surfaces
238
+ Forgeo-specific state the native UI does not (BLOCKED/FAILED reasons, `agent_response`, retry budget) — triage stays in
239
+ the tracker (see [Web console & HTTP API](docs/web-console-api.md)).
240
+
229
241
  ## Develop
230
242
 
231
243
  ```bash
@@ -56,6 +56,10 @@ forgeo init
56
56
 
57
57
  Guided wizard, run from your project root. Writes `forgeo.yaml` (the
58
58
  config) and a `.forgeo/` folder for the backlog, logs and blocker files.
59
+ The wizard asks for the backlog provider (`file` for a local JSON file, or
60
+ `github`/`gitlab`/`jira`/`http` for an external tracker — for `github` it
61
+ auto-detects `owner/repo` from `git remote` and can persist a pasted
62
+ `GITHUB_TOKEN` to `~/.config/forgeo/github_token_env.sh`).
59
63
 
60
64
  The base flow is then three steps: fill the backlog, check the
61
65
  configuration, start the daemon.
@@ -67,8 +71,10 @@ configuration, start the daemon.
67
71
  # Backlog format), created on first use:
68
72
  # .forgeo/backlog.json
69
73
 
70
- # Or: configure `backlog_provider: jira` / `github` / `gitlab` and set `backlog` to the provider base URL
71
- # (see the backlog documentation for Jira/GitHub/GitLab).
74
+ # Or: pick github/gitlab/jira in forgeo init (or configure forgeo.yaml
75
+ # manually): set backlog_provider: github and backlog: https://api.github.com
76
+ # with github.repo + token_env, then export GITHUB_TOKEN and run
77
+ # forgeo validate. See backlog docs for Jira/GitHub/GitLab.
72
78
 
73
79
  # Or: add tasks from the web console once your forgeo is registered
74
80
  # (first `forgeo start` registers it automatically):
@@ -172,6 +178,12 @@ individually with workflow state and engine metadata stored on the issue. A *fil
172
178
  restored automatically if it is ever found corrupt — a bad write never loses
173
179
  your tasks.
174
180
 
181
+ The central dashboard (`forgeo web`) mirrors every instance: for `file`/`http` it is the primary editor; for
182
+ `jira`/`github`/`gitlab` it is a read-mostly mirror of the native tracker — each task card and its detail modal link
183
+ to the native issue (`Open in Jira/GitHub/GitLab ↗`), a top banner links to the native board, and the board surfaces
184
+ Forgeo-specific state the native UI does not (BLOCKED/FAILED reasons, `agent_response`, retry budget) — triage stays in
185
+ the tracker (see [Web console & HTTP API](docs/web-console-api.md)).
186
+
175
187
  ## Develop
176
188
 
177
189
  ```bash
@@ -144,6 +144,11 @@ You add, remove, or reopen tasks by editing the file directly — or use the
144
144
  the task detail modal's **Edit** button updates an existing task's fields
145
145
  (`PATCH /api/instances/<name>/tasks/<id>`), while its **Delete** button
146
146
  removes an `OPEN` or `BLOCKED` task (`DELETE /api/instances/<name>/tasks/<id>`).
147
+ For `file`/`http` backlogs this is the primary editor; for `jira`/`github`/`gitlab` the web console is a
148
+ read-mostly **mirror** of the native tracker — a top banner links to the external board, each task card/modal
149
+ links to the native issue (`Open in Jira/GitHub/GitLab ↗`), and the board surfaces Forgeo-specific state
150
+ (`blocker_reason`, `failure_reason`, `agent_response`, retry budget) that the tracker does not — triage stays in
151
+ Jira/GitHub/GitLab, Forgeo reflects it (see [Web console & HTTP API](web-console-api.md)).
147
152
 
148
153
  ### Resolving a blocked task
149
154
 
@@ -23,6 +23,8 @@ Guided first-time setup: interactively write a `forgeo.yaml`.
23
23
  | <span style="white-space: nowrap">`--config <file>`</span> | Where to write the config (default `forgeo.yaml`). |
24
24
  | <span style="white-space: nowrap">`--force`</span> | Overwrite an existing config file. |
25
25
 
26
+ The wizard asks for: Forgeo folder, backlog provider (`file`/`github`/`gitlab`/`jira`/`http` — for `github` it auto-detects `owner/repo` from `git remote` and can persist `GITHUB_TOKEN`), coding agent command, and refactor prompt.
27
+
26
28
  Exit codes:
27
29
 
28
30
  - `0` — config written.
@@ -56,20 +56,29 @@ Run the guided wizard from your project root:
56
56
  forgeo init
57
57
  ```
58
58
 
59
- The wizard asks for three things:
59
+ The wizard asks for:
60
60
 
61
61
  1. **Forgeo folder** — where the backlog, `BLOCKER.md` and the log live
62
62
  (default `.forgeo`). It is gitignored by default.
63
- 2. **Coding agent command** — the bare command that launches your coding
63
+ 2. **Backlog provider** — where tasks live: `file` (local `.forgeo/backlog.json`),
64
+ `github` / `gitlab` / `jira` / `http`. For `github` it auto-detects
65
+ `owner/repo` from `git remote origin`, asks for `token_env` (default
66
+ `GITHUB_TOKEN`) and can persist a pasted classic PAT (`ghp_...`, scope `repo`)
67
+ to `~/.config/forgeo/github_token_env.sh` (600, wired to `~/.bashrc`). Same
68
+ for `gitlab` (`GITLAB_TOKEN`, base URL) and `jira`/`http`.
69
+ 3. **Coding agent command** — the bare command that launches your coding
64
70
  agent (default `opencode run --auto`). Forgeo appends the standard task
65
71
  prompt (which ends in `$FORGEO_TASK`) automatically, so you never type
66
72
  it. Enter a command that already references `$FORGEO_TASK` and it is
67
73
  kept verbatim.
68
- 3. **Refactor prompt** — the instruction used when the backlog is empty; the
74
+ 4. **Refactor prompt** — the instruction used when the backlog is empty; the
69
75
  default is offered, or you can paste a custom one.
70
76
 
71
77
  `forgeo init` writes `forgeo.yaml`, creates Forgeo folder, and appends
72
- `<folder>/` to `.gitignore` (unless you opt out).
78
+ `<folder>/` to `.gitignore` (unless you opt out). For `github`/`gitlab`/`jira`
79
+ set `backlog_provider` + `backlog` URL + provider block is written automatically
80
+ and `state_dir` is set to the Forgeo folder so runtime files stay beside the
81
+ config.
73
82
 
74
83
  ```bash
75
84
  forgeo init --force # overwrite an existing forgeo.yaml
@@ -77,18 +86,25 @@ forgeo init --force # overwrite an existing forgeo.yaml
77
86
 
78
87
  ## 3. Create your first backlog
79
88
 
80
- The backlog is a plain JSON file (see [Backlog format](backlog.md)). Create
81
- the file configured as `backlog:` in your `forgeo.yaml` — by default
82
- `.forgeo/backlog.json`. Once Forgeo is running you can also add tasks
83
- from the [web console](web-console-api.md) — no file editing needed. (If your
84
- tasks already live in another application, `backlog:` also accepts an
85
- [HTTP endpoint](backlog.md#a-backlog-over-http) or a [Jira source](backlog.md#a-jira-backlog)
86
- instead of a file.)
87
-
88
- For Jira, set `backlog_provider: jira`, point `backlog:` at the Jira base URL,
89
- configure `jira.jql` and the workflow mappings, export the credentials named in
90
- `jira.auth`, and run `forgeo validate` before starting the daemon. See [Jira
91
- backlogs](backlog.md#a-jira-backlog) for the complete configuration.
89
+ If you chose `file` in the wizard, the backlog is a plain JSON file (see
90
+ [Backlog format](backlog.md)) — by default `.forgeo/backlog.json`. Once Forgeo
91
+ is running you can also add tasks from the [web console](web-console-api.md) — no
92
+ file editing needed.
93
+
94
+ If you chose `github`/`gitlab`/`jira`/`http` in the wizard, your `forgeo.yaml`
95
+ already points at the provider (`backlog: https://api.github.com` etc.).
96
+ For `github` create a classic PAT at `https://github.com/settings/tokens/new`
97
+ (scope `repo`), `export GITHUB_TOKEN=ghp_...` (or let the wizard persist it),
98
+ then `forgeo validate` before `forgeo start`. Same for `gitlab` (`GITLAB_TOKEN`)
99
+ and `jira`. See [Backlog format](backlog.md) for provider details.
100
+
101
+ For `jira`/`github`/`gitlab`, set `backlog_provider:` to the provider, point `backlog:` at its base URL
102
+ (`https://jira.example.com`, `https://api.github.com` / `https://github.example.com/api/v3`,
103
+ `https://gitlab.example.com`), configure the provider block (`jira.jql` / `github.repo` / `gitlab.repo` and auth),
104
+ export the credentials named in `*_auth.token_env`, and run `forgeo validate` before starting the daemon.
105
+ The dashboard for these providers is a read-mostly mirror: a banner links to the native board, each card links to
106
+ the native issue, and Forgeo-specific state (BLOCKED/FAILED reasons, `agent_response`) is surfaced on the board — triage
107
+ stays in Jira/GitHub/GitLab. See [Backlog: Jira/GitHub/GitLab](backlog.md) for the complete configuration.
92
108
 
93
109
  ```json
94
110
  {
@@ -66,11 +66,20 @@ open-by-default behavior.
66
66
 
67
67
  - `GET /` — home page listing every registered instance: name, repository,
68
68
  daemon state (lock held), last outcome, next run, and per-status backlog
69
- counts, each linking to its instance page.
69
+ counts, each linking to its instance page. For issue-backed instances
70
+ (`jira`/`github`/`gitlab`) the card also shows an **Open in Jira/GitHub/GitLab ↗**
71
+ link to the native board.
70
72
  - `GET /instances/<name>/` — one instance's page: a kanban backlog, a
71
73
  **Create** tab with a form to add tasks (including an optional *Run at*
72
74
  date/time input for a one-shot schedule), plus tabs for **logs**, **history**,
73
- **blocker** and **config**. The header carries a **DAEMON** section with the
75
+ **blocker** and **config**. For document backlogs (`file`/`http`) this board
76
+ is the primary editor; for issue providers (`jira`/`github`/`gitlab`) it is a
77
+ read-mostly **mirror** of the native tracker: a banner at the top links to
78
+ the external board (`Jira`/`GitHub`/`GitLab`), each task card and its detail
79
+ modal carry an **Open in external ↗** link to the native issue, and the board
80
+ surfaces Forgeo-specific state that the native UI does not (BLOCKED/FAILED
81
+ reasons, `agent_response`, retry budget). Creating/editing still works via
82
+ the dashboard, but triage is expected in the native tool. The header carries a **DAEMON** section with the
74
83
  daemon status tag (`running`/`stopped`) and **Start**/**Stop**/**Restart**
75
84
  buttons that call `POST /api/instances/<name>/start|stop|restart`; the
76
85
  buttons reflect the current state (Start is disabled while running, Stop
@@ -152,15 +161,19 @@ outcome, next run, and backlog counts.
152
161
  curl http://127.0.0.1:8790/api/instances
153
162
  ```
154
163
 
155
- Each row also carries `backlog_error`: `null` normally, and the reason when
156
- that instance's remote backlog could not be read. Its counts are zero in that
157
- case — the row reports a backlog it could not reach, not an empty one. One
158
- unreachable provider never fails the whole listing.
164
+ Each row also carries `backlog_provider` (`file`/`http`/`jira`/`github`/`gitlab`),
165
+ `backlog` (path or base URL), `backlog_is_issue_provider`, and for issue
166
+ providers `external_board_url`/`external_board_label` (the native board link
167
+ shown on the home cards). It also carries `backlog_error`: `null` normally,
168
+ and the reason when that instance's remote backlog could not be read. Its
169
+ counts are zero in that case — the row reports a backlog it could not reach,
170
+ not an empty one. One unreachable provider never fails the whole listing.
159
171
 
160
172
  ### `GET /api/instances/<name>/tasks`
161
173
 
162
174
  List every task in that instance's backlog, in creation order. Each task
163
- carries extra `unsatisfied_dependencies` and retry fields (see below).
175
+ carries extra `unsatisfied_dependencies`, retry fields (see below), and for
176
+ issue providers `external_url` (link to the native Jira/GitHub/GitLab issue).
164
177
 
165
178
  ```bash
166
179
  curl http://127.0.0.1:8790/api/instances/my-repo/tasks
@@ -400,7 +413,10 @@ Errors:
400
413
  ### `GET /api/instances/<name>/status`
401
414
 
402
415
  Daemon status: name, repo, interval, `daemon_running` (whether the instance's
403
- lock is held), the recorded PID, `last_outcome`, and the `next_run_at`.
416
+ lock is held), the recorded PID, `last_outcome`, and the `next_run_at`. Also
417
+ carries `backlog_provider`, `backlog`, `backlog_is_issue_provider`,
418
+ `external_board_url`/`external_board_label` so the instance page can render the
419
+ issue-provider banner and per-task external links.
404
420
 
405
421
  ```bash
406
422
  curl http://127.0.0.1:8790/api/instances/my-repo/status
@@ -414,7 +430,12 @@ curl http://127.0.0.1:8790/api/instances/my-repo/status
414
430
  "daemon_running": true,
415
431
  "pid": 4242,
416
432
  "last_outcome": "task",
417
- "next_run_at": "2026-08-01T12:00:00+00:00"
433
+ "next_run_at": "2026-08-01T12:00:00+00:00",
434
+ "backlog_provider": "github",
435
+ "backlog": "https://api.github.com",
436
+ "backlog_is_issue_provider": true,
437
+ "external_board_url": "https://github.com/owner/repo/issues",
438
+ "external_board_label": "GitHub"
418
439
  }
419
440
  ```
420
441
 
@@ -14,7 +14,7 @@ set -eu
14
14
 
15
15
  REPO_OWNER="lucaGazzola"
16
16
  REPO_NAME="forgeo"
17
- DEFAULT_VERSION="0.9.0"
17
+ DEFAULT_VERSION="0.10.0"
18
18
  MIN_PYTHON="3.11"
19
19
  PYPI_PACKAGE="forgeo-cli"
20
20
  PREFIX="${FORGEO_PREFIX:-${HOME:-}/.local}"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "forgeo-cli"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "A scheduled software forgeo: executes backlog tasks on main, refactors when idle, and writes BLOCKER.md when it needs human input."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,4 +7,4 @@ try:
7
7
 
8
8
  __version__ = _pkg_version("forgeo-cli")
9
9
  except PackageNotFoundError: # standalone binary: no installed package metadata
10
- __version__ = "0.9.0"
10
+ __version__ = "0.10.0"
@@ -206,7 +206,26 @@ class ShellAgent(BaseAgent):
206
206
  except TimeoutError:
207
207
  timed_out = True
208
208
  _kill_process_group(proc)
209
- await proc.wait()
209
+ # ``proc.wait()`` waits for both process exit *and* pipe closure
210
+ # (see ``BaseSubprocessTransport._try_finish``). A grandchild that
211
+ # called ``setsid()`` and holds the write end of the pipe keeps it
212
+ # open forever, so the wait would hang past the drain deadline.
213
+ # Bound it by ``drain_timeout_seconds`` and force-close the
214
+ # transport if it still hangs.
215
+ try:
216
+ await asyncio.wait_for(proc.wait(), timeout=self.drain_timeout_seconds)
217
+ except TimeoutError:
218
+ try:
219
+ proc._transport.close() # type: ignore[attr-defined]
220
+ except Exception: # noqa: BLE001, S110 - close must not fail the task
221
+ pass
222
+ try:
223
+ await asyncio.wait_for(proc.wait(), timeout=1.0)
224
+ except TimeoutError:
225
+ logs.append(
226
+ f"[{self.name}] Process did not exit within "
227
+ f"{self.drain_timeout_seconds:g}s after kill; proceeding."
228
+ )
210
229
  # Always finish draining so lines already written (and any residual
211
230
  # after kill) are captured before we build the result. Bounded: a
212
231
  # grandchild that escaped the process group (daemonized agent, docker
@@ -215,6 +234,11 @@ class ShellAgent(BaseAgent):
215
234
  try:
216
235
  await asyncio.wait_for(readers, timeout=self.drain_timeout_seconds)
217
236
  except TimeoutError:
237
+ readers.cancel()
238
+ try:
239
+ await readers
240
+ except asyncio.CancelledError:
241
+ pass
218
242
  logs.append(
219
243
  f"[{self.name}] Output streams stayed open beyond "
220
244
  f"{self.drain_timeout_seconds:g}s; proceeding without them."
@@ -111,6 +111,11 @@ class GithubClient:
111
111
  ) from exc
112
112
  return data
113
113
 
114
+ def _repo_path(self) -> str:
115
+ # GitHub API expects owner/repo as two separate path segments;
116
+ # encode each segment but keep the slash between them.
117
+ return "/".join(quote(part, safe="") for part in self.config.repo.split("/"))
118
+
114
119
  def search_issues(
115
120
  self,
116
121
  *,
@@ -118,8 +123,7 @@ class GithubClient:
118
123
  per_page: int = 30,
119
124
  state: str = "all",
120
125
  ) -> list[dict[str, Any]]:
121
- repo = self.config.repo
122
- path = f"/repos/{quote(repo, safe='')}/issues"
126
+ path = f"/repos/{self._repo_path()}/issues"
123
127
  query: dict[str, Any] = {"state": state, "per_page": per_page, "page": page}
124
128
  data = self._request("GET", path, query=query)
125
129
  if isinstance(data, list):
@@ -127,28 +131,23 @@ class GithubClient:
127
131
  return []
128
132
 
129
133
  def get_issue(self, issue_number: int) -> dict[str, Any]:
130
- repo = self.config.repo
131
- path = f"/repos/{quote(repo, safe='')}/issues/{issue_number}"
134
+ path = f"/repos/{self._repo_path()}/issues/{issue_number}"
132
135
  return self._request("GET", path) # type: ignore[no-any-return]
133
136
 
134
137
  def create_issue(self, fields: dict[str, Any]) -> dict[str, Any]:
135
- repo = self.config.repo
136
- path = f"/repos/{quote(repo, safe='')}/issues"
138
+ path = f"/repos/{self._repo_path()}/issues"
137
139
  return self._request("POST", path, payload=fields) # type: ignore[no-any-return]
138
140
 
139
141
  def update_issue(self, issue_number: int, fields: dict[str, Any]) -> dict[str, Any]:
140
- repo = self.config.repo
141
- path = f"/repos/{quote(repo, safe='')}/issues/{issue_number}"
142
+ path = f"/repos/{self._repo_path()}/issues/{issue_number}"
142
143
  return self._request("PATCH", path, payload=fields) # type: ignore[no-any-return]
143
144
 
144
145
  def add_comment(self, issue_number: int, body: str) -> None:
145
- repo = self.config.repo
146
- path = f"/repos/{quote(repo, safe='')}/issues/{issue_number}/comments"
146
+ path = f"/repos/{self._repo_path()}/issues/{issue_number}/comments"
147
147
  self._request("POST", path, payload={"body": body})
148
148
 
149
149
  def delete_issue(self, issue_number: int) -> None:
150
- repo = self.config.repo
151
- path = f"/repos/{quote(repo, safe='')}/issues/{issue_number}"
150
+ path = f"/repos/{self._repo_path()}/issues/{issue_number}"
152
151
  self._request("PATCH", path, payload={"state": "closed"})
153
152
 
154
153
 
@@ -68,7 +68,7 @@ from datetime import datetime, timedelta
68
68
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
69
69
  from pathlib import Path
70
70
  from typing import Any
71
- from urllib.parse import parse_qs, unquote, urlparse
71
+ from urllib.parse import parse_qs, quote, unquote, urlparse
72
72
 
73
73
  from pydantic import ValidationError
74
74
  from rich.console import Console
@@ -88,7 +88,7 @@ from forgeo.instances import (
88
88
  list_instances,
89
89
  registry_path,
90
90
  )
91
- from forgeo.models import ForgeoConfig, Task, TaskStatus
91
+ from forgeo.models import ISSUE_PROVIDERS, ForgeoConfig, Task, TaskStatus
92
92
  from forgeo.paths import daemon_state_path, lock_path, runs_path
93
93
  from forgeo.runs import RunRecorder
94
94
  from forgeo.web_common import (
@@ -119,6 +119,71 @@ DEFAULT_FORGEO_CONFIG_DIR = Path.home() / ".config" / "forgeo"
119
119
  _HOME_PAGE = "/central/index.html"
120
120
  _INSTANCE_PAGE = "/central/instance.html"
121
121
 
122
+ _ISSUE_PROVIDER_LABELS: dict[str, str] = {
123
+ "jira": "Jira",
124
+ "github": "GitHub",
125
+ "gitlab": "GitLab",
126
+ }
127
+
128
+
129
+ def _github_web_base(api_base: str) -> str:
130
+ """Derive the web base from a GitHub API base URL.
131
+
132
+ ``https://api.github.com`` → ``https://github.com``,
133
+ ``https://github.example.com/api/v3`` → ``https://github.example.com``,
134
+ otherwise the base itself when already a web URL.
135
+ """
136
+ base = api_base.rstrip("/")
137
+ if base.endswith("/api/v3"):
138
+ return base[:-7].rstrip("/")
139
+ if "api.github.com" in base:
140
+ return base.replace("api.github.com", "github.com")
141
+ return base
142
+
143
+
144
+ def _external_board_url(config: ForgeoConfig | None) -> str | None:
145
+ """The native backlog URL for an issue provider, or ``None`` for documents."""
146
+ if config is None or not isinstance(config.backlog, str):
147
+ return None
148
+ provider = config.effective_backlog_provider
149
+ base = config.backlog.rstrip("/")
150
+ if provider == "jira":
151
+ if config.jira is None:
152
+ return base
153
+ return f"{base}/issues/?jql={quote(config.jira.jql, safe='')}"
154
+ if provider == "github" and config.github is not None:
155
+ web_base = _github_web_base(base)
156
+ repo = config.github.repo.strip("/")
157
+ return f"{web_base}/{repo}/issues"
158
+ if provider == "gitlab" and config.gitlab is not None:
159
+ repo = config.gitlab.repo.strip("/")
160
+ if repo.isdigit():
161
+ return f"{base}/-/issues"
162
+ return f"{base}/{repo}/-/issues"
163
+ return None
164
+
165
+
166
+ def _external_issue_url(config: ForgeoConfig | None, task_id: str) -> str | None:
167
+ """The native issue URL for one task, or ``None`` for document providers."""
168
+ if config is None or not isinstance(config.backlog, str) or not task_id:
169
+ return None
170
+ provider = config.effective_backlog_provider
171
+ base = config.backlog.rstrip("/")
172
+ if provider == "jira":
173
+ return f"{base}/browse/{quote(task_id, safe='')}"
174
+ if provider == "github" and config.github is not None:
175
+ web_base = _github_web_base(base)
176
+ repo = config.github.repo.strip("/")
177
+ # GitHub ids are numeric; a stale WEB-### prefix is stripped for the URL.
178
+ issue_num = task_id.split("-")[-1] if "-" in task_id and task_id.rsplit("-", 1)[-1].isdigit() else task_id
179
+ return f"{web_base}/{repo}/issues/{issue_num}"
180
+ if provider == "gitlab" and config.gitlab is not None:
181
+ repo = config.gitlab.repo.strip("/")
182
+ if repo.isdigit():
183
+ return f"{base}/-/issues/{task_id}"
184
+ return f"{base}/{repo}/-/issues/{task_id}"
185
+ return None
186
+
122
187
  _WEB_TASK_ID_RE = re.compile(r"^WEB-(\d+)$")
123
188
 
124
189
 
@@ -412,7 +477,7 @@ def _task_payload(
412
477
  tasks: list[Task],
413
478
  config: ForgeoConfig | None = None,
414
479
  ) -> dict[str, Any]:
415
- """Serialize a task for the API, annotating its unsatisfied dependencies.
480
+ """Serialize a task for the API, annotating dependencies and external links.
416
481
 
417
482
  The extra ``unsatisfied_dependencies`` field lists every dependency id
418
483
  that is not ``COMPLETED`` yet (with its current status, or ``missing``
@@ -422,6 +487,8 @@ def _task_payload(
422
487
  (``retry_budget``, the per-task ``retries_left`` override falling back to
423
488
  the config's ``failed_retry_max``) and ``retries_remaining`` so the
424
489
  console can show whether a FAILED task will be retried automatically.
490
+ For issue providers the payload also carries ``external_url`` pointing at
491
+ the native issue page.
425
492
  """
426
493
  payload = task.model_dump(mode="json")
427
494
  payload["unsatisfied_dependencies"] = unsatisfied_dependencies(tasks, task)
@@ -429,6 +496,9 @@ def _task_payload(
429
496
  budget = task.retries_left if task.retries_left is not None else config.failed_retry_max
430
497
  payload["retry_budget"] = budget
431
498
  payload["retries_remaining"] = max(0, budget - task.retry_count)
499
+ external = _external_issue_url(config, task.id)
500
+ if external is not None:
501
+ payload["external_url"] = external
432
502
  return payload
433
503
 
434
504
 
@@ -481,6 +551,29 @@ def _next_run(info: InstanceInfo, config: ForgeoConfig | None) -> str | None:
481
551
  return iso(estimate)
482
552
 
483
553
 
554
+ def _backlog_meta(config: ForgeoConfig | None) -> dict[str, Any]:
555
+ """Provider and external URL for a config, or document defaults."""
556
+ if config is None:
557
+ return {
558
+ "backlog_provider": None,
559
+ "backlog": None,
560
+ "backlog_is_issue_provider": False,
561
+ "external_board_url": None,
562
+ "external_board_label": None,
563
+ }
564
+ provider = config.effective_backlog_provider
565
+ is_issue = provider in ISSUE_PROVIDERS
566
+ label = _ISSUE_PROVIDER_LABELS.get(provider) if is_issue else None
567
+ backlog_value = str(config.backlog)
568
+ return {
569
+ "backlog_provider": provider,
570
+ "backlog": backlog_value,
571
+ "backlog_is_issue_provider": is_issue,
572
+ "external_board_url": _external_board_url(config),
573
+ "external_board_label": label,
574
+ }
575
+
576
+
484
577
  def _status_payload(info: InstanceInfo) -> dict[str, Any]:
485
578
  """The per-instance status payload."""
486
579
  config = info.config
@@ -493,6 +586,7 @@ def _status_payload(info: InstanceInfo) -> dict[str, Any]:
493
586
  "pid": None,
494
587
  "last_outcome": None,
495
588
  "next_run_at": None,
589
+ **_backlog_meta(None),
496
590
  }
497
591
  state = _daemon_state(config)
498
592
  pid: int | None = read_lock_pid(lock_path(config))
@@ -508,6 +602,7 @@ def _status_payload(info: InstanceInfo) -> dict[str, Any]:
508
602
  "pid": pid,
509
603
  "last_outcome": _last_outcome(config),
510
604
  "next_run_at": _next_run(info, config),
605
+ **_backlog_meta(config),
511
606
  }
512
607
 
513
608
 
@@ -524,6 +619,7 @@ def _summary(info: InstanceInfo) -> dict[str, Any]:
524
619
  "next_run_at": None,
525
620
  "backlog_counts": {status.value: 0 for status in TaskStatus},
526
621
  "backlog_error": None,
622
+ **_backlog_meta(None),
527
623
  }
528
624
  tasks, backlog_error = _read_tasks_or_error(config)
529
625
  return {
@@ -535,6 +631,7 @@ def _summary(info: InstanceInfo) -> dict[str, Any]:
535
631
  "next_run_at": _next_run(info, config),
536
632
  "backlog_counts": backlog_status_counts(tasks),
537
633
  "backlog_error": backlog_error,
634
+ **_backlog_meta(config),
538
635
  }
539
636
 
540
637
 
@@ -1294,7 +1391,7 @@ class CentralWebServer:
1294
1391
  return False
1295
1392
  self._httpd = httpd
1296
1393
  thread = threading.Thread(
1297
- target=httpd.serve_forever,
1394
+ target=lambda: httpd.serve_forever(poll_interval=0.05),
1298
1395
  name="forgeo-central-web",
1299
1396
  daemon=True,
1300
1397
  )
@@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
32
32
 
33
33
  STOP_TIMEOUT_SECONDS = 600.0
34
34
  START_TIMEOUT_SECONDS = 15.0
35
- _POLL_SECONDS = 0.5
35
+ _POLL_SECONDS = 0.05
36
36
 
37
37
 
38
38
  class DaemonError(Exception):