@nanobpm/nano-workforce 0.96.0 → 0.97.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/AGENTS.md +11 -0
- package/CHANGELOG.md +14 -0
- package/package.json +4 -2
- package/pages/_nav.json +30 -0
- package/pages/board.page.json +40 -8
- package/pages/cockpit.page.json +40 -8
- package/pages/epic-detail.page.json +42 -10
- package/pages/epic.page.json +45 -19
- package/pages/feature.page.json +42 -10
- package/pages/home.page.json +9 -1
- package/pages/lineage.page.json +40 -8
- package/pages/overview.page.json +42 -10
- package/pages/tasks.page.json +11 -2
- package/scripts/sync-nav.test.ts +87 -0
- package/scripts/sync-nav.ts +126 -0
package/AGENTS.md
CHANGED
|
@@ -236,6 +236,17 @@ silently — cheap to avoid, annoying to debug after the fact.
|
|
|
236
236
|
correctly hides it either way. (This is why the same flag can need `NULL` for a
|
|
237
237
|
badge yet work as `0` for a `showWhenField` button.)
|
|
238
238
|
|
|
239
|
+
### The top nav has a single source of truth — edit `pages/_nav.json`
|
|
240
|
+
|
|
241
|
+
The `nav` node (the top-bar item list, including the **Tasks** live open-tasks
|
|
242
|
+
count badge) is **not** authored per page. It lives once in `pages/_nav.json` and
|
|
243
|
+
is materialised into every `pages/*.page.json` by `scripts/sync-nav.ts`. To change
|
|
244
|
+
a nav item or badge, edit `pages/_nav.json` then run **`npm run sync:nav`** — never
|
|
245
|
+
hand-edit the `nav` node in a page file. `scripts/sync-nav.test.ts` (run under
|
|
246
|
+
`npm test`) fails if any page's nav node drifts from the canonical source, and
|
|
247
|
+
`npm run sync:nav:check` is the CI-friendly verify. This is the "no drift surfaces"
|
|
248
|
+
rule applied to the nav that was previously copy-pasted across every page.
|
|
249
|
+
|
|
239
250
|
## The poller owns liveness/reconciliation
|
|
240
251
|
|
|
241
252
|
`main.ts` runs a **self-scheduling** poll loop (`pollOnce` in `app/service.ts`),
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.97.0](https://github.com/nanobpm/nano-workforce/compare/v0.96.1...v0.97.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **nav:** live open-tasks count badge on the Tasks nav item ([#306](https://github.com/nanobpm/nano-workforce/issues/306)) ([#330](https://github.com/nanobpm/nano-workforce/issues/330)) ([10aa55a](https://github.com/nanobpm/nano-workforce/commit/10aa55af9a3d5c60f13391f9e17361b6b33ac653)), closes [nano-ide#338](https://github.com/nano-ide/issues/338) [nano-ide#342](https://github.com/nano-ide/issues/342)
|
|
7
|
+
|
|
8
|
+
## [0.96.1](https://github.com/nanobpm/nano-workforce/compare/v0.96.0...v0.96.1) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pages:** redesign the Epics table to a lean 6-column list ([#327](https://github.com/nanobpm/nano-workforce/issues/327)) ([#328](https://github.com/nanobpm/nano-workforce/issues/328)) ([b5842b3](https://github.com/nanobpm/nano-workforce/commit/b5842b33c89abb2db0aa6161fdddf32293452771)), closes [#87](https://github.com/nanobpm/nano-workforce/issues/87)
|
|
14
|
+
|
|
1
15
|
# [0.96.0](https://github.com/nanobpm/nano-workforce/compare/v0.95.0...v0.96.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.97.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",
|
|
@@ -43,6 +43,8 @@
|
|
|
43
43
|
"gen:check": "urban gen --check",
|
|
44
44
|
"layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
|
|
45
45
|
"layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
|
|
46
|
+
"sync:nav": "node --experimental-strip-types scripts/sync-nav.ts",
|
|
47
|
+
"sync:nav:check": "node --experimental-strip-types scripts/sync-nav.ts --check",
|
|
46
48
|
"dev": "urban dev",
|
|
47
49
|
"pretest": "urban gen",
|
|
48
50
|
"test": "node --experimental-strip-types --test",
|
|
@@ -53,7 +55,7 @@
|
|
|
53
55
|
},
|
|
54
56
|
"dependencies": {
|
|
55
57
|
"@nanobpm/agentic": "^0.1.0",
|
|
56
|
-
"@nanobpm/urban": "^0.
|
|
58
|
+
"@nanobpm/urban": "^0.61.0"
|
|
57
59
|
},
|
|
58
60
|
"devDependencies": {
|
|
59
61
|
"@biomejs/biome": "^2.4.11",
|
package/pages/_nav.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "nav",
|
|
3
|
+
"id": "nav",
|
|
4
|
+
"props": {
|
|
5
|
+
"variant": "bar",
|
|
6
|
+
"title": "Nano Workforce",
|
|
7
|
+
"items": [
|
|
8
|
+
{ "label": "Overview", "page": "overview" },
|
|
9
|
+
{ "label": "Lineage", "page": "lineage" },
|
|
10
|
+
{ "label": "Convergence", "page": "home" },
|
|
11
|
+
{ "label": "Epics", "page": "epic" },
|
|
12
|
+
{ "label": "Feature", "page": "feature" },
|
|
13
|
+
{
|
|
14
|
+
"label": "Tasks",
|
|
15
|
+
"page": "tasks",
|
|
16
|
+
"badge": {
|
|
17
|
+
"source": "app",
|
|
18
|
+
"table": "user_tasks",
|
|
19
|
+
"filter": [],
|
|
20
|
+
"tone": "danger",
|
|
21
|
+
"refreshMs": 5000,
|
|
22
|
+
"hideWhenZero": true
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
{ "label": "Cockpit", "page": "cockpit" },
|
|
26
|
+
{ "label": "Board", "page": "board" }
|
|
27
|
+
],
|
|
28
|
+
"sticky": true
|
|
29
|
+
}
|
|
30
|
+
}
|
package/pages/board.page.json
CHANGED
|
@@ -9,14 +9,46 @@
|
|
|
9
9
|
"variant": "bar",
|
|
10
10
|
"title": "Nano Workforce",
|
|
11
11
|
"items": [
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Tasks",
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "Cockpit",
|
|
46
|
+
"page": "cockpit"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Board",
|
|
50
|
+
"page": "board"
|
|
51
|
+
}
|
|
20
52
|
],
|
|
21
53
|
"sticky": true
|
|
22
54
|
}
|
package/pages/cockpit.page.json
CHANGED
|
@@ -9,14 +9,46 @@
|
|
|
9
9
|
"variant": "bar",
|
|
10
10
|
"title": "Nano Workforce",
|
|
11
11
|
"items": [
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Tasks",
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "Cockpit",
|
|
46
|
+
"page": "cockpit"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Board",
|
|
50
|
+
"page": "board"
|
|
51
|
+
}
|
|
20
52
|
],
|
|
21
53
|
"sticky": true
|
|
22
54
|
}
|
|
@@ -7,18 +7,50 @@
|
|
|
7
7
|
"id": "nav",
|
|
8
8
|
"props": {
|
|
9
9
|
"variant": "bar",
|
|
10
|
-
"sticky": true,
|
|
11
10
|
"title": "Nano Workforce",
|
|
12
11
|
"items": [
|
|
13
|
-
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Tasks",
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "Cockpit",
|
|
46
|
+
"page": "cockpit"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Board",
|
|
50
|
+
"page": "board"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"sticky": true
|
|
22
54
|
}
|
|
23
55
|
},
|
|
24
56
|
{
|
package/pages/epic.page.json
CHANGED
|
@@ -7,18 +7,50 @@
|
|
|
7
7
|
"id": "nav",
|
|
8
8
|
"props": {
|
|
9
9
|
"variant": "bar",
|
|
10
|
-
"sticky": true,
|
|
11
10
|
"title": "Nano Workforce",
|
|
12
11
|
"items": [
|
|
13
|
-
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Tasks",
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "Cockpit",
|
|
46
|
+
"page": "cockpit"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Board",
|
|
50
|
+
"page": "board"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"sticky": true
|
|
22
54
|
}
|
|
23
55
|
},
|
|
24
56
|
{
|
|
@@ -75,15 +107,9 @@
|
|
|
75
107
|
{ "label": "All", "filter": [] }
|
|
76
108
|
],
|
|
77
109
|
"columns": [
|
|
78
|
-
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "
|
|
79
|
-
{ "field": "epic_phase", "header": "Phase" },
|
|
80
|
-
{ "field": "
|
|
81
|
-
{ "field": "delivery", "header": "Delivery" },
|
|
82
|
-
{ "field": "wait_gate_label", "header": "Wait gate" },
|
|
83
|
-
{ "field": "promotion_state", "header": "Promotion" },
|
|
84
|
-
{ "field": "delivery_label", "header": "Landing" },
|
|
85
|
-
{ "field": "base_branch", "header": "Base branch" },
|
|
86
|
-
{ "field": "wave_label", "header": "Wave" },
|
|
110
|
+
{ "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "38%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
|
|
111
|
+
{ "field": "epic_phase", "header": "Phase", "link": { "kind": "processExplorer", "keyField": "process_key" } },
|
|
112
|
+
{ "field": "delivery_label", "header": "Delivery", "truncate": true },
|
|
87
113
|
{ "field": "task_count", "header": "Tasks" },
|
|
88
114
|
{ "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
|
|
89
115
|
{ "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
|
package/pages/feature.page.json
CHANGED
|
@@ -7,18 +7,50 @@
|
|
|
7
7
|
"id": "nav",
|
|
8
8
|
"props": {
|
|
9
9
|
"variant": "bar",
|
|
10
|
-
"sticky": true,
|
|
11
10
|
"title": "Nano Workforce",
|
|
12
11
|
"items": [
|
|
13
|
-
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Tasks",
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "Cockpit",
|
|
46
|
+
"page": "cockpit"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Board",
|
|
50
|
+
"page": "board"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"sticky": true
|
|
22
54
|
}
|
|
23
55
|
},
|
|
24
56
|
{
|
package/pages/home.page.json
CHANGED
|
@@ -31,7 +31,15 @@
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"label": "Tasks",
|
|
34
|
-
"page": "tasks"
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
35
43
|
},
|
|
36
44
|
{
|
|
37
45
|
"label": "Cockpit",
|
package/pages/lineage.page.json
CHANGED
|
@@ -9,14 +9,46 @@
|
|
|
9
9
|
"variant": "bar",
|
|
10
10
|
"title": "Nano Workforce",
|
|
11
11
|
"items": [
|
|
12
|
-
{
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Tasks",
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "Cockpit",
|
|
46
|
+
"page": "cockpit"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Board",
|
|
50
|
+
"page": "board"
|
|
51
|
+
}
|
|
20
52
|
],
|
|
21
53
|
"sticky": true
|
|
22
54
|
}
|
package/pages/overview.page.json
CHANGED
|
@@ -7,18 +7,50 @@
|
|
|
7
7
|
"id": "nav",
|
|
8
8
|
"props": {
|
|
9
9
|
"variant": "bar",
|
|
10
|
-
"sticky": true,
|
|
11
10
|
"title": "Nano Workforce",
|
|
12
11
|
"items": [
|
|
13
|
-
{
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
12
|
+
{
|
|
13
|
+
"label": "Overview",
|
|
14
|
+
"page": "overview"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"label": "Lineage",
|
|
18
|
+
"page": "lineage"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"label": "Convergence",
|
|
22
|
+
"page": "home"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"label": "Epics",
|
|
26
|
+
"page": "epic"
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"label": "Feature",
|
|
30
|
+
"page": "feature"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"label": "Tasks",
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"label": "Cockpit",
|
|
46
|
+
"page": "cockpit"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"label": "Board",
|
|
50
|
+
"page": "board"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"sticky": true
|
|
22
54
|
}
|
|
23
55
|
},
|
|
24
56
|
{
|
package/pages/tasks.page.json
CHANGED
|
@@ -31,7 +31,15 @@
|
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
33
|
"label": "Tasks",
|
|
34
|
-
"page": "tasks"
|
|
34
|
+
"page": "tasks",
|
|
35
|
+
"badge": {
|
|
36
|
+
"source": "app",
|
|
37
|
+
"table": "user_tasks",
|
|
38
|
+
"filter": [],
|
|
39
|
+
"tone": "danger",
|
|
40
|
+
"refreshMs": 5000,
|
|
41
|
+
"hideWhenZero": true
|
|
42
|
+
}
|
|
35
43
|
},
|
|
36
44
|
{
|
|
37
45
|
"label": "Cockpit",
|
|
@@ -41,7 +49,8 @@
|
|
|
41
49
|
"label": "Board",
|
|
42
50
|
"page": "board"
|
|
43
51
|
}
|
|
44
|
-
]
|
|
52
|
+
],
|
|
53
|
+
"sticky": true
|
|
45
54
|
}
|
|
46
55
|
},
|
|
47
56
|
{
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Drift guard for the single-source-of-truth nav (issue #306).
|
|
2
|
+
//
|
|
3
|
+
// `pages/_nav.json` is the ONE place the top-nav item list (and the Tasks
|
|
4
|
+
// open-tasks count badge) is defined; `scripts/sync-nav.ts` materialises it into
|
|
5
|
+
// every `pages/*.page.json`. This test fails if any page's nav node diverges from
|
|
6
|
+
// the canonical source, so the eight copies can never silently drift apart again
|
|
7
|
+
// (AGENTS.md: "Derivation over duplication: no drift surfaces"). Run `npm run
|
|
8
|
+
// sync:nav` to reconcile.
|
|
9
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import { assert } from "#test-assert";
|
|
12
|
+
|
|
13
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
14
|
+
|
|
15
|
+
type Json = any;
|
|
16
|
+
|
|
17
|
+
// Deterministic, key-sorted serialization: compare nav nodes by value, not by
|
|
18
|
+
// authored key order or whitespace.
|
|
19
|
+
function stable(value: Json): string {
|
|
20
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
21
|
+
if (value && typeof value === "object") {
|
|
22
|
+
const keys = Object.keys(value).sort();
|
|
23
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
|
|
24
|
+
}
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function navNodeOf(page: Json): Json {
|
|
29
|
+
const nav = (page.nodes ?? []).find((n: Json) => n?.type === "nav");
|
|
30
|
+
assert(nav, "page has no nav node");
|
|
31
|
+
return nav;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pageFiles(): string[] {
|
|
35
|
+
return readdirSync(`${ROOT}pages`)
|
|
36
|
+
.filter((n) => n.endsWith(".page.json"))
|
|
37
|
+
.sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("issue #306: every page's nav node matches the canonical pages/_nav.json", () => {
|
|
41
|
+
const canon = JSON.parse(readFileSync(`${ROOT}pages/_nav.json`, "utf8"));
|
|
42
|
+
const want = stable(canon);
|
|
43
|
+
const files = pageFiles();
|
|
44
|
+
assert(files.length > 0, "no page files found");
|
|
45
|
+
for (const name of files) {
|
|
46
|
+
const page = JSON.parse(readFileSync(`${ROOT}pages/${name}`, "utf8"));
|
|
47
|
+
const nav = navNodeOf(page);
|
|
48
|
+
assert(
|
|
49
|
+
stable(nav) === want,
|
|
50
|
+
`${name}: nav node has drifted from pages/_nav.json — run \`npm run sync:nav\``,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("issue #306: the Tasks nav item carries the live open-tasks count badge", () => {
|
|
56
|
+
const canon = JSON.parse(readFileSync(`${ROOT}pages/_nav.json`, "utf8"));
|
|
57
|
+
const items = canon?.props?.items ?? [];
|
|
58
|
+
const tasks = items.find((i: Json) => i?.page === "tasks");
|
|
59
|
+
assert(tasks, "canonical nav has no Tasks item");
|
|
60
|
+
const badge = tasks.badge;
|
|
61
|
+
assert(badge, "Tasks nav item must declare a live count badge");
|
|
62
|
+
// The badge counts the single open-escalation projection (user_tasks) with no
|
|
63
|
+
// filter, so count(user_tasks) is exactly "decisions awaiting a human"; danger
|
|
64
|
+
// tone, 5s refresh, hidden at zero to keep a quiet nav clean.
|
|
65
|
+
assert(badge.source === "app", "badge.source must be \"app\"");
|
|
66
|
+
assert(badge.table === "user_tasks", "badge.table must be \"user_tasks\"");
|
|
67
|
+
assert(Array.isArray(badge.filter) && badge.filter.length === 0, "badge.filter must be []");
|
|
68
|
+
assert(badge.tone === "danger", "badge.tone must be \"danger\"");
|
|
69
|
+
assert(badge.refreshMs === 5000, "badge.refreshMs must be 5000");
|
|
70
|
+
assert(badge.hideWhenZero === true, "badge.hideWhenZero must be true");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("issue #306: the badge's user_tasks table is defined by the migrations", () => {
|
|
74
|
+
// The runtime whitelists the badge's datasource table against the live schema; a
|
|
75
|
+
// table the migrations never created would 400 the count fetch. Guard it here so
|
|
76
|
+
// a rename fails CI, mirroring scripts/pages-contract.test.ts.
|
|
77
|
+
let sql = "";
|
|
78
|
+
for (const e of readdirSync(`${ROOT}db/migrations`, { withFileTypes: true })) {
|
|
79
|
+
if (e.isFile() && e.name.endsWith(".sql")) {
|
|
80
|
+
sql += readFileSync(`${ROOT}db/migrations/${e.name}`, "utf8");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
assert(
|
|
84
|
+
/CREATE\s+(?:TABLE|VIEW)\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?user_tasks["`]?/i.test(sql),
|
|
85
|
+
"badge table \"user_tasks\" must be created (TABLE or VIEW) by db/migrations/*.sql",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Single source of truth for the pages' top nav (issue #306).
|
|
2
|
+
//
|
|
3
|
+
// The `nav` node was copy-pasted verbatim into all `pages/*.page.json` files — a
|
|
4
|
+
// classic drift surface (AGENTS.md: "Derivation over duplication"). Adding the
|
|
5
|
+
// live open-tasks count badge to the Tasks item would have meant editing it in
|
|
6
|
+
// eight places. Instead the nav node is authored ONCE in `pages/_nav.json` and
|
|
7
|
+
// materialised into every page by this script; `scripts/sync-nav.test.ts` (run
|
|
8
|
+
// under `npm test`) is the CI drift guard that fails if any page's nav node
|
|
9
|
+
// diverges from the canonical source.
|
|
10
|
+
//
|
|
11
|
+
// node --experimental-strip-types scripts/sync-nav.ts # write pages
|
|
12
|
+
// node --experimental-strip-types scripts/sync-nav.ts --check # verify (CI)
|
|
13
|
+
//
|
|
14
|
+
// Mirrors the repo's other derive/verify pairs (layout-bpmn --check,
|
|
15
|
+
// check-contracts / reconcile-contracts).
|
|
16
|
+
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import process from "node:process";
|
|
18
|
+
|
|
19
|
+
const ROOT = decodeURIComponent(new URL("../", import.meta.url).pathname);
|
|
20
|
+
const PAGES_DIR = `${ROOT}pages`;
|
|
21
|
+
const CANON_PATH = `${PAGES_DIR}/_nav.json`;
|
|
22
|
+
|
|
23
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
24
|
+
|
|
25
|
+
function isRecord(v: unknown): v is Record<string, JsonValue> {
|
|
26
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Deterministic, key-sorted serialization so two nav nodes are compared by value,
|
|
30
|
+
// not by authored key order or whitespace.
|
|
31
|
+
function stable(value: JsonValue): string {
|
|
32
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
|
|
33
|
+
if (isRecord(value)) {
|
|
34
|
+
const keys = Object.keys(value).sort();
|
|
35
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stable(value[k])}`).join(",")}}`;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readJson(path: string): JsonValue {
|
|
41
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Locate the character span of the sole `nav` node object inside a page's raw
|
|
45
|
+
// text, so it can be replaced in place without reformatting the rest of the file.
|
|
46
|
+
function findNavSpan(text: string, file: string): { start: number; end: number } {
|
|
47
|
+
const marker = text.indexOf('"type": "nav"');
|
|
48
|
+
if (marker < 0) throw new Error(`${file}: no nav node found`);
|
|
49
|
+
const start = text.lastIndexOf("{", marker);
|
|
50
|
+
if (start < 0) throw new Error(`${file}: malformed nav node (no opening brace)`);
|
|
51
|
+
let depth = 0;
|
|
52
|
+
let inString = false;
|
|
53
|
+
let escaped = false;
|
|
54
|
+
for (let i = start; i < text.length; i++) {
|
|
55
|
+
const ch = text[i];
|
|
56
|
+
if (inString) {
|
|
57
|
+
if (escaped) escaped = false;
|
|
58
|
+
else if (ch === "\\") escaped = true;
|
|
59
|
+
else if (ch === '"') inString = false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ch === '"') inString = true;
|
|
63
|
+
else if (ch === "{") depth++;
|
|
64
|
+
else if (ch === "}") {
|
|
65
|
+
depth--;
|
|
66
|
+
if (depth === 0) return { start, end: i + 1 };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
throw new Error(`${file}: unbalanced nav node braces`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The canonical node rendered at the indentation a page's `nodes[]` element sits
|
|
73
|
+
// at (its opening brace is already indented by 4 spaces in the file, so only the
|
|
74
|
+
// following lines are prefixed).
|
|
75
|
+
function renderNavBlock(canon: JsonValue): string {
|
|
76
|
+
const lines = JSON.stringify(canon, null, 2).split("\n");
|
|
77
|
+
return lines.map((line, i) => (i === 0 ? line : ` ${line}`)).join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pageFiles(): string[] {
|
|
81
|
+
return readdirSync(PAGES_DIR)
|
|
82
|
+
.filter((n) => n.endsWith(".page.json"))
|
|
83
|
+
.sort();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function main(): void {
|
|
87
|
+
const check = process.argv.includes("--check");
|
|
88
|
+
const canon = readJson(CANON_PATH);
|
|
89
|
+
const wantStable = stable(canon);
|
|
90
|
+
const block = renderNavBlock(canon);
|
|
91
|
+
|
|
92
|
+
const drifted: string[] = [];
|
|
93
|
+
let changed = 0;
|
|
94
|
+
|
|
95
|
+
for (const name of pageFiles()) {
|
|
96
|
+
const path = `${PAGES_DIR}/${name}`;
|
|
97
|
+
const text = readFileSync(path, "utf8");
|
|
98
|
+
const span = findNavSpan(text, name);
|
|
99
|
+
const currentText = text.slice(span.start, span.end);
|
|
100
|
+
const current: JsonValue = JSON.parse(currentText);
|
|
101
|
+
if (stable(current) === wantStable) continue;
|
|
102
|
+
|
|
103
|
+
if (check) {
|
|
104
|
+
drifted.push(name);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const next = `${text.slice(0, span.start)}${block}${text.slice(span.end)}`;
|
|
108
|
+
writeFileSync(path, next);
|
|
109
|
+
changed++;
|
|
110
|
+
process.stdout.write(`synced nav → pages/${name}\n`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (check) {
|
|
114
|
+
if (drifted.length > 0) {
|
|
115
|
+
process.stderr.write(
|
|
116
|
+
`nav drift in: ${drifted.join(", ")}\nFix with: npm run sync:nav (source of truth: pages/_nav.json)\n`,
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
process.stdout.write("nav is in sync across all pages\n");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
process.stdout.write(changed === 0 ? "nav already in sync\n" : `synced ${changed} page(s)\n`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
main();
|