diff --git a/README.md b/README.md index 7151ab8..4c212b1 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,25 @@ agentbase snapshot [-b BOARD] [-o FILE] # Export board to YAML agentbase migrate:from-trello-yaml FILE # Import from old trello.yaml ``` +### Data Model Templates (plugins) + +agentbase boards declare their **data model** via a pinned `🧬 DATA MODEL: ` card. +Templates are pluggable β€” built-in (`status-pipeline`, `correspondence-versioned`), +user-local (`~/.agentbase/templates/*.yaml`), or npm packages prefixed `agentbase-template-*`. + +```bash +agentbase template ls # list installed templates +agentbase template info # template details + schema check +agentbase template scaffold [-o FILE] # starter YAML for a new template + +agentbase model show [-b BOARD] # explain board's data model +agentbase model validate [-b BOARD] # check board against template rules +agentbase model declare [-b BOARD] -t # add the model card to a board +``` + +Boards without a declaration are assumed `status-pipeline@0` for backward compat. +Authoring a new template? See the convention `board-template-plugins`. + ## Configuration ### Config file: `.agentbase/agentbase.yml` diff --git a/package.json b/package.json index 077c00d..e2b62fb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "agentbase": "dist/index.js" }, "scripts": { - "build": "tsc", + "build": "tsc && npm run copy-templates", + "copy-templates": "mkdir -p dist/templates/builtin && cp src/templates/builtin/*.yaml dist/templates/builtin/", "test": "node --test dist/**/*.test.js 2>/dev/null || echo 'no tests'", "pretest": "npm run build", "lint": "tsc --noEmit", diff --git a/src/commands/model.ts b/src/commands/model.ts new file mode 100644 index 0000000..797c3f9 --- /dev/null +++ b/src/commands/model.ts @@ -0,0 +1,221 @@ +/** + * `agentbase model {show,validate,declare,untracked}` β€” board-level + * data-model operations driven by the template plugin system. + */ + +import type { VendorAdapter } from '../types.js'; +import { findById } from '../templates/loader.js'; +import { validateBoardAgainstTemplate, validateTemplateSpec } from '../templates/validator.js'; +import { + buildBoardLite, + readModelDeclaration, + resolveTemplateId, + ASSUMED_DEFAULT_TEMPLATE_ID, +} from '../templates/declaration.js'; +import type { TemplateSpec } from '../templates/types.js'; + +interface ShowOpts { + json?: boolean; +} + +/** + * agentbase model show -b + */ +export async function cmdModelShow( + adapter: VendorAdapter, + boardId: string, + opts: ShowOpts = {} +): Promise { + const { id, declared, declaration } = await resolveTemplateId(adapter, boardId); + const rec = findById(id); + + if (opts.json) { + process.stdout.write( + JSON.stringify( + { + boardId, + templateId: id, + declared, + declarationCardId: declaration?.cardId ?? null, + template: rec ? rec.spec : null, + source: rec?.source ?? null, + }, + null, + 2 + ) + '\n' + ); + return; + } + + if (!rec) { + console.log(`Board: ${boardId}`); + console.log(`Template: ${id} (${declared ? 'declared' : 'assumed default'})`); + console.log(''); + console.log(`⚠️ Template "${id}" not found in registry.`); + console.log(` Install via: agentbase template install agentbase-template-${id}`); + process.exit(2); + } + + const spec = rec.spec; + console.log(`Board: ${boardId}`); + console.log( + `Template: ${spec.id} (v${spec.version}) β€” source: ${rec.source}` + + (declared ? '' : ` [assumed default; no 🧬 DATA MODEL card found]`) + ); + console.log(`Name: ${spec.name}`); + if (spec.description) { + console.log(''); + console.log(spec.description.trim()); + } + console.log(''); + console.log('Axes:'); + console.log(` X (cards within a list): ${spec.axes.x.represents}`); + console.log(` Y (lists, leftβ†’right): ${spec.axes.y.represents}`); + console.log(` Z (per-card status): ${spec.axes.z.represents}`); + if (spec['status-schema']) { + console.log(''); + console.log(`Status (location: ${spec['status-schema'].location}):`); + for (const v of spec['status-schema'].values) { + const e = v.emoji ? `${v.emoji} ` : ''; + console.log(` ${e}${v.key.padEnd(20)} ${v.meaning}`); + } + } + if (spec['required-lists'] && spec['required-lists'].length > 0) { + console.log(''); + console.log('Required lists:'); + for (const r of spec['required-lists']) { + const min = r['min-count'] ?? 0; + const max = r['max-count'] === null || r['max-count'] === undefined ? '∞' : r['max-count']; + console.log(` - ${r.id} (${r.role}): /${r.pattern}/ min=${min} max=${max}`); + } + } + if (declaration) { + console.log(''); + console.log(`Declaration card: ${declaration.cardId}`); + } +} + +/** + * agentbase model validate -b + */ +export async function cmdModelValidate( + adapter: VendorAdapter, + boardId: string, + opts: ShowOpts = {} +): Promise { + const { id, declared } = await resolveTemplateId(adapter, boardId); + const rec = findById(id); + if (!rec) { + if (opts.json) { + process.stdout.write( + JSON.stringify({ ok: false, error: `template ${id} not found` }) + '\n' + ); + } else { + console.error(`Template "${id}" not found in registry.`); + } + process.exit(2); + } + const board = await buildBoardLite(adapter, boardId); + const result = validateBoardAgainstTemplate(board, rec.spec); + if (opts.json) { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + if (!result.ok) process.exit(1); + return; + } + + console.log( + `Validating board ${boardId} against template ${id}` + + (declared ? '' : ' (assumed default)') + ); + if (result.findings.length === 0) { + console.log('βœ… PASS β€” no findings.'); + return; + } + for (const f of result.findings) { + const icon = f.severity === 'error' ? '❌' : f.severity === 'warning' ? '⚠️ ' : 'ℹ️ '; + console.log(`${icon} [${f.rule}] ${f.message}`); + } + if (!result.ok) { + console.log(''); + console.log('FAIL β€” validation errors found.'); + process.exit(1); + } else { + console.log(''); + console.log('PASS (with warnings).'); + } +} + +/** + * agentbase model declare -b -t + * + * Creates the 🧬 DATA MODEL card on the board's first list using the + * template's init.cards model card definition. + */ +export async function cmdModelDeclare( + adapter: VendorAdapter, + boardId: string, + templateId: string +): Promise { + const rec = findById(templateId); + if (!rec) { + console.error(`Template "${templateId}" not found.`); + process.exit(2); + } + const lists = await adapter.lists(boardId); + if (lists.length === 0) { + console.error(`Board ${boardId} has no lists. Add lists first or use 'agentbase init'.`); + process.exit(1); + } + // Check if a model card already exists + const existing = await readModelDeclaration(adapter, boardId); + if (existing) { + console.error( + `Board already has a model declaration card (${existing.cardId}, template=${existing.templateId}).` + ); + console.error('Edit it directly via the vendor UI or remove it first.'); + process.exit(1); + } + // Find the model card in init.cards + const modelCard = rec.spec.init?.cards?.find(c => c.name.includes('🧬 DATA MODEL')); + if (!modelCard) { + console.error(`Template "${templateId}" has no init.cards model declaration.`); + process.exit(2); + } + const desc = (modelCard.desc || '').trim(); + const created = await adapter.cardCreate({ + listId: lists[0].id, + name: modelCard.name, + desc, + boardId, + }); + console.log(`Created model declaration card: ${created.id}`); + console.log(`Template: ${templateId}`); + console.log('Run `agentbase model show -b ` to verify.'); +} + +/** + * Internal helper used by tests and template info command. + */ +export function dumpTemplate(spec: TemplateSpec): void { + const findings = validateTemplateSpec(spec); + console.log(`Template: ${spec.id} (v${spec.version})`); + console.log(`Name: ${spec.name}`); + if (spec.description) console.log(`Desc: ${spec.description.trim().split('\n')[0]}`); + console.log(`Axes X: ${spec.axes?.x?.represents ?? '(missing)'}`); + console.log(`Axes Y: ${spec.axes?.y?.represents ?? '(missing)'}`); + console.log(`Axes Z: ${spec.axes?.z?.represents ?? '(missing)'}`); + if (spec['status-schema']?.values) { + console.log(`Statuses: ${spec['status-schema'].values.map(v => v.key).join(', ')}`); + } + if (findings.length > 0) { + console.log(''); + console.log('Schema findings:'); + for (const f of findings) { + const icon = f.severity === 'error' ? '❌' : '⚠️ '; + console.log(` ${icon} [${f.rule}] ${f.message}`); + } + } +} + +// re-export for index.ts convenience +export { ASSUMED_DEFAULT_TEMPLATE_ID }; diff --git a/src/commands/template.ts b/src/commands/template.ts new file mode 100644 index 0000000..f473c81 --- /dev/null +++ b/src/commands/template.ts @@ -0,0 +1,148 @@ +/** + * `agentbase template {ls,info,scaffold}` β€” registry-level operations. + */ + +import { existsSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { loadAll, findById, loadFromPath } from '../templates/loader.js'; +import { dumpTemplate } from './model.js'; + +export function cmdTemplateList(opts: { json?: boolean } = {}): void { + const records = loadAll(); + if (opts.json) { + process.stdout.write( + JSON.stringify( + records.map(r => ({ + id: r.spec.id, + version: r.spec.version, + name: r.spec.name, + source: r.source, + path: r.path, + })), + null, + 2 + ) + '\n' + ); + return; + } + if (records.length === 0) { + console.log('(no templates discovered)'); + return; + } + console.log(`Found ${records.length} template(s):`); + console.log(''); + for (const r of records) { + console.log(` ${r.spec.id.padEnd(32)} v${r.spec.version} [${r.source}]`); + console.log(` ${r.spec.name}`); + if (r.spec.description) { + console.log(` ${r.spec.description.trim().split('\n')[0]}`); + } + console.log(` ${r.path}`); + console.log(''); + } +} + +export function cmdTemplateInfo(idOrPath: string): void { + // If looks like a path (./, /, contains .yaml), load from path + const looksLikePath = + idOrPath.startsWith('./') || + idOrPath.startsWith('/') || + idOrPath.startsWith('../') || + idOrPath.endsWith('.yaml') || + idOrPath.endsWith('.yml'); + if (looksLikePath) { + const abs = resolve(idOrPath); + if (!existsSync(abs)) { + console.error(`File not found: ${abs}`); + process.exit(1); + } + const rec = loadFromPath(abs); + console.log(`Path: ${rec.path}`); + console.log(`Source: filesystem (loaded directly)`); + console.log(''); + dumpTemplate(rec.spec); + return; + } + const rec = findById(idOrPath); + if (!rec) { + console.error(`Template "${idOrPath}" not found in registry.`); + console.error(`Run 'agentbase template ls' to see installed templates.`); + process.exit(1); + } + console.log(`Source: ${rec.source}`); + console.log(`Path: ${rec.path}`); + console.log(''); + dumpTemplate(rec.spec); +} + +const SCAFFOLD_TEMPLATE = (id: string) => + `id: ${id} +version: 1 +name: "${id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}" +description: | + TODO: one-paragraph summary of when to use this template. + +axes: + x: + represents: "TODO: what cards within a list mean" + cardinality: many + y: + represents: "TODO: what each list means" + cardinality: many + ordered: true + z: + represents: "TODO: where per-card status lives" + location: list-membership + +card-storage: + mode: single + +status-schema: + location: list-membership + values: + - key: todo + emoji: "πŸ“" + meaning: "Not started" + - key: done + emoji: "βœ…" + meaning: "Completed" + +required-lists: + - id: todo + pattern: "^πŸ“ To Do" + role: status + min-count: 1 + +behaviours: + require-model-declaration-card: true + +init: + lists: + - name: "πŸ“ To Do" + pos: 1 + - name: "βœ… Done" + pos: 2 + cards: + - list: "πŸ“ To Do" + name: "🧬 DATA MODEL: ${id} (read first)" + pos: top + desc: | + # 🧬 Data Model: ${id} + + TODO: explain X, Y, Z axes; status values; how to use; why this model. +`; + +export function cmdTemplateScaffold(id: string, opts: { out?: string } = {}): void { + if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { + console.error(`Template id must be kebab-case (a-z, 0-9, hyphens), got: ${id}`); + process.exit(1); + } + const yaml = SCAFFOLD_TEMPLATE(id); + if (opts.out) { + writeFileSync(opts.out, yaml); + console.log(`Wrote scaffold to ${opts.out}`); + } else { + process.stdout.write(yaml); + } +} diff --git a/src/index.ts b/src/index.ts index ebd3486..f549c29 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,8 @@ import { cmdSync } from './commands/sync.js'; import { cmdSnapshot } from './commands/snapshot.js'; import { cmdChecklists, cmdChecklistCreate, cmdChecklistDelete, cmdCheckItemAdd, cmdCheckItemUpdate, cmdCheckItemDelete } from './commands/checklists.js'; import { cmdMigrateFromTrelloYaml } from './commands/migrate.js'; +import { cmdModelShow, cmdModelValidate, cmdModelDeclare } from './commands/model.js'; +import { cmdTemplateList, cmdTemplateInfo, cmdTemplateScaffold } from './commands/template.js'; import type { VendorAdapter, AgentbaseConfig } from './types.js'; import { resolve } from 'node:path'; @@ -144,6 +146,12 @@ COMMANDS checklist:remove Remove checklist item migrate:from-trello-yaml Import from old trello.yaml + model show [-b BOARD] Show declared/assumed template for board + model validate [-b BOARD] Validate board against its template + model declare [-b BOARD] -t ID Create the 🧬 DATA MODEL card + template ls List discovered template plugins + template info Show template details + schema findings + template scaffold [-o FILE] Emit a starter template YAML version Show version help Show this help @@ -188,6 +196,34 @@ async function main(): Promise { return; } + // `template` subcommands work without any board config (registry-only). + if (command === 'template') { + const sub = args[1]; + const json = hasFlag(args, '--json'); + switch (sub) { + case 'ls': + case 'list': + cmdTemplateList({ json }); + return; + case 'info': { + const idOrPath = args[2]; + if (!idOrPath) { console.error('Usage: agentbase template info '); process.exit(1); } + cmdTemplateInfo(idOrPath); + return; + } + case 'scaffold': { + const tplId = args[2]; + if (!tplId) { console.error('Usage: agentbase template scaffold [-o FILE]'); process.exit(1); } + const out = getFlag(args, '-o', '--out'); + cmdTemplateScaffold(tplId, { out }); + return; + } + default: + console.error('Usage: agentbase template {ls|info|scaffold} [args]'); + process.exit(1); + } + } + // All other commands need config + adapter const { config, configDir } = loadConfig(); const adapter = createAdapter(config, configDir); @@ -393,6 +429,41 @@ async function main(): Promise { break; } + case 'model': { + const sub = args[1]; + const json = hasFlag(args, '--json'); + switch (sub) { + case 'show': { + const boardId = getBoardId(config, args); + await cmdModelShow(adapter, boardId, { json }); + break; + } + case 'validate': { + const boardId = getBoardId(config, args); + await cmdModelValidate(adapter, boardId, { json }); + break; + } + case 'declare': { + const boardId = getBoardId(config, args); + const tplId = getFlag(args, '-t', '--template'); + if (!tplId) { console.error('Usage: agentbase model declare -b BOARD -t TEMPLATE_ID'); process.exit(1); } + await cmdModelDeclare(adapter, boardId, tplId); + break; + } + default: + console.error('Usage: agentbase model {show|validate|declare} [-b BOARD] [-t TEMPLATE]'); + process.exit(1); + } + break; + } + + case 'template': { + // Handled before config load (see above). If we reach here, fall through to error. + console.error('internal: template command should have been handled before config load'); + process.exit(1); + break; + } + default: console.error(`Unknown command: ${command}`); console.error('Run "agentbase help" for usage'); diff --git a/src/templates/builtin/correspondence-versioned.yaml b/src/templates/builtin/correspondence-versioned.yaml new file mode 100644 index 0000000..333e70d --- /dev/null +++ b/src/templates/builtin/correspondence-versioned.yaml @@ -0,0 +1,142 @@ +id: correspondence-versioned +version: 1 +name: "Correspondence-Versioned" +description: | + Round-based collection. Items live in a canonical Library list; each + communication round (RFFI #1, RFFI #2, etc) is its own list containing + reference cards back to Library items. Per-round status lives on the + reference card name prefix; canonical state lives on the Library card. + + Use for: insurance RFFI, grant rounds, academic peer review, multi-round + application processes, anything where the SAME item is asked across + multiple rounds and you need to answer "what does the third party + want THIS round?" without losing item identity across rounds. + +axes: + x: + represents: "items asked in that round" + cardinality: many + y: + represents: "communication rounds + Library" + cardinality: many + ordered: true + z: + represents: "per-round submission state" + location: card-name-prefix + +card-storage: + mode: reference + canonical-list-pattern: "^πŸ“š Item Library" + reference-mechanism: "trello-attachment-url" + +status-schema: + location: card-name-prefix + values: + - key: pending + emoji: "πŸ“₯" + meaning: "Not yet started" + - key: findable + emoji: "πŸ”" + meaning: "Agent can find from records" + - key: needs-user + emoji: "πŸ“" + meaning: "User must supply" + - key: needs-third-party + emoji: "πŸ“ž" + meaning: "Long lead-time external" + - key: sent + emoji: "πŸ“€" + meaning: "Submitted, awaiting acknowledgement" + - key: accepted + emoji: "βœ…" + meaning: "Acknowledged / accepted by counterparty" + - key: blocked + emoji: "❌" + meaning: "Cannot supply / dropped" + transitions: + - from: pending + to: [findable, needs-user, needs-third-party, blocked] + - from: [findable, needs-user, needs-third-party] + to: [sent, blocked] + - from: sent + to: [accepted, needs-user] + +required-lists: + - id: library + pattern: "^πŸ“š Item Library" + role: canonical-storage + min-count: 1 + max-count: 1 + - id: round + pattern: "^πŸ“¨ " + role: time-bucket + min-count: 1 + max-count: null + - id: closed + pattern: "^βœ… Closed Out" + role: terminal + min-count: 0 + max-count: 1 + +behaviours: + forbid-card-move-between-rounds: true + require-model-declaration-card: true + duplicate-detection-by: canonical-link + +init: + lists: + - name: "πŸ“š Item Library" + pos: 1 + - name: "πŸ“¨ Original Round" + pos: 2 + - name: "βœ… Closed Out" + pos: 99 + cards: + - list: "πŸ“š Item Library" + name: "🧬 DATA MODEL: correspondence-versioned (read first)" + pos: top + desc: | + # 🧬 Data Model: Correspondence-Versioned + + This board uses the **correspondence-versioned** template, NOT a status pipeline. + + ## Axes + - **Y axis (lists, leftβ†’right)**: Communication rounds with the counterparty. + - πŸ“š Item Library β€” canonical home for every deliverable card + - πŸ“¨ Original Round β€” what was asked at first contact + - πŸ“¨ Round #1 / #2 / #3… β€” each subsequent request + - βœ… Closed Out β€” items no longer requested + - **X axis (cards within a round list)**: Items the counterparty asked for in THAT round. + - **Z axis (per-card status)**: First emoji of the reference card name (πŸ“₯πŸ“πŸ”πŸ“žπŸ“€βœ…βŒ). + Canonical state lives on the Library card via a Submission Log section in its description. + + ## Status values (emoji prefix on reference card name) + - πŸ“₯ pending Not yet started + - πŸ” findable Agent can find from records + - πŸ“ needs-user User must supply + - πŸ“ž needs-third-party Long lead-time external + - πŸ“€ sent Submitted, awaiting acknowledgement + - βœ… accepted Acknowledged / accepted by counterparty + - ❌ blocked Cannot supply / dropped + + ## How to use + 1. **New deliverable** β†’ create card in πŸ“š Item Library only + 2. **Counterparty asks for it in a round** β†’ add a Trello link/shortcut in the round list + (do NOT move the canonical card; do NOT duplicate) + 3. **You submit** β†’ append a 'Submission Log' line in the Library card desc with date + ref + 4. **Counterparty acknowledges** β†’ mark accepted in submission log + flip reference emoji to βœ… + 5. **Counterparty drops the ask** β†’ leave the link in the historical round list, do nothing + in newer rounds + 6. **New round arrives** β†’ create new πŸ“¨ list with the round date in name; populate by + linking existing Library cards (re-asks) + creating new Library cards (new asks) + + ## Why this model + Status-pipeline (Inbox/Sent/Acknowledged) loses the answer to "what does the counterparty + want THIS round?". Correspondence-versioned makes each round a self-contained checklist + while preserving item identity across rounds. + + ## Related + - Convention: `board-as-source-of-truth-for-multi-day-collection` + - Convention: `board-cards-self-contained` + - Convention: `project-data-model-declaration` + - Template plugin: agentbase built-in `correspondence-versioned` diff --git a/src/templates/builtin/status-pipeline.yaml b/src/templates/builtin/status-pipeline.yaml new file mode 100644 index 0000000..b3a3c28 --- /dev/null +++ b/src/templates/builtin/status-pipeline.yaml @@ -0,0 +1,110 @@ +id: status-pipeline +version: 1 +name: "Status Pipeline" +description: | + Classic kanban / bug-tracker / sales-CRM model. Each list is a status, + each card is a work item. Status changes by moving the card between lists. + This is the legacy default for boards without a model declaration. + +axes: + x: + represents: "work items in that status" + cardinality: many + y: + represents: "status states (left-to-right pipeline)" + cardinality: many + ordered: true + z: + represents: "current status" + location: list-membership + +card-storage: + mode: single + +status-schema: + location: list-membership + values: + - key: backlog + emoji: "πŸ“₯" + meaning: "Not yet picked up" + - key: todo + emoji: "πŸ“" + meaning: "Ready to work" + - key: in-progress + emoji: "🚧" + meaning: "Being worked on now" + - key: review + emoji: "πŸ‘€" + meaning: "Awaiting review / QA" + - key: done + emoji: "βœ…" + meaning: "Completed" + - key: blocked + emoji: "❌" + meaning: "Blocked / cannot proceed" + +required-lists: + - id: backlog + pattern: "^(πŸ“₯ )?Backlog|πŸ“₯ .*" + role: status + min-count: 0 + max-count: 1 + - id: done + pattern: "^(βœ… )?Done" + role: terminal + min-count: 0 + max-count: 1 + +behaviours: + forbid-card-move-between-rounds: false + require-model-declaration-card: true + +init: + lists: + - name: "πŸ“₯ Backlog" + pos: 1 + - name: "πŸ“ To Do" + pos: 2 + - name: "🚧 In Progress" + pos: 3 + - name: "πŸ‘€ Review" + pos: 4 + - name: "βœ… Done" + pos: 5 + cards: + - list: "πŸ“₯ Backlog" + name: "🧬 DATA MODEL: status-pipeline (read first)" + pos: top + desc: | + # 🧬 Data Model: Status Pipeline + + This board uses the **status-pipeline** template (the legacy default). + + ## Axes + - **Y axis (lists, leftβ†’right)**: Status states. Move cards between lists to change status. + - **X axis (cards within a list)**: Work items currently in that status. + - **Z axis (per-card status)**: Determined by which list the card is in (`list-membership`). + + ## Status values + - πŸ“₯ backlog Not yet picked up + - πŸ“ todo Ready to work + - 🚧 in-progress Being worked on now + - πŸ‘€ review Awaiting review / QA + - βœ… done Completed + - ❌ blocked Blocked / cannot proceed + + ## How to use + 1. New work item β†’ create card in πŸ“₯ Backlog + 2. Pick up work β†’ move to 🚧 In Progress + 3. Submit for review β†’ move to πŸ‘€ Review + 4. Done β†’ move to βœ… Done + 5. Stuck β†’ move to ❌ Blocked + comment on card + + ## Why this model + Standard kanban; lossy for time-versioned correspondence (use + `correspondence-versioned` for that). Use this for engineering boards, + bug trackers, sales CRMs, anything where one item has one current state. + + ## Related + - Convention: `project-data-model-declaration` + - Template spec: agentbase built-in `status-pipeline` diff --git a/src/templates/declaration.ts b/src/templates/declaration.ts new file mode 100644 index 0000000..9223a4a --- /dev/null +++ b/src/templates/declaration.ts @@ -0,0 +1,84 @@ +/** + * Model declaration parser β€” reads the pinned 🧬 DATA MODEL: card + * from a board's first list and extracts the template id. + * + * Also: derives the assumed default (status-pipeline) for boards lacking + * a declaration. + */ + +import type { Card, List, VendorAdapter } from '../types.js'; +import type { ModelDeclaration } from './types.js'; + +const MODEL_CARD_NAME_RE = /🧬\s*DATA\s*MODEL[::]\s*([a-z0-9-]+)/i; +export const ASSUMED_DEFAULT_TEMPLATE_ID = 'status-pipeline'; + +/** + * Find the model declaration card in a board. Returns null if absent. + */ +export async function readModelDeclaration( + adapter: VendorAdapter, + boardId: string +): Promise { + const lists = await adapter.lists(boardId); + if (lists.length === 0) return null; + + // Sort lists by their natural position if possible; otherwise rely on order + const firstList = lists[0]; + const cards = await adapter.cards(boardId, firstList.id); + if (cards.length === 0) return null; + + // The declaration card should be at top β€” check first by position then by name match + const sorted = [...cards].sort((a, b) => (a.pos ?? 0) - (b.pos ?? 0)); + for (const c of sorted) { + const m = c.name.match(MODEL_CARD_NAME_RE); + if (m) { + return { + templateId: m[1], + cardId: c.id, + rawDesc: c.desc || '', + parsedAt: new Date().toISOString(), + }; + } + } + return null; +} + +/** + * Derive the effective template id for a board: declared, or assumed default. + */ +export async function resolveTemplateId( + adapter: VendorAdapter, + boardId: string +): Promise<{ id: string; declared: boolean; declaration: ModelDeclaration | null }> { + const decl = await readModelDeclaration(adapter, boardId); + if (decl) return { id: decl.templateId, declared: true, declaration: decl }; + return { id: ASSUMED_DEFAULT_TEMPLATE_ID, declared: false, declaration: null }; +} + +/** + * Build a lightweight board representation for validation. + */ +export async function buildBoardLite( + adapter: VendorAdapter, + boardId: string +): Promise<{ + id: string; + lists: { id: string; name: string }[]; + firstListFirstCard: { id: string; name: string; desc: string } | null; +}> { + const lists: List[] = await adapter.lists(boardId); + let firstCard: Card | null = null; + if (lists.length > 0) { + const cards = await adapter.cards(boardId, lists[0].id); + if (cards.length > 0) { + firstCard = [...cards].sort((a, b) => (a.pos ?? 0) - (b.pos ?? 0))[0]; + } + } + return { + id: boardId, + lists: lists.map(l => ({ id: l.id, name: l.name })), + firstListFirstCard: firstCard + ? { id: firstCard.id, name: firstCard.name, desc: firstCard.desc || '' } + : null, + }; +} diff --git a/src/templates/loader.ts b/src/templates/loader.ts new file mode 100644 index 0000000..6d161e0 --- /dev/null +++ b/src/templates/loader.ts @@ -0,0 +1,213 @@ +/** + * Template loader β€” discovers and parses templates from three sources: + * 1. built-in (bundled in dist/templates/builtin/*.yaml) + * 2. user filesystem (~/.agentbase/templates/*.yaml) + * 3. npm packages (node_modules/{@scope/}agentbase-template-* ) + * + * Resolution order: built-in β†’ user β†’ npm. First-seen id wins; collisions + * emit a warning to stderr. + */ + +import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { parseYaml } from '../yaml.js'; +import type { TemplateRecord, TemplateSpec, TemplateSource } from './types.js'; + +const NPM_PREFIX_RE = /^(?:@[^/]+\/)?agentbase-template-[a-z0-9-]+$/; +const TEMPLATE_FILES = ['template.yaml', 'template.yml', 'template/index.yaml']; + +let cache: TemplateRecord[] | null = null; + +/** + * Force a fresh discovery on next loadAll(). + */ +export function invalidateCache(): void { + cache = null; +} + +/** + * Load and parse a single YAML file as a TemplateSpec. + * Throws on parse errors. Does NOT validate the schema (validator does that). + */ +export function loadTemplateFile(path: string): TemplateSpec { + const raw = readFileSync(path, 'utf-8'); + const parsed = parseYaml(raw) as unknown as TemplateSpec; + if (!parsed || typeof parsed !== 'object') { + throw new Error(`Template ${path} is not a valid YAML object`); + } + if (!parsed.id) { + throw new Error(`Template ${path} missing required field: id`); + } + return parsed; +} + +/** + * Locate the built-in template directory. Works in both `src/` (during tests) + * and `dist/` (after build). In CJS, __dirname is the directory of this file. + */ +function getBuiltinDir(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const here: string = (typeof __dirname !== 'undefined') ? __dirname : process.cwd(); + return join(here, 'builtin'); +} + +function listYamlFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir) + .filter(f => f.endsWith('.yaml') || f.endsWith('.yml')) + .map(f => join(dir, f)); +} + +/** + * Discover npm-installed template packages. Walks node_modules from cwd up. + * Also checks the global node_modules. + */ +function discoverNpmTemplates(): { path: string; pkgName: string }[] { + const roots = new Set(); + let dir = process.cwd(); + while (true) { + const nm = join(dir, 'node_modules'); + if (existsSync(nm)) roots.add(nm); + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + // Global + const globalNm = process.env.npm_config_prefix + ? join(process.env.npm_config_prefix, 'lib', 'node_modules') + : join(homedir(), '.nvm', 'versions', 'node'); // best-effort fallback + if (existsSync(globalNm)) { + // For NVM, walk one level for nodeXX/lib/node_modules + try { + const entries = readdirSync(globalNm); + for (const e of entries) { + const candidate = join(globalNm, e, 'lib', 'node_modules'); + if (existsSync(candidate)) roots.add(candidate); + } + } catch { + // ignore + } + } + + const results: { path: string; pkgName: string }[] = []; + for (const nmRoot of roots) { + let entries: string[] = []; + try { + entries = readdirSync(nmRoot); + } catch { + continue; + } + for (const entry of entries) { + if (entry.startsWith('@')) { + // scoped + let scopedEntries: string[] = []; + try { + scopedEntries = readdirSync(join(nmRoot, entry)); + } catch { + continue; + } + for (const sub of scopedEntries) { + const pkgName = `${entry}/${sub}`; + if (!NPM_PREFIX_RE.test(pkgName)) continue; + const pkgDir = join(nmRoot, entry, sub); + const tpl = findTemplateEntry(pkgDir); + if (tpl) results.push({ path: tpl, pkgName }); + } + } else { + if (!NPM_PREFIX_RE.test(entry)) continue; + const pkgDir = join(nmRoot, entry); + const tpl = findTemplateEntry(pkgDir); + if (tpl) results.push({ path: tpl, pkgName: entry }); + } + } + } + return results; +} + +/** + * Find the template entry file inside an installed package directory. + * Honours `agentbase.template` in package.json, falls back to TEMPLATE_FILES. + */ +function findTemplateEntry(pkgDir: string): string | null { + // Honour package.json agentbase.template + const pkgJson = join(pkgDir, 'package.json'); + if (existsSync(pkgJson)) { + try { + const pj = JSON.parse(readFileSync(pkgJson, 'utf-8')); + const declared = pj?.agentbase?.template; + if (declared && typeof declared === 'string') { + const p = resolve(pkgDir, declared); + if (existsSync(p)) return p; + } + } catch { + // ignore + } + } + for (const candidate of TEMPLATE_FILES) { + const p = join(pkgDir, candidate); + if (existsSync(p) && statSync(p).isFile()) return p; + } + return null; +} + +/** + * Load all templates from all sources. Cached for the process lifetime. + */ +export function loadAll(): TemplateRecord[] { + if (cache) return cache; + + const records: TemplateRecord[] = []; + const seen = new Map(); + + const collect = (path: string, source: TemplateSource): void => { + let spec: TemplateSpec; + try { + spec = loadTemplateFile(path); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`agentbase: skipping template ${path}: ${msg}\n`); + return; + } + const rec: TemplateRecord = { spec, source, path }; + const prev = seen.get(spec.id); + if (prev) { + process.stderr.write( + `agentbase: template id "${spec.id}" duplicated. Using ${prev.source}:${prev.path}, ignoring ${source}:${path}\n` + ); + return; + } + seen.set(spec.id, rec); + records.push(rec); + }; + + // 1. Built-in + for (const p of listYamlFiles(getBuiltinDir())) collect(p, 'builtin'); + + // 2. User + const userDir = join(homedir(), '.agentbase', 'templates'); + for (const p of listYamlFiles(userDir)) collect(p, 'user'); + + // 3. npm + for (const { path } of discoverNpmTemplates()) collect(path, 'npm'); + + cache = records; + return cache; +} + +/** + * Look up a template by id across all sources. Returns null if not found. + */ +export function findById(id: string): TemplateRecord | null { + return loadAll().find(r => r.spec.id === id) || null; +} + +/** + * Load a single template directly from a filesystem path (e.g. ./my-template.yaml). + * Used by `agentbase template info `. + */ +export function loadFromPath(path: string): TemplateRecord { + const abs = resolve(path); + const spec = loadTemplateFile(abs); + return { spec, source: 'user', path: abs }; +} diff --git a/src/templates/templates.test.ts b/src/templates/templates.test.ts new file mode 100644 index 0000000..f06f2d8 --- /dev/null +++ b/src/templates/templates.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for the template plugin system. + * + * Run after `npm run build` (the test runner reads dist/, which is where + * built-in YAML files are copied). + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadAll, findById, loadFromPath } from './loader.js'; +import { + validateTemplateSpec, + validateBoardAgainstTemplate, + type BoardLite, +} from './validator.js'; +import { join } from 'node:path'; + +// In CJS test build, __dirname resolves; the test runs against dist/. +const here: string = (typeof __dirname !== 'undefined') ? __dirname : process.cwd(); + +test('loadAll discovers built-in templates', () => { + const records = loadAll(); + const ids = records.map(r => r.spec.id).sort(); + assert.ok(ids.includes('status-pipeline'), `expected status-pipeline; got ${ids.join(',')}`); + assert.ok( + ids.includes('correspondence-versioned'), + `expected correspondence-versioned; got ${ids.join(',')}` + ); +}); + +test('findById returns a record with valid spec', () => { + const rec = findById('correspondence-versioned'); + assert.ok(rec, 'correspondence-versioned not found'); + assert.equal(rec!.spec.id, 'correspondence-versioned'); + assert.equal(rec!.spec.version, 1); + assert.equal(rec!.source, 'builtin'); +}); + +test('built-in templates pass schema validation', () => { + for (const rec of loadAll()) { + const findings = validateTemplateSpec(rec.spec); + const errors = findings.filter(f => f.severity === 'error'); + assert.equal( + errors.length, + 0, + `template ${rec.spec.id} has errors:\n${errors.map(e => ` ${e.rule}: ${e.message}`).join('\n')}` + ); + } +}); + +test('schema validation catches missing id', () => { + const findings = validateTemplateSpec({ version: 1, name: 'X' }); + const ids = findings.map(f => f.rule); + assert.ok(ids.includes('id'), `expected id error; got ${ids.join(',')}`); +}); + +test('schema validation catches bad status location', () => { + const findings = validateTemplateSpec({ + id: 'x', + version: 1, + name: 'X', + axes: { x: { represents: 'a' }, y: { represents: 'b' }, z: { represents: 'c' } }, + 'status-schema': { location: 'bogus', values: [{ key: 'a', meaning: 'A' }] }, + }); + const locErr = findings.find(f => f.rule === 'status-schema.location'); + assert.ok(locErr, 'expected status-schema.location error'); +}); + +test('validateBoardAgainstTemplate: rffi-shaped board passes correspondence-versioned', () => { + const rec = findById('correspondence-versioned'); + assert.ok(rec); + const board: BoardLite = { + id: 'rffi-mock', + lists: [ + { id: 'l1', name: 'πŸ“š Item Library (canonical cards)' }, + { id: 'l2', name: 'πŸ“¨ Original Claim 1 May 2025' }, + { id: 'l3', name: 'πŸ“¨ RFFI #1 25 Mar 2026' }, + { id: 'l4', name: 'βœ… Closed Out' }, + ], + firstListFirstCard: { + id: 'c1', + name: '🧬 DATA MODEL: correspondence-versioned (read first)', + desc: 'see template plugin spec', + }, + }; + const result = validateBoardAgainstTemplate(board, rec!.spec); + assert.equal( + result.ok, + true, + `expected pass; findings:\n${result.findings.map(f => ` [${f.rule}] ${f.message}`).join('\n')}` + ); +}); + +test('validateBoardAgainstTemplate: missing model card fails', () => { + const rec = findById('correspondence-versioned'); + assert.ok(rec); + const board: BoardLite = { + id: 'mock', + lists: [ + { id: 'l1', name: 'πŸ“š Item Library' }, + { id: 'l2', name: 'πŸ“¨ Round 1' }, + ], + firstListFirstCard: { id: 'c1', name: 'Some random card', desc: '' }, + }; + const result = validateBoardAgainstTemplate(board, rec!.spec); + assert.equal(result.ok, false); + assert.ok( + result.findings.some(f => f.rule === 'model-declaration-card' && f.severity === 'error'), + 'expected model-declaration-card error' + ); +}); + +test('validateBoardAgainstTemplate: missing required Library list fails', () => { + const rec = findById('correspondence-versioned'); + assert.ok(rec); + const board: BoardLite = { + id: 'mock', + lists: [{ id: 'l1', name: 'πŸ“¨ Round 1' }], + firstListFirstCard: { + id: 'c1', + name: '🧬 DATA MODEL: correspondence-versioned', + desc: '', + }, + }; + const result = validateBoardAgainstTemplate(board, rec!.spec); + assert.equal(result.ok, false); + assert.ok( + result.findings.some(f => f.rule === 'required-list:library'), + 'expected required-list:library error' + ); +}); + +test('loadFromPath loads a built-in YAML directly', () => { + const path = join(here, 'builtin', 'status-pipeline.yaml'); + const rec = loadFromPath(path); + assert.equal(rec.spec.id, 'status-pipeline'); +}); + +test('status-pipeline status values include backlog and done', () => { + const rec = findById('status-pipeline'); + assert.ok(rec); + const keys = rec!.spec['status-schema'].values.map(v => v.key); + assert.ok(keys.includes('backlog')); + assert.ok(keys.includes('done')); +}); diff --git a/src/templates/types.ts b/src/templates/types.ts new file mode 100644 index 0000000..654e97d --- /dev/null +++ b/src/templates/types.ts @@ -0,0 +1,137 @@ +/** + * Template types β€” the schema agentbase templates conform to. + * + * See ~/.openclaw/workspaces/nebula/agentbase-templates-design.md Β§5 + * for the full schema spec. + */ + +export type CardStorageMode = 'single' | 'reference' | 'duplicate'; +export type StatusLocation = + | 'list-membership' + | 'card-name-prefix' + | 'card-desc-field' + | 'label' + | 'checklist'; + +export interface AxisSpec { + represents: string; + cardinality?: 'one' | 'many'; + ordered?: boolean; + location?: StatusLocation; // z-axis only +} + +export interface StatusValue { + key: string; + emoji?: string; + meaning: string; +} + +export interface StatusTransition { + from: string | string[]; + to: string | string[]; +} + +export interface StatusSchema { + location: StatusLocation; + values: StatusValue[]; + transitions?: StatusTransition[]; +} + +export interface RequiredList { + id: string; + pattern: string; // regex source + role: 'canonical-storage' | 'time-bucket' | 'terminal' | 'status' | 'category' | string; + 'min-count'?: number; + 'max-count'?: number | null; +} + +export interface CardStorage { + mode: CardStorageMode; + 'canonical-list-pattern'?: string; + 'reference-mechanism'?: string; +} + +export interface InitCardSpec { + list: string; // list name (as in init.lists) + name: string; + pos?: 'top' | 'bottom' | number; + desc?: string; + 'desc-template'?: string; // path within template dir +} + +export interface InitListSpec { + name: string; + pos?: number; +} + +export interface InitSpec { + lists?: InitListSpec[]; + cards?: InitCardSpec[]; +} + +export interface TemplateBehaviours { + 'forbid-card-move-between-rounds'?: boolean; + 'require-model-declaration-card'?: boolean; + 'duplicate-detection-by'?: string; + 'strict-hooks'?: boolean; +} + +export interface TemplateHooks { + 'on-list-create'?: string; + 'on-card-move'?: string; + 'on-card-create'?: string; + 'on-correspondence-add'?: string; + 'on-validate'?: string; +} + +export interface TemplateViews { + default?: string; // path to view module, or 'builtin' + [name: string]: string | undefined; +} + +export interface TemplateSpec { + id: string; + version: number; + name: string; + description?: string; + axes: { + x: AxisSpec; + y: AxisSpec; + z: AxisSpec; + }; + 'card-storage'?: CardStorage; + 'status-schema': StatusSchema; + 'required-lists'?: RequiredList[]; + behaviours?: TemplateBehaviours; + hooks?: TemplateHooks; + views?: TemplateViews; + init?: InitSpec; +} + +export type TemplateSource = 'builtin' | 'user' | 'npm'; + +export interface TemplateRecord { + spec: TemplateSpec; + source: TemplateSource; + path: string; // absolute path to template.yaml +} + +export interface ValidationFinding { + severity: 'error' | 'warning' | 'info'; + rule: string; + message: string; + context?: Record; +} + +export interface ValidationResult { + ok: boolean; + template: string; + findings: ValidationFinding[]; +} + +export interface ModelDeclaration { + templateId: string; + cardId: string; + rawDesc: string; + parsedAt: string; +} diff --git a/src/templates/validator.ts b/src/templates/validator.ts new file mode 100644 index 0000000..3601cb0 --- /dev/null +++ b/src/templates/validator.ts @@ -0,0 +1,231 @@ +/** + * Schema validator β€” checks a parsed template object against the agentbase + * template schema. Lightweight, hand-rolled (no JSON Schema dep needed for + * the bounded shape we accept). + * + * Also: validates a board snapshot against a template (required-lists, + * model declaration card, status emoji conformance). + */ + +import type { + TemplateSpec, + ValidationFinding, + ValidationResult, + RequiredList, +} from './types.js'; + +const VALID_LOCATIONS = new Set([ + 'list-membership', + 'card-name-prefix', + 'card-desc-field', + 'label', + 'checklist', +]); + +const VALID_STORAGE_MODES = new Set(['single', 'reference', 'duplicate']); + +/** + * Validate the template YAML structure itself. Returns a list of findings; + * empty list = OK. + */ +export function validateTemplateSpec(spec: unknown): ValidationFinding[] { + const findings: ValidationFinding[] = []; + const err = (rule: string, message: string, ctx?: Record): void => { + findings.push({ severity: 'error', rule, message, context: ctx }); + }; + const warn = (rule: string, message: string, ctx?: Record): void => { + findings.push({ severity: 'warning', rule, message, context: ctx }); + }; + + if (!spec || typeof spec !== 'object') { + err('shape', 'template must be an object'); + return findings; + } + const t = spec as Record; + + // Required scalars + if (!t.id || typeof t.id !== 'string') err('id', 'id is required and must be a string'); + else if (!/^[a-z0-9][a-z0-9-]*$/.test(t.id)) + err('id', `id "${t.id}" must be kebab-case (a-z, 0-9, hyphens)`); + + if (typeof t.version !== 'number' || !Number.isInteger(t.version) || t.version < 1) + err('version', 'version must be a positive integer'); + + if (!t.name || typeof t.name !== 'string') err('name', 'name is required'); + + // Axes + if (!t.axes || typeof t.axes !== 'object') { + err('axes', 'axes (x, y, z) is required'); + } else { + const axes = t.axes as Record; + for (const key of ['x', 'y', 'z']) { + const ax = axes[key]; + if (!ax || typeof ax !== 'object') { + err(`axes.${key}`, `axes.${key} is required`); + continue; + } + const a = ax as Record; + if (!a.represents || typeof a.represents !== 'string') + err(`axes.${key}.represents`, `axes.${key}.represents must be a string`); + } + } + + // status-schema + const ss = t['status-schema']; + if (!ss || typeof ss !== 'object') { + err('status-schema', 'status-schema is required'); + } else { + const s = ss as Record; + if (!s.location || typeof s.location !== 'string') + err('status-schema.location', 'status-schema.location is required'); + else if (!VALID_LOCATIONS.has(s.location as string)) + err( + 'status-schema.location', + `status-schema.location must be one of: ${[...VALID_LOCATIONS].join(', ')}` + ); + + if (!Array.isArray(s.values) || s.values.length === 0) + err('status-schema.values', 'status-schema.values must be a non-empty array'); + else { + const seen = new Set(); + for (const v of s.values) { + if (!v || typeof v !== 'object') { + err('status-schema.values', 'each value must be an object'); + continue; + } + const vv = v as Record; + if (!vv.key || typeof vv.key !== 'string') + err('status-schema.values', 'each value must have a string key'); + else if (seen.has(vv.key as string)) + err('status-schema.values', `duplicate status key: ${vv.key}`); + else seen.add(vv.key as string); + if (!vv.meaning || typeof vv.meaning !== 'string') + warn('status-schema.values', `value ${vv.key} missing meaning`); + } + } + } + + // card-storage (optional) + const cs = t['card-storage']; + if (cs && typeof cs === 'object') { + const c = cs as Record; + if (c.mode && !VALID_STORAGE_MODES.has(c.mode as string)) + err( + 'card-storage.mode', + `card-storage.mode must be one of: ${[...VALID_STORAGE_MODES].join(', ')}` + ); + if (c.mode === 'reference' && !c['canonical-list-pattern']) + warn( + 'card-storage.canonical-list-pattern', + 'card-storage.mode=reference should declare canonical-list-pattern' + ); + } + + // required-lists (optional) + const rl = t['required-lists']; + if (rl !== undefined && !Array.isArray(rl)) { + err('required-lists', 'required-lists must be an array'); + } else if (Array.isArray(rl)) { + for (const r of rl) { + if (!r || typeof r !== 'object') { + err('required-lists', 'each required-list must be an object'); + continue; + } + const rr = r as Record; + if (!rr.id || typeof rr.id !== 'string') + err('required-lists', 'each required-list needs id'); + if (!rr.pattern || typeof rr.pattern !== 'string') + err('required-lists', `required-list ${rr.id} needs pattern`); + else { + try { + new RegExp(rr.pattern as string); + } catch (e) { + err('required-lists', `required-list ${rr.id} has invalid regex: ${(e as Error).message}`); + } + } + } + } + + return findings; +} + +/** + * Convenience: throw if spec invalid. + */ +export function assertValidTemplate(spec: TemplateSpec): void { + const findings = validateTemplateSpec(spec); + const errors = findings.filter(f => f.severity === 'error'); + if (errors.length > 0) { + const lines = errors.map(e => ` - [${e.rule}] ${e.message}`).join('\n'); + throw new Error(`Template ${(spec as TemplateSpec).id} invalid:\n${lines}`); + } +} + +/** + * Validate a board (list of lists with names) against a template's + * `required-lists` rules. + */ +export interface BoardLite { + id: string; + lists: { id: string; name: string }[]; + firstListFirstCard?: { id: string; name: string; desc: string } | null; +} + +export function validateBoardAgainstTemplate( + board: BoardLite, + spec: TemplateSpec +): ValidationResult { + const findings: ValidationFinding[] = []; + + // 1. required-lists + for (const req of spec['required-lists'] || []) { + const re = new RegExp(req.pattern); + const matches = board.lists.filter(l => re.test(l.name)); + const min = req['min-count'] ?? 0; + const max = req['max-count'] ?? null; + if (matches.length < min) { + findings.push({ + severity: 'error', + rule: `required-list:${req.id}`, + message: `Expected β‰₯${min} list(s) matching /${req.pattern}/, found ${matches.length}`, + }); + } + if (max !== null && matches.length > max) { + findings.push({ + severity: 'error', + rule: `required-list:${req.id}`, + message: `Expected ≀${max} list(s) matching /${req.pattern}/, found ${matches.length}`, + }); + } + } + + // 2. model declaration card (if behaviour requires it) + if (spec.behaviours?.['require-model-declaration-card']) { + const card = board.firstListFirstCard; + if (!card) { + findings.push({ + severity: 'error', + rule: 'model-declaration-card', + message: 'First list has no first card; model declaration card missing', + }); + } else if (!card.name.includes('🧬 DATA MODEL')) { + findings.push({ + severity: 'error', + rule: 'model-declaration-card', + message: `First card "${card.name}" is not a model declaration (expected name to contain "🧬 DATA MODEL")`, + }); + } else if (!card.name.includes(spec.id)) { + findings.push({ + severity: 'warning', + rule: 'model-declaration-card', + message: `Model declaration card name does not mention template id "${spec.id}"`, + }); + } + } + + return { + ok: findings.filter(f => f.severity === 'error').length === 0, + template: spec.id, + findings, + }; +}