@bahulam/code 0.1.20 → 0.1.22

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.
@@ -35,6 +35,12 @@ function normalizePathList(value) {
35
35
  return [];
36
36
  }
37
37
 
38
+ function normalizeStringList(value) {
39
+ if (typeof value === 'string' && value.trim()) return [value.trim()];
40
+ if (Array.isArray(value)) return value.map(item => String(item || '').trim()).filter(Boolean);
41
+ return [];
42
+ }
43
+
38
44
  function addAgent(agents, seen, agent) {
39
45
  if (!agent?.slug) return;
40
46
  const key = String(agent.slug).trim().toLowerCase();
@@ -119,6 +125,7 @@ function normalizeAgentDef(agentDef, pluginName, pluginDir) {
119
125
  || ''
120
126
  ),
121
127
  tools: inlineTools.length ? inlineTools : normalizeToolNames(fileTools),
128
+ aliases: normalizeStringList(agentDef.aliases || metadata.aliases || fileConfig.aliases || agent.aliases),
122
129
  model: agentDef.model || agent.model || fileConfig.model || null,
123
130
  models: agentDef.models || agent.models || fileConfig.models || undefined,
124
131
  max_tokens: agentDef.max_tokens || agent.max_tokens || fileConfig.max_tokens || undefined,
@@ -156,6 +163,190 @@ function loadAgentPath(agentPath, pluginDir, pluginName, label) {
156
163
  }
157
164
  }
158
165
 
166
+ // Identifiers that are safe to interpolate into DDL. Manifests are
167
+ // plugin-author controlled, but a typo (or a malicious registry entry)
168
+ // must never be able to escape the quoted identifier and inject SQL.
169
+ const SAFE_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
170
+
171
+ // SQLite type affinities. Anything else collapses to TEXT so a bad
172
+ // manifest degrades into a working table instead of a failed CREATE.
173
+ const SQL_TYPES = new Set(['INTEGER', 'TEXT', 'REAL', 'BLOB', 'NUMERIC']);
174
+
175
+ function normalizeSqlType(value) {
176
+ const raw = String(value || '').trim().toUpperCase();
177
+ if (!raw) return 'TEXT';
178
+ // Accept the common aliases rather than rejecting an otherwise-fine manifest.
179
+ if (raw === 'INT' || raw === 'BIGINT') return 'INTEGER';
180
+ if (raw === 'BOOL' || raw === 'BOOLEAN') return 'INTEGER';
181
+ if (raw === 'FLOAT' || raw === 'DOUBLE') return 'REAL';
182
+ if (raw === 'STRING' || raw === 'VARCHAR' || raw === 'DATETIME' || raw === 'TIMESTAMP' || raw === 'JSON') return 'TEXT';
183
+ return SQL_TYPES.has(raw) ? raw : 'TEXT';
184
+ }
185
+
186
+ /**
187
+ * Normalize `config.state` — the manifest-declared state schema plus the
188
+ * agent-visibility contract for that state.
189
+ *
190
+ * Every plugin already gets a SQLite sidecar for free (the `kv` and
191
+ * `records` tables). This block is how a plugin graduates from "a tool
192
+ * that remembers a cursor" to "a local app that owns its own domain
193
+ * tables" (questions + answers + progress for a tutor, documents for a
194
+ * study aid, and so on).
195
+ *
196
+ * Three parts, matching the three visibility tiers:
197
+ *
198
+ * tables DDL applied idempotently whenever the plugin's state
199
+ * DB is opened. Existing columns are never dropped;
200
+ * new columns are added with ALTER TABLE so a version
201
+ * bump never costs the user their data.
202
+ * context_always Small, high-signal slices injected into the agent
203
+ * context every turn (current lesson, progress).
204
+ * context_tools Read-only tools auto-generated for the agent to call
205
+ * on demand for the larger data (list questions).
206
+ *
207
+ * Anything not declared here stays reachable only from the plugin's own
208
+ * handlers via `state.query()` — the third tier.
209
+ *
210
+ * @param {object|null|undefined} value raw `config.state`
211
+ * @returns {{tables: object[], context_always: object[], context_tools: object[]}}
212
+ */
213
+ function normalizeState(value) {
214
+ const empty = { tables: [], context_always: [], context_tools: [] };
215
+ if (!value || typeof value !== 'object') return empty;
216
+
217
+ const tables = [];
218
+ for (const rawTable of (Array.isArray(value.tables) ? value.tables : [])) {
219
+ if (!rawTable || typeof rawTable !== 'object') continue;
220
+ const name = String(rawTable.name || '').trim();
221
+ if (!SAFE_IDENT_RE.test(name)) {
222
+ console.warn(`Skipping state table with unsafe or missing name: ${JSON.stringify(rawTable.name)}`);
223
+ continue;
224
+ }
225
+
226
+ const columns = [];
227
+ const columnNames = new Set();
228
+ for (const rawCol of (Array.isArray(rawTable.columns) ? rawTable.columns : [])) {
229
+ if (!rawCol || typeof rawCol !== 'object') continue;
230
+ const colName = String(rawCol.name || '').trim();
231
+ if (!SAFE_IDENT_RE.test(colName)) {
232
+ console.warn(`Skipping column with unsafe or missing name in table ${name}: ${JSON.stringify(rawCol.name)}`);
233
+ continue;
234
+ }
235
+ if (columnNames.has(colName)) continue;
236
+ columnNames.add(colName);
237
+
238
+ // `references` is free-form in the manifest, so validate the shape
239
+ // strictly before it reaches a CREATE TABLE string.
240
+ const references = String(rawCol.references || '').trim();
241
+ const safeReferences = /^[A-Za-z_][A-Za-z0-9_]{0,63}\s*\(\s*[A-Za-z_][A-Za-z0-9_]{0,63}\s*\)$/.test(references)
242
+ ? references.replace(/\s+/g, '')
243
+ : null;
244
+ if (references && !safeReferences) {
245
+ console.warn(`Ignoring malformed references "${references}" on ${name}.${colName}`);
246
+ }
247
+
248
+ const primary = rawCol.primary === true || rawCol.primary_key === true || rawCol.primaryKey === true;
249
+ const type = normalizeSqlType(rawCol.type);
250
+ columns.push({
251
+ name: colName,
252
+ type,
253
+ primary,
254
+ // SQLite only allows AUTOINCREMENT on INTEGER PRIMARY KEY.
255
+ autoincrement: (rawCol.autoincrement === true || rawCol.auto_increment === true)
256
+ && primary && type === 'INTEGER',
257
+ not_null: rawCol.not_null === true || rawCol.notNull === true,
258
+ default: rawCol.default === undefined || rawCol.default === null ? null : String(rawCol.default),
259
+ references: safeReferences,
260
+ });
261
+ }
262
+ if (!columns.length) {
263
+ console.warn(`Skipping state table ${name}: no usable columns`);
264
+ continue;
265
+ }
266
+
267
+ const indexes = [];
268
+ for (const rawIndex of (Array.isArray(rawTable.indexes) ? rawTable.indexes : [])) {
269
+ if (!rawIndex || typeof rawIndex !== 'object') continue;
270
+ const source = Array.isArray(rawIndex.columns) ? rawIndex.columns
271
+ : (rawIndex.column ? [rawIndex.column] : []);
272
+ const indexColumns = source
273
+ .map(c => String(c || '').trim())
274
+ .filter(c => SAFE_IDENT_RE.test(c) && columnNames.has(c));
275
+ if (indexColumns.length) {
276
+ indexes.push({ columns: indexColumns, unique: rawIndex.unique === true });
277
+ }
278
+ }
279
+
280
+ tables.push({ name, columns, indexes });
281
+ }
282
+
283
+ const tableNames = new Set(tables.map(t => t.name));
284
+
285
+ // Tier 1 — injected every turn. Accept a bare string as a kv key so the
286
+ // common case stays a one-liner in YAML.
287
+ const contextAlways = [];
288
+ for (const rawEntry of (Array.isArray(value.context_always) ? value.context_always : [])) {
289
+ if (typeof rawEntry === 'string') {
290
+ const key = rawEntry.trim();
291
+ if (key) contextAlways.push({ kind: 'kv', key });
292
+ continue;
293
+ }
294
+ if (!rawEntry || typeof rawEntry !== 'object') continue;
295
+ const stream = String(rawEntry.stream || '').trim();
296
+ if (stream) {
297
+ const limit = Number(rawEntry.limit);
298
+ contextAlways.push({
299
+ kind: 'records',
300
+ stream,
301
+ limit: Number.isFinite(limit) && limit > 0 ? Math.min(Math.trunc(limit), 50) : 5,
302
+ });
303
+ continue;
304
+ }
305
+ const key = String(rawEntry.kv_key || rawEntry.key || '').trim();
306
+ if (key) contextAlways.push({ kind: 'kv', key });
307
+ }
308
+
309
+ // Tier 2 — auto-generated read-only tools.
310
+ const contextTools = [];
311
+ for (const rawTool of (Array.isArray(value.context_tools) ? value.context_tools : [])) {
312
+ if (!rawTool || typeof rawTool !== 'object') continue;
313
+ const name = String(rawTool.name || '').trim();
314
+ if (!SAFE_IDENT_RE.test(name)) {
315
+ console.warn(`Skipping context tool with unsafe or missing name: ${JSON.stringify(rawTool.name)}`);
316
+ continue;
317
+ }
318
+ const table = String(rawTool.table || '').trim();
319
+ if (!tableNames.has(table)) {
320
+ console.warn(`Skipping context tool ${name}: table "${table}" is not declared in config.state.tables`);
321
+ continue;
322
+ }
323
+ const parameters = rawTool.parameters && typeof rawTool.parameters === 'object'
324
+ ? rawTool.parameters
325
+ : { type: 'object', properties: {} };
326
+ // Bind order for a positional `where` clause. Defaults to the declared
327
+ // property order so the simple case needs no extra YAML — minus
328
+ // `limit`, which the CLI consumes itself and must never be bound into
329
+ // the WHERE clause.
330
+ const declaredParams = Array.isArray(rawTool.params)
331
+ ? rawTool.params.map(p => String(p || '').trim()).filter(p => SAFE_IDENT_RE.test(p))
332
+ : Object.keys(parameters.properties || {}).filter(p => p !== 'limit');
333
+ const limit = Number(rawTool.limit);
334
+ contextTools.push({
335
+ name,
336
+ table,
337
+ description: String(rawTool.description || `List rows from ${table}`),
338
+ parameters,
339
+ // Plugin-authored SQL, same trust model as state.query(): the author
340
+ // owns the clause, the CLI binds the values.
341
+ where: String(rawTool.where || '').trim(),
342
+ params: declaredParams,
343
+ limit: Number.isFinite(limit) && limit > 0 ? Math.min(Math.trunc(limit), 500) : 50,
344
+ });
345
+ }
346
+
347
+ return { tables, context_always: contextAlways, context_tools: contextTools };
348
+ }
349
+
159
350
  /**
160
351
  * Parse a plugin manifest from YAML text.
161
352
  * @param {string} yamlText - Raw YAML content
@@ -214,7 +405,7 @@ export function normalizeManifest(raw, source = '') {
214
405
  }
215
406
 
216
407
  const meta = raw.metadata || raw.meta || {};
217
- const config = raw.config || raw.plugin || {};
408
+ const config = raw.config || raw.plugin || raw.spec || {};
218
409
  const name = meta.name || config.name || '';
219
410
  if (!name) {
220
411
  if (process.env.DEBUG) {
@@ -308,6 +499,7 @@ export function normalizeManifest(raw, source = '') {
308
499
  // config for the local plugin without editing mcp.json.
309
500
  const mcpServers = _readMcpServers(config.mcpServers, source);
310
501
  const composes = normalizeComposes(config.composes);
502
+ const state = normalizeState(config.state);
311
503
 
312
504
  return {
313
505
  apiVersion,
@@ -327,6 +519,7 @@ export function normalizeManifest(raw, source = '') {
327
519
  views,
328
520
  mcpServers,
329
521
  composes,
522
+ state,
330
523
  },
331
524
  source,
332
525
  _dir: source ? path.dirname(source) : '',
@@ -408,6 +601,19 @@ export function validatePluginManifest(manifest) {
408
601
  for (const agent of (manifest.config.agents || [])) {
409
602
  if (!agent.slug && !agent.name) errors.push('Agent missing slug or name');
410
603
  }
604
+
605
+ // State declarations that silently vanished during normalization are
606
+ // exactly the kind of thing an author wants to hear about at validate
607
+ // time rather than discover at runtime.
608
+ const state = manifest.config.state;
609
+ if (state) {
610
+ const declaredTables = new Set((state.tables || []).map(t => t.name));
611
+ for (const tool of (state.context_tools || [])) {
612
+ if (!declaredTables.has(tool.table)) {
613
+ errors.push(`State context tool "${tool.name}" references undeclared table "${tool.table}"`);
614
+ }
615
+ }
616
+ }
411
617
  }
412
618
 
413
619
  return {
@@ -19,6 +19,7 @@
19
19
  import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import { execSync } from 'node:child_process';
22
+ import { load as yamlLoad } from 'js-yaml';
22
23
 
23
24
  export const REQUIREMENTS_FILE = '.bahulam-requirements.json';
24
25
 
@@ -168,6 +169,7 @@ function analyzeFile(text, rel, findings) {
168
169
  if (!name) continue;
169
170
  // Ignore Node.js / OS-level env vars users don't set for a plugin.
170
171
  if (['NODE_ENV', 'PATH', 'HOME', 'USER', 'PWD', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'TMP', 'TEMP'].includes(name)) continue;
172
+ if (/_PREFLIGHT$/.test(name)) continue;
171
173
  const existing = findings.envVars.get(name) || { name, seen_in: new Set(), credential: false };
172
174
  existing.seen_in.add(rel);
173
175
  if (CREDENTIAL_SUFFIXES.some(s => name.endsWith(s))) existing.credential = true;
@@ -278,6 +280,70 @@ function extractSkillsFiles(pluginDir) {
278
280
  return skills;
279
281
  }
280
282
 
283
+ function readManifestRequirements(pluginDir) {
284
+ for (const name of ['plugin.yaml', 'plugin.yml']) {
285
+ const filePath = path.join(pluginDir, name);
286
+ if (!fs.existsSync(filePath)) continue;
287
+ try {
288
+ const doc = yamlLoad(fs.readFileSync(filePath, 'utf-8')) || {};
289
+ const reqs = doc.config?.requirements;
290
+ return reqs && typeof reqs === 'object' ? reqs : null;
291
+ } catch {
292
+ return null;
293
+ }
294
+ }
295
+ return null;
296
+ }
297
+
298
+ function normalizeManifestRequirementList(value) {
299
+ if (!value) return [];
300
+ if (Array.isArray(value)) return value;
301
+ if (typeof value === 'string') return [value];
302
+ if (typeof value === 'object') return Object.entries(value).map(([name, detail]) => (
303
+ detail && typeof detail === 'object' ? { name, ...detail } : { name, reason: String(detail || '') }
304
+ ));
305
+ return [];
306
+ }
307
+
308
+ function mergeManifestRequirements(shape, manifestRequirements) {
309
+ if (!manifestRequirements) return shape;
310
+
311
+ const binaries = new Map((shape.system_binaries || []).map(b => [b.name, b]));
312
+ for (const raw of normalizeManifestRequirementList(manifestRequirements.system)) {
313
+ const name = typeof raw === 'string' ? raw : raw?.name;
314
+ const clean = String(name || '').trim();
315
+ if (!clean) continue;
316
+ const existing = binaries.get(clean) || { name: clean, install_hints: INSTALL_HINTS[clean] || null, seen_in: [] };
317
+ existing.install_hints = raw.install_hints || raw.installHints || existing.install_hints || INSTALL_HINTS[clean] || null;
318
+ existing.version = raw.version || existing.version || null;
319
+ existing.reason = raw.reason || existing.reason || 'Declared by plugin manifest.';
320
+ existing.optional = raw.optional === true || existing.optional === true;
321
+ existing.seen_in = [...new Set([...(existing.seen_in || []), 'plugin.yaml:config.requirements.system'])].slice(0, 5);
322
+ binaries.set(clean, existing);
323
+ }
324
+ shape.system_binaries = [...binaries.values()].sort((a, b) => a.name.localeCompare(b.name));
325
+
326
+ const envVars = new Map((shape.env_vars || []).map(v => [v.name, v]));
327
+ for (const raw of normalizeManifestRequirementList(manifestRequirements.env)) {
328
+ const name = typeof raw === 'string' ? raw : raw?.name;
329
+ const clean = String(name || '').trim();
330
+ if (!clean) continue;
331
+ const existing = envVars.get(clean) || { name: clean, seen_in: [], credential: false };
332
+ existing.credential = raw.credential === true || existing.credential || CREDENTIAL_SUFFIXES.some(s => clean.endsWith(s));
333
+ existing.optional = raw.optional === true || existing.optional === true;
334
+ existing.reason = raw.reason || existing.reason || 'Declared by plugin manifest.';
335
+ existing.seen_in = [...new Set([...(existing.seen_in || []), 'plugin.yaml:config.requirements.env'])].slice(0, 5);
336
+ envVars.set(clean, existing);
337
+ }
338
+ shape.env_vars = [...envVars.values()].sort((a, b) => a.name.localeCompare(b.name));
339
+
340
+ const files = normalizeManifestRequirementList(manifestRequirements.filesystem);
341
+ if (files.length) {
342
+ shape.filesystem = files.map(item => typeof item === 'string' ? { path: item } : item).filter(Boolean);
343
+ }
344
+ return shape;
345
+ }
346
+
281
347
  function extractToolConstraints(discoveredTools) {
282
348
  const out = {};
283
349
  for (const tool of discoveredTools || []) {
@@ -377,6 +443,7 @@ export function analyzeRequirements(pluginDir, { discoveredTools = null } = {})
377
443
  skills_available: skills,
378
444
  tool_constraints: toolConstraints,
379
445
  };
446
+ mergeManifestRequirements(shape, readManifestRequirements(pluginDir));
380
447
 
381
448
  try {
382
449
  fs.writeFileSync(path.join(pluginDir, REQUIREMENTS_FILE), JSON.stringify(shape, null, 2));
@@ -462,4 +529,3 @@ export function checkRequirementsAgainstHost(reqs) {
462
529
 
463
530
  return results;
464
531
  }
465
-
@@ -81,6 +81,91 @@ function truncate(s, n) {
81
81
  return str.slice(0, n - 1) + '…';
82
82
  }
83
83
 
84
+ function renderStandardRequirements(requirements) {
85
+ const lines = [' requirements:'];
86
+ const bins = requirements?.system_binaries || [];
87
+ lines.push(' system:');
88
+ if (bins.length) {
89
+ for (const b of bins) {
90
+ lines.push(` - name: ${yamlString(b.name)}`);
91
+ lines.push(` reason: Required by composed pi tool runtime.`);
92
+ if (b.install_hints) {
93
+ lines.push(' install_hints:');
94
+ if (b.install_hints.darwin) lines.push(` darwin: ${yamlString(b.install_hints.darwin)}`);
95
+ if (b.install_hints.linux) lines.push(` linux: ${yamlString(b.install_hints.linux)}`);
96
+ }
97
+ }
98
+ } else {
99
+ lines.push(' []');
100
+ }
101
+
102
+ const envVars = requirements?.env_vars || [];
103
+ lines.push(' env:');
104
+ if (envVars.length) {
105
+ for (const v of envVars) {
106
+ lines.push(` - name: ${yamlString(v.name)}`);
107
+ lines.push(` credential: ${v.credential ? 'true' : 'false'}`);
108
+ lines.push(` optional: true`);
109
+ lines.push(` reason: Read by composed pi package when that feature is used.`);
110
+ }
111
+ } else {
112
+ lines.push(' []');
113
+ }
114
+
115
+ lines.push(' filesystem:');
116
+ lines.push(' - path: .bahulam/plugin-state');
117
+ lines.push(' access: read_write');
118
+ lines.push(' reason: Stores durable notebook records and reports for this generated pack.');
119
+ return lines;
120
+ }
121
+
122
+ function renderStandardState(slug) {
123
+ return [
124
+ ' state:',
125
+ ' tables:',
126
+ ' - name: items',
127
+ ' columns:',
128
+ ' - { name: id, type: INTEGER, primary: true, autoincrement: true }',
129
+ ' - { name: title, type: TEXT, not_null: true }',
130
+ ' - { name: source, type: TEXT, not_null: true, default: "\'\'" }',
131
+ ' - { name: notes, type: TEXT, not_null: true, default: "\'\'" }',
132
+ ' - { name: topic, type: TEXT, not_null: true, default: "\'\'" }',
133
+ ' - { name: created_at, type: TEXT, not_null: true }',
134
+ ' - { name: updated_at, type: TEXT, not_null: true }',
135
+ ' indexes:',
136
+ ' - { columns: [topic] }',
137
+ '',
138
+ ' - name: reports',
139
+ ' columns:',
140
+ ' - { name: id, type: INTEGER, primary: true, autoincrement: true }',
141
+ ' - { name: title, type: TEXT, not_null: true }',
142
+ ' - { name: summary, type: TEXT, not_null: true }',
143
+ ' - { name: item_count, type: INTEGER, not_null: true, default: "0" }',
144
+ ' - { name: created_at, type: TEXT, not_null: true }',
145
+ '',
146
+ ' context_always:',
147
+ ` - { stream: ${slug}_activity, limit: 5 }`,
148
+ '',
149
+ ' context_tools:',
150
+ ' - name: list_saved_items',
151
+ ' table: items',
152
+ ' description: List durable notebook items saved by this plugin.',
153
+ ' parameters:',
154
+ ' type: object',
155
+ ' properties:',
156
+ ' limit: { type: integer, description: "Max rows to return." }',
157
+ '',
158
+ ' - name: list_saved_reports',
159
+ ' table: reports',
160
+ ' description: List generated outcome reports.',
161
+ ' parameters:',
162
+ ' type: object',
163
+ ' properties:',
164
+ ' limit: { type: integer, description: "Max rows to return." }',
165
+ '',
166
+ ];
167
+ }
168
+
84
169
  /**
85
170
  * Compose the "Requirements & constraints" block from the analyzer's
86
171
  * findings. Injected into the generated agent's system prompt so the
@@ -185,7 +270,7 @@ function generatePrompt(packageName, namespace, toolNames, hasState, requirement
185
270
  * (quoted keys, over-escaping); a small emitter here yields a diff-
186
271
  * friendly manifest the user can edit.
187
272
  */
188
- function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, hasState, hasWorkspace }) {
273
+ function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, hasState, hasWorkspace, requirements }) {
189
274
  const versionSpec = versionRange ? `${packageName}@${versionRange}` : packageName;
190
275
  const tools = hasState ? [
191
276
  ' tools:',
@@ -223,6 +308,17 @@ function renderManifest({ slug, packageName, versionRange, namespace, exposeTool
223
308
  ' id: { type: integer, description: "Item id (from list_items)" }',
224
309
  ' required: [id]',
225
310
  '',
311
+ ' - name: build_report',
312
+ ' description: Build a durable outcome report from saved notebook items.',
313
+ ` tool: ./tools/build-report.mjs`,
314
+ ' parameters:',
315
+ ' type: object',
316
+ ' properties:',
317
+ ' title: { type: string, description: "Report title." }',
318
+ ' topic: { type: string, description: "Optional topic filter." }',
319
+ ' summary: { type: string, description: "Optional agent-authored summary." }',
320
+ ' required: [title]',
321
+ '',
226
322
  ] : [' tools: []', ''];
227
323
 
228
324
  const composesBlock = [
@@ -254,17 +350,22 @@ function renderManifest({ slug, packageName, versionRange, namespace, exposeTool
254
350
  ` Edit tools/, workspace/, and this manifest to customize.`,
255
351
  '',
256
352
  'config:',
353
+ ...renderStandardRequirements(requirements),
354
+ '',
355
+ ...(hasState ? renderStandardState(slug) : []),
257
356
  ...tools,
258
357
  ...composesBlock,
259
358
  ' workspace: ./config/workspace.yaml',
359
+ ' agents_from: ./config/agents/',
260
360
  '',
261
361
  ...workspaceBlock,
262
362
  ].join('\n');
263
363
  }
264
364
 
265
- function renderAgentFile({ namespace, exposeTools, agentSlug, agentDescription, hasState, systemPrompt }) {
365
+ function renderAgentFile({ namespace, exposeTools, agentSlug, agentAliases = [], agentDescription, hasState, systemPrompt }) {
266
366
  const agentToolRefs = [
267
367
  ...(hasState ? ['save_item', 'list_items', 'drop_item'] : []),
368
+ ...(hasState ? ['build_report'] : []),
268
369
  ...exposeTools.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`),
269
370
  ];
270
371
 
@@ -276,6 +377,7 @@ function renderAgentFile({ namespace, exposeTools, agentSlug, agentDescription,
276
377
  'metadata:',
277
378
  ` slug: ${agentSlug}`,
278
379
  ` name: ${yamlString(agentSlug.replace(/-/g, ' '))}`,
380
+ ...(agentAliases.length ? [' aliases:', ...agentAliases.map(alias => ` - ${yamlString(alias)}`)] : []),
279
381
  ' role: specialist',
280
382
  ' description: >',
281
383
  ` ${agentDescription}`,
@@ -288,6 +390,29 @@ function renderAgentFile({ namespace, exposeTools, agentSlug, agentDescription,
288
390
  ].join('\n');
289
391
  }
290
392
 
393
+ function renderReviewerAgent({ slug, hasState }) {
394
+ const tools = hasState ? ['list_items', 'build_report'] : [];
395
+ return [
396
+ 'apiVersion: agent.framework/v1',
397
+ 'kind: SubAgent',
398
+ 'metadata:',
399
+ ' slug: outcome-reviewer',
400
+ ' name: Outcome Reviewer',
401
+ ' role: reviewer',
402
+ ' description: >',
403
+ ` Reviews ${slug} outputs, checks whether saved evidence is enough, and prepares a concise handoff report.`,
404
+ 'agent:',
405
+ ' can_be_delegated_to: true',
406
+ ' system_prompt: |',
407
+ ` You are the Outcome Reviewer for ${slug}.`,
408
+ ' Inspect saved items and tool outputs, identify missing evidence, and produce a compact handoff.',
409
+ ' If state tools are available, call list_items first and build_report when the outcome is ready.',
410
+ 'tools:',
411
+ ...tools.map(t => ` - ${t}`),
412
+ '',
413
+ ].join('\n');
414
+ }
415
+
291
416
  const SAVE_ITEM_TOOL = `/**
292
417
  * save_item — persist a single item to the pack's notebook.
293
418
  * Append-style records so a topic can accumulate many entries over time.
@@ -307,6 +432,17 @@ export async function call(args = {}, options = {}) {
307
432
  topic: String(args.topic || '').trim(),
308
433
  at: new Date().toISOString(),
309
434
  };
435
+ if (typeof state.query === 'function') {
436
+ const info = state.query('INSERT INTO items (title, source, notes, topic, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)', [
437
+ item.title, item.source, item.notes, item.topic, item.at, item.at,
438
+ ]);
439
+ if (typeof state.append === 'function') state.append('items', item);
440
+ return {
441
+ success: true,
442
+ output: \`Saved item #\${info?.lastInsertRowid || ''}: \${title}\`,
443
+ item: { ...item, id: info?.lastInsertRowid },
444
+ };
445
+ }
310
446
  const record = state.append('items', item);
311
447
  return {
312
448
  success: true,
@@ -325,6 +461,17 @@ export async function call(args = {}, options = {}) {
325
461
 
326
462
  const topic = String(args.topic || '').trim().toLowerCase();
327
463
  const limit = Math.max(1, Math.min(500, Number(args.limit) || 50));
464
+ if (typeof state.query === 'function') {
465
+ const rows = topic
466
+ ? state.query('SELECT * FROM items WHERE lower(topic) = ? ORDER BY id DESC LIMIT ?', [topic, limit])
467
+ : state.query('SELECT * FROM items ORDER BY id DESC LIMIT ?', [limit]);
468
+ const summary = rows.map(r => \`#\${r.id} · \${r.title || ''}\${r.topic ? ' (' + r.topic + ')' : ''}\`).join('\\n');
469
+ return {
470
+ success: true,
471
+ output: rows.length ? summary : (topic ? \`No items for topic '\${topic}'\` : 'No items yet'),
472
+ items: rows,
473
+ };
474
+ }
328
475
  const rows = state.list('items', { limit, order: 'desc' }) || [];
329
476
  const filtered = topic
330
477
  ? rows.filter(r => String(r.payload?.topic || '').toLowerCase() === topic)
@@ -349,7 +496,14 @@ export async function call(args = {}, options = {}) {
349
496
  const state = options.state ? await options.state : null;
350
497
  if (!state) return { success: false, output: 'Shared blackboard unavailable' };
351
498
 
352
- const info = state.db.prepare('DELETE FROM records WHERE stream = ? AND id = ?').run('items', id);
499
+ let info = null;
500
+ if (typeof state.query === 'function') {
501
+ info = state.query('DELETE FROM items WHERE id = ?', [id]);
502
+ } else if (state.db?.prepare) {
503
+ info = state.db.prepare('DELETE FROM records WHERE stream = ? AND id = ?').run('items', id);
504
+ } else {
505
+ return { success: false, output: 'delete is unavailable in this state adapter' };
506
+ }
353
507
  return {
354
508
  success: info.changes > 0,
355
509
  output: info.changes > 0 ? \`Dropped item #\${id}\` : \`No item with id \${id}\`,
@@ -357,6 +511,50 @@ export async function call(args = {}, options = {}) {
357
511
  }
358
512
  `;
359
513
 
514
+ const BUILD_REPORT_TOOL = `/**
515
+ * build_report — create a concise durable report from saved notebook items.
516
+ */
517
+ export async function call(args = {}, options = {}) {
518
+ const title = String(args.title || '').trim();
519
+ if (!title) return { success: false, output: 'title is required' };
520
+ const topic = String(args.topic || '').trim().toLowerCase();
521
+ const state = options.state ? await options.state : null;
522
+ if (!state) return { success: false, output: 'Shared blackboard unavailable' };
523
+
524
+ let items = [];
525
+ if (typeof state.query === 'function') {
526
+ items = topic
527
+ ? state.query('SELECT * FROM items WHERE lower(topic) = ? ORDER BY id DESC LIMIT 100', [topic])
528
+ : state.query('SELECT * FROM items ORDER BY id DESC LIMIT 100');
529
+ } else {
530
+ const rows = state.list('items', { limit: 100, order: 'desc' }) || [];
531
+ items = rows.map(r => ({ id: r.id, ...(r.payload || {}), created_at: r.created_at }))
532
+ .filter(r => !topic || String(r.topic || '').toLowerCase() === topic);
533
+ }
534
+ const summary = String(args.summary || '').trim() || (
535
+ items.length
536
+ ? \`Report from \${items.length} saved item\${items.length === 1 ? '' : 's'}.\`
537
+ : 'No saved items available yet.'
538
+ );
539
+ const createdAt = new Date().toISOString();
540
+ let reportId = null;
541
+ if (typeof state.query === 'function') {
542
+ const info = state.query('INSERT INTO reports (title, summary, item_count, created_at) VALUES (?, ?, ?, ?)', [
543
+ title, summary, items.length, createdAt,
544
+ ]);
545
+ reportId = info?.lastInsertRowid || null;
546
+ }
547
+ if (typeof state.append === 'function') {
548
+ const record = state.append('reports', { title, summary, item_count: items.length, topic, created_at: createdAt });
549
+ reportId ||= record?.id || null;
550
+ }
551
+ return {
552
+ success: true,
553
+ output: { id: reportId, title, summary, item_count: items.length, topic: topic || null, items },
554
+ };
555
+ }
556
+ `;
557
+
360
558
  function renderPanel(slug, packageName, composedToolNames) {
361
559
  const example = composedToolNames[0] || 'example_tool';
362
560
  return `<!doctype html>
@@ -523,7 +721,8 @@ export function scaffoldPiPack({
523
721
  }
524
722
  fs.mkdirSync(dest, { recursive: true });
525
723
 
526
- const agentSlug = `${namespace}-specialist`;
724
+ const agentSlug = slug;
725
+ const agentAliases = [`${namespace}-specialist`].filter(alias => alias !== agentSlug);
527
726
  const agentDescription = truncate(
528
727
  `Specialist agent for ${packageName}. Composes ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} exposed as ${namespace}${COMPOSED_TOOL_SEPARATOR}*.`,
529
728
  240,
@@ -547,19 +746,24 @@ export function scaffoldPiPack({
547
746
  exposeTools: toolNames,
548
747
  hasState: state,
549
748
  hasWorkspace: workspace,
749
+ requirements,
550
750
  });
551
751
  fs.writeFileSync(path.join(dest, 'plugin.yaml'), manifest);
552
752
 
553
753
  const configDir = path.join(dest, 'config');
554
754
  fs.mkdirSync(configDir, { recursive: true });
755
+ const agentsDir = path.join(configDir, 'agents');
756
+ fs.mkdirSync(agentsDir, { recursive: true });
555
757
  fs.writeFileSync(path.join(configDir, 'workspace.yaml'), renderAgentFile({
556
758
  namespace,
557
759
  exposeTools: toolNames,
558
760
  agentSlug,
761
+ agentAliases,
559
762
  agentDescription,
560
763
  hasState: state,
561
764
  systemPrompt,
562
765
  }));
766
+ fs.writeFileSync(path.join(agentsDir, 'outcome-reviewer.yaml'), renderReviewerAgent({ slug, hasState: state }));
563
767
 
564
768
  if (state) {
565
769
  const toolsDir = path.join(dest, 'tools');
@@ -567,6 +771,7 @@ export function scaffoldPiPack({
567
771
  fs.writeFileSync(path.join(toolsDir, 'save-item.mjs'), SAVE_ITEM_TOOL);
568
772
  fs.writeFileSync(path.join(toolsDir, 'list-items.mjs'), LIST_ITEMS_TOOL);
569
773
  fs.writeFileSync(path.join(toolsDir, 'drop-item.mjs'), DROP_ITEM_TOOL);
774
+ fs.writeFileSync(path.join(toolsDir, 'build-report.mjs'), BUILD_REPORT_TOOL);
570
775
  }
571
776
 
572
777
  if (workspace) {