Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 <commit>`.

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.
89 changes: 89 additions & 0 deletions packages/wavex-os-server/src/mission-control/artifact-block.ts
Original file line number Diff line number Diff line change
@@ -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<string> = 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<string, string> = {};
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(),
};
}
170 changes: 170 additions & 0 deletions packages/wavex-os-server/src/mission-control/reconcile-deliverables.ts
Original file line number Diff line number Diff line change
@@ -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<PaperclipHandoff | null> {
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<ReconcileIssue[]> {
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<string, unknown>;
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<Record<string, unknown>>)
.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<ReconcileResult> {
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;
}
43 changes: 43 additions & 0 deletions packages/wavex-os-server/src/routes/mission-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading