mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
19e4277e12
* 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>
467 lines
17 KiB
JavaScript
Executable File
467 lines
17 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* pod-tick.mjs — One iteration of a business pod (ADR-164 Phase 2).
|
|
*
|
|
* Dry-run by default; --live is reserved for Phase 3 (Managed-Agent + claude-p
|
|
* wiring) and refuses to engage in this Phase 2 build.
|
|
*
|
|
* Lifecycle for one tick:
|
|
* 1. Load pod template, validate against pod-schema (hand-rolled, no AJV dep).
|
|
* 2. Resolve every agent.agentType against ruflo's agent registry (KNOWN_AGENT_TYPES).
|
|
* Unknown types are fatal with an actionable message.
|
|
* 3. Reserve budget via the Phase-2 file-based stub ledger
|
|
* (basePath/budget/<roomId>.json). TODO(adr-164.1): swap to the atomic
|
|
* SQLite tracker once Phase 3 lands.
|
|
* 4. For each agent: construct a dry-run prompt (kickoff text scoped to the
|
|
* pod's bench description + domain). Log prompts; skip in dry-run mode.
|
|
* 5. Commit the reservation with actual_usd. In dry-run actual_usd = $0.
|
|
* 6. Append a structured envelope summary to the room's JSONL log (Phase 1
|
|
* backing store; same path the federation_bbs_publish MCP tool writes).
|
|
* 7. Emit a single JSON line to stdout: {podName, tickId, agentsRan,
|
|
* totalUsd, envelopeId, status} — /loop ingests this verbatim.
|
|
*
|
|
* CLI:
|
|
* pod-tick.mjs --pod-template <path> [--base-path <dir>] [--dry-run|--live]
|
|
* [--budget-cap-usd <amount>] [--tick-id <id>]
|
|
*
|
|
* Exit codes:
|
|
* 0 — tick succeeded (status === 'success')
|
|
* 2 — invalid template / unknown agent type / budget exhausted / arg error
|
|
* 3 — --live mode requested (refused in Phase 2)
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
|
|
import { resolve, dirname, join, isAbsolute } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { createHash, randomBytes } from 'node:crypto';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
const PLUGIN_ROOT = resolve(__dirname, '..');
|
|
|
|
// ---------- arg parsing ----------------------------------------------------
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
podTemplate: null,
|
|
basePath: null,
|
|
dryRun: true,
|
|
live: false,
|
|
budgetCapUsd: null,
|
|
tickId: null,
|
|
};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--pod-template') args.podTemplate = argv[++i];
|
|
else if (a === '--base-path') args.basePath = argv[++i];
|
|
else if (a === '--dry-run') args.dryRun = true;
|
|
else if (a === '--live') { args.live = true; args.dryRun = false; }
|
|
else if (a === '--budget-cap-usd') args.budgetCapUsd = Number(argv[++i]);
|
|
else if (a === '--tick-id') args.tickId = argv[++i];
|
|
else if (a === '--help' || a === '-h') {
|
|
printUsage();
|
|
process.exit(0);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function printUsage() {
|
|
process.stdout.write(`pod-tick.mjs — one tick of a business pod (ADR-164 Phase 2)
|
|
|
|
Usage:
|
|
pod-tick.mjs [--pod-template <path>] [--base-path <dir>] [--dry-run|--live]
|
|
[--budget-cap-usd <amount>] [--tick-id <id>]
|
|
|
|
Defaults:
|
|
--pod-template templates/sales.json (relative to plugin root)
|
|
--base-path <cwd>/.business-pods
|
|
--dry-run true (--live is refused in Phase 2)
|
|
`);
|
|
}
|
|
|
|
// ---------- minimal pod-schema validator (mirrors cli/src/business-pods) ----
|
|
|
|
// The validator is duplicated in this script because the script must be
|
|
// runnable from a checkout without a pre-built CLI. If pod-schema.ts grows,
|
|
// this copy is the one to keep tracking — the smoke contract pins it.
|
|
|
|
const PII_POLICIES = ['soc2', 'gdpr', 'hipaa', 'permissive'];
|
|
const KNOWN_AGENT_TYPES = new Set([
|
|
'coder', 'researcher', 'tester', 'reviewer', 'architect', 'system-architect',
|
|
'coordinator', 'analyst', 'optimizer', 'security-architect', 'security-auditor',
|
|
'memory-specialist', 'swarm-specialist', 'performance-engineer', 'core-architect',
|
|
'test-architect', 'planner', 'task-orchestrator', 'perf-analyzer', 'backend-dev',
|
|
'api-docs', 'cicd-engineer', 'code-analyzer', 'database-specialist',
|
|
// ADR-164 Phase 3 — added for the marketing/hr pods (content drafting +
|
|
// onboarding-template generation). Mirror in pod-schema.ts KNOWN_AGENT_TYPES.
|
|
'base-template-generator',
|
|
]);
|
|
const CRON_RE = /^([\d*/,\-]+\s+){4,5}[\d*/,\-]+$/;
|
|
|
|
function isObject(v) { return typeof v === 'object' && v !== null && !Array.isArray(v); }
|
|
function fail(msg, path) {
|
|
const err = new Error(`pod-template at ${path}: ${msg}`);
|
|
err.path = path;
|
|
throw err;
|
|
}
|
|
function reqStr(o, k, p) { if (typeof o[k] !== 'string' || !o[k]) fail(`field "${k}" must be non-empty string`, p); return o[k]; }
|
|
function reqNum(o, k, p) { if (typeof o[k] !== 'number' || !Number.isFinite(o[k])) fail(`field "${k}" must be finite number`, p); return o[k]; }
|
|
function reqBool(o, k, p) { if (typeof o[k] !== 'boolean') fail(`field "${k}" must be boolean`, p); return o[k]; }
|
|
function reqArr(o, k, p) { if (!Array.isArray(o[k])) fail(`field "${k}" must be array`, p); return o[k]; }
|
|
|
|
function validatePodTemplate(json) {
|
|
if (!isObject(json)) fail('pod-template must be a JSON object', '/');
|
|
const name = reqStr(json, 'name', '/');
|
|
if (!/^[a-z][a-z0-9-]*$/.test(name)) fail('name must be lowercase-kebab', '/');
|
|
const displayName = reqStr(json, 'displayName', '/');
|
|
const roomId = reqStr(json, 'roomId', '/');
|
|
if (!/^[A-Za-z0-9_.\-:/@#]+$/.test(roomId)) {
|
|
fail('roomId may only contain [A-Za-z0-9_.\\-:/@#]', '/');
|
|
}
|
|
const agents = reqArr(json, 'agents', '/').map((a, i) => {
|
|
const p = `/agents[${i}]`;
|
|
if (!isObject(a)) fail('agent must be object', p);
|
|
return {
|
|
role: reqStr(a, 'role', p),
|
|
agentType: reqStr(a, 'agentType', p),
|
|
description: reqStr(a, 'description', p),
|
|
preferLocal: reqBool(a, 'preferLocal', p),
|
|
};
|
|
});
|
|
if (agents.length === 0) fail('agents must have ≥1 entry', '/');
|
|
const allowedMcpTools = reqArr(json, 'allowedMcpTools', '/').map((t, i) => {
|
|
if (typeof t !== 'string' || !t) fail('allowedMcpTools entries must be non-empty strings', `/allowedMcpTools[${i}]`);
|
|
return t;
|
|
});
|
|
if (allowedMcpTools.length === 0) fail('allowedMcpTools must have ≥1 entry', '/');
|
|
const benchRaw = json.bench;
|
|
if (!isObject(benchRaw)) fail('bench must be object', '/bench');
|
|
const bench = {
|
|
name: reqStr(benchRaw, 'name', '/bench'),
|
|
description: reqStr(benchRaw, 'description', '/bench'),
|
|
successCriteria: reqArr(benchRaw, 'successCriteria', '/bench'),
|
|
scheduleHours: reqNum(benchRaw, 'scheduleHours', '/bench'),
|
|
};
|
|
if (bench.successCriteria.length === 0) fail('bench.successCriteria must have ≥1 entry', '/bench');
|
|
if (bench.scheduleHours < 1) fail('bench.scheduleHours must be ≥1', '/bench');
|
|
const piiPolicy = reqStr(json, 'piiPolicy', '/');
|
|
if (!PII_POLICIES.includes(piiPolicy)) fail(`piiPolicy must be one of ${PII_POLICIES.join(', ')}`, '/');
|
|
const budgetUsdMonthly = reqNum(json, 'budgetUsdMonthly', '/');
|
|
if (budgetUsdMonthly < 0) fail('budgetUsdMonthly must be ≥0', '/');
|
|
const budgetUsdPerRun = reqNum(json, 'budgetUsdPerRun', '/');
|
|
if (budgetUsdPerRun < 0) fail('budgetUsdPerRun must be ≥0', '/');
|
|
if (budgetUsdMonthly > 0 && budgetUsdPerRun > budgetUsdMonthly) {
|
|
fail('budgetUsdPerRun must not exceed budgetUsdMonthly', '/');
|
|
}
|
|
const preferLocalExecution = reqBool(json, 'preferLocalExecution', '/');
|
|
const cronSchedule = reqStr(json, 'cronSchedule', '/');
|
|
if (!CRON_RE.test(cronSchedule)) fail('cronSchedule must be POSIX cron (5 or 6 fields)', '/');
|
|
const auditView = json.auditReadView;
|
|
if (!isObject(auditView)) fail('auditReadView must be object', '/auditReadView');
|
|
const auditReadView = {
|
|
includedEventTypes: reqArr(auditView, 'includedEventTypes', '/auditReadView'),
|
|
retentionDays: reqNum(auditView, 'retentionDays', '/auditReadView'),
|
|
};
|
|
if (auditReadView.retentionDays < 1) fail('auditReadView.retentionDays must be ≥1', '/auditReadView');
|
|
let reservationExpiryMs;
|
|
if (json.reservationExpiryMs !== undefined) {
|
|
const v = reqNum(json, 'reservationExpiryMs', '/');
|
|
if (v < 5_000 || v > 300_000) fail('reservationExpiryMs must be within [5000, 300000] ms (ADR-164.1 §3.2)', '/');
|
|
reservationExpiryMs = v;
|
|
}
|
|
return {
|
|
name, displayName, roomId, agents, allowedMcpTools, bench, piiPolicy,
|
|
budgetUsdMonthly, budgetUsdPerRun, preferLocalExecution, cronSchedule,
|
|
auditReadView, reservationExpiryMs,
|
|
};
|
|
}
|
|
|
|
// ---------- Phase-2 file-based budget ledger stub --------------------------
|
|
// TODO(adr-164.1): replace with atomic SQLite tracker in Phase 3. The
|
|
// reservation/commit/release semantics here are file-write best-effort and
|
|
// NOT crash-safe under concurrent writers. This is acceptable for Phase 2
|
|
// dry-run; production live-mode (Phase 3) must use the atomic tracker.
|
|
|
|
function loadLedger(path) {
|
|
if (!existsSync(path)) {
|
|
return { roomId: '', month: '', reserved: 0, spent: 0, reservations: [] };
|
|
}
|
|
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
}
|
|
|
|
function saveLedger(path, ledger) {
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
writeFileSync(path, JSON.stringify(ledger, null, 2));
|
|
}
|
|
|
|
function currentMonthKey() {
|
|
const d = new Date();
|
|
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
}
|
|
|
|
function resetIfNewMonth(ledger, roomId) {
|
|
const month = currentMonthKey();
|
|
if (ledger.month !== month || ledger.roomId !== roomId) {
|
|
return { roomId, month, reserved: 0, spent: 0, reservations: [] };
|
|
}
|
|
return ledger;
|
|
}
|
|
|
|
function pruneExpired(ledger, nowMs) {
|
|
const live = ledger.reservations.filter((r) => r.expiresAtMs > nowMs);
|
|
const pruned = ledger.reservations.length - live.length;
|
|
if (pruned > 0) {
|
|
let releasedAmount = 0;
|
|
for (const r of ledger.reservations) {
|
|
if (r.expiresAtMs <= nowMs) releasedAmount += r.amountUsd;
|
|
}
|
|
ledger.reserved = Math.max(0, ledger.reserved - releasedAmount);
|
|
ledger.reservations = live;
|
|
}
|
|
return ledger;
|
|
}
|
|
|
|
function reserveBudget(ledger, amount, expiryMs) {
|
|
const now = Date.now();
|
|
ledger = pruneExpired(ledger, now);
|
|
const available = Math.max(0, (ledger.monthlyCap ?? Infinity) - ledger.reserved - ledger.spent);
|
|
if (amount > available) {
|
|
return { ok: false, reason: 'BUDGET_EXHAUSTED', available };
|
|
}
|
|
const id = `res-${randomBytes(6).toString('hex')}`;
|
|
ledger.reservations.push({
|
|
id,
|
|
amountUsd: amount,
|
|
createdAtMs: now,
|
|
expiresAtMs: now + expiryMs,
|
|
});
|
|
ledger.reserved += amount;
|
|
return { ok: true, reservationId: id };
|
|
}
|
|
|
|
function commitReservation(ledger, reservationId, actualUsd) {
|
|
const r = ledger.reservations.find((x) => x.id === reservationId);
|
|
if (!r) return { ok: false, reason: 'RESERVATION_NOT_FOUND' };
|
|
ledger.reservations = ledger.reservations.filter((x) => x.id !== reservationId);
|
|
ledger.reserved = Math.max(0, ledger.reserved - r.amountUsd);
|
|
ledger.spent += actualUsd;
|
|
return { ok: true };
|
|
}
|
|
|
|
// ---------- prompt construction (dry-run logged only) ----------------------
|
|
|
|
function constructPrompt(podTemplate, agent) {
|
|
// The dry-run kickoff prompt the agent would receive. Keep it scoped to
|
|
// the bench description so live-mode wiring (Phase 3) inherits the same
|
|
// shape.
|
|
const criteria = podTemplate.bench.successCriteria.map((s) => ` - ${s}`).join('\n');
|
|
return [
|
|
`You are the ${agent.role} (${agent.agentType}) for the ${podTemplate.displayName} pod.`,
|
|
``,
|
|
`Pod bench: ${podTemplate.bench.name}`,
|
|
`Bench description: ${podTemplate.bench.description}`,
|
|
`Success criteria:`,
|
|
criteria,
|
|
``,
|
|
`Your role: ${agent.description}`,
|
|
``,
|
|
`Kickoff task: Review the past ${podTemplate.bench.scheduleHours}h of #${podTemplate.roomId} activity.`,
|
|
`Identify the 3 highest-priority items relevant to your role, then post a`,
|
|
`summary envelope (msgType=task-result) to room "${podTemplate.roomId}" via`,
|
|
`federation_bbs_publish. Adhere to PII policy: ${podTemplate.piiPolicy}.`,
|
|
].join('\n');
|
|
}
|
|
|
|
// ---------- envelope publish (Phase 1 backing store) -----------------------
|
|
|
|
function roomIdFromLabel(label) {
|
|
const norm = String(label).replace(/^#/, '').toLowerCase();
|
|
const h = createHash('sha256').update(`agentbbs:room:${norm}`).digest('hex').slice(0, 8);
|
|
return `${norm}-${h}`;
|
|
}
|
|
|
|
function roomLogPath(basePath, derivedRoomId) {
|
|
return join(basePath, '.agentbbs', `room-${derivedRoomId}.jsonl`);
|
|
}
|
|
|
|
function nextSeq(path) {
|
|
if (!existsSync(path)) return 1;
|
|
const lines = readFileSync(path, 'utf-8').split(/\r?\n/).filter(Boolean);
|
|
if (lines.length === 0) return 1;
|
|
try {
|
|
const last = JSON.parse(lines[lines.length - 1]);
|
|
return (last.seq ?? lines.length) + 1;
|
|
} catch {
|
|
return lines.length + 1;
|
|
}
|
|
}
|
|
|
|
function publishEnvelope(basePath, roomLabel, msgType, payload) {
|
|
const roomId = roomIdFromLabel(roomLabel);
|
|
const path = roomLogPath(basePath, roomId);
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const envelope = {
|
|
envelopeId: randomBytes(12).toString('hex'),
|
|
roomId,
|
|
seq: nextSeq(path),
|
|
msgType,
|
|
payload,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
appendFileSync(path, JSON.stringify(envelope) + '\n');
|
|
return envelope;
|
|
}
|
|
|
|
// ---------- main tick ------------------------------------------------------
|
|
|
|
async function runTick(args) {
|
|
if (args.live) {
|
|
process.stderr.write(
|
|
'ERROR: --live mode is Phase 3 — requires Managed Agent integration and ' +
|
|
'claude-p wiring; do not flip to live in CI. Exiting.\n',
|
|
);
|
|
process.exit(3);
|
|
}
|
|
|
|
const templatePath = args.podTemplate
|
|
? (isAbsolute(args.podTemplate) ? args.podTemplate : resolve(process.cwd(), args.podTemplate))
|
|
: join(PLUGIN_ROOT, 'templates', 'sales.json');
|
|
|
|
if (!existsSync(templatePath)) {
|
|
process.stderr.write(`ERROR: pod template not found: ${templatePath}\n`);
|
|
process.exit(2);
|
|
}
|
|
|
|
let raw;
|
|
try {
|
|
raw = JSON.parse(readFileSync(templatePath, 'utf-8'));
|
|
} catch (e) {
|
|
process.stderr.write(`ERROR: failed to parse template JSON: ${e.message}\n`);
|
|
process.exit(2);
|
|
}
|
|
|
|
let template;
|
|
try {
|
|
template = validatePodTemplate(raw);
|
|
} catch (e) {
|
|
process.stderr.write(`ERROR: ${e.message}\n`);
|
|
process.exit(2);
|
|
}
|
|
|
|
// Resolve agent types against the registry.
|
|
const unknown = template.agents
|
|
.map((a) => a.agentType)
|
|
.filter((t) => !KNOWN_AGENT_TYPES.has(t));
|
|
if (unknown.length > 0) {
|
|
process.stderr.write(
|
|
`ERROR: unknown agent types in pod template: ${unknown.join(', ')}\n` +
|
|
`Known types: ${[...KNOWN_AGENT_TYPES].sort().join(', ')}\n`,
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
// Budget reservation.
|
|
const basePath = args.basePath
|
|
? (isAbsolute(args.basePath) ? args.basePath : resolve(process.cwd(), args.basePath))
|
|
: resolve(process.cwd(), '.business-pods');
|
|
const ledgerPath = join(basePath, 'budget', `${template.roomId}.json`);
|
|
let ledger = resetIfNewMonth(loadLedger(ledgerPath), template.roomId);
|
|
ledger.monthlyCap = template.budgetUsdMonthly === 0 ? Infinity : template.budgetUsdMonthly;
|
|
const capForTick = args.budgetCapUsd !== null && args.budgetCapUsd >= 0
|
|
? Math.min(template.budgetUsdPerRun, args.budgetCapUsd)
|
|
: template.budgetUsdPerRun;
|
|
const expiryMs = template.reservationExpiryMs ?? 60_000;
|
|
|
|
const reserve = reserveBudget(ledger, capForTick, expiryMs);
|
|
if (!reserve.ok) {
|
|
saveLedger(ledgerPath, ledger);
|
|
const out = {
|
|
podName: template.name,
|
|
tickId: args.tickId ?? `tick-${randomBytes(4).toString('hex')}`,
|
|
agentsRan: 0,
|
|
totalUsd: 0,
|
|
envelopeId: null,
|
|
status: 'failed',
|
|
reason: reserve.reason,
|
|
};
|
|
process.stdout.write(JSON.stringify(out) + '\n');
|
|
process.exit(2);
|
|
}
|
|
saveLedger(ledgerPath, ledger);
|
|
|
|
const tickId = args.tickId ?? `tick-${randomBytes(4).toString('hex')}`;
|
|
|
|
// Build prompts (dry-run only logs them).
|
|
const prompts = [];
|
|
for (const agent of template.agents) {
|
|
const prompt = constructPrompt(template, agent);
|
|
prompts.push({ role: agent.role, agentType: agent.agentType, prompt });
|
|
if (args.dryRun) {
|
|
process.stderr.write(
|
|
`\n[dry-run] ${agent.role} (${agent.agentType}):\n` +
|
|
prompt.split('\n').map((l) => ` ${l}`).join('\n') + '\n',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Commit. Dry-run actual = $0.
|
|
const actualUsd = args.dryRun ? 0 : capForTick; // live path is gated above
|
|
commitReservation(ledger, reserve.reservationId, actualUsd);
|
|
saveLedger(ledgerPath, ledger);
|
|
|
|
// Publish summary envelope to the room (federation_bbs_publish backing store).
|
|
const envelope = publishEnvelope(basePath, template.roomId, 'pod-status', {
|
|
podName: template.name,
|
|
tickId,
|
|
agentsRan: template.agents.length,
|
|
totalUsd: actualUsd,
|
|
dryRun: args.dryRun,
|
|
promptShas: prompts.map((p) => ({
|
|
role: p.role,
|
|
sha: createHash('sha256').update(p.prompt).digest('hex').slice(0, 12),
|
|
})),
|
|
});
|
|
|
|
const result = {
|
|
podName: template.name,
|
|
tickId,
|
|
agentsRan: template.agents.length,
|
|
totalUsd: actualUsd,
|
|
envelopeId: envelope.envelopeId,
|
|
status: 'success',
|
|
dryRun: args.dryRun,
|
|
};
|
|
process.stdout.write(JSON.stringify(result) + '\n');
|
|
return result;
|
|
}
|
|
|
|
// ---------- entrypoint -----------------------------------------------------
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
// Allow being imported by tests without auto-running the tick.
|
|
const isDirectInvocation = process.argv[1] && resolve(process.argv[1]) === resolve(__filename);
|
|
if (isDirectInvocation) {
|
|
runTick(args).catch((err) => {
|
|
process.stderr.write(`FATAL: ${err.stack || err.message}\n`);
|
|
process.exit(2);
|
|
});
|
|
}
|
|
|
|
export {
|
|
parseArgs,
|
|
validatePodTemplate,
|
|
KNOWN_AGENT_TYPES,
|
|
reserveBudget,
|
|
commitReservation,
|
|
pruneExpired,
|
|
loadLedger,
|
|
saveLedger,
|
|
resetIfNewMonth,
|
|
constructPrompt,
|
|
publishEnvelope,
|
|
roomIdFromLabel,
|
|
runTick,
|
|
};
|