@cosmicdrift/kumiko-framework 0.235.2 → 0.235.3

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-framework",
3
- "version": "0.235.2",
3
+ "version": "0.235.3",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.235.2",
201
+ "@cosmicdrift/kumiko-types": "0.235.3",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.235.2",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.235.3",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -507,3 +507,88 @@ describe("upgrade command — --apply", () => {
507
507
  expect(existsSync(join(target, ".kumiko/upgrade-state.json"))).toBe(true);
508
508
  });
509
509
  });
510
+
511
+ // Bug: without --from, the filter baseline used to be the installed
512
+ // version, not the recorded marker — a bare `--apply` always reported
513
+ // "Nothing new" and bootstrapped the marker onto the installed version,
514
+ // hiding changelog entries the marker never actually saw (fw#2371-style).
515
+ describe("upgrade command — filter baseline is the marker, not the installed version", () => {
516
+ const marker = (version: string): string =>
517
+ JSON.stringify({ version, appliedAt: "2024-01-01T00:00:00Z", codemods: [] });
518
+
519
+ test("bare --apply with a marker below installed: the entry between them is pending, not 'Nothing new'", async () => {
520
+ const cwd = tmp({
521
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
522
+ "packages/bundled-features/package.json": JSON.stringify({ version: "0.190.0" }),
523
+ ".kumiko/upgrade-state.json": marker("0.160.0"),
524
+ "legacy-test-helper.ts": LEGACY_IMPORT_FIXTURE,
525
+ });
526
+ const spy = makeSpyOutput();
527
+
528
+ const exit = await runUpgradeCli(["--apply"], cwd, spy.out, { repoRoot: REAL_REPO_ROOT });
529
+
530
+ expect(exit).toBe(0);
531
+ expect(spy.logs.join("\n")).not.toContain("Nothing new since your version");
532
+ const rewritten = readFileSync(join(cwd, "legacy-test-helper.ts"), "utf-8");
533
+ expect(rewritten).toContain('from "@cosmicdrift/kumiko-framework/testing"');
534
+ const updatedMarker = JSON.parse(
535
+ readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"),
536
+ );
537
+ expect(updatedMarker.version).toBe("0.167.0");
538
+ });
539
+
540
+ test("bare --apply with a manual breaking change: marker is not stamped to installed, pendingManual records it", async () => {
541
+ const manualEntry = { version: "0.185.0", type: "breaking", title: "manual breaking change" };
542
+ const fixEntry = { version: "0.188.0", type: "fix", title: "later fix" };
543
+ const cwd = tmp({
544
+ "packages/framework/src/changes.json": JSON.stringify([manualEntry, fixEntry]),
545
+ "packages/bundled-features/package.json": JSON.stringify({ version: "0.195.0" }),
546
+ ".kumiko/upgrade-state.json": marker("0.180.0"),
547
+ });
548
+ const spy = makeSpyOutput();
549
+
550
+ const exit = await runUpgradeCli(["--apply"], cwd, spy.out, { repoRoot: REAL_REPO_ROOT });
551
+
552
+ expect(exit).toBe(0);
553
+ expect(spy.logs.join("\n")).toContain("no codemod, manual migration required");
554
+ const updatedMarker = JSON.parse(
555
+ readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"),
556
+ );
557
+ // Stamped to the trailing non-breaking entry's version (0.188.0), not
558
+ // blindly hoisted to the installed version (0.195.0) — proof both
559
+ // entries were actually seen and run through markerVersionForPending,
560
+ // rather than filtered out entirely by a too-high baseline.
561
+ expect(updatedMarker.version).toBe("0.188.0");
562
+ expect(updatedMarker.version).not.toBe("0.195.0");
563
+ });
564
+
565
+ test("bare --apply with no marker (bootstrap): behaves as before, marker is written at the installed version", async () => {
566
+ const cwd = tmp({
567
+ "packages/framework/src/changes.json": breakingEntryWithCodemod(REAL_CODEMOD),
568
+ "packages/framework/package.json": JSON.stringify({ version: "0.170.0" }),
569
+ });
570
+ const spy = makeSpyOutput();
571
+
572
+ const exit = await runUpgradeCli(["--apply"], cwd, spy.out, { repoRoot: REAL_REPO_ROOT });
573
+
574
+ expect(exit).toBe(0);
575
+ expect(spy.logs.join("\n")).toContain("Nothing new since your version");
576
+ const updatedMarker = JSON.parse(
577
+ readFileSync(join(cwd, ".kumiko/upgrade-state.json"), "utf-8"),
578
+ );
579
+ expect(updatedMarker.version).toBe("0.170.0");
580
+ });
581
+
582
+ test("--from wins over an existing marker", async () => {
583
+ const cwd = tmp({
584
+ "packages/framework/src/changes.json": JSON.stringify([
585
+ { version: "0.175.0", type: "fix", title: "explicit-from-test-entry" },
586
+ ]),
587
+ ".kumiko/upgrade-state.json": marker("0.180.0"),
588
+ });
589
+
590
+ const result = await runJson(cwd, "0.170.0");
591
+
592
+ expect(result.pending.map((e) => e.title)).toEqual(["explicit-from-test-entry"]);
593
+ });
594
+ });
@@ -57,6 +57,33 @@ export async function markConsumerProcessing(
57
57
  );
58
58
  }
59
59
 
60
+ // Best-effort record of an infra-level pass failure (event-dispatcher.ts's
61
+ // processConsumer catch) — called OUTSIDE the transaction that just rolled
62
+ // back, since that tx's own markProcessing/updateConsumerDeliveryOutcome
63
+ // writes never committed. Must NOT touch "attempts": that counter is
64
+ // deliverEvents' poison-pill/dead-letter budget, incremented only on a
65
+ // handler throw inside an actual delivery pass. Spending it here would let
66
+ // repeated infra blips (the exact case this backoff exists for) dead-letter
67
+ // the consumer on the next real handler failure instead of after
68
+ // maxAttempts. Visibility is covered by last_error, updated_at, and the
69
+ // caller's console.error — status and the cursor are left as-is too, since
70
+ // we don't know at this point whether the consumer should be "dead" (that's
71
+ // the deliverEvents/maxAttempts contract, which never ran this pass).
72
+ export async function recordConsumerPassFailure(
73
+ db: AnyDb,
74
+ name: string,
75
+ instanceId: string,
76
+ errorMessage: string,
77
+ ): Promise<void> {
78
+ await asRawClient(db).unsafe(
79
+ `UPDATE "kumiko_event_consumers" SET
80
+ "last_error" = $1,
81
+ "updated_at" = now()
82
+ WHERE "name" = $2 AND "instance_id" = $3`,
83
+ [errorMessage, name, instanceId],
84
+ );
85
+ }
86
+
60
87
  export type ConsumerDeliveryOutcome = {
61
88
  readonly cursor: bigint;
62
89
  readonly attempts: number;
@@ -432,6 +432,7 @@ describe("buildAppSchema", () => {
432
432
  "allowPlaintext", // PII-audit reason string, not a renderer concern
433
433
  "lookupable", // blind-index equality lookup, server-side query concern
434
434
  "subjectRef", // GDPR-hook-coverage marker, not a renderer concern
435
+ "description", // AI-agent manifest hint, never rendered to the user
435
436
  ] as const;
436
437
 
437
438
  type Classified = (typeof FORWARDED_KEYS)[number] | (typeof SERVER_ONLY_KEYS)[number];
@@ -459,6 +460,7 @@ describe("buildAppSchema", () => {
459
460
  allowPlaintext: "is_business_data",
460
461
  lookupable: true,
461
462
  subjectRef: true,
463
+ description: "tags for the thing",
462
464
  },
463
465
  },
464
466
  } as unknown as EntityDefinition;
@@ -12,7 +12,10 @@ import type { AccessRule, SessionUser } from "./types";
12
12
  // list `anonymous` in `roles` (e.g. `roles: ["anonymous", "customer"]`).
13
13
  // Without this guard, enabling `anonymousAccess` on the server would silently
14
14
  // turn every existing `openToAll: true` handler into a public endpoint.
15
- export function hasAccess(user: SessionUser, access: AccessRule | undefined): boolean {
15
+ export function hasAccess(
16
+ user: Pick<SessionUser, "roles">,
17
+ access: AccessRule | undefined,
18
+ ): boolean {
16
19
  if (!access) return false;
17
20
  if ("openToAll" in access) {
18
21
  if (access.openToAll !== true) return false;
@@ -1588,6 +1588,63 @@ defineFeature("f", (r) => {
1588
1588
  });
1589
1589
  });
1590
1590
 
1591
+ test("object form: description + agent hints are extracted", () => {
1592
+ const result = parseInline(`
1593
+ defineFeature("f", (r) => {
1594
+ r.writeHandler({
1595
+ name: "task:approve",
1596
+ schema: z.object({ id: z.string() }),
1597
+ handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
1598
+ description: "Approves a pending task.",
1599
+ agent: { expose: true, risk: "high" },
1600
+ });
1601
+ });
1602
+ `);
1603
+
1604
+ expect(result.patterns[0]).toMatchObject({
1605
+ kind: "writeHandler",
1606
+ handlerName: "task:approve",
1607
+ description: "Approves a pending task.",
1608
+ agent: { expose: true, risk: "high" },
1609
+ });
1610
+ });
1611
+
1612
+ test("object form: an unknown agent.risk value is dropped, not passed through", () => {
1613
+ const result = parseInline(`
1614
+ defineFeature("f", (r) => {
1615
+ r.writeHandler({
1616
+ name: "task:approve",
1617
+ schema: z.object({ id: z.string() }),
1618
+ handler: async (event, ctx) => ({ isSuccess: true, data: {} }),
1619
+ agent: { expose: true, risk: "bogus" },
1620
+ });
1621
+ });
1622
+ `);
1623
+
1624
+ expect(result.patterns[0]).toMatchObject({
1625
+ kind: "writeHandler",
1626
+ agent: { expose: true },
1627
+ });
1628
+ expect((result.patterns[0] as { agent?: { risk?: unknown } }).agent?.risk).toBeUndefined();
1629
+ });
1630
+
1631
+ test("4-argument form: description + agent hints are read from the options object", () => {
1632
+ const result = parseInline(`
1633
+ defineFeature("f", (r) => {
1634
+ r.writeHandler("task:create", z.object({ title: z.string() }), async (event, ctx) => {
1635
+ return { isSuccess: true, data: {} };
1636
+ }, { description: "Creates a task.", agent: { risk: "mid" } });
1637
+ });
1638
+ `);
1639
+
1640
+ expect(result.patterns[0]).toMatchObject({
1641
+ kind: "writeHandler",
1642
+ handlerName: "task:create",
1643
+ description: "Creates a task.",
1644
+ agent: { risk: "mid" },
1645
+ });
1646
+ });
1647
+
1591
1648
  test("resolves a same-file identifier arg to its object-literal form (#1007)", () => {
1592
1649
  const result = parseInline(`
1593
1650
  const h = {
@@ -1636,6 +1693,27 @@ defineFeature("f", (r) => {
1636
1693
  handlerName: "task:list",
1637
1694
  });
1638
1695
  });
1696
+
1697
+ test("object form: description + agent hints are extracted", () => {
1698
+ const result = parseInline(`
1699
+ defineFeature("f", (r) => {
1700
+ r.queryHandler({
1701
+ name: "task:list",
1702
+ schema: z.object({}),
1703
+ handler: async (q, ctx) => [],
1704
+ description: "Lists tasks.",
1705
+ agent: { risk: "low" },
1706
+ });
1707
+ });
1708
+ `);
1709
+
1710
+ expect(result.patterns[0]).toMatchObject({
1711
+ kind: "queryHandler",
1712
+ handlerName: "task:list",
1713
+ description: "Lists tasks.",
1714
+ agent: { risk: "low" },
1715
+ });
1716
+ });
1639
1717
  });
1640
1718
 
1641
1719
  describe("extractStreamHandler", () => {
@@ -1688,6 +1766,23 @@ defineFeature("f", (r) => {
1688
1766
  });
1689
1767
  expect(result.errors).toEqual([]);
1690
1768
  });
1769
+
1770
+ test("description/agent are NOT part of StreamHandlerPattern's runtime shape", () => {
1771
+ const result = parseInline(`
1772
+ defineFeature("f", (r) => {
1773
+ r.streamHandler({
1774
+ name: "chat:complete",
1775
+ schema: z.object({ prompt: z.string() }),
1776
+ handler: async function* (input, ctx) { yield "token"; },
1777
+ description: "Should be ignored for streamHandler.",
1778
+ agent: { risk: "high" },
1779
+ });
1780
+ });
1781
+ `);
1782
+
1783
+ expect(result.patterns[0]).not.toHaveProperty("description");
1784
+ expect(result.patterns[0]).not.toHaveProperty("agent");
1785
+ });
1691
1786
  });
1692
1787
 
1693
1788
  describe("extractJob", () => {
@@ -201,14 +201,14 @@ defineFeature("workflow", (r) => {
201
201
  async (event, ctx) => {
202
202
  return { isSuccess: true, data: { id: "x" } };
203
203
  },
204
- { access: { roles: ["user"] } },
204
+ { access: { roles: ["user"] }, description: "Creates a step.", agent: { expose: true, risk: "high" } },
205
205
  );
206
206
 
207
207
  r.queryHandler(
208
208
  "step:list",
209
209
  z.object({}),
210
210
  async (q, ctx) => [],
211
- { access: { openToAll: true } },
211
+ { access: { openToAll: true }, description: "Lists steps.", agent: { risk: "low" } },
212
212
  );
213
213
 
214
214
  r.streamHandler(
@@ -1,6 +1,11 @@
1
- import type { CallExpression, Node, SourceFile } from "ts-morph";
1
+ import type { CallExpression, Node, ObjectLiteralExpression, SourceFile } from "ts-morph";
2
2
  import { SyntaxKind } from "ts-morph";
3
- import type { AccessRule, RateLimitOption } from "../../types/handlers";
3
+ import type {
4
+ AccessRule,
5
+ AgentHandlerHints,
6
+ AgentRisk,
7
+ RateLimitOption,
8
+ } from "../../types/handlers";
4
9
  import type { QueryHandlerPattern, StreamHandlerPattern, WriteHandlerPattern } from "../patterns";
5
10
  import type { SourceLocation } from "../source-location";
6
11
  import { sourceLocationFromNode } from "../source-location";
@@ -23,10 +28,78 @@ export type ParsedHandlerCall = {
23
28
  readonly schemaSource?: SourceLocation;
24
29
  readonly handlerBody?: SourceLocation;
25
30
  readonly access?: AccessRule;
31
+ readonly description?: string;
32
+ readonly agent?: AgentHandlerHints;
26
33
  readonly rateLimit?: RateLimitOption;
27
34
  readonly unsafeSkipTransitionGuard?: boolean;
28
35
  };
29
36
 
37
+ const AGENT_RISK_VALUES: readonly AgentRisk[] = ["low", "mid", "high"];
38
+
39
+ function isAgentRisk(value: unknown): value is AgentRisk {
40
+ return typeof value === "string" && (AGENT_RISK_VALUES as readonly string[]).includes(value);
41
+ }
42
+
43
+ export function readOptionalAgentHints(value: unknown): AgentHandlerHints | undefined {
44
+ if (!isPlainObject(value)) return undefined;
45
+ const expose = typeof value["expose"] === "boolean" ? value["expose"] : undefined;
46
+ const risk = isAgentRisk(value["risk"]) ? value["risk"] : undefined;
47
+ if (expose === undefined && risk === undefined) return undefined;
48
+ return {
49
+ ...(expose !== undefined && { expose }),
50
+ ...(risk !== undefined && { risk }),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Reads the two AI-agent-manifest slots (`description`, `agent`) off an
56
+ * object-form handler-call literal. Factored out of parseHandlerCall to
57
+ * keep that function's branching flat — both fields are optional and
58
+ * independent of the rest of the header (access/rateLimit/schema/handler).
59
+ */
60
+ function readDescriptionAndAgent(
61
+ obj: ObjectLiteralExpression,
62
+ ): Pick<ParsedHandlerCall, "description" | "agent"> {
63
+ const descriptionLiteral = obj
64
+ .getProperty("description")
65
+ ?.asKind(SyntaxKind.PropertyAssignment)
66
+ ?.getInitializer()
67
+ ?.asKind(SyntaxKind.StringLiteral);
68
+ const agentInit = obj
69
+ .getProperty("agent")
70
+ ?.asKind(SyntaxKind.PropertyAssignment)
71
+ ?.getInitializer();
72
+ const agent = agentInit ? readOptionalAgentHints(readDataLiteralNode(agentInit)) : undefined;
73
+ return {
74
+ ...(descriptionLiteral !== undefined && { description: descriptionLiteral.getLiteralValue() }),
75
+ ...(agent !== undefined && { agent }),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Reads `access`/`rateLimit`/`description`/`agent` off the positional
81
+ * 4th-argument options object (the inline-authoring form). Mirrors
82
+ * readDescriptionAndAgent's role for the object-form branch — keeping
83
+ * parseHandlerCall's positional branch to a single assignment instead of
84
+ * four independent mutable locals.
85
+ */
86
+ function readOptionsFields(
87
+ options: unknown,
88
+ ): Pick<ParsedHandlerCall, "access" | "rateLimit" | "description" | "agent"> {
89
+ if (!isPlainObject(options)) return {};
90
+ const access = readOptionalAccessRule(options["access"]);
91
+ const rateLimit = readOptionalRateLimit(options["rateLimit"]);
92
+ const description =
93
+ typeof options["description"] === "string" ? options["description"] : undefined;
94
+ const agent = readOptionalAgentHints(options["agent"]);
95
+ return {
96
+ ...(access !== undefined && { access }),
97
+ ...(description !== undefined && { description }),
98
+ ...(agent !== undefined && { agent }),
99
+ ...(rateLimit !== undefined && { rateLimit }),
100
+ };
101
+ }
102
+
30
103
  /**
31
104
  * Resolves an argument standing in for a handler-call's object-form body:
32
105
  * either it's already an object literal, or a bare identifier declared
@@ -113,6 +186,7 @@ export function parseHandlerCall(
113
186
  const rateLimit = rateLimitInit
114
187
  ? readOptionalRateLimit(readDataLiteralNode(rateLimitInit))
115
188
  : undefined;
189
+ const { description, agent } = readDescriptionAndAgent(obj);
116
190
  const skip = readBooleanProperty(obj, "unsafeSkipTransitionGuard");
117
191
  return ok({
118
192
  source: sourceLocationFromNode(call, sourceFile),
@@ -120,6 +194,8 @@ export function parseHandlerCall(
120
194
  schemaSource: sourceLocationFromNode(schemaInit, sourceFile),
121
195
  handlerBody: sourceLocationFromNode(fn, sourceFile),
122
196
  ...(access !== undefined && { access }),
197
+ ...(description !== undefined && { description }),
198
+ ...(agent !== undefined && { agent }),
123
199
  ...(rateLimit !== undefined && { rateLimit }),
124
200
  ...(skip === true && { unsafeSkipTransitionGuard: true }),
125
201
  });
@@ -168,22 +244,13 @@ export function parseHandlerCall(
168
244
  );
169
245
  }
170
246
  const optionsArg = args[3];
171
- let access: AccessRule | undefined;
172
- let rateLimit: RateLimitOption | undefined;
173
- if (optionsArg) {
174
- const options = readDataLiteralNode(optionsArg);
175
- if (isPlainObject(options)) {
176
- access = readOptionalAccessRule(options["access"]);
177
- rateLimit = readOptionalRateLimit(options["rateLimit"]);
178
- }
179
- }
247
+ const optionsFields = optionsArg ? readOptionsFields(readDataLiteralNode(optionsArg)) : {};
180
248
  return ok({
181
249
  source: sourceLocationFromNode(call, sourceFile),
182
250
  handlerName,
183
251
  schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
184
252
  handlerBody: sourceLocationFromNode(fn, sourceFile),
185
- ...(access !== undefined && { access }),
186
- ...(rateLimit !== undefined && { rateLimit }),
253
+ ...optionsFields,
187
254
  });
188
255
  }
189
256
 
@@ -200,6 +267,8 @@ export function extractWriteHandler(
200
267
  schemaSource: parsed.pattern.schemaSource,
201
268
  handlerBody: parsed.pattern.handlerBody,
202
269
  ...(parsed.pattern.access !== undefined && { access: parsed.pattern.access }),
270
+ ...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
271
+ ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
203
272
  ...(parsed.pattern.rateLimit !== undefined && { rateLimit: parsed.pattern.rateLimit }),
204
273
  ...(parsed.pattern.unsafeSkipTransitionGuard === true && { unsafeSkipTransitionGuard: true }),
205
274
  });
@@ -222,9 +291,17 @@ export function extractQueryHandler(
222
291
  ): ExtractOutput<QueryHandlerPattern> {
223
292
  const parsed = parseHandlerCall(call, sourceFile, "queryHandler");
224
293
  if (parsed.kind === "error") return parsed;
225
- return ok({ kind: "queryHandler", ...readHandlerFields(parsed) });
294
+ return ok({
295
+ kind: "queryHandler",
296
+ ...readHandlerFields(parsed),
297
+ ...(parsed.pattern.description !== undefined && { description: parsed.pattern.description }),
298
+ ...(parsed.pattern.agent !== undefined && { agent: parsed.pattern.agent }),
299
+ });
226
300
  }
227
301
 
302
+ // streamHandler shares parseHandlerCall with writeHandler/queryHandler, but
303
+ // StreamHandlerDef carries neither `description` nor `agent` at runtime —
304
+ // readHandlerFields intentionally does not forward them here.
228
305
  export function extractStreamHandler(
229
306
  call: CallExpression,
230
307
  sourceFile: SourceFile,
@@ -66,7 +66,12 @@ import type {
66
66
  } from "../types/config";
67
67
  import type { MetricOptions, SecretOptions } from "../types/feature";
68
68
  import type { EntityDefinition } from "../types/fields";
69
- import type { AccessRule, ClaimKeyType, RateLimitOption } from "../types/handlers";
69
+ import type {
70
+ AccessRule,
71
+ AgentHandlerHints,
72
+ ClaimKeyType,
73
+ RateLimitOption,
74
+ } from "../types/handlers";
70
75
  import type { HookPhase } from "../types/hooks";
71
76
  import type { HttpRouteMethod } from "../types/http-route";
72
77
  import type { NavDefinition } from "../types/nav";
@@ -87,6 +92,10 @@ import type { SourceLocation } from "./source-location";
87
92
  // framework derives the aggregate table, CRUD events, and the read-side
88
93
  // projection from it at boot. Fully static — the Designer renders it as a
89
94
  // form, the AI patcher edits it as pure data.
95
+ //
96
+ // Carries `definition.description` transitively (no dedicated field here):
97
+ // that prose feeds the AI-agent manifest output. An entity without it still
98
+ // works, but the agent has nothing to say about it in the tool catalogue.
90
99
  export type EntityPattern = {
91
100
  readonly kind: "entity";
92
101
  readonly source: SourceLocation;
@@ -333,6 +342,10 @@ export type ScreenOpaqueMarker = typeof SCREEN_OPAQUE_MARKER;
333
342
  // entity-bound screens reference a registered entity and that column/form
334
343
  // field refs name real fields. Closure-valued props (visibility conditions,
335
344
  // row-action payloads, custom renderers) stay opaque — see `opaqueProps`.
345
+ //
346
+ // Carries `definition.description` transitively (no dedicated field here):
347
+ // that prose feeds the AI-agent manifest output. A `type: "custom"` screen
348
+ // without it renders a `renderer` closure the agent has no way to explain.
336
349
  export type ScreenPattern = {
337
350
  readonly kind: "screen";
338
351
  readonly source: SourceLocation;
@@ -346,6 +359,13 @@ export type ScreenPattern = {
346
359
  // `r.writeHandler(...)` — registers a command handler: name, Zod input
347
360
  // schema, handler closure, plus optional `access` and `rateLimit` rules.
348
361
  // The header is declarative; schema and body stay opaque source spans.
362
+ //
363
+ // `description` is the prose the AI agent sees as this handler's tool
364
+ // description in the manifest — without it, the handler stays invisible to
365
+ // the agent (fail-closed: no description, no exposure). `agent.expose`
366
+ // overrides that derivation explicitly (force-show or force-hide);
367
+ // `agent.risk` ("low" | "mid" | "high") classifies the action's blast
368
+ // radius, defaulting to "mid" for a write handler.
349
369
  export type WriteHandlerPattern = {
350
370
  readonly kind: "writeHandler";
351
371
  readonly source: SourceLocation;
@@ -364,6 +384,8 @@ export type WriteHandlerPattern = {
364
384
  // generates raw TypeScript, no DSL interpretation.
365
385
  readonly handlerBody?: SourceLocation;
366
386
  readonly access?: AccessRule;
387
+ readonly description?: string;
388
+ readonly agent?: AgentHandlerHints;
367
389
  readonly rateLimit?: RateLimitOption;
368
390
  readonly unsafeSkipTransitionGuard?: boolean;
369
391
  };
@@ -372,6 +394,12 @@ export type WriteHandlerPattern = {
372
394
  // handler closure, plus optional `access` and `rateLimit` rules. Read-side
373
395
  // counterpart of `r.writeHandler` with the same header/body split. Opaque
374
396
  // (no handlerName) for the same single-reference case as WriteHandlerPattern.
397
+ //
398
+ // `description` and `agent` mirror WriteHandlerPattern: `description` is the
399
+ // prose the AI agent shows as this query's tool description (absent →
400
+ // invisible to the agent), `agent.expose` overrides that, and `agent.risk`
401
+ // defaults to "low" for a query handler (reads carry less blast radius than
402
+ // writes).
375
403
  export type QueryHandlerPattern = {
376
404
  readonly kind: "queryHandler";
377
405
  readonly source: SourceLocation;
@@ -379,6 +407,8 @@ export type QueryHandlerPattern = {
379
407
  readonly schemaSource?: SourceLocation;
380
408
  readonly handlerBody?: SourceLocation;
381
409
  readonly access?: AccessRule;
410
+ readonly description?: string;
411
+ readonly agent?: AgentHandlerHints;
382
412
  readonly rateLimit?: RateLimitOption;
383
413
  };
384
414
 
@@ -404,6 +404,8 @@ function renderWriteHandler(p: WriteHandlerPattern): string {
404
404
  lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
405
405
  lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
406
406
  if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
407
+ if (p.description !== undefined) lines.push(` description: ${JSON.stringify(p.description)},`);
408
+ if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
407
409
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
408
410
  if (p.unsafeSkipTransitionGuard === true) lines.push(" unsafeSkipTransitionGuard: true,");
409
411
  lines.push("});");
@@ -417,6 +419,8 @@ function renderQueryHandler(p: QueryHandlerPattern): string {
417
419
  lines.push(` schema: ${reindentBody(p.schemaSource?.raw ?? "", PATTERN_INDENT)},`);
418
420
  lines.push(` handler: ${reindentBody(p.handlerBody?.raw ?? "", PATTERN_INDENT)},`);
419
421
  if (p.access !== undefined) lines.push(` access: ${renderValue(p.access)},`);
422
+ if (p.description !== undefined) lines.push(` description: ${JSON.stringify(p.description)},`);
423
+ if (p.agent !== undefined) lines.push(` agent: ${renderValue(p.agent)},`);
420
424
  if (p.rateLimit !== undefined) lines.push(` rateLimit: ${renderValue(p.rateLimit)},`);
421
425
  lines.push("});");
422
426
  return lines.join("\n");
@@ -14,6 +14,7 @@ import { resolveName } from "./handler-helpers";
14
14
  import { splitNamedDefinition } from "./object-form";
15
15
  import type {
16
16
  AccessRule,
17
+ AgentHandlerHints,
17
18
  EntityDefinition,
18
19
  EntityRef,
19
20
  HandlerRef,
@@ -54,6 +55,16 @@ function tryMapEntity(state: FeatureBuilderState, name: string, handlerName: str
54
55
  }
55
56
  }
56
57
 
58
+ function agentSlots(source: {
59
+ readonly description?: string;
60
+ readonly agent?: AgentHandlerHints;
61
+ }): { description?: string; agent?: AgentHandlerHints } {
62
+ return {
63
+ ...(source.description !== undefined && { description: source.description }),
64
+ ...(source.agent && { agent: source.agent }),
65
+ };
66
+ }
67
+
57
68
  // Builds entity/relation/writeHandler/queryHandler — the registrar
58
69
  // methods that create or reference entities.
59
70
  export function buildEntityHandlerMethods<TName extends string>(
@@ -96,7 +107,12 @@ export function buildEntityHandlerMethods<TName extends string>(
96
107
  nameOrDef: string | WriteHandlerDefinition<TName, TSchema>,
97
108
  schema?: TSchema,
98
109
  handler?: WriteHandlerFn<z.infer<TSchema>>,
99
- options?: { access?: AccessRule; rateLimit?: RateLimitOption },
110
+ options?: {
111
+ access?: AccessRule;
112
+ rateLimit?: RateLimitOption;
113
+ description?: string;
114
+ agent?: AgentHandlerHints;
115
+ },
100
116
  ): HandlerRef {
101
117
  if (typeof nameOrDef === "object") {
102
118
  const def = nameOrDef;
@@ -113,6 +129,7 @@ export function buildEntityHandlerMethods<TName extends string>(
113
129
  // is what fails). Explicit cast is the right tool.
114
130
  handler: def.handler as WriteHandlerFn,
115
131
  ...(def.access && { access: def.access }),
132
+ ...agentSlots(def),
116
133
  ...(def.unsafeSkipTransitionGuard && { unsafeSkipTransitionGuard: true }),
117
134
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
118
135
  // Forward the pipeline-build closure so boot-validators and
@@ -135,6 +152,7 @@ export function buildEntityHandlerMethods<TName extends string>(
135
152
  schema,
136
153
  handler: handler as WriteHandlerFn, // @cast-boundary engine-bridge
137
154
  ...(options?.access && { access: options.access }),
155
+ ...agentSlots(options ?? {}),
138
156
  ...(options?.rateLimit && { rateLimit: options.rateLimit }),
139
157
  };
140
158
  tryMapEntity(state, name, nameOrDef);
@@ -144,7 +162,13 @@ export function buildEntityHandlerMethods<TName extends string>(
144
162
  nameOrDef: string | QueryHandlerDefinition<TName, TSchema>,
145
163
  schema?: TSchema,
146
164
  handler?: QueryHandlerFn<z.infer<TSchema>>,
147
- options?: { access?: AccessRule; rateLimit?: RateLimitOption; outputSchema?: ZodType },
165
+ options?: {
166
+ access?: AccessRule;
167
+ rateLimit?: RateLimitOption;
168
+ outputSchema?: ZodType;
169
+ description?: string;
170
+ agent?: AgentHandlerHints;
171
+ },
148
172
  ): HandlerRef {
149
173
  if (typeof nameOrDef === "object") {
150
174
  const def = nameOrDef;
@@ -154,6 +178,7 @@ export function buildEntityHandlerMethods<TName extends string>(
154
178
  // @cast-boundary engine-bridge — typed Dev-API → erased internal storage
155
179
  handler: def.handler as QueryHandlerFn, // @cast-boundary engine-bridge
156
180
  ...(def.access && { access: def.access }),
181
+ ...agentSlots(def),
157
182
  ...(def.rateLimit && { rateLimit: def.rateLimit }),
158
183
  ...(def.outputSchema && { outputSchema: def.outputSchema }),
159
184
  // Carry the definePagedQueryHandler brand through — this rebuild
@@ -170,6 +195,7 @@ export function buildEntityHandlerMethods<TName extends string>(
170
195
  schema,
171
196
  handler: handler as QueryHandlerFn, // @cast-boundary engine-bridge
172
197
  ...(options?.access && { access: options.access }),
198
+ ...agentSlots(options ?? {}),
173
199
  ...(options?.rateLimit && { rateLimit: options.rateLimit }),
174
200
  ...(options?.outputSchema && { outputSchema: options.outputSchema }),
175
201
  };
@@ -279,6 +279,9 @@ export {
279
279
  export type {
280
280
  AccessRule,
281
281
  ActionFormScreenDefinition,
282
+ AgentExposure,
283
+ AgentHandlerHints,
284
+ AgentRisk,
282
285
  AppContext,
283
286
  AppendEventArgs,
284
287
  AppendEventFn,
@@ -469,7 +472,7 @@ export type {
469
472
  WriteHandlerFn,
470
473
  WriteResult,
471
474
  } from "./types";
472
- export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases } from "./types";
475
+ export { DEFAULT_CURRENCIES, DEFAULT_LOCALES, HookPhases, resolveAgentExposure } from "./types";
473
476
  export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
474
477
  export type {
475
478
  AwaitedEventType,