@nanobpm/nano-workforce 0.61.1 → 0.63.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 +14 -0
- package/app/feature.test.ts +2 -0
- package/app/feature.ts +5 -0
- package/nano.app.json +1 -1
- package/package.json +1 -1
- package/pages/cockpit.page.json +1 -0
- package/pages/epic-detail.page.json +1 -0
- package/pages/epic.page.json +1 -0
- package/pages/feature.page.json +1 -0
- package/pages/home.page.json +4 -0
- package/pages/overview.page.json +137 -0
- package/prompts/feature.md +39 -9
- package/scripts/pages-contract.test.ts +54 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.63.0](https://github.com/nanobpm/nano-workforce/compare/v0.62.0...v0.63.0) (2026-08-13)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* Overview landing page with collapsible active-work sections ([#206](https://github.com/nanobpm/nano-workforce/issues/206)) ([64c91df](https://github.com/nanobpm/nano-workforce/commit/64c91dfacd6b9b660a7bbbb1a3c9a8a78603b247)), closes [nanobpm/nano-ide#227](https://github.com/nanobpm/nano-ide/issues/227) [#205](https://github.com/nanobpm/nano-workforce/issues/205)
|
|
7
|
+
|
|
8
|
+
# [0.62.0](https://github.com/nanobpm/nano-workforce/compare/v0.61.1...v0.62.0) (2026-08-13)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **feature-prompt:** claim the issue with a comment before starting work ([#207](https://github.com/nanobpm/nano-workforce/issues/207)) ([b1b23c9](https://github.com/nanobpm/nano-workforce/commit/b1b23c9928704616aa2173cfa056668a11d32e1c))
|
|
14
|
+
|
|
1
15
|
## [0.61.1](https://github.com/nanobpm/nano-workforce/compare/v0.61.0...v0.61.1) (2026-08-13)
|
|
2
16
|
|
|
3
17
|
|
package/app/feature.test.ts
CHANGED
|
@@ -104,6 +104,8 @@ test("startFeature: seeds the single task slice + base-branch brief onto the ins
|
|
|
104
104
|
assertEquals(v.task.prompt.includes("owner/repo#42"), true);
|
|
105
105
|
assertEquals(v.converge, true);
|
|
106
106
|
assertEquals(v.autoMerge, false);
|
|
107
|
+
// A single-issue run owns its issue, so the agent is told it may claim it (epic slices never set this).
|
|
108
|
+
assertEquals(v.claimIssue, true);
|
|
107
109
|
assertEquals(v.baseBranch, "epic/x");
|
|
108
110
|
// The brief is the authoritative base-branch override the agent gets via appendPrompt.
|
|
109
111
|
assertEquals(v.baseBranchBrief.includes("epic/x"), true);
|
package/app/feature.ts
CHANGED
|
@@ -202,6 +202,11 @@ export async function startFeature(
|
|
|
202
202
|
// merge-loop. `converge=false` ⇒ merge is moot.
|
|
203
203
|
converge,
|
|
204
204
|
autoMerge,
|
|
205
|
+
// A single-issue feature run OWNS its issue (the whole issue is the slice), so the agent may
|
|
206
|
+
// claim it with a "starting work" comment on a first run (prompts/feature.md). Epic slices
|
|
207
|
+
// (plan-fanout) deliberately DO NOT set this — their `issue` is the shared parent epic, which
|
|
208
|
+
// must never be claimed per-slice.
|
|
209
|
+
claimIssue: true,
|
|
205
210
|
// Seed the agent-result variables so the escalation loop + record worker can reference them
|
|
206
211
|
// before the first `senior:feature` job completes (the harness merges the real values in).
|
|
207
212
|
answer: null,
|
package/nano.app.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/cockpit.page.json
CHANGED
package/pages/epic.page.json
CHANGED
package/pages/feature.page.json
CHANGED
package/pages/home.page.json
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": "1.0",
|
|
3
|
+
"title": "Overview",
|
|
4
|
+
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"sticky": true,
|
|
11
|
+
"title": "Nano Workforce",
|
|
12
|
+
"items": [
|
|
13
|
+
{ "label": "Overview", "page": "overview" },
|
|
14
|
+
{ "label": "Convergence", "page": "home" },
|
|
15
|
+
{ "label": "Epics", "page": "epic" },
|
|
16
|
+
{ "label": "Feature", "page": "feature" },
|
|
17
|
+
{ "label": "Cockpit", "page": "cockpit" }
|
|
18
|
+
]
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"type": "text",
|
|
23
|
+
"id": "title",
|
|
24
|
+
"props": { "text": "Overview", "variant": "heading" }
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"type": "text",
|
|
28
|
+
"id": "subtitle",
|
|
29
|
+
"props": {
|
|
30
|
+
"text": "Everything currently in flight across the three dispatch surfaces \u2014 PR convergences, Epics, and Feature runs. Each section lists only its active items, remembers whether you collapsed it, and shows a live count in its header. Follow any row to its detail page.",
|
|
31
|
+
"variant": "sub"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"type": "dataGrid",
|
|
36
|
+
"id": "overview-prs",
|
|
37
|
+
"props": {
|
|
38
|
+
"title": "Active PR convergences",
|
|
39
|
+
"collapsible": true,
|
|
40
|
+
"defaultCollapsed": false,
|
|
41
|
+
"showCount": true,
|
|
42
|
+
"rowKey": "pr_key",
|
|
43
|
+
"refreshMs": 5000,
|
|
44
|
+
"data": {
|
|
45
|
+
"kind": "datasource",
|
|
46
|
+
"source": "app",
|
|
47
|
+
"table": "pull_requests",
|
|
48
|
+
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
49
|
+
"filter": [
|
|
50
|
+
{
|
|
51
|
+
"field": "status",
|
|
52
|
+
"in": [
|
|
53
|
+
"converging",
|
|
54
|
+
"waiting_review",
|
|
55
|
+
"escalated",
|
|
56
|
+
"waiting_deps",
|
|
57
|
+
"waiting_merge",
|
|
58
|
+
"queued",
|
|
59
|
+
"merging"
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
},
|
|
64
|
+
"columns": [
|
|
65
|
+
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
66
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
67
|
+
{
|
|
68
|
+
"field": "incident_message",
|
|
69
|
+
"header": "Incident",
|
|
70
|
+
"badge": { "tone": "danger", "label": "1" }
|
|
71
|
+
},
|
|
72
|
+
{ "field": "current_round", "header": "Round" },
|
|
73
|
+
{ "field": "active_worker", "header": "Agent" },
|
|
74
|
+
{ "field": "updated_at", "header": "Updated" }
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"type": "dataGrid",
|
|
80
|
+
"id": "overview-epics",
|
|
81
|
+
"props": {
|
|
82
|
+
"title": "Active Epics",
|
|
83
|
+
"collapsible": true,
|
|
84
|
+
"defaultCollapsed": false,
|
|
85
|
+
"showCount": true,
|
|
86
|
+
"rowKey": "plan_key",
|
|
87
|
+
"refreshMs": 5000,
|
|
88
|
+
"data": {
|
|
89
|
+
"kind": "datasource",
|
|
90
|
+
"source": "app",
|
|
91
|
+
"table": "plans",
|
|
92
|
+
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
93
|
+
"filter": [{ "field": "status", "in": ["planning", "dispatched"] }]
|
|
94
|
+
},
|
|
95
|
+
"columns": [
|
|
96
|
+
{ "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
97
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
98
|
+
{ "field": "delivery", "header": "Delivery" },
|
|
99
|
+
{ "field": "base_branch", "header": "Base branch" },
|
|
100
|
+
{ "field": "wave_label", "header": "Wave" },
|
|
101
|
+
{ "field": "task_count", "header": "Tasks" },
|
|
102
|
+
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
|
103
|
+
{ "field": "updated_at", "header": "Updated" }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"type": "dataGrid",
|
|
109
|
+
"id": "overview-features",
|
|
110
|
+
"props": {
|
|
111
|
+
"title": "Active Features",
|
|
112
|
+
"collapsible": true,
|
|
113
|
+
"defaultCollapsed": false,
|
|
114
|
+
"showCount": true,
|
|
115
|
+
"rowKey": "feature_key",
|
|
116
|
+
"refreshMs": 5000,
|
|
117
|
+
"data": {
|
|
118
|
+
"kind": "datasource",
|
|
119
|
+
"source": "app",
|
|
120
|
+
"table": "feature_runs",
|
|
121
|
+
"orderBy": { "field": "updated_at", "dir": "desc" },
|
|
122
|
+
"filter": [{ "field": "status", "in": ["running", "awaiting_operator"] }]
|
|
123
|
+
},
|
|
124
|
+
"columns": [
|
|
125
|
+
{ "field": "feature_key", "header": "Feature", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
|
|
126
|
+
{ "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
127
|
+
{ "field": "base_branch", "header": "Base branch" },
|
|
128
|
+
{ "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
|
|
129
|
+
{ "field": "converge", "header": "Converge" },
|
|
130
|
+
{ "field": "auto_merge", "header": "Auto-merge" },
|
|
131
|
+
{ "field": "outcome", "header": "Outcome" },
|
|
132
|
+
{ "field": "updated_at", "header": "Updated" }
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
}
|
package/prompts/feature.md
CHANGED
|
@@ -22,16 +22,44 @@ You have `gh` / git authenticated for the target repository.
|
|
|
22
22
|
|
|
23
23
|
Always use the branch **`feat/<task.id>`**. Because a resumed run gets a fresh
|
|
24
24
|
process with no memory of your last run, the branch name MUST be derivable from
|
|
25
|
-
`task.id` alone. On start, check whether it already exists on the remote
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
`task.id` alone. On start, check whether it already exists on the remote with
|
|
26
|
+
`git ls-remote --heads origin feat/<task.id>` — a non-empty result means the
|
|
27
|
+
branch exists. Use this ref check as the authoritative first-run/resume signal:
|
|
28
|
+
a remote branch can exist without any PR, so `gh pr list --head feat/<task.id> --state all`
|
|
29
|
+
is reliable only as a *supplementary* PR lookup once `git ls-remote` has
|
|
30
|
+
established the branch exists, never as the existence check itself:
|
|
28
31
|
|
|
29
32
|
- **It does not exist** → this is a first run. Branch off the base branch (see
|
|
30
33
|
the note below — usually the repository default branch, but an epic may pin an
|
|
31
|
-
integration branch in your appended task context).
|
|
34
|
+
integration branch in your appended task context). **Claim the issue now** — see
|
|
35
|
+
below.
|
|
32
36
|
- **It exists** → this is a **resume**. `git fetch` and check it out, read its diff
|
|
33
37
|
and any open (draft) PR, and **continue from there** — do not restart from
|
|
34
|
-
scratch. Fold in `variables.answer` as the guidance you were waiting on.
|
|
38
|
+
scratch. Fold in `variables.answer` as the guidance you were waiting on. Do
|
|
39
|
+
**not** re-claim — you already announced this run on your first pass.
|
|
40
|
+
|
|
41
|
+
## Claim your issue on a first run (only when `variables.claimIssue` is true)
|
|
42
|
+
|
|
43
|
+
So humans and other agents can see the work has been picked up, announce yourself
|
|
44
|
+
on the issue **before implementing** — but ONLY on a first run (your
|
|
45
|
+
`feat/<task.id>` branch did not yet exist, per the check above) AND only when
|
|
46
|
+
**`variables.claimIssue` is true**:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
gh issue comment <variables.issue> --body-file - <<'BODY'
|
|
50
|
+
🤖 Starting work on this issue — branch `feat/<task.id>`.
|
|
51
|
+
BODY
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- `variables.claimIssue` is set **only for single-issue feature runs**, where
|
|
55
|
+
`variables.issue` IS the issue you implement and close, so claiming it is
|
|
56
|
+
correct. **Epic slices leave it unset** — their `variables.issue` is the shared
|
|
57
|
+
*parent epic*, which must never be claimed per-slice; when `claimIssue` is not
|
|
58
|
+
true, **skip this step entirely**.
|
|
59
|
+
- The branch-existence check above is what makes this idempotent: a resumed run
|
|
60
|
+
(branch already exists) never re-claims, so the issue gets exactly one claim.
|
|
61
|
+
- It is a courtesy claim, **not a gate** — if the comment fails (e.g. a transient
|
|
62
|
+
`gh` error), carry on and implement anyway.
|
|
35
63
|
|
|
36
64
|
## Your base branch (default branch, unless the epic pins one)
|
|
37
65
|
|
|
@@ -44,14 +72,16 @@ against the wrong base will not be merged into the epic.
|
|
|
44
72
|
|
|
45
73
|
## What to do
|
|
46
74
|
|
|
47
|
-
1.
|
|
75
|
+
1. Claim your issue if `variables.claimIssue` is true and this is a first run (see
|
|
76
|
+
above).
|
|
77
|
+
2. Clone / check out your base branch (first run — the default branch, or the
|
|
48
78
|
pinned epic branch if your context names one) or your existing
|
|
49
79
|
`feat/<task.id>` branch (resume — see above).
|
|
50
|
-
|
|
51
|
-
|
|
80
|
+
3. Implement `task.prompt`. Keep the change scoped to this slice only.
|
|
81
|
+
4. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the
|
|
52
82
|
branch, and open a pull request with `gh pr create` describing the slice and
|
|
53
83
|
linking the parent issue (`Depends-on:`/`Closes` as appropriate).
|
|
54
|
-
|
|
84
|
+
5. Clean up any scratch clone/worktree you created outside the commit.
|
|
55
85
|
|
|
56
86
|
> **Do not request the Copilot review yourself.** When you open a *ready* PR the
|
|
57
87
|
> app enrolls it into the review-convergence loop and requests the initial
|
|
@@ -230,3 +230,57 @@ test("issue #87: plan_reviews is surfaced on the per-epic detail page", async ()
|
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
232
|
});
|
|
233
|
+
|
|
234
|
+
test("issue #205: overview is the landing page and first nav item", async () => {
|
|
235
|
+
const overview = JSON.parse(readFileSync(`${ROOT}pages/overview.page.json`, "utf8"));
|
|
236
|
+
|
|
237
|
+
// The overview must be the pages-surface home so it's the default destination.
|
|
238
|
+
const app = JSON.parse(readFileSync(`${ROOT}nano.app.json`, "utf8"));
|
|
239
|
+
assert(
|
|
240
|
+
app?.surfaces?.pages?.homePage === "overview",
|
|
241
|
+
"nano.app.json surfaces.pages.homePage must be \"overview\" (the landing page)",
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Every page's nav must lead with Overview so it's the first tab everywhere.
|
|
245
|
+
for (const e of readdirSync(`${ROOT}pages`, { withFileTypes: true })) {
|
|
246
|
+
if (!e.isFile() || !e.name.endsWith(".page.json")) continue;
|
|
247
|
+
const page = JSON.parse(readFileSync(`${ROOT}pages/${e.name}`, "utf8"));
|
|
248
|
+
const nav = (page.nodes ?? []).find((n: Json) => n.type === "nav");
|
|
249
|
+
if (!nav) continue;
|
|
250
|
+
const first = nav.props?.items?.[0];
|
|
251
|
+
assert(
|
|
252
|
+
first?.page === "overview" && first?.label === "Overview",
|
|
253
|
+
`${e.name}: nav must lead with the Overview tab (first item page "overview")`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Three collapsible active-work sections, one per dispatch surface, each with a
|
|
258
|
+
// live count in its header (showCount) and a persisted collapse toggle (collapsible).
|
|
259
|
+
const expected: Record<string, string[]> = {
|
|
260
|
+
pull_requests: [
|
|
261
|
+
"converging",
|
|
262
|
+
"waiting_review",
|
|
263
|
+
"escalated",
|
|
264
|
+
"waiting_deps",
|
|
265
|
+
"waiting_merge",
|
|
266
|
+
"queued",
|
|
267
|
+
"merging",
|
|
268
|
+
],
|
|
269
|
+
plans: ["planning", "dispatched"],
|
|
270
|
+
feature_runs: ["running", "awaiting_operator"],
|
|
271
|
+
};
|
|
272
|
+
const grids = (overview.nodes ?? []).filter((n: Json) => n.type === "dataGrid");
|
|
273
|
+
for (const [table, statuses] of Object.entries(expected)) {
|
|
274
|
+
const grid = grids.find((g: Json) => g.props?.data?.table === table);
|
|
275
|
+
assert(grid, `overview.page.json must have a section bound to "${table}"`);
|
|
276
|
+
assert(grid.props.collapsible === true, `overview "${table}" section must be collapsible`);
|
|
277
|
+
assert(grid.props.showCount === true, `overview "${table}" section must show a live count`);
|
|
278
|
+
const filter = grid.props?.data?.filter?.find((f: Json) => f.field === "status");
|
|
279
|
+
assert(filter, `overview "${table}" section must filter on status`);
|
|
280
|
+
assert(
|
|
281
|
+
JSON.stringify([...filter.in].sort()) === JSON.stringify([...statuses].sort()),
|
|
282
|
+
`overview "${table}" section must filter to the active statuses ${JSON.stringify(statuses)}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|