feat(board): materialize program items as Main-PM roots, make reports actionable (#711)

Two coupled gaps in the Board Program output path.

Approved items were created unowned and in BACKLOG. Nothing dispatches
BACKLOG, and once activated a cell PM claimed the parentless task as a root,
where _cell_pm_complete resolves its merge target through
resolve_parent_branch — which for a parentless task falls through to the
project head rung. The result was a cell branch merging straight into the
trunk, bypassing the Main-PM root, the root->master PR and the CEO gate
(live: PRs #703 and #704 both targeted slave directly).

All eight materializers now create a PENDING, main-pm-assigned root with
team=Team.MAIN_PM, matching what approve_and_start does for an intake draft.
The team is load-bearing, not cosmetic: _next_hint_pr_fail,
_deliver_pr_fail_to_owner, delegate's wave-chain dispatch and the PR layer
label all key on it, and a cell-teamed root drops the 'do NOT re-submit the
root' steer that exists because of PR #138's infinite pr_fail loop. The
item's own cell survives as a delegation hint in the description, which is
what the Main PM's briefing renders.

Periscope, Sentinel and Coroner produced artifacts with no way to act on
them — three panel surfaces carried explicit 'no approve/reject UI' comments
while each item already held a machine-readable suggested action. They now
have per-item approve and dismiss, modelled on the roadmap queue: idempotent
per item, CEO-gated, deep-copy-before-mutate so SQLAlchemy's dirty check
still fires, and every decision recorded through record_decision so it
reaches the next cycle's prompt. Approving materializes through the same
corrected Main-PM-owned path.

Target project resolves to each engine's own existing anchor — RoboCo's
project for Periscope and Sentinel, the incident's project for Coroner — and
fails with a clean invalid_state naming what is unresolvable rather than
guessing at a repo.

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-26 20:01:24 +02:00
committed by GitHub
co-authored by Renn F
parent 66f0287d11
commit a7b970a3b2
48 changed files with 4412 additions and 377 deletions
@@ -1,15 +1,26 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { Postmortem } from "@/lib/api/coroner";
const { listPostmortems } = vi.hoisted(() => ({
listPostmortems: vi.fn(async () => [] as Postmortem[]),
}));
const { listPostmortems, approveProcessChange, rejectProcessChange } =
vi.hoisted(() => ({
listPostmortems: vi.fn(async () => [] as Postmortem[]),
approveProcessChange: vi.fn(async () => ({
status: "approved",
materialized_task_id: "task-1",
detail: "materialized as a Main-PM-owned task",
})),
rejectProcessChange: vi.fn(async () => ({
status: "rejected",
detail: "dismissed; feeds the next cycle's prompt",
})),
}));
vi.mock("@/lib/api", () => ({
coronerApi: { listPostmortems },
coronerApi: { listPostmortems, approveProcessChange, rejectProcessChange },
}));
import { CoronerPostmortemsCard } from "../coroner-postmortems-card";
@@ -21,24 +32,32 @@ function withQueryClient(ui: ReactNode) {
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
const SAMPLE_POSTMORTEM: Postmortem = {
task_id: "pm-1",
title: "Coroner postmortem",
completed_at: "2026-07-24T10:00:00Z",
incident_task_id: "abc12345-0000-0000-0000-000000000000",
incident_kind: "bounced",
incident_title: "Fix worktree venv rot",
incident_summary: "The task bounced 3 times over a stale venv symptom.",
root_cause: "The gate never verified the venv's dev extras were installed.",
failed_stage: "awaiting_qa",
process_change_kind: "conventions_rule",
process_change_description: "Add a venv-freshness check to make quality.",
playbook_id: null,
};
function buildPostmortem(overrides: Partial<Postmortem> = {}): Postmortem {
return {
task_id: "pm-1",
title: "Coroner postmortem",
completed_at: "2026-07-24T10:00:00Z",
incident_task_id: "abc12345-0000-0000-0000-000000000000",
incident_kind: "bounced",
incident_title: "Fix worktree venv rot",
incident_summary: "The task bounced 3 times over a stale venv symptom.",
root_cause: "The gate never verified the venv's dev extras were installed.",
failed_stage: "awaiting_qa",
process_change_kind: "conventions_rule",
process_change_description: "Add a venv-freshness check to make quality.",
playbook_id: null,
process_change_status: "proposed",
process_change_reject_reason: null,
process_change_materialized_task_id: null,
...overrides,
};
}
const SAMPLE_POSTMORTEM: Postmortem = buildPostmortem();
describe("CoronerPostmortemsCard", () => {
beforeEach(() => {
listPostmortems.mockClear();
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
@@ -61,15 +80,11 @@ describe("CoronerPostmortemsCard", () => {
expect(screen.getByText("bounced 3+ times")).toBeInTheDocument();
expect(screen.getByText("awaiting_qa")).toBeInTheDocument();
expect(screen.getByText("conventions_rule")).toBeInTheDocument();
expect(
screen.getByText(/stale venv symptom/i),
).toBeInTheDocument();
expect(screen.getByText(/stale venv symptom/i)).toBeInTheDocument();
expect(
screen.getByText(/never verified the venv's dev extras/i),
).toBeInTheDocument();
expect(
screen.getByText(/add a venv-freshness check/i),
).toBeInTheDocument();
expect(screen.getByText(/add a venv-freshness check/i)).toBeInTheDocument();
});
it("shows an offline state and retries on error", async () => {
@@ -82,4 +97,70 @@ describe("CoronerPostmortemsCard", () => {
).toBeInTheDocument(),
);
});
// Defect 2 fix: a proposed process change now gets its own approve/dismiss
// action.
it("renders approve/dismiss controls for a proposed process change", async () => {
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
expect(
screen.getByRole("button", { name: /approve/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /dismiss/i }),
).toBeInTheDocument();
});
it("hides the actions once the process change already drafted a playbook", async () => {
listPostmortems.mockResolvedValueOnce([
buildPostmortem({
process_change_kind: "playbook",
process_change_status: "not_applicable",
}),
]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
expect(
screen.queryByRole("button", { name: /approve/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Drafted as playbook")).toBeInTheDocument();
});
it("approves the process change via the API", async () => {
const user = userEvent.setup();
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
await user.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() =>
expect(approveProcessChange).toHaveBeenCalledWith("pm-1"),
);
});
it("dismisses the process change with a reason via the dialog", async () => {
const user = userEvent.setup();
listPostmortems.mockResolvedValueOnce([SAMPLE_POSTMORTEM]);
render(withQueryClient(<CoronerPostmortemsCard />));
await screen.findByText("Fix worktree venv rot");
await user.click(screen.getByRole("button", { name: /dismiss/i }));
const dialog = await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Reason"), "one-off incident");
await user.click(
within(dialog).getByRole("button", { name: /^dismiss$/i }),
);
await waitFor(() =>
expect(rejectProcessChange).toHaveBeenCalledWith(
"pm-1",
"one-off incident",
),
);
});
});
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { MarketBrief } from "@/lib/api/periscope";
@@ -20,6 +21,7 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
claim: "Competitor X launched an autonomous PR-review agent",
source_url: "https://example.com/competitor-x-launch",
relevance: "Directly overlaps our pr_reviewer role",
status: "proposed",
},
],
threats: ["Feature parity gap on PR review"],
@@ -29,17 +31,28 @@ function buildBrief(overrides: Partial<MarketBrief> = {}): MarketBrief {
};
}
const { listBriefs } = vi.hoisted(() => ({
const { listBriefs, approveFinding, rejectFinding } = vi.hoisted(() => ({
listBriefs: vi.fn(
() =>
new Promise((r) => {
resolveListRef.current = r as (v: unknown) => void;
}),
),
approveFinding: vi.fn(async () => ({
status: "approved",
finding_id: "finding-0",
materialized_task_id: "task-1",
detail: "materialized as a Main-PM-owned task",
})),
rejectFinding: vi.fn(async () => ({
status: "rejected",
finding_id: "finding-0",
detail: "dismissed; feeds the next cycle's prompt",
})),
}));
vi.mock("@/lib/api", () => ({
periscopeApi: { listBriefs },
periscopeApi: { listBriefs, approveFinding, rejectFinding },
}));
import { MarketBriefsTab } from "../market-briefs-tab";
@@ -90,28 +103,94 @@ describe("MarketBriefsTab", () => {
"href",
"https://example.com/competitor-x-launch",
);
expect(screen.getByText("Feature parity gap on PR review")).toBeInTheDocument();
expect(
screen.getByText("Feature parity gap on PR review"),
).toBeInTheDocument();
expect(
screen.getByText("Lean into our findings-ledger differentiator"),
).toBeInTheDocument();
expect(
screen.getByText(
"Emphasize the structured findings ledger in messaging",
),
screen.getByText("Emphasize the structured findings ledger in messaging"),
).toBeInTheDocument();
});
it("renders no approve/reject controls — a report has no queue action", async () => {
// Defect 2 fix: a proposed finding now gets its own approve/dismiss action
// even though the brief itself stays a read-only report — was "renders no
// approve/reject controls".
it("renders per-finding approve/dismiss controls for a proposed finding", async () => {
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([buildBrief()]);
await screen.findByText("A rival tool shipped agentic PR review");
expect(
screen.getByRole("button", { name: /approve/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /dismiss/i }),
).toBeInTheDocument();
});
it("hides the actions once a finding is approved", async () => {
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([
buildBrief({
findings: [
{
id: "finding-0",
claim: "Competitor X launched an autonomous PR-review agent",
source_url: "https://example.com/competitor-x-launch",
relevance: "Directly overlaps our pr_reviewer role",
status: "approved",
materialized_task_id: "task-1",
},
],
}),
]);
await screen.findByText("A rival tool shipped agentic PR review");
expect(
screen.queryByRole("button", { name: /approve/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /reject/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Approved")).toBeInTheDocument();
});
it("approves a finding via the API", async () => {
const user = userEvent.setup();
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([buildBrief()]);
await screen.findByText("A rival tool shipped agentic PR review");
await user.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() =>
expect(approveFinding).toHaveBeenCalledWith("brief-1", "finding-0"),
);
});
it("dismisses a finding with a reason via the dialog", async () => {
const user = userEvent.setup();
render(withQueryClient(<MarketBriefsTab />));
resolveListRef.current?.([buildBrief()]);
await screen.findByText("A rival tool shipped agentic PR review");
await user.click(screen.getByRole("button", { name: /dismiss/i }));
const dialog = await screen.findByRole("dialog");
await user.type(
screen.getByLabelText("Reason"),
"not actionable this quarter",
);
await user.click(
within(dialog).getByRole("button", { name: /^dismiss$/i }),
);
await waitFor(() =>
expect(rejectFinding).toHaveBeenCalledWith(
"brief-1",
"finding-0",
"not actionable this quarter",
),
);
});
it("omits optional sections when threats/opportunities/positioning are absent", async () => {
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import type { QualityReport } from "@/lib/api/sentinel";
@@ -18,27 +19,41 @@ function buildReport(overrides: Partial<QualityReport> = {}): QualityReport {
{
id: "item-0",
area: "waivers",
observation: "Minor findings in roboco/services/task.py keep getting waived",
observation:
"Minor findings in roboco/services/task.py keep getting waived",
evidence: "5 waived-minor findings this week (prior week: 1)",
suggested_action: "Convert to a Pest Control bug task",
status: "proposed",
},
],
overall_assessment: "Drift is concentrated in one hotspot file, not systemic yet",
overall_assessment:
"Drift is concentrated in one hotspot file, not systemic yet",
...overrides,
};
}
const { listReports } = vi.hoisted(() => ({
const { listReports, approveItem, rejectItem } = vi.hoisted(() => ({
listReports: vi.fn(
() =>
new Promise((r) => {
resolveListRef.current = r as (v: unknown) => void;
}),
),
approveItem: vi.fn(async () => ({
status: "approved",
item_id: "item-0",
materialized_task_id: "task-1",
detail: "materialized as a Main-PM-owned task",
})),
rejectItem: vi.fn(async () => ({
status: "rejected",
item_id: "item-0",
detail: "dismissed; feeds the next cycle's prompt",
})),
}));
vi.mock("@/lib/api", () => ({
sentinelApi: { listReports },
sentinelApi: { listReports, approveItem, rejectItem },
}));
import { QualityReportsTab } from "../quality-reports-tab";
@@ -100,17 +115,81 @@ describe("QualityReportsTab", () => {
).toBeInTheDocument();
});
it("renders no approve/reject controls — a report has no queue action", async () => {
// Defect 2 fix: a proposed drift item now gets its own approve/dismiss
// action even though the report itself stays a read-only report — was
// "renders no approve/reject controls".
it("renders per-item approve/dismiss controls for a proposed item", async () => {
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([buildReport()]);
await screen.findByText("Waived findings climbed 3x this week");
expect(
screen.getByRole("button", { name: /approve/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /dismiss/i }),
).toBeInTheDocument();
});
it("hides the actions once an item is approved", async () => {
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([
buildReport({
items: [
{
id: "item-0",
area: "waivers",
observation: "Minor findings keep getting waived",
evidence: "5 waived this week",
suggested_action: "Convert to a Pest Control bug task",
status: "approved",
materialized_task_id: "task-1",
},
],
}),
]);
await screen.findByText("Waived findings climbed 3x this week");
expect(
screen.queryByRole("button", { name: /approve/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /reject/i }),
).not.toBeInTheDocument();
expect(screen.getByText("Approved")).toBeInTheDocument();
});
it("approves an item via the API", async () => {
const user = userEvent.setup();
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([buildReport()]);
await screen.findByText("Waived findings climbed 3x this week");
await user.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() =>
expect(approveItem).toHaveBeenCalledWith("report-1", "item-0"),
);
});
it("dismisses an item with a reason via the dialog", async () => {
const user = userEvent.setup();
render(withQueryClient(<QualityReportsTab />));
resolveListRef.current?.([buildReport()]);
await screen.findByText("Waived findings climbed 3x this week");
await user.click(screen.getByRole("button", { name: /dismiss/i }));
const dialog = await screen.findByRole("dialog");
await user.type(screen.getByLabelText("Reason"), "already tracked");
await user.click(
within(dialog).getByRole("button", { name: /^dismiss$/i }),
);
await waitFor(() =>
expect(rejectItem).toHaveBeenCalledWith(
"report-1",
"item-0",
"already tracked",
),
);
});
it("omits the overall-assessment section when absent", async () => {
@@ -1,6 +1,7 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { coronerApi } from "@/lib/api";
import type { Postmortem } from "@/lib/api/coroner";
import {
@@ -11,10 +12,24 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { HelpTip } from "@/components/ui/help-tip";
import { Stethoscope } from "lucide-react";
import { CheckCircle2, Stethoscope, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
const INCIDENT_KIND_LABELS: Record<string, string> = {
bounced: "bounced 3+ times",
@@ -22,7 +37,46 @@ const INCIDENT_KIND_LABELS: Record<string, string> = {
budget: "budget-blocked",
};
function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
function processChangeStatusBadge(postmortem: Postmortem) {
if (postmortem.process_change_status === "approved") {
return (
<HelpTip label="Materialized as a Main-PM-owned task">
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
</HelpTip>
);
}
if (postmortem.process_change_status === "rejected") {
return (
<HelpTip label="Dismissed — not added to the backlog">
<Badge variant="outline">Dismissed</Badge>
</HelpTip>
);
}
if (postmortem.process_change_status === "not_applicable") {
return (
<HelpTip label="Already drafted straight into the playbook review queue — nothing else to decide">
<Badge variant="outline">Drafted as playbook</Badge>
</HelpTip>
);
}
return null;
}
function PostmortemRow({
postmortem,
onApprove,
onReject,
approving,
}: {
postmortem: Postmortem;
onApprove: (taskId: string) => void;
onReject: (postmortem: Postmortem) => void;
approving: boolean;
}) {
const isProposed = postmortem.process_change_status === "proposed";
return (
<div className="rounded-lg border p-4 space-y-2">
<div className="flex flex-wrap items-center gap-2">
@@ -47,6 +101,7 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
<Badge>{postmortem.process_change_kind}</Badge>
</HelpTip>
)}
{processChangeStatusBadge(postmortem)}
</div>
{postmortem.incident_summary && (
<p className="text-sm text-muted-foreground">
@@ -65,20 +120,58 @@ function PostmortemRow({ postmortem }: { postmortem: Postmortem }) {
{postmortem.process_change_description}
</p>
)}
{postmortem.process_change_status === "rejected" &&
postmortem.process_change_reject_reason && (
<p className="text-sm text-destructive">
Dismissed: {postmortem.process_change_reject_reason}
</p>
)}
{postmortem.completed_at && (
<p className="text-xs text-muted-foreground">
{new Date(postmortem.completed_at).toLocaleString()}
</p>
)}
{isProposed && (
<div className="flex justify-end gap-2 pt-1">
<HelpTip label="Records your reason — not added to the backlog">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject(postmortem)}
>
<XCircle className="mr-1 h-3.5 w-3.5" />
Dismiss
</Button>
</HelpTip>
<HelpTip label="Materializes this process change as a Main-PM-owned task — needs normal PM activation to start">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(postmortem.task_id)}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Approve
</Button>
</HelpTip>
</div>
)}
</div>
);
}
// Read-only Postmortems list — a Coroner postmortem completes atomically at
// propose_postmortem time (no per-item approve/reject like Pest Control/
// Roadmap), so unlike those review queues this never hides on empty; it's a
// retrospective report the CEO checks, not an urgent action queue.
// Postmortems list — a Coroner postmortem completes atomically at
// propose_postmortem time (no per-item approve/reject at the TASK level
// like Pest Control/Roadmap), so this never hides on empty; it's a
// retrospective report the CEO checks. Its single process change still
// carries its own per-item approve/dismiss action, below each row.
export function CoronerPostmortemsCard({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<Postmortem | null>(null);
const [reason, setReason] = useState("");
const [approvingTaskId, setApprovingTaskId] = useState<string | null>(null);
const {
data: postmortems,
isLoading,
@@ -90,45 +183,143 @@ export function CoronerPostmortemsCard({ className }: { className?: string }) {
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["coroner", "postmortems"] });
const approveMutation = useMutation({
mutationFn: (taskId: string) => coronerApi.approveProcessChange(taskId),
onSuccess: (result) => {
invalidate();
if (
result.status === "approved" ||
result.status === "already_approved"
) {
toast.success("Process change approved — materialized as a task");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingTaskId(null),
});
const rejectMutation = useMutation({
mutationFn: ({ taskId, reason }: { taskId: string; reason: string }) =>
coronerApi.rejectProcessChange(taskId, reason),
onSuccess: () => {
invalidate();
toast.success("Process change dismissed");
closeReject();
},
onError: (e) =>
toast.error(
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for dismissing");
return;
}
rejectMutation.mutate({ taskId: rejecting.task_id, reason: reason.trim() });
};
const handleApprove = (taskId: string) => {
setApprovingTaskId(taskId);
approveMutation.mutate(taskId);
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Stethoscope className="h-5 w-5" />
Postmortems
</CardTitle>
<CardDescription>
The Auditor&apos;s Coroner autopsies bounced, cancelled, or
budget-blocked incidents, each with a root cause and one proposed
process change.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<>
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Stethoscope className="h-5 w-5" />
Postmortems
</CardTitle>
<CardDescription>
The Auditor&apos;s Coroner autopsies bounced, cancelled, or
budget-blocked incidents, each with a root cause and one proposed
process change.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
) : isError ? (
<OfflineState
title="Failed to load Postmortems"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : !postmortems || postmortems.length === 0 ? (
<p className="text-sm text-muted-foreground">
No postmortems yet Coroner autopsies open only when a task
bounces 3+ times, is cancelled after work started, or is
budget-blocked.
</p>
) : (
<div className="space-y-3">
{postmortems.map((pm) => (
<PostmortemRow
key={pm.task_id}
postmortem={pm}
onApprove={handleApprove}
onReject={setRejecting}
approving={approvingTaskId === pm.task_id}
/>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dismiss process change</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="coroner-reject-reason">Reason</Label>
<Textarea
id="coroner-reject-reason"
placeholder="e.g. one-off incident; not worth a standing process change..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
) : isError ? (
<OfflineState
title="Failed to load Postmortems"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : !postmortems || postmortems.length === 0 ? (
<p className="text-sm text-muted-foreground">
No postmortems yet Coroner autopsies open only when a task
bounces 3+ times, is cancelled after work started, or is
budget-blocked.
</p>
) : (
<div className="space-y-3">
{postmortems.map((pm) => (
<PostmortemRow key={pm.task_id} postmortem={pm} />
))}
</div>
)}
</CardContent>
</Card>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,13 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { periscopeApi } from "@/lib/api";
import type { MarketBrief } from "@/lib/api/periscope";
import type { MarketBrief, MarketBriefFinding } from "@/lib/api/periscope";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { CheckCircle2, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
interface RejectTarget {
taskId: string;
finding: MarketBriefFinding;
}
// ---------------------------------------------------------------------------
// Read-only skeleton placeholder shaped like a brief card
@@ -28,12 +49,115 @@ function BriefCardSkeleton() {
);
}
function findingStatusBadge(finding: MarketBriefFinding) {
if (finding.status === "approved") {
return (
<HelpTip label="Materialized as a Main-PM-owned task">
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
</HelpTip>
);
}
if (finding.status === "rejected") {
return (
<HelpTip label="Dismissed — not added to the backlog">
<Badge variant="outline">Dismissed</Badge>
</HelpTip>
);
}
return null;
}
// ---------------------------------------------------------------------------
// One brief, read-only — headline/findings/sources/threats/opportunities.
// No approve/reject UI: a market brief is a report, not a queue item.
// One finding — claim/relevance/source, plus a per-finding approve/dismiss
// action (a market signal is worth acting on individually, even though the
// brief itself is a report).
// ---------------------------------------------------------------------------
function BriefCard({ brief }: { brief: MarketBrief }) {
function FindingRow({
taskId,
finding,
onApprove,
onReject,
approving,
}: {
taskId: string;
finding: MarketBriefFinding;
onApprove: (taskId: string, findingId: string) => void;
onReject: (target: RejectTarget) => void;
approving: boolean;
}) {
const isProposed = finding.status === "proposed";
return (
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<p className="whitespace-pre-wrap text-sm">{finding.claim}</p>
{findingStatusBadge(finding)}
</div>
<p className="text-xs text-muted-foreground">
{finding.relevance} {" "}
<a
href={finding.source_url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
source
</a>
</p>
{finding.status === "rejected" && finding.reject_reason && (
<p className="text-xs text-destructive">
Dismissed: {finding.reject_reason}
</p>
)}
{isProposed && (
<div className="flex justify-end gap-2 pt-1">
<HelpTip label="Records your reason — not added to the backlog">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject({ taskId, finding })}
>
<XCircle className="mr-1 h-3.5 w-3.5" />
Dismiss
</Button>
</HelpTip>
<HelpTip label="Materializes this finding as a Main-PM-owned task — needs normal PM activation to start">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(taskId, finding.id)}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Approve
</Button>
</HelpTip>
</div>
)}
</li>
);
}
// ---------------------------------------------------------------------------
// One brief — headline/findings/sources/threats/opportunities, each finding
// with its own approve/dismiss action.
// ---------------------------------------------------------------------------
function BriefCard({
brief,
onApprove,
onReject,
approvingFindingId,
}: {
brief: MarketBrief;
onApprove: (taskId: string, findingId: string) => void;
onReject: (target: RejectTarget) => void;
approvingFindingId: string | null;
}) {
return (
<Card>
<CardHeader>
@@ -52,20 +176,14 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
</p>
<ul className="space-y-2">
{brief.findings.map((f) => (
<li key={f.id} className="text-sm">
<p className="whitespace-pre-wrap">{f.claim}</p>
<p className="text-xs text-muted-foreground">
{f.relevance} {" "}
<a
href={f.source_url}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
source
</a>
</p>
</li>
<FindingRow
key={f.id}
taskId={brief.task_id}
finding={f}
onApprove={onApprove}
onReject={onReject}
approving={approvingFindingId === f.id}
/>
))}
</ul>
</div>
@@ -125,6 +243,13 @@ function BriefCard({ brief }: { brief: MarketBrief }) {
// ---------------------------------------------------------------------------
export function MarketBriefsTab() {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
const [reason, setReason] = useState("");
const [approvingFindingId, setApprovingFindingId] = useState<string | null>(
null,
);
const {
data: briefs = [],
isLoading,
@@ -136,37 +261,152 @@ export function MarketBriefsTab() {
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["periscope", "briefs"] });
const approveMutation = useMutation({
mutationFn: ({
taskId,
findingId,
}: {
taskId: string;
findingId: string;
}) => periscopeApi.approveFinding(taskId, findingId),
onSuccess: (result) => {
invalidate();
if (
result.status === "approved" ||
result.status === "already_approved"
) {
toast.success("Finding approved — materialized as a task");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingFindingId(null),
});
const rejectMutation = useMutation({
mutationFn: ({
taskId,
findingId,
reason,
}: {
taskId: string;
findingId: string;
reason: string;
}) => periscopeApi.rejectFinding(taskId, findingId, reason),
onSuccess: () => {
invalidate();
toast.success("Finding dismissed");
closeReject();
},
onError: (e) =>
toast.error(
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for dismissing");
return;
}
rejectMutation.mutate({
taskId: rejecting.taskId,
findingId: rejecting.finding.id,
reason: reason.trim(),
});
};
const handleApprove = (taskId: string, findingId: string) => {
setApprovingFindingId(findingId);
approveMutation.mutate({ taskId, findingId });
};
return (
<Card>
<CardHeader>
<CardTitle>Market Briefs</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<BriefCardSkeleton />
<BriefCardSkeleton />
<>
<Card>
<CardHeader>
<CardTitle>Market Briefs</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<BriefCardSkeleton />
<BriefCardSkeleton />
</div>
) : isError ? (
<OfflineState
title="Failed to load market briefs"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : briefs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No market briefs filed yet. The Head of Marketing files one
weekly, once Periscope is enabled each cited finding can be
approved or dismissed individually once one lands.
</p>
) : (
<div className="space-y-4">
{briefs.map((b) => (
<BriefCard
key={b.task_id}
brief={b}
onApprove={handleApprove}
onReject={setRejecting}
approvingFindingId={approvingFindingId}
/>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dismiss market-brief finding</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="periscope-reject-reason">Reason</Label>
<Textarea
id="periscope-reject-reason"
placeholder="e.g. not actionable right now; already covered by an open task..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
) : isError ? (
<OfflineState
title="Failed to load market briefs"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : briefs.length === 0 ? (
<p className="text-sm text-muted-foreground">
No market briefs filed yet. The Head of Marketing files one
weekly, once Periscope is enabled they appear here as reports,
not a queue to act on.
</p>
) : (
<div className="space-y-4">
{briefs.map((b) => (
<BriefCard key={b.task_id} brief={b} />
))}
</div>
)}
</CardContent>
</Card>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -1,12 +1,34 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { sentinelApi } from "@/lib/api";
import type { QualityReport } from "@/lib/api/sentinel";
import type { QualityReport, QualityReportItem } from "@/lib/api/sentinel";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { HelpTip } from "@/components/ui/help-tip";
import { Skeleton } from "@/components/ui/skeleton";
import { OfflineState } from "@/components/ui/offline-state";
import { CheckCircle2, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON_CHARS = 4;
interface RejectTarget {
taskId: string;
item: QualityReportItem;
}
// ---------------------------------------------------------------------------
// Read-only skeleton placeholder shaped like a report card
@@ -27,12 +49,112 @@ function ReportCardSkeleton() {
);
}
function itemStatusBadge(item: QualityReportItem) {
if (item.status === "approved") {
return (
<HelpTip label="Materialized as a Main-PM-owned task">
<Badge variant="secondary" className="bg-green-600/10 text-green-700">
Approved
</Badge>
</HelpTip>
);
}
if (item.status === "rejected") {
return (
<HelpTip label="Dismissed — not added to the backlog">
<Badge variant="outline">Dismissed</Badge>
</HelpTip>
);
}
return null;
}
// ---------------------------------------------------------------------------
// One report, read-only — headline/items/overall assessment. No approve/
// reject UI: a quality report is a report, not a queue item.
// One drift item — area/observation/evidence/suggested action, plus a
// per-item approve/dismiss action.
// ---------------------------------------------------------------------------
function ReportCard({ report }: { report: QualityReport }) {
function DriftItemRow({
taskId,
item,
onApprove,
onReject,
approving,
}: {
taskId: string;
item: QualityReportItem;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approving: boolean;
}) {
const isProposed = item.status === "proposed";
return (
<li className="space-y-1 border-b pb-3 last:border-b-0 last:pb-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="uppercase text-xs">
{item.area}
</Badge>
{itemStatusBadge(item)}
</div>
<p className="text-sm whitespace-pre-wrap">{item.observation}</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Evidence: {item.evidence}
</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Suggested action: {item.suggested_action}
</p>
{item.status === "rejected" && item.reject_reason && (
<p className="text-xs text-destructive">
Dismissed: {item.reject_reason}
</p>
)}
{isProposed && (
<div className="flex justify-end gap-2 pt-1">
<HelpTip label="Records your reason — not added to the backlog">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => onReject({ taskId, item })}
>
<XCircle className="mr-1 h-3.5 w-3.5" />
Dismiss
</Button>
</HelpTip>
<HelpTip label="Materializes this item as a Main-PM-owned task — needs normal PM activation to start">
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approving}
onClick={() => onApprove(taskId, item.id)}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Approve
</Button>
</HelpTip>
</div>
)}
</li>
);
}
// ---------------------------------------------------------------------------
// One report — headline/items/overall assessment, each item with its own
// approve/dismiss action.
// ---------------------------------------------------------------------------
function ReportCard({
report,
onApprove,
onReject,
approvingItemId,
}: {
report: QualityReport;
onApprove: (taskId: string, itemId: string) => void;
onReject: (target: RejectTarget) => void;
approvingItemId: string | null;
}) {
return (
<Card>
<CardHeader>
@@ -51,18 +173,14 @@ function ReportCard({ report }: { report: QualityReport }) {
</p>
<ul className="space-y-3">
{report.items.map((item) => (
<li key={item.id} className="text-sm space-y-1">
<Badge variant="secondary" className="uppercase text-xs">
{item.area}
</Badge>
<p className="whitespace-pre-wrap">{item.observation}</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Evidence: {item.evidence}
</p>
<p className="text-xs text-muted-foreground whitespace-pre-wrap">
Suggested action: {item.suggested_action}
</p>
</li>
<DriftItemRow
key={item.id}
taskId={report.task_id}
item={item}
onApprove={onApprove}
onReject={onReject}
approving={approvingItemId === item.id}
/>
))}
</ul>
</div>
@@ -87,6 +205,11 @@ function ReportCard({ report }: { report: QualityReport }) {
// ---------------------------------------------------------------------------
export function QualityReportsTab() {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<RejectTarget | null>(null);
const [reason, setReason] = useState("");
const [approvingItemId, setApprovingItemId] = useState<string | null>(null);
const {
data: reports = [],
isLoading,
@@ -98,37 +221,147 @@ export function QualityReportsTab() {
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["sentinel", "reports"] });
const approveMutation = useMutation({
mutationFn: ({ taskId, itemId }: { taskId: string; itemId: string }) =>
sentinelApi.approveItem(taskId, itemId),
onSuccess: (result) => {
invalidate();
if (
result.status === "approved" ||
result.status === "already_approved"
) {
toast.success("Item approved — materialized as a task");
} else {
toast.warning(result.detail);
}
},
onError: (e) =>
toast.error(
`Approve failed: ${e instanceof Error ? e.message : "error"}`,
),
onSettled: () => setApprovingItemId(null),
});
const rejectMutation = useMutation({
mutationFn: ({
taskId,
itemId,
reason,
}: {
taskId: string;
itemId: string;
reason: string;
}) => sentinelApi.rejectItem(taskId, itemId, reason),
onSuccess: () => {
invalidate();
toast.success("Item dismissed");
closeReject();
},
onError: (e) =>
toast.error(
`Dismiss failed: ${e instanceof Error ? e.message : "error"}`,
),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON_CHARS) {
toast.error("Give a brief reason for dismissing");
return;
}
rejectMutation.mutate({
taskId: rejecting.taskId,
itemId: rejecting.item.id,
reason: reason.trim(),
});
};
const handleApprove = (taskId: string, itemId: string) => {
setApprovingItemId(itemId);
approveMutation.mutate({ taskId, itemId });
};
return (
<Card>
<CardHeader>
<CardTitle>Quality Reports</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<ReportCardSkeleton />
<ReportCardSkeleton />
<>
<Card>
<CardHeader>
<CardTitle>Quality Reports</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
<ReportCardSkeleton />
<ReportCardSkeleton />
</div>
) : isError ? (
<OfflineState
title="Failed to load quality reports"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : reports.length === 0 ? (
<p className="text-sm text-muted-foreground">
No quality reports filed yet. The Auditor files one weekly, once
Sentinel is enabled each drift item can be approved or dismissed
individually once one lands.
</p>
) : (
<div className="space-y-4">
{reports.map((r) => (
<ReportCard
key={r.task_id}
report={r}
onApprove={handleApprove}
onReject={setRejecting}
approvingItemId={approvingItemId}
/>
))}
</div>
)}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dismiss quality-report item</DialogTitle>
<DialogDescription>
This records your reason and feeds the next cycle&apos;s prompt
it is not added to the backlog.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="sentinel-reject-reason">Reason</Label>
<Textarea
id="sentinel-reject-reason"
placeholder="e.g. already tracked elsewhere; not worth a task this cycle..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
) : isError ? (
<OfflineState
title="Failed to load quality reports"
description="Could not reach the orchestrator API. Check the backend is running."
onRetry={() => void refetch()}
/>
) : reports.length === 0 ? (
<p className="text-sm text-muted-foreground">
No quality reports filed yet. The Auditor files one weekly, once
Sentinel is enabled they appear here as reports, not a queue to
act on.
</p>
) : (
<div className="space-y-4">
{reports.map((r) => (
<ReportCard key={r.task_id} report={r} />
))}
</div>
)}
</CardContent>
</Card>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+36 -2
View File
@@ -4,8 +4,13 @@ import api from "./client";
// Coroner (Board Program) engine — the Auditor's event-triggered postmortem:
// a task bounced >=3x, was cancelled after work started, or was budget-
// blocked. One propose_postmortem call completes the autopsy atomically —
// there is no per-item approve/reject like Pest Control/Roadmap, so this is
// a plain read-only list. Mirrors lib/api/pest-control.ts.
// the EXPLORATION TASK has no per-item decision to wait on — but the
// postmortem's single process change still carries its own proposed/
// approved/rejected status the CEO decides on afterward (unless it already
// drafted a playbook: process_change_status "not_applicable", nothing left
// to decide). Unlike Periscope/Sentinel there is no item id — a postmortem
// is one process change, not a list — so the action routes key on the task
// id alone.
// ---------------------------------------------------------------------------
export interface Postmortem {
@@ -21,6 +26,16 @@ export interface Postmortem {
process_change_kind: string | null;
process_change_description: string | null;
playbook_id: string | null;
process_change_status:
"proposed" | "approved" | "rejected" | "not_applicable";
process_change_reject_reason: string | null;
process_change_materialized_task_id: string | null;
}
export interface ProcessChangeActionResult {
status: string;
materialized_task_id?: string | null;
detail: string;
}
export const coronerApi = {
@@ -28,4 +43,23 @@ export const coronerApi = {
const { data } = await api.get<Postmortem[]>("/coroner/postmortems");
return data;
},
approveProcessChange: async (
taskId: string,
): Promise<ProcessChangeActionResult> => {
const { data } = await api.post<ProcessChangeActionResult>(
`/coroner/postmortems/${taskId}/process-change/approve`,
{},
);
return data;
},
rejectProcessChange: async (
taskId: string,
reason: string,
): Promise<ProcessChangeActionResult> => {
const { data } = await api.post<ProcessChangeActionResult>(
`/coroner/postmortems/${taskId}/process-change/reject`,
{ reason },
);
return data;
},
};
+36 -3
View File
@@ -3,9 +3,11 @@ import api from "./client";
// ---------------------------------------------------------------------------
// Periscope (Board Program) engine — the Head of Marketing files a weekly
// market-research brief (competitors, adjacent-tool releases, positioning
// shifts). Unlike Roadmap/Pest Control this is a REPORT, not a queue item:
// read-only here, no approve/reject. Mirrors lib/api/pest-control.ts's shape
// minus the mutating routes.
// shifts). The brief itself is read-only — a report, not a queue item, the
// exploration task completes atomically at propose time — but each cited
// finding still carries its own proposed/approved/rejected status the CEO
// decides on afterward. Mirrors lib/api/roadmap.ts's per-item approve/reject
// shape.
// ---------------------------------------------------------------------------
export interface MarketBriefFinding {
@@ -13,6 +15,9 @@ export interface MarketBriefFinding {
claim: string;
source_url: string;
relevance: string;
status: "proposed" | "approved" | "rejected";
reject_reason?: string | null;
materialized_task_id?: string | null;
}
export interface MarketBrief {
@@ -26,9 +31,37 @@ export interface MarketBrief {
positioning_note: string;
}
export interface MarketBriefFindingActionResult {
status: string;
finding_id: string;
materialized_task_id?: string | null;
detail: string;
}
export const periscopeApi = {
listBriefs: async (): Promise<MarketBrief[]> => {
const { data } = await api.get<MarketBrief[]>("/periscope/briefs");
return data;
},
approveFinding: async (
taskId: string,
findingId: string,
): Promise<MarketBriefFindingActionResult> => {
const { data } = await api.post<MarketBriefFindingActionResult>(
`/periscope/briefs/${taskId}/findings/${findingId}/approve`,
{},
);
return data;
},
rejectFinding: async (
taskId: string,
findingId: string,
reason: string,
): Promise<MarketBriefFindingActionResult> => {
const { data } = await api.post<MarketBriefFindingActionResult>(
`/periscope/briefs/${taskId}/findings/${findingId}/reject`,
{ reason },
);
return data;
},
};
+36 -3
View File
@@ -3,9 +3,11 @@ import api from "./client";
// ---------------------------------------------------------------------------
// Sentinel (Board Program) engine — the Auditor files a weekly "state of
// quality" report (waiver-accumulation trends, conventions-violation
// hotspots, budget anomalies). Like Periscope this is a REPORT, not a queue
// item: read-only here, no approve/reject. Mirrors lib/api/periscope.ts's
// shape exactly.
// hotspots, budget anomalies). The report itself is read-only — a report,
// not a queue item, the exploration task completes atomically at propose
// time — but each drift item still carries its own proposed/approved/
// rejected status the CEO decides on afterward. Mirrors lib/api/
// periscope.ts's per-item approve/reject shape.
// ---------------------------------------------------------------------------
export interface QualityReportItem {
@@ -14,6 +16,9 @@ export interface QualityReportItem {
observation: string;
evidence: string;
suggested_action: string;
status: "proposed" | "approved" | "rejected";
reject_reason?: string | null;
materialized_task_id?: string | null;
}
export interface QualityReport {
@@ -25,9 +30,37 @@ export interface QualityReport {
overall_assessment: string;
}
export interface QualityReportItemActionResult {
status: string;
item_id: string;
materialized_task_id?: string | null;
detail: string;
}
export const sentinelApi = {
listReports: async (): Promise<QualityReport[]> => {
const { data } = await api.get<QualityReport[]>("/sentinel/reports");
return data;
},
approveItem: async (
taskId: string,
itemId: string,
): Promise<QualityReportItemActionResult> => {
const { data } = await api.post<QualityReportItemActionResult>(
`/sentinel/reports/${taskId}/items/${itemId}/approve`,
{},
);
return data;
},
rejectItem: async (
taskId: string,
itemId: string,
reason: string,
): Promise<QualityReportItemActionResult> => {
const { data } = await api.post<QualityReportItemActionResult>(
`/sentinel/reports/${taskId}/items/${itemId}/reject`,
{ reason },
);
return data;
},
};