diff --git a/dashboard/README.md b/dashboard/README.md index f3cf963..48724a8 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -29,7 +29,20 @@ npm install npm run dev ``` -Open [http://localhost:5173](http://localhost:5173) to view the dashboard. +Open [http://localhost:5173](http://localhost:5173) to view the engineer dashboard. + +**Team Brain admin review inbox** (local only — [#69](https://github.com/Hrithik-Gavankar/brainstack/issues/69)): + +```bash +# Optional: pre-fill Supabase project (same vars as CLI / supabase/project.public.env) +export VITE_TEAM_BRAIN_SUPABASE_URL=https://xxxx.supabase.co +export VITE_TEAM_BRAIN_SUPABASE_ANON_KEY=eyJ... + +npm run dev +# → http://localhost:5173/admin/review +``` + +Connect with a crew **admin** `tb_…` API key (session storage only). Approve/reject calls the same RPCs as `pending list|approve|reject` in `core/scripts/team-brain-api.sh`. Never deploy real keys to GitHub Pages. ```bash npm run build # production build → dist/ @@ -81,6 +94,39 @@ npm run preview Public demo tip (Vercel): set **Root Directory** to `dashboard`, build command `npm run build`, output `dist`. +## Team Brain admin review queue + +Admin-only inbox for pending memory overrides (#67 / #69). Workshop-friendly alternative to `pending list` JSON in the terminal. + +| View | Source | +|------|--------| +| Pending queue | `list_pending_memories` RPC (`status = pending`, optional `p_jira_key`) | +| Diff panel | Proposed `body_preview` vs live capture via `list_recent` + `target_capture_id` | +| Metadata | `conflict_reason`, author, `match_metadata.matches`, timestamps | +| Actions | `approve_pending_memory` · `reject_pending_memory` (admin role required) | + +**Auth:** Same trust model as CLI — `tb_…` api_key in `sessionStorage`, Supabase anon key for REST RPC calls. No anon `SELECT` on `memory_pending_submissions`; RPC-only. + +**Privacy:** Team-scoped like `list_members`. Never surfaces personal `BRAIN.md`. + +**Route:** `/admin/review` (pathname switch in `Root.tsx`; no extra router dependency). + +``` +dashboard/src/ +├── admin/ # Team Brain review inbox (#69) +│ ├── AdminReviewApp.tsx +│ ├── AdminCredentialsForm.tsx +│ ├── PendingSubmissionCard.tsx +│ └── MemoryDiffPanel.tsx +├── team-brain/ +│ ├── rpc.ts # Supabase REST RPC client +│ ├── session.ts # sessionStorage credentials +│ └── types.ts +├── Root.tsx # /admin/review vs engineer dashboard +``` + +See [docs/team-brain-memory.md §7c](../docs/team-brain-memory.md#7c-redundant-memory-feedback--admin-approval-queue-67). + ## Architecture ``` @@ -88,14 +134,17 @@ dashboard/ ├── public/ │ └── favicon.svg ├── src/ -│ ├── components/ # React presentation components +│ ├── admin/ # Team Brain review inbox (local admin only) +│ ├── team-brain/ # RPC client + types +│ ├── components/ # Engineer dashboard presentation │ ├── data/ │ │ ├── loadDashboardData.ts # Stable data port │ │ ├── sampleData.ts # Demo adapter payload │ │ └── brainAdapter.ts # Stub for BRAIN.md parser │ ├── colors.ts # Chart colors (UI layer) │ ├── types.ts # DashboardData + brain-spec types -│ ├── App.tsx +│ ├── App.tsx # Engineer dashboard (sample data) +│ ├── Root.tsx # Route: /admin/review → AdminReviewApp │ ├── main.tsx │ └── index.css ├── package.json diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 374b8f1..df9a775 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -1,12 +1,12 @@ { - "name": "dashboard", - "version": "0.0.0", + "name": "engineer-brain-dashboard", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "dashboard", - "version": "0.0.0", + "name": "engineer-brain-dashboard", + "version": "0.1.0", "dependencies": { "lucide-react": "^1.25.0", "react": "^19.2.7", diff --git a/dashboard/src/Root.tsx b/dashboard/src/Root.tsx new file mode 100644 index 0000000..e50028b --- /dev/null +++ b/dashboard/src/Root.tsx @@ -0,0 +1,18 @@ +import App from "./App"; +import { AdminReviewApp } from "./admin/AdminReviewApp"; + +const isAdminReviewRoute = (): boolean => { + const base = import.meta.env.BASE_URL.replace(/\/$/, ""); + const path = window.location.pathname.replace(/\/$/, ""); + const suffix = base ? `${base}/admin/review` : "/admin/review"; + return path === suffix || path.endsWith("/admin/review"); +}; + +export const Root = () => { + if (isAdminReviewRoute()) { + return ; + } + return ; +}; + +export default Root; diff --git a/dashboard/src/admin/AdminCredentialsForm.tsx b/dashboard/src/admin/AdminCredentialsForm.tsx new file mode 100644 index 0000000..e33a759 --- /dev/null +++ b/dashboard/src/admin/AdminCredentialsForm.tsx @@ -0,0 +1,137 @@ +import { useCallback, useState } from "react"; +import { KeyRound, ShieldCheck } from "lucide-react"; +import type { TeamBrainSession } from "../team-brain/types"; +import { + hasSupabaseDefaults, + readDefaultAnonKey, + readDefaultSupabaseUrl, +} from "../team-brain/session"; + +interface AdminCredentialsFormProps { + onConnect: (session: TeamBrainSession) => void; +} + +export const AdminCredentialsForm = ({ onConnect }: AdminCredentialsFormProps) => { + const defaultsAvailable = hasSupabaseDefaults(); + const [apiKey, setApiKey] = useState(""); + const [supabaseUrl, setSupabaseUrl] = useState(readDefaultSupabaseUrl()); + const [anonKey, setAnonKey] = useState(readDefaultAnonKey()); + const [error, setError] = useState(null); + + const handleSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + setError(null); + + const trimmedApiKey = apiKey.trim(); + const trimmedUrl = supabaseUrl.trim(); + const trimmedAnon = anonKey.trim(); + + if (!trimmedApiKey.startsWith("tb_")) { + setError("API key must start with tb_ (crew member key from register/join)."); + return; + } + if (!trimmedUrl || !trimmedAnon) { + setError("Supabase URL and anon key are required."); + return; + } + + onConnect({ + apiKey: trimmedApiKey, + supabaseUrl: trimmedUrl, + anonKey: trimmedAnon, + }); + }, + [anonKey, apiKey, onConnect, supabaseUrl], + ); + + return ( +
+
+
+
+
+
+

Team Brain — Review queue

+

Admin-only inbox for pending memory overrides

+
+
+ +

+ Uses the same RPC trust model as the CLI: your crew tb_… key + in session storage only. Never deploy real keys to GitHub Pages. +

+ +
+ + + {!defaultsAvailable && ( + <> + + + + + )} + + {defaultsAvailable && ( +

+ Supabase URL/anon loaded from VITE_TEAM_BRAIN_* env vars. +

+ )} + + {error && ( +

+ {error} +

+ )} + + +
+
+
+ ); +}; diff --git a/dashboard/src/admin/AdminReviewApp.tsx b/dashboard/src/admin/AdminReviewApp.tsx new file mode 100644 index 0000000..b87cd3c --- /dev/null +++ b/dashboard/src/admin/AdminReviewApp.tsx @@ -0,0 +1,293 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Inbox, LogOut, RefreshCw, ShieldAlert } from "lucide-react"; +import { AdminCredentialsForm } from "./AdminCredentialsForm"; +import { PendingSubmissionCard } from "./PendingSubmissionCard"; +import { + approvePendingMemory, + listInitiatives, + listPendingMemories, + rejectPendingMemory, + whoAmI, +} from "../team-brain/rpc"; +import { + clearTeamBrainSession, + loadTeamBrainSession, + saveTeamBrainSession, +} from "../team-brain/session"; +import type { + InitiativeRow, + PendingStatus, + PendingSubmission, + TeamBrainSession, + WhoAmIResponse, +} from "../team-brain/types"; + +export const AdminReviewApp = () => { + const [session, setSession] = useState(() => loadTeamBrainSession()); + const [viewer, setViewer] = useState(null); + const [initiatives, setInitiatives] = useState([]); + const [submissions, setSubmissions] = useState([]); + const [jiraKey, setJiraKey] = useState(""); + const [statusFilter, setStatusFilter] = useState("pending"); + const [loading, setLoading] = useState(false); + const [actingId, setActingId] = useState(null); + const [error, setError] = useState(null); + const [actionMessage, setActionMessage] = useState(null); + + const isAdmin = viewer?.role === "admin"; + const refreshGenerationRef = useRef(0); + + const refreshQueue = useCallback(async (activeSession: TeamBrainSession) => { + const generation = ++refreshGenerationRef.current; + setLoading(true); + setError(null); + try { + const [who, initiativeRows, pendingPayload] = await Promise.all([ + whoAmI(activeSession), + listInitiatives(activeSession), + listPendingMemories( + activeSession, + jiraKey.trim() ? jiraKey.trim().toUpperCase() : null, + statusFilter, + ), + ]); + if (generation !== refreshGenerationRef.current) { + return; + } + setViewer(who); + setInitiatives(initiativeRows); + setSubmissions(pendingPayload.pending ?? []); + } catch (err) { + if (generation !== refreshGenerationRef.current) { + return; + } + setError(err instanceof Error ? err.message : "Failed to load review queue"); + } finally { + if (generation === refreshGenerationRef.current) { + setLoading(false); + } + } + }, [jiraKey, statusFilter]); + + useEffect(() => { + if (!session) { + return; + } + void refreshQueue(session); + }, [session, refreshQueue]); + + const handleConnect = useCallback((nextSession: TeamBrainSession) => { + saveTeamBrainSession(nextSession); + setSession(nextSession); + setViewer(null); + setError(null); + setActionMessage(null); + }, []); + + const handleDisconnect = useCallback(() => { + refreshGenerationRef.current += 1; + clearTeamBrainSession(); + setSession(null); + setViewer(null); + setSubmissions([]); + setError(null); + setActionMessage(null); + }, []); + + const handleRefresh = useCallback(() => { + if (!session) { + return; + } + void refreshQueue(session); + }, [refreshQueue, session]); + + const handleApprove = useCallback( + async (pendingId: string, note: string) => { + if (!session) { + return; + } + setActingId(pendingId); + setError(null); + setActionMessage(null); + try { + const result = await approvePendingMemory(session, pendingId, note || null); + setActionMessage(`Approved — live memory updated (${result.jira_key}).`); + await refreshQueue(session); + } catch (err) { + setError(err instanceof Error ? err.message : "Approve failed"); + } finally { + setActingId(null); + } + }, + [refreshQueue, session], + ); + + const handleReject = useCallback( + async (pendingId: string, note: string) => { + if (!session) { + return; + } + setActingId(pendingId); + setError(null); + setActionMessage(null); + try { + await rejectPendingMemory(session, pendingId, note || null); + setActionMessage("Rejected — existing live memory unchanged."); + await refreshQueue(session); + } catch (err) { + setError(err instanceof Error ? err.message : "Reject failed"); + } finally { + setActingId(null); + } + }, + [refreshQueue, session], + ); + + const pendingCount = useMemo( + () => submissions.filter((row) => row.status === "pending").length, + [submissions], + ); + + if (!session) { + return ; + } + + return ( +
+
+
+
+
+
+

Team Brain — Review queue

+

+ {viewer?.team_name ?? "Connecting…"} + {viewer ? ` · ${viewer.display_name} (${viewer.role})` : ""} +

+
+
+ +
+ + +
+
+ +
+ {!isAdmin && viewer && ( +
+
+ )} + +
+ + + + +

+ {statusFilter === "pending" ? `Pending (${pendingCount})` : `${submissions.length} row(s)`} +

+
+ + {actionMessage && ( +

+ {actionMessage} +

+ )} + + {error && ( +

+ {error} +

+ )} + + {loading && submissions.length === 0 && ( +

Loading review queue…

+ )} + + {!loading && submissions.length === 0 && ( +
+

No submissions in this view

+

+ Members queue overrides with remember … --queue. +

+
+ )} + +
+ {submissions.map((submission) => ( + + ))} +
+
+ +
+

+ Team Brain admin review · RPC-only (no direct table access) · local session credentials +

+
+
+ ); +}; diff --git a/dashboard/src/admin/MemoryDiffPanel.tsx b/dashboard/src/admin/MemoryDiffPanel.tsx new file mode 100644 index 0000000..fc11508 --- /dev/null +++ b/dashboard/src/admin/MemoryDiffPanel.tsx @@ -0,0 +1,54 @@ +import type { LiveMemory } from "../team-brain/types"; + +interface MemoryDiffPanelProps { + proposedBody: string; + proposedSourceRef: string | null; + liveMemory: LiveMemory | null; + loadingLive: boolean; +} + +export const MemoryDiffPanel = ({ + proposedBody, + proposedSourceRef, + liveMemory, + loadingLive, +}: MemoryDiffPanelProps) => { + return ( +
+
+

Proposed

+ {proposedSourceRef && ( +

{proposedSourceRef}

+ )} +

{proposedBody}

+
+ +
+

Live memory

+ {loadingLive && ( +

Loading live capture…

+ )} + {!loadingLive && !liveMemory && ( +

No live capture linked (target may have been deleted).

+ )} + {!loadingLive && liveMemory && ( + <> + {liveMemory.source_ref && ( +

{liveMemory.source_ref}

+ )} +

+ {liveMemory.author_name} · {liveMemory.kind} +

+

{liveMemory.body}

+ + )} +
+
+ ); +}; diff --git a/dashboard/src/admin/PendingSubmissionCard.tsx b/dashboard/src/admin/PendingSubmissionCard.tsx new file mode 100644 index 0000000..15b4938 --- /dev/null +++ b/dashboard/src/admin/PendingSubmissionCard.tsx @@ -0,0 +1,200 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Clock, X } from "lucide-react"; +import type { LiveMemory, PendingSubmission, TeamBrainSession } from "../team-brain/types"; +import { fetchLiveMemoryById } from "../team-brain/rpc"; +import { MemoryDiffPanel } from "./MemoryDiffPanel"; + +interface PendingSubmissionCardProps { + submission: PendingSubmission; + session: TeamBrainSession; + isAdmin: boolean; + acting: boolean; + onApprove: (id: string, note: string) => void; + onReject: (id: string, note: string) => void; +} + +const formatRelativeTime = (iso: string): string => { + const deltaMs = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(deltaMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 48) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +}; + +export const PendingSubmissionCard = ({ + submission, + session, + isAdmin, + acting, + onApprove, + onReject, +}: PendingSubmissionCardProps) => { + const [note, setNote] = useState(""); + const [liveMemory, setLiveMemory] = useState(null); + const [loadingLive, setLoadingLive] = useState(false); + + const matches = useMemo( + () => submission.match_metadata?.matches ?? [], + [submission.match_metadata], + ); + + useEffect(() => { + if (!submission.target_capture_id) { + setLiveMemory(null); + return; + } + + let cancelled = false; + const loadLive = async () => { + setLoadingLive(true); + try { + const live = await fetchLiveMemoryById( + session, + submission.jira_key, + submission.target_capture_id!, + ); + if (!cancelled) { + setLiveMemory(live); + } + } catch { + if (!cancelled) { + setLiveMemory(null); + } + } finally { + if (!cancelled) { + setLoadingLive(false); + } + } + }; + + void loadLive(); + return () => { + cancelled = true; + }; + }, [session, submission.jira_key, submission.target_capture_id]); + + const isPending = submission.status === "pending"; + + return ( +
+
+
+
+ + {submission.conflict_reason} + + {submission.author_name} + + +
+

+ {submission.jira_key} · {submission.kind} + {submission.source_ref ? ` · ${submission.source_ref}` : ""} +

+
+ {!isPending && ( + + {submission.status} + {submission.reviewed_by ? ` by ${submission.reviewed_by}` : ""} + + )} +
+ + {matches.length > 0 && ( +
+

Matches

+
    + {matches.map((match) => ( +
  • + + {match.source_ref ?? match.capture_id ?? "match"} + + {match.author_name && ( + — {match.author_name} + )} + {match.body_preview && ( +

    {match.body_preview}

    + )} +
  • + ))} +
+
+ )} + + {submission.target_capture_id ? ( + + ) : ( +
+

Proposed

+

+ {submission.body_preview} +

+
+ )} + + {submission.review_note && ( +

+ Review note: {submission.review_note} +

+ )} + + {isPending && isAdmin && ( +
+ +
+ + +
+
+ )} + + {isPending && !isAdmin && ( +

Awaiting admin approval.

+ )} +
+ ); +}; diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index bef5202..53541ac 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -1,10 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' -import App from './App.tsx' +import Root from './Root.tsx' createRoot(document.getElementById('root')!).render( - + , ) diff --git a/dashboard/src/team-brain/rpc.ts b/dashboard/src/team-brain/rpc.ts new file mode 100644 index 0000000..50ace3c --- /dev/null +++ b/dashboard/src/team-brain/rpc.ts @@ -0,0 +1,104 @@ +import type { + ApprovePendingResponse, + InitiativeRow, + ListPendingResponse, + LiveMemory, + PendingStatus, + RejectPendingResponse, + TeamBrainSession, + WhoAmIResponse, +} from "./types"; + +const parseRpcError = (raw: string): string => { + try { + const parsed = JSON.parse(raw) as { message?: string; error?: string }; + return parsed.message ?? parsed.error ?? raw; + } catch { + return raw || "RPC request failed"; + } +}; + +export const callTeamBrainRpc = async ( + fn: string, + body: Record, + session: TeamBrainSession, +): Promise => { + const url = `${session.supabaseUrl.replace(/\/$/, "")}/rest/v1/rpc/${fn}`; + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + apikey: session.anonKey, + Authorization: `Bearer ${session.anonKey}`, + }, + body: JSON.stringify(body), + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(parseRpcError(text)); + } + + if (!text) { + return {} as T; + } + + return JSON.parse(text) as T; +}; + +export const whoAmI = (session: TeamBrainSession): Promise => + callTeamBrainRpc("tb_whoami", { p_api_key: session.apiKey }, session); + +export const listInitiatives = (session: TeamBrainSession): Promise => + callTeamBrainRpc("list_initiatives", { p_api_key: session.apiKey }, session); + +export const listPendingMemories = ( + session: TeamBrainSession, + jiraKey: string | null, + status: PendingStatus, +): Promise => + callTeamBrainRpc("list_pending_memories", { + p_api_key: session.apiKey, + p_jira_key: jiraKey, + p_status: status, + }, session); + +export const approvePendingMemory = ( + session: TeamBrainSession, + pendingId: string, + note: string | null, +): Promise => + callTeamBrainRpc("approve_pending_memory", { + p_api_key: session.apiKey, + p_pending_id: pendingId, + p_note: note, + }, session); + +export const rejectPendingMemory = ( + session: TeamBrainSession, + pendingId: string, + note: string | null, +): Promise => + callTeamBrainRpc("reject_pending_memory", { + p_api_key: session.apiKey, + p_pending_id: pendingId, + p_note: note, + }, session); + +export const fetchLiveMemoryById = async ( + session: TeamBrainSession, + jiraKey: string, + captureId: string, +): Promise => { + const payload = await callTeamBrainRpc<{ + memories?: LiveMemory[]; + captures?: LiveMemory[]; + }>("list_recent", { + p_api_key: session.apiKey, + p_jira_key: jiraKey, + p_limit: 200, + }, session); + + const rows = payload.memories ?? payload.captures ?? []; + return rows.find((row) => row.id === captureId) ?? null; +}; diff --git a/dashboard/src/team-brain/session.ts b/dashboard/src/team-brain/session.ts new file mode 100644 index 0000000..66948cb --- /dev/null +++ b/dashboard/src/team-brain/session.ts @@ -0,0 +1,45 @@ +import type { TeamBrainSession } from "./types"; + +const STORAGE_KEYS = { + apiKey: "teamBrain.apiKey", + supabaseUrl: "teamBrain.supabaseUrl", + anonKey: "teamBrain.anonKey", +} as const; + +const readEnv = (key: string): string => import.meta.env[key]?.trim() ?? ""; + +export const readDefaultSupabaseUrl = (): string => + readEnv("VITE_TEAM_BRAIN_SUPABASE_URL"); + +export const readDefaultAnonKey = (): string => + readEnv("VITE_TEAM_BRAIN_SUPABASE_ANON_KEY"); + +export const loadTeamBrainSession = (): TeamBrainSession | null => { + const apiKey = sessionStorage.getItem(STORAGE_KEYS.apiKey)?.trim() ?? ""; + const supabaseUrl = + sessionStorage.getItem(STORAGE_KEYS.supabaseUrl)?.trim() || + readDefaultSupabaseUrl(); + const anonKey = + sessionStorage.getItem(STORAGE_KEYS.anonKey)?.trim() || readDefaultAnonKey(); + + if (!apiKey || !supabaseUrl || !anonKey) { + return null; + } + + return { apiKey, supabaseUrl, anonKey }; +}; + +export const saveTeamBrainSession = (session: TeamBrainSession): void => { + sessionStorage.setItem(STORAGE_KEYS.apiKey, session.apiKey.trim()); + sessionStorage.setItem(STORAGE_KEYS.supabaseUrl, session.supabaseUrl.trim()); + sessionStorage.setItem(STORAGE_KEYS.anonKey, session.anonKey.trim()); +}; + +export const clearTeamBrainSession = (): void => { + sessionStorage.removeItem(STORAGE_KEYS.apiKey); + sessionStorage.removeItem(STORAGE_KEYS.supabaseUrl); + sessionStorage.removeItem(STORAGE_KEYS.anonKey); +}; + +export const hasSupabaseDefaults = (): boolean => + Boolean(readDefaultSupabaseUrl() && readDefaultAnonKey()); diff --git a/dashboard/src/team-brain/types.ts b/dashboard/src/team-brain/types.ts new file mode 100644 index 0000000..67f2de5 --- /dev/null +++ b/dashboard/src/team-brain/types.ts @@ -0,0 +1,100 @@ +export type TeamBrainRole = "admin" | "member" | "viewer"; + +export type PendingStatus = "pending" | "approved" | "rejected" | "all"; + +export type ConflictReason = "semantic_duplicate" | "source_ref_override"; + +export type MemoryKind = "research" | "decision" | "note" | "learning"; + +export interface TeamBrainSession { + supabaseUrl: string; + anonKey: string; + apiKey: string; +} + +export interface WhoAmIResponse { + member_id: string; + display_name: string; + role: TeamBrainRole; + team_id: string; + team_name: string; + invite_code?: string; +} + +export interface InitiativeRow { + id: string; + jira_key: string; + title: string; + status: string; + jira_url?: string | null; + updated_at?: string; +} + +export interface MatchMetadataEntry { + capture_id?: string; + source_ref?: string | null; + kind?: MemoryKind; + body_preview?: string; + author_name?: string; + match_type?: string; + similarity?: number; + distance?: number | null; + rank?: number | null; +} + +export interface PendingMatchMetadata { + matches?: MatchMetadataEntry[]; +} + +export interface PendingSubmission { + id: string; + status: "pending" | "approved" | "rejected"; + conflict_reason: ConflictReason; + kind: MemoryKind; + body_preview: string; + source_ref: string | null; + target_capture_id: string | null; + match_metadata: PendingMatchMetadata; + review_note: string | null; + created_at: string; + reviewed_at: string | null; + jira_key: string; + author_name: string; + reviewed_by: string | null; +} + +export interface ListPendingResponse { + team_id: string; + viewer_role: TeamBrainRole; + status_filter: PendingStatus; + pending: PendingSubmission[]; +} + +export interface LiveMemory { + id: string; + kind: MemoryKind; + body: string; + source_ref: string | null; + author_name: string; + updated_at?: string; + created_at?: string; +} + +export interface ApprovePendingResponse { + ok: boolean; + approved: boolean; + pending_id: string; + jira_key: string; + capture_id: string; + source_ref: string | null; + archived_revision: number | null; + reviewed_by: string; +} + +export interface RejectPendingResponse { + ok: boolean; + rejected: boolean; + pending_id: string; + reviewed_by: string; + review_note: string | null; +} diff --git a/docs/team-brain-memory.md b/docs/team-brain-memory.md index e0ae1df..af56dac 100644 --- a/docs/team-brain-memory.md +++ b/docs/team-brain-memory.md @@ -328,7 +328,7 @@ bash core/scripts/team-brain-api.sh pending reject --note "Duplicat **Workshop Part 5:** B tries to re-store A's topic → feedback engine blocks → B recalls A's `source_ref` OR queues → admin keeps only the improved version. -**UI dashboard:** [#69](https://github.com/Hrithik-Gavankar/brainstack/issues/69) — admin review inbox (CLI/MCP is v1). +**Admin review dashboard:** [`dashboard/`](../dashboard/README.md#team-brain-admin-review-queue) — local `/admin/review` UI (same RPCs as CLI). Run `npm run dev` in `dashboard/`, open `/admin/review`, connect with admin `tb_…` key. GitHub Pages demo does **not** include live review (credentials stay local). --- @@ -339,7 +339,7 @@ bash core/scripts/team-brain-api.sh pending reject --note "Duplicat | Recall before research | ✅ Rule/skill/MCP | Keep mandatory | | Remember after findings | ✅ Direct save | Keep mandatory | | Dedup (`source_ref`) | ✅ | — | -| Redundant guard + pending queue | ✅ CLI/MCP (#67) | Admin dashboard UI | +| Redundant guard + pending queue | ✅ CLI/MCP (#67) | ✅ Admin dashboard UI (`dashboard/admin/review`) | | Correction / learning | ✅ `correct` + `learning` kind | — | | Version/history/soft rollback | ✅ `history` / `restore` + `capture_revisions` | Optional snapshots / UI | | Tombstone delete / governance | ✅ `delete` + `list-members`; peer cache eviction | Per-initiative ACLs (deferred) |