diff --git a/packages/wavex-os-server/src/bridge/liaison-ext/DELIVERABLE_EMIT.md b/packages/wavex-os-server/src/bridge/liaison-ext/DELIVERABLE_EMIT.md new file mode 100644 index 00000000..265cd959 --- /dev/null +++ b/packages/wavex-os-server/src/bridge/liaison-ext/DELIVERABLE_EMIT.md @@ -0,0 +1,66 @@ +# Skill: emit a deliverable artifact when you finish + +When you complete a unit of work that produced something — a document, a +draft, a plan, a dataset, an analysis — **emit a `wavex-artifact` block** as +your closing comment on the issue before you mark it done. WaveX captures +that block into a **git-committed, verifiable deliverable**: the proof that +the work happened and what it produced, inspectable later via +`git show `. + +This is the producer half of the deliverable ledger. The Liaison mirrors +the *accountability* (who/what/cost); your block supplies the *artifact*. + +## When to emit + +- You finished work on an issue AND it produced a concrete output. +- One block per issue, in your final comment, right before you close it. + +Do **not** emit for: pure coordination/triage with no output, or +`code_change` / `db_migration` work — those go through the Git Engineer's +PR flow (`artifacts.pr_url`), not this block. + +## The block + +A fenced block tagged `wavex-artifact`: a header of `key: value` lines, a +`---` separator, then the artifact body. + +``` +\`\`\`wavex-artifact +kind: document +title: Q3 campaign brief +mime: text/markdown +--- +# Q3 Campaign +- Channel: Instagram + email +- Offer: autumn line, 15% launch discount +- Timeline: ships Sept 1 +...the full artifact content... +\`\`\` +``` + +### Header keys + +- `kind` (optional) — one of `document`, `code`, `email_draft`, + `message_draft`, `data_artifact`, `meeting_artifact`. Defaults to + `document` if omitted or unrecognized. +- `title` (**required**) — a short name for the deliverable. No title → the + block is ignored. +- `mime` (optional) — e.g. `text/markdown`, `text/plain`, `application/json`. +- `filename` (optional) — a preferred on-disk name for the artifact. + +### Body + +Everything after `---` is committed verbatim as the artifact. Put the actual +deliverable there — not a summary of it. If the real output lives elsewhere +(a connected doc, a file the Git Engineer handles), keep the body short and +reference it; the block still records that the deliverable exists. + +## Rules + +- **One block per issue.** Re-emitting is harmless — the reconciler is + idempotent (one deliverable per issue) and skips issues that already have + one. +- **Real content, not a recap.** The body is the thing a reviewer opens. +- **No secrets / PII** beyond what already belongs in the issue. +- The deliverable only materializes once the issue reaches a terminal state + (done/closed/completed/resolved/verified). Emit the block, then close. diff --git a/packages/wavex-os-server/src/mission-control/artifact-block.ts b/packages/wavex-os-server/src/mission-control/artifact-block.ts new file mode 100644 index 00000000..ca24dc71 --- /dev/null +++ b/packages/wavex-os-server/src/mission-control/artifact-block.ts @@ -0,0 +1,89 @@ +/** Parse a `wavex-artifact` fenced block out of issue text. + * + * Agents that finish a unit of work emit a block so the reconciler can + * turn their output into a git-committed, verifiable deliverable. It + * mirrors the existing `wavex-contract` block convention (see + * liaison-ext/DELIVERABLE_LEDGER.md): a header of `key: value` lines, + * an optional `---` separator, then the raw artifact body. + * + * ```wavex-artifact + * kind: document + * title: Q3 campaign brief + * mime: text/markdown + * --- + * # Q3 campaign + * Launch the autumn line via IG + email... + * ``` + * + * The header carries `kind` + `title` (+ optional `mime`/`filename`); the + * body after `---` is the artifact content. A block with no `---` and no + * body is treated as header-only (content empty) — still a valid signal + * that a deliverable was produced, just without inline content. */ + +import type { DeliverableKind } from "@wavex-os/shared/types/mission-control"; + +export interface ParsedArtifact { + kind: DeliverableKind; + title: string; + mimeType?: string; + filename?: string; + /** Inline artifact body (everything after the `---` separator). */ + content: string; +} + +const FENCE_RE = /```wavex-artifact\s*\n([\s\S]*?)```/; + +const KNOWN_KINDS: ReadonlySet = new Set([ + "document", + "code", + "email_draft", + "message_draft", + "data_artifact", + "meeting_artifact", +]); + +/** Extract the first `wavex-artifact` block from `text`. Returns null when + * there is no block, or when the block lacks the required `title`. `kind` + * defaults to "document" if absent or unrecognized — a deliverable with a + * loose kind is better than dropping a real artifact on the floor. */ +export function parseArtifactBlock(text: string | null | undefined): ParsedArtifact | null { + if (!text) return null; + const m = FENCE_RE.exec(text); + if (!m) return null; + const inner = m[1] ?? ""; + + // Split header from body on the first line that is exactly `---`. + const lines = inner.split("\n"); + let sepIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i]!.trim() === "---") { + sepIdx = i; + break; + } + } + const headerLines = sepIdx >= 0 ? lines.slice(0, sepIdx) : lines; + const bodyLines = sepIdx >= 0 ? lines.slice(sepIdx + 1) : []; + + const header: Record = {}; + for (const line of headerLines) { + const idx = line.indexOf(":"); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim().toLowerCase(); + const val = line.slice(idx + 1).trim(); + if (key) header[key] = val; + } + + const title = header.title; + if (!title) return null; + + const rawKind = (header.kind ?? "").toLowerCase(); + const kind = (KNOWN_KINDS.has(rawKind) ? rawKind : "document") as DeliverableKind; + + return { + kind, + title, + mimeType: header.mime || header.mimetype || undefined, + filename: header.filename || undefined, + content: bodyLines.join("\n").trim(), + }; +} diff --git a/packages/wavex-os-server/src/mission-control/reconcile-deliverables.ts b/packages/wavex-os-server/src/mission-control/reconcile-deliverables.ts new file mode 100644 index 00000000..563e8808 --- /dev/null +++ b/packages/wavex-os-server/src/mission-control/reconcile-deliverables.ts @@ -0,0 +1,170 @@ +/** Auto-producer: turn completed Paperclip issues into git-committed + * deliverables. + * + * Slice 3 of the deliverable write-path. An agent that finishes work + * emits a `wavex-artifact` block on its issue (see DELIVERABLE_EMIT.md). + * This reconciler scans completed issues, finds those blocks, and calls + * writeDeliverable() — which commits the artifact to the company's + * deliverables git repo. Idempotent: one deliverable per issue, skipped + * if one already exists for that taskRefId. + * + * The caller supplies the already-fetched issues (the route + scheduler + * do the Paperclip fetch), so this core logic is pure and testable. */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { Db } from "@wavex-os/db"; +import type { PaperclipMode } from "@wavex-os/shared/types/mission-control"; +import { parseArtifactBlock } from "./artifact-block.js"; +import { queryDeliverables, writeDeliverable } from "./deliverables.js"; + +/** A Paperclip issue, flattened to what the reconciler needs. The route's + * fetcher maps Paperclip's issue shape into this. `body` should already + * include the description plus any comments to scan for the block. */ +export interface ReconcileIssue { + id: string; + state: string; + title: string; + body: string; + assignee?: string; +} + +export interface ReconcileInput { + companyId: string; + instanceId: string; + modeContext?: PaperclipMode; + issues: ReconcileIssue[]; +} + +export interface ReconcileResult { + created: string[]; // deliverable ids created this run + skipped: number; // issues with a block but an existing deliverable + errors: number; // issues that threw during write +} + +const TERMINAL_HINTS = ["done", "closed", "completed", "resolved", "verified", "delivered"]; + +/** An issue is "completed" when its state contains a terminal hint. We + * match loosely because Paperclip state vocabularies vary by install. */ +export function isTerminalState(state: string | null | undefined): boolean { + if (!state) return false; + const s = state.toLowerCase(); + return TERMINAL_HINTS.some((h) => s.includes(h)); +} + +// ── I/O glue (not unit-tested — the pure reconcileDeliverables is) ─────── + +interface PaperclipHandoff { + paperclipUrl?: string; + paperclipCompanyId?: string; +} + +function stateRoot(): string { + return process.env.WAVEX_OS_STATE_DIR ?? join(homedir(), ".wavex-os"); +} + +async function readHandoff(companyId: string): Promise { + try { + const path = join(stateRoot(), "instances", "default", "companies", companyId, "paperclip-handoff.json"); + return JSON.parse(await readFile(path, "utf8")) as PaperclipHandoff; + } catch { + return null; + } +} + +/** Fetch completed issues for a company from the local Paperclip, flattened + * into ReconcileIssue[]. Defensive on every axis: missing handoff, an + * unreachable Paperclip, or an unexpected response shape all yield [] so + * the reconciler simply no-ops that tick rather than throwing. Field-name + * fallbacks (id|key, state|status, body|description) absorb Paperclip API + * drift. Comments are appended to the body so artifact blocks posted as a + * closing comment are still scanned. */ +export async function fetchCompletedIssuesForCompany( + companyId: string, +): Promise { + const handoff = await readHandoff(companyId); + const base = handoff?.paperclipUrl ?? process.env.PAPERCLIP_HANDOFF_URL ?? "http://127.0.0.1:3100"; + const pcId = handoff?.paperclipCompanyId ?? companyId; + try { + const r = await fetch(`${base}/api/companies/${encodeURIComponent(pcId)}/issues`); + if (!r.ok) return []; + const raw = (await r.json()) as unknown; + const list = Array.isArray(raw) + ? raw + : Array.isArray((raw as { issues?: unknown }).issues) + ? (raw as { issues: unknown[] }).issues + : []; + return list + .map((it): ReconcileIssue | null => { + const o = it as Record; + const id = String(o.id ?? o.key ?? ""); + if (!id) return null; + const description = String(o.description ?? o.body ?? ""); + const comments = Array.isArray(o.comments) + ? (o.comments as Array>) + .map((c) => String(c.body ?? c.text ?? "")) + .join("\n") + : ""; + return { + id, + state: String(o.state ?? o.status ?? ""), + title: String(o.title ?? o.name ?? id), + body: `${description}\n${comments}`, + assignee: o.assignee ? String(o.assignee) : undefined, + }; + }) + .filter((x): x is ReconcileIssue => x !== null); + } catch { + return []; + } +} + +export async function reconcileDeliverables( + input: ReconcileInput, + db?: Db, +): Promise { + const result: ReconcileResult = { created: [], skipped: 0, errors: 0 }; + + for (const issue of input.issues) { + if (!isTerminalState(issue.state)) continue; + const parsed = parseArtifactBlock(issue.body); + if (!parsed) continue; + + // Idempotency — one deliverable per issue. The local deliverables + // table does not upsert, so we guard on existing rows for this issue. + try { + const existing = await queryDeliverables( + { companyId: input.companyId, taskRefId: issue.id, limit: 1 }, + db, + ); + if (existing.length > 0) { + result.skipped += 1; + continue; + } + + const d = await writeDeliverable( + { + companyId: input.companyId, + instanceId: input.instanceId, + modeContext: input.modeContext ?? "solo_founder", + taskRefType: "issue", + taskRefId: issue.id, + producedByNodeId: issue.assignee ?? "unknown", + kind: parsed.kind, + title: parsed.title, + mimeType: parsed.mimeType, + filename: parsed.filename, + payload: { body: parsed.content, issueTitle: issue.title }, + plainLanguageSentence: `Captured deliverable "${parsed.title}" from completed issue ${issue.id}.`, + }, + db, + ); + result.created.push(d.id); + } catch { + result.errors += 1; + } + } + + return result; +} diff --git a/packages/wavex-os-server/src/routes/mission-control.ts b/packages/wavex-os-server/src/routes/mission-control.ts index b3280557..eb1208dd 100644 --- a/packages/wavex-os-server/src/routes/mission-control.ts +++ b/packages/wavex-os-server/src/routes/mission-control.ts @@ -30,6 +30,10 @@ import { type QueryDeliverablesInput, type WriteDeliverableInput, } from "../mission-control/deliverables.js"; +import { + reconcileDeliverables, + fetchCompletedIssuesForCompany, +} from "../mission-control/reconcile-deliverables.js"; import { announceDueImpacts, declareKpiImpact, @@ -673,6 +677,45 @@ export function registerMissionControlRoutes(app: FastifyInstance): void { } }); + // ── POST /api/mission-control/:companyId/reconcile-deliverables ─────── + // Auto-producer trigger: scan the company's completed Paperclip issues + // for `wavex-artifact` blocks and materialize any that don't yet have a + // deliverable (git-committed via writeDeliverable). Idempotent. Intended + // to be called by the Liaison heartbeat or a cron; safe to call anytime + // (no completed issues with blocks → zero created). + app.post("/api/mission-control/:companyId/reconcile-deliverables", async (req, reply) => { + const ar = authReq(req); + try { + assertBoard(ar); + } catch (e) { + if (e instanceof AuthError) + return reply.status(e.statusCode).send({ error: e.message }); + throw e; + } + const { companyId } = req.params as { companyId: string }; + assertCompanyAccess(ar, companyId); + await ensureBootstrap(); + try { + const issues = await fetchCompletedIssuesForCompany(companyId); + const result = await reconcileDeliverables({ + companyId, + instanceId: companyId, + issues, + }); + return { + ok: true, + scanned: issues.length, + created: result.created, + skipped: result.skipped, + errors: result.errors, + }; + } catch (e) { + if (e instanceof AuthError) + return reply.status(e.statusCode).send({ error: e.message }); + return reply.status(503).send({ ok: false, error: e instanceof Error ? e.message : String(e) }); + } + }); + // ── KPI impacts ─────────────────────────────────────────────────────── app.post( "/api/mission-control/:companyId/kpi-impacts", diff --git a/packages/wavex-os-server/test/reconcile-deliverables.test.ts b/packages/wavex-os-server/test/reconcile-deliverables.test.ts new file mode 100644 index 00000000..dbca0422 --- /dev/null +++ b/packages/wavex-os-server/test/reconcile-deliverables.test.ts @@ -0,0 +1,144 @@ +/** Auto-producer — artifact-block parsing + issue→deliverable reconcile. */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { _resetDbCache, runMigrations } from "@wavex-os/db"; +import { parseArtifactBlock } from "../src/mission-control/artifact-block.js"; +import { + reconcileDeliverables, + isTerminalState, + type ReconcileIssue, +} from "../src/mission-control/reconcile-deliverables.js"; +import { queryDeliverables } from "../src/mission-control/deliverables.js"; +import { _resetActivityBusForTesting } from "../src/mission-control/activity-bus.js"; + +describe("parseArtifactBlock", () => { + it("parses kind/title/mime + body after ---", () => { + const text = [ + "Some preamble comment.", + "```wavex-artifact", + "kind: document", + "title: Q3 campaign brief", + "mime: text/markdown", + "---", + "# Q3 Campaign", + "Launch the autumn line.", + "```", + "trailing text", + ].join("\n"); + const p = parseArtifactBlock(text); + expect(p).not.toBeNull(); + expect(p!.kind).toBe("document"); + expect(p!.title).toBe("Q3 campaign brief"); + expect(p!.mimeType).toBe("text/markdown"); + expect(p!.content).toBe("# Q3 Campaign\nLaunch the autumn line."); + }); + + it("returns null when there is no block", () => { + expect(parseArtifactBlock("just a normal comment")).toBeNull(); + expect(parseArtifactBlock("")).toBeNull(); + expect(parseArtifactBlock(null)).toBeNull(); + }); + + it("returns null when title is missing", () => { + const text = "```wavex-artifact\nkind: document\n---\nbody\n```"; + expect(parseArtifactBlock(text)).toBeNull(); + }); + + it("defaults unknown/absent kind to document", () => { + const text = "```wavex-artifact\nkind: wat\ntitle: T\n---\nx\n```"; + expect(parseArtifactBlock(text)!.kind).toBe("document"); + const text2 = "```wavex-artifact\ntitle: T2\n---\nx\n```"; + expect(parseArtifactBlock(text2)!.kind).toBe("document"); + }); + + it("treats a header-only block (no ---) as empty content", () => { + const text = "```wavex-artifact\ntitle: Header only\nkind: document\n```"; + const p = parseArtifactBlock(text); + expect(p).not.toBeNull(); + expect(p!.content).toBe(""); + }); +}); + +describe("isTerminalState", () => { + it("matches terminal hints loosely, rejects open states", () => { + for (const s of ["done", "Closed", "completed", "resolved", "VERIFIED", "delivered"]) { + expect(isTerminalState(s)).toBe(true); + } + for (const s of ["open", "in progress", "assigned", "", null, undefined]) { + expect(isTerminalState(s)).toBe(false); + } + }); +}); + +describe("reconcileDeliverables", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = mkdtempSync(join(tmpdir(), "wavex-reconcile-")); + process.env.WAVEX_OS_STATE_DIR = tempDir; + process.env.WAVEX_DB_DATA_DIR = join(tempDir, "db"); + _resetDbCache(); + _resetActivityBusForTesting(); + await runMigrations(); + }); + + afterEach(() => { + delete process.env.WAVEX_OS_STATE_DIR; + delete process.env.WAVEX_DB_DATA_DIR; + rmSync(tempDir, { recursive: true, force: true }); + }); + + const withBlock = (id: string, state: string): ReconcileIssue => ({ + id, + state, + title: `Issue ${id}`, + body: `done\n\`\`\`wavex-artifact\ntitle: Output ${id}\nkind: document\n---\nthe artifact for ${id}\n\`\`\``, + assignee: "agent-cmo", + }); + + it("materializes a git-committed deliverable for a completed issue with a block", async () => { + const r = await reconcileDeliverables({ + companyId: "co-r", + instanceId: "co-r", + issues: [withBlock("WAV-1", "done")], + }); + expect(r.created).toHaveLength(1); + expect(r.skipped).toBe(0); + + const got = await queryDeliverables({ companyId: "co-r", taskRefId: "WAV-1" }); + expect(got).toHaveLength(1); + expect(got[0]!.title).toBe("Output WAV-1"); + expect(got[0]!.producedByNodeId).toBe("agent-cmo"); + // Git-first: the reconciled deliverable carries a commit. + expect(got[0]!.commitSha).toMatch(/^[a-f0-9]{40,64}$/); + }); + + it("skips non-terminal issues and issues without a block", async () => { + const r = await reconcileDeliverables({ + companyId: "co-r2", + instanceId: "co-r2", + issues: [ + withBlock("WAV-2", "in progress"), // has block but not terminal + { id: "WAV-3", state: "done", title: "no block", body: "just closed it" }, + ], + }); + expect(r.created).toHaveLength(0); + expect(r.skipped).toBe(0); + }); + + it("is idempotent — a second run skips the already-captured issue", async () => { + const issues = [withBlock("WAV-4", "completed")]; + const first = await reconcileDeliverables({ companyId: "co-r3", instanceId: "co-r3", issues }); + expect(first.created).toHaveLength(1); + + const second = await reconcileDeliverables({ companyId: "co-r3", instanceId: "co-r3", issues }); + expect(second.created).toHaveLength(0); + expect(second.skipped).toBe(1); + + const got = await queryDeliverables({ companyId: "co-r3", taskRefId: "WAV-4" }); + expect(got).toHaveLength(1); // not duplicated + }); +});