busa-cli 0.42.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -103,6 +103,21 @@ const LocaleSchema = z.enum({
103
103
  */
104
104
  const iStringRecordSchema = z.partialRecord(LocaleSchema, z.string());
105
105
  const iStringSchema = z.union([z.string(), iStringRecordSchema]).describe("i18n string");
106
+ /**
107
+ * Quick parse to string
108
+ *
109
+ * @param str
110
+ * @param lang default en
111
+ * @returns
112
+ */
113
+ const iStringParse = (str, lang = "en") => {
114
+ if (!str) return "";
115
+ if (typeof str === "string") return str;
116
+ const locales = Object.keys(str || {});
117
+ const result = str[locales.find((locale) => locale === lang) || locales.find((locale) => locale !== "en") || "en"];
118
+ if (result === void 0) console.warn(`iStringParse: ${JSON.stringify(str)} has no '${lang}' or other locales`);
119
+ return result || "";
120
+ };
106
121
  //#endregion
107
122
  //#region ../../packages/busabase-contract/src/contract/node-agent-prompt-schemas.ts
108
123
  /**
@@ -5614,6 +5629,26 @@ const TemplateManifestSchema = z.object({
5614
5629
  secrets: z.array(TemplateSecretSchema).default([]),
5615
5630
  requires: z.object({ airapp: z.boolean().optional() }).default({})
5616
5631
  });
5632
+ const TemplateRiskLevelSchema = z.enum([
5633
+ "gated-write",
5634
+ "local-write",
5635
+ "read-only",
5636
+ "sandbox"
5637
+ ]);
5638
+ /**
5639
+ * Normalize a declared `risk` string to a known level, or `undefined`.
5640
+ *
5641
+ * Deliberately never throws. The frontmatter field stays a free-form string
5642
+ * (see `SkillBusabaseMetadataSchema` below) so an author's typo or an
5643
+ * already-published package using a since-retired term (`"review-first"`, from
5644
+ * before this enum existed) fails soft — the card shows "undeclared" instead of
5645
+ * guessing, and the package still installs. Only `busabase-cli check` should
5646
+ * ever turn an unrecognized value into something the author sees and fixes.
5647
+ */
5648
+ const parseTemplateRisk = (raw) => {
5649
+ const result = TemplateRiskLevelSchema.safeParse(raw);
5650
+ return result.success ? result.data : void 0;
5651
+ };
5617
5652
  /**
5618
5653
  * `metadata.busabase` inside the root `SKILL.md`'s YAML frontmatter.
5619
5654
  *
@@ -5627,6 +5662,12 @@ const SkillBusabaseMetadataSchema = z.object({
5627
5662
  folderSlug: z.string().optional(),
5628
5663
  /** Resource keys the manual talks about; each must exist under `content/`. */
5629
5664
  resources: z.array(z.string()).default([]),
5665
+ /**
5666
+ * Free-form on purpose — see `parseTemplateRisk`. Validating this to the enum
5667
+ * here would turn a stranger's typo or a retired term into a hard parse
5668
+ * failure for the whole frontmatter, which is a worse outcome than a card
5669
+ * that cannot show a risk badge.
5670
+ */
5630
5671
  risk: z.string().optional()
5631
5672
  });
5632
5673
  /**
@@ -5704,208 +5745,6 @@ z.object({
5704
5745
  [TEMPLATE_SKILL_METADATA_KEY]: z.literal(true)
5705
5746
  });
5706
5747
  //#endregion
5707
- //#region ../../packages/busabase-package/src/frontmatter.ts
5708
- /**
5709
- * YAML frontmatter for doc nodes (§6.3): `name`, `description?`, `position?` above
5710
- * the markdown body.
5711
- *
5712
- * Writing is deterministic (§6.6): a fixed key order and LF endings, so re-exporting
5713
- * an unchanged doc is byte-identical.
5714
- */
5715
- const DELIMITER = "---";
5716
- /**
5717
- * The canonical on-disk body: LF endings, no leading or trailing blank lines. Applied
5718
- * on both read and write so the round trip is a fixed point — without it, the blank
5719
- * line the writer puts after the closing `---` and the file's trailing newline would
5720
- * accumulate into the body on every export→install→export cycle.
5721
- *
5722
- * Leading/trailing blank lines around a doc body are therefore normalized away on the
5723
- * first export; internal blank lines are untouched.
5724
- */
5725
- const normalizeDocBody = (body) => body.replaceAll("\r\n", "\n").replace(/^\n+/, "").replace(/\n+$/, "");
5726
- /** Split `---\n<yaml>\n---\n<body>`. A doc with no frontmatter is all body. */
5727
- const parseFrontmatter = (text, filePath) => {
5728
- const normalized = text.replaceAll("\r\n", "\n");
5729
- if (!normalized.startsWith(`${DELIMITER}\n`)) return {
5730
- data: {},
5731
- body: normalizeDocBody(normalized)
5732
- };
5733
- const end = normalized.indexOf(`\n${DELIMITER}`, 3);
5734
- if (end === -1) throw new Error(`${filePath} opens YAML frontmatter with "---" but never closes it. Add a closing "---" line.`);
5735
- const rawYaml = normalized.slice(4, end);
5736
- const body = normalizeDocBody(normalized.slice(end + 1 + 3));
5737
- let data;
5738
- try {
5739
- data = parse(rawYaml);
5740
- } catch (error) {
5741
- throw new Error(`${filePath} has invalid YAML frontmatter: ${error.message}`);
5742
- }
5743
- if (data === null || data === void 0) return {
5744
- data: {},
5745
- body
5746
- };
5747
- if (typeof data !== "object" || Array.isArray(data)) throw new Error(`${filePath} frontmatter must be a YAML mapping (key: value pairs).`);
5748
- return {
5749
- data,
5750
- body
5751
- };
5752
- };
5753
- /** Serialize a doc to `---\n<frontmatter>\n---\n<body>` with a trailing newline. */
5754
- const serializeDoc = (fields, body) => {
5755
- const data = { name: fields.name };
5756
- if (fields.description) data.description = fields.description;
5757
- if (fields.position !== void 0) data.position = fields.position;
5758
- const yaml = stringify(data, { lineWidth: 0 }).replace(/\n$/, "");
5759
- return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
5760
- };
5761
- //#endregion
5762
- //#region ../../packages/busabase-package/src/audit.ts
5763
- /**
5764
- * Is this package **fit to publish**?
5765
- *
5766
- * Deliberately a different question from {@link validateTemplate}, and the split is
5767
- * the point rather than an organisational preference:
5768
- *
5769
- * - `validateTemplate` **classifies**. Its verdict changes what install *does* — a
5770
- * Skill node, ownership stamps and a sample merge, versus a plain package install.
5771
- * Four consumers must agree on it or the catalog lies about what an install does,
5772
- * so it stays small and stable.
5773
- * - `auditPackage` / `auditSkill` **advise**. Nothing behaves differently based on
5774
- * their verdict; they exist for the author and the reviewer, and are free to grow.
5775
- *
5776
- * The practical consequence: a declared screenshot that is not on disk belongs here,
5777
- * never in `validateTemplate`. Promoting it there would stop a template installing
5778
- * over a missing image, which is the wrong lever entirely.
5779
- *
5780
- * Nothing here re-checks what `validateTemplate` already decides (declared resources,
5781
- * the sample-row ceiling, name agreement between manifest and manual, an unambiguous
5782
- * primary AirApp). Run both; they compose.
5783
- *
5784
- * Purely static: no install, no network, and none of the package's own code is
5785
- * executed — safe to run in CI over a pull request from someone you do not know.
5786
- */
5787
- /**
5788
- * A materialized workspace id: busabase-core's `id()` is a three-letter prefix, the
5789
- * creation time in base36, then seven random characters.
5790
- *
5791
- * The lookahead requiring a digit is what keeps ordinary words out — `recommendation`
5792
- * has the right prefix and the wrong shape. A real id always carries its timestamp.
5793
- */
5794
- const WORKSPACE_ID = /(?<![0-9a-z])(?:nod|bse|viw|rec|crq|ast|com|fom|shr|fvl|opr|cmt|rev|whd|aud)(?=[0-9a-z]{15}(?![0-9a-z]))(?=[0-9a-z]*\d)[0-9a-z]{15}/;
5795
- const TEXT_EXTENSIONS = /\.(?:js|mjs|cjs|ts|mts|cts|json|ndjson|md|html|css|ya?ml|txt)$|(?:^|\/)\.env(?:\.|$)/;
5796
- /**
5797
- * Files that exist to *forbid* a string necessarily contain it. An AirApp's own
5798
- * `scripts/check.mjs` asserts the obsolete API prefix is absent, and a test fixture
5799
- * spells out the credential it proves gets rejected — scanning those turns a
5800
- * correctly-guarded package into a failure.
5801
- */
5802
- const IS_ASSERTION_CODE = /(?:^|\/)(?:scripts\/check\.mjs|tests?\/|[^/]*\.test\.[cm]?[jt]s)$/;
5803
- /** Lockfiles carry integrity hashes that are not ours to interpret. */
5804
- const NOT_SOURCE = /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/;
5805
- const relativePaths = (files, root = "") => {
5806
- const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
5807
- const out = [];
5808
- for (const key of files.keys()) if (!prefix || key.startsWith(prefix)) out.push(key.slice(prefix.length));
5809
- return out;
5810
- };
5811
- const readText = (files, root, relative) => {
5812
- const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
5813
- return files.get(`${prefix}${relative}`)?.toString("utf8");
5814
- };
5815
- /**
5816
- * Audit the package layer: identity, the assets the catalog promises, and anything
5817
- * that must never be published.
5818
- *
5819
- * `files` is the flat map `readPackageTree` was given, not the tree. That is load-bearing:
5820
- * `assets/screenshots/` holds no nodes and so never reaches the tree, and "the manifest
5821
- * promises a screenshot that does not exist" is exactly a file-level question.
5822
- */
5823
- const auditPackage = (tree, files, options = {}) => {
5824
- const findings = [];
5825
- const error = (rule, message) => findings.push({
5826
- severity: "error",
5827
- rule,
5828
- message
5829
- });
5830
- const warn = (rule, message) => findings.push({
5831
- severity: "warning",
5832
- rule,
5833
- message
5834
- });
5835
- const root = options.root ?? "";
5836
- const paths = relativePaths(files, root);
5837
- const present = new Set(paths);
5838
- if (options.directoryName !== void 0 && options.directoryName !== tree.manifest.name) error("package/identity", `Directory is "${options.directoryName}" but busabase.json declares "${tree.manifest.name}". The directory name is the package's identity, not a label beside it.`);
5839
- for (const screenshot of tree.manifest.template?.screenshots ?? []) if (!present.has(screenshot)) error("package/screenshot-missing", `busabase.json declares screenshot "${screenshot}", which is not in the package. The Template Center card would render a broken image.`);
5840
- for (const relative of paths) {
5841
- const base = relative.slice(relative.lastIndexOf("/") + 1);
5842
- if (/^\.env(?!\.example$)(?:\.|$)/.test(base)) error("package/dotenv", `${relative} is part of the package. Credentials belong in Vault, never in something people install.`);
5843
- if (/^(?:id_rsa|id_ed25519)$/.test(base) || /\.(?:pem|p12|pfx|keystore)$/.test(base)) error("package/key-material", `${relative} looks like key material and must not be published.`);
5844
- if (/(?:^|\/)(?:node_modules|dist|build)\//.test(relative)) warn("package/build-output", `${relative} looks like installed or built output. Ship source; the installer builds nothing.`);
5845
- }
5846
- for (const relative of paths) {
5847
- if (!TEXT_EXTENSIONS.test(relative)) continue;
5848
- if (NOT_SOURCE.test(relative) || IS_ASSERTION_CODE.test(relative)) continue;
5849
- const source = readText(files, root, relative);
5850
- if (source === void 0) continue;
5851
- const found = WORKSPACE_ID.exec(source);
5852
- if (found) error("package/workspace-ids", `${relative} contains "${found[0]}", a materialized workspace id. Node, Base and Space ids are whoever last ran setup's runtime state — publishing them ships one person's workspace layout to everyone.`);
5853
- }
5854
- return findings;
5855
- };
5856
- /**
5857
- * Audit the Skill layer: is the manual one an agent can actually act on?
5858
- *
5859
- * Runs for any directory carrying a root `SKILL.md` — package or not, template or not.
5860
- * It reports nothing at all when there is no manual: a package without one is a
5861
- * perfectly legitimate thing to publish, not a Skill that failed.
5862
- *
5863
- * The honest limit: a checker can tell you the frontmatter parses and the files the
5864
- * manual points at exist. It cannot tell you the manual is *true*. Everything in
5865
- * `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
5866
- * confident, wrong sentence about what a table means.
5867
- */
5868
- const auditSkill = (files, options = {}) => {
5869
- const findings = [];
5870
- const error = (rule, message) => findings.push({
5871
- severity: "error",
5872
- rule,
5873
- message
5874
- });
5875
- const warn = (rule, message) => findings.push({
5876
- severity: "warning",
5877
- rule,
5878
- message
5879
- });
5880
- const root = options.root ?? "";
5881
- const source = readText(files, root, PACKAGE_SKILL_ENTRY);
5882
- if (source === void 0) return findings;
5883
- let body = "";
5884
- try {
5885
- const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
5886
- body = parsed.body;
5887
- const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
5888
- if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
5889
- else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
5890
- } catch (cause) {
5891
- error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
5892
- return findings;
5893
- }
5894
- const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
5895
- if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
5896
- const present = new Set(relativePaths(files, root));
5897
- const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
5898
- const seen = /* @__PURE__ */ new Set();
5899
- for (const match of body.matchAll(sidecar)) {
5900
- const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
5901
- if (seen.has(target)) continue;
5902
- seen.add(target);
5903
- if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
5904
- }
5905
- return findings;
5906
- };
5907
- const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
5908
- //#endregion
5909
5748
  //#region ../../packages/busabase-contract/src/contract/auto-merge.ts
5910
5749
  /**
5911
5750
  * `autoMerge` on a destructive-but-reversible operation.
@@ -6291,12 +6130,7 @@ z.object({
6291
6130
  z.object({
6292
6131
  parentId: z.string().nullable().optional().describe("Node to start from. Omit or null to start from the space root."),
6293
6132
  depth: z.coerce.number().int().min(1).max(5).optional().describe("How many levels beneath the start point to eagerly include (default 2 once either field is set). Capped at 5."),
6294
- /**
6295
- * `active` (default) walks the live tree. `archived` returns the flat set of
6296
- * soft-archived nodes for the Trash view — no `parentId`/`depth` walk, since
6297
- * archived nodes are shown as a list, not a tree.
6298
- */
6299
- status: z.enum(["active", "archived"]).optional().default("active"),
6133
+ status: z.enum(["active", "archived"]).optional().default("active").describe("`active` walks the live TREE. `archived` returns a FLAT list of soft-archived nodes (the Trash view) with no parent/depth walk — so the response shape you can rely on differs between the two, not just the rows."),
6300
6134
  /**
6301
6135
  * Narrow to specific node types and return a FLAT list of lightweight node
6302
6136
  * summaries (`children: []`) instead of walking the tree. This is what
@@ -6323,8 +6157,8 @@ z.object({
6323
6157
  types: z.union([z.array(z.enum(NODE_TYPES)), z.enum(NODE_TYPES)]).transform((value) => Array.isArray(value) ? value : [value]).optional().describe("Return a flat list of lightweight summaries for these node types instead of the tree. Read one node's full detail with GET /nodes/{nodeId}.")
6324
6158
  }).optional();
6325
6159
  z.object({
6326
- nodeId: z.string(),
6327
- potentialAncestorId: z.string()
6160
+ nodeId: z.string().describe("The node walked UPWARDS from — the possible descendant."),
6161
+ potentialAncestorId: z.string().describe("The node looked for on the way up. Answers \"is `nodeId` inside this one?\", not the reverse — swapping the two silently returns the wrong answer rather than an error.")
6328
6162
  });
6329
6163
  z.object({ isDescendant: z.boolean() });
6330
6164
  z.object({
@@ -6379,8 +6213,8 @@ z.object({
6379
6213
  agentPrompts: customAgentPromptsSchema.nullable()
6380
6214
  });
6381
6215
  z.object({
6382
- query: z.string().min(1),
6383
- limit: z.coerce.number().int().min(1).max(50).optional().default(20)
6216
+ query: z.string().min(1).describe("Matched against node NAMES only. Use `/api/v1/search` to search content."),
6217
+ limit: z.coerce.number().int().min(1).max(50).optional().default(20).describe("Results to return. Capped at 50 here, unlike most listings' 100.")
6384
6218
  });
6385
6219
  z.object({
6386
6220
  id: z.string(),
@@ -6571,8 +6405,8 @@ z.object({
6571
6405
  unreadCount: z.number().int()
6572
6406
  });
6573
6407
  z.object({
6574
- page: z.number().int().min(1).optional().default(1),
6575
- pageSize: z.number().int().min(1).max(100).optional().default(50)
6408
+ page: z.number().int().min(1).optional().default(1).describe("1-indexed, not 0-indexed."),
6409
+ pageSize: z.number().int().min(1).max(100).optional().default(50).describe("Mentions per page. Capped at 100.")
6576
6410
  });
6577
6411
  z.object({
6578
6412
  /** Stamps every unread mention row this caller has on that comment. */
@@ -6810,8 +6644,8 @@ z.object({
6810
6644
  reason: z.string().optional()
6811
6645
  });
6812
6646
  z.object({
6813
- subjectType: commentSubjectTypeSchema,
6814
- subjectId: z.string().min(1)
6647
+ subjectType: commentSubjectTypeSchema.describe("What the comment thread hangs off, which decides how `subjectId` is interpreted."),
6648
+ subjectId: z.string().min(1).describe("The subject's id, interpreted according to `subjectType`.")
6815
6649
  }).extend({
6816
6650
  authorId: z.string().optional().default("local-admin"),
6817
6651
  body: z.string().trim().min(1),
@@ -6822,29 +6656,28 @@ z.object({
6822
6656
  */
6823
6657
  mentions: z.array(commentMentionInputSchema).optional().default([])
6824
6658
  });
6825
- z.object({ limit: z.coerce.number().int().min(1).max(100).optional().default(50) }).optional().default({ limit: 50 });
6826
- z.object({ status: z.enum(["active", "archived"]).optional().default("active") });
6659
+ z.object({ limit: z.coerce.number().int().min(1).max(100).optional().default(50).describe("Rows to return, most recent first. Capped at 100; this listing has no cursor.") }).optional().default({ limit: 50 });
6660
+ z.object({ status: z.enum(["active", "archived"]).optional().default("active").describe("`active` (default) or the soft-archived set. Archived rows have the SAME shape as live ones — this is a predicate, not a different resource.") });
6827
6661
  z.object({
6828
- limit: z.coerce.number().int().min(1).max(100).optional().default(50),
6829
- /** Opaque base64 cursor (`createdAt|id`) for keyset pagination. */
6830
- cursor: z.string().optional(),
6831
- status: z.array(changeRequestStatusSchema).optional(),
6832
- mine: z.boolean().optional(),
6833
- affectsNodeId: z.string().min(1).optional()
6662
+ limit: z.coerce.number().int().min(1).max(100).optional().default(50).describe("Change requests per page. Capped at 100; ask for the next page with `cursor`."),
6663
+ cursor: z.string().optional().describe("Opaque page cursor: pass back the `nextCursor` from the previous response. Do not construct or parse it."),
6664
+ status: z.array(changeRequestStatusSchema).optional().describe("Keep only these statuses. Omitting it returns every status, not just open ones."),
6665
+ mine: z.boolean().optional().describe("Only change requests CREATED by the acting user — not ones awaiting their review."),
6666
+ affectsNodeId: z.string().min(1).optional().describe("Only change requests whose target, or any of their operations, touches this node — including Base-backed nodes. To ask whether a resource already has an unfinished change request, pass this with `limit: 1` rather than paging the space: an empty result is conclusive.")
6834
6667
  }).optional().default({ limit: 50 });
6835
6668
  z.object({
6836
6669
  changeRequests: z.array(changeRequestSchema),
6837
6670
  nextCursor: z.string().nullable()
6838
6671
  });
6839
6672
  const changeRequestPageInputShape = {
6840
- page: z.coerce.number().int().min(1).optional().default(1),
6841
- pageSize: z.coerce.number().int().min(1).max(100).optional().default(50),
6842
- status: z.array(changeRequestStatusSchema).optional(),
6843
- mine: z.boolean().optional()
6673
+ page: z.coerce.number().int().min(1).optional().default(1).describe("1-indexed, not 0-indexed."),
6674
+ pageSize: z.coerce.number().int().min(1).max(100).optional().default(50).describe("Change requests per page. Capped at 100."),
6675
+ status: z.array(changeRequestStatusSchema).optional().describe("Keep only these statuses. Omitting it returns every status, not just open ones."),
6676
+ mine: z.boolean().optional().describe("Only change requests CREATED by the acting user — not ones awaiting their review.")
6844
6677
  };
6845
6678
  z.object({
6846
6679
  ...changeRequestPageInputShape,
6847
- affectsNodeId: z.string().min(1).optional()
6680
+ affectsNodeId: z.string().min(1).optional().describe("Only change requests whose target, or any of their operations, touches this node — including Base-backed nodes.")
6848
6681
  }).optional().default({
6849
6682
  page: 1,
6850
6683
  pageSize: 50
@@ -6876,9 +6709,9 @@ const SEARCH_SOURCES = [
6876
6709
  "nodes"
6877
6710
  ];
6878
6711
  z.object({
6879
- query: z.string().default(""),
6880
- limit: z.coerce.number().int().min(1).max(100).optional().default(20),
6881
- offset: z.coerce.number().int().min(0).optional().default(0),
6712
+ query: z.string().default("").describe("Full-text query. An empty string matches nothing."),
6713
+ limit: z.coerce.number().int().min(1).max(100).optional().default(20).describe("Results per page. Capped at 100; note the default is 20, not 50."),
6714
+ offset: z.coerce.number().int().min(0).optional().default(0).describe("0-indexed skip count. This endpoint pages by offset, not by cursor."),
6882
6715
  /**
6883
6716
  * Restrict which content this call searches. Omitted means all three
6884
6717
  * (unchanged behavior for every caller before this parameter existed).
@@ -6888,7 +6721,7 @@ z.object({
6888
6721
  * (`?sources=records&sources=files`) becomes an array. Accept both shapes
6889
6722
  * and normalize to an array.
6890
6723
  */
6891
- sources: z.union([z.array(z.enum(SEARCH_SOURCES)), z.enum(SEARCH_SOURCES)]).transform((value) => Array.isArray(value) ? value : [value]).optional()
6724
+ sources: z.union([z.array(z.enum(SEARCH_SOURCES)), z.enum(SEARCH_SOURCES)]).transform((value) => Array.isArray(value) ? value : [value]).optional().describe("Restrict which content is searched. Omitting it searches ALL sources. Repeat the parameter to pass several (`?sources=records&sources=files`); a single occurrence is accepted as a bare value.")
6892
6725
  });
6893
6726
  const authSpaceSchema = z.object({
6894
6727
  id: z.string(),
@@ -7140,9 +6973,27 @@ const PACKAGE_MAX_FILE_COUNT = 5e3;
7140
6973
  const PACKAGE_MAX_RECORDS_PER_BASE = 5e4;
7141
6974
  const PackageManifestSchema = z.object({
7142
6975
  format: z.literal(PACKAGE_FORMAT),
7143
- /** Default `--into-folder` name. Not the repo name — a repo can host many packages. */
6976
+ /**
6977
+ * Default `--into-folder` name. Not the repo name — a repo can host many
6978
+ * packages. This is the package's IDENTITY, not a label: it must equal the
6979
+ * on-disk directory name and the SKILL.md frontmatter name (enforced by
6980
+ * `audit.ts`'s "one identity" rule), and it is used as a slug — CLI sort,
6981
+ * `suggestSlug()`, the Dashboard's `/templates/:name` route, and the
6982
+ * install-target folder name all key off it verbatim. Deliberately NOT an
6983
+ * iString: none of those call sites expect a locale-keyed object, and
6984
+ * "the directory name is the package's identity, not a label beside it"
6985
+ * (audit.ts) is exactly the argument against ever making it one. A
6986
+ * human-facing translated title belongs in `description` below, not here.
6987
+ */
7144
6988
  name: z.string().min(1),
7145
- description: z.string().default(""),
6989
+ /**
6990
+ * The one-line blurb shown on a template's catalog card, and (via
6991
+ * `layout-read.ts`) the description on the Folder node created at install
6992
+ * time — Folder/node descriptions are already iString-capable text
6993
+ * columns, so this single field legitimately serves both. Plain string
6994
+ * stays valid forever; nothing that already writes a bare string breaks.
6995
+ */
6996
+ description: iStringSchema.default(""),
7146
6997
  /**
7147
6998
  * Informational / reserved for a future marketplace index. The real version pin
7148
6999
  * is always the git ref in the install URL.
@@ -7161,19 +7012,40 @@ const PackageManifestSchema = z.object({
7161
7012
  */
7162
7013
  template: TemplateManifestSchema.optional()
7163
7014
  });
7015
+ /**
7016
+ * Scenario Agent prompts for the node this sidecar describes — what a person can
7017
+ * ask an agent to DO with it, carried so it survives export → install.
7018
+ *
7019
+ * Same schema the node's own column and `busabase-cli nodes set-agent-prompts`
7020
+ * validate against (50 per node, 80 chars per label, 8 KiB per body per locale),
7021
+ * so a package cannot smuggle in a list the API would reject.
7022
+ *
7023
+ * Optional and absent by default: a package written before this field, or one
7024
+ * whose author wrote no prompts, is unchanged on disk and installs exactly as
7025
+ * before — the nodes then show their node type's default prompts, which is the
7026
+ * behaviour every package had until now.
7027
+ *
7028
+ * Distinct from `busabase.json`'s template-level `agentPrompts`: that one is a
7029
+ * flat list of strings for the Template Center card and the install-time "Ask
7030
+ * agent" action, about the template as a whole. This one is per node, structured,
7031
+ * and becomes that node's prompts in the dialog after install.
7032
+ */
7033
+ const packageNodeAgentPromptsSchema = customAgentPromptsSchema.optional();
7164
7034
  /** `_node.json` — marks a directory as a verbatim-content file-tree node. */
7165
7035
  const PackageFileTreeNodeMetaSchema = z.object({
7166
7036
  type: z.enum(PACKAGE_FILE_TREE_NODE_TYPES),
7167
7037
  name: z.string().min(1),
7168
7038
  description: z.string().default(""),
7169
- position: z.number().int().optional()
7039
+ position: z.number().int().optional(),
7040
+ agentPrompts: packageNodeAgentPromptsSchema
7170
7041
  });
7171
7042
  /** `_folder.json` — optional; also how an empty folder survives git. */
7172
7043
  const PackageFolderMetaSchema = z.object({
7173
7044
  /** Defaults to the humanized slug when absent. */
7174
7045
  name: z.string().optional(),
7175
7046
  description: z.string().default(""),
7176
- position: z.number().int().optional()
7047
+ position: z.number().int().optional(),
7048
+ agentPrompts: packageNodeAgentPromptsSchema
7177
7049
  });
7178
7050
  /** `<filename>.node.json` — optional display metadata for a `file` node. */
7179
7051
  const PackageFileNodeMetaSchema = PackageFolderMetaSchema;
@@ -7198,7 +7070,8 @@ const PackageDocAssetMetaSchema = z.object({
7198
7070
  const PackageDocFrontmatterSchema = z.object({
7199
7071
  name: z.string().min(1),
7200
7072
  description: z.string().default(""),
7201
- position: z.number().int().optional()
7073
+ position: z.number().int().optional(),
7074
+ agentPrompts: packageNodeAgentPromptsSchema
7202
7075
  });
7203
7076
  /**
7204
7077
  * Field options as carried by the package. Mirrors the API's `fieldOptionsSchema`
@@ -7318,7 +7191,8 @@ const PackageBaseSchema = z.object({
7318
7191
  requiredApprovals: z.number()
7319
7192
  }).optional(),
7320
7193
  fields: z.array(PackageBaseFieldSchema).default([]),
7321
- views: z.array(PackageViewSchema).default([])
7194
+ views: z.array(PackageViewSchema).default([]),
7195
+ agentPrompts: packageNodeAgentPromptsSchema
7322
7196
  });
7323
7197
  /**
7324
7198
  * One record per line. `key` is a package-local stable identifier (export uses the
@@ -7334,84 +7208,30 @@ const PackageRecordLineSchema = z.object({
7334
7208
  fields: z.record(z.string(), z.unknown()).default({})
7335
7209
  });
7336
7210
  //#endregion
7337
- //#region ../../packages/busabase-package/src/ignore.ts
7338
- const escapeLiteral = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
7211
+ //#region ../../packages/busabase-package/src/tree.ts
7339
7212
  /**
7340
- * Translate one gitignore-ish pattern into a regex over a POSIX relative path.
7213
+ * The in-memory `busabase-package@1` tree — the shape both `layout-read` (repo dir
7214
+ * → tree) and `layout-write` (space subtree → tree → repo dir) speak, and that
7215
+ * `plan`/`apply` consume. Plus the format's shared validation rules (§6.3).
7216
+ */
7217
+ /**
7218
+ * A node's scenario prompts in a fixed key order — determinism (§6.6).
7341
7219
  *
7342
- * The three globs are handled in one pass so that `**` is recognised before the
7343
- * `*` it starts with — splitting on `*` first would turn `**` into two
7344
- * segment-local wildcards and quietly stop matching across directories.
7345
- */
7346
- const patternToRegex = (pattern, anchored) => {
7347
- let source = "";
7348
- for (let index = 0; index < pattern.length; index += 1) {
7349
- const char = pattern[index];
7350
- if (char === "*") {
7351
- if (pattern[index + 1] === "*") {
7352
- index += 1;
7353
- if (pattern[index + 1] === "/") {
7354
- index += 1;
7355
- source += "(?:.*/)?";
7356
- } else source += ".*";
7357
- } else source += "[^/]*";
7358
- continue;
7359
- }
7360
- if (char === "?") {
7361
- source += "[^/]";
7362
- continue;
7363
- }
7364
- if (char === "/") {
7365
- source += "/";
7366
- continue;
7367
- }
7368
- source += escapeLiteral(char);
7369
- }
7370
- return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${source}(?:/.*)?$`);
7371
- };
7372
- const parseRule = (line) => {
7373
- let pattern = line.trim();
7374
- if (!pattern || pattern.startsWith("#")) return void 0;
7375
- const negated = pattern.startsWith("!");
7376
- if (negated) pattern = pattern.slice(1);
7377
- const directoryOnly = pattern.endsWith("/");
7378
- if (directoryOnly) pattern = pattern.slice(0, -1);
7379
- const anchored = pattern.startsWith("/");
7380
- if (anchored) pattern = pattern.slice(1);
7381
- if (!pattern) return void 0;
7382
- return {
7383
- regex: patternToRegex(pattern, anchored),
7384
- negated,
7385
- directoryOnly
7386
- };
7387
- };
7388
- const parseIgnoreFile = (contents) => {
7389
- const rules = contents.split(/\r?\n/).map(parseRule).filter((rule) => rule !== void 0);
7390
- return {
7391
- isEmpty: rules.length === 0,
7392
- ignores: (relativePath) => {
7393
- let ignored = false;
7394
- for (const rule of rules) {
7395
- if (rule.directoryOnly && !rule.regex.test(`${relativePath}/`)) continue;
7396
- if (!rule.regex.test(relativePath)) continue;
7397
- ignored = !rule.negated;
7398
- }
7399
- return ignored;
7400
- }
7401
- };
7402
- };
7403
- /** A matcher that installs everything — used when no ignore file is present. */
7404
- const IGNORE_NOTHING = {
7405
- ignores: () => false,
7406
- isEmpty: true
7407
- };
7408
- //#endregion
7409
- //#region ../../packages/busabase-package/src/tree.ts
7410
- /**
7411
- * The in-memory `busabase-package@1` tree — the shape both `layout-read` (repo dir
7412
- * → tree) and `layout-write` (space subtree → tree → repo dir) speak, and that
7413
- * `plan`/`apply` consume. Plus the format's shared validation rules (§6.3).
7220
+ * Written through instead of spread verbatim because the list reaches the writer
7221
+ * from two directions: hand-authored on disk (whatever order the author typed)
7222
+ * and via a zod parse (schema order). Without this, re-exporting an unchanged
7223
+ * package would diff on key order alone.
7224
+ *
7225
+ * `intent` is omitted when absent rather than defaulted to `"change"` here — the
7226
+ * schema owns that default, and writing it out would silently rewrite every
7227
+ * author's file on the first round trip.
7414
7228
  */
7229
+ const serializeAgentPrompts = (prompts) => prompts?.length ? prompts.map((prompt) => ({
7230
+ key: prompt.key,
7231
+ ...prompt.intent ? { intent: prompt.intent } : {},
7232
+ label: prompt.label,
7233
+ body: prompt.body
7234
+ })) : void 0;
7415
7235
  /**
7416
7236
  * Every creatable node type's slug is `/^[a-z0-9-]+$/` across the whole API
7417
7237
  * (`docs.create`, `files.create`, `createFileTreeInputSchema`, `createBaseInputSchema`,
@@ -7528,27 +7348,298 @@ const walkNodes = function* (nodes) {
7528
7348
  };
7529
7349
  const collectBaseNodes = (nodes) => [...walkNodes(nodes)].filter((node) => node.type === "base");
7530
7350
  //#endregion
7531
- //#region ../../packages/busabase-package/src/layout-read.ts
7351
+ //#region ../../packages/busabase-package/src/frontmatter.ts
7352
+ const DELIMITER = "---";
7353
+ /**
7354
+ * The canonical on-disk body: LF endings, no leading or trailing blank lines. Applied
7355
+ * on both read and write so the round trip is a fixed point — without it, the blank
7356
+ * line the writer puts after the closing `---` and the file's trailing newline would
7357
+ * accumulate into the body on every export→install→export cycle.
7358
+ *
7359
+ * Leading/trailing blank lines around a doc body are therefore normalized away on the
7360
+ * first export; internal blank lines are untouched.
7361
+ */
7362
+ const normalizeDocBody = (body) => body.replaceAll("\r\n", "\n").replace(/^\n+/, "").replace(/\n+$/, "");
7363
+ /** Split `---\n<yaml>\n---\n<body>`. A doc with no frontmatter is all body. */
7364
+ const parseFrontmatter = (text, filePath) => {
7365
+ const normalized = text.replaceAll("\r\n", "\n");
7366
+ if (!normalized.startsWith(`${DELIMITER}\n`)) return {
7367
+ data: {},
7368
+ body: normalizeDocBody(normalized)
7369
+ };
7370
+ const end = normalized.indexOf(`\n${DELIMITER}`, 3);
7371
+ if (end === -1) throw new Error(`${filePath} opens YAML frontmatter with "---" but never closes it. Add a closing "---" line.`);
7372
+ const rawYaml = normalized.slice(4, end);
7373
+ const body = normalizeDocBody(normalized.slice(end + 1 + 3));
7374
+ let data;
7375
+ try {
7376
+ data = parse(rawYaml);
7377
+ } catch (error) {
7378
+ throw new Error(`${filePath} has invalid YAML frontmatter: ${error.message}`);
7379
+ }
7380
+ if (data === null || data === void 0) return {
7381
+ data: {},
7382
+ body
7383
+ };
7384
+ if (typeof data !== "object" || Array.isArray(data)) throw new Error(`${filePath} frontmatter must be a YAML mapping (key: value pairs).`);
7385
+ return {
7386
+ data,
7387
+ body
7388
+ };
7389
+ };
7390
+ /** Serialize a doc to `---\n<frontmatter>\n---\n<body>` with a trailing newline. */
7391
+ const serializeDoc = (fields, body) => {
7392
+ const data = { name: fields.name };
7393
+ if (fields.description) data.description = fields.description;
7394
+ if (fields.position !== void 0) data.position = fields.position;
7395
+ const agentPrompts = serializeAgentPrompts(fields.agentPrompts);
7396
+ if (agentPrompts) data.agentPrompts = agentPrompts;
7397
+ const yaml = stringify(data, { lineWidth: 0 }).replace(/\n$/, "");
7398
+ return `${DELIMITER}\n${yaml}\n${DELIMITER}\n\n${normalizeDocBody(body)}\n`;
7399
+ };
7400
+ //#endregion
7401
+ //#region ../../packages/busabase-package/src/audit.ts
7402
+ /**
7403
+ * Is this package **fit to publish**?
7404
+ *
7405
+ * Deliberately a different question from {@link validateTemplate}, and the split is
7406
+ * the point rather than an organisational preference:
7407
+ *
7408
+ * - `validateTemplate` **classifies**. Its verdict changes what install *does* — a
7409
+ * Skill node, ownership stamps and a sample merge, versus a plain package install.
7410
+ * Four consumers must agree on it or the catalog lies about what an install does,
7411
+ * so it stays small and stable.
7412
+ * - `auditPackage` / `auditSkill` **advise**. Nothing behaves differently based on
7413
+ * their verdict; they exist for the author and the reviewer, and are free to grow.
7414
+ *
7415
+ * The practical consequence: a declared screenshot that is not on disk belongs here,
7416
+ * never in `validateTemplate`. Promoting it there would stop a template installing
7417
+ * over a missing image, which is the wrong lever entirely.
7418
+ *
7419
+ * Nothing here re-checks what `validateTemplate` already decides (declared resources,
7420
+ * the sample-row ceiling, name agreement between manifest and manual, an unambiguous
7421
+ * primary AirApp). Run both; they compose.
7422
+ *
7423
+ * Purely static: no install, no network, and none of the package's own code is
7424
+ * executed — safe to run in CI over a pull request from someone you do not know.
7425
+ */
7426
+ /**
7427
+ * A materialized workspace id: busabase-core's `id()` is a three-letter prefix, the
7428
+ * creation time in base36, then seven random characters.
7429
+ *
7430
+ * The lookahead requiring a digit is what keeps ordinary words out — `recommendation`
7431
+ * has the right prefix and the wrong shape. A real id always carries its timestamp.
7432
+ */
7433
+ const WORKSPACE_ID = /(?<![0-9a-z])(?:nod|bse|viw|rec|crq|ast|com|fom|shr|fvl|opr|cmt|rev|whd|aud)(?=[0-9a-z]{15}(?![0-9a-z]))(?=[0-9a-z]*\d)[0-9a-z]{15}/;
7434
+ const TEXT_EXTENSIONS = /\.(?:js|mjs|cjs|ts|mts|cts|json|ndjson|md|html|css|ya?ml|txt)$|(?:^|\/)\.env(?:\.|$)/;
7435
+ /**
7436
+ * Files that exist to *forbid* a string necessarily contain it. An AirApp's own
7437
+ * `scripts/check.mjs` asserts the obsolete API prefix is absent, and a test fixture
7438
+ * spells out the credential it proves gets rejected — scanning those turns a
7439
+ * correctly-guarded package into a failure.
7440
+ */
7441
+ const IS_ASSERTION_CODE = /(?:^|\/)(?:scripts\/check\.mjs|tests?\/|[^/]*\.test\.[cm]?[jt]s)$/;
7442
+ /** Lockfiles carry integrity hashes that are not ours to interpret. */
7443
+ const NOT_SOURCE = /(?:^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb)$/;
7444
+ const relativePaths = (files, root = "") => {
7445
+ const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
7446
+ const out = [];
7447
+ for (const key of files.keys()) if (!prefix || key.startsWith(prefix)) out.push(key.slice(prefix.length));
7448
+ return out;
7449
+ };
7450
+ const readText = (files, root, relative) => {
7451
+ const prefix = root ? `${root.replace(/\/+$/, "")}/` : "";
7452
+ return files.get(`${prefix}${relative}`)?.toString("utf8");
7453
+ };
7454
+ /**
7455
+ * Audit the package layer: identity, the assets the catalog promises, and anything
7456
+ * that must never be published.
7457
+ *
7458
+ * `files` is the flat map `readPackageTree` was given, not the tree. That is load-bearing:
7459
+ * `assets/screenshots/` holds no nodes and so never reaches the tree, and "the manifest
7460
+ * promises a screenshot that does not exist" is exactly a file-level question.
7461
+ */
7462
+ const auditPackage = (tree, files, options = {}) => {
7463
+ const findings = [];
7464
+ const error = (rule, message) => findings.push({
7465
+ severity: "error",
7466
+ rule,
7467
+ message
7468
+ });
7469
+ const warn = (rule, message) => findings.push({
7470
+ severity: "warning",
7471
+ rule,
7472
+ message
7473
+ });
7474
+ const root = options.root ?? "";
7475
+ const paths = relativePaths(files, root);
7476
+ const present = new Set(paths);
7477
+ if (options.directoryName !== void 0 && options.directoryName !== tree.manifest.name) error("package/identity", `Directory is "${options.directoryName}" but busabase.json declares "${tree.manifest.name}". The directory name is the package's identity, not a label beside it.`);
7478
+ for (const screenshot of tree.manifest.template?.screenshots ?? []) if (!present.has(screenshot)) error("package/screenshot-missing", `busabase.json declares screenshot "${screenshot}", which is not in the package. The Template Center card would render a broken image.`);
7479
+ for (const relative of paths) {
7480
+ const base = relative.slice(relative.lastIndexOf("/") + 1);
7481
+ if (/^\.env(?!\.example$)(?:\.|$)/.test(base)) error("package/dotenv", `${relative} is part of the package. Credentials belong in Vault, never in something people install.`);
7482
+ if (/^(?:id_rsa|id_ed25519)$/.test(base) || /\.(?:pem|p12|pfx|keystore)$/.test(base)) error("package/key-material", `${relative} looks like key material and must not be published.`);
7483
+ if (/(?:^|\/)(?:node_modules|dist|build)\//.test(relative)) warn("package/build-output", `${relative} looks like installed or built output. Ship source; the installer builds nothing.`);
7484
+ }
7485
+ for (const relative of paths) {
7486
+ if (!TEXT_EXTENSIONS.test(relative)) continue;
7487
+ if (NOT_SOURCE.test(relative) || IS_ASSERTION_CODE.test(relative)) continue;
7488
+ const source = readText(files, root, relative);
7489
+ if (source === void 0) continue;
7490
+ const found = WORKSPACE_ID.exec(source);
7491
+ if (found) error("package/workspace-ids", `${relative} contains "${found[0]}", a materialized workspace id. Node, Base and Space ids are whoever last ran setup's runtime state — publishing them ships one person's workspace layout to everyone.`);
7492
+ }
7493
+ if (!tree.rootSkill) warn("template/no-skill-node", `This package carries no ${PACKAGE_SKILL_ENTRY}, so nothing installs a manual beside its resources. An agent working in the installed folder has nothing to read, and the person who installed it has nothing to point one at.`);
7494
+ const promptless = nodesWithoutAgentPrompts(tree.nodes);
7495
+ if (promptless.length > 0) warn("template/node-without-prompts", `${promptless.length} installed node(s) carry no agentPrompts (${promptless.slice(0, 5).join(", ")}${promptless.length > 5 ? ", …" : ""}). They will show their node type's generic prompts, so nothing tells the user what THIS app is for. Write scenario prompts per node — see \`busabase-cli nodes set-agent-prompts\`.`);
7496
+ return findings;
7497
+ };
7532
7498
  /**
7533
- * Layout reader: a package's files (from a repo dir, or an extracted zip held in
7534
- * memory) → the in-memory tree. Used by `install`, and by the round-trip tests.
7499
+ * Node slugs (dotted by folder) with no scenario prompts of their own.
7535
7500
  *
7536
- * Node detection follows §6.3 exactly — the order matters, first match wins:
7537
- * 1. directory with `_node.json` → skill | airapp | drive (contents verbatim)
7538
- * 2. directory with `base.json` → base
7539
- * 3. any other directory → folder (optional `_folder.json`)
7540
- * 4. `*.md` → doc (YAML frontmatter + body)
7541
- * 5. any other file → file node (optional `<name>.node.json`)
7501
+ * Folders are exempt: a folder is a container, and the useful prompts hang off the
7502
+ * things inside it. Everything else is something a person opens and then has to
7503
+ * work out what to ask for.
7504
+ */
7505
+ const nodesWithoutAgentPrompts = (nodes, prefix = "") => {
7506
+ const out = [];
7507
+ for (const node of nodes) {
7508
+ const path = `${prefix}${node.slug}`;
7509
+ if (node.type === "folder") {
7510
+ out.push(...nodesWithoutAgentPrompts(node.children, `${path}/`));
7511
+ continue;
7512
+ }
7513
+ if (!node.agentPrompts?.length) out.push(path);
7514
+ }
7515
+ return out;
7516
+ };
7517
+ /**
7518
+ * Audit the Skill layer: is the manual one an agent can actually act on?
7542
7519
  *
7543
- * Outside `content/`, the optional `assets/` directory carries the bytes for the
7544
- * images the Docs embed — see {@link readDocAssets}. Nothing in there is a node.
7520
+ * Runs for any directory carrying a root `SKILL.md` — package or not, template or not.
7521
+ * It reports nothing at all when there is no manual: a package without one is a
7522
+ * perfectly legitimate thing to publish, not a Skill that failed.
7545
7523
  *
7546
- * Rule 0 sits ahead of all of them but reads a different place: a `SKILL.md` at
7547
- * the package ROOT (beside the manifest, not under `content/`) makes this
7548
- * package a *template*, and is lifted into `tree.rootSkill` — see
7549
- * {@link readRootSkill}. `content/` itself is untouched by that rule, which is
7550
- * what keeps a template byte-identical to a plain package below the root.
7524
+ * The honest limit: a checker can tell you the frontmatter parses and the files the
7525
+ * manual points at exist. It cannot tell you the manual is *true*. Everything in
7526
+ * `SKILL.md` becomes instructions an agent acts on, and no static rule catches a
7527
+ * confident, wrong sentence about what a table means.
7551
7528
  */
7529
+ const auditSkill = (files, options = {}) => {
7530
+ const findings = [];
7531
+ const error = (rule, message) => findings.push({
7532
+ severity: "error",
7533
+ rule,
7534
+ message
7535
+ });
7536
+ const warn = (rule, message) => findings.push({
7537
+ severity: "warning",
7538
+ rule,
7539
+ message
7540
+ });
7541
+ const root = options.root ?? "";
7542
+ const source = readText(files, root, PACKAGE_SKILL_ENTRY);
7543
+ if (source === void 0) return findings;
7544
+ let body = "";
7545
+ try {
7546
+ const parsed = parseFrontmatter(source, PACKAGE_SKILL_ENTRY);
7547
+ body = parsed.body;
7548
+ const frontmatter = SkillFrontmatterSchema$1.safeParse(parsed.data);
7549
+ if (!frontmatter.success) error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY} frontmatter is not a valid Skill declaration: ${frontmatter.error.issues.map((issue) => `${issue.path.join(".") || "(root)"} ${issue.message}`).join("; ")}`);
7550
+ else if (frontmatter.data.description.trim() === "") error("skill/description", `${PACKAGE_SKILL_ENTRY} has no description. An agent chooses whether to reach for a Skill by reading it, so an empty one is not cosmetic — it is a Skill that never gets picked.`);
7551
+ } catch (cause) {
7552
+ error("skill/frontmatter", `${PACKAGE_SKILL_ENTRY}: ${cause.message}`);
7553
+ return findings;
7554
+ }
7555
+ const todos = (body.match(/^.*\bTODO\b.*$/gm) ?? []).length;
7556
+ if (todos > 0) error("skill/todo", `${PACKAGE_SKILL_ENTRY} still carries ${todos} TODO line(s). The export draft leaves them on purpose — an agent acts on what this file says, and a blank the author was meant to fill in is worse than nothing once it ships.`);
7557
+ const present = new Set(relativePaths(files, root));
7558
+ const sidecar = new RegExp(`\\]\\((\\.?/?(?:${PACKAGE_SIDECARS})/[^)\\s]+)\\)|\`((?:${PACKAGE_SIDECARS})/[^\`\\s]+)\``, "g");
7559
+ const seen = /* @__PURE__ */ new Set();
7560
+ for (const match of body.matchAll(sidecar)) {
7561
+ const target = (match[1] ?? match[2]).replace(/^\.?\//, "");
7562
+ if (seen.has(target)) continue;
7563
+ seen.add(target);
7564
+ if (!present.has(target)) warn("skill/missing-reference", `${PACKAGE_SKILL_ENTRY} points at "${target}", which the package does not ship. An agent told to read it has an instruction it cannot satisfy.`);
7565
+ }
7566
+ return findings;
7567
+ };
7568
+ const PACKAGE_SIDECARS = PACKAGE_SKILL_SIDECAR_DIRS.join("|");
7569
+ //#endregion
7570
+ //#region ../../packages/busabase-package/src/ignore.ts
7571
+ const escapeLiteral = (value) => value.replace(/[.+^${}()|[\]\\]/g, "\\$&");
7572
+ /**
7573
+ * Translate one gitignore-ish pattern into a regex over a POSIX relative path.
7574
+ *
7575
+ * The three globs are handled in one pass so that `**` is recognised before the
7576
+ * `*` it starts with — splitting on `*` first would turn `**` into two
7577
+ * segment-local wildcards and quietly stop matching across directories.
7578
+ */
7579
+ const patternToRegex = (pattern, anchored) => {
7580
+ let source = "";
7581
+ for (let index = 0; index < pattern.length; index += 1) {
7582
+ const char = pattern[index];
7583
+ if (char === "*") {
7584
+ if (pattern[index + 1] === "*") {
7585
+ index += 1;
7586
+ if (pattern[index + 1] === "/") {
7587
+ index += 1;
7588
+ source += "(?:.*/)?";
7589
+ } else source += ".*";
7590
+ } else source += "[^/]*";
7591
+ continue;
7592
+ }
7593
+ if (char === "?") {
7594
+ source += "[^/]";
7595
+ continue;
7596
+ }
7597
+ if (char === "/") {
7598
+ source += "/";
7599
+ continue;
7600
+ }
7601
+ source += escapeLiteral(char);
7602
+ }
7603
+ return new RegExp(`${anchored ? "^" : "^(?:.*/)?"}${source}(?:/.*)?$`);
7604
+ };
7605
+ const parseRule = (line) => {
7606
+ let pattern = line.trim();
7607
+ if (!pattern || pattern.startsWith("#")) return void 0;
7608
+ const negated = pattern.startsWith("!");
7609
+ if (negated) pattern = pattern.slice(1);
7610
+ const directoryOnly = pattern.endsWith("/");
7611
+ if (directoryOnly) pattern = pattern.slice(0, -1);
7612
+ const anchored = pattern.startsWith("/");
7613
+ if (anchored) pattern = pattern.slice(1);
7614
+ if (!pattern) return void 0;
7615
+ return {
7616
+ regex: patternToRegex(pattern, anchored),
7617
+ negated,
7618
+ directoryOnly
7619
+ };
7620
+ };
7621
+ const parseIgnoreFile = (contents) => {
7622
+ const rules = contents.split(/\r?\n/).map(parseRule).filter((rule) => rule !== void 0);
7623
+ return {
7624
+ isEmpty: rules.length === 0,
7625
+ ignores: (relativePath) => {
7626
+ let ignored = false;
7627
+ for (const rule of rules) {
7628
+ if (rule.directoryOnly && !rule.regex.test(`${relativePath}/`)) continue;
7629
+ if (!rule.regex.test(relativePath)) continue;
7630
+ ignored = !rule.negated;
7631
+ }
7632
+ return ignored;
7633
+ }
7634
+ };
7635
+ };
7636
+ /** A matcher that installs everything — used when no ignore file is present. */
7637
+ const IGNORE_NOTHING = {
7638
+ ignores: () => false,
7639
+ isEmpty: true
7640
+ };
7641
+ //#endregion
7642
+ //#region ../../packages/busabase-package/src/layout-read.ts
7552
7643
  const decodeText = (bytes, filePath) => {
7553
7644
  const text = bytes.toString("utf8");
7554
7645
  if (text.includes("�")) throw new Error(`${filePath} is not valid UTF-8 text.`);
@@ -7603,6 +7694,20 @@ const zodParse = (schema, value, filePath) => {
7603
7694
  }
7604
7695
  return result.data;
7605
7696
  };
7697
+ /**
7698
+ * A sidecar's `agentPrompts` as the tree carries it — as a spread, so a node that
7699
+ * has none carries no `agentPrompts` KEY at all rather than one set to `undefined`.
7700
+ *
7701
+ * `[]` and "absent" mean the same thing on disk (the node falls back to its type's
7702
+ * default prompts either way), so an empty list is dropped rather than
7703
+ * round-tripping as a sidecar key that changes nothing.
7704
+ */
7705
+ /** `base.json` minus the node-level key, so the list lives in exactly one place. */
7706
+ const baseWithoutAgentPrompts = (base) => {
7707
+ const { agentPrompts: _agentPrompts, ...rest } = base;
7708
+ return rest;
7709
+ };
7710
+ const agentPromptsField = (prompts) => prompts?.length ? { agentPrompts: prompts } : {};
7606
7711
  const listDir = (files, dir) => {
7607
7712
  const fileNames = /* @__PURE__ */ new Set();
7608
7713
  const dirNames = /* @__PURE__ */ new Set();
@@ -7646,7 +7751,7 @@ const readPackageTree = (files, options = {}) => {
7646
7751
  const manifest = zodParse(PackageManifestSchema, rawManifest, manifestPath);
7647
7752
  const contentDir = `${root}${PACKAGE_CONTENT_DIRNAME}/`;
7648
7753
  const nodes = readNodes(files, contentDir);
7649
- const rootSkill = readRootSkill(files, root, manifest.name, manifest.description);
7754
+ const rootSkill = readRootSkill(files, root, manifest.name, iStringParse(manifest.description, "en"));
7650
7755
  return {
7651
7756
  manifest,
7652
7757
  nodes,
@@ -7775,6 +7880,7 @@ const readDirNode = (files, dir, slug) => {
7775
7880
  name: meta.name,
7776
7881
  description: meta.description,
7777
7882
  position: meta.position,
7883
+ ...agentPromptsField(meta.agentPrompts),
7778
7884
  files: entries
7779
7885
  };
7780
7886
  }
@@ -7788,7 +7894,8 @@ const readDirNode = (files, dir, slug) => {
7788
7894
  name: base.name,
7789
7895
  description: base.description,
7790
7896
  position: base.position,
7791
- base,
7897
+ ...agentPromptsField(base.agentPrompts),
7898
+ base: baseWithoutAgentPrompts(base),
7792
7899
  records
7793
7900
  };
7794
7901
  }
@@ -7796,7 +7903,8 @@ const readDirNode = (files, dir, slug) => {
7796
7903
  const meta = files.has(metaPath) ? zodParse(PackageFolderMetaSchema, parseJsonFile(files, metaPath), metaPath) : {
7797
7904
  name: void 0,
7798
7905
  description: "",
7799
- position: void 0
7906
+ position: void 0,
7907
+ agentPrompts: void 0
7800
7908
  };
7801
7909
  return {
7802
7910
  type: "folder",
@@ -7804,6 +7912,7 @@ const readDirNode = (files, dir, slug) => {
7804
7912
  name: meta.name ?? humanizeSlug(slug),
7805
7913
  description: meta.description,
7806
7914
  position: meta.position,
7915
+ ...agentPromptsField(meta.agentPrompts),
7807
7916
  children: readNodes(files, dir)
7808
7917
  };
7809
7918
  };
@@ -7854,7 +7963,8 @@ const readFileNode = (files, dir, fileName) => {
7854
7963
  const meta = files.has(sidecarPath) ? zodParse(PackageFileNodeMetaSchema, parseJsonFile(files, sidecarPath), sidecarPath) : {
7855
7964
  name: void 0,
7856
7965
  description: "",
7857
- position: void 0
7966
+ position: void 0,
7967
+ agentPrompts: void 0
7858
7968
  };
7859
7969
  return {
7860
7970
  type: "file",
@@ -7862,6 +7972,7 @@ const readFileNode = (files, dir, fileName) => {
7862
7972
  name: meta.name ?? fileName,
7863
7973
  description: meta.description,
7864
7974
  position: meta.position,
7975
+ ...agentPromptsField(meta.agentPrompts),
7865
7976
  fileName,
7866
7977
  mimeType: guessMimeType$1(fileName),
7867
7978
  bytes
@@ -7882,6 +7993,7 @@ const readDocNode = (bytes, dir, fileName) => {
7882
7993
  name: frontmatter.name,
7883
7994
  description: frontmatter.description,
7884
7995
  position: frontmatter.position,
7996
+ ...agentPromptsField(frontmatter.agentPrompts),
7885
7997
  body
7886
7998
  };
7887
7999
  };
@@ -7958,6 +8070,8 @@ const validateTemplate = (tree) => {
7958
8070
  if (skill && skill.name !== tree.manifest.name) errors.push(`Name mismatch: ${PACKAGE_SKILL_ENTRY} declares "${skill.name}" but busabase.json declares "${tree.manifest.name}". They are the same app and must agree.`);
7959
8071
  const bases = baseSlugs(tree);
7960
8072
  for (const key of busabaseMeta?.resources ?? []) if (!bases.has(key)) errors.push(`${PACKAGE_SKILL_ENTRY} lists resource "${key}", but content/ has no Base with that slug. An agent told about a table that does not exist will write to the wrong one.`);
8073
+ const risk = parseTemplateRisk(busabaseMeta?.risk);
8074
+ if (busabaseMeta?.risk !== void 0 && risk === void 0) warnings.push(`metadata.busabase.risk is "${busabaseMeta.risk}", which is not a recognized level (gated-write, local-write, read-only, sandbox). The Template Center will show it as undeclared.`);
7961
8075
  const airapps = airAppSlugs(tree);
7962
8076
  const resolved = resolvePrimaryAirApp(template, airapps);
7963
8077
  let primaryAirApp;
@@ -7994,7 +8108,8 @@ const validateTemplate = (tree) => {
7994
8108
  errors,
7995
8109
  warnings,
7996
8110
  ...primaryAirApp ? { primaryAirApp } : {},
7997
- ...template ? { template } : {}
8111
+ ...template ? { template } : {},
8112
+ ...risk ? { risk } : {}
7998
8113
  };
7999
8114
  };
8000
8115
  /**
@@ -8014,7 +8129,7 @@ const deriveSkillDraft = (tree) => {
8014
8129
  const bases = nodes.filter((node) => node.type === "base");
8015
8130
  const airapps = nodes.filter(isAirApp);
8016
8131
  const name = tree.manifest.name;
8017
- const description = tree.manifest.description || `TODO: one line on what ${name} does.`;
8132
+ const description = iStringParse(tree.manifest.description, "en") || `TODO: one line on what ${name} does.`;
8018
8133
  const yamlString = (value) => JSON.stringify(value);
8019
8134
  const resourceLines = bases.map((node) => ` - ${yamlString(node.slug)}`).join("\n");
8020
8135
  const resourceDocs = bases.map((node) => {
@@ -8496,7 +8611,7 @@ const applyInstall = async (client, plan, options) => {
8496
8611
  parentNodeId: void 0,
8497
8612
  slug: plan.targetFolderSlug,
8498
8613
  name: plan.tree.manifest.name,
8499
- description: plan.tree.manifest.description,
8614
+ description: iStringParse(plan.tree.manifest.description, "en"),
8500
8615
  submittedBy: options.submittedBy,
8501
8616
  metadata: app?.rootMetadata
8502
8617
  });
@@ -8728,6 +8843,37 @@ const stampResource = async (client, app, nodeId, installedSlug, state, extra =
8728
8843
  }
8729
8844
  };
8730
8845
  /**
8846
+ * Write a node's carried scenario prompts onto the node install just created.
8847
+ *
8848
+ * After creation rather than inline, because the prompts live in their own
8849
+ * column with their own route — no create endpoint accepts them. That has one
8850
+ * visible consequence, and it is the reason for the warning below: on the
8851
+ * review-first path a Doc or File node is a PENDING change request with no node
8852
+ * id yet, so there is nothing to write them to. Structure (folders, Bases,
8853
+ * Skills/AirApps/Drives) is always materialized (see {@link createBase}), so the
8854
+ * nodes that carry most prompts are unaffected.
8855
+ *
8856
+ * Never fails the install, for the same reason {@link stampResource} does not:
8857
+ * the resources are already there and useful, and prompts can be re-applied with
8858
+ * `busabase-cli nodes set-agent-prompts` at any time.
8859
+ */
8860
+ const applyAgentPrompts = async (client, nodeId, node, state) => {
8861
+ const agentPrompts = node.agentPrompts;
8862
+ if (!agentPrompts?.length) return;
8863
+ if (!nodeId) {
8864
+ state.warnings.push(`"${node.slug}" installs as a change request, so its ${agentPrompts.length} agent prompt(s) were not applied. After merging it, run \`busabase-cli nodes set-agent-prompts\` for that node, or re-install with --auto-merge.`);
8865
+ return;
8866
+ }
8867
+ try {
8868
+ await client.nodes.updateAgentPrompts({
8869
+ nodeId,
8870
+ agentPrompts
8871
+ });
8872
+ } catch (error) {
8873
+ state.warnings.push(`Could not set agent prompts on "${node.slug}": ${error.message}. The node installed fine; it will show its node type's default prompts.`);
8874
+ }
8875
+ };
8876
+ /**
8731
8877
  * Re-create the package's carried Doc images on THIS host and return the
8732
8878
  * old id → new id map the Doc bodies are rewritten through.
8733
8879
  *
@@ -8761,21 +8907,24 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
8761
8907
  submittedBy: options.submittedBy
8762
8908
  });
8763
8909
  state.created.folders++;
8910
+ await applyAgentPrompts(client, nodeId, node, state);
8764
8911
  await createStructure(client, node.children, nodeId, options, state, bases, fieldIds, docAssetIds, app);
8765
8912
  break;
8766
8913
  }
8767
8914
  case "doc": {
8768
8915
  const warning = reportUnresolvableDocAssets(node.slug, node.body, new Set(docAssetIds.keys()));
8769
8916
  if (warning) state.warnings.push(warning);
8770
- if ((await client.docs.create({
8917
+ const result = await client.docs.create({
8771
8918
  parentNodeId,
8772
8919
  slug: node.slug,
8773
8920
  name: node.name,
8774
8921
  description: node.description,
8775
8922
  body: rewriteDocAssetIds(node.body, docAssetIds),
8776
8923
  autoMerge: options.autoMerge
8777
- })).materialized) state.created.docs++;
8924
+ });
8925
+ if (result.materialized) state.created.docs++;
8778
8926
  else state.pendingChangeRequests++;
8927
+ await applyAgentPrompts(client, result.materialized ? result.node.id : void 0, node, state);
8779
8928
  break;
8780
8929
  }
8781
8930
  case "base":
@@ -8794,15 +8943,17 @@ const createStructure = async (client, nodes, parentNodeId, options, state, base
8794
8943
  state.warnings.push(asSkippableUpload(error, node.fileName));
8795
8944
  break;
8796
8945
  }
8797
- if ((await client.files.create({
8946
+ const result = await client.files.create({
8798
8947
  parentNodeId,
8799
8948
  slug: node.slug,
8800
8949
  name: node.name,
8801
8950
  description: node.description,
8802
8951
  assetId: asset.assetId,
8803
8952
  autoMerge: options.autoMerge
8804
- })).materialized) state.created.files++;
8953
+ });
8954
+ if (result.materialized) state.created.files++;
8805
8955
  else state.pendingChangeRequests++;
8956
+ await applyAgentPrompts(client, result.materialized ? result.node.id : void 0, node, state);
8806
8957
  break;
8807
8958
  }
8808
8959
  }
@@ -8854,6 +9005,7 @@ const createBase = async (client, node, parentNodeId, options, state, bases, fie
8854
9005
  });
8855
9006
  indexFields(fieldIds, node.slug, result.fields);
8856
9007
  await stampResource(client, app, result.nodeId, node.slug, state);
9008
+ await applyAgentPrompts(client, result.nodeId, node, state);
8857
9009
  for (const view of node.base.views) {
8858
9010
  if (!(await client.views.changeRequest({
8859
9011
  operation: "create",
@@ -8888,7 +9040,7 @@ const createFileTreeNode = async (client, node, parentNodeId, options, state, ap
8888
9040
  return;
8889
9041
  }
8890
9042
  }))).filter((entry) => entry !== void 0);
8891
- if ((await client.fileTrees.create({
9043
+ const result = await client.fileTrees.create({
8892
9044
  type: node.type,
8893
9045
  parentNodeId,
8894
9046
  slug: node.slug,
@@ -8898,10 +9050,12 @@ const createFileTreeNode = async (client, node, parentNodeId, options, state, ap
8898
9050
  ...app ? { metadata: resourceStamp(app, node.slug, extraMetadata) } : {},
8899
9051
  mergeMode: "replace",
8900
9052
  autoMerge: options.autoMerge
8901
- })).materialized) {
9053
+ });
9054
+ if (result.materialized) {
8902
9055
  state.created.fileTreeNodes++;
8903
9056
  state.created.files += node.files.length;
8904
9057
  } else state.pendingChangeRequests++;
9058
+ await applyAgentPrompts(client, result.materialized ? result.node.id : void 0, node, state);
8905
9059
  };
8906
9060
  /**
8907
9061
  * Thrown when the host can't accept an upload the normal way — see
@@ -9143,15 +9297,6 @@ const approveAndMerge = async (client, changeRequestId) => {
9143
9297
  };
9144
9298
  //#endregion
9145
9299
  //#region ../../packages/busabase-package/src/collect.ts
9146
- /**
9147
- * `export`'s read half: a space subtree → the in-memory package tree, entirely via
9148
- * the public read API. The inverse of `apply`.
9149
- *
9150
- * Rewrites the three id-bearing option keys back to slug references, drops everything
9151
- * the format has no slot for (ids, history, permissions), carries the bytes behind
9152
- * every Doc's inline images (see {@link collectDocAssets}), and enforces the format's
9153
- * self-containment rule: a relation may not leave the package.
9154
- */
9155
9300
  /** Find the subtree to export by node slug or id. */
9156
9301
  const findSourceNode = (nodes, slugOrId) => {
9157
9302
  const stack = [...nodes];
@@ -9168,7 +9313,8 @@ const collectPackageTree = async (client, root, options) => {
9168
9313
  const { skillSource, rest } = partitionTemplateSkill(children);
9169
9314
  const collectOptions = {
9170
9315
  ...options,
9171
- baseResourceKeys: collectBaseResourceKeys(children)
9316
+ baseResourceKeys: collectBaseResourceKeys(children),
9317
+ agentPromptsUnavailable: options.agentPromptsUnavailable ?? { value: false }
9172
9318
  };
9173
9319
  const nodes = await collectNodes(client, rest, collectOptions);
9174
9320
  const tree = {
@@ -9312,12 +9458,36 @@ const collectNodes = async (client, sources, options) => {
9312
9458
  }
9313
9459
  return sortNodes(nodes);
9314
9460
  };
9461
+ /**
9462
+ * This node's scenario Agent prompts, read from the column they live in.
9463
+ *
9464
+ * A separate read per node because that is the only way to get them: they are
9465
+ * deliberately absent from `nodes.list` (50 prompts x 8 KiB per locale is why
9466
+ * they have their own column and their own route).
9467
+ *
9468
+ * Never fatal. A server that predates the route, or a node this key may read but
9469
+ * whose prompts it may not, costs the export those prompts and nothing else —
9470
+ * the package still installs, and its nodes fall back to their type's defaults,
9471
+ * exactly as every package did before the field existed.
9472
+ */
9473
+ const collectAgentPrompts = async (client, source, options) => {
9474
+ if (options.agentPromptsUnavailable?.value) return void 0;
9475
+ try {
9476
+ const result = await client.nodes.getAgentPrompts({ nodeId: source.id });
9477
+ return result.agentPrompts?.length ? result.agentPrompts : void 0;
9478
+ } catch (error) {
9479
+ if (options.agentPromptsUnavailable) options.agentPromptsUnavailable.value = true;
9480
+ options.warn(`Could not read agent prompts for "${source.slug}": ${error.message}. Exported without them for this node and every node after it; the installed nodes will show their node type's default prompts.`);
9481
+ return;
9482
+ }
9483
+ };
9315
9484
  const collectNode = async (client, source, options) => {
9316
9485
  const common = {
9317
9486
  slug: restoredNodeSlug(source),
9318
9487
  name: source.name,
9319
9488
  description: source.description ?? "",
9320
- position: source.position
9489
+ position: source.position,
9490
+ agentPrompts: await collectAgentPrompts(client, source, options)
9321
9491
  };
9322
9492
  switch (source.type) {
9323
9493
  case "folder": return {
@@ -9919,7 +10089,8 @@ const countTree = (tree) => {
9919
10089
  const renderPlan = (plan) => {
9920
10090
  const lines = [];
9921
10091
  lines.push(`Package: ${plan.tree.manifest.name}`);
9922
- if (plan.tree.manifest.description) lines.push(` ${plan.tree.manifest.description}`);
10092
+ const description = iStringParse(plan.tree.manifest.description, "en");
10093
+ if (description) lines.push(` ${description}`);
9923
10094
  lines.push("");
9924
10095
  lines.push(`Target folder: ${plan.targetFolderSlug}`);
9925
10096
  lines.push("");
@@ -10006,25 +10177,6 @@ const assertPlanIsApplicable = (plan, autoMerge) => {
10006
10177
  //#endregion
10007
10178
  //#region ../../packages/busabase-package/src/discover.ts
10008
10179
  /**
10009
- * What is at the end of this URL — one package, one template, or a repository
10010
- * full of them?
10011
- *
10012
- * A user pasting a GitHub URL does not think in terms of "package roots". They
10013
- * paste the repo they were looking at. Sometimes that repo IS the package;
10014
- * sometimes it is `busabase/skills`, which holds dozens under `skills/*`. Before
10015
- * this, the second case was only ever an error message with a list of
10016
- * subdirectories to paste instead — correct, and a dead end for anyone who did
10017
- * not want to edit a URL by hand.
10018
- *
10019
- * Discovery answers it once for every caller: the CLI turns the result into a
10020
- * pick-one list, the dashboard's install dialog into a chooser, the Template
10021
- * Center's index CI into the set of cards to publish. All three must agree on
10022
- * what counts as a template, which is why the verdict comes from the same
10023
- * `validateTemplate` they would each otherwise re-implement.
10024
- *
10025
- * Spec: `apps/busabase/content/spec/template-center.md` §6.2a.
10026
- */
10027
- /**
10028
10180
  * Only one level deep, matching the `skills/<name>/` layout the skills CLI
10029
10181
  * already established and the Template Center reads. Walking arbitrarily deep
10030
10182
  * would turn one paste into an unbounded scan of a monorepo and surface vendored
@@ -10074,6 +10226,7 @@ const discoverPackages = (files) => {
10074
10226
  counts: countTree(tree),
10075
10227
  ...validation.primaryAirApp ? { primaryAirApp: validation.primaryAirApp } : {},
10076
10228
  ...tree.manifest.template?.category ? { category: tree.manifest.template.category } : {},
10229
+ ...validation.risk ? { risk: validation.risk } : {},
10077
10230
  screenshots: tree.manifest.template?.screenshots ?? [],
10078
10231
  tags: [.../* @__PURE__ */ new Set([...tree.manifest.tags, ...tree.manifest.template?.tags ?? []])],
10079
10232
  agentPrompts: tree.manifest.template?.agentPrompts ?? [],
@@ -10290,22 +10443,6 @@ const fetchGithubPackageFiles = async (repoUrl, options = {}) => {
10290
10443
  };
10291
10444
  //#endregion
10292
10445
  //#region ../../packages/busabase-package/src/index-build.ts
10293
- /**
10294
- * Build the Template Center's catalog from a directory of skills.
10295
- *
10296
- * The catalog is not hand-maintained. A card that says "6 tables" for a package
10297
- * that installs five is a trust bug, not a typo — so every field here is read
10298
- * off the package itself, and whether an entry appears at all is decided by the
10299
- * SAME `validateTemplate` the installer uses. A skill that would install as a
10300
- * plain package can never be listed as a template, because one function answers
10301
- * that question for both.
10302
- *
10303
- * Deliberately a plain function over a file map rather than something that
10304
- * walks a disk: the CLI hands it a checkout, and a future CI job could hand it
10305
- * a fetched zipball, without either needing its own notion of what counts.
10306
- *
10307
- * Spec: `apps/busabase/content/spec/template-center.md` §6.4.
10308
- */
10309
10446
  /** Bumped when a consumer would misread an older file. */
10310
10447
  const TEMPLATE_INDEX_FORMAT = "busabase-template-index@1";
10311
10448
  const toEntry = (found, airapps) => ({
@@ -10313,6 +10450,7 @@ const toEntry = (found, airapps) => ({
10313
10450
  name: found.name,
10314
10451
  description: found.description,
10315
10452
  category: found.category ?? "uncategorized",
10453
+ ...found.risk ? { risk: found.risk } : {},
10316
10454
  tags: found.tags ?? [],
10317
10455
  screenshots: found.screenshots,
10318
10456
  agentPrompts: found.agentPrompts ?? [],
@@ -10420,11 +10558,12 @@ const serializeView = (view) => compact({
10420
10558
  fieldWidths: view.config.fieldWidths
10421
10559
  })
10422
10560
  });
10423
- const serializeBase = (base, position) => toJsonFile(compact({
10561
+ const serializeBase = (base, position, agentPrompts) => toJsonFile(compact({
10424
10562
  name: base.name,
10425
10563
  description: base.description || void 0,
10426
10564
  position,
10427
10565
  reviewPolicy: base.reviewPolicy,
10566
+ agentPrompts: serializeAgentPrompts(agentPrompts),
10428
10567
  fields: base.fields.map(serializeField),
10429
10568
  views: base.views.map(serializeView)
10430
10569
  }));
@@ -10498,10 +10637,11 @@ const renderNodes = (nodes, dir, files) => {
10498
10637
  const renderNode = (node, dir, files) => {
10499
10638
  switch (node.type) {
10500
10639
  case "folder":
10501
- if (node.children.length === 0 || node.description !== "" || node.position !== void 0 || node.name !== void 0) files.set(`${dir}${node.slug}/${PACKAGE_FOLDER_META_FILENAME}`, toJsonFile(compact({
10640
+ if (node.children.length === 0 || node.description !== "" || node.position !== void 0 || node.agentPrompts !== void 0 || node.name !== void 0) files.set(`${dir}${node.slug}/${PACKAGE_FOLDER_META_FILENAME}`, toJsonFile(compact({
10502
10641
  name: node.name,
10503
10642
  description: node.description || void 0,
10504
- position: node.position
10643
+ position: node.position,
10644
+ agentPrompts: serializeAgentPrompts(node.agentPrompts)
10505
10645
  })));
10506
10646
  renderNodes(node.children, `${dir}${node.slug}/`, files);
10507
10647
  return;
@@ -10509,11 +10649,12 @@ const renderNode = (node, dir, files) => {
10509
10649
  files.set(`${dir}${node.slug}.md`, Buffer.from(serializeDoc({
10510
10650
  name: node.name,
10511
10651
  description: node.description,
10512
- position: node.position
10652
+ position: node.position,
10653
+ agentPrompts: node.agentPrompts
10513
10654
  }, node.body), "utf8"));
10514
10655
  return;
10515
10656
  case "base": {
10516
- files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position));
10657
+ files.set(`${dir}${node.slug}/${PACKAGE_BASE_FILENAME}`, serializeBase(node.base, node.position, node.agentPrompts));
10517
10658
  const records = serializeRecords(node.records);
10518
10659
  if (records.byteLength > 0) files.set(`${dir}${node.slug}/${PACKAGE_RECORDS_FILENAME}`, records);
10519
10660
  return;
@@ -10527,7 +10668,8 @@ const renderNode = (node, dir, files) => {
10527
10668
  type: node.type,
10528
10669
  name: node.name,
10529
10670
  description: node.description || void 0,
10530
- position: node.position
10671
+ position: node.position,
10672
+ agentPrompts: serializeAgentPrompts(node.agentPrompts)
10531
10673
  })));
10532
10674
  for (const entry of [...node.files].sort((a, b) => a.path.localeCompare(b.path, "en"))) {
10533
10675
  assertSafeFilePath(entry.path, `${node.type} "${node.slug}"`);
@@ -10538,10 +10680,11 @@ const renderNode = (node, dir, files) => {
10538
10680
  case "file":
10539
10681
  assertSafeFilePath(node.fileName, `file node "${node.slug}"`);
10540
10682
  files.set(`${dir}${node.fileName}`, node.bytes);
10541
- if (node.name !== node.fileName || node.description !== "" || node.position !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
10683
+ if (node.name !== node.fileName || node.description !== "" || node.position !== void 0 || node.agentPrompts !== void 0) files.set(`${dir}${node.fileName}${PACKAGE_NODE_META_SUFFIX}`, toJsonFile(compact({
10542
10684
  name: node.name,
10543
10685
  description: node.description || void 0,
10544
- position: node.position
10686
+ position: node.position,
10687
+ agentPrompts: serializeAgentPrompts(node.agentPrompts)
10545
10688
  })));
10546
10689
  return;
10547
10690
  }
@@ -10701,7 +10844,8 @@ const renderPackageChoices = (repoUrl, candidates, wanted) => {
10701
10844
  candidate.primaryAirApp ? "app" : void 0
10702
10845
  ].filter(Boolean).join(" · ");
10703
10846
  lines.push(` ${candidate.name} (${shape})`);
10704
- if (candidate.description) lines.push(` ${candidate.description}`);
10847
+ const description = iStringParse(candidate.description, "en");
10848
+ if (description) lines.push(` ${description}`);
10705
10849
  for (const error of candidate.templateErrors) lines.push(` not a template: ${error}`);
10706
10850
  }
10707
10851
  lines.push("");
@@ -10752,7 +10896,7 @@ const runExport = async (client, nodeSlugOrId, options) => {
10752
10896
  tree.rootSkill = {
10753
10897
  slug: manifest.name,
10754
10898
  name: manifest.name,
10755
- description: manifest.description,
10899
+ description: iStringParse(manifest.description, "en"),
10756
10900
  files: [{
10757
10901
  path: PACKAGE_SKILL_ENTRY,
10758
10902
  bytes: Buffer.from(deriveSkillDraft(tree), "utf8")