@nanobpm/nano-workforce 0.96.1 → 0.97.1

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.1](https://github.com/nanobpm/nano-workforce/compare/v0.97.0...v0.97.1) (2026-08-19)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge-loop:** give merge-blocked escalation a question + gw-escalated guard ([#329](https://github.com/nanobpm/nano-workforce/issues/329)) ([#331](https://github.com/nanobpm/nano-workforce/issues/331)) ([abb2952](https://github.com/nanobpm/nano-workforce/commit/abb2952b5ff5874ff928085167f4a0fd5f0580e5))
7
+
8
+ # [0.97.0](https://github.com/nanobpm/nano-workforce/compare/v0.96.1...v0.97.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * **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)
14
+
1
15
  ## [0.96.1](https://github.com/nanobpm/nano-workforce/compare/v0.96.0...v0.96.1) (2026-08-19)
2
16
 
3
17
 
@@ -0,0 +1,140 @@
1
+ // Regression guard for the question-less merge escalation defect (issue #329).
2
+ //
3
+ // The merge loop (`resources/processes/merge-loop.bpmn`) raised escalations with NO question
4
+ // whenever a PR was blocked by anything other than a merge conflict: `merge-esc-attempt` called
5
+ // `pr.persist-escalation` with no `question`/`status` ioMapping, and its output flowed
6
+ // UNCONDITIONALLY into `wait-merge-answer`. Two coupled defects fell out of that:
7
+ //
8
+ // 1. A blank question surfaced on the merge-driving inbox — the human was asked to answer but
9
+ // told nothing (observed live on nano-ide PR #354).
10
+ // 2. Per ADR 0002 §1 a blank question is a NON-escalation: `pr.persist-escalation` opens no row
11
+ // and returns `escalated:false`. The convergence loop honours this via a `gw-escalated`
12
+ // branch; the merge loop had none, so a question-less job still parked a dead
13
+ // `wait-merge-answer` with nothing for a human to answer.
14
+ //
15
+ // The fix (mirroring the convergence loop): give `merge-esc-attempt` a human-actionable
16
+ // `status`/`question` that distinguishes its four trigger conditions, and add a `gw-merge-escalated`
17
+ // guard so a `persist-escalation` returning `escalated:false` re-enters the loop (re-arms the
18
+ // poller) instead of parking a dead wait.
19
+ //
20
+ // These are pure text assertions over the committed BPMN (no engine), matching the repo's
21
+ // lightweight model-guard style (see mergeRebaseArm.test.ts, mergeEscalationUserTask.test.ts).
22
+
23
+ import { test } from "node:test";
24
+ import { assert, assertStringIncludes } from "#test-assert";
25
+ import { readFileSync } from "node:fs";
26
+
27
+ const bpmn = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
28
+ // Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
29
+ const flat = bpmn.replace(/\s+/g, " ");
30
+
31
+ function flowHasId(id: string, source: string, target: string): boolean {
32
+ const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*(?:/>|>)`));
33
+ if (!m) return false;
34
+ const tag = m[0];
35
+ return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
36
+ }
37
+
38
+ function gatewayDefault(id: string, def: string): boolean {
39
+ const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
40
+ if (!m) return false;
41
+ return m[0].includes(`default="${def}"`);
42
+ }
43
+
44
+ // The <serviceTask> element for merge-esc-attempt, including its ioMapping. Unescape XML entities so
45
+ // FEEL string literals (authored as `&#34;ready&#34;` inside the attribute) read naturally here.
46
+ const escAttemptRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="merge-esc-attempt"[\s\S]*?<\/bpmn:serviceTask>/);
47
+ const escAttempt = escAttemptRaw
48
+ ? [escAttemptRaw[0].replace(/&#34;/g, '"').replace(/&amp;/g, "&").replace(/&#10;/g, "\n")]
49
+ : null;
50
+
51
+ test("merge-esc-attempt carries a non-blank, human-actionable status + question", () => {
52
+ assert(escAttempt, "merge-esc-attempt service task must exist");
53
+ const el = escAttempt![0];
54
+ // Mirror the merge-esc-conflict mapping style: an explicit `blocked` status…
55
+ assertStringIncludes(el, "<zeebe:ioMapping", "merge-esc-attempt must set an ioMapping (was absent — the #329 defect)");
56
+ assertStringIncludes(el, 'target="status"', "merge-esc-attempt must set a `status`");
57
+ assertStringIncludes(el, 'target="question"', "merge-esc-attempt must set a non-blank `question`");
58
+ // Tighten: assert the explicit `status` INPUT MAPPING sets blocked, not merely the substring
59
+ // `="blocked"` (which the FEEL question's `agentVerdict = "blocked"` comparison would also satisfy
60
+ // even if the status mapping were removed/changed). Match tolerant of attribute order/spacing: the
61
+ // file is XML and a formatter could reorder `source`/`target` within the tag.
62
+ const escInputs = el.match(/<zeebe:input\b[^>]*\/>/g) ?? [];
63
+ const setsBlockedStatus = escInputs.some(
64
+ (t) => t.includes('target="status"') && t.includes('source="="blocked""'),
65
+ );
66
+ assert(setsBlockedStatus, "the explicit `status` input mapping must set `blocked`");
67
+ });
68
+
69
+ test("arm-merge clears the prior verdict `status` so a stale `blocked` cannot misclassify the CI-fix SLA escalation", () => {
70
+ // merge-esc-attempt captures `agentVerdict = status` to split its CI could-not-fix vs SLA question
71
+ // arms. On the SLA boundary path (`f_ci_sla`) no worker sets a fresh `status`, and every escalation
72
+ // task overwrites `status = "blocked"` (merge-esc-attempt line 194, merge-esc-conflict line 174).
73
+ // Without a reset, a retry after any prior escalation re-enters fix-ci with `status` still
74
+ // "blocked", so an SLA timeout would render the wrong ("could not fix") question. arm-merge is the
75
+ // single loop hub every fix-ci entry passes through, so clearing `status` there (to null) each
76
+ // iteration guarantees a genuine SLA reads no stale verdict. Nothing between arm-merge and the next
77
+ // verdict-setter (fix-ci/rebase) reads `status`, so the reset is safe.
78
+ const armRaw = flat.match(/<bpmn:serviceTask\b[^>]*\bid="arm-merge"[\s\S]*?<\/bpmn:serviceTask>/);
79
+ assert(armRaw, "arm-merge service task must exist");
80
+ const armOutputs = armRaw![0].match(/<zeebe:output\b[^>]*\/>/g) ?? [];
81
+ const clearsStatus = armOutputs.some(
82
+ (t) => t.includes('target="status"') && t.includes('source="=null"'),
83
+ );
84
+ assert(clearsStatus, "arm-merge must reset `status` to null each loop iteration so a stale `blocked` cannot misclassify the SLA escalation");
85
+ });
86
+
87
+ test("the question distinguishes all four blocked/SLA triggers rather than a single generic string", () => {
88
+ // Four flows route into merge-esc-attempt — the gate `blocked` default, CI could-not-fix,
89
+ // rebase could-not-resolve, and the CI-fix SLA. Each is a legitimately different escalation and
90
+ // the question must explain which one fired.
91
+ assert(escAttempt, "merge-esc-attempt service task must exist");
92
+ const el = escAttempt![0];
93
+ // gate blocked (gw-merge default): distinguishes on the `ready` mergeState + surfaces mergeStatus.
94
+ assertStringIncludes(el, 'mergeState = "ready"', "must branch on the gate-blocked (ready) trigger");
95
+ assertStringIncludes(el, "mergeStatus", "the gate-blocked question must surface the merge result");
96
+ // rebase could-not-resolve (conflict arm).
97
+ assertStringIncludes(el, 'mergeState = "conflict"', "must branch on the rebase (conflict) trigger");
98
+ // CI could-not-fix vs CI SLA both arrive with mergeState = blocked — split on the agent verdict,
99
+ // captured into a dedicated `agentVerdict` binding so the escalation-classification `status =
100
+ // "blocked"` override in the SAME ioMapping cannot make the SLA branch unreachable (issue #329
101
+ // review). Assert the question branches on that binding, not on the overwritten `status`.
102
+ assertStringIncludes(el, "agentVerdict", "must capture the agent verdict into a dedicated binding");
103
+ assertStringIncludes(el, 'agentVerdict = "blocked"', "must branch CI could-not-fix vs SLA on the agent verdict binding, not the overwritten status");
104
+ });
105
+
106
+ test("a gw-merge-escalated guard honours persist-escalation's escalated:false (mirrors the convergence loop)", () => {
107
+ // The escalation output no longer flows UNCONDITIONALLY into the durable answer wait: it passes
108
+ // through a gateway that reads the worker's `escalated` output.
109
+ assert(
110
+ flowHasId("f_m_escA", "merge-esc-attempt", "gw-merge-escalated"),
111
+ "merge-esc-attempt must route through gw-merge-escalated, not straight to wait-merge-answer",
112
+ );
113
+ // escalated:true → park the native user task for a human to answer.
114
+ assert(
115
+ flowHasId("f_m_escWait", "gw-merge-escalated", "wait-merge-answer"),
116
+ "gw-merge-escalated → wait-merge-answer (escalated) missing",
117
+ );
118
+ const escWait = flat.match(/<bpmn:sequenceFlow[^>]*id="f_m_escWait"[\s\S]*?<\/bpmn:sequenceFlow>/);
119
+ assert(escWait, "f_m_escWait flow missing");
120
+ assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
121
+ // escalated:false (a non-escalation, e.g. a blank question) → re-enter the loop, NOT a dead wait.
122
+ assert(
123
+ gatewayDefault("gw-merge-escalated", "f_m_escReenter"),
124
+ "gw-merge-escalated must default to f_m_escReenter (re-enter, not park)",
125
+ );
126
+ assert(
127
+ flowHasId("f_m_escReenter", "gw-merge-escalated", "arm-merge"),
128
+ "f_m_escReenter must re-arm the merge poller instead of parking a dead wait-merge-answer",
129
+ );
130
+ });
131
+
132
+ test("regression: a question-less escalation can no longer park a dead wait-merge-answer", () => {
133
+ // The exact #329 wedge: `merge-esc-attempt → wait-merge-answer` as a direct, unconditional edge.
134
+ // It must be gone — the only path into the answer wait from the attempt arm is now guarded by
135
+ // `escalated = true`.
136
+ assert(
137
+ !flowHasId("f_m_escA", "merge-esc-attempt", "wait-merge-answer"),
138
+ "merge-esc-attempt must NOT flow directly into wait-merge-answer (the #329 dead-wait defect)",
139
+ );
140
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.96.1",
3
+ "version": "0.97.1",
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
  {
@@ -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
  {
@@ -96,12 +96,16 @@
96
96
  <zeebe:properties>
97
97
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="MergeArmIn" />
98
98
  </zeebe:properties>
99
+ <zeebe:ioMapping>
100
+ <zeebe:output source="=null" target="status" />
101
+ </zeebe:ioMapping>
99
102
  </bpmn:extensionElements>
100
103
  <bpmn:incoming>f_m_deps</bpmn:incoming>
101
104
  <bpmn:incoming>f_m_answer</bpmn:incoming>
102
105
  <bpmn:incoming>f_ci_fixed</bpmn:incoming>
103
106
  <bpmn:incoming>f_reb_rebased</bpmn:incoming>
104
107
  <bpmn:incoming>f_m_evicted</bpmn:incoming>
108
+ <bpmn:incoming>f_m_escReenter</bpmn:incoming>
105
109
  <bpmn:outgoing>f_m_arm</bpmn:outgoing>
106
110
  </bpmn:serviceTask>
107
111
  <bpmn:intermediateCatchEvent id="wait-mergeable" name="Wait: mergeable">
@@ -187,6 +191,11 @@
187
191
  <zeebe:property name="io.nanobpm.dataEnvelope.in" value="EscalationIn" />
188
192
  <zeebe:property name="io.nanobpm.dataEnvelope.out" value="EscalationOut" />
189
193
  </zeebe:properties>
194
+ <zeebe:ioMapping>
195
+ <zeebe:input source="=status" target="agentVerdict" />
196
+ <zeebe:input source="=&#34;This PR cannot be merged and needs a human decision. &#34; + (if mergeState = &#34;conflict&#34; then &#34;The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry.&#34; else if mergeState = &#34;ready&#34; then &#34;The merge attempt did not land (merge result: &#34; + mergeStatus + &#34;). Investigate why GitHub refused the merge, then reply to retry.&#34; else if agentVerdict = &#34;blocked&#34; then &#34;The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry.&#34; else &#34;The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.&#34;)" target="question" />
197
+ <zeebe:input source="=&#34;blocked&#34;" target="status" />
198
+ </zeebe:ioMapping>
190
199
  </bpmn:extensionElements>
191
200
  <bpmn:incoming>f_m_gBlocked</bpmn:incoming>
192
201
  <bpmn:incoming>f_ci_blocked</bpmn:incoming>
@@ -194,6 +203,11 @@
194
203
  <bpmn:incoming>f_ci_sla</bpmn:incoming>
195
204
  <bpmn:outgoing>f_m_escA</bpmn:outgoing>
196
205
  </bpmn:serviceTask>
206
+ <bpmn:exclusiveGateway id="gw-merge-escalated" name="escalation opened?" default="f_m_escReenter">
207
+ <bpmn:incoming>f_m_escA</bpmn:incoming>
208
+ <bpmn:outgoing>f_m_escWait</bpmn:outgoing>
209
+ <bpmn:outgoing>f_m_escReenter</bpmn:outgoing>
210
+ </bpmn:exclusiveGateway>
197
211
  <bpmn:userTask id="wait-merge-answer" name="Answer escalation">
198
212
  <bpmn:extensionElements>
199
213
  <zeebe:formDefinition formId="pr-escalation" />
@@ -204,7 +218,7 @@
204
218
  </zeebe:ioMapping>
205
219
  </bpmn:extensionElements>
206
220
  <bpmn:incoming>f_m_escC</bpmn:incoming>
207
- <bpmn:incoming>f_m_escA</bpmn:incoming>
221
+ <bpmn:incoming>f_m_escWait</bpmn:incoming>
208
222
  <bpmn:outgoing>f_m_answerRecord</bpmn:outgoing>
209
223
  </bpmn:userTask>
210
224
  <bpmn:serviceTask id="record-merge-answer" name="Record answer">
@@ -363,7 +377,11 @@
363
377
  <bpmn:sequenceFlow id="f_m_landed" sourceRef="wait-landed" targetRef="mark-merged" />
364
378
  <bpmn:sequenceFlow id="f_m_done" sourceRef="mark-merged" targetRef="MergeEnd" />
365
379
  <bpmn:sequenceFlow id="f_m_escC" sourceRef="merge-esc-conflict" targetRef="wait-merge-answer" />
366
- <bpmn:sequenceFlow id="f_m_escA" sourceRef="merge-esc-attempt" targetRef="wait-merge-answer" />
380
+ <bpmn:sequenceFlow id="f_m_escA" sourceRef="merge-esc-attempt" targetRef="gw-merge-escalated" />
381
+ <bpmn:sequenceFlow id="f_m_escWait" sourceRef="gw-merge-escalated" targetRef="wait-merge-answer">
382
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=escalated = true</bpmn:conditionExpression>
383
+ </bpmn:sequenceFlow>
384
+ <bpmn:sequenceFlow id="f_m_escReenter" name="no escalation opened" sourceRef="gw-merge-escalated" targetRef="arm-merge" />
367
385
  <bpmn:sequenceFlow id="f_m_answerRecord" sourceRef="wait-merge-answer" targetRef="record-merge-answer" />
368
386
  <bpmn:sequenceFlow id="f_m_answer" sourceRef="record-merge-answer" targetRef="arm-merge" />
369
387
  <bpmn:sequenceFlow id="f_reb_sla" name="agent SLA elapsed" sourceRef="be_rebase_sla" targetRef="merge-esc-conflict" />
@@ -440,11 +458,17 @@
440
458
  <bpmndi:BPMNShape id="BPMNShape_merge-esc-attempt" bpmnElement="merge-esc-attempt">
441
459
  <dc:Bounds x="1438" y="1040" width="100" height="80" />
442
460
  </bpmndi:BPMNShape>
461
+ <bpmndi:BPMNShape id="BPMNShape_gw-merge-escalated" bpmnElement="gw-merge-escalated" isMarkerVisible="true">
462
+ <dc:Bounds x="1663" y="1055" width="50" height="50" />
463
+ <bpmndi:BPMNLabel>
464
+ <dc:Bounds x="1651" y="1022" width="74" height="28" />
465
+ </bpmndi:BPMNLabel>
466
+ </bpmndi:BPMNShape>
443
467
  <bpmndi:BPMNShape id="BPMNShape_wait-merge-answer" bpmnElement="wait-merge-answer">
444
- <dc:Bounds x="1638" y="1040" width="100" height="80" />
468
+ <dc:Bounds x="1838" y="1040" width="100" height="80" />
445
469
  </bpmndi:BPMNShape>
446
470
  <bpmndi:BPMNShape id="BPMNShape_record-merge-answer" bpmnElement="record-merge-answer">
447
- <dc:Bounds x="1838" y="1040" width="100" height="80" />
471
+ <dc:Bounds x="2038" y="1040" width="100" height="80" />
448
472
  </bpmndi:BPMNShape>
449
473
  <bpmndi:BPMNShape id="BPMNShape_gw-ci-fix" bpmnElement="gw-ci-fix" isMarkerVisible="true">
450
474
  <dc:Bounds x="863" y="1535" width="50" height="50" />
@@ -577,16 +601,25 @@
577
601
  </bpmndi:BPMNEdge>
578
602
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escC" bpmnElement="f_m_escC">
579
603
  <di:waypoint x="1338" y="1560" />
580
- <di:waypoint x="1688" y="1560" />
581
- <di:waypoint x="1688" y="1120" />
604
+ <di:waypoint x="1888" y="1560" />
605
+ <di:waypoint x="1888" y="1120" />
606
+ </bpmndi:BPMNEdge>
607
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_escReenter" bpmnElement="f_m_escReenter">
608
+ <di:waypoint x="1688" y="1105" />
609
+ <di:waypoint x="1688" y="1140" />
610
+ <di:waypoint x="402" y="1140" />
611
+ <di:waypoint x="402" y="160" />
612
+ <bpmndi:BPMNLabel>
613
+ <dc:Bounds x="1008" y="1093" width="74" height="42" />
614
+ </bpmndi:BPMNLabel>
582
615
  </bpmndi:BPMNEdge>
583
616
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answerRecord" bpmnElement="f_m_answerRecord">
584
- <di:waypoint x="1738" y="1080" />
585
- <di:waypoint x="1838" y="1080" />
617
+ <di:waypoint x="1938" y="1080" />
618
+ <di:waypoint x="2038" y="1080" />
586
619
  </bpmndi:BPMNEdge>
587
620
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_answer" bpmnElement="f_m_answer">
588
- <di:waypoint x="1888" y="1120" />
589
- <di:waypoint x="1888" y="1140" />
621
+ <di:waypoint x="2088" y="1120" />
622
+ <di:waypoint x="2088" y="1140" />
590
623
  <di:waypoint x="402" y="1140" />
591
624
  <di:waypoint x="402" y="160" />
592
625
  </bpmndi:BPMNEdge>
@@ -626,13 +659,13 @@
626
659
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_go" bpmnElement="f_ci_go">
627
660
  <di:waypoint x="888" y="1585" />
628
661
  <di:waypoint x="888" y="1605" />
629
- <di:waypoint x="1958" y="1605" />
630
- <di:waypoint x="1958" y="555" />
662
+ <di:waypoint x="2158" y="1605" />
663
+ <di:waypoint x="2158" y="555" />
631
664
  <di:waypoint x="1018" y="555" />
632
665
  <di:waypoint x="1018" y="580" />
633
666
  <di:waypoint x="1038" y="580" />
634
667
  <bpmndi:BPMNLabel>
635
- <dc:Bounds x="1963" y="1066" width="49" height="28" />
668
+ <dc:Bounds x="2163" y="1066" width="49" height="28" />
636
669
  </bpmndi:BPMNLabel>
637
670
  </bpmndi:BPMNEdge>
638
671
  <bpmndi:BPMNEdge id="BPMNEdge_f_ci_blocked" bpmnElement="f_ci_blocked">
@@ -657,14 +690,14 @@
657
690
  <di:waypoint x="933" y="280" />
658
691
  <di:waypoint x="933" y="305" />
659
692
  <di:waypoint x="1558" y="305" />
660
- <di:waypoint x="1558" y="1500" />
661
- <di:waypoint x="1418" y="1500" />
693
+ <di:waypoint x="1558" y="1125" />
694
+ <di:waypoint x="1418" y="1125" />
662
695
  <di:waypoint x="1418" y="875" />
663
696
  <di:waypoint x="1018" y="875" />
664
697
  <di:waypoint x="1018" y="900" />
665
698
  <di:waypoint x="1038" y="900" />
666
699
  <bpmndi:BPMNLabel>
667
- <dc:Bounds x="1464" y="1467" width="49" height="28" />
700
+ <dc:Bounds x="1563" y="701" width="49" height="28" />
668
701
  </bpmndi:BPMNLabel>
669
702
  </bpmndi:BPMNEdge>
670
703
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_blocked" bpmnElement="f_reb_blocked">
@@ -718,7 +751,11 @@
718
751
  </bpmndi:BPMNEdge>
719
752
  <bpmndi:BPMNEdge id="BPMNEdge_f_m_escA" bpmnElement="f_m_escA">
720
753
  <di:waypoint x="1538" y="1080" />
721
- <di:waypoint x="1638" y="1080" />
754
+ <di:waypoint x="1663" y="1080" />
755
+ </bpmndi:BPMNEdge>
756
+ <bpmndi:BPMNEdge id="BPMNEdge_f_m_escWait" bpmnElement="f_m_escWait">
757
+ <di:waypoint x="1713" y="1080" />
758
+ <di:waypoint x="1838" y="1080" />
722
759
  </bpmndi:BPMNEdge>
723
760
  <bpmndi:BPMNEdge id="BPMNEdge_f_reb_sla" bpmnElement="f_reb_sla">
724
761
  <di:waypoint x="1088" y="978" />
@@ -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();