@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 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.96.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.59.0"
58
+ "@nanobpm/urban": "^0.61.0"
57
59
  },
58
60
  "devDependencies": {
59
61
  "@biomejs/biome": "^2.4.11",
@@ -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
+ }
@@ -9,14 +9,46 @@
9
9
  "variant": "bar",
10
10
  "title": "Nano Workforce",
11
11
  "items": [
12
- { "label": "Overview", "page": "overview" },
13
- { "label": "Lineage", "page": "lineage" },
14
- { "label": "Convergence", "page": "home" },
15
- { "label": "Epics", "page": "epic" },
16
- { "label": "Feature", "page": "feature" },
17
- { "label": "Tasks", "page": "tasks" },
18
- { "label": "Cockpit", "page": "cockpit" },
19
- { "label": "Board", "page": "board" }
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
  }
@@ -9,14 +9,46 @@
9
9
  "variant": "bar",
10
10
  "title": "Nano Workforce",
11
11
  "items": [
12
- { "label": "Overview", "page": "overview" },
13
- { "label": "Lineage", "page": "lineage" },
14
- { "label": "Convergence", "page": "home" },
15
- { "label": "Epics", "page": "epic" },
16
- { "label": "Feature", "page": "feature" },
17
- { "label": "Tasks", "page": "tasks" },
18
- { "label": "Cockpit", "page": "cockpit" },
19
- { "label": "Board", "page": "board" }
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
- { "label": "Overview", "page": "overview" },
14
- { "label": "Lineage", "page": "lineage" },
15
- { "label": "Convergence", "page": "home" },
16
- { "label": "Epics", "page": "epic" },
17
- { "label": "Feature", "page": "feature" },
18
- { "label": "Tasks", "page": "tasks" },
19
- { "label": "Cockpit", "page": "cockpit" },
20
- { "label": "Board", "page": "board" }
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
  {
@@ -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
- { "label": "Overview", "page": "overview" },
14
- { "label": "Lineage", "page": "lineage" },
15
- { "label": "Convergence", "page": "home" },
16
- { "label": "Epics", "page": "epic" },
17
- { "label": "Feature", "page": "feature" },
18
- { "label": "Tasks", "page": "tasks" },
19
- { "label": "Cockpit", "page": "cockpit" },
20
- { "label": "Board", "page": "board" }
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": "34%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
79
- { "field": "epic_phase", "header": "Phase" },
80
- { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
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" }
@@ -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
- { "label": "Overview", "page": "overview" },
14
- { "label": "Lineage", "page": "lineage" },
15
- { "label": "Convergence", "page": "home" },
16
- { "label": "Epics", "page": "epic" },
17
- { "label": "Feature", "page": "feature" },
18
- { "label": "Tasks", "page": "tasks" },
19
- { "label": "Cockpit", "page": "cockpit" },
20
- { "label": "Board", "page": "board" }
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
  {
@@ -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",
@@ -9,14 +9,46 @@
9
9
  "variant": "bar",
10
10
  "title": "Nano Workforce",
11
11
  "items": [
12
- { "label": "Overview", "page": "overview" },
13
- { "label": "Lineage", "page": "lineage" },
14
- { "label": "Convergence", "page": "home" },
15
- { "label": "Epics", "page": "epic" },
16
- { "label": "Feature", "page": "feature" },
17
- { "label": "Tasks", "page": "tasks" },
18
- { "label": "Cockpit", "page": "cockpit" },
19
- { "label": "Board", "page": "board" }
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
- { "label": "Overview", "page": "overview" },
14
- { "label": "Lineage", "page": "lineage" },
15
- { "label": "Convergence", "page": "home" },
16
- { "label": "Epics", "page": "epic" },
17
- { "label": "Feature", "page": "feature" },
18
- { "label": "Tasks", "page": "tasks" },
19
- { "label": "Cockpit", "page": "cockpit" },
20
- { "label": "Board", "page": "board" }
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
  {
@@ -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();