diff --git a/skills/beadboard-driver/SKILL.md b/skills/beadboard-driver/SKILL.md new file mode 100644 index 0000000..dae9cd3 --- /dev/null +++ b/skills/beadboard-driver/SKILL.md @@ -0,0 +1,66 @@ +--- +name: beadboard-driver +description: Drive BeadBoard agent workflows with strict preflight, per-session unique agent identity, and evidence-backed closeout. Use when handling bead lifecycle work that combines bd status commands with bb agent coordination (register/list/show, send/inbox/read/ack, reserve/release/status), especially in multi-agent sessions where path resolution, collision avoidance, and verification discipline are required. +--- + +# Beadboard Driver + +## Overview + +Use this skill to run repeatable `bd` + `bb` workflows without drift. Resolve `bb` safely, generate a unique session identity, coordinate with reservations/mail, and produce closeout evidence before claiming completion. + +## Core Workflow + +1. Run preflight: +```bash +node skills/beadboard-driver/scripts/session-preflight.mjs +``` + +2. Generate a unique per-session agent name: +```bash +node skills/beadboard-driver/scripts/generate-agent-name.mjs +``` + +3. Register identity, then claim bead: +```bash +& "$env:BB_REPO\bb.ps1" agent register --name --role +bd update --status in_progress --claim +``` + +4. Coordinate during implementation: +```bash +& "$env:BB_REPO\bb.ps1" agent reserve --agent --scope "" --bead +& "$env:BB_REPO\bb.ps1" agent send --from --to --bead --category HANDOFF --subject "" --body "" +``` + +5. Build readiness summary before close: +```bash +node skills/beadboard-driver/scripts/readiness-report.mjs --checks '[{"name":"typecheck","ok":true}]' --artifacts '[{"path":"artifacts/final.png","required":true}]' +``` + +## Path Resolution Policy + +- Treat `BB_REPO` as authoritative when set. +- On invalid `BB_REPO`, stop and return remediation text. Do not silently bypass. +- If `BB_REPO` is unset, resolve from global `bb`, then cached path, then bounded discovery. +- Update the skill cache only after a verified path is found. +- Never mutate shell profile/env vars automatically. + +## Identity Policy + +- Create one unique agent identity per session. +- Use adjective-noun names and retry on collisions. +- Register identity before any mail/reservation command. +- Keep bead claim authority in `bd`; identity alone is not a claim. + +## Verification Policy + +- Do not claim completion without fresh command evidence. +- Require typecheck, test, and lint evidence for closeout tasks. +- Use readiness report output in bead notes. + +## References + +- Command and argument contracts: `references/command-matrix.md` +- Failure and recovery handling: `references/failure-modes.md` +- End-to-end session choreography: `references/session-lifecycle.md` diff --git a/skills/beadboard-driver/agents/openai.yaml b/skills/beadboard-driver/agents/openai.yaml new file mode 100644 index 0000000..60cdd62 --- /dev/null +++ b/skills/beadboard-driver/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Beadboard Driver" + short_description: "Safe bd+bb agent workflow orchestration" + default_prompt: "Use Beadboard Driver to resolve bb path, register a unique session agent, coordinate via bb agent commands, and produce verification-backed closeout notes." diff --git a/skills/beadboard-driver/references/command-matrix.md b/skills/beadboard-driver/references/command-matrix.md new file mode 100644 index 0000000..1fdfcb2 --- /dev/null +++ b/skills/beadboard-driver/references/command-matrix.md @@ -0,0 +1,33 @@ +# Command Matrix + +## Preflight and Identity + +- `node skills/beadboard-driver/scripts/resolve-bb.mjs` + - Output: `{ ok, source, resolved_path, reason, remediation }` +- `node skills/beadboard-driver/scripts/session-preflight.mjs` + - Output: `{ ok, tools.bd, bb }` or `{ ok:false, error_code }` +- `node skills/beadboard-driver/scripts/generate-agent-name.mjs` + - Output: `{ ok, agent_name, attempts, collisions }` or `{ ok:false, error_code }` +- `node skills/beadboard-driver/scripts/readiness-report.mjs --checks --artifacts ` + - Output: `{ ok, checks, artifacts, dependency_sanity, summary }` + +## Coordination Commands (`bb`) + +- `bb agent register --name --role ` +- `bb agent list` +- `bb agent show --agent ` +- `bb agent send --from --to --bead --category --subject --body ` +- `bb agent inbox --agent [--state unread|read|acked]` +- `bb agent read --agent --message ` +- `bb agent ack --agent --message ` +- `bb agent reserve --agent --scope --bead [--ttl ] [--takeover-stale]` +- `bb agent release --agent --scope ` +- `bb agent status [--bead ] [--agent ]` + +## Lifecycle Commands (`bd`) + +- `bd ready` +- `bd show ` +- `bd update --status in_progress --claim` +- `bd update --notes ""` +- `bd close --reason ""` diff --git a/skills/beadboard-driver/references/failure-modes.md b/skills/beadboard-driver/references/failure-modes.md new file mode 100644 index 0000000..f824937 --- /dev/null +++ b/skills/beadboard-driver/references/failure-modes.md @@ -0,0 +1,40 @@ +# Failure Modes + +## `BD_NOT_FOUND` + +- Cause: `bd` missing from PATH. +- Recovery: install beads CLI or add `bd` executable directory to PATH. + +## `BB_NOT_FOUND` + +- Cause: `BB_REPO` invalid or no `bb` command / cache / discovery hit. +- Recovery: + - Set `BB_REPO` to BeadBoard repo root. + - Verify `bb.ps1` exists under `BB_REPO`. + - Retry preflight. + +## `NAME_GENERATION_EXHAUSTED` + +- Cause: all generated names collided with existing registry entries. +- Recovery: + - increase retry count (`BB_NAME_MAX_RETRIES`), + - expand adjective/noun pools, + - retry generation. + +## Reservation Conflicts + +- `RESERVATION_CONFLICT`: active owner exists. +- `RESERVATION_STALE_FOUND`: stale reservation exists; use takeover only when safe. +- `RELEASE_FORBIDDEN`: non-owner attempted release. + +## Mail Lifecycle Errors + +- `UNKNOWN_SENDER` / `UNKNOWN_RECIPIENT`: register agents before send. +- `ACK_FORBIDDEN`: only recipient may ack. +- `MESSAGE_NOT_FOUND`: stale id or wrong message reference. + +## Policy Guardrails + +- Do not write `.beads/issues.jsonl` directly. +- Do not close beads without verification evidence. +- Do not bypass `BB_REPO` when it is set but invalid; fix it explicitly. diff --git a/skills/beadboard-driver/references/session-lifecycle.md b/skills/beadboard-driver/references/session-lifecycle.md new file mode 100644 index 0000000..2bb170f --- /dev/null +++ b/skills/beadboard-driver/references/session-lifecycle.md @@ -0,0 +1,33 @@ +# Session Lifecycle + +## 1) Start Session + +1. Run preflight. +2. Resolve bb path and confirm `bd` availability. +3. Generate unique session agent name. +4. Register agent identity. + +## 2) Pick and Claim Work + +1. `bd ready` +2. `bd show ` +3. `bd update --status in_progress --claim` + +## 3) Coordinate During Work + +1. Reserve sensitive scopes before edits. +2. Send structured mail for blockers and handoffs. +3. Read and acknowledge required messages. + +## 4) Verify and Close + +1. Run required gates (typecheck/test/lint). +2. Build readiness report with checks + artifacts. +3. Post notes to bead. +4. Close bead with explicit reason. + +## 5) Session End Hygiene + +1. Release reservations. +2. Ensure no unresolved blocker mail is pending for your bead. +3. Hand off context if stopping before close. diff --git a/skills/beadboard-driver/scripts/generate-agent-name.mjs b/skills/beadboard-driver/scripts/generate-agent-name.mjs new file mode 100644 index 0000000..2576a0a --- /dev/null +++ b/skills/beadboard-driver/scripts/generate-agent-name.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +function normalizeList(raw, fallback) { + const value = (raw || '').trim(); + if (!value) { + return fallback; + } + return value + .split(',') + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); +} + +function sanitizeName(value) { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function buildRandomSource() { + const sequenceRaw = (process.env.BB_NAME_SEED_SEQUENCE || '').trim(); + if (!sequenceRaw) { + return () => Math.random(); + } + const sequence = sequenceRaw + .split(',') + .map((value) => Number.parseFloat(value.trim())) + .filter((value) => Number.isFinite(value)); + let index = 0; + return () => { + if (sequence.length === 0) { + return Math.random(); + } + const value = sequence[index % sequence.length]; + index += 1; + return Math.min(Math.max(value, 0), 0.999999); + }; +} + +function pickIndex(length, randomFn) { + if (length <= 1) { + return 0; + } + return Math.floor(randomFn() * length); +} + +async function nameExists(registryDir, agentName) { + const filePath = path.join(registryDir, `${agentName}.json`); + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function registryRoot() { + if (process.env.BB_AGENT_REGISTRY_DIR) { + return process.env.BB_AGENT_REGISTRY_DIR; + } + return path.join(process.env.USERPROFILE || os.homedir(), '.beadboard', 'agent', 'agents'); +} + +async function main() { + try { + const adjectives = normalizeList(process.env.BB_NAME_ADJECTIVES, [ + 'green', + 'silver', + 'swift', + 'steady', + ]); + const nouns = normalizeList(process.env.BB_NAME_NOUNS, ['castle', 'harbor', 'falcon', 'orchard']); + const maxRetriesRaw = Number.parseInt(process.env.BB_NAME_MAX_RETRIES || '12', 10); + const maxRetries = Number.isInteger(maxRetriesRaw) && maxRetriesRaw > 0 ? maxRetriesRaw : 12; + const random = buildRandomSource(); + const registryDir = registryRoot(); + + let collisions = 0; + let attempts = 0; + for (let index = 0; index < maxRetries; index += 1) { + attempts += 1; + const adjective = adjectives[pickIndex(adjectives.length, random)]; + const noun = nouns[pickIndex(nouns.length, random)]; + const candidate = sanitizeName(`${adjective}-${noun}`); + if (!candidate) { + continue; + } + const exists = await nameExists(registryDir, candidate); + if (!exists) { + process.stdout.write( + `${JSON.stringify( + { + ok: true, + agent_name: candidate, + attempts, + collisions, + registry_dir: registryDir, + }, + null, + 2, + )}\n`, + ); + return; + } + collisions += 1; + } + + process.stdout.write( + `${JSON.stringify( + { + ok: false, + error_code: 'NAME_GENERATION_EXHAUSTED', + reason: 'Unable to generate a unique agent name in allotted retries.', + attempts, + collisions, + registry_dir: registryDir, + }, + null, + 2, + )}\n`, + ); + } catch (error) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + error_code: 'NAME_GENERATION_INTERNAL_ERROR', + reason: error instanceof Error ? error.message : String(error), + }, + null, + 2, + )}\n`, + ); + } +} + +void main(); diff --git a/skills/beadboard-driver/scripts/lib/driver-lib.mjs b/skills/beadboard-driver/scripts/lib/driver-lib.mjs new file mode 100644 index 0000000..2bb2a19 --- /dev/null +++ b/skills/beadboard-driver/scripts/lib/driver-lib.mjs @@ -0,0 +1,185 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +function homeRoot() { + return process.env.BB_SKILL_HOME || os.homedir(); +} + +function cacheFilePath() { + return path.join(homeRoot(), '.beadboard', 'skill-config.json'); +} + +async function pathExists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readCache() { + const filePath = cacheFilePath(); + try { + const raw = await fs.readFile(filePath, 'utf8'); + return JSON.parse(raw); + } catch { + return {}; + } +} + +async function writeCache(payload) { + const filePath = cacheFilePath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + `${JSON.stringify({ ...payload, updated_at: new Date().toISOString() }, null, 2)}\n`, + 'utf8', + ); +} + +function splitPathVariable(value) { + if (!value) { + return []; + } + return value.split(path.delimiter).map((entry) => entry.trim()).filter(Boolean); +} + +async function findCommandInPath(commandName) { + const pathEntries = splitPathVariable(process.env.PATH || ''); + const candidateNames = + process.platform === 'win32' + ? [`${commandName}.cmd`, `${commandName}.exe`, `${commandName}.ps1`, `${commandName}.bat`, commandName] + : [commandName]; + + for (const entry of pathEntries) { + for (const candidate of candidateNames) { + const fullPath = path.join(entry, candidate); + if (await pathExists(fullPath)) { + return fullPath; + } + } + } + return null; +} + +async function validateRepoPath(repoPath) { + if (!repoPath || !(await pathExists(repoPath))) { + return { ok: false, reason: 'BB_REPO does not exist.' }; + } + + const bbPath = path.join(repoPath, 'bb.ps1'); + if (!(await pathExists(bbPath))) { + return { ok: false, reason: 'BB_REPO is set, but bb.ps1 was not found at BB_REPO\\bb.ps1.' }; + } + + return { ok: true, bbPath }; +} + +async function discoverBbPath() { + const configuredRoots = splitPathVariable(process.env.BB_SEARCH_ROOTS || ''); + const roots = configuredRoots.length > 0 ? configuredRoots : [process.cwd(), path.join(homeRoot(), 'codex'), homeRoot()]; + const maxDepth = 4; + + for (const root of roots) { + if (!(await pathExists(root))) { + continue; + } + + const queue = [{ dir: root, depth: 0 }]; + while (queue.length > 0) { + const current = queue.shift(); + const candidate = path.join(current.dir, 'bb.ps1'); + if (await pathExists(candidate)) { + return candidate; + } + if (current.depth >= maxDepth) { + continue; + } + let entries = []; + try { + entries = await fs.readdir(current.dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory()) { + queue.push({ dir: path.join(current.dir, entry.name), depth: current.depth + 1 }); + } + } + } + } + + return null; +} + +async function resolveBbPath() { + const cache = await readCache(); + const envRepo = (process.env.BB_REPO || '').trim(); + + if (envRepo) { + const validated = await validateRepoPath(envRepo); + if (!validated.ok) { + return { + ok: false, + source: 'env', + resolved_path: null, + reason: validated.reason, + remediation: 'Set BB_REPO to your BeadBoard repo root, e.g. `$env:BB_REPO="C:\\path\\to\\beadboard"`.', + }; + } + + let reason = 'Resolved from BB_REPO.'; + if (cache.bb_path && cache.bb_path !== validated.bbPath) { + reason = 'Resolved from BB_REPO; cache mismatch detected and cache updated.'; + } + await writeCache({ bb_path: validated.bbPath, source: 'env' }); + return { ok: true, source: 'env', resolved_path: validated.bbPath, reason, remediation: null }; + } + + const globalBb = await findCommandInPath('bb'); + if (globalBb) { + await writeCache({ bb_path: globalBb, source: 'global' }); + return { + ok: true, + source: 'global', + resolved_path: globalBb, + reason: 'Resolved from PATH.', + remediation: null, + }; + } + + if (cache.bb_path && (await pathExists(cache.bb_path))) { + return { + ok: true, + source: 'cache', + resolved_path: cache.bb_path, + reason: 'Resolved from cached bb path.', + remediation: null, + }; + } + + const discovered = await discoverBbPath(); + if (discovered) { + await writeCache({ bb_path: discovered, source: 'discovery' }); + return { + ok: true, + source: 'discovery', + resolved_path: discovered, + reason: 'Resolved by filesystem discovery and cached.', + remediation: null, + }; + } + + return { + ok: false, + source: 'none', + resolved_path: null, + reason: 'Unable to find bb command or bb.ps1.', + remediation: + 'Set BB_REPO to your BeadBoard repo root, or install a global bb command, then retry.', + }; +} + +export { cacheFilePath, findCommandInPath, resolveBbPath }; diff --git a/skills/beadboard-driver/scripts/readiness-report.mjs b/skills/beadboard-driver/scripts/readiness-report.mjs new file mode 100644 index 0000000..fb6578b --- /dev/null +++ b/skills/beadboard-driver/scripts/readiness-report.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import fs from 'node:fs/promises'; + +function parseArgs(argv) { + const output = {}; + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith('--')) { + continue; + } + const key = token.slice(2); + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + output[key] = 'true'; + continue; + } + output[key] = value; + index += 1; + } + return output; +} + +function parseJsonArray(raw, fallback) { + if (!raw) { + return fallback; + } + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : fallback; + } catch { + return fallback; + } +} + +async function withArtifactExistence(artifacts) { + const output = []; + for (const artifact of artifacts) { + const item = { + path: artifact.path, + required: Boolean(artifact.required), + exists: false, + }; + if (typeof artifact.path === 'string' && artifact.path.trim()) { + try { + await fs.access(artifact.path); + item.exists = true; + } catch { + item.exists = false; + } + } + output.push(item); + } + return output; +} + +async function main() { + try { + const args = parseArgs(process.argv.slice(2)); + const checks = parseJsonArray(args.checks, []); + const artifacts = parseJsonArray(args.artifacts, []); + const dependencySanity = args['dependency-note'] || ''; + + const normalizedChecks = checks.map((check) => ({ + name: check.name || 'unnamed-check', + ok: Boolean(check.ok), + details: check.details || '', + })); + const normalizedArtifacts = await withArtifactExistence(artifacts); + + const allChecksPass = normalizedChecks.every((check) => check.ok); + const requiredArtifactsPresent = normalizedArtifacts.every((artifact) => !artifact.required || artifact.exists); + const ready = allChecksPass && requiredArtifactsPresent; + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + generated_at: new Date().toISOString(), + checks: normalizedChecks, + artifacts: normalizedArtifacts, + dependency_sanity: dependencySanity, + summary: { + checks_passed: allChecksPass, + required_artifacts_present: requiredArtifactsPresent, + ready, + }, + }, + null, + 2, + )}\n`, + ); + } catch (error) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + reason: error instanceof Error ? error.message : String(error), + summary: { + checks_passed: false, + required_artifacts_present: false, + ready: false, + }, + }, + null, + 2, + )}\n`, + ); + } +} + +void main(); diff --git a/skills/beadboard-driver/scripts/resolve-bb.mjs b/skills/beadboard-driver/scripts/resolve-bb.mjs new file mode 100644 index 0000000..85ba531 --- /dev/null +++ b/skills/beadboard-driver/scripts/resolve-bb.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import { resolveBbPath } from './lib/driver-lib.mjs'; + +async function main() { + try { + const resolved = await resolveBbPath(); + process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`); + } catch (error) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + source: 'internal', + resolved_path: null, + reason: error instanceof Error ? error.message : String(error), + remediation: 'Inspect resolve-bb.js runtime environment and retry.', + }, + null, + 2, + )}\n`, + ); + } +} + +void main(); diff --git a/skills/beadboard-driver/scripts/session-preflight.mjs b/skills/beadboard-driver/scripts/session-preflight.mjs new file mode 100644 index 0000000..2bb3e54 --- /dev/null +++ b/skills/beadboard-driver/scripts/session-preflight.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import { findCommandInPath, resolveBbPath } from './lib/driver-lib.mjs'; + +async function main() { + try { + const bdPath = await findCommandInPath('bd'); + if (!bdPath) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + error_code: 'BD_NOT_FOUND', + reason: 'Could not find bd in PATH.', + remediation: 'Install beads CLI or add bd executable to PATH.', + tools: { + bd: { available: false, path: null }, + }, + bb: null, + }, + null, + 2, + )}\n`, + ); + return; + } + + const bb = await resolveBbPath(); + if (!bb.ok) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + error_code: 'BB_NOT_FOUND', + reason: bb.reason, + remediation: bb.remediation, + tools: { + bd: { available: true, path: bdPath }, + }, + bb, + }, + null, + 2, + )}\n`, + ); + return; + } + + process.stdout.write( + `${JSON.stringify( + { + ok: true, + timestamp: new Date().toISOString(), + tools: { + bd: { available: true, path: bdPath }, + }, + bb, + }, + null, + 2, + )}\n`, + ); + } catch (error) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + error_code: 'PREFLIGHT_INTERNAL_ERROR', + reason: error instanceof Error ? error.message : String(error), + remediation: 'Inspect session-preflight.js and retry.', + tools: { + bd: { available: false, path: null }, + }, + bb: null, + }, + null, + 2, + )}\n`, + ); + } +} + +void main(); diff --git a/skills/beadboard-driver/tests/generate-agent-name.contract.test.mjs b/skills/beadboard-driver/tests/generate-agent-name.contract.test.mjs new file mode 100644 index 0000000..aeb9f86 --- /dev/null +++ b/skills/beadboard-driver/tests/generate-agent-name.contract.test.mjs @@ -0,0 +1,26 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const scriptPath = path.resolve(__dirname, '..', 'scripts', 'generate-agent-name.mjs'); + +test('generate-agent-name contract: returns structured success', async () => { + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + env: { + ...process.env, + BB_NAME_ADJECTIVES: 'green', + BB_NAME_NOUNS: 'castle', + BB_NAME_MAX_RETRIES: '1', + }, + }); + const result = JSON.parse(stdout); + assert.equal(result.ok, true); + assert.equal(result.agent_name, 'green-castle'); + assert.equal(typeof result.attempts, 'number'); +}); diff --git a/skills/beadboard-driver/tests/resolve-bb.contract.test.mjs b/skills/beadboard-driver/tests/resolve-bb.contract.test.mjs new file mode 100644 index 0000000..9388efc --- /dev/null +++ b/skills/beadboard-driver/tests/resolve-bb.contract.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const scriptPath = path.resolve(__dirname, '..', 'scripts', 'resolve-bb.mjs'); + +test('resolve-bb contract: BB_REPO source', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-contract-resolve-')); + try { + const repo = path.join(root, 'beadboard'); + await fs.mkdir(path.join(repo, 'tools'), { recursive: true }); + await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8'); + + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + env: { ...process.env, BB_REPO: repo, BB_SKILL_HOME: path.join(root, 'home'), PATH: '' }, + }); + const result = JSON.parse(stdout); + + assert.equal(result.ok, true); + assert.equal(result.source, 'env'); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/skills/beadboard-driver/tests/run-tests.mjs b/skills/beadboard-driver/tests/run-tests.mjs new file mode 100644 index 0000000..8a1cf65 --- /dev/null +++ b/skills/beadboard-driver/tests/run-tests.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const tests = [ + path.join(__dirname, 'resolve-bb.contract.test.mjs'), + path.join(__dirname, 'generate-agent-name.contract.test.mjs'), + path.join(__dirname, 'session-preflight.contract.test.mjs'), +]; + +const child = spawn(process.execPath, ['--test', ...tests], { + stdio: 'inherit', + env: process.env, +}); + +child.on('exit', (code) => { + process.exit(code ?? 1); +}); diff --git a/skills/beadboard-driver/tests/session-preflight.contract.test.mjs b/skills/beadboard-driver/tests/session-preflight.contract.test.mjs new file mode 100644 index 0000000..2e7d161 --- /dev/null +++ b/skills/beadboard-driver/tests/session-preflight.contract.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; + +const execFileAsync = promisify(execFile); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const scriptPath = path.resolve(__dirname, '..', 'scripts', 'session-preflight.mjs'); + +test('session-preflight contract: surfaces BD_NOT_FOUND when missing', async () => { + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + env: { ...process.env, PATH: '' }, + }); + const result = JSON.parse(stdout); + assert.equal(result.ok, false); + assert.equal(result.error_code, 'BD_NOT_FOUND'); +}); + +test('session-preflight contract: succeeds with bd + BB_REPO', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bb-skill-contract-preflight-')); + try { + const repo = path.join(root, 'beadboard'); + const toolsDir = path.join(root, 'tools'); + await fs.mkdir(path.join(repo, 'tools'), { recursive: true }); + await fs.mkdir(toolsDir, { recursive: true }); + await fs.writeFile(path.join(repo, 'bb.ps1'), 'echo ok', 'utf8'); + await fs.writeFile(path.join(toolsDir, 'bd.cmd'), '@echo off\r\necho beads\r\n', 'utf8'); + + const { stdout } = await execFileAsync(process.execPath, [scriptPath], { + env: { ...process.env, PATH: toolsDir, BB_REPO: repo, BB_SKILL_HOME: path.join(root, 'home') }, + }); + const result = JSON.parse(stdout); + assert.equal(result.ok, true); + assert.equal(result.bb.ok, true); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +});