@cosmicdrift/kumiko-bundled-features 0.235.2 → 0.235.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.235.2",
3
+ "version": "0.235.4",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.235.2",
134
- "@cosmicdrift/kumiko-framework": "0.235.2",
135
- "@cosmicdrift/kumiko-headless": "0.235.2",
136
- "@cosmicdrift/kumiko-renderer": "0.235.2",
137
- "@cosmicdrift/kumiko-renderer-web": "0.235.2",
138
- "@cosmicdrift/kumiko-types": "0.235.2",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.235.4",
134
+ "@cosmicdrift/kumiko-framework": "0.235.4",
135
+ "@cosmicdrift/kumiko-headless": "0.235.4",
136
+ "@cosmicdrift/kumiko-renderer": "0.235.4",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.235.4",
138
+ "@cosmicdrift/kumiko-types": "0.235.4",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.235.2",
168
- "@cosmicdrift/kumiko-locale-es": "0.235.2"
167
+ "@cosmicdrift/kumiko-locale-de": "0.235.4",
168
+ "@cosmicdrift/kumiko-locale-es": "0.235.4"
169
169
  }
170
170
  }
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { AgentDocGapKinds, findAgentDocGaps, formatAgentDocGap } from "../agent-doc-lint";
5
+
6
+ const OPEN_ACCESS = { openToAll: true } as const;
7
+
8
+ async function noopWriteHandler() {
9
+ return { isSuccess: true as const, data: {} };
10
+ }
11
+
12
+ async function noopQueryHandler() {
13
+ return {};
14
+ }
15
+
16
+ describe("findAgentDocGaps", () => {
17
+ test("R1: exactly three undescribed handlers -> exactly three gaps with the expected QNs", () => {
18
+ const feature = defineFeature("doc-gap-demo", (r) => {
19
+ r.writeHandler("do-a", z.object({}), noopWriteHandler, { access: OPEN_ACCESS });
20
+ r.writeHandler("do-b", z.object({}), noopWriteHandler, { access: OPEN_ACCESS });
21
+ r.queryHandler("get-c", z.object({}), noopQueryHandler, { access: OPEN_ACCESS });
22
+ });
23
+
24
+ const gaps = findAgentDocGaps([feature]);
25
+
26
+ expect(gaps).toHaveLength(3);
27
+ expect(gaps.map((g) => g.qn)).toEqual([
28
+ "doc-gap-demo:query:get-c",
29
+ "doc-gap-demo:write:do-a",
30
+ "doc-gap-demo:write:do-b",
31
+ ]);
32
+ });
33
+
34
+ test("R1: handler with description -> no gap", () => {
35
+ const feature = defineFeature("doc-gap-demo", (r) => {
36
+ r.writeHandler("do-a", z.object({}), noopWriteHandler, {
37
+ access: OPEN_ACCESS,
38
+ description: "Does A.",
39
+ });
40
+ });
41
+
42
+ expect(findAgentDocGaps([feature])).toHaveLength(0);
43
+ });
44
+
45
+ test("R1: handler with agent.expose:false opt-out -> no gap", () => {
46
+ const feature = defineFeature("doc-gap-demo", (r) => {
47
+ r.writeHandler("do-a", z.object({}), noopWriteHandler, {
48
+ access: OPEN_ACCESS,
49
+ agent: { expose: false },
50
+ });
51
+ });
52
+
53
+ expect(findAgentDocGaps([feature])).toHaveLength(0);
54
+ });
55
+
56
+ test("R2: custom screen without description -> exactly one gap with the correct QN", () => {
57
+ const feature = defineFeature("doc-gap-demo", (r) => {
58
+ r.screen({ id: "widget-editor", type: "custom", renderer: { react: "stub" } });
59
+ });
60
+
61
+ const gaps = findAgentDocGaps([feature]);
62
+ expect(gaps).toHaveLength(1);
63
+ expect(gaps[0]?.qn).toBe("doc-gap-demo:screen:widget-editor");
64
+ });
65
+
66
+ test("R2: custom screen WITH description -> no gap", () => {
67
+ const feature = defineFeature("doc-gap-demo", (r) => {
68
+ r.screen({
69
+ id: "widget-editor",
70
+ type: "custom",
71
+ renderer: { react: "stub" },
72
+ description: "Edits a widget.",
73
+ });
74
+ });
75
+
76
+ expect(findAgentDocGaps([feature])).toHaveLength(0);
77
+ });
78
+
79
+ test("R2: non-custom screen without description -> no gap (only custom screens are linted)", () => {
80
+ const feature = defineFeature("doc-gap-demo", (r) => {
81
+ r.entity("widget", { fields: {}, description: "A widget." });
82
+ r.screen({ id: "widget-list", type: "entityList", entity: "widget", columns: [] });
83
+ });
84
+
85
+ expect(findAgentDocGaps([feature])).toHaveLength(0);
86
+ });
87
+
88
+ test("R3: entity without description with two agent-visible handlers -> exactly one gap", () => {
89
+ const feature = defineFeature("widgets", (r) => {
90
+ r.entity("widget", { fields: {} });
91
+ r.writeHandler("widget:create", z.object({}), noopWriteHandler, {
92
+ access: OPEN_ACCESS,
93
+ description: "Creates a widget.",
94
+ });
95
+ r.writeHandler("widget:update", z.object({}), noopWriteHandler, {
96
+ access: OPEN_ACCESS,
97
+ description: "Updates a widget.",
98
+ });
99
+ });
100
+
101
+ const gaps = findAgentDocGaps([feature]);
102
+ const entityGaps = gaps.filter((g) => g.qn === "widgets:entity:widget");
103
+ expect(entityGaps).toHaveLength(1);
104
+ });
105
+
106
+ test("R3: entity WITH description -> no entity gap", () => {
107
+ const feature = defineFeature("widgets", (r) => {
108
+ r.entity("widget", { fields: {}, description: "A widget." });
109
+ r.writeHandler("widget:create", z.object({}), noopWriteHandler, {
110
+ access: OPEN_ACCESS,
111
+ description: "Creates a widget.",
112
+ });
113
+ });
114
+
115
+ expect(findAgentDocGaps([feature]).some((g) => g.qn === "widgets:entity:widget")).toBe(false);
116
+ });
117
+
118
+ test("R3: entity whose only mapped handler opts out via agent.expose:false -> no entity gap", () => {
119
+ const feature = defineFeature("widgets", (r) => {
120
+ r.entity("widget", { fields: {} });
121
+ r.writeHandler("widget:create", z.object({}), noopWriteHandler, {
122
+ access: OPEN_ACCESS,
123
+ agent: { expose: false },
124
+ });
125
+ });
126
+
127
+ expect(findAgentDocGaps([feature]).some((g) => g.qn === "widgets:entity:widget")).toBe(false);
128
+ });
129
+
130
+ test("R3: fires for an agent-visible QUERY handler too (handlerEntityMappings can't distinguish namespaces)", () => {
131
+ const feature = defineFeature("widgets", (r) => {
132
+ r.entity("widget", { fields: {} });
133
+ r.queryHandler("widget:list", z.object({}), noopQueryHandler, {
134
+ access: OPEN_ACCESS,
135
+ description: "Lists widgets.",
136
+ });
137
+ });
138
+
139
+ const gaps = findAgentDocGaps([feature]);
140
+ expect(gaps.filter((g) => g.qn === "widgets:entity:widget")).toHaveLength(1);
141
+ });
142
+
143
+ test("R3: camelCase entity name -> qn keeps the raw name, not kebab-cased", () => {
144
+ const feature = defineFeature("document-ingest", (r) => {
145
+ r.entity("documentExtract", { fields: {} });
146
+ r.writeHandler("documentExtract:create", z.object({}), noopWriteHandler, {
147
+ access: OPEN_ACCESS,
148
+ description: "Creates a document extract.",
149
+ });
150
+ });
151
+
152
+ const gaps = findAgentDocGaps([feature]);
153
+ const entityGaps = gaps.filter(
154
+ (g) => g.kind === AgentDocGapKinds.exposedEntityWithoutDescription,
155
+ );
156
+ expect(entityGaps).toHaveLength(1);
157
+ expect(entityGaps[0]?.qn).toBe("document-ingest:entity:documentExtract");
158
+ });
159
+
160
+ test("clean registry (everything described) -> empty array", () => {
161
+ const feature = defineFeature("widgets", (r) => {
162
+ r.entity("widget", { fields: {}, description: "A widget." });
163
+ r.writeHandler("widget:create", z.object({}), noopWriteHandler, {
164
+ access: OPEN_ACCESS,
165
+ description: "Creates a widget.",
166
+ });
167
+ r.screen({
168
+ id: "widget-editor",
169
+ type: "custom",
170
+ renderer: { react: "stub" },
171
+ description: "Edits a widget.",
172
+ });
173
+ });
174
+
175
+ expect(findAgentDocGaps([feature])).toEqual([]);
176
+ });
177
+
178
+ test("result is sorted by qn", () => {
179
+ const feature = defineFeature("doc-gap-demo", (r) => {
180
+ r.queryHandler("get-z", z.object({}), noopQueryHandler, { access: OPEN_ACCESS });
181
+ r.writeHandler("do-a", z.object({}), noopWriteHandler, { access: OPEN_ACCESS });
182
+ r.screen({ id: "widget-editor", type: "custom", renderer: { react: "stub" } });
183
+ });
184
+
185
+ const qns = findAgentDocGaps([feature]).map((g) => g.qn);
186
+ expect(qns).toEqual([...qns].sort());
187
+ });
188
+
189
+ test("formatAgentDocGap includes the qn and the reason", () => {
190
+ const feature = defineFeature("doc-gap-demo", (r) => {
191
+ r.writeHandler("do-a", z.object({}), noopWriteHandler, { access: OPEN_ACCESS });
192
+ });
193
+
194
+ const [gap] = findAgentDocGaps([feature]);
195
+ expect(gap).toBeDefined();
196
+ const formatted = formatAgentDocGap(gap as NonNullable<typeof gap>);
197
+ expect(formatted).toContain((gap as NonNullable<typeof gap>).qn);
198
+ expect(formatted).toContain((gap as NonNullable<typeof gap>).message);
199
+ });
200
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ createEntity,
4
+ createRegistry,
5
+ createSelectField,
6
+ createTextField,
7
+ defineFeature,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import type { ReferenceFieldDef, TextFieldDef } from "@cosmicdrift/kumiko-framework/engine/types";
10
+ import { z } from "zod";
11
+ import { buildAgentManifest } from "../agent-manifest";
12
+
13
+ const gatedEntity = createEntity({
14
+ fields: {
15
+ status: createSelectField({ options: ["draft", "active"] as const, filterable: true }),
16
+ ownerId: { type: "reference", entity: "user" } satisfies ReferenceFieldDef,
17
+ title: createTextField({ searchable: true }),
18
+ // Raw ResolvedPiiFlags form — the manifest is schema, not rows, so this
19
+ // must never surface a value, only the marker.
20
+ secretNote: { type: "text", pii: true } satisfies TextFieldDef,
21
+ },
22
+ });
23
+
24
+ function buildTestFeature() {
25
+ return defineFeature("agent-manifest-test", (r) => {
26
+ r.entity("widget", gatedEntity);
27
+
28
+ r.queryHandler("widget:lookup", z.object({ id: z.string() }), async () => ({}), {
29
+ access: { roles: ["admin", "viewer"] },
30
+ description: "Look up a widget by id.",
31
+ });
32
+
33
+ r.writeHandler(
34
+ "widget:archive",
35
+ z.object({ id: z.string() }),
36
+ async () => ({ isSuccess: true, data: {} }),
37
+ {
38
+ access: { roles: ["admin"] },
39
+ description: "Archive a widget.",
40
+ },
41
+ );
42
+
43
+ r.queryHandler("widget:undescribed", z.object({ id: z.string() }), async () => ({}), {
44
+ access: { roles: ["admin", "viewer"] },
45
+ });
46
+
47
+ r.queryHandler(
48
+ "widget:oddschema",
49
+ z.object({ occurredAt: z.instanceof(Date) }),
50
+ async () => ({}),
51
+ {
52
+ access: { roles: ["admin", "viewer"] },
53
+ description: "Takes a schema z.toJSONSchema cannot express.",
54
+ },
55
+ );
56
+ });
57
+ }
58
+
59
+ const otherEntity = createEntity({
60
+ fields: {
61
+ label: createTextField({ searchable: true }),
62
+ },
63
+ });
64
+
65
+ // Name sorts alphabetically before "agent-manifest-test" so mount order and
66
+ // sort order provably diverge in the determinism test below.
67
+ function buildOtherFeature() {
68
+ return defineFeature("aa-other-feature", (r) => {
69
+ r.entity("other", otherEntity);
70
+
71
+ r.queryHandler("other:lookup", z.object({ id: z.string() }), async () => ({}), {
72
+ access: { roles: ["admin"] },
73
+ description: "Look up an other by id.",
74
+ });
75
+ });
76
+ }
77
+
78
+ describe("buildAgentManifest", () => {
79
+ test("fail-closed: a handler without description and without agent.expose is never exposed", () => {
80
+ const registry = createRegistry([buildTestFeature()]);
81
+ const admin = buildAgentManifest(registry, { locale: "en", roles: ["admin"] });
82
+ const viewer = buildAgentManifest(registry, { locale: "en", roles: ["viewer"] });
83
+
84
+ expect(admin.handlers.some((h) => h.qn.endsWith(":undescribed"))).toBe(false);
85
+ expect(viewer.handlers.some((h) => h.qn.endsWith(":undescribed"))).toBe(false);
86
+ });
87
+
88
+ test("role gating: viewer sees the query handler but not the admin-only write handler", () => {
89
+ const registry = createRegistry([buildTestFeature()]);
90
+ const viewer = buildAgentManifest(registry, { locale: "en", roles: ["viewer"] });
91
+ const admin = buildAgentManifest(registry, { locale: "en", roles: ["admin"] });
92
+
93
+ expect(viewer.handlers.some((h) => h.qn.endsWith(":lookup"))).toBe(true);
94
+ expect(viewer.handlers.some((h) => h.qn.endsWith(":archive"))).toBe(false);
95
+ expect(admin.handlers.some((h) => h.qn.endsWith(":lookup"))).toBe(true);
96
+ expect(admin.handlers.some((h) => h.qn.endsWith(":archive"))).toBe(true);
97
+ });
98
+
99
+ test("risk defaults: query defaults to low, write defaults to mid", () => {
100
+ const registry = createRegistry([buildTestFeature()]);
101
+ const admin = buildAgentManifest(registry, { locale: "en", roles: ["admin"] });
102
+
103
+ const queryHandler = admin.handlers.find((h) => h.qn.endsWith(":lookup"));
104
+ const writeHandler = admin.handlers.find((h) => h.qn.endsWith(":archive"));
105
+
106
+ expect(queryHandler?.risk).toBe("low");
107
+ expect(writeHandler?.risk).toBe("mid");
108
+ });
109
+
110
+ test("inputSchema is a real JSON Schema derived from the zod schema's properties", () => {
111
+ const registry = createRegistry([buildTestFeature()]);
112
+ const admin = buildAgentManifest(registry, { locale: "en", roles: ["admin"] });
113
+ const queryHandler = admin.handlers.find((h) => h.qn.endsWith(":lookup"));
114
+
115
+ expect(queryHandler?.inputSchema).toMatchObject({
116
+ type: "object",
117
+ properties: { id: { type: "string" } },
118
+ });
119
+ });
120
+
121
+ test("a handler whose schema toJSONSchema cannot express is dropped, others stay", () => {
122
+ const registry = createRegistry([buildTestFeature()]);
123
+ const admin = buildAgentManifest(registry, { locale: "en", roles: ["admin"] });
124
+
125
+ expect(admin.handlers.some((h) => h.qn.endsWith(":oddschema"))).toBe(false);
126
+ expect(admin.handlers.some((h) => h.qn.endsWith(":lookup"))).toBe(true);
127
+ expect(admin.handlers.some((h) => h.qn.endsWith(":archive"))).toBe(true);
128
+ });
129
+
130
+ test("PII field carries only the marker, never a value", () => {
131
+ const registry = createRegistry([buildTestFeature()]);
132
+ const admin = buildAgentManifest(registry, { locale: "en", roles: ["admin"] });
133
+ const widget = admin.entities.find((e) => e.name === "widget");
134
+ const secretNote = widget?.fields.find((f) => f.name === "secretNote");
135
+
136
+ expect(secretNote).toEqual({
137
+ name: "secretNote",
138
+ type: "text",
139
+ labels: {},
140
+ pii: true,
141
+ });
142
+ });
143
+
144
+ test("select field carries its options, reference field carries the referenced entity", () => {
145
+ const registry = createRegistry([buildTestFeature()]);
146
+ const admin = buildAgentManifest(registry, { locale: "en", roles: ["admin"] });
147
+ const widget = admin.entities.find((e) => e.name === "widget");
148
+
149
+ expect(widget?.fields.find((f) => f.name === "status")).toMatchObject({
150
+ type: "select",
151
+ options: ["draft", "active"],
152
+ filterable: true,
153
+ });
154
+ expect(widget?.fields.find((f) => f.name === "ownerId")).toMatchObject({
155
+ type: "reference",
156
+ references: "user",
157
+ });
158
+ expect(widget?.fields.find((f) => f.name === "title")).toMatchObject({
159
+ type: "text",
160
+ searchable: true,
161
+ });
162
+ });
163
+
164
+ test("deterministic: manifest is independent of feature mount order", () => {
165
+ const featureA = buildTestFeature();
166
+ const featureB = buildOtherFeature();
167
+ const registryA = createRegistry([featureA, featureB]);
168
+ const registryB = createRegistry([featureB, featureA]);
169
+ const manifestA = buildAgentManifest(registryA, { locale: "en", roles: ["admin"] });
170
+ const manifestB = buildAgentManifest(registryB, { locale: "en", roles: ["admin"] });
171
+
172
+ expect(manifestA).toEqual(manifestB);
173
+
174
+ const qns = manifestA.handlers.map((h) => h.qn);
175
+ expect(qns).toEqual([...qns].sort());
176
+
177
+ const entityNames = manifestA.entities.map((e) => e.name);
178
+ expect(entityNames).toEqual([...entityNames].sort());
179
+ });
180
+ });
@@ -0,0 +1,57 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import { defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "../feature";
5
+
6
+ async function noopWriteHandler() {
7
+ return { isSuccess: true as const, data: {} };
8
+ }
9
+
10
+ function undocumentedHandlersFeature() {
11
+ return defineFeature("doc-gap-demo", (r) => {
12
+ r.writeHandler("do-a", z.object({}), noopWriteHandler, { access: { openToAll: true } });
13
+ r.writeHandler("do-b", z.object({}), noopWriteHandler, { access: { openToAll: true } });
14
+ r.writeHandler("do-c", z.object({}), noopWriteHandler, { access: { openToAll: true } });
15
+ });
16
+ }
17
+
18
+ describe("createAgentToolsFeature", () => {
19
+ test("declares name, description, ui hints and exactly one boot check", () => {
20
+ const feature = createAgentToolsFeature();
21
+ expect(feature.name).toBe(AGENT_TOOLS_FEATURE_NAME);
22
+ expect(feature.description?.length ?? 0).toBeGreaterThan(0);
23
+ expect(feature.uiHints?.displayLabel).toBeTruthy();
24
+ expect(feature.bootChecks).toHaveLength(1);
25
+ });
26
+
27
+ test("boot check WARNS on undocumented handlers instead of failing the boot", () => {
28
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
29
+ try {
30
+ expect(() =>
31
+ validateBoot([createAgentToolsFeature(), undocumentedHandlersFeature()]),
32
+ ).not.toThrow();
33
+ expect(warn).toHaveBeenCalled();
34
+ const loggedLines = warn.mock.calls.flat().join("\n");
35
+ expect(loggedLines).toContain("doc-gap-demo:write:do-a");
36
+ } finally {
37
+ warn.mockRestore();
38
+ }
39
+ });
40
+
41
+ test("boot check stays silent for a fully-described registry", () => {
42
+ const describedFeature = defineFeature("doc-clean-demo", (r) => {
43
+ r.writeHandler("do-a", z.object({}), noopWriteHandler, {
44
+ access: { openToAll: true },
45
+ description: "Does A.",
46
+ });
47
+ });
48
+
49
+ const warn = spyOn(console, "warn").mockImplementation(() => {});
50
+ try {
51
+ expect(() => validateBoot([createAgentToolsFeature(), describedFeature])).not.toThrow();
52
+ expect(warn).not.toHaveBeenCalled();
53
+ } finally {
54
+ warn.mockRestore();
55
+ }
56
+ });
57
+ });
@@ -0,0 +1,122 @@
1
+ import type {
2
+ FeatureDefinition,
3
+ QueryHandlerDef,
4
+ WriteHandlerDef,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { QnTypes, qn, resolveAgentExposure, toKebab } from "@cosmicdrift/kumiko-framework/engine";
7
+
8
+ export const AgentDocGapKinds = {
9
+ handlerWithoutDescription: "handler-without-description",
10
+ customScreenWithoutDescription: "custom-screen-without-description",
11
+ exposedEntityWithoutDescription: "exposed-entity-without-description",
12
+ } as const;
13
+
14
+ export type AgentDocGapKind = (typeof AgentDocGapKinds)[keyof typeof AgentDocGapKinds];
15
+
16
+ export type AgentDocGap = {
17
+ readonly qn: string;
18
+ readonly feature: string;
19
+ readonly kind: AgentDocGapKind;
20
+ readonly message: string;
21
+ };
22
+
23
+ // No shared screen-type union exists in the codebase to import (ScreenDefinition
24
+ // is a discriminated union keyed by inline string literals) — name the one
25
+ // value this lint cares about instead of comparing against a bare string.
26
+ const CUSTOM_SCREEN_TYPE = "custom";
27
+
28
+ const ENTITY_QN_SEGMENT = "entity";
29
+
30
+ // QNs contain `:`, so localeCompare's ICU punctuation rules would make sort
31
+ // order depend on runtime locale — compare by code point instead (same
32
+ // reasoning as agent-manifest.ts's compareIds).
33
+ function compareByCodePoint(a: string, b: string): number {
34
+ return a < b ? -1 : a > b ? 1 : 0;
35
+ }
36
+
37
+ function handlerDocGaps(
38
+ feature: FeatureDefinition,
39
+ handlers: Readonly<Record<string, WriteHandlerDef | QueryHandlerDef>>,
40
+ handlerQnType: typeof QnTypes.write | typeof QnTypes.query,
41
+ handlerNoun: string,
42
+ ): readonly AgentDocGap[] {
43
+ const gaps: AgentDocGap[] = [];
44
+ for (const [name, def] of Object.entries(handlers)) {
45
+ if (def.description !== undefined || def.agent?.expose === false) continue;
46
+ gaps.push({
47
+ qn: qn(toKebab(feature.name), handlerQnType, toKebab(name)),
48
+ feature: feature.name,
49
+ kind: AgentDocGapKinds.handlerWithoutDescription,
50
+ message: `This ${handlerNoun} has no description, so it stays invisible to the AI agent — set \`description\` to expose it, or \`agent: { expose: false }\` to opt out deliberately.`,
51
+ });
52
+ }
53
+ return gaps;
54
+ }
55
+
56
+ function screenDocGaps(feature: FeatureDefinition): readonly AgentDocGap[] {
57
+ const gaps: AgentDocGap[] = [];
58
+ for (const [shortId, screen] of Object.entries(feature.screens ?? {})) {
59
+ if (screen.type !== CUSTOM_SCREEN_TYPE || screen.description !== undefined) continue;
60
+ gaps.push({
61
+ qn: qn(toKebab(feature.name), QnTypes.screen, toKebab(shortId)),
62
+ feature: feature.name,
63
+ kind: AgentDocGapKinds.customScreenWithoutDescription,
64
+ message:
65
+ "This custom screen has no description, so the AI agent can't tell what it's for — set `description` to explain it.",
66
+ });
67
+ }
68
+ return gaps;
69
+ }
70
+
71
+ // The handlerEntityMappings record can't distinguish write from query
72
+ // namespaces (both share one short-name-keyed map), so both must be probed
73
+ // per mapped handler name.
74
+ function agentVisibleEntityNames(feature: FeatureDefinition): ReadonlySet<string> {
75
+ const entityNames = new Set<string>();
76
+ for (const [handlerName, entityName] of Object.entries(feature.handlerEntityMappings ?? {})) {
77
+ const writeDef = feature.writeHandlers?.[handlerName];
78
+ if (writeDef && resolveAgentExposure(writeDef, "write").expose) entityNames.add(entityName);
79
+ const queryDef = feature.queryHandlers?.[handlerName];
80
+ if (queryDef && resolveAgentExposure(queryDef, "query").expose) entityNames.add(entityName);
81
+ }
82
+ return entityNames;
83
+ }
84
+
85
+ function exposedEntityDocGaps(feature: FeatureDefinition): readonly AgentDocGap[] {
86
+ const gaps: AgentDocGap[] = [];
87
+ for (const entityName of agentVisibleEntityNames(feature)) {
88
+ const entity = feature.entities?.[entityName];
89
+ if (!entity || entity.description !== undefined) continue;
90
+ gaps.push({
91
+ // Registry keeps entities under their raw, non-kebab name (unlike
92
+ // handlers/screens) — qn() would throw on a camelCase segment.
93
+ qn: `${toKebab(feature.name)}:${ENTITY_QN_SEGMENT}:${entityName}`,
94
+ feature: feature.name,
95
+ kind: AgentDocGapKinds.exposedEntityWithoutDescription,
96
+ message:
97
+ "This entity is reachable through an agent-visible handler, but the agent can't explain its schema — set `description` to describe it.",
98
+ });
99
+ }
100
+ return gaps;
101
+ }
102
+
103
+ export function findAgentDocGaps(features: readonly FeatureDefinition[]): readonly AgentDocGap[] {
104
+ const gaps: AgentDocGap[] = [];
105
+ for (const feature of features) {
106
+ gaps.push(
107
+ ...handlerDocGaps(feature, feature.writeHandlers ?? {}, QnTypes.write, "write handler"),
108
+ );
109
+ gaps.push(
110
+ ...handlerDocGaps(feature, feature.queryHandlers ?? {}, QnTypes.query, "query handler"),
111
+ );
112
+ gaps.push(...screenDocGaps(feature));
113
+ gaps.push(...exposedEntityDocGaps(feature));
114
+ }
115
+ // Map/Record iteration follows feature-mount order; the result must not
116
+ // depend on it (mirrors agent-manifest.ts's sortedByKey rationale).
117
+ return [...gaps].sort((a, b) => compareByCodePoint(a.qn, b.qn));
118
+ }
119
+
120
+ export function formatAgentDocGap(gap: AgentDocGap): string {
121
+ return `${gap.qn} — ${gap.message}`;
122
+ }
@@ -0,0 +1,329 @@
1
+ import type {
2
+ AccessRule,
3
+ EntityDefinition,
4
+ FeatureDefinition,
5
+ FieldDefinition,
6
+ NavDefinition,
7
+ QueryHandlerDef,
8
+ ScreenDefinition,
9
+ TranslationKeys,
10
+ WorkspaceDefinition,
11
+ WriteHandlerDef,
12
+ } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { hasAccess, resolveAgentExposure } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { z } from "zod";
15
+ import type {
16
+ AgentManifest,
17
+ AgentManifestEntity,
18
+ AgentManifestFeature,
19
+ AgentManifestField,
20
+ AgentManifestHandler,
21
+ AgentManifestNav,
22
+ AgentManifestOptions,
23
+ AgentManifestScreen,
24
+ AgentManifestWorkspace,
25
+ RegistryManifestView,
26
+ } from "./types";
27
+
28
+ /** Translation keys carry a feature prefix the manifest can't reconstruct
29
+ * (`showcase:entity:item:field:title`), so match on the suffix instead. */
30
+ function labelsForSuffix(
31
+ translations: TranslationKeys,
32
+ suffix: string,
33
+ ): Readonly<Record<string, string>> {
34
+ for (const [key, labels] of Object.entries(translations)) {
35
+ if (key === suffix || key.endsWith(suffix)) return labels;
36
+ }
37
+ return {};
38
+ }
39
+
40
+ /** Nav/screen/workspace gating is opt-in: no rule means visible to everyone
41
+ * (NavDefinition.access). Handlers are the opposite — `hasAccess` is
42
+ * default-deny there. */
43
+ function uiVisible(access: AccessRule | undefined, roles: readonly string[]): boolean {
44
+ return access === undefined || hasAccess({ roles }, access);
45
+ }
46
+
47
+ // localeCompare depends on the runtime locale and ICU punctuation rules; qualified
48
+ // names contain `:`, so compare by code point instead for deterministic ordering.
49
+ function compareIds(a: string, b: string): number {
50
+ return a < b ? -1 : a > b ? 1 : 0;
51
+ }
52
+
53
+ function dedupeSorted(values: readonly string[]): readonly string[] {
54
+ return [...new Set(values)].sort(compareIds);
55
+ }
56
+
57
+ // Map iteration follows the feature-mount order; the manifest must not depend on it.
58
+ function sortedByKey<T>(items: readonly T[], key: (item: T) => string): readonly T[] {
59
+ return [...items].sort((a, b) => compareIds(key(a), key(b)));
60
+ }
61
+
62
+ function visibleWorkspaceIds(
63
+ workspaceIds: readonly string[] | undefined,
64
+ workspaces: ReadonlyMap<string, WorkspaceDefinition>,
65
+ roles: readonly string[],
66
+ ): readonly string[] {
67
+ if (!workspaceIds) return [];
68
+ return dedupeSorted(
69
+ workspaceIds.filter((id) => {
70
+ const workspace = workspaces.get(id);
71
+ return workspace !== undefined && uiVisible(workspace.access, roles);
72
+ }),
73
+ );
74
+ }
75
+
76
+ function isFilterableField(field: FieldDefinition): boolean {
77
+ return "filterable" in field && field.filterable === true;
78
+ }
79
+
80
+ function isPiiField(field: FieldDefinition): boolean {
81
+ return "pii" in field && field.pii === true;
82
+ }
83
+
84
+ function buildField(
85
+ entityName: string,
86
+ fieldName: string,
87
+ field: FieldDefinition,
88
+ translations: TranslationKeys,
89
+ searchableFields: ReadonlySet<string>,
90
+ ): AgentManifestField {
91
+ const options =
92
+ field.type === "select" || field.type === "multiSelect" ? field.options : undefined;
93
+ const references = field.type === "reference" ? field.entity : undefined;
94
+
95
+ return {
96
+ name: fieldName,
97
+ type: field.type,
98
+ labels: labelsForSuffix(translations, `:entity:${entityName}:field:${fieldName}`),
99
+ ...(field.description !== undefined && { description: field.description }),
100
+ ...(options !== undefined && { options }),
101
+ ...(references !== undefined && { references }),
102
+ ...(searchableFields.has(fieldName) && { searchable: true as const }),
103
+ ...(isFilterableField(field) && { filterable: true as const }),
104
+ ...(isPiiField(field) && { pii: true as const }),
105
+ };
106
+ }
107
+
108
+ /** Entity schemas are emitted unfiltered by design: roles gate handlers, screens,
109
+ * navs and workspaces, while the handler pipeline gates the values themselves.
110
+ * The manifest describes shape, never data. */
111
+ function buildEntities(
112
+ entities: ReadonlyMap<string, EntityDefinition>,
113
+ translations: TranslationKeys,
114
+ getSearchableFields: (entityName: string) => readonly string[],
115
+ ): readonly AgentManifestEntity[] {
116
+ const result: AgentManifestEntity[] = [];
117
+ for (const [entityName, entity] of entities) {
118
+ const searchableFields = new Set(getSearchableFields(entityName));
119
+ const fields = Object.entries(entity.fields).map(([fieldName, field]) =>
120
+ buildField(entityName, fieldName, field, translations, searchableFields),
121
+ );
122
+ result.push({
123
+ name: entityName,
124
+ labels: labelsForSuffix(translations, `:entity:${entityName}`),
125
+ ...(entity.description !== undefined && { description: entity.description }),
126
+ fields,
127
+ });
128
+ }
129
+ return result;
130
+ }
131
+
132
+ function buildFeatures(
133
+ features: ReadonlyMap<string, FeatureDefinition>,
134
+ ): readonly AgentManifestFeature[] {
135
+ const result: AgentManifestFeature[] = [];
136
+ for (const [, feature] of features) {
137
+ result.push({
138
+ name: feature.name,
139
+ ...(feature.description !== undefined && { description: feature.description }),
140
+ ...(feature.uiHints?.displayLabel !== undefined && {
141
+ displayLabel: feature.uiHints.displayLabel,
142
+ }),
143
+ });
144
+ }
145
+ return result;
146
+ }
147
+
148
+ function buildHandlerEntry(
149
+ qn: string,
150
+ kind: "query" | "write",
151
+ def: QueryHandlerDef | WriteHandlerDef,
152
+ roles: readonly string[],
153
+ getHandlerEntity: (qualifiedHandler: string) => string | undefined,
154
+ ): AgentManifestHandler | undefined {
155
+ const exposure = resolveAgentExposure(def, kind);
156
+ if (!exposure.expose) return undefined;
157
+ if (!hasAccess({ roles }, def.access)) return undefined;
158
+
159
+ // `agent.expose: true` without a `description` still opts in — the author
160
+ // asked for exposure explicitly, there's just nothing to show as prose.
161
+ const description = def.description ?? "";
162
+
163
+ let inputSchema: Readonly<Record<string, unknown>>;
164
+ try {
165
+ // A handler whose schema can't be expressed as JSON Schema (transforms,
166
+ // z.instanceof, ...) is unusable for an agent tool call — leave it out
167
+ // rather than ship a broken tool.
168
+ inputSchema = z.toJSONSchema(def.schema, { io: "input" }) as Readonly<Record<string, unknown>>;
169
+ } catch {
170
+ return undefined;
171
+ }
172
+
173
+ const entity = getHandlerEntity(qn);
174
+ return {
175
+ qn,
176
+ kind,
177
+ description,
178
+ risk: exposure.risk,
179
+ inputSchema,
180
+ ...(entity !== undefined && { entity }),
181
+ };
182
+ }
183
+
184
+ function buildHandlers(
185
+ queryHandlers: ReadonlyMap<string, QueryHandlerDef>,
186
+ writeHandlers: ReadonlyMap<string, WriteHandlerDef>,
187
+ roles: readonly string[],
188
+ getHandlerEntity: (qualifiedHandler: string) => string | undefined,
189
+ ): readonly AgentManifestHandler[] {
190
+ const result: AgentManifestHandler[] = [];
191
+ for (const [qn, def] of queryHandlers) {
192
+ const entry = buildHandlerEntry(qn, "query", def, roles, getHandlerEntity);
193
+ if (entry) result.push(entry);
194
+ }
195
+ for (const [qn, def] of writeHandlers) {
196
+ const entry = buildHandlerEntry(qn, "write", def, roles, getHandlerEntity);
197
+ if (entry) result.push(entry);
198
+ }
199
+ return result;
200
+ }
201
+
202
+ function buildNavs(
203
+ navs: ReadonlyMap<string, NavDefinition>,
204
+ workspaces: ReadonlyMap<string, WorkspaceDefinition>,
205
+ translations: TranslationKeys,
206
+ roles: readonly string[],
207
+ ): readonly AgentManifestNav[] {
208
+ const result: AgentManifestNav[] = [];
209
+ for (const [, nav] of navs) {
210
+ if (!uiVisible(nav.access, roles)) continue;
211
+ const visibleWorkspaces = visibleWorkspaceIds(nav.workspaces, workspaces, roles);
212
+ const hasWorkspaces = nav.workspaces !== undefined && nav.workspaces.length > 0;
213
+ if (hasWorkspaces && visibleWorkspaces.length === 0) continue;
214
+
215
+ result.push({
216
+ id: nav.id,
217
+ labels: translations[nav.label] ?? {},
218
+ ...(nav.screen !== undefined && { screen: nav.screen }),
219
+ ...(nav.parent !== undefined && { parent: nav.parent }),
220
+ workspaces: visibleWorkspaces,
221
+ });
222
+ }
223
+ return result;
224
+ }
225
+
226
+ function lastSegment(qualifiedId: string): string {
227
+ const idx = qualifiedId.lastIndexOf(":");
228
+ return idx === -1 ? qualifiedId : qualifiedId.slice(idx + 1);
229
+ }
230
+
231
+ function buildScreens(
232
+ screens: ReadonlyMap<string, ScreenDefinition>,
233
+ navs: ReadonlyMap<string, NavDefinition>,
234
+ workspaces: ReadonlyMap<string, WorkspaceDefinition>,
235
+ translations: TranslationKeys,
236
+ roles: readonly string[],
237
+ ): readonly AgentManifestScreen[] {
238
+ const allNavs = [...navs.values()];
239
+ const result: AgentManifestScreen[] = [];
240
+
241
+ for (const [, screen] of screens) {
242
+ const matchingNavs = allNavs.filter((nav) => nav.screen === screen.id);
243
+ const accessibleNavs = matchingNavs.filter((nav) => uiVisible(nav.access, roles));
244
+
245
+ // A screen with no nav pointing at it is a detail view, reached only via
246
+ // navigation from a list row — never hidden by nav/workspace gating.
247
+ const reachable =
248
+ matchingNavs.length === 0 ||
249
+ accessibleNavs.some((nav) => {
250
+ if (!nav.workspaces || nav.workspaces.length === 0) return true;
251
+ return visibleWorkspaceIds(nav.workspaces, workspaces, roles).length > 0;
252
+ });
253
+ if (!uiVisible(screen.access, roles) || !reachable) continue;
254
+
255
+ const shortId = lastSegment(screen.id);
256
+ const screenWorkspaces = dedupeSorted(
257
+ accessibleNavs.flatMap((nav) => visibleWorkspaceIds(nav.workspaces, workspaces, roles)),
258
+ );
259
+
260
+ result.push({
261
+ id: screen.id,
262
+ type: screen.type,
263
+ titles: labelsForSuffix(translations, `screen:${shortId}.title`),
264
+ ...(screen.description !== undefined && { description: screen.description }),
265
+ ...("entity" in screen && screen.entity !== undefined && { entity: screen.entity }),
266
+ // A detail screen takes the row id; everything else is parameterless.
267
+ params: screen.detailFor !== undefined ? ["id"] : [],
268
+ workspaces: screenWorkspaces,
269
+ });
270
+ }
271
+ return result;
272
+ }
273
+
274
+ function buildWorkspaces(
275
+ workspaces: ReadonlyMap<string, WorkspaceDefinition>,
276
+ translations: TranslationKeys,
277
+ roles: readonly string[],
278
+ ): readonly AgentManifestWorkspace[] {
279
+ const result: AgentManifestWorkspace[] = [];
280
+ for (const [, workspace] of workspaces) {
281
+ if (!uiVisible(workspace.access, roles)) continue;
282
+ result.push({
283
+ id: workspace.id,
284
+ labels: translations[workspace.label] ?? {},
285
+ ...(workspace.default === true && { default: true as const }),
286
+ });
287
+ }
288
+ return result;
289
+ }
290
+
291
+ /** Registry snapshot → AI-agent manifest. Pure, deterministic, no I/O — mirrors
292
+ * `buildToolCatalog`'s contract, just describing the app's shape instead of
293
+ * generating callable tools from it. */
294
+ export function buildAgentManifest(
295
+ registry: RegistryManifestView,
296
+ options: AgentManifestOptions,
297
+ ): AgentManifest {
298
+ const translations = registry.getAllTranslations();
299
+ const workspaceMap = registry.getAllWorkspaces();
300
+ const navMap = registry.getAllNavs();
301
+ const { roles } = options;
302
+
303
+ const features = buildFeatures(registry.features);
304
+ const entities = buildEntities(registry.getAllEntities(), translations, (name) =>
305
+ registry.getSearchableFields(name),
306
+ );
307
+ const handlers = buildHandlers(
308
+ registry.getAllQueryHandlers(),
309
+ registry.getAllWriteHandlers(),
310
+ roles,
311
+ (qn) => registry.getHandlerEntity(qn),
312
+ );
313
+ const navs = buildNavs(navMap, workspaceMap, translations, roles);
314
+ const screens = buildScreens(registry.getAllScreens(), navMap, workspaceMap, translations, roles);
315
+ const workspaces = buildWorkspaces(workspaceMap, translations, roles);
316
+
317
+ return {
318
+ features: sortedByKey(features, (f) => f.name),
319
+ entities: sortedByKey(entities, (e) => e.name),
320
+ handlers: sortedByKey(handlers, (h) => h.qn),
321
+ screens: sortedByKey(screens, (s) => s.id),
322
+ navs: sortedByKey(navs, (n) => n.id),
323
+ workspaces: sortedByKey(workspaces, (w) => w.id),
324
+ tenantSettings: {
325
+ locale: options.locale,
326
+ ...(options.currency && { currency: options.currency }),
327
+ },
328
+ };
329
+ }
@@ -0,0 +1,36 @@
1
+ import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { findAgentDocGaps, formatAgentDocGap } from "./agent-doc-lint";
3
+
4
+ export const AGENT_TOOLS_FEATURE_NAME = "agent-tools";
5
+
6
+ export function createAgentToolsFeature(): FeatureDefinition {
7
+ return defineFeature(AGENT_TOOLS_FEATURE_NAME, (r) => {
8
+ r.describe(
9
+ "Builds a tool catalog and an agent manifest from the mounted registry so an LLM agent can call handlers and understand the app's shape. A handler, custom screen, or entity without a `description` stays invisible to the agent by construction (see `resolveAgentExposure`) — this feature surfaces those gaps at boot and via `kumiko agent lint` so an app author notices before an agent silently can't see a feature.",
10
+ );
11
+ r.uiHints({
12
+ displayLabel: "AI Agent Tools",
13
+ category: "ai",
14
+ recommended: false,
15
+ });
16
+
17
+ // Warns instead of throwing: undocumented handlers/screens/entities are a
18
+ // usability gap for the agent, not an invalid app — failing the boot here
19
+ // would turn every incrementally-documented app into an outage.
20
+ r.bootCheck(({ features }) => {
21
+ const gaps = findAgentDocGaps(features);
22
+ if (gaps.length === 0) {
23
+ // skip: no documentation gaps, nothing to warn about
24
+ return;
25
+ }
26
+ // biome-ignore lint/suspicious/noConsole: operator-visibility at boot for agent-doc gaps
27
+ console.warn(
28
+ `[agent-tools] ${gaps.length} handler/screen/entity gap(s) invisible to the AI agent — run \`kumiko agent lint\` for details:`,
29
+ );
30
+ for (const gap of gaps) {
31
+ // biome-ignore lint/suspicious/noConsole: operator-visibility at boot for agent-doc gaps
32
+ console.warn(` ${formatAgentDocGap(gap)}`);
33
+ }
34
+ });
35
+ });
36
+ }
@@ -1,7 +1,21 @@
1
+ export type { AgentDocGap, AgentDocGapKind } from "./agent-doc-lint";
2
+ export { AgentDocGapKinds, findAgentDocGaps, formatAgentDocGap } from "./agent-doc-lint";
3
+ export { buildAgentManifest } from "./agent-manifest";
4
+ export { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "./feature";
1
5
  export { buildToolCatalog } from "./tool-catalog";
2
6
  export type { ToolCallResult, ToolDispatcher } from "./tool-dispatch";
3
7
  export { dispatchToolCall } from "./tool-dispatch";
4
8
  export type {
9
+ AgentManifest,
10
+ AgentManifestEntity,
11
+ AgentManifestFeature,
12
+ AgentManifestField,
13
+ AgentManifestHandler,
14
+ AgentManifestNav,
15
+ AgentManifestOptions,
16
+ AgentManifestScreen,
17
+ AgentManifestWorkspace,
18
+ RegistryManifestView,
5
19
  RegistrySearchView,
6
20
  ToolCatalog,
7
21
  ToolDefinition,
@@ -1,4 +1,14 @@
1
- import type { EntityDefinition, QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
1
+ import type {
2
+ AgentRisk,
3
+ EntityDefinition,
4
+ FeatureDefinition,
5
+ NavDefinition,
6
+ QueryHandlerDef,
7
+ ScreenDefinition,
8
+ TranslationKeys,
9
+ WorkspaceDefinition,
10
+ WriteHandlerDef,
11
+ } from "@cosmicdrift/kumiko-framework/engine";
2
12
 
3
13
  /** Mirrors `ToolDefinition` in `@cosmicdriftgamestudio/kumiko-ai-foundation` (providers/types.ts)
4
14
  * field-for-field so a generated catalog needs no translation layer at the call site. Kept as
@@ -36,3 +46,104 @@ export type ToolCatalog = {
36
46
  readonly tools: readonly ToolDefinition[];
37
47
  readonly dispatchTable: ReadonlyMap<string, ToolDispatchDescriptor>;
38
48
  };
49
+
50
+ // --- agent manifest ---
51
+
52
+ export type AgentManifestOptions = {
53
+ readonly locale: string;
54
+ readonly roles: readonly string[];
55
+ /** Tenant currency — the registry knows nothing about tenants, so the
56
+ * caller passes it through into the manifest's tenant-settings block. */
57
+ readonly currency?: string;
58
+ };
59
+
60
+ export type AgentManifestField = {
61
+ readonly name: string;
62
+ readonly type: string;
63
+ /** Label per app locale, keyed by locale code. */
64
+ readonly labels: Readonly<Record<string, string>>;
65
+ readonly description?: string;
66
+ readonly options?: readonly string[];
67
+ /** Entity a `reference` field points at. */
68
+ readonly references?: string;
69
+ readonly searchable?: true;
70
+ readonly filterable?: true;
71
+ /** Marks the field as personal data. Never carries a value — the manifest
72
+ * is schema, not rows. */
73
+ readonly pii?: true;
74
+ };
75
+
76
+ export type AgentManifestEntity = {
77
+ readonly name: string;
78
+ readonly labels: Readonly<Record<string, string>>;
79
+ readonly description?: string;
80
+ readonly fields: readonly AgentManifestField[];
81
+ };
82
+
83
+ export type AgentManifestHandler = {
84
+ readonly qn: string;
85
+ readonly kind: "query" | "write";
86
+ readonly description: string;
87
+ readonly risk: AgentRisk;
88
+ readonly inputSchema: Readonly<Record<string, unknown>>;
89
+ readonly entity?: string;
90
+ };
91
+
92
+ export type AgentManifestScreen = {
93
+ readonly id: string;
94
+ readonly type: string;
95
+ readonly titles: Readonly<Record<string, string>>;
96
+ readonly description?: string;
97
+ readonly entity?: string;
98
+ readonly params: readonly string[];
99
+ readonly workspaces: readonly string[];
100
+ };
101
+
102
+ export type AgentManifestNav = {
103
+ readonly id: string;
104
+ readonly labels: Readonly<Record<string, string>>;
105
+ readonly screen?: string;
106
+ readonly parent?: string;
107
+ readonly workspaces: readonly string[];
108
+ };
109
+
110
+ export type AgentManifestWorkspace = {
111
+ readonly id: string;
112
+ readonly labels: Readonly<Record<string, string>>;
113
+ readonly default?: true;
114
+ };
115
+
116
+ export type AgentManifestFeature = {
117
+ readonly name: string;
118
+ readonly description?: string;
119
+ readonly displayLabel?: string;
120
+ };
121
+
122
+ export type AgentManifest = {
123
+ readonly features: readonly AgentManifestFeature[];
124
+ readonly entities: readonly AgentManifestEntity[];
125
+ readonly handlers: readonly AgentManifestHandler[];
126
+ readonly screens: readonly AgentManifestScreen[];
127
+ readonly navs: readonly AgentManifestNav[];
128
+ readonly workspaces: readonly AgentManifestWorkspace[];
129
+ readonly tenantSettings: {
130
+ readonly locale: string;
131
+ readonly currency?: string;
132
+ };
133
+ };
134
+
135
+ /** Narrow view of `Registry` for manifest generation — same rationale as
136
+ * `RegistrySearchView`: tests build a plain object instead of a full
137
+ * Registry; a real `createRegistry(...)` result is structurally assignable. */
138
+ export type RegistryManifestView = {
139
+ readonly features: ReadonlyMap<string, FeatureDefinition>;
140
+ getAllEntities(): ReadonlyMap<string, EntityDefinition>;
141
+ getAllQueryHandlers(): ReadonlyMap<string, QueryHandlerDef>;
142
+ getAllWriteHandlers(): ReadonlyMap<string, WriteHandlerDef>;
143
+ getHandlerEntity(qualifiedHandler: string): string | undefined;
144
+ getSearchableFields(entityName: string): readonly string[];
145
+ getAllTranslations(): TranslationKeys;
146
+ getAllScreens(): ReadonlyMap<string, ScreenDefinition>;
147
+ getAllNavs(): ReadonlyMap<string, NavDefinition>;
148
+ getAllWorkspaces(): ReadonlyMap<string, WorkspaceDefinition>;
149
+ };