Feat/v0.13.0 (#270)

* feat(release): add release-manager feature flag (default off)

* feat(release): change classification + semver-bump derivation

* feat(release): readiness audit (changelog/version-ref/docs/migration/gate)

* feat(release): release-manager engine proposes a gated release

* feat(release): fail-closed release executor (bump, gate, publish)

* feat(release): CEO approve/reject release-proposal surface

* docs(release): document the gated release manager

* feat(memory): add org-memory feature flags (default off)

* feat(memory): add playbooks table + status enum + migration

* feat(memory): playbook service with auditor curation transitions

* feat(memory): playbooks RAG index plugin

* feat(memory): index a playbook into RAG on approval

* feat(memory): distill a high-signal lesson at task completion

* feat(memory): keep private journal reflections out of the shared RAG corpus

* feat(memory): draft_playbook verb + auditor curation verbs

* fix(ci): resolve mypy tests/ errors blocking the gate (UUID casts, annotations)

* feat(memory): auto-inject similar lessons/playbooks into the briefing

* feat(memory): auditor playbook review queue (api + panel)

* docs(memory): document the org-memory loop + playbook verbs

* fix(provisioning): idempotent pitch provisioning (reuse product/project by slug on re-approval)

* fix(memory): add chunks_playbooks to the chunk schema + isolate release route tests

- Migration 030's CHUNK_TABLES was missing chunks_playbooks, breaking the
  IndexType<->migration parity guard once the PLAYBOOKS index landed. The
  upgrade is ALTER ... IF EXISTS so adding it is safe on any DB shape.
- The release-route fixture's approve/reject paths call db.commit() (real
  behavior), so a held proposal outlived the per-test rollback and leaked
  into engine tests that read the global list_open_release_proposals().
  Tear down source=release_manager rows after each test.
- Make the gather_snapshot real-repo smoke version-agnostic (semver match)
  so it stops pinning the literal repo version.

* chore(release): 0.13.0

* ++

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-26 01:43:08 +02:00
committed by GitHub
co-authored by Renn F
parent 153723406e
commit 5612375cba
87 changed files with 5032 additions and 91 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "roboco-panel",
"version": "0.12.0",
"version": "0.13.0",
"private": true,
"packageManager": "pnpm@10.25.0",
"scripts": {
@@ -10,6 +10,8 @@ import { RecentActivityFeed } from "./recent-activity-feed";
import { QuickActionsBar } from "./quick-actions-bar";
import { CeoApprovalQueue } from "./ceo-approval-queue";
import { PrReviewQueue } from "./pr-review-queue";
import { ReleaseProposalCard } from "./release-proposal-card";
import { PlaybookReviewQueue } from "./playbook-review-queue";
import { StrategySignalsPanel } from "./strategy-signals-panel";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
@@ -89,6 +91,12 @@ export function CommandCenter() {
{/* External-PR review decision queue (hidden when empty) */}
<PrReviewQueue />
{/* Gated release proposal (hidden when none open) */}
<ReleaseProposalCard />
{/* Playbook review queue (hidden when no drafts) */}
<PlaybookReviewQueue />
{/* Metrics, Alerts, and Usage Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3 gap-6">
<KeyMetricsPanel
+2
View File
@@ -10,5 +10,7 @@ export { ActivityItem } from "./activity-item";
export { QuickActionsBar } from "./quick-actions-bar";
export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue";
export { ReleaseProposalCard } from "./release-proposal-card";
export { PlaybookReviewQueue } from "./playbook-review-queue";
export { StrategySignalsPanel } from "./strategy-signals-panel";
export { UsageOverviewPanel } from "./usage-overview-panel";
@@ -0,0 +1,179 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { playbooksApi } from "@/lib/api";
import type { Playbook } from "@/lib/api/playbooks";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { BookOpen, CheckCircle2, XCircle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REASON = 4;
// Auditor/CEO review queue for drafted playbooks. Hidden when none are pending
// (mirrors the PR-review + release-proposal cards).
export function PlaybookReviewQueue({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [rejecting, setRejecting] = useState<Playbook | null>(null);
const [reason, setReason] = useState("");
const { data: drafts, isLoading } = useQuery({
queryKey: ["playbooks", "drafts"],
queryFn: () => playbooksApi.listDrafts(),
refetchInterval: 30000,
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["playbooks", "drafts"] });
const approveMutation = useMutation({
mutationFn: (id: string) => playbooksApi.approve(id),
onSuccess: () => {
invalidate();
toast.success("Playbook approved and indexed");
},
onError: (e) =>
toast.error(`Approve failed: ${e instanceof Error ? e.message : "error"}`),
});
const rejectMutation = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) =>
playbooksApi.reject(id, reason),
onSuccess: () => {
invalidate();
toast.success("Playbook rejected (archived)");
closeReject();
},
onError: (e) =>
toast.error(`Reject failed: ${e instanceof Error ? e.message : "error"}`),
});
const closeReject = () => {
setRejecting(null);
setReason("");
};
const confirmReject = () => {
if (!rejecting) return;
if (reason.trim().length < _MIN_REASON) {
toast.error("Give a brief reason for rejecting");
return;
}
rejectMutation.mutate({ id: rejecting.id, reason: reason.trim() });
};
if (isLoading || !drafts || drafts.length === 0) return null;
return (
<>
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BookOpen className="h-5 w-5" />
Playbook Review
<Badge variant="secondary">{drafts.length}</Badge>
</CardTitle>
<CardDescription>
Drafted playbooks awaiting your approval approved ones are indexed
and auto-suggested to agents.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{drafts.map((pb) => (
<div
key={pb.id}
className="rounded-lg border p-4 transition-colors hover:bg-muted/50"
>
<div className="mb-1 flex items-center gap-2">
<span className="font-medium">{pb.title}</span>
{pb.team && <Badge variant="outline">{pb.team}</Badge>}
{pb.tags.map((t) => (
<Badge key={t} variant="secondary" className="text-xs">
{t}
</Badge>
))}
</div>
<p className="text-sm text-muted-foreground">
<span className="font-semibold">When:</span> {pb.problem}
</p>
<pre className="mt-2 max-h-40 overflow-auto rounded bg-muted p-2 text-xs whitespace-pre-wrap">
{pb.procedure}
</pre>
<div className="mt-3 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setRejecting(pb)}
>
<XCircle className="mr-1 h-4 w-4" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
disabled={approveMutation.isPending}
onClick={() => approveMutation.mutate(pb.id)}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve
</Button>
</div>
</div>
))}
</CardContent>
</Card>
<Dialog open={!!rejecting} onOpenChange={() => closeReject()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject playbook</DialogTitle>
<DialogDescription>
This archives the playbook. Give a brief reason (it is recorded).
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="reject-reason">Reason</Label>
<Textarea
id="reject-reason"
placeholder="e.g. duplicate of an existing playbook; too task-specific..."
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeReject}>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmReject}
disabled={rejectMutation.isPending}
>
{rejectMutation.isPending ? "Rejecting..." : "Reject & Archive"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,259 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { releaseApi } from "@/lib/api";
import type { ReleaseExecuteResult } from "@/lib/api/release";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CheckCircle2, XCircle, Rocket, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
const _MIN_REJECT_CHARS = 10;
function gateBadgeVariant(
gate: string,
): "default" | "secondary" | "destructive" | "outline" {
if (gate === "green") return "default";
if (gate === "red") return "destructive";
return "secondary";
}
// A red gate / open gaps make publishing risky — the CEO should resolve them
// first. Approval still runs the fail-closed executor, so it can't ship a bad
// release; this only steers the CEO.
export function ReleaseProposalCard({ className }: { className?: string }) {
const queryClient = useQueryClient();
const [action, setAction] = useState<"approve" | "reject" | null>(null);
const [requiredChanges, setRequiredChanges] = useState("");
const { data: proposal, isLoading } = useQuery({
queryKey: ["release", "proposal"],
queryFn: () => releaseApi.getProposal(),
refetchInterval: 30000,
});
const approveMutation = useMutation({
mutationFn: () => releaseApi.approve(),
onSuccess: (result: ReleaseExecuteResult) => {
queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
if (result.status === "published") {
toast.success(
`Published v${result.version}` +
(result.release_url ? "" : " (no release URL returned)"),
);
} else {
toast.warning(`Release halted (${result.status}): ${result.detail}`);
}
closeDialog();
},
onError: (error) => {
toast.error(
`Approve failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
});
const rejectMutation = useMutation({
mutationFn: (changes: string) => releaseApi.reject(changes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["release", "proposal"] });
toast.success("Proposal sent back with required changes");
closeDialog();
},
onError: (error) => {
toast.error(
`Reject failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
},
});
const closeDialog = () => {
setAction(null);
setRequiredChanges("");
};
const handleConfirm = () => {
if (action === "approve") {
approveMutation.mutate();
} else if (action === "reject") {
if (requiredChanges.trim().length < _MIN_REJECT_CHARS) {
toast.error("Describe the required changes (≥ 10 characters)");
return;
}
rejectMutation.mutate(requiredChanges.trim());
}
};
// Hidden entirely when there is no open proposal (mirrors PrReviewQueue).
if (isLoading || !proposal) return null;
const { report } = proposal;
const pending = approveMutation.isPending || rejectMutation.isPending;
return (
<>
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Rocket className="h-5 w-5" />
Release Proposal
<Badge variant="outline">v{report.proposed_version}</Badge>
<Badge variant="secondary">{report.bump_kind}</Badge>
<Badge variant={gateBadgeVariant(report.gate_state)}>
gate: {report.gate_state}
</Badge>
</CardTitle>
<CardDescription>
{report.change_summary.length} change(s) since the last tag · review
and approve to cut the release (nothing publishes until you do).
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{report.gaps.length > 0 && (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3">
<p className="flex items-center gap-1.5 text-sm font-medium text-amber-600">
<AlertTriangle className="h-4 w-4" />
{report.gaps.length} gap(s) to resolve before publishing
</p>
<ul className="mt-2 space-y-1 text-sm text-muted-foreground">
{report.gaps.map((gap, i) => (
<li key={`${gap.category}-${i}`}>
<span className="font-mono text-xs uppercase">
[{gap.category}]
</span>{" "}
{gap.detail}
</li>
))}
</ul>
</div>
)}
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Drafted CHANGELOG
</p>
<pre className="max-h-60 overflow-auto rounded-md bg-muted p-3 text-xs whitespace-pre-wrap">
{report.drafted_changelog}
</pre>
</div>
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Version bump plan ({report.version_bump_plan.length} files)
</p>
<p className="text-sm text-muted-foreground">
{report.version_bump_plan.join(", ")}
</p>
</div>
{report.migration_notes.length > 0 && (
<div>
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Migrations
</p>
<ul className="space-y-1 text-sm text-muted-foreground">
{report.migration_notes.map((note, i) => (
<li key={i}>{note}</li>
))}
</ul>
</div>
)}
{proposal.required_changes && (
<p className="text-sm text-amber-600">
Awaiting revision you requested: {proposal.required_changes}
</p>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setAction("reject")}
>
<XCircle className="mr-1 h-4 w-4" />
Reject with changes
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => setAction("approve")}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Approve &amp; publish
</Button>
</div>
</CardContent>
</Card>
<Dialog open={!!action} onOpenChange={() => closeDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{action === "approve"
? `Approve release v${report.proposed_version}?`
: "Reject with required changes"}
</DialogTitle>
<DialogDescription>
{action === "approve"
? "This runs the fail-closed executor: write the bumps + CHANGELOG, run make quality, commit, wait for green CI, then publish. It aborts on a red gate or red CI."
: "Record what must change. The proposal stays open for revision; nothing is published."}
</DialogDescription>
</DialogHeader>
{action === "reject" && (
<div className="space-y-2">
<Label htmlFor="required-changes">Required changes</Label>
<Textarea
id="required-changes"
placeholder="e.g. tighten the CHANGELOG wording for the API change; hold for the migration fix..."
value={requiredChanges}
onChange={(e) => setRequiredChanges(e.target.value)}
rows={3}
/>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={closeDialog} disabled={pending}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={pending}
variant={action === "reject" ? "destructive" : "default"}
className={
action === "approve" ? "bg-green-600 hover:bg-green-700" : ""
}
>
{pending
? "Processing..."
: action === "approve"
? "Approve & publish"
: "Send back for revision"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -34,6 +34,14 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
transcript_prune_enabled: "Run the background sweep that prunes old transcripts.",
gateway_health_enabled:
"Recover an agent whose MCP gateway has broken (it can run no tools) while its container stays up — kill + respawn it instead of shielding it from the reaper forever.",
ci_watch_enabled:
"Watch every opted-in project's CI and open a fix task when its default branch goes red (per-project opt-in; never auto-merges).",
dep_update_enabled:
"Periodically probe opted-in projects for dependency updates and open an update task when a lockfile would change (per-project opt-in; never auto-merges).",
release_manager_enabled:
"Run the deterministic release-readiness sweep and propose a release for you to approve or reject — it never publishes without your approval, and the executor is fail-closed on a red gate.",
org_memory_enabled:
"Close the learn→reuse loop: distill a lesson at task completion, index journal reflections, and auto-inject similar past lessons + approved playbooks into an agent's briefing on claim.",
};
export function FeatureFlagsCard() {
+9
View File
@@ -15,3 +15,12 @@ export { streamApi } from "./stream";
export { groupsApi } from "./groups";
export { settingsApi } from "./settings";
export { companyGoalsApi } from "./company-goals";
export { releaseApi } from "./release";
export type {
ReleaseProposal,
ReleaseReport,
ReleaseGap,
ReleaseExecuteResult,
} from "./release";
export { playbooksApi } from "./playbooks";
export type { Playbook } from "./playbooks";
+37
View File
@@ -0,0 +1,37 @@
import api from "./client";
// ---------------------------------------------------------------------------
// Playbooks — curated, reusable procedures. Delivery agents draft them; the
// Auditor (or CEO, via this panel) approves → indexed + auto-suggested, or
// rejects → archived.
// ---------------------------------------------------------------------------
export interface Playbook {
id: string;
title: string;
slug: string;
problem: string;
procedure: string;
tags: string[];
team?: string | null;
scope: string;
status: string;
created_at?: string | null;
}
export const playbooksApi = {
listDrafts: async (): Promise<Playbook[]> => {
const { data } = await api.get<Playbook[]>("/playbooks", {
params: { status: "draft" },
});
return data;
},
approve: async (id: string): Promise<Playbook> => {
const { data } = await api.post<Playbook>(`/playbooks/${id}/approve`);
return data;
},
reject: async (id: string, reason: string): Promise<Playbook> => {
const { data } = await api.post<Playbook>(`/playbooks/${id}/reject`, { reason });
return data;
},
};
+68
View File
@@ -0,0 +1,68 @@
import axios from "axios";
import api from "./client";
// ---------------------------------------------------------------------------
// Release manager — the CEO approves or rejects a held release proposal that
// the release-manager engine prepared (deterministic readiness sweep). Nothing
// publishes until the CEO approves; the executor is fail-closed on a red gate.
// ---------------------------------------------------------------------------
export interface ReleaseGap {
category: string;
detail: string;
}
export interface ReleaseReport {
proposed_version: string;
bump_kind: string;
change_summary: string[];
drafted_changelog: string;
version_bump_plan: string[];
gaps: ReleaseGap[];
migration_notes: string[];
gate_state: string;
}
export interface ReleaseProposal {
task_id: string;
title: string;
status: string;
required_changes?: string | null;
report: ReleaseReport;
}
export interface ReleaseExecuteResult {
status: string;
version: string;
files_changed: string[];
commit_sha?: string | null;
release_url?: string | null;
detail: string;
}
export const releaseApi = {
// 404 means "no open proposal" — a normal empty state, returned as null.
getProposal: async (): Promise<ReleaseProposal | null> => {
try {
const { data } = await api.get<ReleaseProposal>("/release/proposal");
return data;
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
return null;
}
throw err;
}
},
approve: async (): Promise<ReleaseExecuteResult> => {
const { data } = await api.post<ReleaseExecuteResult>(
"/release/proposal/approve",
);
return data;
},
reject: async (requiredChanges: string): Promise<ReleaseProposal> => {
const { data } = await api.post<ReleaseProposal>("/release/proposal/reject", {
required_changes: requiredChanges,
});
return data;
},
};