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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/design-docs/skill-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,3 +362,43 @@ are prompt-heavy and should expect iteration after real transcripts.
curation snapshots + (optional) user git on the skills dir cover it, same
posture as the prior doc.
- **No cross-agent skill sharing** yet; workspace scoping stands.

## Shipped status (2026-08-11)

### Shipped

**Phase 1 — foundations** (PR #621): serde_yaml frontmatter, `skill_usage` table,
`WriteOrigin`, API reload fix, watcher fixes, `skills_search` status-format fix,
`read_skill` cap, branch skill index, `skills_search`/`install_skill` moved to
channel toolset.

**Phase 2 — mutation** (PR #622): `skill_manage` tool with full validation and
origin-scoped rails, archive semantics, deterministic `reload_skills` on every
mutation, `skills_list` tool.

**Phase 3 — the pump** (PR #624, #633): Reflection riding the memory-persistence
branch — turn-work and worker-completion triggers, restricted tool server
(`read_skill`, `skill_manage`, `skills_list` + memory-save, no shell/no
messaging/no spawn), reflection prompt section with decide-first and
negative-capture bans, cooldown state, reflection signal with worker-ID
tracking, worker transcript feeding for reflection passes.

**Reflection run record** (this PR): Durable `reflection_runs` table with
agent/channel identity, trigger source, referenced worker IDs, start/end
timestamps, terminal status (`success`/`no_op`/`error`/`cancelled`), declared
rationale (separate from observed actions), outcome summary, affected skill
identifiers, and token usage slot. Fire-and-forget persistence via
`ReflectionRunLogger` matching the existing `ProcessRunLogger` pattern.
`ReflectionRunCompleted` event on the shared `ProcessEvent` bus, piped through
the existing `ApiEvent` SSE pipeline. Minimal timeline surface in the portal UI
rendering reflection outcomes inline (same pattern as chronicle checkpoints).

### Deferred to Phase 4 (curation) and Phase 5 (surfaces)

- Deterministic stale/archive pass in cortex maintenance
- LLM consolidation pass (default-off)
- Snapshots + rollback
- `POST /agents/skills/write` + `CreateSkill.tsx`
- Full `SkillInspector` usage row (provenance, state, counts, pin toggle)
- `write_approval` staging mode
- CLI `pin`/`adopt`/`archive`/`restore` commands
22 changes: 22 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,17 @@ export interface BranchCompletedEvent {
conclusion: string;
}

export interface ReflectionRunCompletedEvent {
type: "reflection_run_completed";
agent_id: string;
channel_id: string;
branch_id: string;
status: string;
outcome_summary: string;
trigger_source: string;
affected_skills: string;
}

export interface ToolStartedEvent {
type: "tool_started";
agent_id: string;
Expand Down Expand Up @@ -296,6 +307,7 @@ export type ApiEvent =
| WorkerCompletedEvent
| BranchStartedEvent
| BranchCompletedEvent
| ReflectionRunCompletedEvent
| ChronicleCheckpointEvent
| ToolStartedEvent
| ToolCompletedEvent
Expand Down Expand Up @@ -359,6 +371,16 @@ export interface TimelineCheckpoint {
created_at: string;
}

export interface TimelineReflectionRun {
type: "reflection_run";
id: string;
status: string;
outcome_summary: string;
trigger_source: string;
affected_skills: string;
started_at: string;
}

// Note: TimelineItem is re-exported from types.ts as a union type

async function fetchJson<T>(path: string): Promise<T> {
Expand Down
31 changes: 30 additions & 1 deletion interface/src/components/portal/PortalTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {useEffect, useRef, useState} from "react";
import {useQuery} from "@tanstack/react-query";
import {InlineBranchCard, MessageBubble} from "@spacedrive/ai";
import {File as FileIcon} from "@phosphor-icons/react";
import {api, type AttachmentMeta, type TimelineBranchRun, type TimelineCheckpoint, type TimelineItem, type WorkerListItem} from "@/api/client";
import {api, type AttachmentMeta, type TimelineBranchRun, type TimelineCheckpoint, type TimelineItem, type TimelineReflectionRun, type WorkerListItem} from "@/api/client";
import {Markdown} from "@/components/Markdown";
import {ToolCall, type ToolCallPair, tryParseJson, isErrorResult} from "@/components/ToolCall";
import {PortalWorkerCard} from "./PortalWorkerCard";
Expand All @@ -13,6 +13,32 @@ import clsx from "clsx";
* the span it covers and opens to the summary. Not a message — it was written
* by neither side of the conversation.
*/
function InlineReflectionRunCard({item}: {item: TimelineReflectionRun}) {
const statusLabel =
item.status === "success" ? "Learned" :
item.status === "no_op" ? "No change" :
item.status === "error" ? "Error" :
"Reflection";
const statusColor =
item.status === "success" ? "text-green-11" :
item.status === "no_op" ? "text-ink-faint" :
item.status === "error" ? "text-red-11" :
"text-ink-dull";

return (
<div className="py-2">
<div className="flex w-full items-center gap-3">
<span className="h-px flex-1 bg-app-line/60" />
<span className={`flex-shrink-0 text-tiny ${statusColor}`}>
{statusLabel}
{item.outcome_summary ? `: ${item.outcome_summary}` : ""}
</span>
<span className="h-px flex-1 bg-app-line/60" />
</div>
</div>
);
}

function InlineCheckpointCard({item}: {item: TimelineCheckpoint}) {
const [expanded, setExpanded] = useState(false);
const from = new Date(item.covers_from);
Expand Down Expand Up @@ -360,6 +386,9 @@ export function PortalTimeline({
</div>
);
}
if ((item as Record<string, unknown>).type === "reflection_run") {
return <InlineReflectionRunCard key={item.id} item={item as unknown as TimelineReflectionRun} />;
}
if (item.type === "checkpoint") {
return <InlineCheckpointCard key={item.id} item={item} />;
}
Expand Down
17 changes: 17 additions & 0 deletions interface/src/hooks/useChannelLiveState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type BranchCompletedEvent,
type BranchStartedEvent,
type ChronicleCheckpointEvent,
type ReflectionRunCompletedEvent,
type InboundMessageEvent,
type OutboundMessageDeltaEvent,
type OutboundMessageEvent,
Expand Down Expand Up @@ -554,6 +555,21 @@ export function useChannelLiveState(channels: ChannelInfo[]) {
// A checkpoint carries its whole record, so it lands in the timeline without
// a refetch. Duplicate ids are ignored — the same checkpoint can arrive over
// SSE and again in a history page loaded around the same moment.
const handleReflectionRunCompleted = useCallback((data: unknown) => {
const event = data as ReflectionRunCompletedEvent;
// reflection_run is a client-only timeline item not in the OpenAPI
// TimelineItem union; use a cast matching the existing branch_run pattern.
pushItem(event.channel_id, {
type: "reflection_run",
id: event.branch_id,
status: event.status,
outcome_summary: event.outcome_summary,
trigger_source: event.trigger_source,
affected_skills: event.affected_skills,
started_at: new Date().toISOString(),
} as unknown as Parameters<typeof pushItem>[1]);
}, [pushItem]);

const handleChronicleCheckpoint = useCallback((data: unknown) => {
const event = data as ChronicleCheckpointEvent;
setLiveStates((prev) => {
Expand Down Expand Up @@ -908,6 +924,7 @@ export function useChannelLiveState(channels: ChannelInfo[]) {
worker_completed: handleWorkerCompleted,
branch_started: handleBranchStarted,
chronicle_checkpoint: handleChronicleCheckpoint,
reflection_run_completed: handleReflectionRunCompleted,
branch_completed: handleBranchCompleted,
tool_started: handleToolStarted,
tool_completed: handleToolCompleted,
Expand Down
44 changes: 44 additions & 0 deletions migrations/20260811000001_reflection_runs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-- Durable reflection-run record for the skill-reflection loop.
--
-- Every reflection pass (riding the memory persistence branch) writes one
-- row: agent/channel identity, trigger provenance, referenced workers,
-- start/end timestamps, terminal status, concise user-legible outcome
-- summary, affected skill identifiers/actions, error/no-op reason, and
-- token usage where available.
--
-- The record distinguishes declared rationale (the branch's own summary)
-- from authoritative runtime outcomes (actions actually observed).
-- Chain-of-thought is never stored.
CREATE TABLE reflection_runs (
id TEXT PRIMARY KEY, -- UUID (branch_id of the persistence branch)
agent_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
-- Which trigger fired: 'turn_work' | 'worker_success' | 'reflection'
trigger_source TEXT NOT NULL,
-- Workers referenced by this reflection pass (JSON array of {id, success})
referenced_workers TEXT,
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
-- Terminal status: 'success' | 'no_op' | 'error' | 'cancelled'
status TEXT NOT NULL DEFAULT 'running',
-- Declared by the reflection branch itself (its own summary of what
-- it did or decided). Distinct from `observed_actions`.
declared_rationale TEXT,
-- Authoritative: what skill mutations actually happened, observed
-- from tool-call results. JSON array of {action, skill_name, detail?}.
-- Empty array for no-op runs. Null until the pass completes.
observed_actions TEXT,
-- User-legible one-line summary (rendered in the UI timeline).
-- Derived from observed_actions + declared_rationale.
outcome_summary TEXT,
-- Human-readable reason when status is 'no_op' or 'error'.
terminal_reason TEXT,
-- Token usage for the reflection branch (JSON: {input, output, cache_read, reasoning}).
token_usage TEXT,
-- Created skills, patched skills (comma-separated lowercase canonical names).
-- Populated from observed_actions for easy querying.
affected_skills TEXT
);

CREATE INDEX idx_reflection_runs_channel ON reflection_runs(channel_id, started_at);
CREATE INDEX idx_reflection_runs_agent ON reflection_runs(agent_id, started_at);
Loading
Loading