@githolon/dsl 0.66.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.44.0
2
+
3
+ - Activity-feed runtime reads on `NomosBridge`/`NomosSession`: `activityLog({base, limit})` — the committed
4
+ intent log as raw rows `{intentId, domain, directiveId, actorSubject, occurredAt, payload}` (decoded
5
+ payload; the verified author; the physical HLC) — and `watchActivity({base})`, a stream re-deriving the
6
+ feed on every sync tick. Generic (raw Maps); the generated `renderActivity` (from directives' declared
7
+ `.activity()` metadata) types them into `ActivityEvent`s. Requires `nomos_types >= 0.1.2`.
8
+
1
9
  ## 0.43.0
2
10
 
3
11
  - `NomosBridge.foreignReader({cloud})` — a `NomosForeignReader` backed by the device's multi-workspace bridge
@@ -1168,6 +1168,26 @@ class NomosBridge {
1168
1168
  .toList();
1169
1169
  }
1170
1170
 
1171
+ /// RUNTIME activity feed — per-committed-intent rows (newest-first) from the local ledger, each carrying
1172
+ /// `{intentId, domain, directiveId, actorSubject, occurredAt, payload}` (payload = the DECODED directive
1173
+ /// payload). `base` = a commit-oid cursor (only intents newer than it); omitted = from the tip bounded by
1174
+ /// `limit` (default 200). Generic raw rows — the typed shaping is the codegen `renderActivity`.
1175
+ Future<List<Map<String, dynamic>>> activityLog(
1176
+ {String? base, int? limit}) async {
1177
+ final r = _asMap(await _call('activityLog', {
1178
+ if (base != null) 'base': base,
1179
+ if (limit != null) 'limit': limit,
1180
+ }));
1181
+ return ((r['rows'] as List?) ?? const [])
1182
+ .map((e) => (e as Map).cast<String, dynamic>())
1183
+ .toList();
1184
+ }
1185
+
1186
+ /// Watch the RUNTIME activity feed: a stream that re-emits the newest-first activity rows on every local
1187
+ /// change (doorbell/sync tick). `base` = a commit-oid cursor. Cancelling drops the watch on the runner.
1188
+ Stream<List<dynamic>> watchActivity({String? base, String? session}) =>
1189
+ _watchStream(kind: 'activity', base: base, session: session);
1190
+
1171
1191
  /// Mint an ed25519 DEVICE KEY in-kernel → `{secret, public}` (base64). The SECRET stays on this device
1172
1192
  /// (persist it in secure storage); pass it to [connect] as `authorSecret` (or per-offer `signWith`) so the
1173
1193
  /// holon SIGNS each intent. The PUBLIC half is what gets enrolled so the gate re-verifies the signature on
@@ -1594,6 +1614,7 @@ class NomosBridge {
1594
1614
  String? id,
1595
1615
  String? spatialId,
1596
1616
  NomosBbox? bbox,
1617
+ String? base,
1597
1618
  String? session}) {
1598
1619
  final subId = 'w${++_seq}';
1599
1620
  late final StreamController<List<dynamic>> ctrl;
@@ -1606,6 +1627,7 @@ class NomosBridge {
1606
1627
  if (id != null) 'id': id,
1607
1628
  if (spatialId != null) 'spatialId': spatialId,
1608
1629
  if (bbox != null) 'bbox': bbox.toJson(),
1630
+ if (base != null) 'base': base,
1609
1631
  if (session != null) 'session': session,
1610
1632
  }),
1611
1633
  onCancel: () async {
@@ -1709,6 +1731,9 @@ class NomosSession extends NomosBridge {
1709
1731
  [String? session]) =>
1710
1732
  parent.watchSpatial(spatialId, bbox, session ?? workspace);
1711
1733
  @override
1734
+ Stream<List<dynamic>> watchActivity({String? base, String? session}) =>
1735
+ parent.watchActivity(base: base, session: session ?? workspace);
1736
+ @override
1712
1737
  Stream<Map<String, Object?>> watchCompatibility(
1713
1738
  Map<String, Object?> requirement,
1714
1739
  [String? session]) =>
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.43.0
14
+ version: 0.44.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -20,7 +20,7 @@ dependencies:
20
20
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
21
21
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
22
22
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
23
- nomos_types: ^0.1.1
23
+ nomos_types: ^0.1.2
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -1,3 +1,10 @@
1
+ ## 0.1.2
2
+
3
+ - Activity-feed runtime types: `ActivityEvent` (the shared activity row — kind/actor/occurredAt, rendered
4
+ title+message, stable `titleKey`/`messageKey` + params for localization, typed `refs`, category/icon/
5
+ severity, domain/directiveId provenance), `ActivityRef`, `ActivitySeverity`, `renderActivityTemplate`.
6
+ Consumed by the generated `renderActivity`/`describe`/`describeHistory`. Purely additive.
7
+
1
8
  ## 0.1.1
2
9
 
3
10
  - Add `t.foreignRef` runtime types: `ForeignRef<T>` (a typed cross-workspace locator), `NomosForeignReader` /
@@ -31,3 +31,4 @@ export 'src/errors.dart';
31
31
  // `NomosForeignReader`/`NomosForeignWorkspaceReader` read surfaces + the named resolve outcomes. Pure data +
32
32
  // abstract interfaces (no bridge) — so both the layered TYPES package and nomos_client resolve them.
33
33
  export 'src/foreign_ref.dart';
34
+ export 'src/activity.dart';
@@ -0,0 +1,160 @@
1
+ // NOMOS — the ACTIVITY-FEED runtime types (directive `.activity(...)` descriptors). The shared, tenant-
2
+ // agnostic shape a client renders from the intent log: a directive DECLARES its activity metadata (kind,
3
+ // message template, refs, params) and the generated `renderActivity` turns each committed intent into an
4
+ // [ActivityEvent] — no string-enricher guessing meaning beside the truth. The kernel/ledger stays the audit
5
+ // truth; this is a pure client-side human projection.
6
+ //
7
+ // These live in the pure `nomos_types` core (no bridge/lifecycle) so BOTH the layered TYPES package (where
8
+ // the generated pure `renderActivity` lives) AND the runtime `nomos_client` package resolve them.
9
+ library;
10
+
11
+ /// A declared navigation target on an activity row: the referenced aggregate/contract TYPE + the resolved id
12
+ /// (read from a payload field the directive named). Replaces the old enricher's blind id-probing — the app
13
+ /// knows exactly what `id` points at and can deep-link/resolve it.
14
+ class ActivityRef {
15
+ final String type;
16
+ final String id;
17
+ const ActivityRef({required this.type, required this.id});
18
+
19
+ factory ActivityRef.fromJson(Map<String, dynamic> j) =>
20
+ ActivityRef(type: (j['type'] ?? '') as String, id: (j['id'] ?? '') as String);
21
+ Map<String, dynamic> toJson() => {'type': type, 'id': id};
22
+
23
+ @override
24
+ bool operator ==(Object other) => other is ActivityRef && other.type == type && other.id == id;
25
+ @override
26
+ int get hashCode => Object.hash(type, id);
27
+ }
28
+
29
+ /// Row prominence — a declared default the app may style or threshold on.
30
+ enum ActivitySeverity {
31
+ info,
32
+ success,
33
+ warning,
34
+ critical;
35
+
36
+ static ActivitySeverity fromWire(String? s) {
37
+ switch (s) {
38
+ case 'success':
39
+ return ActivitySeverity.success;
40
+ case 'warning':
41
+ return ActivitySeverity.warning;
42
+ case 'critical':
43
+ return ActivitySeverity.critical;
44
+ default:
45
+ return ActivitySeverity.info;
46
+ }
47
+ }
48
+
49
+ String get wire => name;
50
+ }
51
+
52
+ /// A rendered activity row — the tenant-agnostic "tiny shared activity contract". Produced by the generated
53
+ /// `renderActivity(intentMeta, payload)` from a directive's declared `.activity(...)` descriptor. The UI
54
+ /// consumes this directly (title/message already interpolated; `refs` for navigation; `titleKey`/`messageKey`
55
+ /// + `params` for later localization).
56
+ class ActivityEvent {
57
+ /// The intent id — a stable per-event identity (dedupe/order key).
58
+ final String eventId;
59
+
60
+ /// The declared STABLE event kind (e.g. `"site.created"`).
61
+ final String kind;
62
+
63
+ /// The intent's author (principal) — the "who".
64
+ final String actorSubject;
65
+
66
+ /// When it happened (the intent's physical HLC timestamp, ISO-8601 when available).
67
+ final String occurredAt;
68
+
69
+ /// The rendered title (template interpolated); [titleKey] + [params] localize it later.
70
+ final String title;
71
+
72
+ /// The rendered message (template interpolated); [messageKey] + [params] localize it later.
73
+ final String message;
74
+
75
+ /// Stable i18n key for the title (`activity.<kind>.title`).
76
+ final String titleKey;
77
+
78
+ /// Stable i18n key for the message (`activity.<kind>.message`).
79
+ final String messageKey;
80
+
81
+ /// The interpolation params (name → display value, read from the payload) — for localized re-rendering.
82
+ final Map<String, String> params;
83
+
84
+ /// Declared navigation targets (name → typed ref).
85
+ final Map<String, ActivityRef> refs;
86
+
87
+ /// Optional grouping category (e.g. `"estate"`).
88
+ final String? category;
89
+
90
+ /// Optional default icon token (app-interpreted).
91
+ final String? icon;
92
+
93
+ /// Row prominence (default [ActivitySeverity.info]).
94
+ final ActivitySeverity severity;
95
+
96
+ /// Provenance — the domain + directive that produced this event.
97
+ final String domain;
98
+ final String directiveId;
99
+
100
+ const ActivityEvent({
101
+ required this.eventId,
102
+ required this.kind,
103
+ required this.actorSubject,
104
+ required this.occurredAt,
105
+ required this.title,
106
+ required this.message,
107
+ required this.titleKey,
108
+ required this.messageKey,
109
+ required this.params,
110
+ required this.refs,
111
+ required this.severity,
112
+ required this.domain,
113
+ required this.directiveId,
114
+ this.category,
115
+ this.icon,
116
+ });
117
+
118
+ factory ActivityEvent.fromJson(Map<String, dynamic> j) => ActivityEvent(
119
+ eventId: (j['eventId'] ?? '') as String,
120
+ kind: (j['kind'] ?? '') as String,
121
+ actorSubject: (j['actorSubject'] ?? '') as String,
122
+ occurredAt: (j['occurredAt'] ?? '') as String,
123
+ title: (j['title'] ?? '') as String,
124
+ message: (j['message'] ?? '') as String,
125
+ titleKey: (j['titleKey'] ?? '') as String,
126
+ messageKey: (j['messageKey'] ?? '') as String,
127
+ params: ((j['params'] as Map?)?.cast<String, dynamic>() ?? const {})
128
+ .map((k, v) => MapEntry(k, '$v')),
129
+ refs: ((j['refs'] as Map?)?.cast<String, dynamic>() ?? const {})
130
+ .map((k, v) => MapEntry(k, ActivityRef.fromJson((v as Map).cast<String, dynamic>()))),
131
+ category: j['category'] as String?,
132
+ icon: j['icon'] as String?,
133
+ severity: ActivitySeverity.fromWire(j['severity'] as String?),
134
+ domain: (j['domain'] ?? '') as String,
135
+ directiveId: (j['directiveId'] ?? '') as String,
136
+ );
137
+
138
+ Map<String, dynamic> toJson() => {
139
+ 'eventId': eventId,
140
+ 'kind': kind,
141
+ 'actorSubject': actorSubject,
142
+ 'occurredAt': occurredAt,
143
+ 'title': title,
144
+ 'message': message,
145
+ 'titleKey': titleKey,
146
+ 'messageKey': messageKey,
147
+ 'params': params,
148
+ 'refs': refs.map((k, v) => MapEntry(k, v.toJson())),
149
+ if (category != null) 'category': category,
150
+ if (icon != null) 'icon': icon,
151
+ 'severity': severity.wire,
152
+ 'domain': domain,
153
+ 'directiveId': directiveId,
154
+ };
155
+ }
156
+
157
+ /// Interpolate `{name}` placeholders in [template] from [vars] — the render helper the generated
158
+ /// `renderActivity` uses. A missing var renders empty (the compiler already validated every placeholder).
159
+ String renderActivityTemplate(String template, Map<String, String> vars) =>
160
+ template.replaceAllMapped(RegExp(r'\{([a-zA-Z0-9_]+)\}'), (m) => vars[m.group(1)] ?? '');
@@ -11,7 +11,7 @@ description: >-
11
11
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
12
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
13
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.1.1
14
+ version: 0.1.2
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.66.0",
3
+ "version": "0.67.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,179 @@
1
+ // NOMOS — DIRECTIVE ACTIVITY DESCRIPTORS (architecture/activity_descriptors.md). A directive OPT-IN declares
2
+ // its own user-facing activity metadata, so a client can fold typed activity rows straight from the intent
3
+ // log — no string-enricher guessing meaning beside the truth. The kernel/ledger stays the audit truth; this
4
+ // is a pure CLIENT-SIDE human projection, declared where the event semantics live.
5
+ //
6
+ // directive("createSite").creates(EstateSite).payload(SitePayload)
7
+ // .activity({
8
+ // kind: "site.created", // STABLE event id (seeds the i18n keys)
9
+ // title: "Created site",
10
+ // message: "{actor} created {siteName}", // {actor} + declared params
11
+ // category: "estate", icon: "add_location", severity: "info",
12
+ // refs: { site: { type: EstateSite, from: "siteId" } }, // navigation target (type + payload path)
13
+ // params: { siteName: "name" }, // display value ← payload field path
14
+ // })
15
+ //
16
+ // NON-LAW, additive metadata (same class as `.doc()`): a directive that never calls `.activity()` is
17
+ // byte-identical in the canonical manifest.
18
+ import { z } from "zod";
19
+ import type { AggregateHandle } from "./aggregate.js";
20
+ import { isContractHandle, type ContractHandle } from "./contract.js";
21
+
22
+ /** How prominent an activity row is — a declared default the app may style/threshold on. */
23
+ export type ActivitySeverity = "info" | "success" | "warning" | "critical";
24
+
25
+ /** A declared navigation target: the referenced aggregate/contract TYPE + the payload field path holding its
26
+ * id. Replaces the enricher's blind id-probing — the reference is declared, not guessed. */
27
+ export interface ActivityRefInput {
28
+ /** The target type — an aggregate handle or a `contract(...)` (reuse contracts for the nav target). */
29
+ readonly type: AggregateHandle | ContractHandle;
30
+ /** The payload field path holding the target's id (dotted for nested, e.g. `"location.siteId"`). */
31
+ readonly from: string;
32
+ }
33
+
34
+ /** The `.activity({...})` input (declarative — field PATHS, never functions, so it generates to Dart too). */
35
+ export interface ActivityInput {
36
+ /** STABLE event id (e.g. `"site.created"`) — the wire kind + the seed for the i18n keys. */
37
+ readonly kind: string;
38
+ /** Default (English) title; falls back to `kind` if omitted. */
39
+ readonly title?: string;
40
+ /** Message template — `{actor}` + any declared `params` name in braces (e.g. `"{actor} created {siteName}"`). */
41
+ readonly message: string;
42
+ /** Optional grouping category (e.g. `"estate"`). */
43
+ readonly category?: string;
44
+ /** Optional default icon token (app-interpreted). */
45
+ readonly icon?: string;
46
+ /** Row prominence (default `"info"`). */
47
+ readonly severity?: ActivitySeverity;
48
+ /** Navigation targets: name → { target type, payload path to its id }. */
49
+ readonly refs?: Record<string, ActivityRefInput>;
50
+ /** Display values: template-param name → payload field path (dotted ok). */
51
+ readonly params?: Record<string, string>;
52
+ }
53
+
54
+ /** The NORMALIZED, serializable descriptor stored on the directive + emitted to codegen/IR. */
55
+ export interface ActivityDescriptor {
56
+ readonly kind: string;
57
+ readonly title: string;
58
+ readonly message: string;
59
+ readonly category?: string;
60
+ readonly icon?: string;
61
+ readonly severity: ActivitySeverity;
62
+ /** Refs sorted by name; `type` is the target's wire id (a string). */
63
+ readonly refs: readonly { readonly name: string; readonly type: string; readonly from: string }[];
64
+ /** Params sorted by name. */
65
+ readonly params: readonly { readonly name: string; readonly from: string }[];
66
+ /** Stable i18n keys derived from `kind` (`activity.<kind>.title` / `.message`). */
67
+ readonly titleKey: string;
68
+ readonly messageKey: string;
69
+ }
70
+
71
+ const SEVERITIES: ReadonlySet<string> = new Set(["info", "success", "warning", "critical"]);
72
+
73
+ /** The i18n key for a `kind` + slot — stable, derived (never hand-authored). */
74
+ export function activityKey(kind: string, slot: "title" | "message"): string {
75
+ return `activity.${kind}.${slot}`;
76
+ }
77
+
78
+ /** Interpolate `{name}` placeholders in a template from `vars` (a missing var renders as the empty string —
79
+ * the compile-time validator already guarantees every placeholder is declared, so this is defensive only).
80
+ * The ONE render helper shared by the TS + Dart renderers (each reimplements the same trivial substitution). */
81
+ export function renderActivityTemplate(template: string, vars: Record<string, string>): string {
82
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_m, name: string) => vars[name] ?? "");
83
+ }
84
+
85
+ /** The `{placeholder}` names referenced by a template. */
86
+ function templateVars(template: string): string[] {
87
+ const out: string[] = [];
88
+ for (const m of template.matchAll(/\{([a-zA-Z0-9_]+)\}/g)) out.push(m[1]!);
89
+ return out;
90
+ }
91
+
92
+ /** Top-level payload field names (best-effort — a plain `z.object`); `undefined` when the schema isn't a
93
+ * destructurable object (validation of `from` roots is then skipped, staying permissive). */
94
+ function payloadFieldNames(schema: unknown): Set<string> | undefined {
95
+ const def = (schema as { _def?: { shape?: unknown }; def?: { shape?: unknown } })?._def
96
+ ?? (schema as { def?: { shape?: unknown } })?.def;
97
+ const shape = (def as { shape?: unknown })?.shape;
98
+ const resolved = typeof shape === "function" ? (shape as () => Record<string, unknown>)() : shape;
99
+ if (resolved === undefined || resolved === null || typeof resolved !== "object") return undefined;
100
+ return new Set(Object.keys(resolved as Record<string, unknown>));
101
+ }
102
+
103
+ /**
104
+ * Validate + normalize an `.activity({...})` input at author time (throws a typed error naming the problem).
105
+ * Checks: `kind`/`message` non-empty; `severity` valid; every message/title `{placeholder}` is `actor` or a
106
+ * declared param; every ref/param `from` ROOT names a real payload field (when the payload shape is
107
+ * introspectable). Returns the serializable {@link ActivityDescriptor}. Pure.
108
+ */
109
+ export function normalizeActivity(directiveId: string, input: ActivityInput, payloadSchema?: unknown): ActivityDescriptor {
110
+ const where = `directive '${directiveId}' .activity(...)`;
111
+ if (typeof input.kind !== "string" || input.kind.trim().length === 0) {
112
+ throw new Error(`${where}: 'kind' must be a non-empty stable string (e.g. "site.created").`);
113
+ }
114
+ if (typeof input.message !== "string" || input.message.trim().length === 0) {
115
+ throw new Error(`${where}: 'message' must be a non-empty template string.`);
116
+ }
117
+ const severity = input.severity ?? "info";
118
+ if (!SEVERITIES.has(severity)) {
119
+ throw new Error(`${where}: 'severity' must be one of info|success|warning|critical (got '${severity}').`);
120
+ }
121
+ const params = Object.entries(input.params ?? {}).map(([name, from]) => {
122
+ if (typeof from !== "string" || from.length === 0) {
123
+ throw new Error(`${where}: param '${name}' must map to a payload field path string.`);
124
+ }
125
+ return { name, from };
126
+ });
127
+ const paramNames = new Set(params.map((p) => p.name));
128
+ // Every {placeholder} must be `actor` or a declared param — an undeclared var is a typo, refuse it.
129
+ for (const tmpl of [input.title ?? "", input.message]) {
130
+ for (const v of templateVars(tmpl)) {
131
+ if (v !== "actor" && !paramNames.has(v)) {
132
+ throw new Error(
133
+ `${where}: template references '{${v}}' but no such param is declared (known: actor${
134
+ paramNames.size > 0 ? ", " + [...paramNames].join(", ") : ""
135
+ }). Declare it in params or fix the template.`,
136
+ );
137
+ }
138
+ }
139
+ }
140
+ const refs = Object.entries(input.refs ?? {}).map(([name, r]) => {
141
+ const targetId = (r?.type as { id?: unknown })?.id;
142
+ const isTarget = typeof targetId === "string" && ((r.type as { __isAggregateHandle?: unknown }).__isAggregateHandle === true || isContractHandle(r.type));
143
+ if (!isTarget) {
144
+ throw new Error(`${where}: ref '${name}'.type must be an aggregate handle or a contract(...) (got ${typeof r?.type}).`);
145
+ }
146
+ if (typeof r.from !== "string" || r.from.length === 0) {
147
+ throw new Error(`${where}: ref '${name}'.from must be a payload field path string.`);
148
+ }
149
+ return { name, type: targetId as string, from: r.from };
150
+ });
151
+ // Best-effort payload-field existence check (root of a dotted path).
152
+ const fields = payloadFieldNames(payloadSchema);
153
+ if (fields !== undefined) {
154
+ for (const { name, from } of [...params, ...refs.map((r) => ({ name: `ref ${r.name}`, from: r.from }))]) {
155
+ const root = from.split(".")[0]!;
156
+ if (!fields.has(root)) {
157
+ throw new Error(
158
+ `${where}: ${name} reads payload field '${root}' which the payload does not declare ` +
159
+ `(known: ${[...fields].sort().join(", ")}). Fix the path or add the field.`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+ return {
165
+ kind: input.kind,
166
+ title: input.title ?? input.kind,
167
+ message: input.message,
168
+ ...(input.category !== undefined ? { category: input.category } : {}),
169
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
170
+ severity,
171
+ refs: refs.sort((a, b) => a.name.localeCompare(b.name)),
172
+ params: params.sort((a, b) => a.name.localeCompare(b.name)),
173
+ titleKey: activityKey(input.kind, "title"),
174
+ messageKey: activityKey(input.kind, "message"),
175
+ };
176
+ }
177
+
178
+ /** Re-exported so directive.ts can accept the zod payload type in the `.activity` overload without a cycle. */
179
+ export type { z };
@@ -2002,6 +2002,133 @@ export function generateDartContracts(contracts: readonly import("./contract.js"
2002
2002
  ].join("\n");
2003
2003
  }
2004
2004
 
2005
+ // ── ACTIVITY DESCRIPTORS (architecture/activity_descriptors.md) — shared registry codegen ──
2006
+
2007
+ /** One directive's declared activity, tagged with its provenance (domain + directive id). */
2008
+ export interface PackageActivityEntry {
2009
+ readonly domain: string;
2010
+ readonly directiveId: string;
2011
+ readonly desc: import("./activity.js").ActivityDescriptor;
2012
+ }
2013
+
2014
+ /** Collect every directive's `.activity(...)` descriptor across the package's modules, tagged with its
2015
+ * domain + directive id, sorted by directive id (byte-deterministic switch/registry order). Empty when
2016
+ * no directive opts in — an activity-free package then emits NO registry (byte-identical). Shared by the
2017
+ * flat + layered Dart emit AND the TS client (one source of truth). */
2018
+ export function collectActivityDescriptors(modules: readonly DomainModule[]): PackageActivityEntry[] {
2019
+ const out: PackageActivityEntry[] = [];
2020
+ for (const mod of modules) {
2021
+ const domain = mod.domain ?? mod.name;
2022
+ for (const d of mod.directives as AnyDirective[]) {
2023
+ const desc = (d as { activityDescriptor?: import("./activity.js").ActivityDescriptor }).activityDescriptor;
2024
+ if (desc !== undefined) out.push({ domain, directiveId: d.id, desc });
2025
+ }
2026
+ }
2027
+ return out.sort((a, b) => (a.directiveId < b.directiveId ? -1 : a.directiveId > b.directiveId ? 1 : 0));
2028
+ }
2029
+
2030
+ /** Dart string literal (single-quoted, backslash/quote/`$` escaped). */
2031
+ const dartLit = (s: string): string => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\$/g, "\\$")}'`;
2032
+
2033
+ /** Emit ONE `case '<directiveId>':` arm folding a raw row into a typed [ActivityEvent]. */
2034
+ function emitDartActivityArm(e: PackageActivityEntry): string[] {
2035
+ const { directiveId, desc } = e;
2036
+ const paramEntries = desc.params.map((p) => ` ${dartLit(p.name)}: _activityPayloadPath(payload, ${dartLit(p.from)}),`);
2037
+ const refEntries = desc.refs.map(
2038
+ (r) => ` ${dartLit(r.name)}: ActivityRef(type: ${dartLit(r.type)}, id: _activityPayloadPath(payload, ${dartLit(r.from)})),`,
2039
+ );
2040
+ return [
2041
+ ` case ${dartLit(directiveId)}:`,
2042
+ ` {`,
2043
+ ` final params = <String, String>{`,
2044
+ ...paramEntries,
2045
+ ` };`,
2046
+ ` final vars = <String, String>{'actor': actorSubject, ...params};`,
2047
+ ` return ActivityEvent(`,
2048
+ ` eventId: (rawRow['intentId'] ?? '') as String,`,
2049
+ ` kind: ${dartLit(desc.kind)},`,
2050
+ ` actorSubject: actorSubject,`,
2051
+ ` occurredAt: (rawRow['occurredAt'] ?? '') as String,`,
2052
+ ` title: renderActivityTemplate(${dartLit(desc.title)}, vars),`,
2053
+ ` message: renderActivityTemplate(${dartLit(desc.message)}, vars),`,
2054
+ ` titleKey: ${dartLit(desc.titleKey)},`,
2055
+ ` messageKey: ${dartLit(desc.messageKey)},`,
2056
+ ` params: params,`,
2057
+ ` refs: <String, ActivityRef>{`,
2058
+ ...refEntries,
2059
+ ` },`,
2060
+ ...(desc.category !== undefined ? [` category: ${dartLit(desc.category)},`] : []),
2061
+ ...(desc.icon !== undefined ? [` icon: ${dartLit(desc.icon)},`] : []),
2062
+ ` severity: ActivitySeverity.${desc.severity},`,
2063
+ ` domain: (rawRow['domain'] ?? '') as String,`,
2064
+ ` directiveId: ${dartLit(directiveId)},`,
2065
+ ` );`,
2066
+ ` }`,
2067
+ ];
2068
+ }
2069
+
2070
+ /**
2071
+ * The full source of the shared `activity_registry.dart` file — the PURE per-directive activity registry
2072
+ * + `renderActivity(rawRow) => ActivityEvent?`, emitted ONCE per package into the TYPES package (both the
2073
+ * flat + layered emit). References only nomos_types (`ActivityEvent`/`ActivityRef`/`ActivitySeverity`/
2074
+ * `renderActivityTemplate`), so the file is PURE (frontend-safe, no bridge). A raw row is
2075
+ * `{ intentId, domain, directiveId, actorSubject, occurredAt, payload }` (the runtime's per-committed-intent
2076
+ * shape); `renderActivity` returns null for a directive with no declared `.activity(...)`.
2077
+ */
2078
+ export function generateDartActivityRegistry(entries: readonly PackageActivityEntry[]): string {
2079
+ const sorted = [...entries].sort((a, b) => (a.directiveId < b.directiveId ? -1 : a.directiveId > b.directiveId ? 1 : 0));
2080
+ return [
2081
+ `// GENERATED by nomos-compile — the package's shared ACTIVITY REGISTRY (architecture/activity_descriptors.md). DO NOT EDIT.`,
2082
+ `// A directive OPT-IN declares its user-facing activity metadata; \`renderActivity\` folds each committed-intent`,
2083
+ `// raw row into a typed ActivityEvent. Pure (nomos_types only) — the kernel/ledger stays the audit truth.`,
2084
+ `//`,
2085
+ `// ignore_for_file: unused_import`,
2086
+ `library;`,
2087
+ ``,
2088
+ `import 'package:nomos_client/nomos_client.dart';`,
2089
+ ``,
2090
+ `/// A NARRATIVE over the activity log (for an in-app LLM): the filtered [events] + a plain-English`,
2091
+ `/// [transcript] (ONE LINE PER EVENT, oldest-first, \`"<occurredAt> — <title>: <message>"\`) grounded in`,
2092
+ `/// the VERIFIED log, never raw payloads. Produced by the client's \`describe\` / \`describeHistory\`.`,
2093
+ `class ActivityNarrative {`,
2094
+ ` final List<ActivityEvent> events;`,
2095
+ ` final String transcript;`,
2096
+ ` const ActivityNarrative({required this.events, required this.transcript});`,
2097
+ `}`,
2098
+ ``,
2099
+ `/// Wrap events into an [ActivityNarrative], building the LLM-ready transcript: one line per event,`,
2100
+ `/// OLDEST-FIRST (the log yields newest-first).`,
2101
+ `ActivityNarrative activityNarrative(List<ActivityEvent> events) => ActivityNarrative(`,
2102
+ ` events: events,`,
2103
+ ` transcript: events.reversed.map((e) => '\${e.occurredAt} — \${e.title}: \${e.message}').join('\\n'),`,
2104
+ ` );`,
2105
+ ``,
2106
+ `/// Read a dotted path off a decoded intent payload, coerced to String ('' when absent/null).`,
2107
+ `String _activityPayloadPath(Map<String, dynamic> payload, String path) {`,
2108
+ ` dynamic cur = payload;`,
2109
+ ` for (final seg in path.split('.')) {`,
2110
+ ` if (cur is! Map) return '';`,
2111
+ ` cur = cur[seg];`,
2112
+ ` }`,
2113
+ ` return cur == null ? '' : '\$cur';`,
2114
+ `}`,
2115
+ ``,
2116
+ `/// Fold ONE committed-intent raw row into a typed [ActivityEvent], or null when the directive declares`,
2117
+ `/// no \`.activity(...)\`. rawRow = { intentId, domain, directiveId, actorSubject, occurredAt, payload }.`,
2118
+ `ActivityEvent? renderActivity(Map<String, dynamic> rawRow) {`,
2119
+ ` final directiveId = (rawRow['directiveId'] ?? '') as String;`,
2120
+ ` final actorSubject = (rawRow['actorSubject'] ?? '') as String;`,
2121
+ ` final payload = ((rawRow['payload'] as Map?)?.cast<String, dynamic>()) ?? const <String, dynamic>{};`,
2122
+ ` switch (directiveId) {`,
2123
+ ...sorted.flatMap(emitDartActivityArm),
2124
+ ` default:`,
2125
+ ` return null;`,
2126
+ ` }`,
2127
+ `}`,
2128
+ ``,
2129
+ ].join("\n");
2130
+ }
2131
+
2005
2132
  /** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
2006
2133
  * shared [emitEvidenceRefClass] once. */
2007
2134
  function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
@@ -121,6 +121,19 @@ export interface DartBridgeClientOptions {
121
121
  * client re-exports that barrel so one import of the client still gives the whole typed surface.
122
122
  */
123
123
  readonly typesImportUri?: string;
124
+ /**
125
+ * ACTIVITY FEED (architecture/activity_descriptors.md): true iff the PACKAGE declares ≥1 directive
126
+ * `.activity(...)`. When set the client emits `watchActivity()` / `activityLog()` (folding the bridge's
127
+ * raw committed-intent rows through the shared pure `renderActivity`). Off ⇒ byte-identical to before.
128
+ */
129
+ readonly emitActivity?: boolean;
130
+ /**
131
+ * FLAT (vendored) mode only: the relative import of the shared `activity_registry.dart` (where the pure
132
+ * `renderActivity` lives), needed because a flat client imports only its sibling `<name>.dart`. In layered
133
+ * mode the registry resolves through {@link typesImportUri} (the types barrel re-exports it), so this is
134
+ * omitted. Only meaningful when {@link emitActivity} is set.
135
+ */
136
+ readonly activityImportUri?: string;
124
137
  }
125
138
 
126
139
  /** Generate the `<name>.client.dart` async bridge client for one domain module. */
@@ -345,6 +358,55 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
345
358
  );
346
359
  }
347
360
 
361
+ // ── activity feed → typed reactive + one-shot (declared `.activity(...)`) ──
362
+ if (opts.emitActivity === true) {
363
+ methods.push(
364
+ ` /// THE ACTIVITY FEED (architecture/activity_descriptors.md): fold the committed intent log into typed`,
365
+ ` /// [ActivityEvent]s (declared \`.activity(...)\`). Re-emits on every new committed intent; a directive`,
366
+ ` /// with no declared activity is dropped. Cancel the subscription to stop.`,
367
+ ` Stream<List<ActivityEvent>> watchActivity() =>`,
368
+ ` bridge.watchActivity().map((rows) => rows`,
369
+ ` .map((r) => renderActivity((r as Map).cast<String, dynamic>()))`,
370
+ ` .whereType<ActivityEvent>()`,
371
+ ` .toList());`,
372
+ ``,
373
+ ` /// One-shot: the current committed activity log, folded to typed [ActivityEvent]s (declared \`.activity(...)\`).`,
374
+ ` Future<List<ActivityEvent>> activityLog({String? base, int? limit}) async =>`,
375
+ ` (await bridge.activityLog(base: base, limit: limit))`,
376
+ ` .map((r) => renderActivity(r.cast<String, dynamic>()))`,
377
+ ` .whereType<ActivityEvent>()`,
378
+ ` .toList();`,
379
+ ``,
380
+ ` /// NARRATIVE (for an in-app LLM): the workspace story — the activity log folded to typed events + a`,
381
+ ` /// plain-English [ActivityNarrative.transcript] grounded in the VERIFIED log. Filter by [actor] /`,
382
+ ` /// [category] / [since] (ISO occurredAt lower bound); [limit] caps the rows pulled. Newest-first.`,
383
+ ` Future<ActivityNarrative> describe({String? since, String? actor, String? category, int? limit}) async {`,
384
+ ` final raw = await bridge.activityLog(limit: limit);`,
385
+ ` var events = raw`,
386
+ ` .map((r) => renderActivity(r.cast<String, dynamic>()))`,
387
+ ` .whereType<ActivityEvent>()`,
388
+ ` .toList();`,
389
+ ` if (actor != null) events = events.where((e) => e.actorSubject == actor).toList();`,
390
+ ` if (category != null) events = events.where((e) => e.category == category).toList();`,
391
+ ` if (since != null) events = events.where((e) => e.occurredAt.compareTo(since) >= 0).toList();`,
392
+ ` return activityNarrative(events);`,
393
+ ` }`,
394
+ ``,
395
+ ` /// NARRATIVE for ONE thing: the story of the aggregate [aggregateId] — events whose declared refs`,
396
+ ` /// point at it (only directives declaring a ref to the aggregate appear in its story) + a transcript.`,
397
+ ` Future<ActivityNarrative> describeHistory(String aggregateId, {int? limit}) async {`,
398
+ ` final raw = await bridge.activityLog(limit: limit);`,
399
+ ` final events = raw`,
400
+ ` .map((r) => renderActivity(r.cast<String, dynamic>()))`,
401
+ ` .whereType<ActivityEvent>()`,
402
+ ` .where((e) => e.refs.values.any((ref) => ref.id == aggregateId))`,
403
+ ` .toList();`,
404
+ ` return activityNarrative(events);`,
405
+ ` }`,
406
+ ``,
407
+ );
408
+ }
409
+
348
410
  return [
349
411
  `// AUTO-GENERATED by nomos-compile — typed ASYNC Dart client for domain "${domain}".`,
350
412
  `// Drives the REAL githolon over a NomosBridge (the WebView runner). Regenerated every compile; do`,
@@ -368,6 +430,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
368
430
  // Layered-packages mode: the sibling types live in their OWN package — one barrel import
369
431
  // replaces both the relative types import and the relative framework import.
370
432
  ...(opts.typesImportUri !== undefined ? [`import '${opts.typesImportUri}';`] : [`import '${mod.name}.dart';`]),
433
+ // ACTIVITY FEED (flat mode): the shared pure `renderActivity` lives in the sibling `activity_registry.dart`
434
+ // (in layered mode it resolves through the types-package barrel above). Only when the package opts in.
435
+ ...(opts.emitActivity === true && opts.activityImportUri !== undefined ? [`import '${opts.activityImportUri}';`] : []),
371
436
  // Framework-shared aggregate types (RelationTupleReadModel, …) live in the ONE shared file — import
372
437
  // them (the by-id accessors reference them) and re-export them (once per package, Dart de-dups).
373
438
  ...(opts.typesImportUri === undefined && opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
package/src/codegen_ts.ts CHANGED
@@ -40,6 +40,7 @@ import type { AggregateHandle } from "./aggregate.js";
40
40
  import type { Field, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
41
41
  import type { Directive } from "./directive.js";
42
42
  import type { DomainModule } from "./codegen_dart.js";
43
+ import { collectActivityDescriptors, type PackageActivityEntry } from "./codegen_dart.js";
43
44
  import type { NomosCompat } from "./compat_manifest.js";
44
45
  import type { QueryDecl } from "./query.js";
45
46
  import { voMetaOf, collectPayloadValueObjectDescriptors } from "./value_object.js";
@@ -665,6 +666,86 @@ export interface TsClientOptions {
665
666
  readonly compat?: NomosCompat;
666
667
  }
667
668
 
669
+ /** TS string literal (JSON.stringify covers escaping). */
670
+ const _actLit = (s: string): string => JSON.stringify(s);
671
+
672
+ /** Emit the ACTIVITY-FEED block (architecture/activity_descriptors.md): the `ActivityEvent`/`ActivityRef`/
673
+ * `ActivityRawRow` runtime types, a dotted payload-path getter, the per-directive `renderActivity` registry,
674
+ * and the shared template renderer — inline in the generated `.client.ts` (mirrors the ForeignRef placement).
675
+ * Empty when no directive declares `.activity(...)` — an activity-free package stays BYTE-IDENTICAL. */
676
+ function emitTsActivityBlock(entries: readonly PackageActivityEntry[]): string[] {
677
+ if (entries.length === 0) return [];
678
+ const arm = (e: PackageActivityEntry): string[] => {
679
+ const { directiveId, desc } = e;
680
+ const params = desc.params.map((p) => `${_actLit(p.name)}: _activityPayloadPath(row.payload, ${_actLit(p.from)})`).join(", ");
681
+ const refs = desc.refs
682
+ .map((r) => `${_actLit(r.name)}: { type: ${_actLit(r.type)}, id: _activityPayloadPath(row.payload, ${_actLit(r.from)}) }`)
683
+ .join(", ");
684
+ return [
685
+ ` ${_actLit(directiveId)}: (row: ActivityRawRow): ActivityEvent => {`,
686
+ ` const params: Record<string, string> = { ${params} };`,
687
+ ` const vars = { actor: row.actorSubject, ...params };`,
688
+ ` return {`,
689
+ ` eventId: row.intentId, kind: ${_actLit(desc.kind)}, actorSubject: row.actorSubject, occurredAt: row.occurredAt,`,
690
+ ` title: _renderActivityTemplate(${_actLit(desc.title)}, vars), message: _renderActivityTemplate(${_actLit(desc.message)}, vars),`,
691
+ ` titleKey: ${_actLit(desc.titleKey)}, messageKey: ${_actLit(desc.messageKey)},`,
692
+ ` params, refs: { ${refs} },`,
693
+ ...(desc.category !== undefined ? [` category: ${_actLit(desc.category)},`] : []),
694
+ ...(desc.icon !== undefined ? [` icon: ${_actLit(desc.icon)},`] : []),
695
+ ` severity: ${_actLit(desc.severity)}, domain: row.domain, directiveId: ${_actLit(directiveId)},`,
696
+ ` };`,
697
+ ` },`,
698
+ ];
699
+ };
700
+ return [
701
+ `// ── activity feed (architecture/activity_descriptors.md) — typed rows folded from the intent log ──`,
702
+ `/** A declared navigation target on an activity row (the referenced type + the resolved id). */`,
703
+ `export interface ActivityRef { type: string; id: string }`,
704
+ `/** A rendered activity row — produced by \`renderActivity\` from a directive's declared \`.activity(...)\`. */`,
705
+ `export interface ActivityEvent {`,
706
+ ` eventId: string; kind: string; actorSubject: string; occurredAt: string;`,
707
+ ` title: string; message: string; titleKey: string; messageKey: string;`,
708
+ ` params: Record<string, string>; refs: Record<string, ActivityRef>;`,
709
+ ` category?: string; icon?: string;`,
710
+ ` severity: "info" | "success" | "warning" | "critical";`,
711
+ ` domain: string; directiveId: string;`,
712
+ `}`,
713
+ `/** The runtime's per-committed-intent RAW ROW (the shape \`holon.watchActivity\`/\`activityLog\` yield). */`,
714
+ `export interface ActivityRawRow { intentId: string; domain: string; directiveId: string; actorSubject: string; occurredAt: string; payload: Record<string, unknown> }`,
715
+ `/** A NARRATIVE over the activity log (for an in-app LLM): the filtered events + a plain-English \`transcript\``,
716
+ ` * (ONE LINE PER EVENT, oldest-first, \`"<occurredAt> — <title>: <message>"\`) — grounding text over the`,
717
+ ` * VERIFIED log, never raw payloads. Produced by the client's \`describe\` / \`describeHistory\`. */`,
718
+ `export interface ActivityNarrative { events: ActivityEvent[]; transcript: string }`,
719
+ `/** Build the LLM-ready transcript: one line per event, OLDEST-FIRST (the log yields newest-first). */`,
720
+ `function _activityTranscript(events: readonly ActivityEvent[]): string {`,
721
+ " return [...events].reverse().map((e) => `${e.occurredAt} — ${e.title}: ${e.message}`).join(\"\\n\");",
722
+ `}`,
723
+ `/** Read a dotted path off a decoded intent payload, coerced to string ("" when absent/null). */`,
724
+ `function _activityPayloadPath(obj: unknown, path: string): string {`,
725
+ ` let cur: unknown = obj;`,
726
+ ` for (const seg of path.split(".")) {`,
727
+ ` if (cur === null || typeof cur !== "object") return "";`,
728
+ ` cur = (cur as Record<string, unknown>)[seg];`,
729
+ ` }`,
730
+ ` return cur === null || cur === undefined ? "" : String(cur);`,
731
+ `}`,
732
+ `/** Interpolate \`{name}\` placeholders in a template from vars (a missing var renders empty). */`,
733
+ `function _renderActivityTemplate(template: string, vars: Record<string, string>): string {`,
734
+ ` return template.replace(/\\{([a-zA-Z0-9_]+)\\}/g, (_m, name: string) => vars[name] ?? "");`,
735
+ `}`,
736
+ `const _ACTIVITY_REGISTRY: Record<string, (row: ActivityRawRow) => ActivityEvent> = {`,
737
+ ...entries.flatMap(arm),
738
+ `};`,
739
+ `/** Fold ONE committed-intent raw row into a typed [ActivityEvent], or null when the directive declares`,
740
+ ` * no \`.activity(...)\`. Pure — the kernel/ledger stays the audit truth; this is a client-side projection. */`,
741
+ `export function renderActivity(row: ActivityRawRow): ActivityEvent | null {`,
742
+ ` const f = _ACTIVITY_REGISTRY[row.directiveId];`,
743
+ ` return f === undefined ? null : f(row);`,
744
+ `}`,
745
+ ``,
746
+ ];
747
+ }
748
+
668
749
  /** TRUE when any module declares a PACKED workspace taxonomy (the routed-holon shape).
669
750
  * Gates every slice-4 paged-surface emission: taxonomy-free clients stay BYTE-IDENTICAL. */
670
751
  function anyPackedTaxonomy(modules: readonly DomainModule[]): boolean {
@@ -690,6 +771,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
690
771
  );
691
772
  const encPresent = encMap !== null;
692
773
  const encConst = encryptionConstName(opts.packageName);
774
+ // ACTIVITY FEED (architecture/activity_descriptors.md): the package's declared `.activity(...)` descriptors.
775
+ // Empty ⇒ NOTHING activity-related emits (byte-identical to a pre-activity compile).
776
+ const activityEntries = collectActivityDescriptors(modules);
777
+ const activityPresent = activityEntries.length > 0;
693
778
  const out: string[] = [];
694
779
  // The PACKAGE-LEVEL recursive-type registry: every directive payload's `z.lazy` knots mint
695
780
  // named recursive type aliases into it (deduped by lazy identity across directives); the decls
@@ -853,6 +938,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
853
938
  ` deadLetters(): Promise<{ id?: string; oid: string; source: string; error: string; domain?: string | null; directiveId?: string | null; at: string }[]>;`,
854
939
  ` retryDeadLetter(id: string): Promise<{ ok: boolean; head?: string; alreadyOnMain?: boolean; error?: string }>;`,
855
940
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
941
+ ...(activityPresent
942
+ ? [
943
+ ` /** THE ACTIVITY FEED (declared \`.activity(...)\`): subscribe to per-committed-intent RAW ROWS`,
944
+ ` * (the runtime folds the intent log). \`cb\` fires with the current rows + on every new intent;`,
945
+ ` * returns an unsubscribe fn. The generated client maps these through \`renderActivity\`. */`,
946
+ ` watchActivity(cb: (rows: ActivityRawRow[]) => void, opts?: { base?: string }): () => void;`,
947
+ ` /** One-shot: the current committed activity log as RAW ROWS (folded to typed rows by the client). */`,
948
+ ` activityLog(opts?: { base?: string; limit?: number }): Promise<ActivityRawRow[]>;`,
949
+ ]
950
+ : []),
856
951
  ` /** THE COMPAT SUPERPOWER (#72b) — OPTIONAL: a holon from @githolon/client's connect() attaches it.`,
857
952
  ` * The factory hands over this client's baked compat manifest + structural interface (the requirement),`,
858
953
  ` * and the holon computes the LIVE per-member verdict on \`holon.compat\`. A KNOWN-incompatible directive`,
@@ -1489,6 +1584,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1489
1584
  }
1490
1585
  }
1491
1586
 
1587
+ // ── activity feed (declared `.activity(...)`) — types + `renderActivity` registry (empty ⇒ nothing) ──
1588
+ out.push(...emitTsActivityBlock(activityEntries));
1589
+
1492
1590
  // ── payload types + per-domain client factories ──
1493
1591
  const usedPayloadNames = new Set<string>();
1494
1592
  for (const mod of modules) {
@@ -1714,6 +1812,38 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1714
1812
  );
1715
1813
  }
1716
1814
 
1815
+ if (activityPresent) {
1816
+ methods.push(
1817
+ ` /** THE ACTIVITY FEED (declared \`.activity(...)\`): typed activity rows folded from the intent log.`,
1818
+ ` * \`cb\` fires with the current rows + on every new committed intent; returns stop(). Directives with`,
1819
+ ` * no declared activity are dropped. */`,
1820
+ ` watchActivity: (cb: (events: ActivityEvent[]) => void, opts?: { base?: string }) =>`,
1821
+ ` holon.watchActivity((rows) => cb(rows.map(renderActivity).filter((e): e is ActivityEvent => e !== null)), opts),`,
1822
+ ` /** One-shot: the current committed activity log, folded to typed [ActivityEvent]s. */`,
1823
+ ` activityLog: async (opts?: { base?: string; limit?: number }): Promise<ActivityEvent[]> =>`,
1824
+ ` (await holon.activityLog(opts)).map(renderActivity).filter((e): e is ActivityEvent => e !== null),`,
1825
+ ` /** NARRATIVE (for an in-app LLM): the workspace story — the activity log folded to typed events +`,
1826
+ ` * a plain-English \`transcript\` grounded in the VERIFIED log. Filter by \`actor\` / \`category\` /`,
1827
+ ` * \`since\` (ISO occurredAt lower bound); \`limit\` caps the rows pulled. Events are newest-first. */`,
1828
+ ` describe: async (opts: { since?: string; actor?: string; category?: string; limit?: number } = {}): Promise<ActivityNarrative> => {`,
1829
+ ` const raw = await holon.activityLog(opts.limit !== undefined ? { limit: opts.limit } : {});`,
1830
+ ` let events = raw.map(renderActivity).filter((e): e is ActivityEvent => e !== null);`,
1831
+ ` if (opts.actor !== undefined) events = events.filter((e) => e.actorSubject === opts.actor);`,
1832
+ ` if (opts.category !== undefined) events = events.filter((e) => e.category === opts.category);`,
1833
+ ` if (opts.since !== undefined) events = events.filter((e) => e.occurredAt >= opts.since!);`,
1834
+ ` return { events, transcript: _activityTranscript(events) };`,
1835
+ ` },`,
1836
+ ` /** NARRATIVE for ONE thing: the story of the aggregate \`aggregateId\` — events whose declared refs`,
1837
+ ` * point at it (only directives declaring a ref to the aggregate appear in its story) + a transcript. */`,
1838
+ ` describeHistory: async (aggregateId: string, opts: { limit?: number } = {}): Promise<ActivityNarrative> => {`,
1839
+ ` const raw = await holon.activityLog(opts.limit !== undefined ? { limit: opts.limit } : {});`,
1840
+ ` const events = raw.map(renderActivity).filter((e): e is ActivityEvent => e !== null)`,
1841
+ ` .filter((e) => Object.values(e.refs).some((r) => r.id === aggregateId));`,
1842
+ ` return { events, transcript: _activityTranscript(events) };`,
1843
+ ` },`,
1844
+ );
1845
+ }
1846
+
1717
1847
  out.push(
1718
1848
  `/** Typed client for the \`${domain}\` domain, bound to a connected holon.`,
1719
1849
  ` * Pass \`{ actor }\` (the verified principal uid) to author AS that user — every write seals it as`,
@@ -56,6 +56,8 @@ import type { AggregateHandle } from "./aggregate.js";
56
56
  import {
57
57
  generateDartDomain,
58
58
  generateDartContracts,
59
+ generateDartActivityRegistry,
60
+ collectActivityDescriptors,
59
61
  type DartImport,
60
62
  type DartRefImport,
61
63
  type DomainModule,
@@ -428,6 +430,7 @@ const DART_SUPPORT_CORE_SRC = [
428
430
  // The foreign-ref locator runtime (ForeignRef<T>/NomosForeignReader/…, t.foreignRef) — a generated read
429
431
  // model with a `t.foreignRef` field references `ForeignRef<Target>` through the barrel, so it must vendor.
430
432
  "foreign_ref.dart",
433
+ "activity.dart",
431
434
  ] as const;
432
435
  // The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
433
436
  // bridge the generated `<name>.client.dart` drives, and the provider surface. Their
@@ -450,6 +453,10 @@ const FRAMEWORK_FILE = "nomos_framework";
450
453
  /** The shared basename of the domain-contracts DTO file emitted once per package (domain_contracts.md). */
451
454
  const CONTRACTS_FILE = "contracts";
452
455
 
456
+ /** The shared basename of the ACTIVITY REGISTRY file emitted once per package (activity_descriptors.md).
457
+ * Absent when no directive declares `.activity(...)` — an activity-free package emits no `activity_registry.dart`. */
458
+ const ACTIVITY_FILE = "activity_registry";
459
+
453
460
  /** Collect every domain contract across the package's modules, deduped by id (later-wins). Sorted by id.
454
461
  * Empty when no domain declares a contract (contract-free packages emit no `contracts.dart`). */
455
462
  function collectPackageContracts(modules: readonly DomainModule[]): ContractHandle[] {
@@ -731,6 +738,14 @@ function emitDart(
731
738
  if (packageContracts.length > 0) {
732
739
  writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts));
733
740
  }
741
+ // 2a-ter. the shared ACTIVITY REGISTRY (`activity_registry.dart`) — the pure `renderActivity`, ONCE. Emitted
742
+ // iff a directive opts in via `.activity(...)` (activity-free packages stay byte-identical). Pure
743
+ // (nomos_types), so the client folds typed activity rows from the intent log with no runtime import.
744
+ const packageActivity = collectActivityDescriptors(modules);
745
+ const activityImportUri = packageActivity.length > 0 ? `${ACTIVITY_FILE}.dart` : undefined;
746
+ if (packageActivity.length > 0) {
747
+ writeGenerated(ACTIVITY_FILE, generateDartActivityRegistry(packageActivity));
748
+ }
734
749
  // CROSS-DOMAIN `t.foreignRef` (auto import). A foreignRef field typed `ForeignRef<CatalogueListing>` +
735
750
  // `CatalogueListing.fromJson` needs the TARGET aggregate's Dart class — which lives in the target DOMAIN's
736
751
  // generated file. Within ONE package the compiler knows which domain owns each aggregate, so we auto-emit
@@ -794,6 +809,7 @@ function emitDart(
794
809
  ...(compat !== undefined ? { compat } : {}),
795
810
  supportImport,
796
811
  ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
812
+ ...(packageActivity.length > 0 ? { emitActivity: true, ...(activityImportUri !== undefined ? { activityImportUri } : {}) } : {}),
797
813
  });
798
814
  const cp = path.join(genDir, `${mod.name}.client.dart`);
799
815
  writeFileSync(cp, clientSrc, "utf8");
@@ -820,6 +836,7 @@ function emitDart(
820
836
  : `export 'package:nomos_client/nomos_client.dart';`,
821
837
  ...(hasFrameworkSymbols ? [`export '${genRel}/${FRAMEWORK_FILE}.dart';`] : []),
822
838
  ...(contractsImportUri !== undefined ? [`export '${genRel}/${CONTRACTS_FILE}.dart';`] : []),
839
+ ...(activityImportUri !== undefined ? [`export '${genRel}/${ACTIVITY_FILE}.dart';`] : []),
823
840
  ...modules.flatMap((m) => [
824
841
  `export '${genRel}/${m.name}.dart';`,
825
842
  `export '${genRel}/${m.name}.client.dart';`,
@@ -844,8 +861,10 @@ function emitDart(
844
861
  /** The pure-core support import generated TYPES files carry in layered-packages mode. */
845
862
  const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
846
863
  /** Version constraints the scaffolded pubspecs pin — move together with the Dart packages' releases. */
847
- const NOMOS_TYPES_DEP = "^0.1.0";
848
- const NOMOS_CLIENT_DEP = "^0.38.0";
864
+ // FLOOR = the release carrying every symbol generated code may reference (0.1.2: ActivityEvent).
865
+ const NOMOS_TYPES_DEP = "^0.1.2";
866
+ // FLOOR = the release carrying the bridge methods generated clients call (0.44.0: activityLog/watchActivity).
867
+ const NOMOS_CLIENT_DEP = "^0.44.0";
849
868
 
850
869
  const isPubPackageName = (s: string): boolean => /^[a-z][a-z0-9_]*$/.test(s);
851
870
 
@@ -960,7 +979,7 @@ function emitDartPackages(
960
979
  // field as the target aggregate's class, which lives in the target domain's file). A sibling types file is
961
980
  // still PURE (no runtime/bridge surface — the purity gate exists to keep nomos_client out, not to isolate
962
981
  // domain files from each other; Dart allows type-import cycles for mutual refs).
963
- const siblingTypeFiles = new Set<string>([...modules.map((m) => `${m.name}.dart`), `${FRAMEWORK_FILE}.dart`, `${CONTRACTS_FILE}.dart`]);
982
+ const siblingTypeFiles = new Set<string>([...modules.map((m) => `${m.name}.dart`), `${FRAMEWORK_FILE}.dart`, `${CONTRACTS_FILE}.dart`, `${ACTIVITY_FILE}.dart`]);
964
983
  const writeTypes = (fileBase: string, src: string): void => {
965
984
  if (!src.includes(DART_PACKAGE_IMPORT)) {
966
985
  fail(`generated Dart for '${fileBase}' does not carry the expected support import — codegen_dart header drifted`);
@@ -994,6 +1013,10 @@ function emitDartPackages(
994
1013
  const packageContracts = collectPackageContracts(modules);
995
1014
  const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
996
1015
  if (packageContracts.length > 0) writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts));
1016
+ // ACTIVITY REGISTRY (layered): the pure `renderActivity` in the TYPES package (nomos_types only). The
1017
+ // client resolves it through the types-package barrel (which re-exports every types file). Opt-in only.
1018
+ const packageActivity = collectActivityDescriptors(modules);
1019
+ if (packageActivity.length > 0) writeTypes(ACTIVITY_FILE, generateDartActivityRegistry(packageActivity));
997
1020
  // CROSS-DOMAIN foreignRef (layered): map aggregate id → its domain's type file, so a foreignRef field's
998
1021
  // target class resolves via a sibling import (the purity gate now permits sibling type files).
999
1022
  const aggIdToTypeFile = new Map<string, string>();
@@ -1085,6 +1108,7 @@ function emitDartPackages(
1085
1108
  ...(compat !== undefined ? { compat } : {}),
1086
1109
  supportImport: "package",
1087
1110
  typesImportUri,
1111
+ ...(packageActivity.length > 0 ? { emitActivity: true } : {}),
1088
1112
  });
1089
1113
  const cp = path.join(clientGen, `${mod.name}.client.dart`);
1090
1114
  writeFileSync(cp, clientSrc, "utf8");
package/src/directive.ts CHANGED
@@ -23,6 +23,7 @@ import type { Ports } from "./ctx.js";
23
23
  import type { CertifiedReadDecl } from "./certified_read.js";
24
24
  import type { RelationDecl } from "./relation.js";
25
25
  import type { QueryDecl } from "./query.js";
26
+ import { normalizeActivity, type ActivityInput, type ActivityDescriptor } from "./activity.js";
26
27
  import { BIRTH_METER_QUERIES } from "./birth_meter.js";
27
28
  import {
28
29
  isFromPayloadRef,
@@ -115,6 +116,15 @@ export interface Directive<P = unknown> {
115
116
  * byte-identical in the canonical manifest to before `description` existed).
116
117
  */
117
118
  readonly description?: string;
119
+ /**
120
+ * OPT-IN user-facing ACTIVITY metadata (`architecture/activity_descriptors.md`) — the kind/title/message
121
+ * template, refs, params, category/icon/severity a client uses to render a typed activity row from this
122
+ * directive's intents (no string-enricher). Set by `.activity({...})`; NON-LAW, additive (same class as
123
+ * `description` — a directive that never declares activity is byte-identical). Normalized + validated at
124
+ * author time. `undefined` ⇒ this directive contributes no activity row. (The setter is `.activity({...})`;
125
+ * this stored field is `activityDescriptor` to avoid a method/field name clash on `RequirableDirective`.)
126
+ */
127
+ readonly activityDescriptor?: ActivityDescriptor;
118
128
  /**
119
129
  * NATIVE ZANZIBAR write-authz (P4): the RELATION the captured author must HOLD to author this
120
130
  * directive, and the OBJECT it is checked on. The ONE gate runs `engine.check(schema, object,
@@ -414,6 +424,14 @@ export interface RequirableDirective<P> extends Directive<P> {
414
424
  * calls this declares no prose (codegen falls back to a default derived from the id).
415
425
  */
416
426
  doc(description: string): RequirableDirective<P>;
427
+ /**
428
+ * Declare OPT-IN user-facing ACTIVITY metadata (`architecture/activity_descriptors.md`) — a stable `kind`,
429
+ * a `{actor}`+params message template, navigation `refs`, display `params` (payload field paths), and
430
+ * category/icon/severity. Codegen emits a typed activity descriptor + `renderActivity` so a client folds
431
+ * typed activity rows from the intent log (no string-enricher). Validated at author time (unknown template
432
+ * var / missing payload field refuse). Non-law, additive; a directive that never calls it is byte-identical.
433
+ */
434
+ activity(input: ActivityInput): RequirableDirective<P>;
417
435
  /**
418
436
  * Declare the directive's CertifiedReads (survival Constraint 5; `certified_read.md`
419
437
  * §"Tenant authoring") — the profiled, named write-path reads its `decide()` consults, the
@@ -810,6 +828,11 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
810
828
  doc(description: string): RequirableDirective<P> {
811
829
  return makeRequirable({ ...base, description });
812
830
  },
831
+ activity(input: ActivityInput): RequirableDirective<P> {
832
+ // Normalize + validate against this directive's payload (unknown template var / missing payload field
833
+ // refuse here). Non-law, additive — stored like `description`.
834
+ return makeRequirable({ ...base, activityDescriptor: normalizeActivity(base.id, input, base.payloadSchema) });
835
+ },
813
836
  readsCertified(reads: Record<string, CertifiedReadDecl>): RequirableDirective<P> {
814
837
  // Accumulate onto any prior declared CertifiedReads (a repeated local name's later decl
815
838
  // wins) — additive, non-destructive. The local binding NAME (the key) is what `decide`
package/src/index.ts CHANGED
@@ -136,6 +136,7 @@ export {
136
136
  // surface). The dev never sees a "prim" — that is the GitHolon's internal OpenUSD IR.
137
137
  export { create, existing, ensure, type AggregateRef } from "./authoring.js";
138
138
  export { contract, isContractHandle, satisfiesContract, type ContractHandle, type ContractField, type ContractSatisfaction } from "./contract.js";
139
+ export { normalizeActivity, renderActivityTemplate, activityKey, type ActivityInput, type ActivityDescriptor, type ActivityRefInput, type ActivitySeverity } from "./activity.js";
139
140
  // THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
140
141
  // the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
141
142
  // queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).