Files
ruvnet__ruflo/plugins/ruflo-business-pods/scripts/pod-tick.test.mjs
rUv 19e4277e12 feat: ADR-164 agentbbs business autopilot — Phases 1-4 (4 MCP tools, 7 pods, http_fetch, atomic budget tracker) (#2503)
* docs(adr): ADR-164 — AgentBBS federated business-management autopilot

Draft ADR proposing agentbbs@0.1.0 integration as a federation-peer kind +
business pod scaffolding. Covers the 4 new MCP tools, 7 domain pod templates
(sales/marketing/finance/ops/support/hr/exec), upstream changes required in
both ruflo and ruvnet/agentbbs, 5-phase rollout, and 7 honest risks.

Builds on the optional-dep + graceful-degraded pattern shipped today in PR
#2500 (agenticow) and ADR-150 (metaharness).

Status: Draft. Implementation gated on upstream confirmation of agentbbs
persistence semantics + latency baseline.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* docs(adr): amend ADR-164 + add ADR-164.1 (budget atomicity)

ADR-164 amendments (Draft v2, 1193 → 1412 lines, +219 net):
  - Rewrite agentbbs maturity assessment — it's a 13-crate Rust workspace
    with 7 existing CI workflows + 30+ release tags + existing federation
    crate, NOT a 16h-old greenfield. The npm package is the launcher only.
  - §3.2.4 — two-phase auth: 15-min token is handshake-only;
    long-lived stream sessions persist past expiry until idle-close.
  - §3.5.4 — new "Founder-bootstrap trust elevation" admin escape hatch
    so #exec cross-pod synthesis works on Day 1 without 500-interaction wait.
  - §4.4 — Ops pod gets aidefence_*, terminal_execute, agent_execute,
    new http_fetch tool, cloud-provider MCP servers; concrete bench scenario.
  - §5.1.8 — new MCP tool `http_fetch` (URL allowlist, timeout, size cap).
  - §5.2 — survey-first upstream changes; many requirements may already
    exist in crates/agentbbs-federation and crates/agentbbs-mcp.
  - §7.3 + §9.5 — forward-reference to ADR-164.1 for race resolution.
  - §9.1 — risk #1 rewritten: real risks are npm/Rust version drift, FSL
    license implications, cargo first-run requirement, MCP server
    compatibility.

ADR-164.1 (new, 724 lines):
  - Atomic reserve-and-commit token bucket against AgentDB-style
    SQLite store with BEGIN IMMEDIATE serialization.
  - Schema: bbs_budget_reservations + bbs_budget_rooms tables.
  - Three-op API: reserve / commit / release with discriminated returns.
  - 5 concurrency tests (vitest-implementable) closing the race window.
  - Migration plan: feature-flagged rollout behind
    CLAUDE_FLOW_BBS_ATOMIC_BUDGET, default flip after 1 week zero incidents.
  - p99 < 5ms target with ~1000 reserves/sec throughput ceiling honestly
    documented (partition-per-pod as the scale-out path, not replacing
    SQLite).

Both Draft status. Phase 1 implementation gated on these.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* docs(adr): ADR-164.1 amendments — close the Expired Commit Leak

Peer review (2026-06-29) identified three issues:

1. CRITICAL — Expired Commit Leak (§8.1, §5.3, §4.1)
   Original draft rejected commit() of an expired reservation, so the
   actual API spend went unrecorded. A high-latency loop could silently
   burn unbounded $ past the monthly cap. Fixed: commit() now ACCEPTS
   the spend, transitions state to new 'committed_post_expiry' value,
   records actual_usd, returns { ok: true, warned: 'COMMIT_AFTER_EXPIRY' },
   and emits a reservation.committed_post_expiry audit envelope.

2. SQLite locking clarification (§3.2 _lock_bump field note)
   BEGIN IMMEDIATE itself acquires the RESERVED lock; _lock_bump is now
   documented as belt-and-suspenders + code-review visibility, not the
   primary serialization primitive.

3. Per-pod reservationExpiryMs (§3.2 expires_at field note)
   Default 60s is too short for local large-model pods. Pod templates
   may now set reservationExpiryMs in [5_000, 300_000] ms; ceiling
   defends against unbounded runaway.

Plus new Test 6 in §9: load-bearing proof that COMMIT_AFTER_EXPIRY
captures the real spend and the room's subsequent gate-check reflects it.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* feat(mcp): Phase 1 — agentbbs integration scaffold (4 MCP tools)

Implements ADR-164 §3.2 + §5.1 with the same architectural pattern as
PR #2500 (agenticow): optional-dep + graceful-degraded fallback.

New MCP tools (4):
  - federation_bbs_register     register a BBS room as a federation peer
  - federation_bbs_publish      publish a typed envelope to a room
  - federation_bbs_watch        poll recent envelopes (streaming = Phase 4)
  - federation_bbs_human_join   single-use Ed25519 token + web/SSH handshake

Phase 1 scope (scaffolding the surface, not the wire):
  - loadAgentbbs() checks importability; degrades to {degraded:true,
    reason:'agentbbs-not-found'} when missing — never throws
  - Validation runs BEFORE the optional-dep check so path-traversal /
    label / msgType rejection is deterministic regardless of install state
  - Backing store: local JSON-lines in <basePath>/room-<id>.jsonl +
    rooms.json registry. The Rust agentbbs-federation crate will replace
    this with signed wire envelopes in Phase 2.
  - Ed25519 keypair is per-process ephemeral; real federation keypair
    wiring is Phase 2 (documented inline at the call site).

Gates (all PASS):
  - Build: clean (v3/@claude-flow/cli npm run build)
  - Vitest: 8 pass + 4 skip-conditional (agentbbs binary not built locally)
  - ADR-112 audit: 349/349 tools with Use-when guidance (was 345; +4 new)
  - Bash smoke: 8/8 (end-to-end register→publish→watch cycle via dist handlers)

Plus:
  - .github/workflows/no-agentbbs-smoke.yml — CI guard mirroring
    no-metaharness-smoke.yml; asserts agentbbs stays in optionalDependencies
    and runtime drill exits 0 with --ignore-optional install
  - plugins/ruflo-bbs-federation/.claude-plugin/plugin.json — plugin metadata

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* docs(adr-164): replace §5.2 hypotheticals with concrete agentbbs survey findings

Phase 1 survey of ruvnet/agentbbs @ ca3c6e0 resolved each compatibility
check in §5.2 against the live upstream source. Six "VERIFY FIRST / if-
exists / if-missing" branches are replaced with definite answers:

- §5.2.1 FederationPayload — already covers ruflo's needs (4 variants,
  Ed25519-sealed); map publish→ReplicateMessage, register→AnnounceBoard.
  No upstream PR.
- §5.2.2 MCP tool registration — static 4-tool surface (list_boards,
  read_board, post_message, search_memory). Re-scoped from "web UI
  rendering" to the actual MCP shape concern. Gap #1 filed for missing
  create_board tool. No upstream PR for Phase 1.
- §5.2.3 Subscription/streaming — genuinely missing (subscribe=false
  hardcoded), but matches ruflo's Phase 1 polling design. Gap #2 filed
  for Phase 4 streaming. No upstream PR for Phase 1.
- §5.2.4 Integration seam — surprise finding: agentbbs already drives
  `npx ruflo federation` via RufloAdapter (other direction). Ruflo's
  CLI now has a contract obligation to keep init/join/status alive.
- §5.2.5 Durable retention — postgres backend exists in CI but
  `agentbbs mcp` defaults to MemoryStore; gap #3 filed for Phase 3.
- §5.2.6 CI regression coverage — no existing end-to-end agentbbs↔ruflo
  test. Closed by ruvnet/AgentBBS#3 regression guard (PR opened upstream
  in this same Phase 1 cycle).

The regression guard at ruvnet/AgentBBS#3 asserts the four MCP tool
names, four FederationPayload variants, and three RufloAdapter
subcommand names continue to exist on every agentbbs PR + nightly cron.

Cross-ref:
- Upstream PR: https://github.com/ruvnet/AgentBBS/pull/3
- Survey commit: ruvnet/AgentBBS@ca3c6e0

* feat(pods): Phase 2 — sales pod end-to-end (dry-run + validator + smoke)

Implements ADR-164 Phase 2: scaffold the first concrete business pod
(sales) end-to-end with a typed validator, a tick runner, and full smoke
coverage. Dry-run mode is the Phase 2 default; --live mode exits 3 with
a "Phase 3" message until Managed Agent + claude-p wiring lands.

New surface (~1,597 LOC):
  v3/@claude-flow/cli/src/business-pods/pod-schema.ts   — PodTemplate type
                                                        + validatePodTemplate()
                                                        + KNOWN_AGENT_TYPES mirror
  v3/@claude-flow/cli/src/mcp-tools/business-pod-tools.ts — 1 new MCP tool
                                                            (business_pod_validate)
  plugins/ruflo-business-pods/
    .claude-plugin/plugin.json
    templates/sales.json                                — Phase 2 sales pod template
                                                          (matches ADR-164 §4.1 +
                                                          §3.3 schema)
    scripts/pod-tick.mjs                                — dry-run tick runner +
                                                          file-based budget ledger
                                                          (TODO(adr-164.1): swap to
                                                          atomic SQLite tracker)
    scripts/pod-tick.test.mjs                           — 15 node:test tests
    scripts/smoke.sh                                    — 8-step smoke contract
    skills/pod-sales/SKILL.md
  .github/workflows/business-pods-smoke.yml             — CI gate

Gates (all PASS):
  - Build: clean
  - vitest business-pod-tools: 19/19 PASS
  - node:test pod-tick: 15/15 PASS
  - ADR-112 audit: 350/350 with guidance (was 349; +1 new tool)
  - business-pods smoke: 8/8 PASS
  - agentbbs regression smoke: 8/8 PASS (no regression from Phase 1)
  - agenticow regression smoke: 8/8 PASS (no regression from PR #2500)

Honest Phase 2 deviations:
  - Schema validator duplicated TS / JS (single canonical TS in cli/src/,
    JS copy in pod-tick.mjs so script runs without prebuilt CLI). Phase 3
    consolidates via shared agents/registry.ts.
  - Budget ledger is file-based JSON stub; ADR-164.1 atomic SQLite
    tracker swap deferred to Phase 3.
  - agentbbs subprocess wiring not invoked; envelopes go directly to
    the Phase 1 JSONL backing store (matches ADR-164 §5.2.2 phase plan).

Phase 3 follow-ups inline-marked TODO(adr-164.1) / TODO(phase-3).

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* feat(pods): Phase 3 — remaining 6 pods + domain-affinity router policy

Implements ADR-164 Phase 3: templates for marketing/finance/ops/support/hr/exec
+ the @metaharness/router domain-affinity policy hook.

New templates (6) at plugins/ruflo-business-pods/templates/:
  marketing.json   SOC2, $40/mo, cloud-tolerant
  finance.json     GDPR, $80/mo, local-pinned, expiryMs=180_000 (3min)
  ops.json         SOC2, $50/mo, local-pinned, synthetic-HTTP bench scenario;
                   TODO(phase-3-http-fetch) for the unbuilt http_fetch tool
  support.json     SOC2, $40/mo, cloud-tolerant
  hr.json          GDPR, $30/mo, local-pinned (employee PII = finance-grade)
  exec.json        GDPR, $60/mo, local-pinned, references §3.5.4
                   founder-bootstrap trust elevation for share-context on Day 1

New code:
  v3/@claude-flow/cli/src/business-pods/domain-affinity-policy.ts (86 LOC)
    - selectAgentBackend(pod): {backend, reason}
    - rules: preferLocalExecution=true → local-stdio
             else if budget.monthlyUsd >= 50 → cloud-managed
             else → remote-peer
  v3/@claude-flow/cli/src/mcp-tools/business-pod-tools.ts (+107 LOC)
    - new MCP tool: business_pod_route_backend
    - shared loadTemplate(input: object | string) helper

Schema mirror in lockstep: added `base-template-generator` to KNOWN_AGENT_TYPES
in BOTH TS (pod-schema.ts) and JS (pod-tick.mjs) copies.

Gates (all PASS):
  - Build: clean
  - vitest business-pod-tools: 44/44 PASS (was 19; +18 Phase 3 cases)
  - node:test pod-tick: 21/21 PASS (was 15; +6 happy-path tests)
  - ADR-112 audit: 351/351 with guidance (was 350; +1 new tool)
  - business-pods smoke: 11/11 PASS (was 8; +3 Phase 3 steps)
  - agentbbs regression smoke: 8/8 PASS
  - agenticow regression smoke: 8/8 PASS

Phase 4 follow-ups (inline TODOs):
  - http_fetch MCP tool implementation (referenced by ops.json)
  - @metaharness/router wire point in policy-engine.ts
  - ruflo federation trust elevate ... --to TRUSTED CLI escape hatch
  - Live-mode dispatch (Managed Agent + claude -p wiring)
  - Atomic SQLite budget tracker swap (ADR-164.1)

Per ADR-164 §3.4, all rules codified verbatim; smoke step 11 + 4 vitest cases
assert what the rules produce per template. Live mode still exits 3 with
"Phase 4" message — Phase 3 is templates + validator + routing-policy only.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* feat: Phase 4 — http_fetch + atomic budget tracker + federation trust elevate

Three Phase 4 deliverables landing per ADR-164:

A. http_fetch MCP tool (ADR-164 §5.1.8)
   - 1 new tool (#352) for ops-pod's synthetic-endpoint bench
   - Security-default-rejects: private IPs (RFC-1918, localhost), auth
     headers (Authorization/Cookie/X-Auth-*) unless env vars opt-in
   - URL allowlist + timeout + response-size cap (256 KB default, 1 MB max)
   - 25/25 vitest pass (URL allowlist, timeout abort, truncation,
     auth-header rejection, happy path against node:http mock)

B. Atomic SQLite budget tracker (ADR-164.1 §3-§5)
   - bbs_budget_rooms + bbs_budget_reservations tables
   - reserve/commit/release with BEGIN IMMEDIATE + _lock_bump
   - committed_post_expiry state machine (Expired Commit Leak fix from
     peer review 2026-06-29) implemented
   - expires_at clamped to [5_000, 300_000] ms per-pod override
   - Behind CLAUDE_FLOW_BBS_ATOMIC_BUDGET=1 — file-based stub remains
     the default until next flip; pod-tick.mjs migration deferred to
     Phase 5
   - 15/15 vitest pass (Tests 1, 2, 3, 6 from ADR-164.1 §9, skipping
     Test 5 clock-skew which would require system-clock manipulation)
   - reservationId is randomUUID v4 (ADR-164.1 §3.2 notes v7 is purely
     aesthetic — v7 generator would add a transitive dep we don't need)

C. ruflo federation trust elevate CLI (ADR-164 §3.5.4)
   - New subcommand: bypasses 500-interaction TRUST_TRANSITION_THRESHOLDS
   - Mandatory --reason + --audit flags (both required by spec)
   - Refuses unknown peer node-ids
   - Reuses existing trust_level_changed audit envelope with
     metadata.tag='bootstrap_elevation' + metadata.operatorBypass=true
     (cheapest path — keeps SEVERITY_BY_EVENT_TYPE / CATEGORY_BY_EVENT_TYPE
     stable rather than introducing a new event-type that needs propagation
     through downstream consumers)

Gates (all PASS):
  - Build: clean (tsc)
  - vitest http-fetch + bbs-budget-tracker: 40/40 PASS (25 + 15)
  - vitest business-pod-tools regression: 44/44 PASS (Phase 3 still green)
  - ADR-112 audit: 352/352 with guidance (was 351; +1 http_fetch)
  - business-pods smoke: 11/11 PASS
  - agentbbs smoke: 8/8 PASS
  - agenticow smoke: 8/8 PASS

Phase 5 follow-ups (explicit deferrals):
  - Live-mode dispatch (pod-tick --live still exits 3)
  - Sweeper timer integration site (daemon worker, not pod-tick)
  - Spend-reporter audit adapter (write to federation_spend namespace
    BEFORE BEGIN IMMEDIATE per ADR-164.1 §6.1)
  - pod-tick.mjs migration from file-based to atomic tracker
  - ACL gate on `federation trust elevate` (currently consistent with
    `federation evict` / `reactivate` — no founder-only crypto check yet)
  - http_fetch wiring into pod-tick live-mode for ops bench

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* chore(lock): regen v3/pnpm-lock.yaml with agentbbs entry (PR #2503 CI fix)

Same fix as we did on the agenticow PR #2500 push — pnpm install at root
regenerates pnpm-lock.yaml (the npm workspace), but the real workspace is
v3/pnpm-workspace.yaml and its sibling v3/pnpm-lock.yaml. CI uses
--frozen-lockfile so the v3 lockfile must be in sync with v3/@claude-flow/cli's
package.json (which declares agentbbs: ~0.1.0 as optional).

* fix(ci): re-export-tolerant agentbbs guard + skip business-pods smoke under --ignore-optional

PR #2503 CI surfaced 2 ADR-164 issues:

1. no-agentbbs-smoke Rule 2 false-positive on mcp-tools/index.ts.
   The static-grep treated `export { agentbbsTools } from './agentbbs-tools.js';`
   as an unguarded agentbbs reference. The agentbbsTools symbol itself
   contains the loadAgentbbs() guard — the index.ts re-export is benign.
   Fixed: strip re-export + spread + import-of-agentbbsTools patterns + all
   comments before the unguarded-reference check.

2. ruflo-business-pods smoke 9/11 under no-metaharness-smoke's runtime
   drill (which does `npm install --ignore-optional`). Without optional
   deps, vitest may be absent AND the cli dist may not exist. Steps 4
   (vitest) and 11 (call into dist) now SKIP cleanly when those are
   missing instead of failing — the main test-suite jobs still gate
   the full vitest + build runs.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): business-pods-smoke uses pnpm (root npm chokes on workspace:*)

The root package.json declares some workspace deps with the pnpm `workspace:*`
protocol that npm doesn't support. Symptom: `npm install` step exited with
EUNSUPPORTEDPROTOCOL.

The v3/ tree is a real pnpm workspace (v3/pnpm-workspace.yaml). Switch the
workflow to install + build from there using pnpm + frozen-lockfile, then
fall through to vitest / node:test / smoke contract.

Pattern matches the existing v3-ci.yml install steps.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): pnpm --filter '@claude-flow/cli...' to include workspace deps

The previous `pnpm --filter @claude-flow/cli build` didn't build workspace
dependencies first, so the cli's tsc step failed with TS2307 errors looking
for @claude-flow/memory, @claude-flow/neural, @claude-flow/cli-core types.
The `...` suffix tells pnpm to include all of the cli's workspace
dependencies in the build graph.

Applied to both business-pods-smoke.yml and no-agentbbs-smoke.yml's
runtime drill.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): pnpm -r build (whole workspace, dep-first) — pnpm filter can't
discover cross-package dist/ imports

Round 4 surfaced two pre-existing fragilities in the workspace's build wiring:

1. business-pods-smoke: @claude-flow/cli imports from @claude-flow/swarm/dist/
   not via package.json deps but via direct file path. `--filter "@claude-flow/cli..."`
   doesn't traverse those. Fixed: `pnpm -r --no-bail build` builds the whole
   workspace dep-first, with a fallback to a direct cli build.

2. no-agentbbs-smoke runtime drill: `--no-optional` stripped agentdb, which
   is a transitive consumer of @claude-flow/neural and breaks neural's tsc
   even though neural has its own optional-degradation path internally.
   Fixed: install everything, then SURGICALLY rm node_modules/agentbbs only,
   so only the dep we're testing graceful-degradation for is missing.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-06-29 18:28:28 -04:00

289 lines
10 KiB
JavaScript

/**
* pod-tick.mjs tests — uses Node's built-in test runner (no extra deps).
* Run with: node --test scripts/pod-tick.test.mjs
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
parseArgs,
validatePodTemplate,
KNOWN_AGENT_TYPES,
reserveBudget,
commitReservation,
pruneExpired,
resetIfNewMonth,
constructPrompt,
publishEnvelope,
} from './pod-tick.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PLUGIN_ROOT = resolve(__dirname, '..');
const SALES_TEMPLATE = join(PLUGIN_ROOT, 'templates', 'sales.json');
const POD_TICK = join(__dirname, 'pod-tick.mjs');
function tmp(prefix = 'pod-tick-') {
return mkdtempSync(join(tmpdir(), prefix));
}
test('parseArgs — defaults: dryRun=true, live=false', () => {
const a = parseArgs([]);
assert.equal(a.dryRun, true);
assert.equal(a.live, false);
assert.equal(a.podTemplate, null);
});
test('parseArgs — --live flips dryRun off', () => {
const a = parseArgs(['--live']);
assert.equal(a.live, true);
assert.equal(a.dryRun, false);
});
test('validatePodTemplate — sales.json validates verbatim', () => {
const sales = JSON.parse(readFileSync(SALES_TEMPLATE, 'utf-8'));
const t = validatePodTemplate(sales);
assert.equal(t.name, 'sales');
assert.equal(t.agents.length, 4);
assert.equal(t.reservationExpiryMs, 60_000);
});
test('validatePodTemplate — reservationExpiryMs out of bounds rejected', () => {
const sales = JSON.parse(readFileSync(SALES_TEMPLATE, 'utf-8'));
sales.reservationExpiryMs = 1;
assert.throws(() => validatePodTemplate(sales), /reservationExpiryMs must be within/);
});
test('KNOWN_AGENT_TYPES — every sales-pod agentType is registered', () => {
const sales = JSON.parse(readFileSync(SALES_TEMPLATE, 'utf-8'));
for (const a of sales.agents) {
assert.ok(
KNOWN_AGENT_TYPES.has(a.agentType),
`agentType "${a.agentType}" not in KNOWN_AGENT_TYPES`,
);
}
});
test('reserveBudget/commit — happy path returns reservationId then commits', () => {
let ledger = resetIfNewMonth({}, 'sales');
ledger.monthlyCap = 50;
const r = reserveBudget(ledger, 0.5, 60_000);
assert.equal(r.ok, true);
assert.ok(r.reservationId.startsWith('res-'));
assert.equal(ledger.reserved, 0.5);
const c = commitReservation(ledger, r.reservationId, 0); // dry-run actual = $0
assert.equal(c.ok, true);
assert.equal(ledger.reserved, 0);
assert.equal(ledger.spent, 0);
});
test('reserveBudget — refuses when budget exhausted', () => {
let ledger = resetIfNewMonth({}, 'sales');
ledger.monthlyCap = 1;
ledger.spent = 0.95;
const r = reserveBudget(ledger, 0.5, 60_000);
assert.equal(r.ok, false);
assert.equal(r.reason, 'BUDGET_EXHAUSTED');
});
test('pruneExpired — releases reservations past their expiry', () => {
let ledger = resetIfNewMonth({}, 'sales');
ledger.monthlyCap = 50;
reserveBudget(ledger, 1.0, 60_000);
// Manually expire the only reservation by rewinding its expiresAtMs.
ledger.reservations[0].expiresAtMs = Date.now() - 1000;
ledger = pruneExpired(ledger, Date.now());
assert.equal(ledger.reservations.length, 0);
assert.equal(ledger.reserved, 0);
});
test('constructPrompt — includes role, bench description, PII policy', () => {
const sales = JSON.parse(readFileSync(SALES_TEMPLATE, 'utf-8'));
const t = validatePodTemplate(sales);
const p = constructPrompt(t, t.agents[0]);
assert.match(p, /lead-gen-agent/);
assert.match(p, /researcher/);
assert.match(p, /sales-pipeline-bench/);
assert.match(p, /PII policy: soc2/);
});
test('publishEnvelope — appends a JSONL row to the room log', () => {
const work = tmp();
try {
const env = publishEnvelope(work, 'sales', 'pod-status', { foo: 'bar' });
assert.ok(env.envelopeId);
assert.equal(env.msgType, 'pod-status');
// The room log path uses a derived roomId — find it by listing the dir.
const fs = readFileSync(join(work, '.agentbbs', `room-${env.roomId}.jsonl`), 'utf-8');
const parsed = JSON.parse(fs.trim().split('\n')[0]);
assert.equal(parsed.msgType, 'pod-status');
assert.deepEqual(parsed.payload, { foo: 'bar' });
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test('CLI: dry-run on sales.json exits 0 with expected stdout shape', () => {
const work = tmp();
try {
const res = spawnSync('node', [
POD_TICK,
'--pod-template', SALES_TEMPLATE,
'--base-path', work,
'--dry-run',
'--tick-id', 'test-tick-1',
], { encoding: 'utf-8' });
assert.equal(res.status, 0, `non-zero exit: stderr=${res.stderr}`);
const out = JSON.parse(res.stdout.trim().split('\n').pop());
assert.equal(out.podName, 'sales');
assert.equal(out.tickId, 'test-tick-1');
assert.equal(out.agentsRan, 4);
assert.equal(out.totalUsd, 0);
assert.equal(out.status, 'success');
assert.equal(out.dryRun, true);
assert.ok(out.envelopeId);
// Dry-run prompts are logged to stderr, one per agent
assert.match(res.stderr, /lead-gen-agent/);
assert.match(res.stderr, /pipeline-analyst/);
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test('CLI: --live exits 3 with refusal message (Phase 2 gate)', () => {
const work = tmp();
try {
const res = spawnSync('node', [
POD_TICK,
'--pod-template', SALES_TEMPLATE,
'--base-path', work,
'--live',
], { encoding: 'utf-8' });
assert.equal(res.status, 3);
assert.match(res.stderr, /Phase 3/);
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test('CLI: unknown agent type produces actionable error', () => {
const work = tmp();
try {
const badTemplate = JSON.parse(readFileSync(SALES_TEMPLATE, 'utf-8'));
badTemplate.agents[0].agentType = 'not-a-real-agent-xyz';
const badPath = join(work, 'bad-sales.json');
writeFileSync(badPath, JSON.stringify(badTemplate));
const res = spawnSync('node', [
POD_TICK,
'--pod-template', badPath,
'--base-path', work,
'--dry-run',
], { encoding: 'utf-8' });
assert.equal(res.status, 2);
assert.match(res.stderr, /unknown agent types.*not-a-real-agent-xyz/);
} finally {
rmSync(work, { recursive: true, force: true });
}
});
test('CLI: budget reservation creates ledger and commits to $0 on dry-run', () => {
const work = tmp();
try {
const res = spawnSync('node', [
POD_TICK,
'--pod-template', SALES_TEMPLATE,
'--base-path', work,
'--dry-run',
], { encoding: 'utf-8' });
assert.equal(res.status, 0);
const ledgerPath = join(work, 'budget', 'sales.json');
assert.ok(existsSync(ledgerPath), 'budget ledger should exist');
const ledger = JSON.parse(readFileSync(ledgerPath, 'utf-8'));
assert.equal(ledger.roomId, 'sales');
assert.equal(ledger.spent, 0); // dry-run actual = $0
assert.equal(ledger.reserved, 0); // reservation committed and released
} finally {
rmSync(work, { recursive: true, force: true });
}
});
// ADR-164 Phase 3 — every additional pod template (marketing/finance/ops/
// support/hr/exec) must validate and produce a successful dry-run tick. The
// existing sales template is covered by 'CLI: dry-run on sales.json' above;
// this loop covers the new six.
const PHASE3_PODS = ['marketing', 'finance', 'ops', 'support', 'hr', 'exec'];
for (const pod of PHASE3_PODS) {
test(`Phase 3: ${pod}.json validates and dry-runs cleanly`, () => {
const podPath = join(PLUGIN_ROOT, 'templates', `${pod}.json`);
// schema validation first
const raw = JSON.parse(readFileSync(podPath, 'utf-8'));
const t = validatePodTemplate(raw);
assert.equal(t.name, pod);
// every agentType must be in the registry — pod-tick.mjs hard-checks this
for (const a of t.agents) {
assert.ok(
KNOWN_AGENT_TYPES.has(a.agentType),
`${pod}.json agentType "${a.agentType}" not in KNOWN_AGENT_TYPES`,
);
}
// dry-run the tick end-to-end
const work = tmp(`pod-tick-${pod}-`);
try {
const res = spawnSync('node', [
POD_TICK,
'--pod-template', podPath,
'--base-path', work,
'--dry-run',
'--tick-id', `phase3-${pod}-tick`,
], { encoding: 'utf-8' });
assert.equal(res.status, 0, `non-zero exit for ${pod}: stderr=${res.stderr}`);
const out = JSON.parse(res.stdout.trim().split('\n').pop());
assert.equal(out.podName, pod);
assert.equal(out.tickId, `phase3-${pod}-tick`);
assert.equal(out.agentsRan, t.agents.length);
assert.equal(out.totalUsd, 0); // dry-run actual = $0
assert.equal(out.status, 'success');
assert.equal(out.dryRun, true);
assert.ok(out.envelopeId);
// envelope JSONL exists
const ledgerPath = join(work, 'budget', `${t.roomId}.json`);
assert.ok(existsSync(ledgerPath), `${pod} ledger should exist`);
} finally {
rmSync(work, { recursive: true, force: true });
}
});
}
test('CLI: envelope is written to room JSONL backing store', async () => {
const work = tmp();
try {
const res = spawnSync('node', [
POD_TICK,
'--pod-template', SALES_TEMPLATE,
'--base-path', work,
'--dry-run',
], { encoding: 'utf-8' });
assert.equal(res.status, 0);
const out = JSON.parse(res.stdout.trim().split('\n').pop());
// Find the JSONL log produced by publishEnvelope
const { readdirSync } = await import('node:fs');
const agentbbsDir = join(work, '.agentbbs');
const files = readdirSync(agentbbsDir);
const log = files.find((f) => f.startsWith('room-') && f.endsWith('.jsonl'));
assert.ok(log, `expected room jsonl file in ${agentbbsDir}`);
const lines = readFileSync(join(agentbbsDir, log), 'utf-8').trim().split('\n');
const last = JSON.parse(lines[lines.length - 1]);
assert.equal(last.envelopeId, out.envelopeId);
assert.equal(last.msgType, 'pod-status');
assert.equal(last.payload.podName, 'sales');
} finally {
rmSync(work, { recursive: true, force: true });
}
});