@yaklabs-ai/plugin-sdk 0.12.2 → 0.12.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/README.md CHANGED
@@ -13,7 +13,7 @@ it under the name your plugin code actually imports:
13
13
  ```json
14
14
  {
15
15
  "dependencies": {
16
- "@kay/plugin-sdk": "npm:@yaklabs-ai/plugin-sdk@^0.12.2"
16
+ "@kay/plugin-sdk": "npm:@yaklabs-ai/plugin-sdk@^0.12.4"
17
17
  }
18
18
  }
19
19
  ```
@@ -131,8 +131,10 @@ Pass the loader's inputs, not a subset — the audit judges what it is given:
131
131
  violations Kay itself would not report.
132
132
 
133
133
  `auditSkillCodegenPairs({ declaredSkills, exportedSkills, packageDir })` is the
134
- companion check for a bundled-skill plugin. Both are pure — they return
135
- violations rather than asserting — so any runner works. Both read from disk
134
+ companion check for a bundled-skill plugin. It compares the decoded YAML
135
+ `name` and `description` with the export, so quoting and block scalars are
136
+ supported; body content must match after trimming surrounding whitespace.
137
+ Both return violations rather than asserting, so any runner works. Both read from disk
136
138
  and call your own `help()`, so run them in Node, and keep `activate` free of
137
139
  side effects: the probe calls it.
138
140
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "package": "@yaklabs-ai/plugin-sdk",
4
- "version": "0.12.2",
4
+ "version": "0.12.4",
5
5
  "target": "npm",
6
6
  "firstPartyLicense": "MIT",
7
7
  "bundled": [
@@ -53,6 +53,13 @@
53
53
  "source": "https://github.com/colinhacks/zod",
54
54
  "note": "Installed as a consumer dependency; it is not bundled into the staged artifacts.",
55
55
  "versionRange": "^4.0.0"
56
+ },
57
+ {
58
+ "name": "yaml",
59
+ "license": "ISC",
60
+ "source": "https://github.com/eemeli/yaml",
61
+ "note": "Installed as a consumer dependency; it is not bundled into the staged artifacts.",
62
+ "versionRange": "^2.9.0"
56
63
  }
57
64
  ]
58
65
  }
@@ -213,7 +213,8 @@ export declare interface AuditPluginDisclosureInput {
213
213
  * The codegen-pair equivalence check every bundled-skill plugin hand-rolled (see
214
214
  * `plugins/ARCHITECTURE.md` → "Bundled skills", step 4): every exported `PluginSkillDefinition`
215
215
  * must have a matching manifest declaration (same `localId`, same `description`), and the
216
- * declared `SKILL.md` file's frontmatter + body must match the export byte-for-byte.
216
+ * declared `SKILL.md` file's decoded YAML name and description must match the export.
217
+ * The body is compared byte-for-byte after trimming surrounding whitespace.
217
218
  */
218
219
  export declare function auditSkillCodegenPairs(input: AuditSkillCodegenPairsInput): Promise<readonly DisclosureViolation[]>;
219
220
 
@@ -1257,6 +1258,14 @@ declare interface KayAgentPluginApi {
1257
1258
  * guard it with `if (api.withJev !== undefined)`.
1258
1259
  */
1259
1260
  readonly withJev: WithJev;
1261
+ /**
1262
+ * The daemon's clock and the time zone the user is in, read together on each `snapshot()` call.
1263
+ * Always present and ungated: it confers no authority, since a plugin runs in the daemon's
1264
+ * process and can read the clock itself. Read the user's zone here, never from `Intl` or
1265
+ * `process.env.TZ`, and render user-facing times in it rather than an event's own zone. This is
1266
+ * the same zone-of-record port the session stamp uses; see {@link KayAppTime.snapshot}.
1267
+ */
1268
+ readonly time: KayAppTime;
1260
1269
  /**
1261
1270
  * Publish the plugin's declared events, for example so a default notification rule can tell the
1262
1271
  * user what the agent just did. Always present, like {@link withJev}: there is nothing to
@@ -1486,6 +1495,81 @@ declare interface KayAgentWhiteboardUpdateInput {
1486
1495
  readonly title?: string;
1487
1496
  }
1488
1497
 
1498
+ /**
1499
+ * `app.time` / `api.time`: host facts about time. Added to `kay.app` in SDK 0.12.1 and to
1500
+ * `kay.agent` with the calendar zone-of-record work: a plugin that calls it declares
1501
+ * `compatibility.sdk` `^0.12.1`, so an older Bonsai refuses a directory or repository plugin at
1502
+ * load instead of the call failing.
1503
+ */
1504
+ declare interface KayAppTime {
1505
+ /**
1506
+ * Reads the daemon's clock and the user's time zone now.
1507
+ *
1508
+ * Every call asks the host again and returns a new object; nothing is cached. Call it where you
1509
+ * need the value (inside a tool, a handler or a workflow step), not inside `activate()`: a value
1510
+ * read there is as old as the activation, and the host can run a bundled plugin's `activate()`
1511
+ * against a recording api that has no `time`, so a read there throws. The exception is a
1512
+ * directory plugin that declares a workflow with a cron `timezone`: `workflows.define` is legal
1513
+ * only during `activate()`, so it reads the zone there, and the schedule then keeps that zone.
1514
+ * Keep the result for as long as you use it, and not across a long wait (a sleep, a timer, a
1515
+ * queued run): the zone can change while you wait. On a daemon that runs on the user's machine,
1516
+ * a change of the process's `TZ` shows in the next call. A `TZ` that names a zone wins over the
1517
+ * operating system's zone, so a change of the operating-system zone does not show while the
1518
+ * process has one. When `TZ` is unset or does not name a zone, a change of the operating-system
1519
+ * zone is meant to show, but none has been seen on a real machine yet.
1520
+ *
1521
+ * A zone the host cannot determine is an answer (`zoneSource: "utc-fallback"`), not an error.
1522
+ *
1523
+ * Use this to find the zone, not `Intl.DateTimeFormat().resolvedOptions().timeZone`, which can
1524
+ * keep a zone the runtime read earlier, or `process.env.TZ`, which is often unset or not a zone.
1525
+ * Neither says that the zone is unknown.
1526
+ *
1527
+ * It does not use `this`, so it is safe to destructure.
1528
+ */
1529
+ readonly snapshot: () => KayAppTimeSnapshot;
1530
+ }
1531
+
1532
+ /**
1533
+ * The plugin-facing time contract: the daemon's clock and the time zone the user is in,
1534
+ * read together in one call.
1535
+ *
1536
+ * Ungated and always present, like `app.paths`. It confers no authority: a plugin runs in the
1537
+ * daemon's process and can already read the clock. What the door adds is the daemon's answer
1538
+ * for the USER's zone, and a label for the case where that answer is unknown.
1539
+ *
1540
+ * This is a leaf module. It lives beside both target SDKs (rather than inside `kay-app`) so the
1541
+ * `kay.agent` SDK can expose the same door without importing the `kay.app` surface, which pulls
1542
+ * that surface into every agent-facing bundle.
1543
+ */
1544
+ /** One reading of the daemon's clock and the user's time zone. Each call returns a new object. */
1545
+ declare interface KayAppTimeSnapshot {
1546
+ /** The daemon's clock when the snapshot was read: milliseconds since the Unix epoch. */
1547
+ readonly nowMs: number;
1548
+ /**
1549
+ * The time zone the user is in, as an IANA id in the spelling ICU resolves (`America/New_York`).
1550
+ * One zone can have two ids, and ICU picks one: `Asia/Kolkata` comes back as `Asia/Calcutta`. So
1551
+ * never compare zone ids with `===`. A first-party plugin compares with `sameZone` from
1552
+ * `@kay/time`. A plugin that imports only this SDK and node builtins resolves each id with
1553
+ * `new Intl.DateTimeFormat("en-US", { timeZone: id }).resolvedOptions().timeZone` and compares the
1554
+ * results. Pass `zone` as the `timeZone` of `Intl.DateTimeFormat` or `toLocaleString` to format a
1555
+ * time for the user.
1556
+ *
1557
+ * When `zoneSource` is `utc-fallback` the zone is unknown, and this value is `UTC` only because a
1558
+ * value is required.
1559
+ */
1560
+ readonly zone: string;
1561
+ /**
1562
+ * How the daemon found `zone`.
1563
+ * - `host`: the daemon runs on the user's machine, and `zone` is the zone the daemon process
1564
+ * runs in, read for this call: the process's `TZ` when that names a zone, otherwise the
1565
+ * operating system's zone.
1566
+ * - `utc-fallback`: the daemon does not know the user's zone. It runs on another machine (a remote
1567
+ * daemon), or the machine's zone could not be read or is not an IANA id. `zone` is `UTC`, which
1568
+ * here is a placeholder and not a fact about the user: do not show it to the user as their zone.
1569
+ */
1570
+ readonly zoneSource: "host" | "utc-fallback";
1571
+ }
1572
+
1489
1573
  declare type KnownApi = "openai-completions" | "mistral-conversations" | "openai-responses" | "azure-openai-responses" | "openai-codex-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" | "google-vertex" | "pi-messages";
1490
1574
 
1491
1575
  declare type KnownProvider = "amazon-bedrock" | "ant-ling" | "anthropic" | "google" | "google-vertex" | "openai" | "azure-openai-responses" | "openai-codex" | "radius" | "typesafe" | "nvidia" | "deepseek" | "github-copilot" | "xai" | "groq" | "cerebras" | "openrouter" | "vercel-ai-gateway" | "zai" | "zai-coding-cn" | "mistral" | "minimax" | "minimax-cn" | "moonshotai" | "moonshotai-cn" | "huggingface" | "fireworks" | "together" | "baseten" | "opencode" | "opencode-go" | "kimi-coding" | "meta" | "cloudflare-workers-ai" | "cloudflare-ai-gateway" | "qwen-token-plan" | "qwen-token-plan-cn" | "qwen-token-plan-individual" | "xiaomi" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-sgp";
@@ -3146,8 +3230,9 @@ declare interface UriModule {
3146
3230
  /** Pure route/payload validation applied before a consequential permission ask. */
3147
3231
  readonly preflight?: UriOperationPreflightPolicy;
3148
3232
  /**
3149
- * Structured facts naming the WHOLE consequence of this call, or `undefined` when
3150
- * the route cannot honestly be described on a one-line approval surface. The module
3233
+ * Structured facts for approval surfaces, or `undefined` when unavailable.
3234
+ * A one-line description must name the whole consequence; `preview` instead
3235
+ * supplies expanded, literal-text detail and suppresses one-line approval. The module
3151
3236
  * owns the static action phrase, target resolution, payload decoding and declaration
3152
3237
  * of covered payload fields and query parameters. Unknown fields cost the summary.
3153
3238
  * The host owns framing, the whole-quote fit (no elision) and display-safety admission.
@@ -3305,6 +3390,18 @@ declare interface UriOperationDescription {
3305
3390
  readonly target: string;
3306
3391
  /** Model-authored content; the host flattens, budgets, elides and bounds it. */
3307
3392
  readonly quote?: string;
3393
+ /** Full, render-only detail. Hosts must not turn a rich preview into one-line approval. */
3394
+ readonly preview?: {
3395
+ readonly title: string;
3396
+ readonly description?: string;
3397
+ readonly fields?: readonly {
3398
+ readonly label: string;
3399
+ readonly value: string;
3400
+ readonly oldValue?: string;
3401
+ }[];
3402
+ };
3403
+ /** True only when every displayed fact comes from the input, independent of the executing account. */
3404
+ readonly accountIndependent?: true;
3308
3405
  /** What this description accounts for; the host refuses undeclared input. */
3309
3406
  readonly accounts: {
3310
3407
  /** The payload actually read by the module, after its own decoding. */
@@ -1,5 +1,6 @@
1
1
  // packages/plugin-sdk/dist/disclosure/disclosure-conformance.js
2
2
  import { readFile } from "node:fs/promises";
3
+ import { parse as parseYaml } from "yaml";
3
4
 
4
5
  // packages/herd-uri/dist/connection.js
5
6
  var CONNECTION_SELECTOR_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)@([^/]+)(?:\/(.*))?$/;
@@ -700,24 +701,36 @@ async function auditSkillCodegenPairFile(skill, declaration, packageDir) {
700
701
  ];
701
702
  }
702
703
  const codegenPairMatch = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(file);
703
- const frontmatter = codegenPairMatch?.[1] === void 0 ? void 0 : `${codegenPairMatch[1]}
704
- `;
704
+ const yaml = codegenPairMatch?.[1];
705
+ let frontmatter = {};
706
+ let parseError;
707
+ if (yaml !== void 0) {
708
+ try {
709
+ const parsed = parseYaml(yaml);
710
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
711
+ frontmatter = parsed;
712
+ } else {
713
+ parseError = "frontmatter must be a YAML mapping";
714
+ }
715
+ } catch (error) {
716
+ parseError = errorMessage(error);
717
+ }
718
+ }
719
+ const parseDetail = parseError === void 0 ? "" : ` Could not parse '${declaration.path}': ${parseError}`;
705
720
  const body = codegenPairMatch?.[2];
706
721
  const violations = [];
707
- if (frontmatter === void 0 || !frontmatter.includes(`name: ${skill.name}
708
- `)) {
722
+ if (frontmatter["name"] !== skill.name) {
709
723
  violations.push({
710
724
  moduleId: skill.name,
711
725
  rule: "skill-twin-frontmatter-name-mismatch",
712
- detail: `SKILL.md frontmatter does not declare 'name: ${skill.name}'.`
726
+ detail: `SKILL.md frontmatter name does not match the exported name '${skill.name}'.${parseDetail}`
713
727
  });
714
728
  }
715
- if (frontmatter === void 0 || !frontmatter.includes(`description: ${skill.description}
716
- `)) {
729
+ if (frontmatter["description"] !== skill.description) {
717
730
  violations.push({
718
731
  moduleId: skill.name,
719
732
  rule: "skill-twin-frontmatter-description-mismatch",
720
- detail: "SKILL.md frontmatter description does not match the exported description."
733
+ detail: `SKILL.md frontmatter description does not match the exported description.${parseDetail}`
721
734
  });
722
735
  }
723
736
  if (body === void 0 || body.trim() !== skill.content.trim()) {
@@ -806,7 +819,12 @@ function createKayAgentProbeCapabilityHandles() {
806
819
  withJev: INERT_WITH_JEV,
807
820
  // Fire-and-forget on the real api too, so dropping is the inert shape: nothing to hang on.
808
821
  events: { publish: () => {
809
- } }
822
+ } },
823
+ // A probe has no user-time port, so it answers the zone as unknown — the same contract the
824
+ // real capability keeps for a zone the host cannot read.
825
+ time: {
826
+ snapshot: () => ({ nowMs: 0, zone: "UTC", zoneSource: "utc-fallback" })
827
+ }
810
828
  };
811
829
  }
812
830
 
@@ -531,7 +531,7 @@ declare interface ExtensionPaths {
531
531
  export declare function fauxAuthContext(overrides?: Partial<ExtensionAuthContext>): ExtensionAuthContext;
532
532
 
533
533
  /** The capability handles a `KayAgentPluginApi` always carries — authority is decided per call. */
534
- export declare type FauxKayAgentCapabilities = Pick<KayAgentPluginApi, "filesystem" | "network" | "webSession" | "process" | "whiteboard" | "interaction" | "interceptToolCall" | "withJev" | "events">;
534
+ export declare type FauxKayAgentCapabilities = Pick<KayAgentPluginApi, "filesystem" | "network" | "webSession" | "process" | "whiteboard" | "interaction" | "interceptToolCall" | "withJev" | "events" | "time">;
535
535
 
536
536
  export declare function fauxKayAgentCapabilities(): FauxKayAgentCapabilities;
537
537
 
@@ -1159,6 +1159,14 @@ declare interface KayAgentPluginApi {
1159
1159
  * guard it with `if (api.withJev !== undefined)`.
1160
1160
  */
1161
1161
  readonly withJev: WithJev;
1162
+ /**
1163
+ * The daemon's clock and the time zone the user is in, read together on each `snapshot()` call.
1164
+ * Always present and ungated: it confers no authority, since a plugin runs in the daemon's
1165
+ * process and can read the clock itself. Read the user's zone here, never from `Intl` or
1166
+ * `process.env.TZ`, and render user-facing times in it rather than an event's own zone. This is
1167
+ * the same zone-of-record port the session stamp uses; see {@link KayAppTime.snapshot}.
1168
+ */
1169
+ readonly time: KayAppTime;
1162
1170
  /**
1163
1171
  * Publish the plugin's declared events, for example so a default notification rule can tell the
1164
1172
  * user what the agent just did. Always present, like {@link withJev}: there is nothing to
@@ -1388,6 +1396,81 @@ declare interface KayAgentWhiteboardUpdateInput {
1388
1396
  readonly title?: string;
1389
1397
  }
1390
1398
 
1399
+ /**
1400
+ * `app.time` / `api.time`: host facts about time. Added to `kay.app` in SDK 0.12.1 and to
1401
+ * `kay.agent` with the calendar zone-of-record work: a plugin that calls it declares
1402
+ * `compatibility.sdk` `^0.12.1`, so an older Bonsai refuses a directory or repository plugin at
1403
+ * load instead of the call failing.
1404
+ */
1405
+ declare interface KayAppTime {
1406
+ /**
1407
+ * Reads the daemon's clock and the user's time zone now.
1408
+ *
1409
+ * Every call asks the host again and returns a new object; nothing is cached. Call it where you
1410
+ * need the value (inside a tool, a handler or a workflow step), not inside `activate()`: a value
1411
+ * read there is as old as the activation, and the host can run a bundled plugin's `activate()`
1412
+ * against a recording api that has no `time`, so a read there throws. The exception is a
1413
+ * directory plugin that declares a workflow with a cron `timezone`: `workflows.define` is legal
1414
+ * only during `activate()`, so it reads the zone there, and the schedule then keeps that zone.
1415
+ * Keep the result for as long as you use it, and not across a long wait (a sleep, a timer, a
1416
+ * queued run): the zone can change while you wait. On a daemon that runs on the user's machine,
1417
+ * a change of the process's `TZ` shows in the next call. A `TZ` that names a zone wins over the
1418
+ * operating system's zone, so a change of the operating-system zone does not show while the
1419
+ * process has one. When `TZ` is unset or does not name a zone, a change of the operating-system
1420
+ * zone is meant to show, but none has been seen on a real machine yet.
1421
+ *
1422
+ * A zone the host cannot determine is an answer (`zoneSource: "utc-fallback"`), not an error.
1423
+ *
1424
+ * Use this to find the zone, not `Intl.DateTimeFormat().resolvedOptions().timeZone`, which can
1425
+ * keep a zone the runtime read earlier, or `process.env.TZ`, which is often unset or not a zone.
1426
+ * Neither says that the zone is unknown.
1427
+ *
1428
+ * It does not use `this`, so it is safe to destructure.
1429
+ */
1430
+ readonly snapshot: () => KayAppTimeSnapshot;
1431
+ }
1432
+
1433
+ /**
1434
+ * The plugin-facing time contract: the daemon's clock and the time zone the user is in,
1435
+ * read together in one call.
1436
+ *
1437
+ * Ungated and always present, like `app.paths`. It confers no authority: a plugin runs in the
1438
+ * daemon's process and can already read the clock. What the door adds is the daemon's answer
1439
+ * for the USER's zone, and a label for the case where that answer is unknown.
1440
+ *
1441
+ * This is a leaf module. It lives beside both target SDKs (rather than inside `kay-app`) so the
1442
+ * `kay.agent` SDK can expose the same door without importing the `kay.app` surface, which pulls
1443
+ * that surface into every agent-facing bundle.
1444
+ */
1445
+ /** One reading of the daemon's clock and the user's time zone. Each call returns a new object. */
1446
+ declare interface KayAppTimeSnapshot {
1447
+ /** The daemon's clock when the snapshot was read: milliseconds since the Unix epoch. */
1448
+ readonly nowMs: number;
1449
+ /**
1450
+ * The time zone the user is in, as an IANA id in the spelling ICU resolves (`America/New_York`).
1451
+ * One zone can have two ids, and ICU picks one: `Asia/Kolkata` comes back as `Asia/Calcutta`. So
1452
+ * never compare zone ids with `===`. A first-party plugin compares with `sameZone` from
1453
+ * `@kay/time`. A plugin that imports only this SDK and node builtins resolves each id with
1454
+ * `new Intl.DateTimeFormat("en-US", { timeZone: id }).resolvedOptions().timeZone` and compares the
1455
+ * results. Pass `zone` as the `timeZone` of `Intl.DateTimeFormat` or `toLocaleString` to format a
1456
+ * time for the user.
1457
+ *
1458
+ * When `zoneSource` is `utc-fallback` the zone is unknown, and this value is `UTC` only because a
1459
+ * value is required.
1460
+ */
1461
+ readonly zone: string;
1462
+ /**
1463
+ * How the daemon found `zone`.
1464
+ * - `host`: the daemon runs on the user's machine, and `zone` is the zone the daemon process
1465
+ * runs in, read for this call: the process's `TZ` when that names a zone, otherwise the
1466
+ * operating system's zone.
1467
+ * - `utc-fallback`: the daemon does not know the user's zone. It runs on another machine (a remote
1468
+ * daemon), or the machine's zone could not be read or is not an IANA id. `zone` is `UTC`, which
1469
+ * here is a placeholder and not a fact about the user: do not show it to the user as their zone.
1470
+ */
1471
+ readonly zoneSource: "host" | "utc-fallback";
1472
+ }
1473
+
1391
1474
  declare type KnownApi = "openai-completions" | "mistral-conversations" | "openai-responses" | "azure-openai-responses" | "openai-codex-responses" | "anthropic-messages" | "bedrock-converse-stream" | "google-generative-ai" | "google-vertex" | "pi-messages";
1392
1475
 
1393
1476
  declare type KnownProvider = "amazon-bedrock" | "ant-ling" | "anthropic" | "google" | "google-vertex" | "openai" | "azure-openai-responses" | "openai-codex" | "radius" | "typesafe" | "nvidia" | "deepseek" | "github-copilot" | "xai" | "groq" | "cerebras" | "openrouter" | "vercel-ai-gateway" | "zai" | "zai-coding-cn" | "mistral" | "minimax" | "minimax-cn" | "moonshotai" | "moonshotai-cn" | "huggingface" | "fireworks" | "together" | "baseten" | "opencode" | "opencode-go" | "kimi-coding" | "meta" | "cloudflare-workers-ai" | "cloudflare-ai-gateway" | "qwen-token-plan" | "qwen-token-plan-cn" | "qwen-token-plan-individual" | "xiaomi" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-sgp";
@@ -3045,8 +3128,9 @@ declare interface UriModule {
3045
3128
  /** Pure route/payload validation applied before a consequential permission ask. */
3046
3129
  readonly preflight?: UriOperationPreflightPolicy;
3047
3130
  /**
3048
- * Structured facts naming the WHOLE consequence of this call, or `undefined` when
3049
- * the route cannot honestly be described on a one-line approval surface. The module
3131
+ * Structured facts for approval surfaces, or `undefined` when unavailable.
3132
+ * A one-line description must name the whole consequence; `preview` instead
3133
+ * supplies expanded, literal-text detail and suppresses one-line approval. The module
3050
3134
  * owns the static action phrase, target resolution, payload decoding and declaration
3051
3135
  * of covered payload fields and query parameters. Unknown fields cost the summary.
3052
3136
  * The host owns framing, the whole-quote fit (no elision) and display-safety admission.
@@ -3204,6 +3288,18 @@ declare interface UriOperationDescription {
3204
3288
  readonly target: string;
3205
3289
  /** Model-authored content; the host flattens, budgets, elides and bounds it. */
3206
3290
  readonly quote?: string;
3291
+ /** Full, render-only detail. Hosts must not turn a rich preview into one-line approval. */
3292
+ readonly preview?: {
3293
+ readonly title: string;
3294
+ readonly description?: string;
3295
+ readonly fields?: readonly {
3296
+ readonly label: string;
3297
+ readonly value: string;
3298
+ readonly oldValue?: string;
3299
+ }[];
3300
+ };
3301
+ /** True only when every displayed fact comes from the input, independent of the executing account. */
3302
+ readonly accountIndependent?: true;
3207
3303
  /** What this description accounts for; the host refuses undeclared input. */
3208
3304
  readonly accounts: {
3209
3305
  /** The payload actually read by the module, after its own decoding. */
@@ -185,7 +185,13 @@ function fauxKayAgentCapabilities() {
185
185
  // Accepts and drops, like the registrations above: the real `publish` never throws, so a
186
186
  // rejecting double would fail tests for a reason production never has. A test asserting what
187
187
  // its plugin publishes overrides this handle after the spread.
188
- events: { publish: () => void 0 }
188
+ events: { publish: () => void 0 },
189
+ // A test double has no user-time port, so it answers the zone as unknown — the same contract
190
+ // the real capability keeps for a zone the host cannot read. A test that exercises a plugin's
191
+ // zone-of-record path overrides this handle after the spread.
192
+ time: {
193
+ snapshot: () => ({ nowMs: 0, zone: "UTC", zoneSource: "utc-fallback" })
194
+ }
189
195
  };
190
196
  }
191
197
  function defaultTestToolContext() {
@@ -1620,6 +1620,14 @@ export declare interface KayAgentPluginApi {
1620
1620
  * guard it with `if (api.withJev !== undefined)`.
1621
1621
  */
1622
1622
  readonly withJev: WithJev;
1623
+ /**
1624
+ * The daemon's clock and the time zone the user is in, read together on each `snapshot()` call.
1625
+ * Always present and ungated: it confers no authority, since a plugin runs in the daemon's
1626
+ * process and can read the clock itself. Read the user's zone here, never from `Intl` or
1627
+ * `process.env.TZ`, and render user-facing times in it rather than an event's own zone. This is
1628
+ * the same zone-of-record port the session stamp uses; see {@link KayAppTime.snapshot}.
1629
+ */
1630
+ readonly time: KayAppTime;
1623
1631
  /**
1624
1632
  * Publish the plugin's declared events, for example so a default notification rule can tell the
1625
1633
  * user what the agent just did. Always present, like {@link withJev}: there is nothing to
@@ -1863,6 +1871,81 @@ export declare interface KayAgentWhiteboardUpdateInput {
1863
1871
  readonly title?: string;
1864
1872
  }
1865
1873
 
1874
+ /**
1875
+ * `app.time` / `api.time`: host facts about time. Added to `kay.app` in SDK 0.12.1 and to
1876
+ * `kay.agent` with the calendar zone-of-record work: a plugin that calls it declares
1877
+ * `compatibility.sdk` `^0.12.1`, so an older Bonsai refuses a directory or repository plugin at
1878
+ * load instead of the call failing.
1879
+ */
1880
+ declare interface KayAppTime {
1881
+ /**
1882
+ * Reads the daemon's clock and the user's time zone now.
1883
+ *
1884
+ * Every call asks the host again and returns a new object; nothing is cached. Call it where you
1885
+ * need the value (inside a tool, a handler or a workflow step), not inside `activate()`: a value
1886
+ * read there is as old as the activation, and the host can run a bundled plugin's `activate()`
1887
+ * against a recording api that has no `time`, so a read there throws. The exception is a
1888
+ * directory plugin that declares a workflow with a cron `timezone`: `workflows.define` is legal
1889
+ * only during `activate()`, so it reads the zone there, and the schedule then keeps that zone.
1890
+ * Keep the result for as long as you use it, and not across a long wait (a sleep, a timer, a
1891
+ * queued run): the zone can change while you wait. On a daemon that runs on the user's machine,
1892
+ * a change of the process's `TZ` shows in the next call. A `TZ` that names a zone wins over the
1893
+ * operating system's zone, so a change of the operating-system zone does not show while the
1894
+ * process has one. When `TZ` is unset or does not name a zone, a change of the operating-system
1895
+ * zone is meant to show, but none has been seen on a real machine yet.
1896
+ *
1897
+ * A zone the host cannot determine is an answer (`zoneSource: "utc-fallback"`), not an error.
1898
+ *
1899
+ * Use this to find the zone, not `Intl.DateTimeFormat().resolvedOptions().timeZone`, which can
1900
+ * keep a zone the runtime read earlier, or `process.env.TZ`, which is often unset or not a zone.
1901
+ * Neither says that the zone is unknown.
1902
+ *
1903
+ * It does not use `this`, so it is safe to destructure.
1904
+ */
1905
+ readonly snapshot: () => KayAppTimeSnapshot;
1906
+ }
1907
+
1908
+ /**
1909
+ * The plugin-facing time contract: the daemon's clock and the time zone the user is in,
1910
+ * read together in one call.
1911
+ *
1912
+ * Ungated and always present, like `app.paths`. It confers no authority: a plugin runs in the
1913
+ * daemon's process and can already read the clock. What the door adds is the daemon's answer
1914
+ * for the USER's zone, and a label for the case where that answer is unknown.
1915
+ *
1916
+ * This is a leaf module. It lives beside both target SDKs (rather than inside `kay-app`) so the
1917
+ * `kay.agent` SDK can expose the same door without importing the `kay.app` surface, which pulls
1918
+ * that surface into every agent-facing bundle.
1919
+ */
1920
+ /** One reading of the daemon's clock and the user's time zone. Each call returns a new object. */
1921
+ declare interface KayAppTimeSnapshot {
1922
+ /** The daemon's clock when the snapshot was read: milliseconds since the Unix epoch. */
1923
+ readonly nowMs: number;
1924
+ /**
1925
+ * The time zone the user is in, as an IANA id in the spelling ICU resolves (`America/New_York`).
1926
+ * One zone can have two ids, and ICU picks one: `Asia/Kolkata` comes back as `Asia/Calcutta`. So
1927
+ * never compare zone ids with `===`. A first-party plugin compares with `sameZone` from
1928
+ * `@kay/time`. A plugin that imports only this SDK and node builtins resolves each id with
1929
+ * `new Intl.DateTimeFormat("en-US", { timeZone: id }).resolvedOptions().timeZone` and compares the
1930
+ * results. Pass `zone` as the `timeZone` of `Intl.DateTimeFormat` or `toLocaleString` to format a
1931
+ * time for the user.
1932
+ *
1933
+ * When `zoneSource` is `utc-fallback` the zone is unknown, and this value is `UTC` only because a
1934
+ * value is required.
1935
+ */
1936
+ readonly zone: string;
1937
+ /**
1938
+ * How the daemon found `zone`.
1939
+ * - `host`: the daemon runs on the user's machine, and `zone` is the zone the daemon process
1940
+ * runs in, read for this call: the process's `TZ` when that names a zone, otherwise the
1941
+ * operating system's zone.
1942
+ * - `utc-fallback`: the daemon does not know the user's zone. It runs on another machine (a remote
1943
+ * daemon), or the machine's zone could not be read or is not an IANA id. `zone` is `UTC`, which
1944
+ * here is a placeholder and not a fact about the user: do not show it to the user as their zone.
1945
+ */
1946
+ readonly zoneSource: "host" | "utc-fallback";
1947
+ }
1948
+
1866
1949
  declare const KayPluginManifestV1: z.ZodObject<{
1867
1950
  protocol: z.ZodLiteral<"kay.plugin.v1">;
1868
1951
  id: z.ZodString;
@@ -2188,6 +2271,9 @@ declare const KayPluginManifestV1: z.ZodObject<{
2188
2271
  kind: z.ZodLiteral<"working-document">;
2189
2272
  }, z.core.$strict>, z.ZodObject<{
2190
2273
  kind: z.ZodLiteral<"browser-pane">;
2274
+ }, z.core.$strict>, z.ZodObject<{
2275
+ kind: z.ZodLiteral<"page">;
2276
+ pageId: z.ZodString;
2191
2277
  }, z.core.$strict>], "kind">;
2192
2278
  variants: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
2193
2279
  resourceKind: z.ZodString;
@@ -4889,8 +4975,9 @@ export declare interface UriModule {
4889
4975
  /** Pure route/payload validation applied before a consequential permission ask. */
4890
4976
  readonly preflight?: UriOperationPreflightPolicy;
4891
4977
  /**
4892
- * Structured facts naming the WHOLE consequence of this call, or `undefined` when
4893
- * the route cannot honestly be described on a one-line approval surface. The module
4978
+ * Structured facts for approval surfaces, or `undefined` when unavailable.
4979
+ * A one-line description must name the whole consequence; `preview` instead
4980
+ * supplies expanded, literal-text detail and suppresses one-line approval. The module
4894
4981
  * owns the static action phrase, target resolution, payload decoding and declaration
4895
4982
  * of covered payload fields and query parameters. Unknown fields cost the summary.
4896
4983
  * The host owns framing, the whole-quote fit (no elision) and display-safety admission.
@@ -5065,6 +5152,18 @@ export declare interface UriOperationDescription {
5065
5152
  readonly target: string;
5066
5153
  /** Model-authored content; the host flattens, budgets, elides and bounds it. */
5067
5154
  readonly quote?: string;
5155
+ /** Full, render-only detail. Hosts must not turn a rich preview into one-line approval. */
5156
+ readonly preview?: {
5157
+ readonly title: string;
5158
+ readonly description?: string;
5159
+ readonly fields?: readonly {
5160
+ readonly label: string;
5161
+ readonly value: string;
5162
+ readonly oldValue?: string;
5163
+ }[];
5164
+ };
5165
+ /** True only when every displayed fact comes from the input, independent of the executing account. */
5166
+ readonly accountIndependent?: true;
5068
5167
  /** What this description accounts for; the host refuses undeclared input. */
5069
5168
  readonly accounts: {
5070
5169
  /** The payload actually read by the module, after its own decoding. */
package/dist/kay-app.d.ts CHANGED
@@ -2322,6 +2322,30 @@ export declare interface KayAppEndpoints {
2322
2322
  register(name: string, handler: (payload: unknown) => unknown | Promise<unknown>): void;
2323
2323
  }
2324
2324
 
2325
+ /**
2326
+ * The plugin-facing `kay.app` repository contract: what git checkout encloses a folder, read by
2327
+ * the host.
2328
+ *
2329
+ * Ungated and always present, like `app.paths`: a plugin runs in the daemon's process and could
2330
+ * read these files itself. What `app.repos` adds is that the host reads them asynchronously, under
2331
+ * a time limit, and shares one cache with the daemon. A plugin that reads `.git` files with
2332
+ * `node:fs` sync calls blocks the daemon's event loop for as long as each read takes, which on a
2333
+ * protected or unresponsive folder can be a very long time (kay#11575).
2334
+ */
2335
+ /** One `[remote "<name>"]` in the repository's common `config`. */
2336
+ export declare interface KayAppGitRemote {
2337
+ readonly name: string;
2338
+ /** The `url =` value as written, still usable to connect. */
2339
+ readonly url: string;
2340
+ /**
2341
+ * `url` reduced to a key that is the same however the repository is addressed: no scheme, user
2342
+ * or port, lowercase host, no trailing `.git` (`git@github.com:org/repo.git` and
2343
+ * `https://github.com/org/repo` are both `github.com/org/repo`). Absent when `url` reduces to
2344
+ * nothing. For `owner/repo`, take the last two `/` segments.
2345
+ */
2346
+ readonly identity?: string;
2347
+ }
2348
+
2325
2349
  /** A real human carrier projected into the source session's transcript. */
2326
2350
  export declare interface KayAppHumanMessageSessionEvent extends KayAppSessionEventBase {
2327
2351
  readonly type: "human-message";
@@ -2698,6 +2722,13 @@ export declare interface KayAppPluginApi {
2698
2722
  * `Intl` or `process.env.TZ`; see {@link KayAppTime.snapshot}.
2699
2723
  */
2700
2724
  readonly time: KayAppTime;
2725
+ /**
2726
+ * Git facts about any folder (its checkout, primary checkout, common dir, remotes, and git's
2727
+ * linked-worktree registry), read by the host asynchronously and under a time limit. Always
2728
+ * present and ungated, like {@link paths}. Use it instead of reading `.git` files with `node:fs`,
2729
+ * which blocks the daemon; see {@link KayAppRepos}.
2730
+ */
2731
+ readonly repos: KayAppRepos;
2701
2732
  /**
2702
2733
  * Read/arrange access to the live desktop workspace. Present whenever the host has a workspace
2703
2734
  * (a headless daemon may not); absence is a host fact, never a grant signal. Reading the snapshot
@@ -2872,6 +2903,102 @@ export declare interface KayAppRegisterUriModuleOptions {
2872
2903
  readonly scope: "app";
2873
2904
  }
2874
2905
 
2906
+ /** The git checkout that encloses a folder. Paths are absolute and not resolved through symlinks. */
2907
+ export declare interface KayAppRepoFacts {
2908
+ /** The directory holding the `.git` entry: the nearest checkout at or above the folder. */
2909
+ readonly repoRoot: string;
2910
+ /**
2911
+ * This checkout's own git directory: `<repoRoot>/.git`, or the target of a `.git` pointer file
2912
+ * (a linked worktree or a submodule). `null` when the pointer could not be read.
2913
+ */
2914
+ readonly gitDir: string | null;
2915
+ /**
2916
+ * The git directory every worktree of the repository shares, as `git rev-parse
2917
+ * --git-common-dir` names it; for a submodule, its own `.git/modules/<name>`. `null` when the
2918
+ * host cannot place it (an unreadable pointer or `commondir` file).
2919
+ */
2920
+ readonly commonDir: string | null;
2921
+ /**
2922
+ * The primary checkout that owns a linked worktree; `repoRoot` itself for an ordinary
2923
+ * checkout, a submodule, or a worktree of a bare repository. Every worktree of one repository
2924
+ * shares this value.
2925
+ */
2926
+ readonly primaryCheckoutRoot: string;
2927
+ /** A stable name for the repository: the primary checkout's directory name. */
2928
+ readonly displayName: string;
2929
+ /** Every remote in the common `config`, in file order. Empty when the config is unreadable. */
2930
+ readonly remotes: readonly KayAppGitRemote[];
2931
+ /**
2932
+ * The `origin` remote. `absent` means the config was read and has no origin; `unavailable`
2933
+ * means the config could not be read, which is not the same answer and is usually temporary.
2934
+ */
2935
+ readonly origin: {
2936
+ readonly kind: "origin";
2937
+ readonly url: string;
2938
+ readonly identity: string;
2939
+ } | {
2940
+ readonly kind: "absent";
2941
+ } | {
2942
+ readonly kind: "unavailable";
2943
+ readonly detail: string;
2944
+ };
2945
+ }
2946
+
2947
+ export declare interface KayAppRepoReadOptions {
2948
+ /**
2949
+ * How old a cached answer may be, in milliseconds. `0` asks the host to read again; `Infinity`
2950
+ * accepts any cached answer. Default 5000.
2951
+ */
2952
+ readonly maxAgeMs?: number;
2953
+ }
2954
+
2955
+ /** What the host knows about one folder. */
2956
+ export declare type KayAppRepoResolution = {
2957
+ readonly kind: "resolved";
2958
+ /** The folder asked about, as an absolute path. */
2959
+ readonly folder: string;
2960
+ /**
2961
+ * Whether the folder itself exists. A folder that is gone still reports the checkout that
2962
+ * encloses where it was, so a deleted subdirectory keeps its repository.
2963
+ */
2964
+ readonly folderExists: boolean;
2965
+ /** `null` when no checkout encloses the folder. */
2966
+ readonly repo: KayAppRepoFacts | null;
2967
+ } | {
2968
+ /**
2969
+ * The host could not answer: `timeout` when the reads took longer than the host's time limit
2970
+ * (the folder may be on an unresponsive drive), `error` when they failed unexpectedly. Not
2971
+ * cached, so asking again later may succeed. Treat it as "do not know", never as "no repo".
2972
+ */
2973
+ readonly kind: "unknown";
2974
+ readonly folder: string;
2975
+ readonly reason: "timeout" | "error";
2976
+ readonly detail: string;
2977
+ };
2978
+
2979
+ /**
2980
+ * `app.repos`: git facts about folders, read by the host. Added in SDK 0.12.4: a plugin that calls
2981
+ * it declares `compatibility.sdk` `^0.12.4`, so an older Bonsai refuses a directory or repository
2982
+ * plugin at load instead of the call failing.
2983
+ *
2984
+ * Both members never reject and do not use `this`, so they are safe to destructure. Call them
2985
+ * where you need the answer, not inside `activate()`.
2986
+ */
2987
+ export declare interface KayAppRepos {
2988
+ /**
2989
+ * The checkout that encloses `folder` (an absolute path), or why the host could not tell. A
2990
+ * relative path answers `unknown`.
2991
+ */
2992
+ readonly resolve: (folder: string, options?: KayAppRepoReadOptions) => Promise<KayAppRepoResolution>;
2993
+ /**
2994
+ * The working directories of the linked worktrees git has registered under `commonDir`
2995
+ * (`<commonDir>/worktrees/<name>/gitdir`), including ones whose directory has since been
2996
+ * deleted. Empty when there are none; `undefined` when the registry or one of its entries could
2997
+ * not be read, or not in time. `undefined` is not cached; treat it as "do not know".
2998
+ */
2999
+ readonly linkedWorktrees: (commonDir: string, options?: KayAppRepoReadOptions) => Promise<readonly string[] | undefined>;
3000
+ }
3001
+
2875
3002
  /** What the plugin is asking for, and what it intends to do with it. */
2876
3003
  export declare interface KayAppResolveCredentialRequest {
2877
3004
  /** An auth service this plugin's own manifest declares, by its LOCAL id. */
@@ -3890,7 +4017,8 @@ export declare interface KayAppStorage {
3890
4017
  export declare type KayAppThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
3891
4018
 
3892
4019
  /**
3893
- * `app.time`: host facts about time. Added in SDK 0.12.1: a plugin that calls it declares
4020
+ * `app.time` / `api.time`: host facts about time. Added to `kay.app` in SDK 0.12.1 and to
4021
+ * `kay.agent` with the calendar zone-of-record work: a plugin that calls it declares
3894
4022
  * `compatibility.sdk` `^0.12.1`, so an older Bonsai refuses a directory or repository plugin at
3895
4023
  * load instead of the call failing.
3896
4024
  */
@@ -3923,12 +4051,16 @@ export declare interface KayAppTime {
3923
4051
  }
3924
4052
 
3925
4053
  /**
3926
- * The plugin-facing `kay.app` time contract: the daemon's clock and the time zone the user is in,
4054
+ * The plugin-facing time contract: the daemon's clock and the time zone the user is in,
3927
4055
  * read together in one call.
3928
4056
  *
3929
4057
  * Ungated and always present, like `app.paths`. It confers no authority: a plugin runs in the
3930
- * daemon's process and can already read the clock. What `app.time` adds is the daemon's answer
4058
+ * daemon's process and can already read the clock. What the door adds is the daemon's answer
3931
4059
  * for the USER's zone, and a label for the case where that answer is unknown.
4060
+ *
4061
+ * This is a leaf module. It lives beside both target SDKs (rather than inside `kay-app`) so the
4062
+ * `kay.agent` SDK can expose the same door without importing the `kay.app` surface, which pulls
4063
+ * that surface into every agent-facing bundle.
3932
4064
  */
3933
4065
  /** One reading of the daemon's clock and the user's time zone. Each call returns a new object. */
3934
4066
  export declare interface KayAppTimeSnapshot {
@@ -5122,7 +5254,7 @@ declare interface ResolveTokenSuccess {
5122
5254
  * failure.
5123
5255
  */
5124
5256
  /** Provider-neutral factual category for a failed model run. */
5125
- declare type RunFailureReason = "auth" | "budget_exceeded" | "content_blocked" | "context_overflow" | "invalid_request" | "model_unavailable" | "network" | "overloaded" | "quota_exhausted" | "rate_limit" | "safeguard_block" | "seat_unpaid" | "server_error" | "stream_interrupted" | "timeout" | "unknown";
5257
+ declare type RunFailureReason = "auth" | "content_blocked" | "context_overflow" | "invalid_request" | "model_unavailable" | "network" | "overloaded" | "quota_exhausted" | "rate_limit" | "safeguard_block" | "seat_unpaid" | "server_error" | "stream_interrupted" | "timeout" | "unknown";
5126
5258
 
5127
5259
  declare type SchemaInput = z.ZodType | JsonSchema;
5128
5260
 
@@ -6388,6 +6520,18 @@ declare interface UriOperationDescription {
6388
6520
  readonly target: string;
6389
6521
  /** Model-authored content; the host flattens, budgets, elides and bounds it. */
6390
6522
  readonly quote?: string;
6523
+ /** Full, render-only detail. Hosts must not turn a rich preview into one-line approval. */
6524
+ readonly preview?: {
6525
+ readonly title: string;
6526
+ readonly description?: string;
6527
+ readonly fields?: readonly {
6528
+ readonly label: string;
6529
+ readonly value: string;
6530
+ readonly oldValue?: string;
6531
+ }[];
6532
+ };
6533
+ /** True only when every displayed fact comes from the input, independent of the executing account. */
6534
+ readonly accountIndependent?: true;
6391
6535
  /** What this description accounts for; the host refuses undeclared input. */
6392
6536
  readonly accounts: {
6393
6537
  /** The payload actually read by the module, after its own decoding. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yaklabs-ai/plugin-sdk",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "description": "The Kay plugin SDK — types and helpers for authoring a Kay directory plugin.",
5
5
  "author": "YakLabs",
6
6
  "type": "module",
@@ -51,7 +51,8 @@
51
51
  "skills"
52
52
  ],
53
53
  "dependencies": {
54
- "zod": "^4.0.0"
54
+ "zod": "^4.0.0",
55
+ "yaml": "^2.9.0"
55
56
  },
56
57
  "publishConfig": {
57
58
  "registry": "https://registry.npmjs.org",
@@ -55,6 +55,31 @@ and symlinking `node_modules/@kay` into the copy does not rescue it either: pnpm
55
55
  layout does not expose workspace packages that way. The scaffold exists precisely so this
56
56
  cannot happen.
57
57
 
58
+ ## Check the manifest before you wait for a load
59
+
60
+ ```
61
+ pnpm plugin:check ~/.kay/plugins/widgets
62
+ ```
63
+
64
+ Run from the root of a Kay checkout, against the plugin directory. It runs the daemon's OWN
65
+ manifest schema and contract resolver (imported from `@kay/plugin-protocol`, not re-implemented),
66
+ so it answers "would Kay accept this manifest?" in about a second instead of after a load cycle.
67
+ Run it after every manifest edit, and before concluding a plugin is broken for some deeper reason.
68
+
69
+ It prints the resolved global contribution ids, exits non-zero when the plugin would be refused,
70
+ and separates the two outcomes that look alike from outside:
71
+
72
+ - **Refused outright** — schema-invalid manifest, a reserved `kay.*` / `herd.*` id, a declared
73
+ path escaping the plugin directory, or contributions that do not resolve. Every contribution
74
+ shares ONE flat `<namespace>.<localId>` space ACROSS kinds, so a tool and an endpoint both
75
+ called `inspect` collide and the whole plugin is refused (#8936).
76
+ - **Loads, minus something** — a retired `compatibility` / `permissions.byTarget` key the parser
77
+ strips silently, a `toolName` that is not the namespace-derived name (that tool is excluded and
78
+ nothing tells the user), a missing entrypoint or skill file.
79
+
80
+ It reads the manifest only. A clean verdict says nothing about `activate()` throwing, the
81
+ disclosure audit, or an id another installed plugin already claims.
82
+
58
83
  ## The SDK resolution contract
59
84
 
60
85
  - Plugin source imports `@kay/plugin-sdk/<subpath>` as a **bare specifier**
@@ -123,8 +148,10 @@ edited.
123
148
  IS a git repository: a version is a commit, and a directory without git backing fails to
124
149
  load (the plugin's row says exactly that, with the fix). So after writing the files, commit
125
150
  them: `git init && git add -A && git commit -m "adopt plugin"` inside the plugin directory.
126
- The daemon watches the root, so the plugin appears by itself within a couple of seconds, no
127
- verb called and no restart; the source refresh verb forces the walk if you want it now.
151
+ The daemon watches the root and, under the watch, checks each plugin's Git HEAD every 5
152
+ seconds, so it notices the plugin by itself within about 10 seconds even when file events
153
+ are not arriving: no verb called and no restart. The source refresh verb forces the walk if
154
+ you want it now.
128
155
 
129
156
  The root is `<home>/.kay/plugins`, and **`<home>` is the running instance's home**: a dev
130
157
  app launched against its own state root has its own plugins root, not your real
@@ -144,9 +171,13 @@ list` names the exact local root — because writing into the wrong one produces
144
171
  commit** identifies the serving snapshot. Try new tools in a fresh session;
145
172
  an existing session may still hold its previous generation.
146
173
 
147
- A failed committed update keeps the last working revision and shows the load
148
- diagnostic. Fix and commit the repair. Explicit source refresh retries discovery
149
- or a failed read; it never activates uncommitted files. Keep runtime data in
174
+ Kay notices a commit within about 10 seconds even with no file events, and acquiring it may
175
+ take up to 30 seconds more. A **Running commit** still behind `git rev-parse --short HEAD` a
176
+ minute later, with a diagnostic on the plugin's row, is a failed read: fix what it names
177
+ instead of refreshing and waiting. Without a diagnostic, refresh the `local` source. A failed committed
178
+ update keeps the last working revision and shows that diagnostic. Fix and commit the
179
+ repair. Explicit source refresh skips the wait and retries a failed read; a commit
180
+ does not normally need it, and it never activates uncommitted files. Keep runtime data in
150
181
  host-owned plugin storage, not beside the snapshot's source files.
151
182
 
152
183
  4. **Grant what the manifest asks for, or activation is refused.** Anything declared under
@@ -177,9 +208,10 @@ list` names the exact local root — because writing into the wrong one produces
177
208
  The commit is the activation boundary, so run these against the working tree first. Each catches
178
209
  a failure class the load would otherwise be the first thing to surface:
179
210
 
180
- 1. **Validate `kay-plugin.json` against `KayPluginManifestV1`** (from `@kay/plugin-protocol`),
181
- not just "it is valid JSON" — that schema is the gate the scan actually runs. Check every
182
- declared target's endpoints are namespaced correctly.
211
+ 1. **Run `pnpm plugin:check <dir>`** from a Kay checkout. It validates `kay-plugin.json` against
212
+ `KayPluginManifestV1` and resolves its contributions with the daemon's own resolver (both from
213
+ `@kay/plugin-protocol`), not just "it is valid JSON": that schema and resolver are the gate the
214
+ scan actually runs. A non-zero exit means the plugin would be refused.
183
215
  2. **Run the disclosure audit yourself**: `auditPluginDisclosure` from `@kay/plugin-sdk/disclosure`.
184
216
  It is not redundant with typecheck. A plugin shipped a `search` verb with no `verbDocs` entry
185
217
  that typechecked clean and would have failed the whole `kay.agent` target.
@@ -229,6 +261,8 @@ on the plugin's row (or on the plugin source's row, when the whole source found
229
261
  - **The disclosure audit is fail-closed** and runs on every directory plugin; a violation fails
230
262
  the whole `kay.agent` target. Run `auditPluginDisclosure` yourself before committing, per the
231
263
  pre-commit checks above.
264
+ - **Before any of the above, run `pnpm plugin:check <dir>`.** A manifest the daemon cannot
265
+ resolve produces a plugin that never appears, and the check names the reason without a load.
232
266
 
233
267
  ## Declaring a scheduled workflow
234
268