@nanobpm/urban 0.52.0 → 0.53.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 (54) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/extensions.d.ts +131 -0
  5. package/dist/runtime/core/extensions.js +137 -0
  6. package/dist/runtime/core/glob.d.ts +7 -6
  7. package/dist/runtime/core/glob.js +16 -14
  8. package/dist/runtime/core/host.d.ts +4 -1
  9. package/dist/runtime/core/modules/agent.d.ts +17 -0
  10. package/dist/runtime/core/modules/agent.js +63 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  12. package/dist/runtime/core/modules/deploy.js +62 -16
  13. package/dist/runtime/core/modules/pages.js +397 -20
  14. package/dist/runtime/core/modules/surfaces.js +8 -0
  15. package/dist/runtime/core/phase.d.ts +168 -0
  16. package/dist/runtime/core/phase.js +367 -0
  17. package/dist/runtime/core/runtime.d.ts +17 -0
  18. package/dist/runtime/core/runtime.js +30 -0
  19. package/dist/runtime/index.d.ts +6 -0
  20. package/dist/runtime/index.js +7 -0
  21. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  22. package/dist/toolkit/derivers/system-brief.js +156 -0
  23. package/dist/toolkit/gen.d.ts +5 -5
  24. package/dist/toolkit/gen.js +20 -14
  25. package/dist/toolkit/index.d.ts +5 -0
  26. package/dist/toolkit/index.js +3 -0
  27. package/dist/toolkit/models.d.ts +4 -3
  28. package/dist/toolkit/models.js +4 -3
  29. package/package.json +1 -1
  30. package/src/runtime/core/events.test.ts +382 -0
  31. package/src/runtime/core/events.ts +370 -0
  32. package/src/runtime/core/extensions.test.ts +378 -0
  33. package/src/runtime/core/extensions.ts +276 -0
  34. package/src/runtime/core/glob.ts +15 -12
  35. package/src/runtime/core/host.ts +4 -1
  36. package/src/runtime/core/modules/agent.test.ts +104 -0
  37. package/src/runtime/core/modules/agent.ts +76 -0
  38. package/src/runtime/core/modules/deploy.test.ts +84 -16
  39. package/src/runtime/core/modules/deploy.ts +59 -16
  40. package/src/runtime/core/modules/pages.test.ts +309 -5
  41. package/src/runtime/core/modules/pages.ts +398 -20
  42. package/src/runtime/core/modules/surfaces.ts +9 -0
  43. package/src/runtime/core/phase.test.ts +350 -0
  44. package/src/runtime/core/phase.ts +526 -0
  45. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  46. package/src/runtime/core/runtime.ts +49 -0
  47. package/src/runtime/devserver.test.ts +3 -0
  48. package/src/runtime/index.ts +57 -0
  49. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  50. package/src/toolkit/derivers/system-brief.ts +210 -0
  51. package/src/toolkit/gen.test.ts +28 -4
  52. package/src/toolkit/gen.ts +20 -13
  53. package/src/toolkit/index.ts +14 -0
  54. package/src/toolkit/models.ts +4 -3
@@ -20,6 +20,8 @@ import { mountTriggers } from "./modules/triggers.ts";
20
20
  import { mountInstanceTracking } from "./modules/instance-tracking.ts";
21
21
  import type { SchedulerDeps } from "./modules/scheduler.ts";
22
22
  import { mountSecurity, type SecurityPolicy } from "./modules/security.ts";
23
+ import { EventBus } from "./events.ts";
24
+ import { createUrbanEvents, mountExtensions, type UrbanEvents, type UrbanExtension } from "./extensions.ts";
23
25
 
24
26
  /** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
25
27
  * error when $PORT is set but not a valid integer in 0..65535. */
@@ -82,6 +84,14 @@ export interface CreateUrbanAppOptions {
82
84
  * so one injected scheduler drives them both (no per-module wiring, no drift).
83
85
  */
84
86
  scheduler?: SchedulerDeps;
87
+ /**
88
+ * Pluggable Urban {@link UrbanExtension}s — the agentic families and connector
89
+ * packs that extend the app lifecycle through the typed extension-event taxonomy
90
+ * (issue #262) instead of editing the runtime. The runtime runs each through the
91
+ * `extension/register` serial checkpoint in a deterministic order and disposes
92
+ * everything they register on `stop()` / HMR. Empty by default.
93
+ */
94
+ extensions?: readonly UrbanExtension[];
85
95
  }
86
96
 
87
97
  export interface UrbanApp {
@@ -112,6 +122,14 @@ export interface UrbanApp {
112
122
  * `undefined` before `start()`, after `stop()`, and on hosts that don't surface one (Deno).
113
123
  */
114
124
  readonly httpServer: object | undefined;
125
+ /**
126
+ * The app's typed extension-event taxonomy (issue #262): lifecycle notifications,
127
+ * the extension-registration checkpoint, request/security waterfall gates, and
128
+ * the reconcile fan-out — each a channel with a declared dispatch mode. Modules
129
+ * and {@link UrbanExtension}s hook onto these seams instead of wiring directly;
130
+ * every registration is an effect on a dispose ladder that `stop()` unwinds.
131
+ */
132
+ readonly events: UrbanEvents;
115
133
  }
116
134
 
117
135
  export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<UrbanApp> {
@@ -137,6 +155,20 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
137
155
  // correlation context).
138
156
  const appLog = createLogger((l, m, f) => host.log(l, m, f));
139
157
 
158
+ // The app-wide extension-event microkernel (issue #262): one typed taxonomy over
159
+ // one dispose ladder. Contained listener throws land in the structured log rather
160
+ // than stranding the pipeline. Modules and extensions hook onto `events`; the
161
+ // whole ladder unwinds on teardown.
162
+ const bus = new EventBus({
163
+ onError: (err, info) =>
164
+ appLog.warn("extension listener threw and was contained", {
165
+ event: info.event,
166
+ mode: info.mode,
167
+ error: String(err),
168
+ }),
169
+ });
170
+ const events = createUrbanEvents(bus);
171
+
140
172
  // Install the ambient job-execution store once per process (idempotent) so worker dispatch can
141
173
  // stamp write-provenance onto DataLayer inserts. Absent-safe: a host without `createAsyncStore`
142
174
  // leaves provenance capture disabled.
@@ -162,6 +194,8 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
162
194
  // Release every mounted resource and reset internal state so a subsequent
163
195
  // start() begins clean. Used by stop() and by start()'s failure path.
164
196
  const teardown = async () => {
197
+ // Notify lifecycle listeners before we start tearing seams down (issue #262).
198
+ events.lifecycle.emit({ app: manifest.id, phase: "stopping" });
165
199
  if (server) {
166
200
  try {
167
201
  await server.stop();
@@ -189,12 +223,18 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
189
223
  data = undefined;
190
224
  security = undefined;
191
225
  started = false;
226
+ // Terminal lifecycle notification, then unwind the whole dispose ladder LIFO:
227
+ // every extension listener/effect (and the lifecycle channel itself) is torn
228
+ // down, so a subsequent start() — or a dev-server HMR reload — leaks nothing.
229
+ events.lifecycle.emit({ app: manifest.id, phase: "stopped" });
230
+ bus.dispose();
192
231
  };
193
232
 
194
233
  const app: UrbanApp = {
195
234
  manifest,
196
235
  root,
197
236
  log: appLog,
237
+ events,
198
238
  get data() {
199
239
  return data;
200
240
  },
@@ -212,6 +252,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
212
252
  async start() {
213
253
  if (started) throw new Error("app already started");
214
254
  started = true;
255
+ events.lifecycle.emit({ app: manifest.id, phase: "starting" });
215
256
  try {
216
257
  if (flags.security) security = mountSecurity(ctx);
217
258
  if (flags.deploy) describe.deploy = await deployModels(ctx);
@@ -237,6 +278,13 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
237
278
  if (c.jobTypes.length > 0) describe.connectors = c.describe?.();
238
279
  }
239
280
 
281
+ // Run the agentic-family / connector-pack extension surface through the
282
+ // typed taxonomy (issue #262). Everything an extension registers rides the
283
+ // shared dispose ladder, so teardown's single `bus.dispose()` unwinds it —
284
+ // the extension host is deliberately NOT pushed onto `mounted`.
285
+ const extHost = await mountExtensions(api, opts.extensions ?? [], { bus, events });
286
+ describe.extensions = extHost.describe?.();
287
+
240
288
  const routes: Route[] = [];
241
289
  let hasUiSurfaces = false;
242
290
  if (flags.surfaces) {
@@ -308,6 +356,7 @@ export async function createUrbanApp(opts: CreateUrbanAppOptions): Promise<Urban
308
356
  }
309
357
  }
310
358
  host.log("info", `urban app "${manifest.id}" started`, {});
359
+ events.lifecycle.emit({ app: manifest.id, phase: "started" });
311
360
  } catch (err) {
312
361
  // A failed start must not leave the app half-mounted (leaked workers,
313
362
  // a bound port) or wedged in the "already started" state.
@@ -4,6 +4,8 @@ import { runDev, shouldReload, type DevDeps } from "./devserver.ts";
4
4
  import type { HostContext } from "./core/host.ts";
5
5
  import type { UrbanApp } from "./core/runtime.ts";
6
6
  import { createLogger } from "./core/logger.ts";
7
+ import { createUrbanEvents } from "./core/extensions.ts";
8
+ import { EventBus } from "./core/events.ts";
7
9
 
8
10
  const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
9
11
 
@@ -232,6 +234,7 @@ function fakeApp(stops: string[], id: string): UrbanApp {
232
234
  security: undefined,
233
235
  httpPort: undefined,
234
236
  httpServer: undefined,
237
+ events: createUrbanEvents(new EventBus()),
235
238
  };
236
239
  }
237
240
 
@@ -8,6 +8,37 @@ export type {
8
8
  UrbanApp,
9
9
  } from "./core/runtime.ts";
10
10
 
11
+ // Extension-event taxonomy + microkernel (issue #262)
12
+ export { EventBus } from "./core/events.ts";
13
+ export type {
14
+ DispatchMode,
15
+ Disposer,
16
+ EmitChannel,
17
+ ErrorSink,
18
+ EventBusOptions,
19
+ Listener,
20
+ Middleware,
21
+ ParallelChannel,
22
+ SerialChannel,
23
+ WaterfallChannel,
24
+ } from "./core/events.ts";
25
+ export { URBAN_EVENT_MODES, createUrbanEvents, mountExtensions, urbanEventMode } from "./core/extensions.ts";
26
+ export type {
27
+ DispatchRequest,
28
+ DispatchResponse,
29
+ ExtensionHost,
30
+ ExtensionRegistered,
31
+ ExtensionSetupContext,
32
+ GateDecision,
33
+ GateRequest,
34
+ LifecycleEvent,
35
+ MountExtensionsOptions,
36
+ ReconcileEvent,
37
+ UrbanEventName,
38
+ UrbanEvents,
39
+ UrbanExtension,
40
+ } from "./core/extensions.ts";
41
+
11
42
  // Manifest
12
43
  export {
13
44
  bindModeToHost,
@@ -178,6 +209,32 @@ export type {
178
209
  LineageStoreOptions,
179
210
  } from "./core/modules/lineage-store.ts";
180
211
 
212
+ // Phase — the framework-level *domain phase* primitive (issue #266): derive "which phase is this
213
+ // instance in?" from the BPMN scope hierarchy + write-provenance (Tier 0, zero declaration), an
214
+ // optional `nano:phase` override (Tier 1), and a lineage rollup for the epic frontier (Tier 2).
215
+ export {
216
+ buildScopeIndex,
217
+ derivePhase,
218
+ deriveInstancePhases,
219
+ furthestReached,
220
+ rollupLineagePhase,
221
+ SCOPE_CONTAINERS,
222
+ } from "./core/phase.ts";
223
+ export type {
224
+ CrumbKind,
225
+ CrumbSource,
226
+ DerivePhaseOptions,
227
+ FurthestReached,
228
+ InstancePhase,
229
+ LineagePhaseRollup,
230
+ Phase,
231
+ PhaseCrumb,
232
+ PhaseGranularity,
233
+ ProvenanceProgressRow,
234
+ ScopeElement,
235
+ ScopeIndex,
236
+ } from "./core/phase.ts";
237
+
181
238
  // Model deploy (deploy-by-convention under `resources/`, ADR 0062)
182
239
  export { deployModels, RESOURCES_DIR } from "./core/modules/deploy.ts";
183
240
  export { defaultScheduler, MAX_TIMER_DELAY_MS } from "./core/modules/scheduler.ts";
@@ -0,0 +1,150 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ buildSystemBrief,
5
+ deriveSystemBrief,
6
+ emitSystemBriefMd,
7
+ foldOwnership,
8
+ scanModelDecisions,
9
+ SYSTEM_BRIEF_JSON,
10
+ SYSTEM_BRIEF_MD,
11
+ type SystemBrief,
12
+ } from "./system-brief.ts";
13
+ import type { ModelSource } from "./worker-io.ts";
14
+
15
+ // A model with a service task (worker), a business-rule task (decision), and ownership
16
+ // `nano:meta` (single-valued + two repeated `adr`s).
17
+ const ORDER_XML = `
18
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
19
+ xmlns:zeebe="http://camunda.org/schema/zeebe/1.0">
20
+ <bpmn:process id="order-fulfilment">
21
+ <bpmn:extensionElements>
22
+ <nano:meta xmlns:nano="urn:nano" key="owner" value="payments-team" />
23
+ <nano:meta xmlns:nano="urn:nano" key="slack" value="#payments" />
24
+ <nano:meta xmlns:nano="urn:nano" key="adr" value="ADR 0040" />
25
+ <nano:meta xmlns:nano="urn:nano" key="adr" value="ADR 0060" />
26
+ </bpmn:extensionElements>
27
+ <bpmn:serviceTask id="charge">
28
+ <bpmn:extensionElements>
29
+ <zeebe:taskDefinition type="charge-card" />
30
+ <zeebe:ioMapping>
31
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="io.acme.ChargeReq" />
32
+ <zeebe:property name="io.nanobpm.dataEnvelope.out" value="io.acme.ChargeRes" />
33
+ </zeebe:ioMapping>
34
+ </bpmn:extensionElements>
35
+ </bpmn:serviceTask>
36
+ <bpmn:businessRuleTask id="risk">
37
+ <bpmn:extensionElements>
38
+ <zeebe:calledDecision decisionId="fraud-score" resultVariable="score" />
39
+ </bpmn:extensionElements>
40
+ </bpmn:businessRuleTask>
41
+ </bpmn:process>
42
+ </bpmn:definitions>`;
43
+
44
+ const models: ModelSource[] = [{ path: "order.bpmn", xml: ORDER_XML }];
45
+
46
+ test("scanModelDecisions extracts businessRuleTask → calledDecision", () => {
47
+ const decisions = scanModelDecisions(ORDER_XML);
48
+ assert.deepEqual(decisions, [
49
+ { process: "order-fulfilment", elementId: "risk", decisionId: "fraud-score" },
50
+ ]);
51
+ });
52
+
53
+ test("foldOwnership folds reserved meta keys, accumulating repeated `adr`", () => {
54
+ const own = foldOwnership(models);
55
+ assert.equal(own.owner, "payments-team");
56
+ assert.equal(own.slack, "#payments");
57
+ assert.deepEqual(own.adrs, ["ADR 0040", "ADR 0060"]);
58
+ assert.equal(own.team, undefined);
59
+ });
60
+
61
+ // `nano:meta` keys are user-authored, so a crafted key that collides with an
62
+ // `Object.prototype` member (e.g. `toString`) must NOT be treated as a reserved
63
+ // ownership key. Guards against prototype-chain membership checks (`k in OWNERSHIP_KEYS`).
64
+ test("foldOwnership ignores Object.prototype keys in crafted meta", () => {
65
+ const xml = `
66
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
67
+ <bpmn:process id="hostile">
68
+ <bpmn:extensionElements>
69
+ <nano:meta xmlns:nano="urn:nano" key="toString" value="pwned" />
70
+ <nano:meta xmlns:nano="urn:nano" key="constructor" value="pwned" />
71
+ <nano:meta xmlns:nano="urn:nano" key="hasOwnProperty" value="pwned" />
72
+ </bpmn:extensionElements>
73
+ </bpmn:process>
74
+ </bpmn:definitions>`;
75
+ const own = foldOwnership([{ path: "hostile.bpmn", xml }]);
76
+ assert.deepEqual(own, { adrs: [] });
77
+ });
78
+
79
+ test("buildSystemBrief folds processes, call graph, decisions, ownership", () => {
80
+ const b: SystemBrief = buildSystemBrief(models, "acme-orders");
81
+ assert.equal(b.app, "acme-orders");
82
+ assert.deepEqual(b.processes, ["order-fulfilment"]);
83
+ assert.equal(b.workers.length, 1);
84
+ assert.equal(b.workers[0].taskType, "charge-card");
85
+ assert.equal(b.workers[0].in, "io.acme.ChargeReq");
86
+ assert.equal(b.decisions[0].decisionId, "fraud-score");
87
+ assert.equal(b.ownership.owner, "payments-team");
88
+ });
89
+
90
+ test("emitSystemBriefMd renders owner, call graph, and decisions", () => {
91
+ const md = emitSystemBriefMd(buildSystemBrief(models, "acme-orders"));
92
+ assert.match(md, /# acme-orders — system brief/);
93
+ assert.match(md, /payments-team/);
94
+ assert.match(md, /ADR 0040, ADR 0060/);
95
+ assert.match(md, /charge-card/);
96
+ assert.match(md, /fraud-score/);
97
+ });
98
+
99
+ test("emitSystemBriefMd degrades gracefully with no ownership meta", () => {
100
+ const bare = `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
101
+ <bpmn:process id="p1"></bpmn:process></bpmn:definitions>`;
102
+ const md = emitSystemBriefMd(buildSystemBrief([{ path: "p.bpmn", xml: bare }]));
103
+ assert.match(md, /No ownership metadata declared/);
104
+ assert.match(md, /## Processes/);
105
+ });
106
+
107
+ test("emitSystemBriefMd escapes `|` and newlines in model-authored ownership cells", () => {
108
+ const evil = `<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL">
109
+ <bpmn:process id="p1">
110
+ <bpmn:extensionElements>
111
+ <nano:meta xmlns:nano="urn:nano" key="owner" value="a | b" />
112
+ </bpmn:extensionElements>
113
+ </bpmn:process></bpmn:definitions>`;
114
+ const md = emitSystemBriefMd(buildSystemBrief([{ path: "p.bpmn", xml: evil }]));
115
+ const ownerLine = md.split("\n").find((l) => l.startsWith("| Owner |"));
116
+ assert.ok(ownerLine, "owner row rendered");
117
+ // The literal `|` from the meta value must be escaped so it can't add a phantom column.
118
+ assert.match(ownerLine!, /a \\\| b/);
119
+ });
120
+
121
+ test("processId only recognises `bpmn:process` (consistent with scanModelWorkers)", () => {
122
+ // A non-`bpmn:`-prefixed process must not be listed, otherwise the brief would show a process
123
+ // id while every worker/decision row carried an empty Process column.
124
+ const other = `<definitions xmlns:foo="urn:foo">
125
+ <foo:process id="not-bpmn"></foo:process></definitions>`;
126
+ const b = buildSystemBrief([{ path: "o.bpmn", xml: other }]);
127
+ assert.deepEqual(b.processes, []);
128
+ });
129
+
130
+ test("deriveSystemBrief emits both artifacts with valid JSON", () => {
131
+ const arts = deriveSystemBrief(models, "acme-orders");
132
+ const md = arts.find((a) => a.path.endsWith(SYSTEM_BRIEF_MD));
133
+ const json = arts.find((a) => a.path.endsWith(SYSTEM_BRIEF_JSON));
134
+ assert.ok(md && json);
135
+ const parsed: unknown = JSON.parse(json!.content);
136
+ assertSystemBriefShape(parsed);
137
+ assert.equal(parsed.app, "acme-orders");
138
+ assert.equal(parsed.workers[0].taskType, "charge-card");
139
+ });
140
+
141
+ /** Runtime-validate the parsed JSON shape (no `as` cast — banned in this repo) and narrow it to
142
+ * `SystemBrief` for the assertions that follow. */
143
+ function assertSystemBriefShape(v: unknown): asserts v is SystemBrief {
144
+ assert.ok(typeof v === "object" && v !== null, "brief is an object");
145
+ assert.ok(!("app" in v) || typeof v.app === "string", "app is absent or a string");
146
+ assert.ok("workers" in v && Array.isArray(v.workers), "workers is an array");
147
+ assert.ok("processes" in v && Array.isArray(v.processes), "processes is an array");
148
+ assert.ok("decisions" in v && Array.isArray(v.decisions), "decisions is an array");
149
+ assert.ok("ownership" in v && typeof v.ownership === "object" && v.ownership !== null, "ownership is an object");
150
+ }
@@ -0,0 +1,210 @@
1
+ // Deriver: BPMN models + manifest → `system-brief.md` / `system-brief.json`, the app's
2
+ // *institutional-memory* brief (ADR 0060). Where `worker-io.ts` emits worker *types* and
3
+ // `meta.ts` emits a *typed accessor*, this deriver folds the SAME scanned structure into
4
+ // agent- and human-readable **system context**: the app's processes, its service-task call
5
+ // graph, the decisions it encodes, and the ownership / "why" carried on
6
+ // the model as reserved `nano:meta` keys. It reuses the sibling scanners (single parse rule
7
+ // per vocabulary) and is pure (models in, artifacts out) so `urban gen --check` keeps it
8
+ // honest — the brief can never rot the way a hand-written README does (ADR 0060 §1).
9
+ //
10
+ // Wired into `runGen` (gen.ts) as a standard artifact and served at runtime via `/app/agent`
11
+ // (agent.ts, ADR 0060 §2): the shape, scanners, fold, and both emitters are real and tested.
12
+
13
+ import type { DerivedArtifact, Deriver } from "../artifact.ts";
14
+ import { GENERATED_DIR } from "../artifact.ts";
15
+ import { byModelPath, type ModelSource, scanModelWorkers, type WorkerIo } from "./worker-io.ts";
16
+ import { scanModelMeta } from "./meta.ts";
17
+
18
+ /** Basenames of the two emitted artifacts. */
19
+ export const SYSTEM_BRIEF_MD = "system-brief.md";
20
+ export const SYSTEM_BRIEF_JSON = "system-brief.json";
21
+
22
+ /** Reserved `nano:meta` keys the brief recognises as ownership / provenance context (ADR 0060
23
+ * §1). All optional and additive — absence degrades the brief to structure-only. `adr` may be
24
+ * declared more than once (repeated `nano:meta`, matching the existing scan) to reference
25
+ * multiple ADRs; the scan collects them into `adrs[]`. */
26
+ export interface Ownership {
27
+ owner?: string;
28
+ team?: string;
29
+ slack?: string;
30
+ runbook?: string;
31
+ since?: string;
32
+ adrs: string[];
33
+ }
34
+
35
+ /** One decision the app encodes: a `businessRuleTask` with a `zeebe:calledDecision`. */
36
+ export interface DecisionRef {
37
+ process?: string;
38
+ elementId?: string;
39
+ decisionId: string;
40
+ }
41
+
42
+ /** The derived, machine-readable system model — the payload of `system-brief.json` and the
43
+ * input the runtime injects as session context / the retro plane consumes (ADR 0060 §3). */
44
+ export interface SystemBrief {
45
+ /** App id/name from the manifest, if provided. */
46
+ app?: string;
47
+ /** Every process id the models declare, in scan order. */
48
+ processes: string[];
49
+ /** Service-task call graph: each worker binding (task type + in/out envelope + process). The
50
+ * dependency edges are implicit — a process depends on the task types its service tasks invoke. */
51
+ workers: WorkerIo[];
52
+ /** Decisions (`businessRuleTask` → DMN) the models encode. */
53
+ decisions: DecisionRef[];
54
+ /** Ownership / provenance folded from reserved `nano:meta` keys. */
55
+ ownership: Ownership;
56
+ }
57
+
58
+ function attr(tag: string, name: string): string | undefined {
59
+ const m = tag.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`));
60
+ return m ? m[1] : undefined;
61
+ }
62
+
63
+ function processId(xml: string): string | undefined {
64
+ const m = xml.match(/<bpmn:process\b[^>]*>/);
65
+ return m ? attr(m[0], "id") : undefined;
66
+ }
67
+
68
+ /** Scan one BPMN document for `businessRuleTask` → `zeebe:calledDecision` decision refs, in
69
+ * document order, tagged with the enclosing process (mirrors `scanModelWorkers`' shape). */
70
+ export function scanModelDecisions(xml: string): DecisionRef[] {
71
+ const proc = processId(xml);
72
+ const out: DecisionRef[] = [];
73
+ const blockRe = /<bpmn:businessRuleTask\b([^>]*)>([\s\S]*?)<\/bpmn:businessRuleTask>/g;
74
+ let m: RegExpExecArray | null;
75
+ while ((m = blockRe.exec(xml)) !== null) {
76
+ const elementId = attr(`<x ${m[1]}>`, "id");
77
+ const cd = m[2].match(/<zeebe:calledDecision\b[^>]*>/);
78
+ if (!cd) continue;
79
+ const decisionId = attr(cd[0], "decisionId");
80
+ if (!decisionId) continue;
81
+ out.push({ process: proc, elementId, decisionId });
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /** Reserved single-valued ownership keys → their `Ownership` field. */
87
+ const OWNERSHIP_KEYS: Record<string, Exclude<keyof Ownership, "adrs">> = {
88
+ owner: "owner",
89
+ team: "team",
90
+ slack: "slack",
91
+ runbook: "runbook",
92
+ since: "since",
93
+ };
94
+
95
+ /** Fold the models' reserved `nano:meta` entries into `Ownership`. Single-valued keys take the
96
+ * last (scan-order) write, matching `foldMeta`; `adr` is multi-valued and accumulates. */
97
+ export function foldOwnership(models: ModelSource[]): Ownership {
98
+ const own: Ownership = { adrs: [] };
99
+ for (const model of [...models].sort(byModelPath)) {
100
+ for (const { key, value } of scanModelMeta(model.xml)) {
101
+ const k = key.trim().toLowerCase();
102
+ if (k === "adr") {
103
+ if (value && !own.adrs.includes(value)) own.adrs.push(value);
104
+ } else if (Object.hasOwn(OWNERSHIP_KEYS, k)) {
105
+ own[OWNERSHIP_KEYS[k]] = value ?? "";
106
+ }
107
+ }
108
+ }
109
+ return own;
110
+ }
111
+
112
+ /** Fold all models + manifest into the machine-readable `SystemBrief`. Deterministic: models
113
+ * are scanned in path order. */
114
+ export function buildSystemBrief(models: ModelSource[], app?: string): SystemBrief {
115
+ const sorted = [...models].sort(byModelPath);
116
+ const workers: WorkerIo[] = [];
117
+ const decisions: DecisionRef[] = [];
118
+ const processes: string[] = [];
119
+ for (const model of sorted) {
120
+ const proc = processId(model.xml);
121
+ if (proc && !processes.includes(proc)) processes.push(proc);
122
+ workers.push(...scanModelWorkers(model.xml));
123
+ decisions.push(...scanModelDecisions(model.xml));
124
+ }
125
+ return { app, processes, workers, decisions, ownership: foldOwnership(models) };
126
+ }
127
+
128
+ /** Escape a Markdown table cell: neutralise `|` and newlines, which would otherwise break the
129
+ * table. Ownership values come from model-authored `nano:meta`, so this both keeps rendering
130
+ * honest and blunts Markdown/prompt injection into the agent-facing brief. Backticks are left
131
+ * intact — the emitter deliberately wraps ids in code spans. */
132
+ function mdCell(s: string): string {
133
+ return s.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
134
+ }
135
+
136
+ function mdTable(headers: string[], rows: string[][]): string {
137
+ if (rows.length === 0) return "_none_\n";
138
+ const head = `| ${headers.join(" | ")} |\n| ${headers.map(() => "---").join(" | ")} |\n`;
139
+ return head + rows.map((r) => `| ${r.map((c) => mdCell(c || "")).join(" | ")} |`).join("\n") + "\n";
140
+ }
141
+
142
+ /** Emit the agent- and human-readable `system-brief.md` from the machine model. Compact by
143
+ * design (call graph + owners, not model XML) to keep the per-session prompt budget small
144
+ * (ADR 0060 §3 / open question 1). */
145
+ export function emitSystemBriefMd(b: SystemBrief): string {
146
+ const o = b.ownership;
147
+ let m = `# ${b.app ?? "App"} — system brief\n\n`;
148
+ m += "_Auto-derived from the process models (ADR 0060). Do not edit — regenerated by " +
149
+ "`urban gen`; drift fails `urban gen --check`._\n\n";
150
+
151
+ m += "## Ownership\n\n";
152
+ const ownRows = [
153
+ ["Owner", o.owner ?? ""],
154
+ ["Team", o.team ?? ""],
155
+ ["Slack", o.slack ?? ""],
156
+ ["Runbook", o.runbook ?? ""],
157
+ ["Since", o.since ?? ""],
158
+ ["ADRs", o.adrs.join(", ")],
159
+ ].filter((r) => r[1]);
160
+ m += ownRows.length ? mdTable(["Field", "Value"], ownRows) : "_No ownership metadata declared " +
161
+ "(add reserved `nano:meta` keys: owner, team, slack, runbook, adr, since)._\n";
162
+ m += "\n";
163
+
164
+ m += "## Processes\n\n";
165
+ m += b.processes.length ? b.processes.map((p) => `- \`${p}\``).join("\n") + "\n" : "_none_\n";
166
+ m += "\n";
167
+
168
+ m += "## Service-task call graph (dependency edges)\n\n";
169
+ m += mdTable(
170
+ ["Process", "Task type (dependency)", "In", "Out"],
171
+ b.workers.map((w) => [
172
+ w.process ? `\`${w.process}\`` : "",
173
+ `\`${w.taskType}\``,
174
+ w.in ? `\`${w.in}\`` : "",
175
+ w.out ? `\`${w.out}\`` : "",
176
+ ]),
177
+ );
178
+ m += "\n";
179
+
180
+ m += "## Decisions (DMN)\n\n";
181
+ m += mdTable(
182
+ ["Process", "Element", "Decision"],
183
+ b.decisions.map((d) => [
184
+ d.process ? `\`${d.process}\`` : "",
185
+ d.elementId ? `\`${d.elementId}\`` : "",
186
+ `\`${d.decisionId}\``,
187
+ ]),
188
+ );
189
+ return m;
190
+ }
191
+
192
+ /**
193
+ * Derive `nano-generated/system-brief.md` + `system-brief.json` from BPMN models (ADR 0060 §1).
194
+ * Pure and deterministic (path-ordered scan), so it is a drop-in drift-checkable artifact like
195
+ * its sibling derivers.
196
+ */
197
+ export function deriveSystemBrief(models: ModelSource[], app?: string): DerivedArtifact[] {
198
+ const brief = buildSystemBrief(models, app);
199
+ return [
200
+ { path: `${GENERATED_DIR}/${SYSTEM_BRIEF_MD}`, content: emitSystemBriefMd(brief) },
201
+ { path: `${GENERATED_DIR}/${SYSTEM_BRIEF_JSON}`, content: JSON.stringify(brief, null, 2) + "\n" },
202
+ ];
203
+ }
204
+
205
+ /** The `Deriver` binding, peer to `worker-io` / `meta`, for wiring into `gen.ts`. */
206
+ export const systemBriefDeriver: Deriver<{ models: ModelSource[]; app?: string }> = {
207
+ id: "system-brief",
208
+ describe: "App institutional-memory brief (processes, call graph, decisions, ownership) — ADR 0060",
209
+ derive: ({ models, app }) => deriveSystemBrief(models, app),
210
+ };
@@ -74,7 +74,7 @@ function fixture(): Record<string, string> {
74
74
  };
75
75
  }
76
76
 
77
- test("runGen writes migrations, the worker index, the meta accessor, and the message map", async () => {
77
+ test("runGen writes migrations, the worker index, the meta accessor, the message map, and the system brief", async () => {
78
78
  const io = memIO(fixture());
79
79
  const res = await runGen({ root: "/app", io });
80
80
  const paths = res.artifacts.map((a) => a.path).sort();
@@ -83,6 +83,8 @@ test("runGen writes migrations, the worker index, the meta accessor, and the mes
83
83
  "nano-generated/domain-rows.d.ts",
84
84
  "nano-generated/message-io.d.ts",
85
85
  "nano-generated/meta.ts",
86
+ "nano-generated/system-brief.json",
87
+ "nano-generated/system-brief.md",
86
88
  "nano-generated/worker-io.d.ts",
87
89
  ]);
88
90
  assert.ok(io.files["/app/nano-generated/app.schema.sql"].includes("CREATE TABLE"));
@@ -91,6 +93,27 @@ test("runGen writes migrations, the worker index, the meta accessor, and the mes
91
93
  assert.ok(io.files["/app/nano-generated/message-io.d.ts"].includes("export type MessageName"));
92
94
  assert.ok(io.files["/app/nano-generated/domain-rows.d.ts"].includes('"greetings": Greetings;'));
93
95
  assert.ok(io.files["/app/nano-generated/domain-rows.d.ts"].includes('"greeting": {'));
96
+ // The system brief threads the app id from the manifest and folds the process + call graph.
97
+ assert.ok(io.files["/app/nano-generated/system-brief.md"].includes("# demo — system brief"));
98
+ assert.ok(io.files["/app/nano-generated/system-brief.md"].includes("demo.do"));
99
+ const brief = JSON.parse(io.files["/app/nano-generated/system-brief.json"]);
100
+ assert.equal(brief.app, "demo");
101
+ assert.deepEqual(brief.processes, ["p"]);
102
+ assert.equal(brief.workers[0].taskType, "demo.do");
103
+ });
104
+
105
+ test("the system brief is drift-checked and swept like its sibling artifacts", async () => {
106
+ const io = memIO(fixture());
107
+ await runGen({ root: "/app", io });
108
+ // Drift gate owns the brief: a hand-edit is reported by --check.
109
+ io.files["/app/nano-generated/system-brief.json"] = "{ tampered: true }";
110
+ const check = await runGen({ root: "/app", io, check: true });
111
+ assert.ok(check.drift.includes("nano-generated/system-brief.json"), "brief drift is reported");
112
+ // Stale sweep owns the brief: a renamed-away brief artifact is removed on the next write.
113
+ io.files["/app/nano-generated/system-brief.old.md"] = "# stale brief";
114
+ const res = await runGen({ root: "/app", io });
115
+ assert.ok(res.swept.includes("nano-generated/system-brief.old.md"), "stale brief swept");
116
+ assert.ok("/app/nano-generated/system-brief.md" in io.files, "fresh brief kept");
94
117
  });
95
118
 
96
119
  test("gen --check reports no drift right after a write", async () => {
@@ -345,13 +368,14 @@ test("readModels: a models block without processes suppresses the resources/ con
345
368
  const io = memIO({
346
369
  "/app/resources/a.bpmn": "<bpmn:definitions/>",
347
370
  "/app/resources/sub/b.bpmn": "<bpmn:definitions/>",
371
+ "/app/resources/sub/deeper/c.bpmn": "<bpmn:definitions/>",
348
372
  });
349
- // No models block at all → convention scans resources/ (shallow, one level deep).
373
+ // No models block at all → convention scans resources/ recursively (issue #231).
350
374
  const byConvention = await readModels("/app", io, {});
351
375
  assert.deepEqual(
352
376
  byConvention.map((m) => m.path).sort(),
353
- ["resources/a.bpmn", "resources/sub/b.bpmn"],
354
- "with no models block, gen discovers under resources/ by convention",
377
+ ["resources/a.bpmn", "resources/sub/b.bpmn", "resources/sub/deeper/c.bpmn"],
378
+ "with no models block, gen discovers under resources/ recursively by convention",
355
379
  );
356
380
  // A declared models block (even one that resolves to zero process files) is an explicit override:
357
381
  // the convention scan must be skipped entirely, matching runtime deployModels.