@nanobpm/agentic 0.1.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.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The opinionated core vocabulary.
3
+ *
4
+ * Ships working out of the box (S0 invariant 6): the standard agentic SDLC
5
+ * networks — `planning.*`, `qa.*`, `implementation.*`, `ci.*` — plus the bare
6
+ * `decide` role, each gated by an enrolment `requires` predicate and sized with
7
+ * seats. Authors EXTEND this in the SAME schema (see {@link mergeVocab}); there
8
+ * is no second schema for extensions.
9
+ *
10
+ * Seats & diversity: review roles carry two named seats `#red` / `#blue` with
11
+ * `seatsDistinctFamily: true` so the diversity SLO (S3) fails RED when both
12
+ * reviewers are the same family. Non-review roles leave `seatsDistinctFamily`
13
+ * off (warn-default): a same-family collision there is AMBER, not RED.
14
+ *
15
+ * Capability is NEVER in the token — `requires` is the registry gate, evaluated
16
+ * over the declared enrolment capability (cognition/weight/family/host).
17
+ */
18
+ import type { VocabDocument } from "../protocol/index.ts";
19
+
20
+ /** The current core-vocabulary artifact version. */
21
+ export const CORE_VOCAB_VERSION = 1;
22
+
23
+ /**
24
+ * The frozen core vocabulary document. Deep-frozen so a consumer cannot mutate
25
+ * the shared artifact; author extensions go through {@link mergeVocab}, which
26
+ * returns a fresh document.
27
+ */
28
+ export const CORE_VOCAB: VocabDocument = deepFreeze({
29
+ version: CORE_VOCAB_VERSION,
30
+ networks: {
31
+ planning: {
32
+ roles: {
33
+ planner: { requires: ["cognition=planning"], weight: 5, seats: 1 },
34
+ reviewer: {
35
+ requires: ["cognition=planning"],
36
+ weight: 4,
37
+ seats: ["red", "blue"],
38
+ seatsDistinctFamily: true,
39
+ },
40
+ },
41
+ },
42
+ qa: {
43
+ roles: {
44
+ tester: { requires: ["cognition=qa"], weight: 3, seats: 2 },
45
+ reviewer: {
46
+ requires: ["cognition=qa"],
47
+ weight: 3,
48
+ seats: ["red", "blue"],
49
+ seatsDistinctFamily: true,
50
+ },
51
+ },
52
+ },
53
+ implementation: {
54
+ roles: {
55
+ senior: { requires: ["cognition=implementation", "weight>=4"], weight: 5, seats: 1 },
56
+ junior: { requires: ["cognition=implementation"], weight: 2, seats: 3 },
57
+ reviewer: {
58
+ requires: ["cognition=implementation"],
59
+ weight: 4,
60
+ seats: ["red", "blue"],
61
+ seatsDistinctFamily: true,
62
+ },
63
+ },
64
+ },
65
+ ci: {
66
+ roles: {
67
+ runner: { requires: ["cognition=ci"], weight: 1, seats: 1 },
68
+ },
69
+ },
70
+ // The bare `decide` role (single-segment token): a self-named top-level role
71
+ // the resolver collapses to the network-less token `decide`.
72
+ decide: {
73
+ roles: {
74
+ decide: { requires: ["cognition=decide"], weight: 5, seats: 1 },
75
+ },
76
+ },
77
+ },
78
+ });
79
+
80
+ function deepFreeze<T>(value: T): T {
81
+ if (value !== null && typeof value === "object") {
82
+ for (const child of Object.values(value)) {
83
+ deepFreeze(child);
84
+ }
85
+ Object.freeze(value);
86
+ }
87
+ return value;
88
+ }
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { CORE_VOCAB } from "./core-vocab.ts";
4
+ import {
5
+ computeDiversity,
6
+ correlateRegistry,
7
+ type RegisteredWorker,
8
+ type SeatAssignment,
9
+ } from "./diversity.ts";
10
+ import { VocabResolver } from "./resolver.ts";
11
+
12
+ const resolver = new VocabResolver(CORE_VOCAB);
13
+
14
+ function assignments(entries: Array<[string, SeatAssignment[]]>): Map<string, SeatAssignment[]> {
15
+ return new Map(entries);
16
+ }
17
+
18
+ test("green when distinct families fill a strict role's seats", () => {
19
+ const report = computeDiversity(
20
+ resolver,
21
+ assignments([
22
+ [
23
+ "planning.reviewer",
24
+ [
25
+ { seat: "red", family: "acme" },
26
+ { seat: "blue", family: "globex" },
27
+ ],
28
+ ],
29
+ ]),
30
+ );
31
+ assert.equal(report.status, "green");
32
+ assert.equal(report.roles[0]?.status, "green");
33
+ assert.deepEqual(report.roles[0]?.collidingFamilies, []);
34
+ });
35
+
36
+ test("red when a strict role's seats share a family", () => {
37
+ const report = computeDiversity(
38
+ resolver,
39
+ assignments([
40
+ [
41
+ "planning.reviewer",
42
+ [
43
+ { seat: "red", family: "acme" },
44
+ { seat: "blue", family: "acme" },
45
+ ],
46
+ ],
47
+ ]),
48
+ );
49
+ assert.equal(report.status, "red");
50
+ assert.equal(report.roles[0]?.status, "red");
51
+ assert.deepEqual(report.roles[0]?.collidingFamilies, ["acme"]);
52
+ });
53
+
54
+ test("amber when a warn-default role's seats share a family (no opt-in)", () => {
55
+ // qa.tester has 2 counted seats and does NOT opt into distinct families.
56
+ const report = computeDiversity(
57
+ resolver,
58
+ assignments([
59
+ [
60
+ "qa.tester",
61
+ [
62
+ { seat: "0", family: "acme" },
63
+ { seat: "1", family: "acme" },
64
+ ],
65
+ ],
66
+ ]),
67
+ );
68
+ assert.equal(report.status, "amber");
69
+ assert.equal(report.roles[0]?.status, "amber");
70
+ });
71
+
72
+ test("overall status is the worst across roles (red dominates amber)", () => {
73
+ const report = computeDiversity(
74
+ resolver,
75
+ assignments([
76
+ [
77
+ "qa.tester",
78
+ [
79
+ { seat: "0", family: "acme" },
80
+ { seat: "1", family: "acme" },
81
+ ],
82
+ ],
83
+ [
84
+ "planning.reviewer",
85
+ [
86
+ { seat: "red", family: "globex" },
87
+ { seat: "blue", family: "globex" },
88
+ ],
89
+ ],
90
+ ]),
91
+ );
92
+ assert.equal(report.status, "red");
93
+ assert.equal(report.roles.length, 2);
94
+ // roles are sorted by token
95
+ assert.equal(report.roles[0]?.token, "planning.reviewer");
96
+ assert.equal(report.roles[1]?.token, "qa.tester");
97
+ });
98
+
99
+ test("unknown tokens are ignored", () => {
100
+ const report = computeDiversity(
101
+ resolver,
102
+ assignments([["mystery.role", [{ seat: "red", family: "acme" }]]]),
103
+ );
104
+ assert.equal(report.status, "green");
105
+ assert.equal(report.roles.length, 0);
106
+ });
107
+
108
+ test("correlateRegistry seats registered workers and grades the live family mix", () => {
109
+ const workers: RegisteredWorker[] = [
110
+ { instance: "w-a", capability: { cognition: "planning", family: "acme" } },
111
+ { instance: "w-b", capability: { cognition: "planning", family: "acme" } },
112
+ ];
113
+ // Both planning workers share family "acme"; planning.reviewer is strict → red.
114
+ const report = correlateRegistry(resolver, workers);
115
+ const reviewer = report.roles.find((r) => r.token === "planning.reviewer");
116
+ assert.ok(reviewer !== undefined);
117
+ assert.equal(reviewer?.status, "red");
118
+ assert.equal(report.status, "red");
119
+ });
120
+
121
+ test("correlateRegistry is green when distinct families cover the review seats", () => {
122
+ const workers: RegisteredWorker[] = [
123
+ { instance: "w-a", capability: { cognition: "planning", family: "acme" } },
124
+ { instance: "w-b", capability: { cognition: "planning", family: "globex" } },
125
+ ];
126
+ const report = correlateRegistry(resolver, workers);
127
+ const reviewer = report.roles.find((r) => r.token === "planning.reviewer");
128
+ assert.equal(reviewer?.status, "green");
129
+ });
130
+
131
+ test("correlateRegistry skips workers with no declared family", () => {
132
+ const workers: RegisteredWorker[] = [
133
+ { instance: "w-a", capability: { cognition: "planning" } },
134
+ { instance: "w-b", capability: { cognition: "planning" } },
135
+ ];
136
+ const report = correlateRegistry(resolver, workers);
137
+ assert.equal(report.status, "green");
138
+ assert.equal(report.roles.length, 0);
139
+ });
140
+
141
+ test("correlateRegistry seats deterministically by instance id", () => {
142
+ const workers: RegisteredWorker[] = [
143
+ { instance: "w-b", capability: { cognition: "planning", family: "globex" } },
144
+ { instance: "w-a", capability: { cognition: "planning", family: "acme" } },
145
+ ];
146
+ const report = correlateRegistry(resolver, workers);
147
+ const reviewer = report.roles.find((r) => r.token === "planning.reviewer");
148
+ // Sorted by instance: w-a(acme)->red, w-b(globex)->blue.
149
+ assert.deepEqual(reviewer?.assignments, [
150
+ { seat: "red", family: "acme", instance: "w-a" },
151
+ { seat: "blue", family: "globex", instance: "w-b" },
152
+ ]);
153
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The diversity SLO — `family(#red) ≠ family(#blue)`.
3
+ *
4
+ * A role may declare `seatsDistinctFamily: true` (STRICT opt-in) to require its
5
+ * seats be filled by distinct families. The SLO grades an assignment of seats to
6
+ * families red / amber / green:
7
+ *
8
+ * - GREEN — no role has two seats sharing a family.
9
+ * - AMBER — a same-family collision on a WARN-DEFAULT role (one that did NOT
10
+ * opt into `seatsDistinctFamily`): tolerated, but surfaced.
11
+ * - RED — a same-family collision on a STRICT role (`seatsDistinctFamily`):
12
+ * an SLO violation.
13
+ *
14
+ * Warn-default is the point of the "strict opt-in per role": every role wants
15
+ * family diversity, but only a role that opts in makes a collision RED; elsewhere
16
+ * a collision is an AMBER warning, never a hard failure.
17
+ *
18
+ * The assignment can be given explicitly (seat→family) or CORRELATED from the S2
19
+ * presence registry: {@link correlateRegistry} resolves each registered worker's
20
+ * capability to the roles it may fill and seats them deterministically, so the
21
+ * live registry's family mix is graded against the same SLO.
22
+ */
23
+ import type { Capability } from "../protocol/index.ts";
24
+ import type { ResolvedRole, VocabResolver } from "./resolver.ts";
25
+
26
+ export type DiversityStatus = "green" | "amber" | "red";
27
+
28
+ /** One seat of a role filled by a worker of a given family. */
29
+ export interface SeatAssignment {
30
+ /** The seat label (named seat, or a synthesised index for counted seats). */
31
+ readonly seat: string;
32
+ /** The enrolment family occupying the seat. */
33
+ readonly family: string;
34
+ /** The worker instance occupying the seat, when known (registry correlation). */
35
+ readonly instance?: string;
36
+ }
37
+
38
+ /** The diversity grade for a single role. */
39
+ export interface RoleDiversity {
40
+ /** The role's routing token. */
41
+ readonly token: string;
42
+ /** Whether the role opted into strict distinct-family seating. */
43
+ readonly seatsDistinctFamily: boolean;
44
+ /** The seats considered, in seat order. */
45
+ readonly assignments: readonly SeatAssignment[];
46
+ /** Families that fill more than one seat of this role (the collisions). */
47
+ readonly collidingFamilies: readonly string[];
48
+ /** This role's grade. */
49
+ readonly status: DiversityStatus;
50
+ }
51
+
52
+ /** The overall diversity report across every graded role. */
53
+ export interface DiversityReport {
54
+ /** The worst grade across all roles (red > amber > green). */
55
+ readonly status: DiversityStatus;
56
+ /** Per-role grades, sorted by token. */
57
+ readonly roles: readonly RoleDiversity[];
58
+ }
59
+
60
+ const SEVERITY: Record<DiversityStatus, number> = { green: 0, amber: 1, red: 2 };
61
+
62
+ function worst(a: DiversityStatus, b: DiversityStatus): DiversityStatus {
63
+ return SEVERITY[a] >= SEVERITY[b] ? a : b;
64
+ }
65
+
66
+ function collidingFamilies(assignments: readonly SeatAssignment[]): string[] {
67
+ const counts = new Map<string, number>();
68
+ for (const { family } of assignments) {
69
+ counts.set(family, (counts.get(family) ?? 0) + 1);
70
+ }
71
+ const colliding: string[] = [];
72
+ for (const [family, count] of counts) {
73
+ if (count > 1) colliding.push(family);
74
+ }
75
+ colliding.sort();
76
+ return colliding;
77
+ }
78
+
79
+ function gradeRole(role: ResolvedRole, assignments: readonly SeatAssignment[]): RoleDiversity {
80
+ const colliding = collidingFamilies(assignments);
81
+ const status: DiversityStatus =
82
+ colliding.length === 0 ? "green" : role.seatsDistinctFamily ? "red" : "amber";
83
+ return {
84
+ token: role.token,
85
+ seatsDistinctFamily: role.seatsDistinctFamily,
86
+ assignments,
87
+ collidingFamilies: colliding,
88
+ status,
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Grade an explicit seat assignment. `assignments` maps a role's routing token
94
+ * to the families seated in it. Only roles known to the resolver are graded; an
95
+ * unknown token is ignored (there is nothing to grade it against).
96
+ */
97
+ export function computeDiversity(
98
+ resolver: VocabResolver,
99
+ assignments: ReadonlyMap<string, readonly SeatAssignment[]>,
100
+ ): DiversityReport {
101
+ const roles: RoleDiversity[] = [];
102
+ let status: DiversityStatus = "green";
103
+ for (const [token, seatAssignments] of assignments) {
104
+ const role = resolver.roleForToken(token);
105
+ if (role === undefined) continue;
106
+ const graded = gradeRole(role, seatAssignments);
107
+ roles.push(graded);
108
+ status = worst(status, graded.status);
109
+ }
110
+ roles.sort((a, b) => (a.token < b.token ? -1 : a.token > b.token ? 1 : 0));
111
+ return { status, roles };
112
+ }
113
+
114
+ /** A registered worker as seen on the S2 presence registry (structural). */
115
+ export interface RegisteredWorker {
116
+ /** The worker instance id. */
117
+ readonly instance: string;
118
+ /** The declared enrolment capability (its `family` fills a seat). */
119
+ readonly capability: Capability;
120
+ }
121
+
122
+ /** Seat labels for a role: its named seats, or synthesised `0..n-1` for a count. */
123
+ function seatLabels(role: ResolvedRole): string[] {
124
+ if (typeof role.seats === "number") {
125
+ return Array.from({ length: role.seats }, (_unused, index) => String(index));
126
+ }
127
+ return [...role.seats];
128
+ }
129
+
130
+ /**
131
+ * Correlate the live S2 registry against the vocab and grade its diversity.
132
+ *
133
+ * Each registered worker is resolved to the roles it may fill; for every role,
134
+ * the workers that qualify are seated deterministically (sorted by instance)
135
+ * into the role's seats, and the resulting family mix is graded by
136
+ * {@link computeDiversity}. Workers with no declared family, and roles with no
137
+ * qualifying worker, are skipped. Overflow workers beyond a role's seat count do
138
+ * not take a seat (they are surplus supply, not a diversity collision).
139
+ */
140
+ export function correlateRegistry(
141
+ resolver: VocabResolver,
142
+ workers: readonly RegisteredWorker[],
143
+ ): DiversityReport {
144
+ const sorted = [...workers].sort((a, b) => (a.instance < b.instance ? -1 : a.instance > b.instance ? 1 : 0));
145
+ // Resolve each worker's SERVE token set once (O(workers)) so per-role seating
146
+ // is a membership check, not a repeated resolve — correlation stays O(workers × roles).
147
+ const workerTokens = sorted.map((worker) => new Set(resolver.resolve(worker.capability).tokens));
148
+ const perRole = new Map<string, SeatAssignment[]>();
149
+
150
+ for (const role of resolver.roles()) {
151
+ const seats = seatLabels(role);
152
+ if (seats.length === 0) continue;
153
+ const qualifying = sorted.filter(
154
+ (worker, index) => worker.capability.family !== undefined && workerTokens[index].has(role.token),
155
+ );
156
+ const assignments: SeatAssignment[] = [];
157
+ for (let index = 0; index < seats.length && index < qualifying.length; index += 1) {
158
+ const worker = qualifying[index];
159
+ const family = worker.capability.family;
160
+ if (family === undefined) continue;
161
+ assignments.push({ seat: seats[index], family, instance: worker.instance });
162
+ }
163
+ if (assignments.length > 0) {
164
+ perRole.set(role.token, assignments);
165
+ }
166
+ }
167
+
168
+ return computeDiversity(resolver, perRole);
169
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @nanobpm/agentic-vocab — the vocab resolver & core vocabulary for the Nano
3
+ * agentic protocol (ADR 0056, slice S3).
4
+ *
5
+ * Turns the versioned vocab artifact (the ONE capability→token map) into the
6
+ * REGISTER→SERVE handshake: a declared enrolment capability resolves to a
7
+ * deterministic SERVE token set ({@link VocabResolver}); the opinionated core
8
+ * vocabulary ships working out of the box ({@link CORE_VOCAB}); authors extend it
9
+ * in the same schema ({@link mergeVocab}); and the diversity SLO grades seating
10
+ * red / amber / green ({@link computeDiversity} / {@link correlateRegistry}).
11
+ *
12
+ * The wire contract (family set, token grammar, vocab schema, `serve` payload)
13
+ * lives in `@nanobpm/agentic-protocol`; this package builds on it and never
14
+ * redefines it. Capability is NEVER in the routing token — it is the enrolment
15
+ * attribute the `requires` gate reads.
16
+ */
17
+ export {
18
+ VocabResolver,
19
+ VocabDocumentError,
20
+ type Resolution,
21
+ type ResolvedRole,
22
+ } from "./resolver.ts";
23
+
24
+ export { CORE_VOCAB, CORE_VOCAB_VERSION } from "./core-vocab.ts";
25
+
26
+ export { mergeVocab } from "./merge.ts";
27
+
28
+ export {
29
+ REQUIRES_FIELDS,
30
+ RequiresParseError,
31
+ parseRequires,
32
+ parseRequiresList,
33
+ satisfiesRequires,
34
+ satisfiesPredicate,
35
+ type RequiresField,
36
+ type RequiresOp,
37
+ type RequiresPredicate,
38
+ } from "./requires.ts";
39
+
40
+ export {
41
+ computeDiversity,
42
+ correlateRegistry,
43
+ type DiversityStatus,
44
+ type DiversityReport,
45
+ type RoleDiversity,
46
+ type SeatAssignment,
47
+ type RegisteredWorker,
48
+ } from "./diversity.ts";
49
+
50
+ export {
51
+ buildServePayload,
52
+ buildServeFrame,
53
+ serveCapability,
54
+ type ServeSink,
55
+ } from "./serve.ts";
@@ -0,0 +1,73 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { CORE_VOCAB } from "./core-vocab.ts";
4
+ import { mergeVocab } from "./merge.ts";
5
+ import { VocabDocumentError, VocabResolver } from "./resolver.ts";
6
+
7
+ test("an author extension adds a new network without disturbing the core", () => {
8
+ const merged = mergeVocab({
9
+ version: 1,
10
+ networks: { research: { roles: { scout: { requires: ["cognition=research"], seats: 2 } } } },
11
+ });
12
+ const resolver = new VocabResolver(merged);
13
+ assert.ok(resolver.tokens().includes("research.scout"));
14
+ // Core roles survive intact.
15
+ assert.ok(resolver.tokens().includes("planning.planner"));
16
+ assert.ok(resolver.tokens().includes("decide"));
17
+ });
18
+
19
+ test("an extension retunes a single role field, leaving the rest", () => {
20
+ const merged = mergeVocab({
21
+ version: 1,
22
+ networks: { implementation: { roles: { junior: { weight: 9 } } } },
23
+ });
24
+ const resolver = new VocabResolver(merged);
25
+ const junior = resolver.roleForToken("implementation.junior");
26
+ assert.equal(junior?.weight, 9, "extension weight wins");
27
+ // The core requires/seats on junior are preserved (not clobbered).
28
+ assert.deepEqual(junior?.seats, 3);
29
+ const light = resolver.resolve({ cognition: "implementation", weight: 2 });
30
+ assert.ok(light.tokens.includes("implementation.junior"), "core requires gate preserved");
31
+ });
32
+
33
+ test("an extension can add a seatsDistinctFamily opt-in to an existing role", () => {
34
+ const merged = mergeVocab({
35
+ version: 1,
36
+ networks: { qa: { roles: { tester: { seats: ["red", "blue"], seatsDistinctFamily: true } } } },
37
+ });
38
+ const tester = new VocabResolver(merged).roleForToken("qa.tester");
39
+ assert.equal(tester?.seatsDistinctFamily, true);
40
+ assert.deepEqual(tester?.seats, ["red", "blue"]);
41
+ });
42
+
43
+ test("version becomes the max of base and extension", () => {
44
+ assert.equal(mergeVocab({ version: 7, networks: {} }).version, Math.max(CORE_VOCAB.version, 7));
45
+ assert.equal(mergeVocab({ version: 1, networks: {} }).version, CORE_VOCAB.version);
46
+ });
47
+
48
+ test("merge does not mutate either input", () => {
49
+ const before = JSON.stringify(CORE_VOCAB);
50
+ const ext = { version: 1, networks: { x: { roles: { y: {} } } } };
51
+ const extBefore = JSON.stringify(ext);
52
+ mergeVocab(ext);
53
+ assert.equal(JSON.stringify(CORE_VOCAB), before, "core vocab untouched");
54
+ assert.equal(JSON.stringify(ext), extBefore, "extension untouched");
55
+ });
56
+
57
+ test("merge over an explicit base merges recursively into subnetworks", () => {
58
+ const base = JSON.parse(
59
+ '{"version":1,"networks":{"net":{"subnetworks":{"sub":{"roles":{"a":{"weight":1}}}}}}}',
60
+ );
61
+ const merged = mergeVocab(
62
+ { version: 1, networks: { net: { subnetworks: { sub: { roles: { b: { weight: 2 } } } } } } },
63
+ base,
64
+ );
65
+ const tokens = new VocabResolver(merged).tokens();
66
+ assert.ok(tokens.includes("net.sub.a"));
67
+ assert.ok(tokens.includes("net.sub.b"));
68
+ });
69
+
70
+ test("merge rejects an invalid extension", () => {
71
+ const bad = JSON.parse('{"version":1,"networks":{"Bad Name":{}}}');
72
+ assert.throws(() => mergeVocab(bad), VocabDocumentError);
73
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Author-extension merge — extend the core vocabulary in the SAME schema.
3
+ *
4
+ * Authors do not get a second schema for extensions (S0 invariant 6): they hand
5
+ * a {@link VocabDocument} in the exact core shape and {@link mergeVocab} deep-
6
+ * merges it over a base (the {@link CORE_VOCAB} by default). The merge is
7
+ * structural and deterministic:
8
+ *
9
+ * - networks / subnetworks are merged recursively (union of names);
10
+ * - a role present in both is merged field-by-field, the extension winning per
11
+ * field, so an author can retune one attribute (e.g. bump `weight` or add a
12
+ * `seatsDistinctFamily`) without restating the whole role;
13
+ * - a role/network present only in the extension is added;
14
+ * - `version` becomes the MAX of the two, so an extension can advance it.
15
+ *
16
+ * The merged document is re-validated against the S0 schema and returned fresh —
17
+ * neither input is mutated, and a merge that produces an invalid artifact throws
18
+ * {@link VocabDocumentError} rather than yielding a subtly broken vocab.
19
+ */
20
+ import { validateVocabDocument } from "../protocol/index.ts";
21
+ import type { VocabDocument, VocabNetwork, VocabRole } from "../protocol/index.ts";
22
+ import { CORE_VOCAB } from "./core-vocab.ts";
23
+ import { VocabDocumentError } from "./resolver.ts";
24
+
25
+ function mergeRole(base: VocabRole, ext: VocabRole): VocabRole {
26
+ const merged: {
27
+ requires?: readonly string[];
28
+ weight?: number;
29
+ seats?: number | readonly string[];
30
+ seatsDistinctFamily?: boolean;
31
+ } = {};
32
+ const requires = ext.requires ?? base.requires;
33
+ if (requires !== undefined) merged.requires = [...requires];
34
+ const weight = ext.weight ?? base.weight;
35
+ if (weight !== undefined) merged.weight = weight;
36
+ const seats = ext.seats ?? base.seats;
37
+ if (seats !== undefined) merged.seats = typeof seats === "number" ? seats : [...seats];
38
+ const seatsDistinctFamily = ext.seatsDistinctFamily ?? base.seatsDistinctFamily;
39
+ if (seatsDistinctFamily !== undefined) merged.seatsDistinctFamily = seatsDistinctFamily;
40
+ return merged;
41
+ }
42
+
43
+ const EMPTY_ROLE: VocabRole = {};
44
+
45
+ function cloneRole(role: VocabRole): VocabRole {
46
+ return mergeRole(EMPTY_ROLE, role);
47
+ }
48
+
49
+ function mergeRoles(
50
+ base: Readonly<Record<string, VocabRole>> | undefined,
51
+ ext: Readonly<Record<string, VocabRole>> | undefined,
52
+ ): Record<string, VocabRole> | undefined {
53
+ if (base === undefined && ext === undefined) return undefined;
54
+ const out: Record<string, VocabRole> = {};
55
+ for (const [name, role] of Object.entries(base ?? {})) {
56
+ out[name] = cloneRole(role);
57
+ }
58
+ for (const [name, role] of Object.entries(ext ?? {})) {
59
+ const existing = out[name];
60
+ out[name] = existing === undefined ? cloneRole(role) : mergeRole(existing, role);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function mergeNetwork(base: VocabNetwork | undefined, ext: VocabNetwork): VocabNetwork {
66
+ const roles = mergeRoles(base?.roles, ext.roles);
67
+ const subnetworks = mergeNetworks(base?.subnetworks, ext.subnetworks);
68
+ const merged: { roles?: Record<string, VocabRole>; subnetworks?: Record<string, VocabNetwork> } = {};
69
+ if (roles !== undefined) merged.roles = roles;
70
+ if (subnetworks !== undefined) merged.subnetworks = subnetworks;
71
+ return merged;
72
+ }
73
+
74
+ function mergeNetworks(
75
+ base: Readonly<Record<string, VocabNetwork>> | undefined,
76
+ ext: Readonly<Record<string, VocabNetwork>> | undefined,
77
+ ): Record<string, VocabNetwork> | undefined {
78
+ if (base === undefined && ext === undefined) return undefined;
79
+ const out: Record<string, VocabNetwork> = {};
80
+ for (const [name, network] of Object.entries(base ?? {})) {
81
+ out[name] = mergeNetwork(undefined, network);
82
+ }
83
+ for (const [name, network] of Object.entries(ext ?? {})) {
84
+ out[name] = mergeNetwork(out[name], network);
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * Merge an author `extension` over a `base` vocab (default {@link CORE_VOCAB}),
91
+ * returning a fresh, re-validated {@link VocabDocument}.
92
+ *
93
+ * @throws VocabDocumentError if either input or the merged result is not a valid
94
+ * vocab artifact.
95
+ */
96
+ export function mergeVocab(extension: VocabDocument, base: VocabDocument = CORE_VOCAB): VocabDocument {
97
+ for (const [label, doc] of [
98
+ ["base", base],
99
+ ["extension", extension],
100
+ ] as const) {
101
+ const check = validateVocabDocument(doc);
102
+ if (!check.ok) {
103
+ throw new VocabDocumentError(check.errors.map((e) => ({ path: `${label}:${e.path}`, message: e.message })));
104
+ }
105
+ }
106
+
107
+ const merged = {
108
+ version: Math.max(base.version, extension.version),
109
+ networks: mergeNetworks(base.networks, extension.networks) ?? {},
110
+ };
111
+
112
+ const result = validateVocabDocument(merged);
113
+ if (!result.ok) {
114
+ throw new VocabDocumentError(result.errors.map((e) => ({ path: `merged:${e.path}`, message: e.message })));
115
+ }
116
+ return result.value;
117
+ }